Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 3m38s
ci / sonar (push) Successful in 3m38s

Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
This commit is contained in:
Leonid Pershin
2026-07-30 12:48:51 +03:00
parent 398b60facc
commit 926a20020f
15 changed files with 370 additions and 39 deletions
@@ -416,7 +416,7 @@ public class GenerateGridTests
var result = await new GenerateGridCommandHandler(
db,
Planner(db),
new SlotWriter(db)
GroupServices.Slots(db)
).Handle(
new GenerateGridCommand(
channelId,
@@ -657,7 +657,7 @@ public class GenerateGridTests
var result = await new GenerateGridCommandHandler(
db,
Planner(db),
new SlotWriter(db)
GroupServices.Slots(db)
).Handle(
new GenerateGridCommand(
channelId,
@@ -709,7 +709,7 @@ public class GenerateGridTests
var result = await new GenerateGridCommandHandler(
db,
Planner(db),
new SlotWriter(db)
GroupServices.Slots(db)
).Handle(
new GenerateGridCommand(
channelId,
@@ -111,10 +111,12 @@ public class GridTransferTests
)
{
await using var db = fixture.New();
var result = await new ImportGridCommandHandler(db, new SlotWriter(db), Stats(db)).Handle(
new ImportGridCommand(channelId, config, replace),
CancellationToken.None
);
var result = await new ImportGridCommandHandler(
db,
GroupServices.Slots(db),
Stats(db),
GroupServices.Interstitials(db)
).Handle(new ImportGridCommand(channelId, config, replace), CancellationToken.None);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return result.Value;
@@ -0,0 +1,152 @@
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Ролики в сетке. Технически это те же шоу, поэтому группа рекламы неотличима от группы сериалов
/// по типам — и оба места, куда её можно поставить (слот и аварийная группа), обязаны сказать «нет»:
/// в эфире это полоса рекламы, подписанная как программа.
/// </summary>
public class InterstitialGroupGuardTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private sealed record World(TestDb Db, Guid LayerId, Guid TemplateId, Guid Ads, Guid Content);
private static Show Ready(string name, ShowKind kind, List<MediaAsset> assets, int seconds)
{
var show = Show.Create(name, kind);
var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
TimeSpan.FromSeconds(seconds),
6,
1000,
1920,
1080,
"h264",
"aac",
$"assets/{name}"
)
);
assets.Add(asset);
show.AddEpisode(asset.Id);
return show;
}
private static async Task<World> SeedAsync()
{
var fixture = new TestDb();
var assets = new List<MediaAsset>();
var shows = new List<Show>();
var ads = Group.Create("Реклама");
for (var i = 0; i < 3; i++)
{
var clip = Ready($"Ролик {i}", ShowKind.Interstitial, assets, 20);
shows.Add(clip);
ads.AddElement(GroupElementKind.Show, clip.Id);
}
var series = Ready("Сериал", ShowKind.Series, assets, 1320);
shows.Add(series);
var content = Group.Create("Сериалы");
content.AddElement(GroupElementKind.Show, series.Id);
var channel = Channel.Create("Первый", "one", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Основная", 10);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.MediaAssets.AddRange(assets);
seed.Shows.AddRange(shows);
seed.Groups.AddRange([ads, content]);
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return new World(fixture, layer.Id, template.Id, ads.Id, content.Id);
}
private static SlotInput Input(Guid groupId) =>
new(
"Блок",
null,
new TimeOnly(20, 0),
60,
Daypart.Prime,
SlotKind.Content,
groupId,
null,
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
false,
30,
null,
null,
null
);
[Fact]
public async Task Slot_WithInterstitialGroup_IsRejected()
{
var world = await SeedAsync();
await using var db = world.Db.New();
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(world.LayerId, Input(world.Ads)),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(TemplateErrors.InterstitialGroupInSlot, result.Error);
}
[Fact]
public async Task Slot_WithContentGroup_IsAccepted()
{
// Обратная сторона запрета: обычная группа обязана проходить, иначе признак «ролики»
// ловил бы всё подряд.
var world = await SeedAsync();
await using var db = world.Db.New();
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(world.LayerId, Input(world.Content)),
CancellationToken.None
);
Assert.True(result.IsSuccess);
}
[Fact]
public async Task FallbackGroup_OfInterstitials_IsRejected()
{
// Аварийная группа закрывает каждую паузу эфира — из роликов она превращает в рекламу
// остаток каждого слота.
var world = await SeedAsync();
await using var db = world.Db.New();
var result = await new UpdateTemplateCommandHandler(
db,
GroupServices.Interstitials(db)
).Handle(
new UpdateTemplateCommand(world.TemplateId, "Сетка", world.Ads, null, null),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(TemplateErrors.InterstitialFallbackGroup, result.Error);
}
}
@@ -87,7 +87,7 @@ public class TemplateEditingTests
private static async Task<Guid> AddSlotAsync(Fixture f, SlotInput input)
{
await using var db = f.Db.New();
var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var created = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(f.LayerId, input),
CancellationToken.None
);
@@ -116,7 +116,7 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
@@ -151,7 +151,7 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: null)),
CancellationToken.None
);
@@ -165,7 +165,7 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
@@ -179,7 +179,7 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)),
CancellationToken.None
);
@@ -211,7 +211,7 @@ public class TemplateEditingTests
await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new CreateSlotCommand(
f.LayerId,
Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId)
@@ -228,7 +228,7 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new UpdateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
@@ -245,7 +245,7 @@ public class TemplateEditingTests
await using (var db = f.Db.New())
{
// Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой.
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
var result = await new UpdateSlotCommandHandler(GroupServices.Slots(db)).Handle(
new UpdateSlotCommand(
slotId,
Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId)
@@ -270,7 +270,7 @@ public class TemplateEditingTests
await using (var db = f.Db.New())
{
var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
var missing = await new DeleteSlotCommandHandler(GroupServices.Slots(db)).Handle(
new DeleteSlotCommand(Guid.NewGuid()),
CancellationToken.None
);
@@ -279,7 +279,7 @@ public class TemplateEditingTests
await using (var db = f.Db.New())
{
var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
var deleted = await new DeleteSlotCommandHandler(GroupServices.Slots(db)).Handle(
new DeleteSlotCommand(slotId),
CancellationToken.None
);
@@ -407,7 +407,10 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
var result = await new UpdateTemplateCommandHandler(
db,
GroupServices.Interstitials(db)
).Handle(
new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null),
CancellationToken.None
);
@@ -421,7 +424,10 @@ public class TemplateEditingTests
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
var result = await new UpdateTemplateCommandHandler(
db,
GroupServices.Interstitials(db)
).Handle(
new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null),
CancellationToken.None
);
@@ -447,7 +453,10 @@ public class TemplateEditingTests
await using (var db = f.Db.New())
{
var result = await new UpdateTemplateCommandHandler(db).Handle(
var result = await new UpdateTemplateCommandHandler(
db,
GroupServices.Interstitials(db)
).Handle(
new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules),
CancellationToken.None
);
@@ -63,8 +63,9 @@ public class TransferGuardsTests
var config = new GridConfig([new GridConfigLayer("Основная сетка", 10, [])]);
var result = await new ImportGridCommandHandler(
db,
new SlotWriter(db),
GroupServices.Stats(db)
GroupServices.Slots(db),
GroupServices.Stats(db),
GroupServices.Interstitials(db)
).Handle(new ImportGridCommand(Guid.NewGuid(), config, false), CancellationToken.None);
Assert.False(result.IsSuccess);