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 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}]" ); } }