Implement asynchronous bumper rendering and add integration tests: refactor BumperAsset to support async rendering, introduce BumperRenderBackgroundService for processing, and create TeleWave.Integration.Tests with Testcontainers-Postgres for comprehensive testing of scheduling and bumper generation scenarios. Update documentation and ensure tests skip if Docker is not available.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
[Collection("postgres")]
|
||||
public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private const int Seg = 2;
|
||||
|
||||
private static MediaAsset ReadyAsset(string name, int seconds)
|
||||
{
|
||||
var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||
a.MarkReady(
|
||||
TimeSpan.FromSeconds(seconds),
|
||||
Seg,
|
||||
seconds / Seg,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{a.Id}"
|
||||
);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static ScheduleGenerator NewGenerator(
|
||||
AppDbContext db,
|
||||
IRandomSource random,
|
||||
IBumperRenderQueue queue
|
||||
)
|
||||
{
|
||||
var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg });
|
||||
var resolver = new ScheduleBumperResolver(
|
||||
db,
|
||||
queue,
|
||||
Options.Create(new BumperOptions()),
|
||||
streaming
|
||||
);
|
||||
return new ScheduleGenerator(
|
||||
db,
|
||||
random,
|
||||
resolver,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }),
|
||||
streaming
|
||||
);
|
||||
}
|
||||
|
||||
private sealed class RecordingQueue : IBumperRenderQueue
|
||||
{
|
||||
public List<Guid> Enqueued { get; } = [];
|
||||
|
||||
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
|
||||
|
||||
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_MaterializesProgramSchedule()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show A", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var a2 = ReadyAsset("a2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
int added;
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.True(added > 0);
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(entries);
|
||||
Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program);
|
||||
Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
// Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку;
|
||||
// её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера.
|
||||
var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now);
|
||||
channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null);
|
||||
var showA = Show.Create("A", ShowKind.Series);
|
||||
var showB = Show.Create("B", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var b1 = ReadyAsset("b1.mkv", 1200);
|
||||
showA.AddEpisode(a1.Id);
|
||||
showB.AddEpisode(b1.Id);
|
||||
channel.AddShow(showA.Id, 1, BlockMode.Count, 1);
|
||||
channel.AddShow(showB.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.AddRange(showA, showB);
|
||||
seed.MediaAssets.AddRange(a1, b1);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var queue = new RecordingQueue();
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
// Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B».
|
||||
await NewGenerator(db, new SequenceRandom(0, 1), queue)
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var bumperCache = await verify
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => b.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(bumperCache);
|
||||
|
||||
var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList();
|
||||
var bumperAssets = await verify
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => bumperAssetIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
Assert.All(
|
||||
bumperAssets,
|
||||
a =>
|
||||
{
|
||||
Assert.Equal(MediaSource.Generated, a.Source);
|
||||
Assert.Equal(MediaAssetStatus.Pending, a.Status);
|
||||
}
|
||||
);
|
||||
Assert.NotEmpty(queue.Enqueued);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show", ShowKind.Series);
|
||||
var a1 = ReadyAsset("r1.mkv", 1200);
|
||||
var a2 = ReadyAsset("r2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать,
|
||||
// иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись.
|
||||
await using var db1 = fixture.CreateContext();
|
||||
await using var db2 = fixture.CreateContext();
|
||||
var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
await Task.WhenAll(g1, g2);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(entries);
|
||||
for (var i = 1; i < entries.Count; i++)
|
||||
Assert.True(
|
||||
entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc,
|
||||
$"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user