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; /// /// Обмен конфигурацией сетки: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ. /// Главное свойство — ссылки в файле именные: он должен переноситься между каналами и установками, /// где тех же идентификаторов нет. /// 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); } /// Канал с одной группой, одним стыком и слоем «Основная сетка» из одного слота. private static async Task 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 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 ImportAsync( TestDb fixture, Guid channelId, GridConfig config, bool replace = true ) { await using var db = fixture.New(); 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; } [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( 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(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)); }