using TeleWave.Application.Broadcast;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
///
/// Проверки сетки до генерации: пустые группы, пересечения слотов, недостижимое остывание,
/// конфликт с детским временем и непокрытые интервалы суток.
///
public class ValidateTemplateTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// Слот на сутки целиком — им закрываем сетку, когда дыры не тема теста.
private static Slot FullDaySlot(GridLayer layer, Guid? groupId, TimeOnly dayStart)
{
var slot = layer.AddSlot("Круглосуточно", dayStart, 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
groupId,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
return slot;
}
private static void Fill(
Slot slot,
Guid? groupId,
SlotStrategy? strategy = null,
SlotKind kind = SlotKind.Content
) =>
slot.UpdateContent(
new SlotContent(
slot.Title,
kind,
groupId,
(strategy ?? new SlotStrategy(SlotStrategyType.Sequential)).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
private static async Task> ValidateAsync(
TestDb fixture,
Guid channelId
)
{
await using var db = fixture.New();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(channelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
return result.Value;
}
[Fact]
public async Task Validate_UnknownChannel_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task Validate_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 ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(channel.Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
}
[Fact]
public async Task Validate_EmptyGrid_ReportsGapForEveryDay()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
// Семь суток без единого слота — семь дыр «весь день».
var gaps = issues.Where(i => i.Kind == TemplateIssueKind.GridGap).ToList();
Assert.Equal(7, gaps.Count);
Assert.All(gaps, g => Assert.Contains("весь день", g.Details));
Assert.All(gaps, g => Assert.Equal(TemplateIssueSeverity.Error, g.Severity));
}
[Fact]
public async Task Validate_SlotWithoutGroup_ReportsGroupMissing()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, null, channel.DayStartTime);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupMissing);
// Сутки закрыты слотом — дыр быть не должно.
Assert.DoesNotContain(issues, i => i.Kind == TemplateIssueKind.GridGap);
}
[Fact]
public async Task Validate_EmptyGroup_ReportsGroupEmpty()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Пустая");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
var issue = Assert.Single(issues, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains("Пустая", issue.Details);
}
[Fact]
public async Task Validate_SmallGroupWithCooldown_ReportsBothWarnings()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Маленькая");
group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
// Ежедневный слот — 7 выходов в неделю на 3 позиции, да ещё с двухнедельным остыванием.
Fill(
slot,
group.Id,
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14)
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupTooSmall);
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.CooldownUnreachable);
Assert.All(
issues.Where(i => i.Kind != TemplateIssueKind.GridGap),
i => Assert.Equal(TemplateIssueSeverity.Warning, i.Severity)
);
}
[Fact]
public async Task Validate_OverlappingSlotsInOneLayer_AreReported()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
var layer = template.AddLayer("Прайм", 10);
var first = layer.AddSlot("Кино", new TimeOnly(20, 0), 120);
Fill(first, group.Id);
var second = layer.AddSlot("Сериал", new TimeOnly(21, 0), 60);
Fill(second, group.Id);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
// Внутри слоя приоритетов нет: второй слот молча пропал бы при генерации.
var overlap = Assert.Single(issues, i => i.Kind == TemplateIssueKind.SlotOverlap);
Assert.Equal(second.Id, overlap.SlotId);
Assert.Equal(layer.Id, overlap.LayerId);
}
[Fact]
public async Task Validate_AdultContentInKidsWindow_ReportsAudienceConflict()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var mild = Show.Create("Мультик", ShowKind.Single);
mild.SetAudience(ShowAudience.G);
var harsh = Show.Create("Ужастик", ShowKind.Single);
harsh.SetAudience(ShowAudience.R);
// Строжайший рейтинг группы приходит через коллекцию: франшиза идёт целиком.
var collection = Collection.Create("Франшиза");
collection.AddShow(harsh.Id);
var group = Group.Create("Вечернее");
group.AddElement(GroupElementKind.Show, mild.Id);
group.AddElement(GroupElementKind.Collection, collection.Id);
group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
slot.UpdateTiming(
null,
new TimeOnly(10, 0),
24 * 60,
Daypart.Day,
isAnchor: false,
5,
null
);
template.SetRules(
new PlanningRules(
MaxAudienceByTime:
[
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(21, 0), ShowAudience.Pg),
]
).ToJson()
);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.Collections.Add(collection);
seed.Shows.AddRange(mild, harsh);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
var issues = await ValidateAsync(fixture, channel.Id);
var conflict = Assert.Single(issues, i => i.Kind == TemplateIssueKind.AudienceConflict);
Assert.Contains("Вечернее", conflict.Details);
}
}