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, IReadOnlyList? pad = null, TimeSpan maxPad = default ) => new( Guid.NewGuid(), start ?? T0, (start ?? T0).AddHours(horizonHours), slots, fallback ?? [Unit(1, Guid.NewGuid(), 0)], SegmentSeconds: 2, UtcOffsetMinutes: 0, background ?? [], pad ?? [], maxPad ); private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad); 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 RepeatSlot_TakesSourceFromTheSameRun() { var evening = Slot(T0, 60, [Element(1, 60)]); var morning = Slot(T0.AddHours(2), 60, []) with { SlotKind = SlotKind.Repeat, RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)), }; var result = Run(Input([evening, morning], horizonHours: 4)); var source = result.Items.First(i => i.StartsAtUtc == T0); Assert.Contains( result.Items, i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId ); Assert.DoesNotContain( result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty ); } /// Источник нашёлся, но слот короче — это другая беда, и молчать о ней нельзя. [Fact] public void RepeatSlot_WithSourceTooLongForSlot_Warns() { var evening = Slot(T0, 60, [Element(1, 60)]); var morning = Slot(T0.AddHours(2), 30, []) with { SlotKind = SlotKind.Repeat, RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)), }; var result = Run(Input([evening, morning], horizonHours: 4)); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); } /// /// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора /// обязано ловить его по пересечению, а не по попаданию старта внутрь. /// [Fact] public void RepeatSlot_CatchesUnitStartedBeforeWindow() { var evening = Slot(T0, 60, [Element(1, 60)]); var morning = Slot(T0.AddHours(2), 60, []) with { SlotKind = SlotKind.Repeat, RepeatWindow = new PlanningRepeatWindow(T0.AddMinutes(10), T0.AddMinutes(70)), }; var result = Run(Input([evening, morning], horizonHours: 4)); var source = result.Items.First(i => i.StartsAtUtc == T0); Assert.Contains( result.Items, i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId ); } /// /// Слот приходит по экземпляру на каждые свои сутки, и курсор у всех этих экземпляров один — /// снимок из базы. Без общей позиции вторник переигрывал бы понедельник. /// [Fact] public void SameSlot_ContinuesAcrossItsDays_WithinOneRun() { var element = Element(4, 30); var monday = Slot(T0, 60, [element]); var tuesday = monday with { TargetStartUtc = T0.AddHours(3) }; var result = Run(Input([monday, tuesday], horizonHours: 6)); var played = result .Items.Where(i => i.Kind == PlannedItemKind.Program && i.SlotId == monday.SlotId) .Select(i => i.MediaAssetId) .ToList(); Assert.Equal(4, played.Count); Assert.Equal(4, played.Distinct().Count()); } /// /// Тот же слот бывает перекрыт в одни сутки и свободен в другие: фон и основной цикл ведут его /// вместе, и второй счётчик позиции переигрывал бы уже поставленные серии. /// [Fact] public void Background_AndMainLoop_ShareOneSlotPosition() { var element = Element(8, 30); var background = Slot(T0, 360, [element]); var main = background with { TargetStartUtc = T0.AddHours(2), TargetDurationMinutes = 60 }; var result = Run(Input([main], horizonHours: 4, fallback: [], background: [background])); var played = result .Items.Where(i => i.Kind == PlannedItemKind.Program) .Select(i => i.MediaAssetId) .ToList(); Assert.Equal(8, played.Count); Assert.Equal(8, played.Distinct().Count()); } /// Остывание обязано видеть то, что прогон поставил сам: горизонт строится разом. [Fact] public void Cooldown_CountsPlaysFromTheSameRun() { var first = Slot( T0, 60, [Element(1, 60, position: 0), Element(1, 60, position: 1)], strategy: SlotStrategyKind.RandomWithCooldown, cooldownDays: 14 ); var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) }; var result = Run(Input([first, second], horizonHours: 4)); var shows = result .Items.Where(i => i.Kind == PlannedItemKind.Program) .Select(i => i.ShowId) .ToList(); Assert.Equal(2, shows.Count); Assert.Equal(2, shows.Distinct().Count()); } /// Потолок повторов — тот же случай: показы прогона считаются наравне с записанными. [Fact] public void RepeatLimit_CountsPlaysFromTheSameRun() { var first = Slot( T0, 60, [Element(1, 60, position: 0), Element(1, 60, position: 1)], strategy: SlotStrategyKind.RandomWithCooldown ) with { RepeatLimit = new RepeatLimit(WindowDays: 7, Max: 1), }; var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) }; var result = Run(Input([first, second], horizonHours: 4)); var shows = result .Items.Where(i => i.Kind == PlannedItemKind.Program) .Select(i => i.ShowId) .ToList(); Assert.Equal(2, shows.Count); Assert.Equal(2, shows.Distinct().Count()); } [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 Pad_FillsSeamBeforeSlotWithAds() { // Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени // на настоящем ТВ занимает реклама, а не ещё одна серия. var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true); var result = Run( Input( [Slot(T0, 15, [Element(1, 15)]), anchor], horizonHours: 1, pad: [Ad(5)], maxPad: TimeSpan.FromMinutes(30) ) ); var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); Assert.Equal(3, ads.Count); Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc); // К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса. Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc); } [Fact] public void Pad_IsCappedSoLongHolesStayProgramming() { // Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы. var background = Slot(T0, 24 * 60, [Element(6, 10)]); var result = Run( Input( [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)], horizonHours: 2, pad: [Ad(5)], maxPad: TimeSpan.FromMinutes(10), background: [background] ) ); var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); Assert.Equal( TimeSpan.FromMinutes(10), ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc)) ); Assert.Contains(result.Items, i => i.SlotId == background.SlotId); } [Fact] public void Pad_CapCountsWholeSeam_WhenBackgroundHasNothingToPlay() { // Часовая дыра и пустой фоновый слой: добор до якоря и остаток перед ним сходятся встык, // и без общего потолка в эфир ушло бы двадцать минут рекламы подряд вместо десяти. var result = Run( Input( [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)], horizonHours: 2, pad: [Ad(5)], maxPad: TimeSpan.FromMinutes(10) ) ); // Считается самый длинный рекламный блок подряд: два добора по разные стороны от фона — // это два шва, а сошедшиеся встык — один, и он потолком и ограничен. var longest = TimeSpan.Zero; var current = TimeSpan.Zero; foreach (var item in result.Items.OrderBy(i => i.StartsAtUtc)) { current = item.Kind == PlannedItemKind.Ad ? current + (item.EndsAtUtc - item.StartsAtUtc) : TimeSpan.Zero; if (current > longest) longest = current; } Assert.Equal(TimeSpan.FromMinutes(10), longest); } [Fact] public void Pad_ClosesTailWhenProgrammeDoesNotFit() { // Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер. var background = Slot(T0, 24 * 60, [Element(4, 30)]); var result = Run( Input( [Slot(T0, 55, [Element(1, 55)])], horizonHours: 1, pad: [Ad(5)], background: [background] ) ); Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback); } [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); } }