Files
TeleWave/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs
T
Leonid Pershin 7a164dbd90
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m22s
ci / sonar (push) Successful in 3m41s
Enhance channel settings to include icon image support for IPTV
Updated the Channel and related DTOs to incorporate an optional IconImageId property, allowing for the specification of a channel icon for external IPTV players. Modified the UpdateChannelSettings command and its handler to accommodate this new property. Adjusted frontend components to manage the icon image, including selection and display functionality. Updated localization files to support new UI elements related to the IPTV icon. This enhancement improves the user experience by providing a visual representation of channels in IPTV applications.
2026-08-02 17:07:24 +03:00

487 lines
22 KiB
C#

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;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[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);
}
/// <summary>
/// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое
/// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии.
/// </summary>
[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);
}
/// <summary>
/// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз
/// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без
/// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал
/// по только что снесённому хвосту.
/// </summary>
[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)
);
}
/// <summary>
/// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14,
/// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил
/// вторую полнометражку подряд: слот-то по времени ещё не истёк.
/// </summary>
[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<IBumperRenderQueue>(), 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
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> 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, null);
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);
}
/// <summary>
/// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком
/// (<see cref="OverflowPolicy.ExtendSlot"/>) — на этом и ловится повторное открытие слота.
/// </summary>
private static async Task<World> 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, null);
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);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
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;
}
}