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.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
///
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
///
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
///
/// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое
/// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии.
///
[SkippableFact]
public async Task Generate_Rebuild_RewindsCursorToWhatActuallyAired()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
// Серий заведомо больше, чем влезет в горизонт: иначе сериал успеет пойти по кругу,
// и «продолжает с той же серии» проверять станет нечем.
var world = await SeedAsync(seedDb, episodes: 100);
// Первый прогон строит сутки вперёд от момента двухчасовой давности.
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var aired = beforeDb
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc < Now
)
.OrderByDescending(e => e.StartsAtUtc)
.First();
var beforeRebuild = beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId);
// Курсор ушёл за эфир: он прошёл весь построенный хвост, а не только вышедшее.
Assert.True(beforeRebuild.NextUnitIndex > aired.EpisodeIndex + 1);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var firstAfterRebuild = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= Now
)
.OrderBy(e => e.StartsAtUtc)
.First();
// Пересобранный эфир продолжает ровно с той серии, что шла следующей по отыгранному.
Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex);
}
///
/// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз
/// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без
/// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал
/// по только что снесённому хвосту.
///
[SkippableFact]
public async Task Generate_Rebuild_StartsOver_WhenNothingHasAiredYet()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 100);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var beforeDb = fixture.CreateContext();
// Ничего не отыграно: вся построенная лента — будущее.
Assert.Empty(
beforeDb.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.StartsAtUtc < Now
)
);
Assert.True(beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId).NextUnitIndex > 0);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var firstProgram = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.OrderBy(e => e.StartsAtUtc)
.First();
Assert.Equal(0, firstProgram.EpisodeIndex);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
///
/// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14,
/// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил
/// вторую полнометражку подряд: слот-то по времени ещё не истёк.
///
[SkippableFact]
public async Task Generate_Rebuild_DoesNotReopenSlotThatAlreadyAired()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedFilmsAsync(seedDb);
await using (var first = fixture.CreateContext())
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var read = fixture.CreateContext();
var film = read
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.SlotId == world.SlotId
&& e.Kind == ScheduleEntryKind.Program
)
.OrderBy(e => e.StartsAtUtc)
.First();
var during = film.StartsAtUtc.AddMinutes(30);
await using (var rebuild = fixture.CreateContext())
await Generator(rebuild).GenerateAsync(world.ChannelId, during, true, default);
await using var verify = fixture.CreateContext();
// Второго кино из того же слота быть не должно; следующее окно этого слота — только завтра.
var extra = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.SlotId == world.SlotId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= during
&& e.StartsAtUtc < film.StartsAtUtc.AddHours(4)
)
.ToList();
Assert.Empty(extra);
// Само кино при этом не тронуто: прошлое и идущая запись неприкосновенны.
Assert.NotNull(verify.ScheduleEntries.FirstOrDefault(e => e.Id == film.Id));
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(
db,
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
),
new BumperResolver(db, Substitute.For(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
///
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. кладёт в группу коллекцию, а не шоу.
///
private static async Task SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
///
/// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком
/// () — на этом и ловится повторное открытие слота.
///
private static async Task SeedFilmsAsync(AppDbContext db)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Полнометражки {suffix}");
for (var i = 0; i < 3; i++)
{
var film = Show.Create($"Фильм {i} {suffix}", ShowKind.Single);
film.AddEpisode(ReadyAsset(db, $"film{i}-{suffix}.mkv", TimeSpan.FromMinutes(139)).Id);
db.Shows.Add(film);
group.AddElement(GroupElementKind.Show, film.Id);
}
db.Groups.Add(group);
// Филлер подлиннее: паузу до вечера он закрывает по кругу, и минутный дал бы сотни записей.
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Кино в 21:00", new TimeOnly(21, 0), 135);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
1,
OverflowPolicy.ExtendSlot
)
);
channel.SetTemplate(template.Id);
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, Guid.Empty, null);
}
/// Ассет, доведённый до Ready: в эфир попадают только такие.
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}"
)
);
db.MediaAssets.Add(asset);
return asset;
}
}