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