using System.IO.Compression; using System.Text; using System.Text.Json; 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.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.Domain.Programming.Planning; using TeleWave.Infrastructure.Persistence; using Xunit; namespace TeleWave.Application.Tests.Programming; /// /// Отладочный дамп канала. Его собирают, когда эфир выглядит не так, как ждали, — значит он сам /// обязан работать при любой библиотеке, включая сломанную: упавший ассет, серия без файла, /// пустая группа. Ровно это здесь и проверяется, вместе с самодиагностикой в issues.json. /// public class DebugExportTests { 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; } [Fact] public async Task Export_PacksChannelAndLibraryIntoOneArchive() { var fixture = await SeedAsync(); var store = Substitute.For(); store .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns("/debug/dump.zip"); await using var db = fixture.New(); var channelId = db.Channels.Single().Id; var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); Assert.True(result.IsSuccess); Assert.StartsWith("telewave-debug-one-", result.Value.FileName); Assert.Equal("/debug/dump.zip", result.Value.SavedTo); var files = Unpack(result.Value.Content); // Дамп отвечает на два разных вопроса: «что построилось» и «из чего было строить». Assert.Contains("README.md", files.Keys); Assert.Contains("summary.json", files.Keys); Assert.Contains("drift.json", files.Keys); Assert.Contains("effective-grid.json", files.Keys); Assert.Contains("plan-preview.json", files.Keys); Assert.Contains("library-shows.json", files.Keys); Assert.Contains("library-interstitials.json", files.Keys); Assert.Contains("groups-composition.json", files.Keys); Assert.Contains("issues.json", files.Keys); } [Fact] public async Task Export_IssuesListEverythingThatKeepsShowsOffAir() { var fixture = await SeedAsync(); var store = Substitute.For(); await using var db = fixture.New(); var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None); var issues = JsonDocument.Parse(Unpack(result.Value.Content)["issues.json"]).RootElement; var failed = Assert.Single(issues.GetProperty("failedEpisodes").EnumerateArray()); Assert.Equal("broken.mkv", failed.GetProperty("file").GetString()); Assert.Equal("Таймаут ffmpeg", failed.GetProperty("errorMessage").GetString()); // Серия есть, ассета нет вовсе — удаление в обход админки: в эфир не идёт и молчит. var orphan = Assert.Single(issues.GetProperty("orphanEpisodes").EnumerateArray()); Assert.Equal("Симпсоны", orphan.GetProperty("show").GetString()); var empty = Assert.Single(issues.GetProperty("emptyGroups").EnumerateArray()); Assert.Equal("Пустая", empty.GetProperty("name").GetString()); // Кэш статистики группы никто не пересчитывал — цифры в её карточке врут, и это видно. var stale = Assert.Single(issues.GetProperty("staleGroupStats").EnumerateArray()); Assert.Equal("Мультсериалы", stale.GetProperty("name").GetString()); Assert.Equal(0, stale.GetProperty("cachedUnits").GetInt32()); Assert.Equal(3, stale.GetProperty("actualUnits").GetInt32()); // Играбельных меньше, чем заведено: одна серия упала, ещё одна осталась без файла. Assert.Equal(1, stale.GetProperty("playableUnits").GetInt32()); } [Fact] public async Task Export_ShowsPlayableUnitsBesideDeclaredOnes() { var fixture = await SeedAsync(); var store = Substitute.For(); await using var db = fixture.New(); var result = await Handler(db, store) .Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None); var groups = JsonDocument .Parse(Unpack(result.Value.Content)["groups-composition.json"]) .RootElement; var content = groups .EnumerateArray() .Single(g => g.GetProperty("name").GetString() == "Мультсериалы"); var element = Assert.Single(content.GetProperty("composition").EnumerateArray()); Assert.Equal("Симпсоны", element.GetProperty("name").GetString()); Assert.Equal(3, element.GetProperty("unitCount").GetInt32()); // Планировщик видит только готовые серии — расхождение с unitCount и есть ответ // на «почему в эфир пошло меньше, чем заведено». Assert.Equal(1, element.GetProperty("playableUnits").GetInt32()); } [Fact] public async Task Export_WithoutChannelOrTemplate_Fails() { var fixture = await SeedAsync(); var store = Substitute.For(); await using var db = fixture.New(); var missing = await Handler(db, store) .Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None); Assert.False(missing.IsSuccess); var channel = db.Channels.Single(); channel.SetTemplate(null); await db.SaveChangesAsync(CancellationToken.None); // Канал без шаблона собирать нечего: дамп строится вокруг сетки. var untemplated = await Handler(db, store) .Handle(new ExportChannelDebugCommand(channel.Id), CancellationToken.None); Assert.False(untemplated.IsSuccess); } private static ExportChannelDebugCommandHandler Handler( AppDbContext db, IDebugExportStore store ) { var expander = new GroupExpander(db, GroupServices.Dynamic(db)); var generator = new GridScheduleGenerator( db, expander, new BumperResolver(db, Substitute.For(), new FirstAlways()), new PostCheckRunner(db), new FirstAlways(), 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 async Task SeedAsync() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var show = Show.Create("Симпсоны", ShowKind.Series); var ready = ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)); var broken = MediaAsset.Register("broken.mkv", ".mkv", MediaSource.Upload); broken.MarkProcessing(); broken.MarkFailed("Таймаут ffmpeg"); show.AddEpisode(ready.Id); show.AddEpisode(broken.Id); show.AddEpisode(Guid.NewGuid()); var spot = Show.Create("Ролик", ShowKind.Interstitial); var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30)); spot.AddEpisode(spotAsset.Id); var content = Group.Create("Мультсериалы"); content.AddElement(GroupElementKind.Show, show.Id); var empty = Group.Create("Пустая"); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, Daypart.Day, false, 30, null); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, content.Id, new SlotStrategy(SlotStrategyType.Sequential).ToJson(), null, SlotBlockMode.Count, 1, OverflowPolicy.ContinueNext, null ) ); channel.SetTemplate(template.Id); await using var seed = fixture.New(); seed.Channels.Add(channel); seed.Shows.AddRange(show, spot); seed.MediaAssets.AddRange(ready, broken, spotAsset); seed.Groups.AddRange(content, empty); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return fixture; } 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 Dictionary Unpack(byte[] content) { using var buffer = new MemoryStream(content); using var archive = new ZipArchive(buffer, ZipArchiveMode.Read); return archive.Entries.ToDictionary( entry => entry.FullName, entry => { using var reader = new StreamReader(entry.Open(), Encoding.UTF8); return reader.ReadToEnd(); } ); } }