using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; using Xunit; namespace TeleWave.Domain.Tests.Broadcast; public class SchedulePlannerTests { private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); /// Источник случайности с фиксированной (циклической) последовательностью значений. private sealed class FixedRandom(params int[] values) : IRandomSource { private int _i; public int Next(int maxExclusive) => values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive; } private static Dictionary Durations(params (Guid Id, int Minutes)[] items) => items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes)); [Fact] public void Count_Block_ProducesConsecutiveEpisodes_BackToBack() { var cs = Guid.NewGuid(); var show = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; var input = new PlannerInput( ChannelId: Guid.NewGuid(), AdInsertion: AdInsertion.BetweenBlocks, AdsPerBreak: 1, NextAdIndex: 0, Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)], AdPool: [], Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)), Overrides: [], StartTime: Start, HorizonEnd: Start.AddSeconds(1) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Equal(3, result.Entries.Count); Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind)); Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex)); Assert.Equal(Start, result.Entries[0].StartsAtUtc); Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc); Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc); Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]); } [Fact] public void Count_Block_WrapsAtEndOfSeries() { var cs = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; var input = BaseInput( [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)], Durations((eps[0], 2), (eps[1], 2)), horizonEnd: Start.AddSeconds(1) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex)); Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]); } [Fact] public void Duration_Block_TakesEpisodesUntilBudgetReached() { var cs = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; var input = BaseInput( [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)], Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)), horizonEnd: Start.AddMinutes(30) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); // 10+10+10 = 30 >= 25 → три серии. Assert.Equal(3, result.Entries.Count); Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex)); Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]); } [Fact] public void AdsBetweenBlocks_InsertsAdAfterBlock() { var cs = Guid.NewGuid(); Guid ep = Guid.NewGuid(), ad0 = Guid.NewGuid(), ad1 = Guid.NewGuid(); var input = new PlannerInput( Guid.NewGuid(), AdInsertion.BetweenBlocks, AdsPerBreak: 1, NextAdIndex: 0, Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)], AdPool: [ad0, ad1], Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)), Overrides: [], StartTime: Start, HorizonEnd: Start.AddSeconds(1) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Equal(2, result.Entries.Count); Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind); Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind); Assert.Equal(ad0, result.Entries[1].MediaAssetId); Assert.Equal(1, result.NextAdIndex); } [Fact] public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode() { var cs = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; Guid ad = Guid.NewGuid(); var input = new PlannerInput( Guid.NewGuid(), AdInsertion.BetweenEpisodes, AdsPerBreak: 1, NextAdIndex: 0, Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)], AdPool: [ad], Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)), Overrides: [], StartTime: Start, HorizonEnd: Start.AddSeconds(1) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Equal( [ ScheduleEntryKind.Program, ScheduleEntryKind.Ad, ScheduleEntryKind.Program, ScheduleEntryKind.Ad, ], result.Entries.Select(e => e.Kind) ); Assert.Equal(2, result.NextAdIndex); } [Fact] public void WeightedPick_RespectsRoll() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 3, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations( (a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20) ); var pickA = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(0)); var pickB = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(3)); Assert.Equal(a.ShowId, pickA.Entries[0].ShowId); Assert.Equal(b.ShowId, pickB.Entries[0].ShowId); } [Fact] public void ExclusiveOverride_ForcesSingleShow() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 10, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); var input = new PlannerInput( Guid.NewGuid(), AdInsertion.BetweenBlocks, 0, 0, [a, b], [], durations, Overrides: [ new PlannerOverride( Start, Start.AddHours(1), OverrideMode.Exclusive, [new PlannerOverrideShow(b.ShowId, 1)] ), ], StartTime: Start, HorizonEnd: Start.AddSeconds(1) ); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Equal(b.ShowId, result.Entries[0].ShowId); } [Fact] public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), }; // Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2). var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); // Перед первым блоком заставки нет; далее по одной на каждый переход. Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind); var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); Assert.NotEmpty(bumpers); var first = result.Entries[1]; Assert.Equal(ScheduleEntryKind.Bumper, first.Kind); Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор Assert.Equal(a.ShowId, first.FromShowId); Assert.Equal(b.ShowId, first.ToShowId); Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc); // Встык: программа → заставка → программа. Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc); Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc); } [Fact] public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows() { var cs = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; var input = BaseInput( [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], Durations((eps[0], 20), (eps[1], 20)), horizonEnd: Start.AddMinutes(50) ) with { Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } [Fact] public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent() { var cs = Guid.NewGuid(); Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; var input = BaseInput( [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], Durations((eps[0], 20), (eps[1], 20)), horizonEnd: Start.AddMinutes(50) ) with { Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } [Fact] public void Bumpers_MinInterval_SuppressesTooFrequentBumpers() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); // Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй. var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { Bumpers = new PlannerBumperConfig( true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.FromMinutes(30) ), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper)); } [Fact] public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); Guid j0 = Guid.NewGuid(), j1 = Guid.NewGuid(); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1), (j1, 1)); var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { Bumpers = new PlannerBumperConfig( true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero, Mode: BumperMode.Static, JinglePool: [j0, j1] ), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); Assert.Equal(2, bumpers.Count); Assert.Equal([j0, j1], bumpers.Select(e => e.MediaAssetId)); // ротация пула Assert.All(bumpers, e => Assert.Null(e.FromShowId)); // статик — не по паре шоу Assert.Equal(2, result.NextJingleIndex); } [Fact] public void Bumpers_StaticMode_EmptyPool_NoBumpers() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { Bumpers = new PlannerBumperConfig( true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero, Mode: BumperMode.Static, JinglePool: [] ), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } [Fact] public void Bumpers_BothMode_AlternatesDynamicAndStatic() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var j0 = Guid.NewGuid(); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1)); // Три перехода: динамика, джингл, динамика. var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with { Bumpers = new PlannerBumperConfig( true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero, Mode: BumperMode.Both, JinglePool: [j0] ), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); Assert.True(bumpers.Count >= 2); Assert.Equal(Guid.Empty, bumpers[0].MediaAssetId); // первый — динамический (плейсхолдер) Assert.Equal(j0, bumpers[1].MediaAssetId); // второй — статичный джингл } [Fact] public void Bumpers_Disabled_ProduceNoBumperEntries() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } [Fact] public void NoPlayableShows_ReturnsEmpty() { var input = BaseInput([], new Dictionary(), Start.AddHours(1)); var result = SchedulePlanner.Plan(input, new FixedRandom(0)); Assert.Empty(result.Entries); } private static PlannerInput BaseInput( IReadOnlyList shows, IReadOnlyDictionary durations, DateTimeOffset horizonEnd ) => new( ChannelId: Guid.NewGuid(), AdInsertion: AdInsertion.BetweenBlocks, AdsPerBreak: 0, NextAdIndex: 0, Shows: shows, AdPool: [], Durations: durations, Overrides: [], StartTime: Start, HorizonEnd: horizonEnd ); }