using System.Globalization; using System.Text; using TeleWave.Domain.Broadcast.Scheduling; using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner; using TeleWave.Domain.Programming; using TeleWave.Domain.Programming.Planning; using Xunit; namespace TeleWave.Domain.Tests.Programming; /// /// Золотые тесты планировщика: для набора эталонных конфигураций фиксируется посуточная лента /// целиком. Любая правка стратегий или подгонки сразу показывает построчный диф — что именно /// поехало, — вместо «упало N тестов» через неделю после того, как это ушло в эфир. /// /// Эталон обновляется осознанно: переписыванием ожидаемой строки в тесте, а не автоматически при /// расхождении. Автообновление превратило бы их в тесты, которые всегда зелёные. /// public class GoldenScheduleTests { private static readonly DateTimeOffset Day = new(2026, 3, 2, 6, 0, 0, TimeSpan.Zero); /// Псевдослучайность с фиксированным зерном: жребий обязан быть воспроизводимым. private sealed class SeededRandom(int seed) : IRandomSource { private int _state = seed; public int Next(int maxExclusive) { if (maxExclusive <= 0) return 0; _state = (int)((_state * 1103515245L + 12345) & 0x7FFFFFFF); return _state % maxExclusive; } } /// Лента в компактном виде: время начала, тип, шоу и номер единицы — построчно. private static List Render( PlanningResult result, IReadOnlyDictionary names ) => result .Items.OrderBy(i => i.StartsAtUtc) .Select(item => { var label = item.ShowId is { } showId && names.TryGetValue(showId, out var name) ? $"{name}#{item.UnitIndex}" : item.Kind.ToString(); return string.Create( CultureInfo.InvariantCulture, $"{item.StartsAtUtc:HH:mm} {label}" ); }) .ToList(); private static (PlanningElement Element, Guid ShowId) Series(int episodes, int minutes) { var showId = Guid.NewGuid(); var units = Enumerable .Range(0, episodes) .Select(i => new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, i)) .ToList(); return ( new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units), showId ); } private static PlanningSlot ContentSlot( DateTimeOffset start, int durationMinutes, IReadOnlyList elements, SlotBlockMode blockMode, int blockValue, bool isAnchor = false, int? snap = null ) => new( Guid.NewGuid(), start, durationMinutes, SlotKind.Content, isAnchor, MaxDriftMinutes: 15, snap, blockMode, blockValue, OverflowPolicy.ContinueNext, new PlanningStrategy(SlotStrategyKind.Sequential), elements, Cursor: null ); private static PlanningUnit Filler() => new(Guid.NewGuid(), TimeSpan.FromMinutes(1), Guid.Empty, 0); [Fact] public void Golden_MorningCartoonsThenPrimeMovie() { var (cartoons, cartoonShow) = Series(6, 20); var (movie, movieShow) = Series(1, 95); var slots = new[] { // Утренний блок: три мультфильма подряд. ContentSlot(Day.AddHours(1), 60, [cartoons], SlotBlockMode.Count, 3), // Прайм с якорем: старт жёсткий, предыдущий блок через него не перелезает. ContentSlot(Day.AddHours(14), 120, [movie], SlotBlockMode.Count, 1, isAnchor: true), }; var input = new PlanningInput( Guid.NewGuid(), Day, Day.AddHours(17), slots, [Filler()], SegmentSeconds: 2 ); var result = GridPlanner.Plan(input, new SeededRandom(42)); var names = new Dictionary { [cartoonShow] = "Мультфильмы", [movieShow] = "Кино", }; var tape = Render(result, names) .Where(line => !line.Contains("Fallback", StringComparison.Ordinal)) .ToList(); Assert.Equal( [ "07:00 Мультфильмы#0", "07:20 Мультфильмы#1", "07:40 Мультфильмы#2", "20:00 Кино#0", ], tape ); } [Fact] public void Golden_DriftIsCarriedForward() { var (series, showId) = Series(6, 25); // Три слота подряд по часу, блок — две серии по 25 минут: каждый слот отдаёт 50 минут, // и разбег накапливается, пока его не подберёт следующий целевой старт. var slots = Enumerable .Range(0, 3) .Select(i => ContentSlot(Day.AddHours(i), 60, [series], SlotBlockMode.Count, 2) ) .ToArray(); var input = new PlanningInput( Guid.NewGuid(), Day, Day.AddHours(4), slots, [Filler()], SegmentSeconds: 2 ); var result = GridPlanner.Plan(input, new SeededRandom(7)); var programs = result .Items.Where(i => i.Kind == PlannedItemKind.Program) .OrderBy(i => i.StartsAtUtc) .Select(i => i.StartsAtUtc.ToString("HH:mm", CultureInfo.InvariantCulture)) .ToList(); // Слот не начинается раньше своего целевого времени: недобор закрывает фон, а не сдвиг вперёд. Assert.Equal(["06:00", "06:25", "07:00", "07:25", "08:00", "08:25"], programs); Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback); Assert.Equal(showId, result.Items.First(i => i.Kind == PlannedItemKind.Program).ShowId); } [Fact] public void Golden_SameInputProducesSameTape() { var (series, _) = Series(10, 30); var slots = new[] { ContentSlot(Day, 180, [series], SlotBlockMode.FillSlot, 1) }; // Один и тот же филлер на оба прогона: разные ассеты фона сделали бы сравнение бессмысленным. var filler = Filler(); PlanningInput MakeInput() => new(Guid.NewGuid(), Day, Day.AddHours(4), slots, [filler], SegmentSeconds: 2); var first = GridPlanner.Plan(MakeInput(), new SeededRandom(1)); var second = GridPlanner.Plan(MakeInput(), new SeededRandom(1)); // Один вход и одно зерно обязаны давать одну ленту: иначе предпросмотр разойдётся с эфиром. Assert.Equal( first.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)), second.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)) ); } }