208 lines
8.1 KiB
C#
208 lines
8.1 KiB
C#
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}]"
|
|
);
|
|
}
|
|
}
|