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 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 ); }