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, Catalog(db)); private static GroupCatalog Catalog(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.Where(s => s.Start < new TimeOnly(14, 0)), slot => Assert.Null(slot.Weekday) ); // Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся // днём. У них дни проставлены явно. Assert.All( plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)), slot => Assert.NotNull(slot.Weekday) ); Assert.All( plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)), slot => Assert.NotNull(slot.Weekday) ); Assert.Equal( 5, plan.Slots.Count(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5) ); } [Fact] public async Task NeighbouringSlots_DoNotShareTheSameGroup() { // Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну // и ту же встык — два блока подряд из одной группы читаются как один длинный. var fixture = new TestDb(); var first = Playable("Первый сериал", ShowKind.Series, 60, 25); var second = Playable("Второй сериал", ShowKind.Series, 60, 25); var (channelId, _) = await SeedChannelAsync( fixture, [first.Show, second.Show], [.. first.Assets, .. second.Assets], [GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)] ); var plan = await PlanAsync(fixture, channelId); var byDay = plan .Slots.Where(s => s.GroupId is not null) .GroupBy(s => s.Weekday) .Select(g => g.OrderBy(s => s.Start).ToList()); foreach (var day in byDay) { for (var i = 1; i < day.Count; i++) Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId); } } [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 InterstitialGroup_GoesNeitherIntoSlots_NorIntoFallback() { // Группа рекламы — материал для стыков. По размеру она бьёт любую библиотеку, поэтому // раньше выигрывала каждую полосу без предпочтения по типу и становилась аварийной: // в эфир уходила полоса роликов, подписанная как программа. var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 40, 25); var films = Films(8, 100); var ads = Enumerable .Range(0, 60) .Select(i => Playable($"Ролик {i}", ShowKind.Interstitial, 1, 1)) .ToList(); var adsGroup = GroupOf("Реклама", [.. ads.Select(a => a.Show.Id)]); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show, .. films.Shows, .. ads.Select(a => a.Show)], [.. series.Assets, .. films.Assets, .. ads.SelectMany(a => a.Assets)], [ GroupOf("Сериалы", series.Show.Id), GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), adsGroup, ] ); var plan = await PlanAsync( fixture, channelId, GridProfileKind.Mixed, GridGenerationMode.Rebuild ); Assert.DoesNotContain(plan.Slots, s => s.GroupId == adsGroup.Id); Assert.NotNull(plan.FallbackGroupId); Assert.NotEqual(adsGroup.Id, plan.FallbackGroupId); } [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.Text.Contains("Ночной блок 18+")); Assert.Contains( plan.Slots, s => s.SlotKind == SlotKind.Repeat && s.Title == "Ночной блок 18+" ); Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); } [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 Rebuild_BuildsSeasonLayer_WithAnnualApplicability() { 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)]), ] ); await using (var db = fixture.New()) { var result = await new GenerateGridCommandHandler( db, Planner(db), GroupServices.Slots(db) ).Handle( new GenerateGridCommand( channelId, GridProfileKind.Mixed, GridGenerationMode.Rebuild ), CancellationToken.None ); Assert.True(result.IsSuccess); 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); var season = GridProfiles.Get(GridProfileKind.Mixed).Season; Assert.NotNull(season); var layer = Assert.Single(stored.Layers, l => l.Name == season.Name); // Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как // обычное воскресенье. var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority); Assert.True(layer.Priority > otherPriority); Assert.NotEmpty(layer.Slots); // Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно. var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson); Assert.NotNull(applicability); Assert.Equal([season.Range], applicability.AnnualRanges); Assert.True(applicability.Covers(new DateOnly(2027, 1, 1))); Assert.False(applicability.Covers(new DateOnly(2027, 3, 1))); } [Fact] public async Task Season_DoesNotSpendTheWeeksBudget() { // Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать // в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом. 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, GridProfileKind.Mixed, GridGenerationMode.Rebuild ); Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season); Assert.All( plan.Slots.Where(s => s.Layer == GridPlanLayer.Season), s => Assert.Null(s.Weekday) ); Assert.True(plan.CoveredMinutes <= plan.FreeMinutes); } [Fact] public void Sitcoms_AirInPairs() { // Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как // марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день. var sitcom = GridProfiles.Get(GridProfileKind.Sitcom); var blocks = sitcom .Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0) .ToList(); Assert.NotEmpty(blocks); Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock)); } [Fact] public async Task SlotLength_FollowsContent_RoundedToTheGrid() { // Серии по 25 минут: двухчасовая полоса должна разложиться на блоки под серии, а не // отдать десятки минут фону. Длина при этом округляется вверх до четверти часа — // программа передач состоит из круглых времён, а разницу занимает рекламный шов. var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 60, 25); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show], series.Assets, [GroupOf("Сериалы", series.Show.Id)] ); var plan = await PlanAsync(fixture, channelId); var morning = plan.Slots.Where(s => s.Daypart == Daypart.Morning && s.GroupId is not null); Assert.All( morning, slot => { var content = slot.BlockValue * 25; // Слот не короче своего контента — иначе блок перелезет через объявленное время. Assert.True(slot.DurationMinutes >= content); // И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота. Assert.True(slot.DurationMinutes - content < 15); Assert.Equal(0, slot.DurationMinutes % 15); Assert.Equal(SlotBlockMode.Count, slot.BlockMode); } ); } [Fact] public async Task ExhaustedGroup_IsReplacedByRepeatOfPrime() { // Контента на неделю заведомо мало: сетка должна закрыть день повторами, а не крутить // одно и то же по третьему кругу молча. var fixture = new TestDb(); var tiny = Playable("Мало", ShowKind.Series, 3, 25); var (channelId, _) = await SeedChannelAsync( fixture, [tiny.Show], tiny.Assets, [GroupOf("Мало", tiny.Show.Id)] ); var plan = await PlanAsync(fixture, channelId); Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.Repeat); Assert.Contains(plan.Notes, n => n.Text.Contains("повтор вечернего блока")); // Повтор ссылается на вечерний блок прошлых суток — с начала прайма профиля. var repeat = plan.Slots.First(s => s.RepeatSource is not null); Assert.Equal(1, repeat.RepeatSource!.DaysAgo); Assert.Equal(new TimeOnly(18, 0), repeat.RepeatSource.Time); } [Fact] public async Task PrimeRotatesAcrossWeekdays() { var fixture = new TestDb(); var first = Films(6, 100); var second = Playable("Сериал", ShowKind.Series, 40, 25); var (channelId, _) = await SeedChannelAsync( fixture, [second.Show, .. first.Shows], [.. second.Assets, .. first.Assets], [ GroupOf("Кино А", first.Shows[0].Id, first.Shows[1].Id, first.Shows[2].Id), GroupOf("Кино Б", first.Shows[3].Id, first.Shows[4].Id, first.Shows[5].Id), GroupOf("Сериалы", second.Show.Id), ] ); var plan = await PlanAsync( fixture, channelId, GridProfileKind.Mixed, GridGenerationMode.Rebuild ); var prime = plan .Slots.Where(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5) .ToList(); Assert.Equal(5, prime.Count); // Всю неделю один и тот же фильм в прайме — это не сетка, а один повторяющийся день. Assert.True(prime.Select(s => s.GroupName).Distinct().Count() > 1); } [Fact] public async Task WeeklyShortage_IsReportedWithNumbers() { var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 4, 25); var (channelId, _) = await SeedChannelAsync( fixture, [series.Show], series.Assets, [GroupOf("Сериалы", series.Show.Id)] ); var plan = await PlanAsync(fixture, channelId); // Проверки считаются по тому же бюджету, по которому раскладывалось, — до нажатия кнопки. Assert.Contains(plan.Notes, n => n.Text.Contains("выходах в неделю")); } [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), GroupServices.Slots(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), GroupServices.Slots(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); } }