Updated the GridScheduleGenerator to reset cursors for slots with no aired positions, ensuring proper state management during rebuilds. Introduced a new method, ResetUnairedCursorsAsync, to handle cursor resets effectively. Enhanced the handling of interstitial groups in the scheduling process, preventing them from being included in slots or fallback groups. Updated related tests to verify the correct behavior of these changes, ensuring robust scheduling logic and accurate group categorization.
391 lines
17 KiB
C#
391 lines
17 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)
|
|
);
|
|
}
|
|
|
|
// ── Обвязка ─────────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
}
|