Files
TeleWave/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs
T
Leonid Pershin 91af589547
ci / build-backend (push) Successful in 2m30s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 2m36s
ci / sonar (push) Failing after 33s
Implement dynamic group management features with exclusions and mode handling
Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
2026-07-27 05:03:09 +03:00

303 lines
13 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);
}
[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)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
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, bumpersEnabled: false, 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);
}
/// <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;
}
}