Refactor LibraryDebugCollector and TelegramApi for improved data handling and clarity
Updated the LibraryDebugCollector to introduce a new CompositionEntry record for better organization of group composition data. Refactored the composition logic to utilize this new structure, enhancing readability and maintainability. In the TelegramApi, replaced inline allowed_updates array with a static readonly field to reduce redundancy and improve clarity in API call configurations. Adjusted related tests to ensure proper handling of message assertions, enhancing overall test reliability.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Отладочный дамп канала. Его собирают, когда эфир выглядит не так, как ждали, — значит он сам
|
||||
/// обязан работать при любой библиотеке, включая сломанную: упавший ассет, серия без файла,
|
||||
/// пустая группа. Ровно это здесь и проверяется, вместе с самодиагностикой в issues.json.
|
||||
/// </summary>
|
||||
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<IDebugExportStore>();
|
||||
store
|
||||
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.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<IDebugExportStore>();
|
||||
|
||||
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<IDebugExportStore>();
|
||||
|
||||
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<IDebugExportStore>();
|
||||
|
||||
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<IBumperRenderQueue>(), 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Канал со сеткой и намеренно битой библиотекой: из трёх серий играбельна одна, вторая
|
||||
/// упала на нарезке, у третьей нет файла. Плюс пустая группа — дыра в сетке.
|
||||
/// </summary>
|
||||
private static async Task<TestDb> 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<string, string> 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user