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 ) => new( Guid.NewGuid(), start ?? T0, (start ?? T0).AddHours(horizonHours), slots, fallback ?? [Unit(1, Guid.NewGuid(), 0)], SegmentSeconds: 2 ); 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 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); } }