Files
TeleWave/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs
T
Leonid Pershin fe11034b16
ci / build-backend (push) Successful in 1m43s
ci / build-frontend (push) Successful in 58s
ci / tests (push) Successful in 4m14s
ci / sonar (push) Successful in 4m38s
Enhance grid import/export functionality to support dynamic groups and collections
Updated the grid import/export process to allow for the inclusion of groups and collections directly from the configuration file. This enhancement ensures that missing groups and collections are created during import, improving the flexibility of the grid management system. Adjusted related classes and methods to accommodate these changes, ensuring a cohesive integration. Enhanced documentation and user prompts to clarify the new functionality and its usage.
2026-07-28 02:23:43 +03:00

555 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, GroupServices.Dynamic(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), Stats(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 Import_CreatesGroupsFromShows()
{
// Сетка, собранная ИИ под ещё не разобранную библиотеку: групп нет, и положить слоты
// некуда — значит файл приносит группы с собой.
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: "Вечерние сериалы"
),
]
),
],
Groups: [new GridConfigGroup("Вечерние сериалы", Shows: ["Сериал"])]
);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Groups);
Assert.Equal(1, imported.Slots);
Assert.Empty(imported.Warnings);
await using var db = fixture.New();
var group = await db
.Groups.Include(g => g.Items)
.FirstAsync(g => g.Name == "Вечерние сериалы", CancellationToken.None);
Assert.Single(group.Items);
// Статистика — кэш карточки группы, и пустой после создания она быть не должна.
Assert.Equal(1, group.UnitCount);
}
[Fact]
public async Task Import_CreatesCollections_AndPutsThemIntoGroups()
{
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: "Циклы"
),
]
),
],
Collections: [new GridConfigCollection("Франшиза", ["Сериал"])],
Groups: [new GridConfigGroup("Циклы", Collections: ["Франшиза"])]
);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Collections);
Assert.Equal(1, imported.Groups);
Assert.Empty(imported.Warnings);
await using var db = fixture.New();
var collection = await db
.Collections.Include(c => c.Items)
.FirstAsync(c => c.Name == "Франшиза", CancellationToken.None);
Assert.Single(collection.Items);
var group = await db
.Groups.Include(g => g.Items)
.FirstAsync(g => g.Name == "Циклы", CancellationToken.None);
var item = Assert.Single(group.Items);
Assert.Equal(GroupElementKind.Collection, item.ElementKind);
Assert.Equal(collection.Id, item.ElementId);
}
[Fact]
public async Task Import_DoesNotTouchExistingGroup()
{
// Группа с этим именем уже стоит в слотах соседнего канала: переписать её состав чужим
// файлом значит молча перекроить чужой эфир.
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig(
[new GridConfigLayer("Основная сетка", 10, [])],
Groups: [new GridConfigGroup("Сериалы", Shows: ["Сериал", "Сериал"])]
);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(0, imported.Groups);
await using var db = fixture.New();
var group = await db
.Groups.Include(g => g.Items)
.FirstAsync(g => g.Name == "Сериалы", CancellationToken.None);
Assert.Single(group.Items);
}
[Fact]
public async Task Import_WarnsAboutUnknownShow_AndEmptyGroup()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig(
[new GridConfigLayer("Основная сетка", 10, [])],
Groups: [new GridConfigGroup("Придуманное", Shows: ["Шоу, которого нет"])]
);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Groups);
Assert.Contains(
imported.Warnings,
w => w.Contains("Шоу, которого нет", StringComparison.Ordinal)
);
// Пустая группа — это дыра в эфире, и сказать об этом надо на импорте, а не после эфира.
Assert.Contains(
imported.Warnings,
w => w.Contains("создана пустой", StringComparison.Ordinal)
);
}
[Fact]
public async Task Export_CarriesCompositionOfUsedGroups()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
// Файл должен быть самодостаточным: на другой установке этих групп нет, а слот без группы
// положить некуда.
var group = Assert.Single(config.Groups ?? []);
Assert.Equal("Сериалы", group.Name);
Assert.Equal(["Сериал"], group.Shows);
}
[Fact]
public async Task Prompt_ExplainsHowToBuildGroups()
{
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, [], null),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var prompt = result.Value.Prompt;
// Модель должна знать и что группы можно собрать самой, и что названия шоу берутся дословно.
Assert.Contains("\"groups\"", prompt, StringComparison.Ordinal);
Assert.Contains("\"collections\"", prompt, StringComparison.Ordinal);
Assert.Contains("дословно", prompt, StringComparison.Ordinal);
}
[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));
private static GroupStatsService Stats(IAppDbContext db) =>
new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
}