using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Programming; using TeleWave.Domain.Programming.Planning; using Xunit; namespace TeleWave.Domain.Tests.Programming; public class SchedulePlannerTests { private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero); /// Всегда первый вариант — жребий в тестах не должен добавлять шума. private sealed class FirstAlways : IRandomSource { public int Next(int maxExclusive) => 0; } private static PlanningUnit Unit(int minutes, Guid showId, int index) => new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index); private static PlanningElement Element( int unitCount, int minutesEach, int weight = 1, int position = 0, DateTimeOffset? lastPlayed = null ) { var showId = Guid.NewGuid(); var units = Enumerable .Range(0, unitCount) .Select(i => Unit(minutesEach, showId, i)) .ToList(); return new PlanningElement( GroupElementKind.Show, Guid.NewGuid(), weight, position, units, lastPlayed ); } private static PlanningSlot Slot( DateTimeOffset startUtc, int durationMinutes, IReadOnlyList elements, SlotBlockMode blockMode = SlotBlockMode.FillSlot, int blockValue = 1, OverflowPolicy overflow = OverflowPolicy.ContinueNext, bool isAnchor = false, int? snapToMinutes = null, PlanningCursor? cursor = null, SlotStrategyKind strategy = SlotStrategyKind.Sequential, int cooldownDays = 0 ) => new( Guid.NewGuid(), startUtc, durationMinutes, SlotKind.Content, isAnchor, MaxDriftMinutes: 30, snapToMinutes, blockMode, blockValue, overflow, new PlanningStrategy(strategy, CooldownDays: cooldownDays), elements, cursor ); private static PlanningInput Input( IReadOnlyList slots, DateTimeOffset? start = null, int horizonHours = 6, IReadOnlyList? fallback = null, IReadOnlyList? background = null ) => new( Guid.NewGuid(), start ?? T0, (start ?? T0).AddHours(horizonHours), slots, fallback ?? [Unit(1, Guid.NewGuid(), 0)], SegmentSeconds: 2, UtcOffsetMinutes: 0, background ?? [] ); private static PlanningResult Run(PlanningInput input) => TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways()); [Fact] public void ContinuousTape_HasNoGapsBetweenItems() { var element = Element(4, 30); var result = Run(Input([Slot(T0, 120, [element])])); var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList(); Assert.NotEmpty(ordered); for (var i = 1; i < ordered.Count; i++) Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc); } [Fact] public void BlockModeCount_PlacesExactlyRequestedUnits() { var element = Element(10, 20); var result = Run( Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1) ); Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); } [Fact] public void BlockModeDuration_IncludesLastUnitWholly() { var element = Element(10, 20); var result = Run( Input( [Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)], horizonHours: 2 ) ); // 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя. Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); } [Fact] public void Anchor_DoesNotStartUnitThatCrossesIt() { var first = Element(10, 25); var second = Element(1, 30); var slots = new[] { Slot(T0, 60, [first]), Slot(T0.AddMinutes(60), 60, [second], isAnchor: true), }; var result = Run(Input(slots)); var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId); // Третья 25-минутная серия перелезла бы через якорь — её не начинают. Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc); } [Fact] public void Snap_MovesStartToRoundTime() { // Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00. var first = Element(1, 47); var second = Element(1, 30); var slots = new[] { Slot(T0, 47, [first]), Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15), }; var result = Run(Input(slots)); var secondItem = result.Items.First(i => i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program ); Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15); } [Fact] public void Snap_SkippedWhenDriftWouldExceedAllowance() { var element = Element(1, 30); var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with { MaxDriftMinutes = 2, }; var result = Run(Input([slot])); // Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время. var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.Equal(T0, program.StartsAtUtc); } [Fact] public void GapBeforeSlot_IsFilledWithFallback() { var element = Element(1, 30); var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])])); Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback); Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc)); } [Fact] public void EmptySlot_WarnsAndFallsBack() { var result = Run(Input([Slot(T0, 30, [])])); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty); Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind)); } [Fact] public void Cursor_AdvancesToNextUnit() { var element = Element(5, 20); var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2); var result = Run(Input([slot], horizonHours: 1)); var cursor = Assert.Single(result.Cursors); Assert.Equal(element.ElementId, cursor.ElementId); Assert.Equal(2, cursor.NextUnitIndex); } [Fact] public void Cursor_ResumesMidElement() { var element = Element(5, 20); var slot = Slot( T0, 60, [element], SlotBlockMode.Count, blockValue: 1, cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3) ); var result = Run(Input([slot], horizonHours: 1)); var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.Equal(3, program.UnitIndex); } [Fact] public void SkipIfNotFits_LeavesSlotToFallback() { // Элемент из трёх часовых частей в часовой слот целиком не влезает. var element = Element(3, 60); var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits); var result = Run(Input([slot], horizonHours: 2)); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program); } [Fact] public void ExtendSlot_PlaysElementBeyondBudget() { var element = Element(3, 60); var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot); var result = Run(Input([slot], horizonHours: 5)); // Трилогия доигрывается целиком, слот залезает на соседей — так и задумано. Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); } [Fact] public void Cooldown_PrefersElementOutsideWindow() { var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1)); var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40)); var slot = Slot( T0, 30, [hot, cold], SlotBlockMode.Count, blockValue: 1, strategy: SlotStrategyKind.RandomWithCooldown, cooldownDays: 28 ); var result = Run(Input([slot], horizonHours: 1)); var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.Equal(cold.Units[0].ShowId, program.ShowId); } [Fact] public void Cooldown_Exhausted_WarnsAndTakesOldest() { var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2)); var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5)); var slot = Slot( T0, 30, [recentA, recentB], SlotBlockMode.Count, blockValue: 1, strategy: SlotStrategyKind.RandomWithCooldown, cooldownDays: 28 ); var result = Run(Input([slot], horizonHours: 1)); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted); var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.Equal(recentB.Units[0].ShowId, program.ShowId); } [Fact] public void SignOffSlot_MarksItemsAccordingly() { var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff }; var result = Run(Input([slot], horizonHours: 2)); Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff); } [Fact] public void RepeatSlot_ReplaysSourceUnits() { var showId = Guid.NewGuid(); var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) }; var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat }; var result = Run(Input([slot], horizonHours: 2)); Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); } [Fact] public void RepeatSlot_WithEmptySource_WarnsAndFallsBack() { var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] }; var result = Run(Input([slot], horizonHours: 2)); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); } [Fact] public void Trace_IsWrittenWithSlotAndStrategy() { var element = Element(1, 30); var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1); var result = Run(Input([slot], horizonHours: 1)); var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.NotNull(program.Trace); Assert.Equal(slot.SlotId, program.Trace!.SlotId); Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy); Assert.Equal(element.ElementId, program.Trace.ElementId); } [Fact] public void Background_PlaysInLeftoverInsteadOfEmergencyFiller() { // Слот на полчаса при часовом горизонте: остаток по замыслу принадлежит фоновому слою. var backgroundElement = Element(4, 10); var background = Slot(T0, 24 * 60, [backgroundElement]); var result = Run( Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background]) ); var filled = result .Items.Where(i => i.SlotId == background.SlotId) .OrderBy(i => i.StartsAtUtc) .ToList(); Assert.Equal(3, filled.Count); // Фон — это программа своего слота, а не аварийный запас. Assert.All(filled, item => Assert.Equal(PlannedItemKind.Program, item.Kind)); Assert.All( filled, item => Assert.Equal(backgroundElement.ElementId, item.Trace!.ElementId) ); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback); } [Fact] public void Background_ContinuesAcrossGaps() { var backgroundElement = Element(6, 10); var background = Slot(T0, 24 * 60, [backgroundElement]); var result = Run( Input( [Slot(T0, 20, [Element(1, 20)]), Slot(T0.AddMinutes(40), 20, [Element(1, 20)])], horizonHours: 2, background: [background] ) ); var filled = result .Items.Where(i => i.SlotId == background.SlotId) .OrderBy(i => i.StartsAtUtc) .Select(i => i.UnitIndex) .ToList(); // Вторая пауза продолжает сериал с той серии, на которой остановилась первая; исчерпав // элемент, последовательная стратегия начинает его заново — это её штатное поведение. Assert.Equal([0, 1, 2, 3, 4, 5], filled.Take(6)); } [Fact] public void Background_FallsBackToEmergencyWhenEpisodeDoesNotFit() { var background = Slot(T0, 24 * 60, [Element(4, 30)]); var result = Run( Input( [Slot(T0, 50, [Element(1, 50)])], horizonHours: 1, fallback: [Unit(2, Guid.Empty, 0)], background: [background] ) ); // Остаток — 10 минут, серия фона длиннее: хвост добирает аварийный запас, а не дыра. Assert.DoesNotContain(result.Items, i => i.SlotId == background.SlotId); Assert.Equal(5, result.Items.Count(i => i.Kind == PlannedItemKind.Fallback)); } [Fact] public void Background_FillsEmptyRepeatSlot() { var backgroundElement = Element(6, 10); var background = Slot(T0, 24 * 60, [backgroundElement]); var repeat = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] }; var result = Run(Input([repeat], horizonHours: 1, background: [background])); // Свежему каналу повторять нечего — по замыслу это место отдаётся фоновому слою. Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); Assert.Equal(6, result.Items.Count(i => i.SlotId == background.SlotId)); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback); } [Fact] public void Background_CursorIsSavedOnlyForOverlappedSlots() { var backgroundElement = Element(6, 10); var background = Slot(T0, 24 * 60, [backgroundElement]); var result = Run( Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background]) ); var cursor = Assert.Single(result.Cursors, c => c.SlotId == background.SlotId); Assert.Equal(backgroundElement.ElementId, cursor.ElementId); Assert.Equal(3, cursor.NextUnitIndex); } [Fact] public void Fallback_KeepsShowOfItsUnits() { var showId = Guid.NewGuid(); // Слот на 30 минут при часовом горизонте: остаток закрывает фон. var result = Run( Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, fallback: [Unit(5, showId, 3)]) ); var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList(); Assert.NotEmpty(fallback); // Фон — это программы из группы: без шоу в ленте на их месте прочерк. Assert.All(fallback, item => Assert.Equal(showId, item.ShowId)); Assert.All(fallback, item => Assert.Equal(3, item.UnitIndex)); } [Fact] public void Fallback_AnonymousFillerHasNoShow() { // Аварийный филлер канала приходит без шоу — подписывать его нечем и не нужно. var result = Run( Input( [Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, fallback: [Unit(5, Guid.Empty, 0)] ) ); Assert.All( result.Items.Where(i => i.Kind == PlannedItemKind.Fallback), item => Assert.Null(item.ShowId) ); } [Fact] public void Fallback_RotatesUnitsBetweenGaps() { var first = Unit(5, Guid.NewGuid(), 0); var second = Unit(5, Guid.NewGuid(), 0); var result = Run( Input( [Slot(T0, 30, [Element(1, 30)]), Slot(T0.AddMinutes(40), 30, [Element(1, 30)])], horizonHours: 2, fallback: [first, second] ) ); var fallback = result .Items.Where(i => i.Kind == PlannedItemKind.Fallback) .OrderBy(i => i.StartsAtUtc) .ToList(); // Пауза продолжает перебор с того места, где остановилась прошлая: иначе ночь выглядит // одним зациклённым куском. Assert.Equal(first.MediaAssetId, fallback[0].MediaAssetId); Assert.Equal(second.MediaAssetId, fallback[1].MediaAssetId); } [Fact] public void Fallback_TakesShorterUnitWhenNextDoesNotFit() { var showId = Guid.NewGuid(); // Список отсортирован по возрастанию, как его готовит оркестратор. var result = Run( Input( [Slot(T0, 50, [Element(1, 50)])], horizonHours: 1, fallback: [Unit(2, showId, 0), Unit(30, showId, 1)] ) ); // Остаток — 10 минут: длинная единица не влезает, и вместо дыры в ленте идут короткие. var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList(); Assert.Equal(5, fallback.Count); Assert.All( fallback, item => Assert.Equal(TimeSpan.FromMinutes(2), item.EndsAtUtc - item.StartsAtUtc) ); } [Fact] public void NoFallbackUnits_ProducesWarning() { var input = Input([], fallback: []); var result = Run(input); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty); } [Fact] public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted() { var first = Element(1, 30, position: 0); var second = Element(1, 30, position: 1); var slot = Slot( T0, 30, [first, second], SlotBlockMode.Count, blockValue: 1, cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1) ); var result = Run(Input([slot], horizonHours: 1)); var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); Assert.Equal(second.Units[0].ShowId, program.ShowId); } }