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