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 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 ) => new( JunctionElementKind.Ad, Enumerable.Range(0, 8).Select(_ => Unit(minutesEach)).ToList(), JunctionAmountMode.Count, count, required, onlyOnElementChange, minMinutesBetween ); 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) => GridPlanner.Plan( new PlanningInput( Guid.NewGuid(), T0, T0.AddHours(5), [slot], [Unit(1)], SegmentSeconds: 2 ), 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( 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 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 Bumper_ReservesDurationWithEmptyAssetAndShowPair() { var element = Series(2, 20, out var showId); var bumper = new PlanningJunctionElement( JunctionElementKind.Bumper, [], JunctionAmountMode.Count, 1, IsRequired: true, BumperTemplateId: Guid.NewGuid(), 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(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc); } [Fact] public void RequiredElement_GoesBeforeOptional() { var element = Series(2, 20, out _); var optional = Ads(1, minutesEach: 1); var required = new PlanningJunctionElement( 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.Promo, junctionItems[0].Kind); } [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) ); } }