using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Streaming; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using TeleWave.Domain.Programming; using TeleWave.Domain.Programming.Planning; using TeleWave.Infrastructure.Persistence; using Xunit; namespace TeleWave.Application.Tests.Programming; /// /// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым /// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа. /// public class GridJunctionPreviewTests { private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero); private sealed class FirstAlways : IRandomSource { public int Next(int maxExclusive) => 0; } private static GridScheduleGenerator Generator(AppDbContext db) => new( db, new GroupExpander(db, GroupServices.Dynamic(db)), new BumperResolver(db, Substitute.For(), new FirstAlways()), new PostCheckRunner(db), new FirstAlways(), Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), Options.Create(new StreamingOptions()) ); private static MediaAsset ReadyAsset(string name, TimeSpan duration) { var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( new MediaReadyInfo( duration, 2, (int)(duration.TotalSeconds / 2), 1920, 1080, "h264", "aac", "assets/x" ) ); return asset; } /// Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок. private static async Task<( TestDb Fixture, Guid ChannelId, JunctionTemplate Junction )> SeedAsync(Daypart daypart = Daypart.Prime) { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var show = Show.Create("Симпсоны", ShowKind.Series); var episodes = new[] { ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)), ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)), }; foreach (var episode in episodes) show.AddEpisode(episode.Id); var spot = Show.Create("Ролик", ShowKind.Interstitial); var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30)); spot.AddEpisode(spotAsset.Id); var content = Group.Create("Мультсериалы"); content.AddElement(GroupElementKind.Show, show.Id); var ads = Group.Create("Реклама"); ads.AddElement(GroupElementKind.Show, spot.Id); var bumper = BumperTemplate.Create("Блок", "Текст 1"); bumper.SetAudio(".mp3", 8); bumper .Variants[0] .SetLines([ BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), ]); var junction = JunctionTemplate.Create("Прайм"); junction.Update("Прайм", 600); junction .AddElement(JunctionElementKind.Ad) .Update( new JunctionElementSettings( JunctionElementKind.Ad, ads.Id, null, null, JunctionAmountMode.Count, 1, true, null, 1, null ) ); junction .AddElement(JunctionElementKind.Bumper) .Update( new JunctionElementSettings( JunctionElementKind.Bumper, null, bumper.Id, null, JunctionAmountMode.Count, 1, false, null, 1, // Только в прайме: дейпарт — свойство слота, и отсекается он при сборке входа. new JunctionConditions(Dayparts: [Daypart.Prime]).ToJson() ) ); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, daypart, false, 30, null); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, content.Id, new SlotStrategy(SlotStrategyType.Sequential).ToJson(), null, SlotBlockMode.Count, 2, OverflowPolicy.ContinueNext, junction.Id ) ); channel.SetTemplate(template.Id); await using var seed = fixture.New(); seed.Channels.Add(channel); seed.Shows.AddRange(show, spot); seed.MediaAssets.AddRange([.. episodes, spotAsset]); seed.Groups.AddRange(content, ads); seed.BumperTemplates.Add(bumper); seed.JunctionTemplates.Add(junction); seed.ScheduleTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); return (fixture, channel.Id, junction); } [Fact] public async Task Preview_ExpandsJunctionWithAdsAndBumperReservation() { var (fixture, channelId, _) = await SeedAsync(); await using var db = fixture.New(); var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None); Assert.NotNull(result); // Слот выходит в каждые вещательные сутки горизонта — врезки повторяются вместе с ним. var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); var bumpers = result.Items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList(); Assert.NotEmpty(ads); Assert.Equal(ads.Count, bumpers.Count); var ad = ads[0]; var bumper = bumpers[0]; // Заставка резервирует длину по звуку (8 с), выровненную на сегмент. Assert.Equal(TimeSpan.FromSeconds(8), bumper.EndsAtUtc - bumper.StartsAtUtc); Assert.Equal(TimeSpan.FromSeconds(30), ad.EndsAtUtc - ad.StartsAtUtc); // Ассет заставки в сухом прогоне не рендерится — только место под него. Assert.Equal(Guid.Empty, bumper.MediaAssetId); Assert.NotNull(bumper.BumperTemplateId); } [Fact] public async Task Preview_DropsBreaksOutsideTheirDayparts() { var (fixture, channelId, _) = await SeedAsync(Daypart.Night); await using var db = fixture.New(); var result = await Generator(db).PreviewAsync(channelId, T0, 1, CancellationToken.None); Assert.NotNull(result); // Заставка разрешена только в прайме, а слот ночной — в ленте её быть не должно. Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper); Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] public async Task Preview_SkipsBreaksWithoutSource() { var (fixture, channelId, junction) = await SeedAsync(); await using (var db = fixture.New()) { // Врезка ссылается на блок, которого нет: настроена не до конца — играть ей нечем. var stored = await JunctionWithElementsAsync(db, junction.Id); var element = stored.Elements.Single(e => e.Kind == JunctionElementKind.Bumper); element.Update( new JunctionElementSettings( JunctionElementKind.Bumper, null, Guid.NewGuid(), null, JunctionAmountMode.Count, 1, false, null, 1, null ) ); await db.SaveChangesAsync(CancellationToken.None); } await using var context = fixture.New(); var result = await Generator(context) .PreviewAsync(channelId, T0, 1, CancellationToken.None); Assert.NotNull(result); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Bumper); } [Fact] public async Task Preview_UnknownChannel_ReturnsNull() { var fixture = new TestDb(); await using var db = fixture.New(); Assert.Null( await Generator(db).PreviewAsync(Guid.NewGuid(), T0, 1, CancellationToken.None) ); } private static Task JunctionWithElementsAsync( AppDbContext db, Guid junctionId ) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId); }