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; /// /// Витрина автосборки: каталог профилей, предпросмотр плана и его перевод в DTO. Предпросмотр — /// то, по чему админ принимает решение, поэтому проверяется отдельно от самой раскладки. /// public class GridPlanPreviewTests { private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static PlannedSlot Slot( string title, SlotKind kind, SlotBlockMode blockMode, int blockValue, int? weekday = null ) => new( GridPlanLayer.Main, weekday, new TimeOnly(20, 0), 120, title, Daypart.Prime, kind, kind == SlotKind.Content ? Guid.NewGuid() : null, kind == SlotKind.Content ? title : null, blockMode, blockValue, null, null, IsAnchor: false ); [Fact] public async Task Profiles_AreListed_WithReferenceAndDescription() { var result = await new ListGridProfilesQueryHandler().Handle( new ListGridProfilesQuery(), CancellationToken.None ); Assert.True(result.IsSuccess); // Каждый профиль должен объяснять себя: имя, на что похоже и для какой библиотеки годится. Assert.Equal(GridProfiles.All.Count, result.Value.Count); Assert.All( result.Value, profile => { Assert.False(string.IsNullOrWhiteSpace(profile.Name)); Assert.False(string.IsNullOrWhiteSpace(profile.Reference)); Assert.False(string.IsNullOrWhiteSpace(profile.Description)); } ); Assert.Contains(result.Value, p => p.Kind == GridProfileKind.Animation); } [Fact] public void Mapper_DescribesEveryBlockKind() { var plan = new GridPlan( GridProfiles.Get(GridProfileKind.Mixed), GridGenerationMode.Rebuild, [ Slot("Сериалы", SlotKind.Content, SlotBlockMode.Count, 3), Slot("Кино", SlotKind.Content, SlotBlockMode.FillSlot, 1), Slot("Полоса", SlotKind.Content, SlotBlockMode.Duration, 90, weekday: 6), Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1), Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1), ], ["замечание"], SlotsToRemove: 4, FallbackGroupId: Guid.NewGuid(), FallbackGroupName: "Фон", FreeMinutes: 10080, CoveredMinutes: 5040 ); var dto = plan.ToDto(); Assert.Equal(GridProfileKind.Mixed, dto.Profile); Assert.Equal(plan.Profile.Name, dto.ProfileName); Assert.Equal(4, dto.SlotsToRemove); Assert.Equal("Фон", dto.FallbackGroupName); Assert.Equal(["замечание"], dto.Notes); // Описание блока — то, что читает админ вместо режима и числа. Assert.Equal( ["3 подряд", "до конца слота", "90 мин", "повтор", "конец вещания"], dto.Slots.Select(s => s.Block) ); // Время приезжает строкой «ЧЧ:ММ»: в предпросмотре оно только показывается. Assert.All(dto.Slots, slot => Assert.Equal("20:00", slot.Start)); Assert.Equal(6, dto.Slots[2].Weekday); Assert.Null(dto.Slots[0].Weekday); // У неконтентных слотов группы нет — показывать в колонке нечего. Assert.Null(dto.Slots[3].GroupName); Assert.Equal("Сериалы", dto.Slots[0].GroupName); } [Fact] public async Task Preview_ReturnsPlanForProfile() { var fixture = new TestDb(); var channelId = await SeedAsync(fixture); await using var db = fixture.New(); var result = await new PreviewGeneratedGridQueryHandler( new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) ).Handle( new PreviewGeneratedGridQuery( channelId, GridProfileKind.Kids, GridGenerationMode.Rebuild ), CancellationToken.None ); Assert.True(result.IsSuccess); Assert.Equal(GridProfileKind.Kids, result.Value.Profile); Assert.NotEmpty(result.Value.Slots); // Пересборка сносит то, что было, и это видно до нажатия. Assert.True(result.Value.SlotsToRemove >= 0); Assert.Equal(7 * GridCoverage.MinutesInDay, result.Value.FreeMinutes); // У детского профиля ночь — конец вещания, и в предпросмотре он подписан именно так. Assert.Contains(result.Value.Slots, s => s.Block == "конец вещания"); } [Fact] public async Task Preview_FailsWhenChannelIsGone() { var fixture = new TestDb(); await using var db = fixture.New(); var result = await new PreviewGeneratedGridQueryHandler( new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) ).Handle( new PreviewGeneratedGridQuery( Guid.NewGuid(), GridProfileKind.Mixed, GridGenerationMode.FillGaps ), CancellationToken.None ); Assert.False(result.IsSuccess); } [Fact] public void Validator_RejectsUnknownProfileAndMode() { var validator = new GenerateGridCommandValidator(); Assert.True( validator .Validate( new GenerateGridCommand( Guid.NewGuid(), GridProfileKind.Sitcom, GridGenerationMode.Rebuild ) ) .IsValid ); // Значения enum приходят с клиента строкой — за пределами каталога они бессмысленны. Assert.False( validator .Validate( new GenerateGridCommand( Guid.NewGuid(), (GridProfileKind)42, GridGenerationMode.FillGaps ) ) .IsValid ); } /// Канал с одной группой мягкого рейтинга — чтобы детскому профилю было чем закрыться. private static async Task SeedAsync(TestDb fixture) { var show = Show.Create("Мультсериал", ShowKind.Series); show.SetAudience(ShowAudience.G); var assets = new List(); for (var i = 0; i < 20; i++) { var asset = MediaAsset.Register($"e{i}.mkv", ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( new MediaReadyInfo( TimeSpan.FromMinutes(22), 6, 220, 1920, 1080, "h264", "aac", $"assets/e{i}" ) ); assets.Add(asset); show.AddEpisode(asset.Id); } var group = Group.Create("Мультфильмы"); group.AddElement(GroupElementKind.Show, show.Id); var channel = Channel.Create("Детский", "kids", T0); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); channel.SetTemplate(template.Id); await using var seed = fixture.New(); seed.MediaAssets.AddRange(assets); seed.Shows.Add(show); seed.Groups.Add(group); seed.Channels.Add(channel); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return channel.Id; } }