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);
}
}