Implement template export/import endpoints and enhance grid generation logic
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / sonar (push) Successful in 4m40s

Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
This commit is contained in:
Leonid Pershin
2026-07-28 02:15:04 +03:00
parent 7bee84c548
commit f7e7b5f7c3
45 changed files with 2929 additions and 242 deletions
@@ -66,19 +66,28 @@ public class BumperPlaceholdersTests
{
var empty = new BumperContext("Канал", null, Moment);
// Ни следующей программы, ни номера канала: строка схлопывается, осиротевший разделитель уходит.
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty));
// Ни текущей, ни следующей программы: строка писалась ради них и исчезает целиком —
// «СЕЙЧАС» в кадре без названия ничего не значит.
Assert.Equal("", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty));
Assert.Equal("", BumperPlaceholders.Resolve("{next.title}", empty));
// А подставилось хоть что-то — строка остаётся, осиротевший хвост подчищается.
Assert.Equal("Канал", BumperPlaceholders.Resolve("{channel} {channel.number}", empty));
// А висящий предлог остаётся: отличить его от слова в чужом тексте нельзя.
Assert.Equal("ДАЛЕЕ В", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
// Строка, где не подставился ни один плейсхолдер, исчезает целиком: «ДАЛЕЕ В» без времени —
// это мусор в кадре, а не подпись.
Assert.Equal("", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
// А постоянный текст без плейсхолдеров остаётся всегда.
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС", empty));
}
[Fact]
public void Resolve_UnknownTokenBecomesEmpty()
public void Resolve_UnknownTokenLeavesNothing()
{
Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее {next.tittle}", Full()));
// Незнакомый плейсхолдер подставить нечем, а значит и строка пуста — как если бы данных
// не нашлось. Сохранить такую строку валидатор всё равно не даст.
Assert.Equal("", BumperPlaceholders.Resolve("Далее {next.tittle}", Full()));
Assert.Equal("", BumperPlaceholders.Resolve(" ", Full()));
// Постоянный текст без плейсхолдеров остаётся как есть.
Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее", Full()));
}
[Fact]
@@ -340,6 +340,74 @@ public class BumperResolverTests
);
}
[Fact]
public async Task Resolve_AnnouncesTonightsPrime()
{
var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}"));
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
// Канал в UTC: так время в анонсе читается без пересчёта в уме.
channel.UpdateTimeSettings(null, 0, Channel.DefaultDayStartTime);
var from = Show.Create("Симпсоны", ShowKind.Series);
var to = Show.Create("Гриффины", ShowKind.Series);
var feature = Show.Create("Терминатор", ShowKind.Single);
var grid = ScheduleTemplate.Create(channel.Id, "Сетка");
var day = grid.Background!.AddSlot("Дневной блок", new TimeOnly(14, 0), 240);
var prime = grid.Background!.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
prime.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, false, 30, null);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.BumperTemplates.Add(template);
seed.Shows.AddRange(from, to, feature);
seed.ScheduleTemplates.Add(grid);
await seed.SaveChangesAsync(CancellationToken.None);
}
var items = Lente(template.Id, from.Id, to.Id, day.Id);
// Вечером тех же вещательных суток стоит фильм в прайме — его и должен назвать анонс.
items.Add(
new PlannedItem(
Guid.NewGuid(),
T0.AddHours(2),
T0.AddHours(4),
feature.Id,
0,
prime.Id,
PlannedItemKind.Program
)
);
await using var db = fixture.New();
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
var line = Assert.Single(BumperRenderedText.FromJson(cache.RenderedLinesJson));
Assert.Equal("СЕГОДНЯ В 20:00 — Терминатор", line.Text);
}
[Fact]
public async Task Resolve_SkipsAnnouncementThatAlreadyStarted()
{
var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}"));
var (fixture, channel, from, to) = await SeedAsync(template);
// Прайма впереди нет — анонсировать нечего, и строка обязана схлопнуться, а не соврать.
await using var db = fixture.New();
var assets = await NewResolver(db)
.ResolveAsync(channel, Lente(template.Id, from.Id, to.Id), CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
Assert.Empty(BumperRenderedText.FromJson(cache.RenderedLinesJson));
}
[Fact]
public async Task Resolve_IgnoresReservationsWithoutTemplate()
{
@@ -107,7 +107,9 @@ public class GenerateGridTests
return (channel.Id, template);
}
private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) =>
private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => new(db, Catalog(db));
private static GroupCatalog Catalog(Common.Interfaces.IAppDbContext db) =>
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
private static async Task<GridPlan> PlanAsync(
@@ -149,10 +151,15 @@ public class GenerateGridTests
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
// Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
Assert.All(
plan.Slots.Where(s => s.Start < new TimeOnly(20, 0)),
plan.Slots.Where(s => s.Start < new TimeOnly(14, 0)),
slot => Assert.Null(slot.Weekday)
);
// Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно.
// Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся
// днём. У них дни проставлены явно.
Assert.All(
plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)),
slot => Assert.NotNull(slot.Weekday)
);
Assert.All(
plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
slot => Assert.NotNull(slot.Weekday)
@@ -163,6 +170,35 @@ public class GenerateGridTests
);
}
[Fact]
public async Task NeighbouringSlots_DoNotShareTheSameGroup()
{
// Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну
// и ту же встык — два блока подряд из одной группы читаются как один длинный.
var fixture = new TestDb();
var first = Playable("Первый сериал", ShowKind.Series, 60, 25);
var second = Playable("Второй сериал", ShowKind.Series, 60, 25);
var (channelId, _) = await SeedChannelAsync(
fixture,
[first.Show, second.Show],
[.. first.Assets, .. second.Assets],
[GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)]
);
var plan = await PlanAsync(fixture, channelId);
var byDay = plan
.Slots.Where(s => s.GroupId is not null)
.GroupBy(s => s.Weekday)
.Select(g => g.OrderBy(s => s.Start).ToList());
foreach (var day in byDay)
{
for (var i = 1; i < day.Count; i++)
Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId);
}
}
[Fact]
public async Task PrimeGetsFeature_AndMorningGetsSeries()
{
@@ -322,10 +358,117 @@ public class GenerateGridTests
}
[Fact]
public async Task SlotLength_FollowsContent_NotProfileConstant()
public async Task Rebuild_BuildsSeasonLayer_WithAnnualApplicability()
{
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии,
// а не отдать десятки минут фону.
var fixture = new TestDb();
var series = Playable("Сериал", ShowKind.Series, 40, 25);
var films = Films(8, 100);
var (channelId, _) = await SeedChannelAsync(
fixture,
[series.Show, .. films.Shows],
[.. series.Assets, .. films.Assets],
[
GroupOf("Сериалы", series.Show.Id),
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
]
);
await using (var db = fixture.New())
{
var result = await new GenerateGridCommandHandler(
db,
Planner(db),
new SlotWriter(db)
).Handle(
new GenerateGridCommand(
channelId,
GridProfileKind.Mixed,
GridGenerationMode.Rebuild
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var check = fixture.New();
var stored = await check
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
var season = GridProfiles.Get(GridProfileKind.Mixed).Season;
Assert.NotNull(season);
var layer = Assert.Single(stored.Layers, l => l.Name == season.Name);
// Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как
// обычное воскресенье.
var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority);
Assert.True(layer.Priority > otherPriority);
Assert.NotEmpty(layer.Slots);
// Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно.
var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson);
Assert.NotNull(applicability);
Assert.Equal([season.Range], applicability.AnnualRanges);
Assert.True(applicability.Covers(new DateOnly(2027, 1, 1)));
Assert.False(applicability.Covers(new DateOnly(2027, 3, 1)));
}
[Fact]
public async Task Season_DoesNotSpendTheWeeksBudget()
{
// Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать
// в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом.
var fixture = new TestDb();
var series = Playable("Сериал", ShowKind.Series, 40, 25);
var films = Films(8, 100);
var (channelId, _) = await SeedChannelAsync(
fixture,
[series.Show, .. films.Shows],
[.. series.Assets, .. films.Assets],
[
GroupOf("Сериалы", series.Show.Id),
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
]
);
var plan = await PlanAsync(
fixture,
channelId,
GridProfileKind.Mixed,
GridGenerationMode.Rebuild
);
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season);
Assert.All(
plan.Slots.Where(s => s.Layer == GridPlanLayer.Season),
s => Assert.Null(s.Weekday)
);
Assert.True(plan.CoveredMinutes <= plan.FreeMinutes);
}
[Fact]
public void Sitcoms_AirInPairs()
{
// Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как
// марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день.
var sitcom = GridProfiles.Get(GridProfileKind.Sitcom);
var blocks = sitcom
.Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0)
.ToList();
Assert.NotEmpty(blocks);
Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock));
}
[Fact]
public async Task SlotLength_FollowsContent_RoundedToTheGrid()
{
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки под серии, а не
// отдать десятки минут фону. Длина при этом округляется вверх до четверти часа —
// программа передач состоит из круглых времён, а разницу занимает рекламный шов.
var fixture = new TestDb();
var series = Playable("Сериал", ShowKind.Series, 60, 25);
var (channelId, _) = await SeedChannelAsync(
@@ -342,8 +485,12 @@ public class GenerateGridTests
morning,
slot =>
{
// Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут.
Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25);
var content = slot.BlockValue * 25;
// Слот не короче своего контента — иначе блок перелезет через объявленное время.
Assert.True(slot.DurationMinutes >= content);
// И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота.
Assert.True(slot.DurationMinutes - content < 15);
Assert.Equal(0, slot.DurationMinutes % 15);
Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
}
);
@@ -116,7 +116,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
new GridPlanner(
db,
new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
)
).Handle(
new PreviewGeneratedGridQuery(
channelId,
@@ -143,7 +146,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
new GridPlanner(
db,
new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
)
).Handle(
new PreviewGeneratedGridQuery(
Guid.NewGuid(),
@@ -0,0 +1,378 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.Transfer;
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 GridTransferTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static (Show Show, MediaAsset Asset) Playable(string name, int minutes)
{
var show = Show.Create(name, ShowKind.Series);
var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
TimeSpan.FromMinutes(minutes),
6,
minutes * 10,
1920,
1080,
"h264",
"aac",
$"assets/{name}"
)
);
show.AddEpisode(asset.Id);
return (show, asset);
}
/// <summary>Канал с одной группой, одним стыком и слоем «Основная сетка» из одного слота.</summary>
private static async Task<Guid> SeedAsync(TestDb fixture, bool withSlot = true)
{
var (show, asset) = Playable("Сериал", 25);
var group = Group.Create("Сериалы");
group.AddElement(GroupElementKind.Show, show.Id);
var junction = JunctionTemplate.Create("Рекламный шов");
var channel = Channel.Create("Первый", "first", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
channel.SetTemplate(template.Id);
template.SetFallbackGroup(group.Id);
template.SetDefaultJunction(junction.Id);
var layer = template.AddLayer("Основная сетка", 10);
if (withSlot)
{
var slot = layer.AddSlot("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime);
slot.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, true, 5, 15);
slot.UpdateContent(
new SlotContent(
"Вечерний блок",
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
4,
OverflowPolicy.ContinueNext,
null,
junction.Id
)
);
}
await using var seed = fixture.New();
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
seed.Groups.Add(group);
seed.JunctionTemplates.Add(junction);
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return channel.Id;
}
private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
{
await using var db = fixture.New();
var result = await new ExportGridQueryHandler(db).Handle(
new ExportGridQuery(channelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
return result.Value;
}
private static async Task<GridImportResultDto> ImportAsync(
TestDb fixture,
Guid channelId,
GridConfig config,
bool replace = true
)
{
await using var db = fixture.New();
var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle(
new ImportGridCommand(channelId, config, replace),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return result.Value;
}
[Fact]
public async Task Export_ReferencesGroupsAndJunctionsByName()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
Assert.Equal(GridConfig.CurrentFormat, config.Format);
Assert.Equal(GridConfig.CurrentVersion, config.Version);
Assert.Equal("Сериалы", config.FallbackGroup);
Assert.Equal("Рекламный шов", config.DefaultJunction);
Assert.Equal("Первый", config.Channel?.Name);
var layer = Assert.Single(config.Layers, l => !l.IsBackground);
var slot = Assert.Single(layer.Slots);
Assert.Equal("Сериалы", slot.Group);
Assert.Equal("Рекламный шов", slot.JunctionAfter);
Assert.True(slot.IsAnchor);
Assert.Equal(15, slot.SnapToMinutes);
// Файл должен быть читаемым JSON — его правят руками и отдают модели.
Assert.Contains("\"format\": \"telewave.grid\"", config.ToJson(), StringComparison.Ordinal);
}
[Fact]
public async Task Import_RestoresTheSameGrid_AfterExport()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
// Через сериализацию — так файл и приходит обратно, а не объектом в памяти.
var roundTripped = JsonSerializer.Deserialize<GridConfig>(
config.ToJson(),
GridConfig.Options
);
Assert.NotNull(roundTripped);
var imported = await ImportAsync(fixture, channelId, roundTripped);
Assert.Equal(1, imported.Slots);
Assert.Equal(0, imported.Skipped);
Assert.Equal(1, imported.Removed);
Assert.Empty(imported.Warnings);
var restored = await ExportAsync(fixture, channelId);
Assert.Equal(config.Layers.Count, restored.Layers.Count);
Assert.Equal(
config.Layers.SelectMany(l => l.Slots),
restored.Layers.SelectMany(l => l.Slots)
);
}
[Fact]
public async Task Import_ReusesLayerByName_AndDoesNotDuplicateIt()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
await ImportAsync(fixture, channelId, config);
await ImportAsync(fixture, channelId, config);
await using var db = fixture.New();
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
Assert.Single(template.Layers, l => l.Name == "Основная сетка");
Assert.Equal(1, template.Layers.Sum(l => l.Slots.Count));
}
[Fact]
public async Task Import_SkipsUnknownGroup_AndSaysSo()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig([
new GridConfigLayer(
"Основная сетка",
10,
[
new GridConfigSlot(
"Чужой блок",
new TimeOnly(20, 0),
120,
Daypart.Prime,
Group: "Сериалы с другого канала"
),
new GridConfigSlot(
"Свой блок",
new TimeOnly(22, 0),
60,
Daypart.Night,
Group: "Сериалы"
),
]
),
]);
var imported = await ImportAsync(fixture, channelId, config);
// Одна незнакомая ссылка не должна ронять весь файл — иначе админ чинит JSON руками.
Assert.Equal(1, imported.Slots);
Assert.Equal(1, imported.Skipped);
Assert.Contains(
imported.Warnings,
w => w.Contains("Сериалы с другого канала", StringComparison.Ordinal)
);
}
[Fact]
public async Task Import_SkipsOverlappingSlot_AndKeepsTheGridValid()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig([
new GridConfigLayer(
"Основная сетка",
10,
[
new GridConfigSlot(
"Первый",
new TimeOnly(20, 0),
120,
Daypart.Prime,
Group: "Сериалы"
),
new GridConfigSlot(
"Наложился",
new TimeOnly(21, 0),
60,
Daypart.Prime,
Group: "Сериалы"
),
]
),
]);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Slots);
Assert.Equal(1, imported.Skipped);
Assert.NotEmpty(imported.Warnings);
}
[Fact]
public async Task Import_MarksTemplateChanged_ButDoesNotTouchAir()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
await using (var applied = fixture.New())
{
var template = await applied.ScheduleTemplates.FirstAsync(CancellationToken.None);
template.MarkApplied();
await applied.SaveChangesAsync(CancellationToken.None);
}
await ImportAsync(fixture, channelId, config);
await using var db = fixture.New();
var stored = await db.ScheduleTemplates.FirstAsync(CancellationToken.None);
Assert.True(stored.HasPendingChanges);
}
[Fact]
public async Task Prompt_ListsGroupsAndSchema()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
await using var db = fixture.New();
var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
new BuildGridPromptQuery(
channelId,
["2×2", "Paramount Comedy"],
"Хочу ситкомы вечером"
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var prompt = result.Value.Prompt;
// Модель обязана видеть, чем канал располагает и в каком виде ждут ответ.
Assert.Contains("Сериалы", prompt, StringComparison.Ordinal);
Assert.Contains("2×2", prompt, StringComparison.Ordinal);
Assert.Contains("Хочу ситкомы вечером", prompt, StringComparison.Ordinal);
Assert.Contains("Рекламный шов", prompt, StringComparison.Ordinal);
Assert.Contains(GridConfig.CurrentFormat, prompt, StringComparison.Ordinal);
Assert.Equal(1, result.Value.Groups);
Assert.Equal(1, result.Value.Shows);
Assert.Equal(prompt.Length, result.Value.Characters);
}
[Fact]
public async Task Prompt_SchemaSurvivesImport()
{
// Схема в запросе и импорт обязаны быть одним форматом: пример из запроса должен читаться
// импортом без правок, иначе ответ модели придётся чинить руками.
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var answer = """
{
"format": "telewave.grid",
"version": 1,
"fallbackGroup": "Сериалы",
"layers": [
{
"name": "Основная сетка",
"priority": 10,
"slots": [
{
"title": "Вечерний блок",
"start": "20:00",
"durationMinutes": 120,
"daypart": "Prime",
"kind": "Content",
"group": "Сериалы",
"blockMode": "Count",
"blockValue": 4,
"strategy": { "type": "Sequential", "restartOnEnd": true },
"isAnchor": true,
"snapToMinutes": 15
}
]
}
]
}
""";
var config = JsonSerializer.Deserialize<GridConfig>(answer, GridConfig.Options);
Assert.NotNull(config);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Slots);
Assert.Empty(imported.Warnings);
var stored = await ExportAsync(fixture, channelId);
var slot = Assert.Single(stored.Layers.SelectMany(l => l.Slots));
Assert.Equal("Вечерний блок", slot.Title);
Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
Assert.True(slot.IsAnchor);
}
private static GroupCatalog Catalog(IAppDbContext db) =>
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
}
@@ -42,7 +42,8 @@ public class JunctionFillerTests
PlanningTimeWindow? window = null,
string? choiceKey = null,
int choiceWeight = 1,
int poolSize = 8
int poolSize = 8,
int nearHourMinutes = 0
) =>
new(
Guid.NewGuid(),
@@ -55,6 +56,7 @@ public class JunctionFillerTests
minMinutesBetween,
chance,
window,
nearHourMinutes,
choiceKey,
choiceWeight
);
@@ -234,6 +236,58 @@ public class JunctionFillerTests
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public void NearHour_SkipsAwayFromRoundHour()
{
// Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место.
var element = Series(3, 20, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
)
);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public void NearHour_PlacesOnTheHour()
{
// Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом.
var element = Series(3, 60, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(2, ads.Count);
Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute));
}
[Fact]
public void NearHour_CountsChannelTime_NotUtc()
{
// Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу —
// «московское время» объявляют в эфире, а не в UTC.
var element = Series(3, 60, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
),
utcOffsetMinutes: 210
);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public void Choice_PlacesExactlyOneOfTheFork()
{
@@ -75,7 +75,9 @@ public class SchedulePlannerTests
DateTimeOffset? start = null,
int horizonHours = 6,
IReadOnlyList<PlanningUnit>? fallback = null,
IReadOnlyList<PlanningSlot>? background = null
IReadOnlyList<PlanningSlot>? background = null,
IReadOnlyList<PadUnit>? pad = null,
TimeSpan maxPad = default
) =>
new(
Guid.NewGuid(),
@@ -85,9 +87,13 @@ public class SchedulePlannerTests
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2,
UtcOffsetMinutes: 0,
background ?? []
background ?? [],
pad ?? [],
maxPad
);
private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad);
private static PlanningResult Run(PlanningInput input) =>
TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());
@@ -350,6 +356,72 @@ public class SchedulePlannerTests
Assert.Equal(element.ElementId, program.Trace.ElementId);
}
[Fact]
public void Pad_FillsSeamBeforeSlotWithAds()
{
// Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени
// на настоящем ТВ занимает реклама, а не ещё одна серия.
var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true);
var result = Run(
Input(
[Slot(T0, 15, [Element(1, 15)]), anchor],
horizonHours: 1,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(30)
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(3, ads.Count);
Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc);
// К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса.
Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc);
}
[Fact]
public void Pad_IsCappedSoLongHolesStayProgramming()
{
// Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы.
var background = Slot(T0, 24 * 60, [Element(6, 10)]);
var result = Run(
Input(
[Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
horizonHours: 2,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(10),
background: [background]
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(
TimeSpan.FromMinutes(10),
ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc))
);
Assert.Contains(result.Items, i => i.SlotId == background.SlotId);
}
[Fact]
public void Pad_ClosesTailWhenProgrammeDoesNotFit()
{
// Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер.
var background = Slot(T0, 24 * 60, [Element(4, 30)]);
var result = Run(
Input(
[Slot(T0, 55, [Element(1, 55)])],
horizonHours: 1,
pad: [Ad(5)],
background: [background]
)
);
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
}
[Fact]
public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
{