using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
///
/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам —
/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию».
///
public class TemplateEditingTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static SlotInput Input(
string title = "Вечернее кино",
TimeOnly? start = null,
int minutes = 120,
SlotKind kind = SlotKind.Content,
Guid? groupId = null,
RepeatSource? repeat = null,
int? weekday = null
) =>
new(
title,
weekday,
start ?? new TimeOnly(20, 0),
minutes,
Daypart.Prime,
kind,
groupId,
new SlotStrategy(SlotStrategyType.Sequential),
repeat,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
IsAnchor: true,
MaxDriftMinutes: 10,
SnapToMinutes: 15
);
/// Канал с сеткой: один обычный слой поверх фонового плюс группа контента.
private sealed record Fixture(
TestDb Db,
Guid ChannelId,
Guid TemplateId,
Guid LayerId,
Guid BackgroundLayerId,
Guid GroupId
);
private static async Task SeedAsync()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Прайм", 10);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return new Fixture(
fixture,
channel.Id,
template.Id,
layer.Id,
template.Background!.Id,
group.Id
);
}
private static async Task AddSlotAsync(Fixture f, SlotInput input)
{
await using var db = f.Db.New();
var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, input),
CancellationToken.None
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return created.Value;
}
private static async Task LoadSlotAsync(Fixture f, Guid slotId)
{
await using var db = f.Db.New();
return await db.Slots.AsNoTracking().FirstAsync(s => s.Id == slotId);
}
private static async Task RevisionAsync(Fixture f)
{
await using var db = f.Db.New();
return (
await db.ScheduleTemplates.AsNoTracking().FirstAsync(t => t.Id == f.TemplateId)
).Revision;
}
[Fact]
public async Task CreateSlot_UnknownLayer_ReturnsLayerNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_StoresTimingAndContent_AndBumpsRevision()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId, weekday: 3));
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Вечернее кино", slot.Title);
Assert.Equal(3, slot.Weekday);
Assert.Equal(new TimeOnly(20, 0), slot.TargetStart);
Assert.Equal(120, slot.TargetDurationMinutes);
Assert.Equal(Daypart.Prime, slot.Daypart);
Assert.Equal(f.GroupId, slot.GroupId);
Assert.True(slot.IsAnchor);
Assert.Equal(15, slot.SnapToMinutes);
Assert.NotNull(SlotStrategy.FromJson(slot.StrategyJson));
// Правка сетки эфир не двигает — только помечает шаблон изменённым.
Assert.Equal(1, await RevisionAsync(f));
}
[Fact]
public async Task CreateSlot_ContentWithoutGroup_ReturnsGroupRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: null)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupRequired, result.Error);
}
[Fact]
public async Task CreateSlot_UnknownGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_RepeatWithoutSource_ReturnsRepeatSourceRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.RepeatSourceRequired, result.Error);
}
[Fact]
public async Task CreateSlot_Repeat_KeepsSourceAndDropsGroup()
{
var f = await SeedAsync();
var repeat = new RepeatSource(1, new TimeOnly(20, 0), 120);
var slotId = await AddSlotAsync(
f,
Input(kind: SlotKind.Repeat, groupId: f.GroupId, repeat: repeat)
);
var slot = await LoadSlotAsync(f, slotId);
// Группа к повтору отношения не имеет: домен гасит поля чужого типа слота.
Assert.Null(slot.GroupId);
Assert.Equal(repeat, RepeatSource.FromJson(slot.RepeatSourceJson));
}
[Fact]
public async Task CreateSlot_OverlappingInSameLayer_ReturnsSlotsOverlap()
{
var f = await SeedAsync();
await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(
f.LayerId,
Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotsOverlap, result.Error);
}
[Fact]
public async Task UpdateSlot_UnknownSlot_ReturnsSlotNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, result.Error);
}
[Fact]
public async Task UpdateSlot_MovesSlot_WithoutCountingItAsOverlap()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
// Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой.
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(
slotId,
Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Ночное кино", slot.Title);
Assert.Equal(new TimeOnly(21, 0), slot.TargetStart);
Assert.Equal(2, await RevisionAsync(f));
}
[Fact]
public async Task DeleteSlot_RemovesSlot_OrReportsNotFound()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(slotId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.Slots.AnyAsync(s => s.Id == slotId));
}
[Fact]
public async Task CreateLayer_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(Guid.NewGuid(), "Лето", 20),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task CreateLayer_AddsLayerToTemplate()
{
var f = await SeedAsync();
Guid layerId;
await using (var db = f.Db.New())
{
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(f.TemplateId, "Лето", 20),
CancellationToken.None
);
Assert.True(result.IsSuccess);
layerId = result.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == layerId);
Assert.Equal("Лето", layer.Name);
Assert.Equal(20, layer.Priority);
Assert.False(layer.IsBackground);
}
[Fact]
public async Task UpdateLayer_StoresApplicability_OrReportsNotFound()
{
var f = await SeedAsync();
var applicability = new LayerApplicability(Weekdays: [5, 6]);
await using (var db = f.Db.New())
{
var missing = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(Guid.NewGuid(), "Нет", 5, null, true),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var result = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(f.LayerId, "Выходные", 30, applicability, false),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == f.LayerId);
Assert.Equal("Выходные", layer.Name);
Assert.Equal(30, layer.Priority);
Assert.False(layer.IsEnabled);
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
Assert.Equal([5, 6], stored?.Weekdays);
}
[Fact]
public async Task DeleteLayer_RefusesBackground_AndRemovesOrdinary()
{
var f = await SeedAsync();
await using (var db = f.Db.New())
{
var background = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.BackgroundLayerId),
CancellationToken.None
);
// Без фонового слоя первую же дыру в сетке нечем закрыть.
Assert.Equal(TemplateErrors.BackgroundLayerCannotBeDeleted, background.Error);
}
await using (var db = f.Db.New())
{
var missing = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.LayerId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.GridLayers.AnyAsync(l => l.Id == f.LayerId));
}
[Fact]
public async Task UpdateTemplate_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_UnknownFallbackGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_StoresFallbackAndRules()
{
var f = await SeedAsync();
var rules = new PlanningRules(
MaxAudienceByTime:
[
new AudienceWindow(
new TimeOnly(6, 0),
new TimeOnly(21, 0),
Domain.Library.ShowAudience.Pg
),
],
MaxRepeatsInWindow: new RepeatLimitRule(7, 2)
);
await using (var db = f.Db.New())
{
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var template = await verify
.ScheduleTemplates.AsNoTracking()
.FirstAsync(t => t.Id == f.TemplateId);
Assert.Equal("Новая сетка", template.Name);
Assert.Equal(f.GroupId, template.FallbackGroupId);
var storedRules = PlanningRules.FromJson(template.RulesJson);
Assert.Equal(rules.MaxRepeatsInWindow, storedRules?.MaxRepeatsInWindow);
Assert.Equal(rules.MaxAudienceByTime, storedRules?.MaxAudienceByTime);
Assert.True(template.HasPendingChanges);
}
[Fact]
public async Task GetChannelTemplate_UnknownChannel_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_ChannelWithoutGrid_ReturnsTemplateNotFound()
{
var fixture = new TestDb();
var channel = Channel.Create("Без сетки", "nogrid", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(channel.Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_OrdersLayersByPriority_AndResolvesGroupNames()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(f.ChannelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var dto = result.Value;
Assert.Equal(f.TemplateId, dto.Id);
Assert.True(dto.HasPendingChanges);
// Приоритетный слой идёт первым, фоновый — последним.
Assert.Equal(
new[] { f.LayerId, f.BackgroundLayerId },
dto.Layers.Select(l => l.Id).ToList()
);
Assert.True(dto.Layers[^1].IsBackground);
var slot = Assert.Single(dto.Layers[0].Slots);
Assert.Equal(slotId, slot.Id);
Assert.Equal("Кино", slot.GroupName);
Assert.Equal(SlotStrategyType.Sequential, slot.Strategy?.Type);
}
}