Files
TeleWave/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs
T
Leonid Pershin 926a20020f
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 3m38s
ci / sonar (push) Successful in 3m38s
Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling
Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
2026-07-30 12:48:51 +03:00

542 lines
19 KiB
C#

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;
/// <summary>
/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам —
/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию».
/// </summary>
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
);
/// <summary>Канал с сеткой: один обычный слой поверх фонового плюс группа контента.</summary>
private sealed record Fixture(
TestDb Db,
Guid ChannelId,
Guid TemplateId,
Guid LayerId,
Guid BackgroundLayerId,
Guid GroupId
);
private static async Task<Fixture> 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<Guid> AddSlotAsync(Fixture f, SlotInput input)
{
await using var db = f.Db.New();
var created = await new CreateSlotCommandHandler(GroupServices.Slots(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<Slot> 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<int> 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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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(GroupServices.Slots(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,
GroupServices.Interstitials(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,
GroupServices.Interstitials(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,
GroupServices.Interstitials(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);
}
}