Files
TeleWave/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs
T
Leonid Pershin 7163a0b937
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Implement media retry functionality and enhance error handling
Added endpoints for retrying failed media processing, allowing users to requeue media assets that encountered errors. Introduced error messages for scenarios where a media asset cannot be retried due to its status. Updated the MaintenanceBackgroundService to remove orphaned episodes and recompute group statistics, ensuring data integrity. Enhanced the frontend to support retry actions, including bulk retry options for failed media. Updated localization strings to reflect new features in both English and Russian.
2026-07-31 03:58:01 +03:00

331 lines
14 KiB
C#

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 })
),
new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)),
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",
"bumpers.json",
"channel.json",
"drift.json",
"effective-grid.json",
"groups-composition.json",
"groups.json",
"issues.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<IDebugExportStore>())
.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());
// «Заведено» и «может выйти» — разные числа, и в дампе они стоят рядом: их расхождение
// и есть ответ на «почему в эфир пошло меньше, чем в библиотеке».
Assert.Equal(2, composition.GetProperty("playableUnits").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());
// На здоровой библиотеке список проблем пуст — иначе он бы кричал волком при каждом дампе.
using var issues = JsonDocument.Parse(files["issues.json"]);
Assert.Empty(issues.RootElement.GetProperty("failedEpisodes").EnumerateArray());
Assert.Empty(issues.RootElement.GetProperty("orphanEpisodes").EnumerateArray());
Assert.Empty(
issues.RootElement.GetProperty("showsWithoutPlayableEpisodes").EnumerateArray()
);
}
[Fact]
public async Task Export_MeasuresDriftAgainstTheGrid()
{
// «Слот уехал» — первый вопрос к любой сетке, и сводить целевые времена с фактическими
// вручную по двум файлам приходилось каждый раз.
var (fixture, channelId) = await SeedAsync();
var store = Substitute.For<IDebugExportStore>();
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<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);
}
}