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
@@ -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));
}