using Microsoft.EntityFrameworkCore; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using TeleWave.Domain.Programming; using Xunit; namespace TeleWave.Application.Tests.Programming; /// /// Автосборка сетки: раскладка по полосам профиля, подбор групп под рейтинг и тип, заполнение /// только дыр и пересборка с нуля. Главное свойство — предпросмотр и создание считают одно и то же. /// public class GenerateGridTests { private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); /// Шоу с готовыми сериями: без длительности группа считалась бы пустой. private static (Show Show, List Assets) Playable( string name, ShowKind kind, int episodes, int minutes, ShowAudience? audience = null ) { var show = Show.Create(name, kind); if (audience is { } value) show.SetAudience(value); var assets = new List(); for (var i = 0; i < episodes; i++) { var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( new MediaReadyInfo( TimeSpan.FromMinutes(minutes), 6, minutes * 10, 1920, 1080, "h264", "aac", $"assets/{name}-{i}" ) ); assets.Add(asset); show.AddEpisode(asset.Id); } return (show, assets); } /// Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу. private static (List Shows, List Assets) Films( int count, int minutes, ShowAudience? audience = null ) { var shows = new List(); var assets = new List(); for (var i = 0; i < count; i++) { var (show, made) = Playable($"Фильм {i}", ShowKind.Single, 1, minutes, audience); shows.Add(show); assets.AddRange(made); } return (shows, assets); } private static Group GroupOf(string name, params Guid[] showIds) { var group = Group.Create(name); foreach (var id in showIds) group.AddElement(GroupElementKind.Show, id); return group; } /// Канал с пустой сеткой: один фоновый слой без слотов. private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync( TestDb fixture, IEnumerable shows, IEnumerable assets, IEnumerable groups ) { var channel = Channel.Create("Первый", "first", T0); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); channel.SetTemplate(template.Id); await using var seed = fixture.New(); seed.MediaAssets.AddRange(assets); seed.Shows.AddRange(shows); seed.Groups.AddRange(groups); seed.Channels.Add(channel); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return (channel.Id, template); } private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); private static async Task PlanAsync( TestDb fixture, Guid channelId, GridProfileKind profile = GridProfileKind.Mixed, GridGenerationMode mode = GridGenerationMode.FillGaps ) { await using var db = fixture.New(); var result = await Planner(db).BuildAsync(channelId, profile, mode, CancellationToken.None); Assert.True(result.IsSuccess); return result.Value; } [Fact] public async Task EmptyGrid_IsCoveredEntirely_AndCollapsesIntoEveryDaySlots() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(6, 100); // Ночному блоку 18+ нужен строгий рейтинг, иначе полосу закрыть будет нечем. var adult = Playable("Ночное", ShowKind.Series, 10, 45, ShowAudience.R); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show, adult.Show, .. films.Shows], [.. series.Assets, .. adult.Assets, .. films.Assets], [ GroupOf("Сериалы", series.Show.Id), GroupOf("Ночное", adult.Show.Id), GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), ] ); var plan = await PlanAsync(fixture, channelId); // Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком. Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes); Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); // Свободно во все семь дней — значит слоты идут «каждый день», а не семью копиями. Assert.All(plan.Slots, slot => Assert.Null(slot.Weekday)); } [Fact] public async Task PrimeGetsFeature_AndMorningGetsSeries() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(8, 100); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show, .. films.Shows], [.. series.Assets, .. films.Assets], [ GroupOf("Сериалы", series.Show.Id), GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), ] ); var plan = await PlanAsync(fixture, channelId); var prime = plan.Slots.First(s => s.Start == new TimeOnly(20, 0)); Assert.Equal("Фильмы", prime.GroupName); // Кино ставится по одной единице и с якорем: объявленное время начала — обещание зрителю. Assert.Equal(SlotBlockMode.Count, prime.BlockMode); Assert.Equal(1, prime.BlockValue); Assert.True(prime.IsAnchor); var morning = plan.Slots.First(s => s.Daypart == Daypart.Morning); Assert.Equal("Сериалы", morning.GroupName); } [Fact] public async Task ChildrenBand_SkipsAdultGroup() { var fixture = new TestDb(); var adult = Playable("Взрослое", ShowKind.Series, 20, 25, ShowAudience.R); var kids = Playable("Детское", ShowKind.Series, 20, 25, ShowAudience.G); var (channelId, _) = await SeedChannelAsync( fixture, [adult.Show, kids.Show], [.. adult.Assets, .. kids.Assets], [GroupOf("Взрослое", adult.Show.Id), GroupOf("Детское", kids.Show.Id)] ); var plan = await PlanAsync(fixture, channelId, GridProfileKind.Kids); // Детский профиль не пускает строгое никуда, а ночь у него — конец вещания. Assert.DoesNotContain(plan.Slots, s => s.GroupName == "Взрослое"); Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.SignOff); } [Fact] public async Task AdultNightBand_NeedsRatedContent() { var fixture = new TestDb(); // Рейтинга нет ни у чего: ночной блок 18+ ставить не из чего, и это должно быть сказано. var series = Playable("Сериал", ShowKind.Series, 40, 25); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show], series.Assets, [GroupOf("Сериалы", series.Show.Id)] ); var plan = await PlanAsync(fixture, channelId); Assert.Contains(plan.Notes, note => note.Contains("Ночной блок 18+")); Assert.True(plan.CoveredMinutes < plan.FreeMinutes); } [Fact] public async Task FillGaps_LeavesExistingSlotsAlone() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var group = GroupOf("Сериалы", series.Show.Id); var (channelId, template) = await SeedChannelAsync( fixture, [series.Show], series.Assets, [group] ); // Занятая полоса: вторник с 20:00 на два часа. await using (var db = fixture.New()) { var tracked = await db .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(t => t.Id == template.Id, CancellationToken.None); var slot = tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2); slot.UpdateContent( new SlotContent( "Ручной", SlotKind.Content, group.Id, null, null, SlotBlockMode.FillSlot, 1, OverflowPolicy.ContinueNext ) ); await db.SaveChangesAsync(CancellationToken.None); } var plan = await PlanAsync(fixture, channelId); Assert.Equal(0, plan.SlotsToRemove); Assert.Equal(7 * GridCoverage.MinutesInDay - 120, plan.FreeMinutes); // Занятое время не застраивается: во вторник в 20:00 нового слота нет. Assert.DoesNotContain( plan.Slots, s => s.Weekday == 2 && s.Start >= new TimeOnly(20, 0) && s.Start < new TimeOnly(22, 0) ); // Остальные дни в это время свободны, поэтому слот «каждый день» туда уже не схлопнется. Assert.Contains(plan.Slots, s => s.Weekday is not null); } [Fact] public async Task Rebuild_CountsExistingSlotsAsRemoved_AndSeparatesWeekend() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(8, 100); var group = GroupOf("Сериалы", series.Show.Id); var (channelId, template) = await SeedChannelAsync( fixture, [series.Show, .. films.Shows], [.. series.Assets, .. films.Assets], [group, GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)])] ); await using (var db = fixture.New()) { var tracked = await db .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(t => t.Id == template.Id, CancellationToken.None); tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2); await db.SaveChangesAsync(CancellationToken.None); } var plan = await PlanAsync( fixture, channelId, GridProfileKind.Mixed, GridGenerationMode.Rebuild ); Assert.Equal(1, plan.SlotsToRemove); Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Weekend && s.Weekday == 6); Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null); } [Fact] public async Task Fails_WhenNothingToPlanFrom() { var fixture = new TestDb(); // Группа есть, но её шоу без готового медиа — эфира из неё не получится. var show = Show.Create("Пусто", ShowKind.Series); var (channelId, _) = await SeedChannelAsync( fixture, [show], [], [GroupOf("Пустая", show.Id)] ); await using var db = fixture.New(); var result = await Planner(db) .BuildAsync( channelId, GridProfileKind.Mixed, GridGenerationMode.FillGaps, CancellationToken.None ); Assert.False(result.IsSuccess); Assert.Equal(TemplateErrors.NoGroupsToGenerate, result.Error); } [Fact] public async Task Generate_WritesSlots_AndMarksTemplateChanged() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(8, 100); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show, .. films.Shows], [.. series.Assets, .. films.Assets], [ GroupOf("Сериалы", series.Show.Id), GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), ] ); int planned; await using (var db = fixture.New()) { var result = await new GenerateGridCommandHandler( db, Planner(db), new SlotWriter(db) ).Handle( new GenerateGridCommand( channelId, GridProfileKind.Mixed, GridGenerationMode.FillGaps ), CancellationToken.None ); Assert.True(result.IsSuccess); planned = result.Value.Created; Assert.Equal(0, result.Value.Removed); await db.SaveChangesAsync(CancellationToken.None); } await using var check = fixture.New(); var stored = await check .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .FirstAsync(CancellationToken.None); Assert.Equal(planned, stored.Layers.Sum(l => l.Slots.Count)); Assert.Contains(stored.Layers, l => l.Name == GridPlanner.MainLayerName); // Правка правил эфир не двигает — она только помечает шаблон изменённым. Assert.True(stored.HasPendingChanges); // Аварийная группа проставляется, если своей не было: иначе паузы останутся пустыми. Assert.NotNull(stored.FallbackGroupId); } [Fact] public async Task Generate_Twice_ReusesLayer_AndAddsNothingNew() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(8, 100); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show, .. films.Shows], [.. series.Assets, .. films.Assets], [ GroupOf("Сериалы", series.Show.Id), GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), ] ); for (var run = 0; run < 2; run++) { await using var db = fixture.New(); var result = await new GenerateGridCommandHandler( db, Planner(db), new SlotWriter(db) ).Handle( new GenerateGridCommand( channelId, GridProfileKind.Mixed, GridGenerationMode.FillGaps ), CancellationToken.None ); Assert.True(result.IsSuccess); // Второй прогон видит сетку закрытой и не создаёт ничего: дыр больше нет. Assert.True(run == 0 ? result.Value.Created > 0 : result.Value.Created == 0); await db.SaveChangesAsync(CancellationToken.None); } await using var check = fixture.New(); var layers = await check.ScheduleTemplates.Include(t => t.Layers).FirstAsync(); Assert.Single(layers.Layers, l => l.Name == GridPlanner.MainLayerName); } }