From 926a20020fc39d3ebd73f3c21738cc9dd4adebd4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 30 Jul 2026 12:48:51 +0300 Subject: [PATCH] Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling 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. --- .../DependencyInjection.cs | 1 + .../Programming/Groups/InterstitialGroups.cs | 90 +++++++++++ .../Templates/Layers/LayerCommandHandlers.cs | 23 ++- .../Programming/Templates/SlotWriter.cs | 8 +- .../Programming/Templates/TemplateErrors.cs | 12 ++ .../Transfer/BuildGridPromptQueryHandler.cs | 25 ++- .../Templates/Transfer/GridPromptText.cs | 5 +- .../Transfer/ImportGridCommandHandler.cs | 22 ++- .../Programming/GenerateGridTests.cs | 6 +- .../Programming/GridTransferTests.cs | 10 +- .../InterstitialGroupGuardTests.cs | 152 ++++++++++++++++++ .../Programming/TemplateEditingTests.cs | 35 ++-- .../Programming/TransferGuardsTests.cs | 5 +- .../Support/GroupServices.cs | 6 + docs/tv-scheduler-architecture.md | 9 ++ 15 files changed, 370 insertions(+), 39 deletions(-) create mode 100644 backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 06bdd99..0b80b6b 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -55,6 +55,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs b/backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs new file mode 100644 index 0000000..c46b847 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Отличает группу роликов от группы контента. +/// +/// Ролик — то же шоу с одной «серией» (см. 6.7), поэтому «Реклама» технически неотличима от +/// «Сериалов»: те же позиции, тот же разворот, то же остывание. В эфире разница огромная — слот +/// по роликам выдаёт полосу рекламы, подписанную как программа, а аварийная группа из роликов +/// превращает в рекламу каждую паузу. Оба места спрашивают одно и то же, поэтому признак живёт +/// здесь, а не в проверке каждого из них. +/// +/// Признак — по позициям, а не по единицам: группа роликов состоит из роликов, и сотня коротких +/// единиц против одного длинного сериала здесь ничего не решает. +/// +public sealed class InterstitialGroups(IAppDbContext dbContext) +{ + /// + /// Собрана ли группа из роликов. Группа, которой нет, роликовой не считается: её отсутствие — + /// отдельная ошибка, и подменять её этой значило бы врать в сообщении. + /// + public async Task IsInterstitialAsync(Guid groupId, CancellationToken cancellationToken) + { + // Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит + // слоты в том же прогоне — так же, как это делает проверка «группа существует». + var group = + dbContext.Groups.Local.FirstOrDefault(g => g.Id == groupId) + ?? await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken); + if (group is null) + return false; + + // Правило без указанного типа означает «кино и сериалы»: ролики в динамический состав + // сами не попадают (см. GroupFilterMatcher), и разбирать позиции незачем. + if (group.Mode == GroupMode.Dynamic) + return GroupFilter.FromJson(group.FilterJson) is { ShowKinds: { Count: > 0 } kinds } + && kinds.All(kind => kind == ShowKind.Interstitial); + + var items = group.Items.Where(i => i.Role != GroupItemRole.Excluded).ToList(); + if (items.Count == 0) + return false; + + var showIds = items + .Where(i => i.ElementKind == GroupElementKind.Show) + .Select(i => i.ElementId) + .ToList(); + var collectionIds = items + .Where(i => i.ElementKind == GroupElementKind.Collection) + .Select(i => i.ElementId) + .ToList(); + + // Коллекции разворачиваются, а не пропускаются: рекламный блок собирают и коллекцией — + // так у него получается фиксированный порядок роликов. + var parts = + collectionIds.Count == 0 + ? [] + : await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .Select(i => new { i.CollectionId, i.ShowId }) + .ToListAsync(cancellationToken); + + var knownIds = showIds.Concat(parts.Select(p => p.ShowId)).Distinct().ToList(); + var interstitialIds = await dbContext + .Shows.AsNoTracking() + .Where(s => knownIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + var rollers = interstitialIds.ToHashSet(); + + var byCollection = parts.ToLookup(p => p.CollectionId, p => p.ShowId); + var interstitialItems = + showIds.Count(rollers.Contains) + // Коллекция считается роликовой, только если из роликов собрана целиком: одна серия + // внутри франшизы делает её обычным контентом. + + collectionIds.Count(id => + byCollection[id].Any() && byCollection[id].All(rollers.Contains) + ); + + // Ровно половина уже считается роликовой: смешанная группа в слоте ведёт себя так же плохо, + // а собрать её осмысленно нельзя — это либо недоразумение, либо забытая позиция. + return interstitialItems * 2 >= items.Count; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs index 3bcc63f..46824cc 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates.Layers; @@ -82,8 +83,10 @@ public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext) } } -public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) - : ICommandHandler +public sealed class UpdateTemplateCommandHandler( + IAppDbContext dbContext, + InterstitialGroups interstitials +) : ICommandHandler { public async Task Handle( UpdateTemplateCommand command, @@ -97,11 +100,17 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) if (template is null) return Result.Failure(TemplateErrors.NotFound); - if ( - command.FallbackGroupId is { } groupId - && !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken) - ) - return Result.Failure(TemplateErrors.GroupNotFound); + if (command.FallbackGroupId is { } groupId) + { + if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) + return Result.Failure(TemplateErrors.GroupNotFound); + + // Аварийная группа закрывает каждую паузу: остаток слота, добор до якоря, пустой повтор. + // Из роликов она превращает всё это в рекламу — ровно то, что видно в расписании + // строками «аварийный запас» подряд. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + return Result.Failure(TemplateErrors.InterstitialFallbackGroup); + } template.Rename(command.Name); template.SetFallbackGroup(command.FallbackGroupId); diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs index 333574f..07ab2b0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates; @@ -9,7 +10,7 @@ namespace TeleWave.Application.Programming.Templates; /// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что /// им нужны соседние слоты и справочник групп. /// -public sealed class SlotWriter(IAppDbContext dbContext) +public sealed class SlotWriter(IAppDbContext dbContext, InterstitialGroups interstitials) { /// /// Проверяет вход и применяет его к слоту. = null — проверка перед @@ -45,6 +46,11 @@ public sealed class SlotWriter(IAppDbContext dbContext) || await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken); if (!known) return Result.Failure(TemplateErrors.GroupNotFound); + + // Ролики в слоте — не вкусовщина, а поломка эфира: планировщик поставит их подряд + // как программы, и зритель получит полосу рекламы вместо передачи. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + return Result.Failure(TemplateErrors.InterstitialGroupInSlot); } if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null) diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index ebf0b13..5839d47 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -39,6 +39,18 @@ public static class TemplateErrors "Группа не найдена." ); + public static readonly Error InterstitialGroupInSlot = Error.Validation( + "Templates.InterstitialGroupInSlot", + "В слоте группа роликов — в эфир уйдёт полоса рекламы, подписанная как программа. " + + "Ролики ставятся врезками стыка, а не слотом." + ); + + public static readonly Error InterstitialFallbackGroup = Error.Validation( + "Templates.InterstitialFallbackGroup", + "Аварийной группой выбрана группа роликов — тогда каждая пауза в эфире станет рекламой. " + + "Выберите группу контента или снимите её вовсе." + ); + public static readonly Error JunctionNotFound = Error.NotFound( "Templates.JunctionNotFound", "Шаблон стыка не найден." diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs index c59229c..51b400c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs @@ -131,7 +131,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa Culture, $"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | " + $"{(int)Math.Round(group.AverageUnitMinutes)} мин | " - + $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |" + + $"{AudienceName(group.Strictest)} | {genre ?? "—"} |" ); } } @@ -164,7 +164,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa Culture, $"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | " + $"{show.Units} | {show.AverageMinutes} мин | " - + $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |" + + $"{AudienceName(show.Audience)} | {genre ?? "—"} |" ); } @@ -186,6 +186,22 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names)); } + /// + /// Рейтинг ровно так, как он пишется в файле обмена («PG-13», не «Pg13»). Модель переписывает + /// в ответ то, что увидела в таблице, а формат читает написание с провода: разойдись эти два + /// написания — и правило детского времени из ответа не разберётся вовсе. + /// + private static string AudienceName(ShowAudience? audience) => + audience switch + { + ShowAudience.G => "G", + ShowAudience.Pg => "PG", + ShowAudience.Pg13 => "PG-13", + ShowAudience.R => "R", + ShowAudience.Nc17 => "NC-17", + _ => "—", + }; + /// Тип контента словами: «сериал», «полный метр» — так его читает модель. private static string KindName(ShowKind kind) => kind switch @@ -198,11 +214,16 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa /// /// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла /// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент. + /// + /// Ролики в список не идут: их бывают сотни, они съедают потолок перечисления, а главное — + /// поимённый список рекламы читается моделью как материал для слотов, и она честно собирает + /// из него группу. В эфире это полоса рекламы, подписанная как программа. /// private async Task> LoadShowsAsync(CancellationToken cancellationToken) { var shows = await dbContext .Shows.AsNoTracking() + .Where(s => s.Kind != ShowKind.Interstitial) .Include(s => s.Episodes) .ToListAsync(cancellationToken); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs index d6c398b..c5264e0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs @@ -29,8 +29,9 @@ public static class GridPromptText - Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет. - Стыки и заставки придумывать нельзя — только имена из списков ниже. - - Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя: - в эфир уйдёт полоса роликов, подписанная как программа. + - Группы типа «ролик» — это реклама и джинглы для стыков. Ни в слот, ни в fallbackGroup их + ставить нельзя: в эфир уйдёт полоса роликов, подписанная как программа. Рекламы в списке + библиотеки нет по той же причине — собирать группы из неё не нужно. - Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»). - weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs index 52e35bc..e022f1e 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -22,7 +22,8 @@ namespace TeleWave.Application.Programming.Templates.Transfer; public sealed class ImportGridCommandHandler( IAppDbContext dbContext, SlotWriter writer, - GroupStatsService stats + GroupStatsService stats, + InterstitialGroups interstitials ) : ICommandHandler> { public async Task> Handle( @@ -74,7 +75,7 @@ public sealed class ImportGridCommandHandler( skipped += dropped; } - Apply(template, command.Config, groups, junctions, warnings); + await ApplyAsync(template, command.Config, groups, junctions, warnings, cancellationToken); template.MarkChanged(); return Result.Success( @@ -303,12 +304,13 @@ public sealed class ImportGridCommandHandler( } /// Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале. - private static void Apply( + private async Task ApplyAsync( ScheduleTemplate template, GridConfig config, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, - List warnings + List warnings, + CancellationToken cancellationToken ) { if (config.Rules is { } rules) @@ -318,7 +320,17 @@ public sealed class ImportGridCommandHandler( config.FallbackGroup is { Length: > 0 } fallback && Resolve(fallback, groups, "Группа", warnings) is { } groupId ) - template.SetFallbackGroup(groupId); + { + // Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием, + // а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + warnings.Add( + $"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: " + + "паузы эфира стали бы рекламой." + ); + else + template.SetFallbackGroup(groupId); + } if ( config.DefaultJunction is { Length: > 0 } junction diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs index 499405a..66d4b44 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs @@ -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, diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs index 7d7627a..5937ecf 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs @@ -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; diff --git a/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs new file mode 100644 index 0000000..6a5c089 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs @@ -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; + +/// +/// Ролики в сетке. Технически это те же шоу, поэтому группа рекламы неотличима от группы сериалов +/// по типам — и оба места, куда её можно поставить (слот и аварийная группа), обязаны сказать «нет»: +/// в эфире это полоса рекламы, подписанная как программа. +/// +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 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 SeedAsync() + { + var fixture = new TestDb(); + var assets = new List(); + var shows = new List(); + + 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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs index 0611b0d..fc425be 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs @@ -87,7 +87,7 @@ public class TemplateEditingTests private static async Task 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 ); diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs index 181d78b..fe52f86 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs @@ -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); diff --git a/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs index 9f10191..7696235 100644 --- a/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs +++ b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs @@ -1,5 +1,6 @@ using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Tests.Support; @@ -14,4 +15,9 @@ internal static class GroupServices public static GroupStatsService Stats(IAppDbContext db) => new(db, new GroupElementResolver(db), Dynamic(db)); + + /// Писатель слотов: ему нужен ещё и признак «группа роликов» — их в слот не пускают. + public static InterstitialGroups Interstitials(IAppDbContext db) => new(db); + + public static SlotWriter Slots(IAppDbContext db) => new(db, Interstitials(db)); } diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 6bf1ef1..7874819 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -541,6 +541,15 @@ JunctionElement что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм, статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу. +Обратная сторона этой экономии: группа роликов неотличима от группы сериалов — те же позиции, тот же +разворот, — и её можно поставить туда, где она означает поломку. **Ролики не идут ни в слот, ни +в аварийную группу**, и это проверяется на всех путях: ручная правка слота и правил канала отвечают +ошибкой, импорт файла — замечанием (одно поле не должно валить всю сетку), автосборка и запрос к ИИ +такую группу просто не предлагают. Иначе выходит полоса рекламы, подписанная в программе как +передача: планировщик честно ставит «единицы» подряд, а зритель видит сорок минут роликов. +Признак — по позициям, а не по единицам: сотня коротких роликов против одного длинного сериала +ничего не решает, а вот «половина позиций — ролики» решает. + **Рекламный блок — это коллекция.** Реклама здесь не служебный элемент, а половина обаяния: её помнят лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному — и это получается само, без единой строчки специального кода: коллекция из шести `Interstitial`-шоу