using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using TeleWave.Domain.Programming; using TeleWave.Infrastructure.Persistence; using Xunit; namespace TeleWave.Integration.Tests; /// /// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты, /// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется /// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete. /// [Collection("postgres")] public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture) { private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero); [SkippableFact] public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 3); await using var db = fixture.CreateContext(); var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); Assert.False(report.ChannelSkipped); Assert.True(report.Added > 0); await using var verify = fixture.CreateContext(); var entries = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId) .OrderBy(e => e.StartsAtUtc) .ToList(); Assert.NotEmpty(entries); // Лента обязана быть непрерывной: живой край иначе упрётся в дыру. for (var i = 1; i < entries.Count; i++) Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc); var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); Assert.NotEmpty(programs); Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId)); Assert.All(programs, e => Assert.NotNull(e.TraceJson)); // Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой. var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId); Assert.Equal(world.ShowId, state.CurrentElementId); Assert.True(state.NextUnitIndex > 0); // И шаблон помечен применённым: баннер «правила изменены» должен погаснуть. Assert.False( verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges ); } [SkippableFact] public async Task Generate_IsIdempotentWithinHorizon() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 3); await using var first = fixture.CreateContext(); await Generator(first).GenerateAsync(world.ChannelId, Now, false, default); await using var second = fixture.CreateContext(); var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default); // Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего. Assert.Equal(0, again.Added); } [SkippableFact] public async Task Generate_Rebuild_KeepsPastAndReplacesFuture() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 3); await using var first = fixture.CreateContext(); await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default); await using var beforeDb = fixture.CreateContext(); var past = beforeDb .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now) .Select(e => e.Id) .ToHashSet(); Assert.NotEmpty(past); await using var rebuild = fixture.CreateContext(); await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); await using var verify = fixture.CreateContext(); var survived = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id)) .Select(e => e.Id) .ToList(); // Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу. Assert.Equal(past.Count, survived.Count); } /// /// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое /// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии. /// [SkippableFact] public async Task Generate_Rebuild_RewindsCursorToWhatActuallyAired() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); // Серий заведомо больше, чем влезет в горизонт: иначе сериал успеет пойти по кругу, // и «продолжает с той же серии» проверять станет нечем. var world = await SeedAsync(seedDb, episodes: 100); // Первый прогон строит сутки вперёд от момента двухчасовой давности. await using var first = fixture.CreateContext(); await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default); await using var beforeDb = fixture.CreateContext(); var aired = beforeDb .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program && e.StartsAtUtc < Now ) .OrderByDescending(e => e.StartsAtUtc) .First(); var beforeRebuild = beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId); // Курсор ушёл за эфир: он прошёл весь построенный хвост, а не только вышедшее. Assert.True(beforeRebuild.NextUnitIndex > aired.EpisodeIndex + 1); await using var rebuild = fixture.CreateContext(); await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); await using var verify = fixture.CreateContext(); var firstAfterRebuild = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program && e.StartsAtUtc >= Now ) .OrderBy(e => e.StartsAtUtc) .First(); // Пересобранный эфир продолжает ровно с той серии, что шла следующей по отыгранному. Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex); } /// /// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз /// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без /// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал /// по только что снесённому хвосту. /// [SkippableFact] public async Task Generate_Rebuild_StartsOver_WhenNothingHasAiredYet() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 100); await using var first = fixture.CreateContext(); await Generator(first).GenerateAsync(world.ChannelId, Now, false, default); await using var beforeDb = fixture.CreateContext(); // Ничего не отыграно: вся построенная лента — будущее. Assert.Empty( beforeDb.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.StartsAtUtc < Now ) ); Assert.True(beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId).NextUnitIndex > 0); await using var rebuild = fixture.CreateContext(); await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); await using var verify = fixture.CreateContext(); var firstProgram = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program ) .OrderBy(e => e.StartsAtUtc) .First(); Assert.Equal(0, firstProgram.EpisodeIndex); } [SkippableFact] public async Task Generate_CollectionInGroup_StampsCollectionOnEntries() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 2, asCollection: true); await using var db = fixture.CreateContext(); await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); await using var verify = fixture.CreateContext(); var programs = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program ) .ToList(); Assert.NotEmpty(programs); // Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте. Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId)); } [SkippableFact] public async Task Preview_WritesNothing() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 3); await using var db = fixture.CreateContext(); var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default); Assert.NotNull(result); Assert.NotEmpty(result.Items); await using var verify = fixture.CreateContext(); // Сухой прогон: ни ленты, ни курсоров, ни отметки о применении. Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)); Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId)); // Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что. Assert.True( verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges ); } [SkippableFact] public async Task Generate_EmptyGroup_FallsBackAndWarns() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedAsync(seedDb, episodes: 0); await using var db = fixture.CreateContext(); var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); // Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение. Assert.Contains( report.Warnings, w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty ); await using var verify = fixture.CreateContext(); Assert.All( verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(), e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind) ); } /// /// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14, /// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил /// вторую полнометражку подряд: слот-то по времени ещё не истёк. /// [SkippableFact] public async Task Generate_Rebuild_DoesNotReopenSlotThatAlreadyAired() { Skip.IfNot(fixture.Available, "Docker недоступен"); await using var seedDb = fixture.CreateContext(); var world = await SeedFilmsAsync(seedDb); await using (var first = fixture.CreateContext()) await Generator(first).GenerateAsync(world.ChannelId, Now, false, default); await using var read = fixture.CreateContext(); var film = read .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.SlotId == world.SlotId && e.Kind == ScheduleEntryKind.Program ) .OrderBy(e => e.StartsAtUtc) .First(); var during = film.StartsAtUtc.AddMinutes(30); await using (var rebuild = fixture.CreateContext()) await Generator(rebuild).GenerateAsync(world.ChannelId, during, true, default); await using var verify = fixture.CreateContext(); // Второго кино из того же слота быть не должно; следующее окно этого слота — только завтра. var extra = verify .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.SlotId == world.SlotId && e.Kind == ScheduleEntryKind.Program && e.StartsAtUtc >= during && e.StartsAtUtc < film.StartsAtUtc.AddHours(4) ) .ToList(); Assert.Empty(extra); // Само кино при этом не тронуто: прошлое и идущая запись неприкосновенны. Assert.NotNull(verify.ScheduleEntries.FirstOrDefault(e => e.Id == film.Id)); } // ── Обвязка ───────────────────────────────────────────────────────────── private static GridScheduleGenerator Generator(AppDbContext db) { var random = new SequenceRandom(0); return new GridScheduleGenerator( db, new GroupExpander( db, new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db)) ), new BumperResolver(db, Substitute.For(), random), new PostCheckRunner(db), random, Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), Options.Create(new StreamingOptions()) ); } private sealed record World( Guid ChannelId, Guid TemplateId, Guid SlotId, Guid ShowId, Guid? CollectionId ); /// /// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день /// и филлер для пауз. кладёт в группу коллекцию, а не шоу. /// private static async Task SeedAsync( AppDbContext db, int episodes, bool asCollection = false ) { var suffix = Guid.NewGuid().ToString("N")[..8]; var show = Show.Create($"Шоу {suffix}", ShowKind.Series); for (var i = 0; i < episodes; i++) { var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30)); show.AddEpisode(asset.Id); } db.Shows.Add(show); Collection? collection = null; if (asCollection) { collection = Collection.Create($"Франшиза {suffix}"); collection.AddShow(show.Id); db.Collections.Add(collection); } var group = Group.Create($"Группа {suffix}"); group.AddElement( asCollection ? GroupElementKind.Collection : GroupElementKind.Show, asCollection ? collection!.Id : show.Id ); db.Groups.Add(group); var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1)); var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7)); channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var layer = template.AddLayer("Базовый", 10); var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, group.Id, new SlotStrategy(SlotStrategyType.Sequential).ToJson(), null, SlotBlockMode.FillSlot, 1, OverflowPolicy.ContinueNext ) ); channel.SetTemplate(template.Id); // Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить». template.MarkChanged(); db.Channels.Add(channel); db.ScheduleTemplates.Add(template); await db.SaveChangesAsync(); return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id); } /// /// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком /// () — на этом и ловится повторное открытие слота. /// private static async Task SeedFilmsAsync(AppDbContext db) { var suffix = Guid.NewGuid().ToString("N")[..8]; var group = Group.Create($"Полнометражки {suffix}"); for (var i = 0; i < 3; i++) { var film = Show.Create($"Фильм {i} {suffix}", ShowKind.Single); film.AddEpisode(ReadyAsset(db, $"film{i}-{suffix}.mkv", TimeSpan.FromMinutes(139)).Id); db.Shows.Add(film); group.AddElement(GroupElementKind.Show, film.Id); } db.Groups.Add(group); // Филлер подлиннее: паузу до вечера он закрывает по кругу, и минутный дал бы сотни записей. var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30)); var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7)); channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var layer = template.AddLayer("Базовый", 10); var slot = layer.AddSlot("Кино в 21:00", new TimeOnly(21, 0), 135); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, group.Id, new SlotStrategy(SlotStrategyType.Sequential).ToJson(), null, SlotBlockMode.Count, 1, OverflowPolicy.ExtendSlot ) ); channel.SetTemplate(template.Id); template.MarkChanged(); db.Channels.Add(channel); db.ScheduleTemplates.Add(template); await db.SaveChangesAsync(); return new World(channel.Id, template.Id, slot.Id, Guid.Empty, null); } /// Ассет, доведённый до Ready: в эфир попадают только такие. private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration) { var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( new MediaReadyInfo( duration, SegmentSeconds: 2, SegmentCount: (int)(duration.TotalSeconds / 2), Width: 1920, Height: 1080, VideoCodec: "h264", AudioCodec: "aac", RelativePath: $"segments/{asset.Id}" ) ); db.MediaAssets.Add(asset); return asset; } }