Updated the CI workflow to run tests for individual projects, ensuring accurate coverage reports are generated for each module. Enhanced the BumperPlaceholders class to handle dangling separators more effectively, improving text resolution logic and maintaining clarity in output.
386 lines
15 KiB
C#
386 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using TeleWave.Application.Broadcast.Bumpers;
|
||
using TeleWave.Application.Common.Interfaces;
|
||
using TeleWave.Application.Programming.Planning;
|
||
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.Broadcast;
|
||
|
||
/// <summary>
|
||
/// Подстановка ассетов заставкам после сборки ленты: текст с подставленными плейсхолдерами,
|
||
/// кэш по сигнатуре содержимого и выбор подблока. Всё это невозможно проверить на планировщике —
|
||
/// пара соседей и время показа известны только здесь.
|
||
/// </summary>
|
||
public class BumperResolverTests
|
||
{
|
||
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero);
|
||
|
||
private sealed class FakeQueue : IBumperRenderQueue
|
||
{
|
||
public List<Guid> Enqueued { get; } = [];
|
||
|
||
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
|
||
|
||
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||
}
|
||
|
||
private sealed class FirstAlways : IRandomSource
|
||
{
|
||
public int Next(int maxExclusive) => 0;
|
||
}
|
||
|
||
private static BumperResolver NewResolver(AppDbContext db, FakeQueue? queue = null) =>
|
||
new(db, queue ?? new FakeQueue(), new FirstAlways());
|
||
|
||
private static BumperTemplate TemplateWith(params BumperLine[] lines)
|
||
{
|
||
var template = BumperTemplate.Create("Блок", "Текст 1");
|
||
template.Variants[0].SetLines(lines);
|
||
return template;
|
||
}
|
||
|
||
private static BumperLine Title(string text) =>
|
||
BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, text);
|
||
|
||
private static MediaAsset ReadyAsset(string name)
|
||
{
|
||
var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||
asset.MarkProcessing();
|
||
asset.MarkReady(
|
||
new MediaReadyInfo(
|
||
TimeSpan.FromMinutes(20),
|
||
2,
|
||
600,
|
||
1920,
|
||
1080,
|
||
"h264",
|
||
"aac",
|
||
"assets/x"
|
||
)
|
||
);
|
||
return asset;
|
||
}
|
||
|
||
/// <summary>Лента «программа → заставка → программа»: ровно тот случай, ради которого всё и есть.</summary>
|
||
private static List<PlannedItem> Lente(
|
||
Guid templateId,
|
||
Guid fromShowId,
|
||
Guid toShowId,
|
||
Guid? slotId = null,
|
||
Guid? variantId = null
|
||
) =>
|
||
[
|
||
new PlannedItem(
|
||
Guid.NewGuid(),
|
||
T0,
|
||
T0.AddMinutes(20),
|
||
fromShowId,
|
||
0,
|
||
slotId,
|
||
PlannedItemKind.Program
|
||
),
|
||
new PlannedItem(
|
||
Guid.Empty,
|
||
T0.AddMinutes(20),
|
||
T0.AddMinutes(20).AddSeconds(8),
|
||
toShowId,
|
||
null,
|
||
slotId,
|
||
PlannedItemKind.Bumper,
|
||
null,
|
||
templateId,
|
||
fromShowId,
|
||
toShowId,
|
||
variantId
|
||
),
|
||
new PlannedItem(
|
||
Guid.NewGuid(),
|
||
T0.AddMinutes(21),
|
||
T0.AddMinutes(41),
|
||
toShowId,
|
||
1,
|
||
slotId,
|
||
PlannedItemKind.Program
|
||
),
|
||
];
|
||
|
||
private static async Task<(TestDb Fixture, Channel Channel, Show From, Show To)> SeedAsync(
|
||
BumperTemplate template,
|
||
Action<AppDbContext>? extra = null
|
||
)
|
||
{
|
||
var fixture = new TestDb();
|
||
var channel = Channel.Create("Первый", "one", T0);
|
||
channel.UpdateTimeSettings(4, 180, Channel.DefaultDayStartTime);
|
||
var from = Show.Create("Симпсоны", ShowKind.Series);
|
||
var to = Show.Create("Терминатор", ShowKind.Single);
|
||
|
||
await using var seed = fixture.New();
|
||
seed.Channels.Add(channel);
|
||
seed.BumperTemplates.Add(template);
|
||
seed.Shows.AddRange(from, to);
|
||
extra?.Invoke(seed);
|
||
await seed.SaveChangesAsync(CancellationToken.None);
|
||
|
||
return (fixture, channel, from, to);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_SubstitutesNeighbourNamesAndChannel()
|
||
{
|
||
var template = TemplateWith(
|
||
BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
|
||
BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"),
|
||
BumperLine.Create(
|
||
2,
|
||
BumperLineStyle.Label,
|
||
BumperLineColor.Accent,
|
||
"ДАЛЕЕ В {next.time}"
|
||
),
|
||
BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
|
||
BumperLine.Create(
|
||
4,
|
||
BumperLineStyle.Caption,
|
||
BumperLineColor.Accent,
|
||
"{channel} · {channel.number}"
|
||
)
|
||
);
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
var items = Lente(template.Id, from.Id, to.Id);
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
// Ассет привязан к индексу записи: одна и та же пара шоу может дать разный текст.
|
||
var assetId = Assert.Contains(1, assets);
|
||
await using var verify = fixture.New();
|
||
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assetId);
|
||
var lines = BumperRenderedText.FromJson(cache.RenderedLinesJson);
|
||
|
||
Assert.Equal(
|
||
["СЕЙЧАС", "Симпсоны", "ДАЛЕЕ В 21:21", "Терминатор", "Первый · 4"],
|
||
lines.Select(l => l.Text)
|
||
);
|
||
Assert.Equal(template.Id, cache.TemplateId);
|
||
Assert.Equal(template.Variants[0].Id, cache.VariantId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_DropsLinesThatResolvedToNothing()
|
||
{
|
||
var template = TemplateWith(Title("{next.genre}"), Title("{next.title}"));
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db)
|
||
.ResolveAsync(channel, Lente(template.Id, from.Id, to.Id), CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
await using var verify = fixture.New();
|
||
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
|
||
// Жанра у шоу нет — строка не должна оставить дыру в кадре.
|
||
Assert.Single(BumperRenderedText.FromJson(cache.RenderedLinesJson));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_ReusesAssetForSameText_AndRendersDifferentOnce()
|
||
{
|
||
var template = TemplateWith(Title("{next.title}"));
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
// Две заставки подряд с одинаковой парой соседей — текст один, значит и ассет один.
|
||
var items = Lente(template.Id, from.Id, to.Id);
|
||
items.Add(items[1] with { StartsAtUtc = T0.AddHours(2), EndsAtUtc = T0.AddHours(2) });
|
||
|
||
var queue = new FakeQueue();
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db, queue)
|
||
.ResolveAsync(channel, items, CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
Assert.Equal(assets[1], assets[3]);
|
||
Assert.Single(queue.Enqueued);
|
||
await using var verify = fixture.New();
|
||
Assert.Equal(1, await verify.BumperAssets.CountAsync());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_TimePlaceholderBreaksTheCache()
|
||
{
|
||
var template = TemplateWith(Title("{next.title} в {time}"));
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
var items = Lente(template.Id, from.Id, to.Id);
|
||
items.Add(items[1] with { StartsAtUtc = T0.AddHours(2), EndsAtUtc = T0.AddHours(2) });
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
// Каждый показ уникален — за это редактор и предупреждает у таких плейсхолдеров.
|
||
Assert.NotEqual(assets[1], assets[3]);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_PicksVariantByTrigger_UnlessElementFixedIt()
|
||
{
|
||
var template = BumperTemplate.Create("Блок", "На смене");
|
||
template.Variants[0].SetLines([Title("смена")]);
|
||
var between = template.AddVariant("Между сериями");
|
||
between.Update(
|
||
"Между сериями",
|
||
BumperTrigger.BetweenEpisodes,
|
||
BumperBackground.Template,
|
||
1
|
||
);
|
||
between.SetLines([Title("между")]);
|
||
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
await using var db = fixture.New();
|
||
var resolver = NewResolver(db);
|
||
|
||
// Шоу не меняется — играет подблок «между сериями».
|
||
var sameShow = await resolver.ResolveAsync(
|
||
channel,
|
||
Lente(template.Id, from.Id, from.Id),
|
||
CancellationToken.None
|
||
);
|
||
// Врезка задала подблок жёстко — триггер больше не спрашивают.
|
||
var fixedVariant = await resolver.ResolveAsync(
|
||
channel,
|
||
Lente(template.Id, from.Id, from.Id, variantId: template.Variants[0].Id),
|
||
CancellationToken.None
|
||
);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
await using var verify = fixture.New();
|
||
var byTrigger = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == sameShow[1]);
|
||
var forced = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == fixedVariant[1]);
|
||
Assert.Equal(between.Id, byTrigger.VariantId);
|
||
Assert.Equal(template.Variants[0].Id, forced.VariantId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_StoresPosterShowForBackground()
|
||
{
|
||
var template = TemplateWith(Title("{next.title}"));
|
||
template.Variants[0].Update("Текст 1", BumperTrigger.Both, BumperBackground.NowPoster, 1);
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db)
|
||
.ResolveAsync(channel, Lente(template.Id, from.Id, to.Id), CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
await using var verify = fixture.New();
|
||
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
|
||
// Фон «постер предыдущего» — значит рендеру нужен постер шоу, которое было до стыка.
|
||
Assert.Equal(from.Id, cache.PosterShowId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_FillsEpisodeGenreAndSlotFromNeighbours()
|
||
{
|
||
var template = TemplateWith(
|
||
Title("{now.episode}"),
|
||
Title("{next.genre} {next.year}"),
|
||
Title("{slot}")
|
||
);
|
||
|
||
var fixture = new TestDb();
|
||
var channel = Channel.Create("Первый", "one", T0);
|
||
var genre = Genre.Create("Боевик", "action");
|
||
var from = Show.Create("Симпсоны", ShowKind.Series);
|
||
var to = Show.Create("Терминатор", ShowKind.Single);
|
||
to.ApplyMetadata("tmdb", "1", null, 1991, null);
|
||
to.SetGenres([genre.Id], genre.Id);
|
||
|
||
var firstEpisode = ReadyAsset("s05e11.mkv");
|
||
var secondEpisode = ReadyAsset("s05e12.mkv");
|
||
from.AddEpisode(firstEpisode.Id).SetNumbers(5, 11);
|
||
from.AddEpisode(secondEpisode.Id).SetNumbers(5, 12);
|
||
|
||
var grid = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||
var slot = grid.Background!.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||
|
||
await using (var seed = fixture.New())
|
||
{
|
||
seed.Channels.Add(channel);
|
||
seed.BumperTemplates.Add(template);
|
||
seed.Genres.Add(genre);
|
||
seed.Shows.AddRange(from, to);
|
||
seed.MediaAssets.AddRange(firstEpisode, secondEpisode);
|
||
seed.ScheduleTemplates.Add(grid);
|
||
await seed.SaveChangesAsync(CancellationToken.None);
|
||
}
|
||
|
||
// Предыдущая программа — вторая серия сериала (UnitIndex = 1).
|
||
var items = Lente(template.Id, from.Id, to.Id, slot.Id);
|
||
items[0] = items[0] with { UnitIndex = 1 };
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
|
||
await using var verify = fixture.New();
|
||
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
|
||
Assert.Equal(
|
||
["с5э12", "Боевик 1991", "Вечернее кино"],
|
||
BumperRenderedText.FromJson(cache.RenderedLinesJson).Select(l => l.Text)
|
||
);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_IgnoresReservationsWithoutTemplate()
|
||
{
|
||
var template = TemplateWith(Title("{next.title}"));
|
||
var (fixture, channel, from, to) = await SeedAsync(template);
|
||
|
||
var items = Lente(template.Id, from.Id, to.Id);
|
||
items[1] = items[1] with { BumperTemplateId = Guid.NewGuid() };
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
|
||
|
||
// Блок удалили после планирования — запись останется без ассета, а не с чужим.
|
||
Assert.Empty(assets);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resolve_WithoutBumpers_DoesNothing()
|
||
{
|
||
var template = TemplateWith(Title("{next.title}"));
|
||
var (fixture, channel, from, _) = await SeedAsync(template);
|
||
|
||
await using var db = fixture.New();
|
||
var assets = await NewResolver(db)
|
||
.ResolveAsync(
|
||
channel,
|
||
[
|
||
new PlannedItem(
|
||
Guid.NewGuid(),
|
||
T0,
|
||
T0.AddMinutes(20),
|
||
from.Id,
|
||
0,
|
||
null,
|
||
PlannedItemKind.Program
|
||
),
|
||
],
|
||
CancellationToken.None
|
||
);
|
||
|
||
Assert.Empty(assets);
|
||
}
|
||
}
|