Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
221 lines
8.5 KiB
C#
221 lines
8.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Откат правок сетки к применённому состоянию: что вернулось, что удалилось и что при этом
|
|
/// сохранило свой идентификатор — от него зависит, переживёт ли откат курсор сериала.
|
|
/// </summary>
|
|
public class RestoreTemplateTests
|
|
{
|
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
/// <summary>Канал с применённой сеткой: один слот и снятый по нему снимок.</summary>
|
|
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<ScheduleTemplate> 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<RestoreTemplateResultDto> 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);
|
|
}
|
|
}
|