Add debug export functionality for channel analysis
Implemented a new endpoint for exporting debug data related to channel scheduling, allowing users to download an archive containing channel settings, slot states, and trace information. Updated the frontend to include a button for triggering the export, along with necessary API adjustments for handling the download. Enhanced localization strings to support the new debug export feature in both English and Russian. Updated .gitignore to include debug export files while ensuring the directory structure is maintained for development.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Отладочный дамп канала. Проверяется не текст файлов, а то, ради чего он существует: архив
|
||||
/// собирается целиком, в нём есть все части входа планировщика, и по ним можно узнать конкретное
|
||||
/// шоу — дамп из одних идентификаторов бесполезен ровно тогда, когда его открывают.
|
||||
/// </summary>
|
||||
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<IBumperRenderQueue>(), 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 })
|
||||
),
|
||||
store
|
||||
);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Unpack(byte[] archive)
|
||||
{
|
||||
using var buffer = new MemoryStream(archive);
|
||||
using var zip = new ZipArchive(buffer, ZipArchiveMode.Read);
|
||||
|
||||
var files = new Dictionary<string, string>(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<IDebugExportStore>();
|
||||
store
|
||||
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.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",
|
||||
"channel.json",
|
||||
"effective-grid.json",
|
||||
"groups.json",
|
||||
"junctions.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_DryRunGoesIntoTheDump()
|
||||
{
|
||||
// Сухой прогон — половина ценности дампа: он показывает, что планировщик построил бы
|
||||
// сейчас, и без него по архиву не отличить «данные плохие» от «расчёт плохой».
|
||||
var (fixture, channelId) = await SeedAsync();
|
||||
// Каталог не настроен — хранилище так и отвечает: копии нет.
|
||||
var store = Substitute.For<IDebugExportStore>();
|
||||
store
|
||||
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.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<IDebugExportStore>())
|
||||
.Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user