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(new[] { 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); } }