using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; 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_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) ); } // ── Обвязка ───────────────────────────────────────────────────────────── private static GridScheduleGenerator Generator(AppDbContext db) { var random = new SequenceRandom(0); return new GridScheduleGenerator( db, new GroupExpander(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, bumpersEnabled: false, filler.Id); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var layer = template.AddLayer("Базовый", 10); var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateContent( 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); } /// Ассет, доведённый до Ready: в эфир попадают только такие. private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration) { var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( 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; } }