using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Images; using TeleWave.Domain.Library; using TeleWave.Domain.Programming; using TeleWave.Infrastructure.Persistence; using Xunit; namespace TeleWave.Application.Tests.Broadcast; /// /// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине /// сегмента HLS, а плейсхолдеры подставляются образцами выбранного канала — блок общий, но /// смотреть на него надо глазами конкретного канала. /// public class BumperPreviewTests { private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static RenderBumperPreviewCommandHandler Handler( AppDbContext db, IBumperRenderer? renderer = null, IBumperTemplateStorage? storage = null, IImageStore? imageStore = null ) => new( db, renderer ?? Substitute.For(), storage ?? Substitute.For(), imageStore ?? Substitute.For(), Options.Create(new BumperOptions()), Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); private static BumperTemplate NewTemplate() { var template = BumperTemplate.Create("Блок", "Текст 1"); template .Variants[0] .SetLines([ BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"), BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"), BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"), BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), ]); return template; } private static IReadOnlyList Specs(IBumperRenderer renderer) => renderer .ReceivedCalls() .Select(c => c.GetArguments()[1]) .OfType() .ToList(); [Fact] public async Task Render_UnknownTemplate_ReturnsNotFound() { var fixture = new TestDb(); await using var db = fixture.New(); var result = await Handler(db) .Handle(new RenderBumperPreviewCommand(Guid.NewGuid(), null), CancellationToken.None); Assert.Equal(BumperErrors.TemplateNotFound, result.Error); } [Fact] public async Task Render_DrawsEveryVariant_WithChannelSamplesAndAlignedDuration() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var template = NewTemplate(); var second = template.AddVariant("Второй подблок"); second.SetLines([ BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{channel}"), ]); // Шоу канала: слот сетки ссылается на группу, в группе — шоу. var show = Show.Create("Наше шоу", ShowKind.Series); var group = Group.Create("Кино"); group.AddElement(GroupElementKind.Show, show.Id); var grid = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = grid.Background!.AddSlot("Круглосуточно", new TimeOnly(6, 0), 24 * 60); slot.UpdateContent( new SlotContent( slot.Title, SlotKind.Content, group.Id, null, null, SlotBlockMode.FillSlot, 1, OverflowPolicy.ContinueNext ) ); channel.SetTemplate(grid.Id); await using (var seed = fixture.New()) { seed.Channels.Add(channel); seed.BumperTemplates.Add(template); seed.Shows.Add(show); seed.Groups.Add(group); seed.ScheduleTemplates.Add(grid); await seed.SaveChangesAsync(CancellationToken.None); } var renderer = Substitute.For(); await using var db = fixture.New(); var result = await Handler(db, renderer) .Handle( new RenderBumperPreviewCommand(template.Id, channel.Id), CancellationToken.None ); Assert.True(result.IsSuccess); // Оба подблока отрисованы, каждый — в свой ассет-превью. await renderer .Received(1) .RenderAsync( BumperPreview.AssetId(template.Variants[0].Id), Arg.Any(), Arg.Any() ); await renderer .Received(1) .RenderAsync( BumperPreview.AssetId(second.Id), Arg.Any(), Arg.Any() ); var specs = Specs(renderer); // Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек. Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds)); var nowNext = specs.First(s => s.Lines.Count == 4); Assert.Equal("Наше шоу", nowNext.Lines[1].Text); var channelLine = specs.First(s => s.Lines.Count == 1); Assert.Equal("Первый", channelLine.Lines[0].Text); } [Fact] public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry() { var fixture = new TestDb(); var template = NewTemplate(); var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png"); template.SetBackgroundImage(image.Id); await using (var seed = fixture.New()) { seed.BumperTemplates.Add(template); seed.Images.Add(image); await seed.SaveChangesAsync(CancellationToken.None); } var renderer = Substitute.For(); var imageStore = Substitute.For(); imageStore.ResolvePath(image.Id, ".png").Returns("/data/images/bg.png"); await using var db = fixture.New(); var result = await Handler(db, renderer, imageStore: imageStore) .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); Assert.True(result.IsSuccess); var spec = Specs(renderer)[0]; Assert.Equal("/data/images/bg.png", spec.BackgroundFile); // Канала нет — образцы заглушечные, но кадр всё равно собирается. Assert.Equal("Первое шоу", spec.Lines[1].Text); } [Fact] public async Task Render_DropsLinesThatResolveToNothing() { var fixture = new TestDb(); var template = BumperTemplate.Create("Блок", "Текст 1"); template .Variants[0] .SetLines([ BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "{next.genre}"), BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), ]); await using (var seed = fixture.New()) { seed.BumperTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); } var renderer = Substitute.For(); await using var db = fixture.New(); await Handler(db, renderer) .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); // Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре. var spec = Specs(renderer)[0]; Assert.Single(spec.Lines); } }