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); } }