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.
This commit is contained in:
@@ -55,6 +55,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<GroupStatsService>();
|
||||
services.AddScoped<GroupSuggestionBuilder>();
|
||||
services.AddScoped<GroupMembershipCleaner>();
|
||||
services.AddScoped<InterstitialGroups>();
|
||||
services.AddScoped<SlotWriter>();
|
||||
services.AddScoped<GroupCatalog>();
|
||||
services.AddScoped<GridPlanner>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Отличает группу роликов от группы контента.
|
||||
///
|
||||
/// Ролик — то же шоу с одной «серией» (см. 6.7), поэтому «Реклама» технически неотличима от
|
||||
/// «Сериалов»: те же позиции, тот же разворот, то же остывание. В эфире разница огромная — слот
|
||||
/// по роликам выдаёт полосу рекламы, подписанную как программа, а аварийная группа из роликов
|
||||
/// превращает в рекламу каждую паузу. Оба места спрашивают одно и то же, поэтому признак живёт
|
||||
/// здесь, а не в проверке каждого из них.
|
||||
///
|
||||
/// Признак — по позициям, а не по единицам: группа роликов состоит из роликов, и сотня коротких
|
||||
/// единиц против одного длинного сериала здесь ничего не решает.
|
||||
/// </summary>
|
||||
public sealed class InterstitialGroups(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Собрана ли группа из роликов. Группа, которой нет, роликовой не считается: её отсутствие —
|
||||
/// отдельная ошибка, и подменять её этой значило бы врать в сообщении.
|
||||
/// </summary>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -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<UpdateTemplateCommand, Result>
|
||||
public sealed class UpdateTemplateCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
InterstitialGroups interstitials
|
||||
) : ICommandHandler<UpdateTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateTemplateCommand command,
|
||||
@@ -97,12 +100,18 @@ 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)
|
||||
)
|
||||
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);
|
||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||
|
||||
@@ -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;
|
||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||
/// им нужны соседние слоты и справочник групп.
|
||||
/// </summary>
|
||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
public sealed class SlotWriter(IAppDbContext dbContext, InterstitialGroups interstitials)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = 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)
|
||||
|
||||
@@ -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",
|
||||
"Шаблон стыка не найден."
|
||||
|
||||
+23
-2
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Рейтинг ровно так, как он пишется в файле обмена («PG-13», не «Pg13»). Модель переписывает
|
||||
/// в ответ то, что увидела в таблице, а формат читает написание с провода: разойдись эти два
|
||||
/// написания — и правило детского времени из ответа не разберётся вовсе.
|
||||
/// </summary>
|
||||
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",
|
||||
_ => "—",
|
||||
};
|
||||
|
||||
/// <summary>Тип контента словами: «сериал», «полный метр» — так его читает модель.</summary>
|
||||
private static string KindName(ShowKind kind) =>
|
||||
kind switch
|
||||
@@ -198,11 +214,16 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
/// <summary>
|
||||
/// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла
|
||||
/// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент.
|
||||
///
|
||||
/// Ролики в список не идут: их бывают сотни, они съедают потолок перечисления, а главное —
|
||||
/// поимённый список рекламы читается моделью как материал для слотов, и она честно собирает
|
||||
/// из него группу. В эфире это полоса рекламы, подписанная как программа.
|
||||
/// </summary>
|
||||
private async Task<List<ShowFact>> LoadShowsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Kind != ShowKind.Interstitial)
|
||||
.Include(s => s.Episodes)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ public static class GridPromptText
|
||||
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||
- Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя:
|
||||
в эфир уйдёт полоса роликов, подписанная как программа.
|
||||
- Группы типа «ролик» — это реклама и джинглы для стыков. Ни в слот, ни в fallbackGroup их
|
||||
ставить нельзя: в эфир уйдёт полоса роликов, подписанная как программа. Рекламы в списке
|
||||
библиотеки нет по той же причине — собирать группы из неё не нужно.
|
||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||
|
||||
+16
-4
@@ -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<ImportGridCommand, Result<GridImportResultDto>>
|
||||
{
|
||||
public async Task<Result<GridImportResultDto>> 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(
|
||||
}
|
||||
|
||||
/// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
|
||||
private static void Apply(
|
||||
private async Task ApplyAsync(
|
||||
ScheduleTemplate template,
|
||||
GridConfig config,
|
||||
IReadOnlyDictionary<string, Guid> groups,
|
||||
IReadOnlyDictionary<string, Guid> junctions,
|
||||
List<string> warnings
|
||||
List<string> 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
|
||||
)
|
||||
{
|
||||
// Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием,
|
||||
// а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя.
|
||||
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
|
||||
warnings.Add(
|
||||
$"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: "
|
||||
+ "паузы эфира стали бы рекламой."
|
||||
);
|
||||
else
|
||||
template.SetFallbackGroup(groupId);
|
||||
}
|
||||
|
||||
if (
|
||||
config.DefaultJunction is { Length: > 0 } junction
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
/// <summary>Писатель слотов: ему нужен ещё и признак «группа роликов» — их в слот не пускают.</summary>
|
||||
public static InterstitialGroups Interstitials(IAppDbContext db) => new(db);
|
||||
|
||||
public static SlotWriter Slots(IAppDbContext db) => new(db, Interstitials(db));
|
||||
}
|
||||
|
||||
@@ -541,6 +541,15 @@ JunctionElement
|
||||
что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм,
|
||||
статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу.
|
||||
|
||||
Обратная сторона этой экономии: группа роликов неотличима от группы сериалов — те же позиции, тот же
|
||||
разворот, — и её можно поставить туда, где она означает поломку. **Ролики не идут ни в слот, ни
|
||||
в аварийную группу**, и это проверяется на всех путях: ручная правка слота и правил канала отвечают
|
||||
ошибкой, импорт файла — замечанием (одно поле не должно валить всю сетку), автосборка и запрос к ИИ
|
||||
такую группу просто не предлагают. Иначе выходит полоса рекламы, подписанная в программе как
|
||||
передача: планировщик честно ставит «единицы» подряд, а зритель видит сорок минут роликов.
|
||||
Признак — по позициям, а не по единицам: сотня коротких роликов против одного длинного сериала
|
||||
ничего не решает, а вот «половина позиций — ролики» решает.
|
||||
|
||||
**Рекламный блок — это коллекция.** Реклама здесь не служебный элемент, а половина обаяния: её помнят
|
||||
лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному —
|
||||
и это получается само, без единой строчки специального кода: коллекция из шести `Interstitial`-шоу
|
||||
|
||||
Reference in New Issue
Block a user