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();
}
);
}
}