using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Programming; using TeleWave.Domain.Programming.Planning; using Xunit; using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner; namespace TeleWave.Domain.Tests.Programming; public class JunctionFillerTests { private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero); private sealed class FirstAlways : IRandomSource { public int Next(int maxExclusive) => 0; } /// Всегда последний вариант — по нему видно, что жребий вообще участвует в выборе. private sealed class LastAlways : IRandomSource { public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1); } /// /// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу /// его и отличаем от выбора внутри развилки, который спрашивает сумму весов. /// private sealed class CountingRandom : IRandomSource { public int Rolls { get; private set; } public int Next(int maxExclusive) { if (maxExclusive == 100) Rolls++; return 0; } } private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) => new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index); private static PlanningElement Series(int episodes, int minutes, out Guid showId) { showId = Guid.NewGuid(); var id = showId; var units = Enumerable.Range(0, episodes).Select(i => Unit(minutes, id, i)).ToList(); return new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units); } private static PlanningJunctionElement Ads( int count, double minutesEach = 0.5, bool required = false, int minMinutesBetween = 0, bool onlyOnElementChange = false, int chance = 100, PlanningTimeWindow? window = null, string? choiceKey = null, int choiceWeight = 1, int poolSize = 8, int nearHourMinutes = 0 ) => new( Guid.NewGuid(), JunctionElementKind.Ad, Enumerable.Range(0, poolSize).Select(_ => Unit(minutesEach)).ToList(), JunctionAmountMode.Count, count, required, onlyOnElementChange, minMinutesBetween, chance, window, nearHourMinutes, choiceKey, choiceWeight ); private static PlanningSlot Slot( PlanningElement element, int blockValue, PlanningJunction? between = null, PlanningJunction? after = null ) => new( Guid.NewGuid(), T0, 240, SlotKind.Content, IsAnchor: false, MaxDriftMinutes: 30, SnapToMinutes: null, SlotBlockMode.Count, blockValue, OverflowPolicy.ContinueNext, new PlanningStrategy(SlotStrategyKind.Sequential), [element], Cursor: null, RepeatUnits: null, JunctionBetween: between, JunctionAfter: after ); private static PlanningResult Run( PlanningSlot slot, IRandomSource? random = null, int utcOffsetMinutes = 0 ) => GridPlanner.Plan( new PlanningInput( Guid.NewGuid(), T0, T0.AddHours(5), [slot], [Unit(1)], SegmentSeconds: 2, UtcOffsetMinutes: utcOffsetMinutes ), random ?? new FirstAlways() ); [Fact] public void BetweenJunction_PlacesAdsBetweenUnitsButNotBeforeFirst() { var element = Series(3, 20, out _); var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(2)]))); var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList(); Assert.Equal(PlannedItemKind.Program, ordered[0].Kind); // Между тремя сериями два стыка по две врезки. Assert.Equal(4, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void AfterJunction_PlacesAdsAtBlockEnd() { var element = Series(2, 20, out _); var result = Run(Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(3)]))); var last = result .Items.Where(i => i.Kind != PlannedItemKind.Fallback) .OrderBy(i => i.StartsAtUtc) .Last(); Assert.Equal(PlannedItemKind.Ad, last.Kind); Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void DurationMode_IncludesLastUnitWholly() { var element = Series(2, 20, out _); var ads = new PlanningJunctionElement( Guid.NewGuid(), JunctionElementKind.Ad, Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(), JunctionAmountMode.Duration, 1, IsRequired: false ); var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [ads]))); // Бюджет 1 минута, ролики по 30 секунд: две штуки добирают ровно минуту, третья не нужна. Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void MinInterval_SkipsSecondJunction() { var element = Series(3, 20, out _); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, minMinutesBetween: 60)]) ) ); // Серии по 20 минут: второй стык приходит через 20 минут после первого — раньше разрешённого. Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void MinInterval_IsPerElement_NotPerKind() { var element = Series(2, 20, out _); var frequent = Ads(1, minMinutesBetween: 0); var rare = Ads(1, minMinutesBetween: 600); var result = Run( Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [frequent, rare])) ); // Обе врезки рекламные, но ограничение у каждой своё — общий счётчик на вид склеил бы их. Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void Units_RotateBetweenJunctions() { var element = Series(3, 20, out _); var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1)]))); var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); // Иначе каждый рекламный блок начинался бы с одного и того же ролика. Assert.Equal(2, ads.Select(a => a.MediaAssetId).Distinct().Count()); } [Fact] public void OnlyOnElementChange_SkipsWithinSameShow() { var element = Series(3, 20, out _); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, onlyOnElementChange: true)]) ) ); // Внутри одного сериала смены нет — врезка не ставится. Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void Chance_Zero_SkipsElement() { var element = Series(3, 20, out _); var result = Run( Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, chance: 0)])) ); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void TimeWindow_SkipsOutsideChannelHours() { var element = Series(3, 20, out _); // Стык идёт в 18:20 UTC, канал в UTC+3 — 21:20 по времени канала, окно 06:00–20:00 закрыто. var window = new PlanningTimeWindow(new TimeOnly(6, 0), new TimeOnly(20, 0)); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, window: window)]) ), utcOffsetMinutes: 180 ); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void NearHour_SkipsAwayFromRoundHour() { // Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место. var element = Series(3, 20, out _); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) ) ); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void NearHour_PlacesOnTheHour() { // Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом. var element = Series(3, 60, out _); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) ) ); var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); Assert.Equal(2, ads.Count); Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute)); } [Fact] public void NearHour_CountsChannelTime_NotUtc() { // Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу — // «московское время» объявляют в эфире, а не в UTC. var element = Series(3, 60, out _); var result = Run( Slot( element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) ), utcOffsetMinutes: 210 ); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void Choice_PlacesExactlyOneOfTheFork() { var element = Series(2, 20, out _); var first = Ads(1, choiceKey: "fork"); var second = new PlanningJunctionElement( Guid.NewGuid(), JunctionElementKind.Promo, [Unit(0.5)], JunctionAmountMode.Count, 1, IsRequired: false, ChoiceKey: "fork" ); var byFirst = Run( Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])), new FirstAlways() ); var byLast = Run( Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])), new LastAlways() ); Assert.Single(byFirst.Items, i => i.Kind == PlannedItemKind.Ad); Assert.DoesNotContain(byFirst.Items, i => i.Kind == PlannedItemKind.Promo); Assert.Single(byLast.Items, i => i.Kind == PlannedItemKind.Promo); Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void Choice_RollsChanceOnce_ForTheWholeFork() { // Три заставки в одной развилке — это один выбор, а не три независимых. Бросок жребия // на каждую превратил бы «показать в 60 % случаев» в «показать хоть какую-то почти всегда». var element = Series(2, 20, out _); var fork = new PlanningJunction( Guid.NewGuid(), [ Ads(1, chance: 60, choiceKey: "fork"), Ads(1, chance: 60, choiceKey: "fork"), Ads(1, chance: 60, choiceKey: "fork"), ] ); var random = new CountingRandom(); Run(Slot(element, 2, between: fork), random); Assert.Equal(1, random.Rolls); } [Fact] public void Choice_TakesChanceOfThePickedVariant() { // Жребий бросает уже выбранный вариант: у него шанс ноль — значит развилка молчит целиком, // а не подставляет вместо него соседа с сотней. var element = Series(2, 20, out _); var fork = new PlanningJunction( Guid.NewGuid(), [Ads(1, chance: 0, choiceKey: "fork"), Ads(1, chance: 100, choiceKey: "fork")] ); var result = Run(Slot(element, 2, between: fork), new FirstAlways()); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void MaxTotal_CapsJunctionLength() { var element = Series(2, 20, out _); var junction = new PlanningJunction( Guid.NewGuid(), [Ads(6, minutesEach: 1)], MaxTotal: TimeSpan.FromMinutes(2) ); var result = Run(Slot(element, 2, between: junction)); Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } [Fact] public void Bumper_ReservesDurationWithEmptyAssetAndShowPair() { var element = Series(2, 20, out var showId); var variantId = Guid.NewGuid(); var bumper = new PlanningJunctionElement( Guid.NewGuid(), JunctionElementKind.Bumper, [], JunctionAmountMode.Count, 1, IsRequired: true, BumperTemplateId: Guid.NewGuid(), BumperVariantId: variantId, BumperDuration: TimeSpan.FromSeconds(10) ); var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [bumper]))); var placed = Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Bumper); // Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна. Assert.Equal(Guid.Empty, placed.MediaAssetId); Assert.Equal(showId, placed.ToShowId); Assert.Equal(variantId, placed.BumperVariantId); Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc); } [Fact] public void OrderFollowsPosition_NotRequiredFlag() { var element = Series(2, 20, out _); var optional = Ads(1, minutesEach: 1); var required = new PlanningJunctionElement( Guid.NewGuid(), JunctionElementKind.Promo, [Unit(0.5)], JunctionAmountMode.Count, 1, IsRequired: true ); var result = Run( Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [optional, required])) ); var junctionItems = result .Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo) .OrderBy(i => i.StartsAtUtc) .ToList(); // Обязательность решает, кого выбросить при нехватке времени, а не кто идёт первым. Assert.Equal(PlannedItemKind.Ad, junctionItems[0].Kind); Assert.Equal(PlannedItemKind.Promo, junctionItems[1].Kind); } [Fact] public void RequiredSurvives_WhenOptionalDoesNotFit() { var element = Series(2, 20, out _); var optional = Ads(4, minutesEach: 1); var required = new PlanningJunctionElement( Guid.NewGuid(), JunctionElementKind.Promo, [Unit(1)], JunctionAmountMode.Count, 1, IsRequired: true ); var junction = new PlanningJunction( Guid.NewGuid(), [optional, required], MaxTotal: TimeSpan.FromMinutes(2) ); var result = Run(Slot(element, 2, between: junction)); Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Promo); Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public void JunctionDoesNotCrossAnchor() { var element = Series(2, 20, out _); var slot = Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(4, 5)])) with { TargetDurationMinutes = 40, }; var anchor = new PlanningSlot( Guid.NewGuid(), T0.AddMinutes(45), 60, SlotKind.Content, IsAnchor: true, MaxDriftMinutes: 5, SnapToMinutes: null, SlotBlockMode.Count, 1, OverflowPolicy.ContinueNext, new PlanningStrategy(SlotStrategyKind.Sequential), [Series(1, 30, out _)], Cursor: null ); var result = GridPlanner.Plan( new PlanningInput( Guid.NewGuid(), T0, T0.AddHours(3), [slot, anchor], [Unit(1)], SegmentSeconds: 2 ), new FirstAlways() ); // До якоря остаётся пять минут: влезает одна пятиминутная врезка, остальные не начинают. Assert.All( result.Items.Where(i => i.Kind == PlannedItemKind.Ad), item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc) ); Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); } }