using System.IO.Compression; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Planning.DebugExport; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Streaming; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using TeleWave.Domain.Programming; using TeleWave.Infrastructure.Persistence; using Xunit; namespace TeleWave.Application.Tests.Programming; /// /// Отладочный дамп канала. Проверяется не текст файлов, а то, ради чего он существует: архив /// собирается целиком, в нём есть все части входа планировщика, и по ним можно узнать конкретное /// шоу — дамп из одних идентификаторов бесполезен ровно тогда, когда его открывают. /// public class ChannelDebugExportTests { private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero); private sealed class FirstAlways : IRandomSource { public int Next(int maxExclusive) => 0; } private static MediaAsset ReadyAsset(string name, TimeSpan duration) { var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( new MediaReadyInfo( duration, 2, (int)(duration.TotalSeconds / 2), 1920, 1080, "h264", "aac", "assets/x" ) ); return asset; } private static async Task<(TestDb Fixture, Guid ChannelId)> SeedAsync() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var show = Show.Create("Симпсоны", ShowKind.Series); var episodes = new[] { ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)), ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)), }; foreach (var episode in episodes) show.AddEpisode(episode.Id); var group = Group.Create("Мультсериалы"); group.AddElement(GroupElementKind.Show, show.Id); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, group.Id, new SlotStrategy(SlotStrategyType.Sequential).ToJson(), null, SlotBlockMode.Count, 2, OverflowPolicy.ContinueNext ) ); channel.SetTemplate(template.Id); await using var seed = fixture.New(); seed.MediaAssets.AddRange(episodes); seed.Shows.Add(show); seed.Groups.Add(group); seed.Channels.Add(channel); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return (fixture, channel.Id); } private static ExportChannelDebugCommandHandler Handler( AppDbContext db, IDebugExportStore store ) { var random = new FirstAlways(); var expander = new GroupExpander(db, GroupServices.Dynamic(db)); var generator = new GridScheduleGenerator( db, expander, new BumperResolver(db, Substitute.For(), random), new PostCheckRunner(db), random, Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), Options.Create(new StreamingOptions()) ); return new ExportChannelDebugCommandHandler( db, new ChannelDebugCollector( db, expander, generator, Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }) ), new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)), store ); } private static Dictionary Unpack(byte[] archive) { using var buffer = new MemoryStream(archive); using var zip = new ZipArchive(buffer, ZipArchiveMode.Read); var files = new Dictionary(StringComparer.Ordinal); foreach (var entry in zip.Entries) { using var stream = entry.Open(); using var reader = new StreamReader(stream, Encoding.UTF8); files[entry.FullName] = reader.ReadToEnd(); } return files; } [Fact] public async Task Export_PacksEveryPartOfThePlannerInput() { var (fixture, channelId) = await SeedAsync(); var store = Substitute.For(); store .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns("D:/debug-exports/dump.zip"); await using var db = fixture.New(); var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); Assert.True(result.IsSuccess); Assert.EndsWith(".zip", result.Value.FileName, StringComparison.Ordinal); Assert.Equal("D:/debug-exports/dump.zip", result.Value.SavedTo); var files = Unpack(result.Value.Content); Assert.Equal( [ "README.md", "bumpers.json", "channel.json", "drift.json", "effective-grid.json", "groups-composition.json", "groups.json", "junctions.json", "library-collections.json", "library-interstitials.json", "library-media.json", "library-shows.json", "plan-preview.json", "schedule.json", "slot-states.json", "summary.json", "template.json", ], files.Keys.Order(StringComparer.Ordinal) ); // Дамп обязан читаться глазами: рядом с идентификаторами стоят имена, а единицы группы // развёрнуты поштучно — иначе «почему вышла эта серия» по нему не разобрать. Assert.Contains("Симпсоны", files["groups.json"], StringComparison.Ordinal); Assert.Contains("Дневной блок", files["template.json"], StringComparison.Ordinal); Assert.Contains("Дневной блок", files["effective-grid.json"], StringComparison.Ordinal); using var groups = JsonDocument.Parse(files["groups.json"]); var units = groups .RootElement[0] .GetProperty("elements")[0] .GetProperty("units") .EnumerateArray() .ToList(); Assert.Equal(2, units.Count); Assert.Equal("Симпсоны", units[0].GetProperty("show").GetString()); } [Fact] public async Task Export_DescribesTheLibraryItWasBuiltFrom() { // Вторая половина разбора: когда в эфир вышло не то, смотрят не на ленту, а на то, // из чего её собирали — сколько у шоу серий, готовы ли ассеты, что дало правило группы. var (fixture, channelId) = await SeedAsync(); await using var db = fixture.New(); var result = await Handler(db, Substitute.For()) .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); Assert.True(result.IsSuccess); var files = Unpack(result.Value.Content); using var shows = JsonDocument.Parse(files["library-shows.json"]); var show = shows.RootElement.EnumerateArray().Single(); Assert.Equal("Симпсоны", show.GetProperty("name").GetString()); Assert.Equal(2, show.GetProperty("episodes").GetInt32()); Assert.Equal(2, show.GetProperty("readyEpisodes").GetInt32()); // Серии перечислены по порядку показа — по нему планировщик их и ставит. var items = show.GetProperty("items").EnumerateArray().ToList(); Assert.Equal(2, items.Count); Assert.Equal("s01e01.mkv", items[0].GetProperty("file").GetString()); Assert.Equal("Ready", items[0].GetProperty("status").GetString()); using var groups = JsonDocument.Parse(files["groups-composition.json"]); var group = groups.RootElement.EnumerateArray().Single(); Assert.Equal("Мультсериалы", group.GetProperty("name").GetString()); // Состав вычислен, а не переписан из позиций: у динамической группы он только так и виден. var composition = group.GetProperty("composition").EnumerateArray().Single(); Assert.Equal("Симпсоны", composition.GetProperty("name").GetString()); Assert.Equal(2, composition.GetProperty("unitCount").GetInt32()); using var media = JsonDocument.Parse(files["library-media.json"]); Assert.Equal(2, media.RootElement.GetProperty("total").GetInt32()); Assert.Empty(media.RootElement.GetProperty("problems").EnumerateArray()); } [Fact] public async Task Export_MeasuresDriftAgainstTheGrid() { // «Слот уехал» — первый вопрос к любой сетке, и сводить целевые времена с фактическими // вручную по двум файлам приходилось каждый раз. var (fixture, channelId) = await SeedAsync(); var store = Substitute.For(); await using var db = fixture.New(); var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); Assert.True(result.IsSuccess); var files = Unpack(result.Value.Content); using var drift = JsonDocument.Parse(files["drift.json"]); var summary = drift.RootElement.GetProperty("summary"); var instances = drift.RootElement.GetProperty("instances").EnumerateArray().ToList(); Assert.NotEmpty(instances); Assert.Equal(instances.Count, summary.GetProperty("instances").GetInt32()); // Ленты ещё нет — значит и дрейф мерить не по чему: это «без записей», а не нулевой сдвиг. Assert.Equal(instances.Count, summary.GetProperty("withoutEntries").GetInt32()); Assert.All( instances, i => { Assert.Equal(JsonValueKind.Null, i.GetProperty("actualStartUtc").ValueKind); Assert.Equal(JsonValueKind.Null, i.GetProperty("driftMinutes").ValueKind); Assert.False(i.GetProperty("exceedsTolerance").GetBoolean()); Assert.True(i.GetProperty("maxDriftMinutes").GetInt32() > 0); } ); } [Fact] public async Task Export_DryRunGoesIntoTheDump() { // Сухой прогон — половина ценности дампа: он показывает, что планировщик построил бы // сейчас, и без него по архиву не отличить «данные плохие» от «расчёт плохой». var (fixture, channelId) = await SeedAsync(); // Каталог не настроен — хранилище так и отвечает: копии нет. var store = Substitute.For(); store .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((string?)null); await using var db = fixture.New(); var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); Assert.True(result.IsSuccess); var files = Unpack(result.Value.Content); using var preview = JsonDocument.Parse(files["plan-preview.json"]); Assert.NotEqual(JsonValueKind.Null, preview.RootElement.ValueKind); Assert.NotEmpty(preview.RootElement.GetProperty("items").EnumerateArray()); // Копии нет, но архив всё равно собран и уезжает в браузер. Assert.Null(result.Value.SavedTo); } [Fact] public async Task Export_FailsWithoutChannel() { var (fixture, _) = await SeedAsync(); await using var db = fixture.New(); var result = await Handler(db, Substitute.For()) .Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None); Assert.False(result.IsSuccess); } }