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).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, 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( 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(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)); }