using Microsoft.EntityFrameworkCore; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.Restore; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Programming; using Xunit; namespace TeleWave.Application.Tests.Programming; /// /// Откат правок сетки к применённому состоянию: что вернулось, что удалилось и что при этом /// сохранило свой идентификатор — от него зависит, переживёт ли откат курсор сериала. /// public class RestoreTemplateTests { private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); /// Канал с применённой сеткой: один слот и снятый по нему снимок. private static async Task<(Guid ChannelId, Guid SlotId)> SeedAppliedAsync( TestDb fixture, Guid? groupId = null ) { var channel = Channel.Create("Первый", "first", T0); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); channel.SetTemplate(template.Id); var layer = template.AddLayer("Основная сетка", 10); var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); slot.UpdateContent( new SlotContent( "Вечернее кино", SlotKind.Content, groupId, null, null, SlotBlockMode.FillSlot, 1, OverflowPolicy.ContinueNext ) ); // Применение снимает слепок — именно к нему потом откатываемся. template.MarkApplied(TemplateSnapshot.Capture(template).ToJson()); await using var seed = fixture.New(); seed.Channels.Add(channel); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return (channel.Id, slot.Id); } private static async Task LoadAsync(TestDb fixture) { await using var db = fixture.New(); return await db .ScheduleTemplates.AsNoTracking() .Include(t => t.Layers) .ThenInclude(l => l.Slots) .AsSplitQuery() .FirstAsync(CancellationToken.None); } private static async Task RestoreAsync(TestDb fixture, Guid channelId) { await using var db = fixture.New(); var result = await new RestoreTemplateCommandHandler(db).Handle( new RestoreTemplateCommand(channelId), CancellationToken.None ); Assert.True(result.IsSuccess); await db.SaveChangesAsync(CancellationToken.None); return result.Value; } [Fact] public async Task RevertsEditedSlot_KeepingItsIdentity() { var fixture = new TestDb(); var (channelId, slotId) = await SeedAppliedAsync(fixture); await using (var db = fixture.New()) { var template = await db .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(CancellationToken.None); var slot = template.Layers.SelectMany(l => l.Slots).Single(); slot.UpdateTiming(3, new TimeOnly(8, 0), 30, Daypart.Morning, false, 5, null); template.MarkChanged(); await db.SaveChangesAsync(CancellationToken.None); } var result = await RestoreAsync(fixture, channelId); Assert.Equal(1, result.Slots); var restored = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single(); Assert.Equal(new TimeOnly(20, 0), restored.TargetStart); Assert.Equal(120, restored.TargetDurationMinutes); Assert.Null(restored.Weekday); // Тот же слот, а не копия: с его идентификатором связано состояние (курсор сериала). Assert.Equal(slotId, restored.Id); } [Fact] public async Task RemovesWhatAppearedAfterApply_AndBringsBackWhatWasDeleted() { var fixture = new TestDb(); var (channelId, slotId) = await SeedAppliedAsync(fixture); await using (var db = fixture.New()) { var template = await db .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(CancellationToken.None); var layer = template.Layers.Single(l => !l.IsBackground); layer.RemoveSlot(slotId); layer.AddSlot("Лишний", new TimeOnly(10, 0), 60); template.AddLayer("Лишний слой", 20); template.MarkChanged(); await db.SaveChangesAsync(CancellationToken.None); } await RestoreAsync(fixture, channelId); var restored = await LoadAsync(fixture); var slot = Assert.Single(restored.Layers.SelectMany(l => l.Slots)); Assert.Equal("Вечернее кино", slot.Title); Assert.DoesNotContain(restored.Layers, l => l.Name == "Лишний слой"); // Удалённый слот возвращается новым: его состояние ушло вместе с ним, вернуть нечего. Assert.NotEqual(slotId, slot.Id); } [Fact] public async Task ClearsPendingFlag() { var fixture = new TestDb(); var (channelId, _) = await SeedAppliedAsync(fixture); await using (var db = fixture.New()) { var template = await db.ScheduleTemplates.FirstAsync(CancellationToken.None); template.SetRules("{\"maxBreakMinutesPerHour\":10}"); template.MarkChanged(); await db.SaveChangesAsync(CancellationToken.None); Assert.True(template.HasPendingChanges); } await RestoreAsync(fixture, channelId); var restored = await LoadAsync(fixture); Assert.False(restored.HasPendingChanges); Assert.Null(restored.RulesJson); } [Fact] public async Task DropsReferenceToGroupDeletedAfterApply() { var fixture = new TestDb(); var group = Group.Create("Кино"); Guid channelId; await using (var seed = fixture.New()) { seed.Groups.Add(group); await seed.SaveChangesAsync(CancellationToken.None); } (channelId, _) = await SeedAppliedAsync(fixture, group.Id); // Слот удалили, следом удалили и группу — ссылка в снимке осталась висеть. await using (var db = fixture.New()) { var template = await db .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(CancellationToken.None); var layer = template.Layers.Single(l => !l.IsBackground); layer.RemoveSlot(layer.Slots[0].Id); db.Groups.Remove(await db.Groups.FirstAsync(CancellationToken.None)); template.MarkChanged(); await db.SaveChangesAsync(CancellationToken.None); } var result = await RestoreAsync(fixture, channelId); // Откат не падает из-за прибранной группы: слот возвращается без неё, а потеря сосчитана. Assert.Equal(1, result.DroppedRefs); var slot = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single(); Assert.Null(slot.GroupId); } [Fact] public async Task Fails_WhenTemplateWasNeverApplied() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "first", T0); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); channel.SetTemplate(template.Id); await using (var seed = fixture.New()) { seed.Channels.Add(channel); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); var result = await new RestoreTemplateCommandHandler(db).Handle( new RestoreTemplateCommand(channel.Id), CancellationToken.None ); Assert.False(result.IsSuccess); Assert.Equal(TemplateErrors.NothingToRestore, result.Error); } }