Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
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 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(
|
||||
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(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
using TeleWave.Application.Programming.Templates.Validate;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
|
||||
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new CopyTemplateCommandHandler(db).Handle(
|
||||
new CopyTemplateCommand(sourceId, targetId),
|
||||
default
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Assert.Equal(1, result.Value.Layers);
|
||||
Assert.Equal(1, result.Value.Slots);
|
||||
Assert.Equal(1, result.Value.Junctions);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var target = verify.Channels.Single(c => c.Id == targetId);
|
||||
var copied = verify
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.Single(t => t.ChannelId == targetId);
|
||||
|
||||
// У приёмника ровно один шаблон, и канал смотрит именно на него.
|
||||
Assert.Equal(copied.Id, target.TemplateId);
|
||||
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
|
||||
|
||||
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
|
||||
// Группы общие — ссылка переносится как есть, а не копией группы.
|
||||
Assert.Equal(groupId, slot.GroupId);
|
||||
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
|
||||
|
||||
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
|
||||
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
|
||||
Assert.Equal(junction.Id, slot.JunctionAfterId);
|
||||
Assert.NotEqual(
|
||||
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
|
||||
slot.JunctionAfterId
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Copy_ToItself_IsRejectedByValidator()
|
||||
{
|
||||
var validator = new CopyTemplateCommandValidator();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
|
||||
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Validate_ReportsEmptyGroupAndGridGap()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
new ValidateTemplateQuery(sourceId),
|
||||
default
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
||||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
|
||||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
|
||||
}
|
||||
|
||||
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
|
||||
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(AppDbContext db)
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var group = Group.Create($"Группа {suffix}");
|
||||
db.Groups.Add(group);
|
||||
|
||||
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
|
||||
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
|
||||
|
||||
var junction = JunctionTemplate.Create(source.Id, "Прайм");
|
||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
|
||||
db.JunctionTemplates.Add(junction);
|
||||
|
||||
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
|
||||
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||
slot.UpdateContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
junctionAfterId: junction.Id
|
||||
);
|
||||
sourceTemplate.SetDefaultJunction(junction.Id);
|
||||
source.SetTemplate(sourceTemplate.Id);
|
||||
|
||||
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
|
||||
target.SetTemplate(targetTemplate.Id);
|
||||
|
||||
db.Channels.AddRange(source, target);
|
||||
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return (source.Id, target.Id, group.Id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user