605 lines
25 KiB
C#
605 lines
25 KiB
C#
using TeleWave.Domain.Broadcast;
|
||
using TeleWave.Domain.Broadcast.Scheduling;
|
||
using Xunit;
|
||
|
||
namespace TeleWave.Domain.Tests.Broadcast;
|
||
|
||
public class SchedulePlannerTests
|
||
{
|
||
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||
|
||
/// <summary>Источник случайности с фиксированной (циклической) последовательностью значений.</summary>
|
||
private sealed class FixedRandom(params int[] values) : IRandomSource
|
||
{
|
||
private int _i;
|
||
|
||
public int Next(int maxExclusive) =>
|
||
values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive;
|
||
}
|
||
|
||
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
|
||
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
|
||
|
||
private static readonly Guid DefaultTemplate = Guid.NewGuid();
|
||
private static readonly Guid DefaultVariant = Guid.NewGuid();
|
||
|
||
/// <summary>Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы.</summary>
|
||
private static PlannerBumperConfig Bumper(
|
||
bool enabled,
|
||
BumperTrigger trigger,
|
||
TimeSpan minInterval,
|
||
BumperSelection selection = BumperSelection.Rotation,
|
||
params PlannerBumperVariant[] variants
|
||
) =>
|
||
new(
|
||
enabled,
|
||
minInterval,
|
||
selection,
|
||
variants.Length == 0
|
||
? [new PlannerBumperVariant(DefaultVariant, DefaultTemplate, TimeSpan.FromSeconds(8), trigger)]
|
||
: variants
|
||
);
|
||
|
||
[Fact]
|
||
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
var show = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||
|
||
var input = new PlannerInput(
|
||
ChannelId: Guid.NewGuid(),
|
||
AdInsertion: AdInsertion.BetweenBlocks,
|
||
AdsPerBreak: 1,
|
||
NextAdIndex: 0,
|
||
Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)],
|
||
AdPool: [],
|
||
Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)),
|
||
Overrides: [],
|
||
StartTime: Start,
|
||
HorizonEnd: Start.AddSeconds(1)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(3, result.Entries.Count);
|
||
Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind));
|
||
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
|
||
Assert.Equal(Start, result.Entries[0].StartsAtUtc);
|
||
Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc);
|
||
Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc);
|
||
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Count_Block_WrapsAtEndOfSeries()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||
|
||
var input = BaseInput(
|
||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)],
|
||
Durations((eps[0], 2), (eps[1], 2)),
|
||
horizonEnd: Start.AddSeconds(1)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex));
|
||
Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Duration_Block_TakesEpisodesUntilBudgetReached()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||
|
||
var input = BaseInput(
|
||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)],
|
||
Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)),
|
||
horizonEnd: Start.AddMinutes(30)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
// 10+10+10 = 30 >= 25 → три серии.
|
||
Assert.Equal(3, result.Entries.Count);
|
||
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
|
||
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
|
||
}
|
||
|
||
[Fact]
|
||
public void AdsBetweenBlocks_InsertsAdAfterBlock()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid ep = Guid.NewGuid(),
|
||
ad0 = Guid.NewGuid(),
|
||
ad1 = Guid.NewGuid();
|
||
|
||
var input = new PlannerInput(
|
||
Guid.NewGuid(),
|
||
AdInsertion.BetweenBlocks,
|
||
AdsPerBreak: 1,
|
||
NextAdIndex: 0,
|
||
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)],
|
||
AdPool: [ad0, ad1],
|
||
Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)),
|
||
Overrides: [],
|
||
StartTime: Start,
|
||
HorizonEnd: Start.AddSeconds(1)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(2, result.Entries.Count);
|
||
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
|
||
Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind);
|
||
Assert.Equal(ad0, result.Entries[1].MediaAssetId);
|
||
Assert.Equal(1, result.NextAdIndex);
|
||
}
|
||
|
||
[Fact]
|
||
public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||
Guid ad = Guid.NewGuid();
|
||
|
||
var input = new PlannerInput(
|
||
Guid.NewGuid(),
|
||
AdInsertion.BetweenEpisodes,
|
||
AdsPerBreak: 1,
|
||
NextAdIndex: 0,
|
||
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)],
|
||
AdPool: [ad],
|
||
Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)),
|
||
Overrides: [],
|
||
StartTime: Start,
|
||
HorizonEnd: Start.AddSeconds(1)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(
|
||
[
|
||
ScheduleEntryKind.Program,
|
||
ScheduleEntryKind.Ad,
|
||
ScheduleEntryKind.Program,
|
||
ScheduleEntryKind.Ad,
|
||
],
|
||
result.Entries.Select(e => e.Kind)
|
||
);
|
||
Assert.Equal(2, result.NextAdIndex);
|
||
}
|
||
|
||
[Fact]
|
||
public void WeightedPick_RespectsRoll()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 3, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations(
|
||
(a.EpisodeAssetIds[0], 20),
|
||
(b.EpisodeAssetIds[0], 20)
|
||
);
|
||
|
||
var pickA = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(0));
|
||
var pickB = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(3));
|
||
|
||
Assert.Equal(a.ShowId, pickA.Entries[0].ShowId);
|
||
Assert.Equal(b.ShowId, pickB.Entries[0].ShowId);
|
||
}
|
||
|
||
[Fact]
|
||
public void ExclusiveOverride_ForcesSingleShow()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 10, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = new PlannerInput(
|
||
Guid.NewGuid(),
|
||
AdInsertion.BetweenBlocks,
|
||
0,
|
||
0,
|
||
[a, b],
|
||
[],
|
||
durations,
|
||
Overrides:
|
||
[
|
||
new PlannerOverride(
|
||
OverrideMode.Exclusive,
|
||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||
StartsAtUtc: Start,
|
||
EndsAtUtc: Start.AddHours(1)
|
||
),
|
||
],
|
||
StartTime: Start,
|
||
HorizonEnd: Start.AddSeconds(1)
|
||
);
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||
};
|
||
|
||
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
// Перед первым блоком заставки нет; далее по одной на каждый переход.
|
||
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
|
||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||
Assert.NotEmpty(bumpers);
|
||
|
||
var first = result.Entries[1];
|
||
Assert.Equal(ScheduleEntryKind.Bumper, first.Kind);
|
||
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
|
||
Assert.Equal(a.ShowId, first.FromShowId);
|
||
Assert.Equal(b.ShowId, first.ToShowId);
|
||
Assert.Equal(DefaultTemplate, first.BumperTemplateId);
|
||
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
|
||
// Встык: программа → заставка → программа.
|
||
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
|
||
Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||
|
||
var input = BaseInput(
|
||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||
Durations((eps[0], 20), (eps[1], 20)),
|
||
horizonEnd: Start.AddMinutes(50)
|
||
) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent()
|
||
{
|
||
var cs = Guid.NewGuid();
|
||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||
|
||
var input = BaseInput(
|
||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||
Durations((eps[0], 20), (eps[1], 20)),
|
||
horizonEnd: Start.AddMinutes(50)
|
||
) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_MinInterval_SuppressesTooFrequentBumpers()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
// Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй.
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.FromMinutes(30)),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper));
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
Guid t0 = Guid.NewGuid(),
|
||
t1 = Guid.NewGuid();
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||
{
|
||
Bumpers = Bumper(
|
||
true,
|
||
BumperTrigger.Both,
|
||
TimeSpan.Zero,
|
||
BumperSelection.Rotation,
|
||
new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both),
|
||
new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(4), BumperTrigger.Both)
|
||
),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||
Assert.True(bumpers.Count >= 2);
|
||
// Ротация: первый подблок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу.
|
||
Assert.Equal(t0, bumpers[0].BumperTemplateId);
|
||
Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc);
|
||
Assert.Equal(t1, bumpers[1].BumperTemplateId);
|
||
Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc);
|
||
Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId));
|
||
Assert.Equal(bumpers.Count, result.NextBumperIndex);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
Guid t0 = Guid.NewGuid(),
|
||
t1 = Guid.NewGuid();
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||
{
|
||
Bumpers = Bumper(
|
||
true,
|
||
BumperTrigger.Both,
|
||
TimeSpan.Zero,
|
||
BumperSelection.AlwaysFirst,
|
||
new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both),
|
||
new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(4), BumperTrigger.Both)
|
||
),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||
Assert.True(bumpers.Count >= 2);
|
||
Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId));
|
||
Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_NoTemplates_NoBumpers()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||
{
|
||
Bumpers = new PlannerBumperConfig(
|
||
true,
|
||
MinInterval: TimeSpan.Zero,
|
||
Selection: BumperSelection.Rotation,
|
||
Variants: []
|
||
),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_Disabled_ProduceNoBumperEntries()
|
||
{
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||
{
|
||
Bumpers = Bumper(false, BumperTrigger.OnShowChange, TimeSpan.Zero),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange()
|
||
{
|
||
// Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано,
|
||
// каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём.
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { ShowChangeChance = 0.0 },
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||
|
||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes()
|
||
{
|
||
// Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки.
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
|
||
{
|
||
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { EpisodeChangeChance = 0.0 },
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant()
|
||
{
|
||
// Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5).
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20));
|
||
Guid t0 = Guid.NewGuid(),
|
||
t1 = Guid.NewGuid();
|
||
|
||
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
|
||
{
|
||
Bumpers = Bumper(
|
||
true,
|
||
BumperTrigger.Both,
|
||
TimeSpan.Zero,
|
||
BumperSelection.WeightedRandom,
|
||
new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both, 0),
|
||
new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(8), BumperTrigger.Both, 5)
|
||
),
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4));
|
||
|
||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||
Assert.NotEmpty(bumpers);
|
||
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
|
||
}
|
||
|
||
[Fact]
|
||
public void WeeklyOverride_AppliesOnMatchingDayAndTime()
|
||
{
|
||
// Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:00–01:00.
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
|
||
{
|
||
Overrides =
|
||
[
|
||
new PlannerOverride(
|
||
OverrideMode.Exclusive,
|
||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||
OverrideRecurrence.Weekly,
|
||
DayOfWeek: (int)Start.UtcDateTime.DayOfWeek,
|
||
StartMinute: 0,
|
||
EndMinute: 60
|
||
),
|
||
],
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
|
||
}
|
||
|
||
[Fact]
|
||
public void WeeklyOverride_IgnoredOnOtherDay()
|
||
{
|
||
// Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a).
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7;
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
|
||
{
|
||
Overrides =
|
||
[
|
||
new PlannerOverride(
|
||
OverrideMode.Exclusive,
|
||
[new PlannerOverrideShow(b.ShowId, 1)],
|
||
OverrideRecurrence.Weekly,
|
||
DayOfWeek: otherDay,
|
||
StartMinute: 0,
|
||
EndMinute: 1440
|
||
),
|
||
],
|
||
};
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Equal(a.ShowId, result.Entries[0].ShowId);
|
||
}
|
||
|
||
[Fact]
|
||
public void PreferredHours_BoostsWeight_InsideWindow()
|
||
{
|
||
// Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны.
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(
|
||
Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0,
|
||
PreferredHours: [new PlannerHourWindow(0, 24)], PreferredWeightMultiplier: 100);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
|
||
|
||
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||
Assert.NotEmpty(programs);
|
||
Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId));
|
||
}
|
||
|
||
[Fact]
|
||
public void PreferredHours_OutsideWindow_KeepsBaseWeight()
|
||
{
|
||
// Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a).
|
||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||
var b = new PlannerShow(
|
||
Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0,
|
||
PreferredHours: [new PlannerHourWindow(10, 12)], PreferredWeightMultiplier: 100);
|
||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||
|
||
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
|
||
|
||
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||
Assert.NotEmpty(programs);
|
||
Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId));
|
||
}
|
||
|
||
[Fact]
|
||
public void NoPlayableShows_ReturnsEmpty()
|
||
{
|
||
var input = BaseInput([], new Dictionary<Guid, TimeSpan>(), Start.AddHours(1));
|
||
|
||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||
|
||
Assert.Empty(result.Entries);
|
||
}
|
||
|
||
private static PlannerInput BaseInput(
|
||
IReadOnlyList<PlannerShow> shows,
|
||
IReadOnlyDictionary<Guid, TimeSpan> durations,
|
||
DateTimeOffset horizonEnd
|
||
) =>
|
||
new(
|
||
ChannelId: Guid.NewGuid(),
|
||
AdInsertion: AdInsertion.BetweenBlocks,
|
||
AdsPerBreak: 0,
|
||
NextAdIndex: 0,
|
||
Shows: shows,
|
||
AdPool: [],
|
||
Durations: durations,
|
||
Overrides: [],
|
||
StartTime: Start,
|
||
HorizonEnd: horizonEnd
|
||
);
|
||
}
|