Refactor CI workflow for improved test coverage reporting and enhance BumperPlaceholders logic
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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Подстановка плейсхолдеров. Правило одно и важное: неизвестное значение схлопывается вместе
|
||||
/// с осиротевшим разделителем, а не оставляет дыру в кадре.
|
||||
/// </summary>
|
||||
public class BumperPlaceholdersTests
|
||||
{
|
||||
private static readonly DateTimeOffset Moment = new(
|
||||
2026,
|
||||
4,
|
||||
6,
|
||||
21,
|
||||
24,
|
||||
0,
|
||||
TimeSpan.FromHours(3)
|
||||
);
|
||||
|
||||
private static BumperContext Full() =>
|
||||
new(
|
||||
"Первый",
|
||||
4,
|
||||
Moment,
|
||||
"Симпсоны",
|
||||
"Терминатор 2",
|
||||
"с5э12",
|
||||
"с1э3",
|
||||
1991,
|
||||
"Боевик",
|
||||
new TimeOnly(21, 30),
|
||||
"Вечернее кино"
|
||||
);
|
||||
|
||||
[Theory]
|
||||
[InlineData("{channel}", "Первый")]
|
||||
[InlineData("{channel.number}", "4")]
|
||||
[InlineData("{now.title}", "Симпсоны")]
|
||||
[InlineData("{next.title}", "Терминатор 2")]
|
||||
[InlineData("{now.episode}", "с5э12")]
|
||||
[InlineData("{next.episode}", "с1э3")]
|
||||
[InlineData("{next.year}", "1991")]
|
||||
[InlineData("{next.genre}", "Боевик")]
|
||||
[InlineData("{next.time}", "21:30")]
|
||||
[InlineData("{time}", "21:24")]
|
||||
[InlineData("{date}", "6 апреля")]
|
||||
[InlineData("{weekday}", "понедельник")]
|
||||
[InlineData("{slot}", "Вечернее кино")]
|
||||
public void Resolve_SubstitutesEveryToken(string text, string expected) =>
|
||||
Assert.Equal(expected, BumperPlaceholders.Resolve(text, Full()));
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MixesTextAndTokens()
|
||||
{
|
||||
var resolved = BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time} — {next.title}", Full());
|
||||
|
||||
Assert.Equal("ДАЛЕЕ В 21:30 — Терминатор 2", resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CollapsesGapsFromMissingValues()
|
||||
{
|
||||
var empty = new BumperContext("Канал", null, Moment);
|
||||
|
||||
// Ни следующей программы, ни номера канала: строка схлопывается, осиротевший разделитель уходит.
|
||||
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty));
|
||||
Assert.Equal("", BumperPlaceholders.Resolve("{next.title}", empty));
|
||||
Assert.Equal("Канал", BumperPlaceholders.Resolve("{channel} {channel.number}", empty));
|
||||
// А висящий предлог остаётся: отличить его от слова в чужом тексте нельзя.
|
||||
Assert.Equal("ДАЛЕЕ В", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownTokenBecomesEmpty()
|
||||
{
|
||||
Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее {next.tittle}", Full()));
|
||||
Assert.Equal("", BumperPlaceholders.Resolve(" ", Full()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownTokens_ListsOnlyStrangers()
|
||||
{
|
||||
Assert.Equal(
|
||||
["next.tittle", "chanel"],
|
||||
BumperPlaceholders.UnknownTokens("{next.title} {next.tittle} {chanel} {next.tittle}")
|
||||
);
|
||||
Assert.Empty(BumperPlaceholders.UnknownTokens("{channel}"));
|
||||
Assert.Empty(BumperPlaceholders.UnknownTokens(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensIn_CollectsAcrossTexts()
|
||||
{
|
||||
var tokens = BumperPlaceholders.TokensIn(["СЕЙЧАС {now.title}", "", "{next.genre}"]);
|
||||
|
||||
Assert.Equal(["next.genre", "now.title"], tokens.Order());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{time}", true)]
|
||||
[InlineData("{date}", true)]
|
||||
[InlineData("{weekday}", true)]
|
||||
[InlineData("{next.time}", false)]
|
||||
[InlineData("СЕЙЧАС", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsVolatile_MarksTokensTiedToTheMoment(string? text, bool expected) =>
|
||||
Assert.Equal(expected, BumperPlaceholders.IsVolatile(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData(5, 12, "с5э12")]
|
||||
[InlineData(null, 7, "э7")]
|
||||
[InlineData(3, null, null)]
|
||||
[InlineData(null, null, null)]
|
||||
public void Episode_FormatsWhatIsKnown(int? season, int? episode, string? expected) =>
|
||||
Assert.Equal(expected, BumperPlaceholders.Episode(season, episode));
|
||||
|
||||
[Fact]
|
||||
public void RenderedText_RoundTripsAndSurvivesGarbage()
|
||||
{
|
||||
var lines = new List<BumperRenderLine>
|
||||
{
|
||||
new(BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
|
||||
new(BumperLineStyle.Title, BumperLineColor.Text, "Симпсоны"),
|
||||
};
|
||||
|
||||
var restored = BumperRenderedText.FromJson(BumperRenderedText.ToJson(lines));
|
||||
|
||||
Assert.Equal(lines, restored);
|
||||
Assert.Empty(BumperRenderedText.FromJson("{не json}"));
|
||||
Assert.Empty(BumperRenderedText.FromJson(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Восстановление спецификации для фонового рендерера. Текст берётся из кэша как есть: время показа
|
||||
/// и пара соседей задним числом не восстанавливаются, а ffmpeg запускается уже после записи ленты.
|
||||
/// </summary>
|
||||
public class BumperSpecLoaderTests
|
||||
{
|
||||
private static readonly IReadOnlyList<BumperRenderLine> Lines =
|
||||
[
|
||||
new(BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
|
||||
new(BumperLineStyle.Title, BumperLineColor.Text, "Симпсоны"),
|
||||
];
|
||||
|
||||
private static BumperSpecLoader NewLoader(
|
||||
Infrastructure.Persistence.AppDbContext db,
|
||||
IBumperTemplateStorage? storage = null,
|
||||
IImageStore? images = null
|
||||
) =>
|
||||
new(
|
||||
db,
|
||||
storage ?? Substitute.For<IBumperTemplateStorage>(),
|
||||
images ?? Substitute.For<IImageStore>(),
|
||||
Options.Create(new BumperOptions()),
|
||||
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Load_UnknownAsset_ReturnsNull()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
Assert.Null(await NewLoader(db).LoadAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_RestoresRenderedTextAndPaths()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var template = BumperTemplate.Create("Блок", "Текст 1");
|
||||
template.UpdateStyle(
|
||||
new BumperStyle("Блок", BumperFont.Serif, "0x111111", "0x222222", "0x333333", "white")
|
||||
);
|
||||
template.SetAudio(".mp3", 12);
|
||||
var background = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png");
|
||||
template.SetBackgroundImage(background.Id);
|
||||
|
||||
var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "poster.jpg");
|
||||
var show = Show.Create("Терминатор", ShowKind.Single);
|
||||
show.ApplyMetadata("tmdb", "1", null, 1991, poster.Id);
|
||||
|
||||
var asset = MediaAsset.RegisterGenerated("Блок: СЕЙЧАС / Симпсоны");
|
||||
var cache = BumperAsset.Create(
|
||||
template.Id,
|
||||
template.Variants[0].Id,
|
||||
"sig",
|
||||
BumperRenderedText.ToJson(Lines),
|
||||
show.Id,
|
||||
asset.Id
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.BumperTemplates.Add(template);
|
||||
seed.Images.AddRange(background, poster);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.BumperAssets.Add(cache);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
storage.AudioPath(template.Id, ".mp3").Returns("/media/bumpers/audio.mp3");
|
||||
var images = Substitute.For<IImageStore>();
|
||||
images.ResolvePath(background.Id, ".png").Returns("/media/images/bg.png");
|
||||
images.ResolvePath(poster.Id, ".jpg").Returns("/media/images/poster.jpg");
|
||||
|
||||
await using var db = fixture.New();
|
||||
var spec = await NewLoader(db, storage, images).LoadAsync(asset.Id, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(spec);
|
||||
Assert.Equal(Lines, spec.Lines);
|
||||
Assert.Equal("/media/bumpers/audio.mp3", spec.MusicFile);
|
||||
Assert.Equal("/media/images/bg.png", spec.BackgroundFile);
|
||||
Assert.Equal("/media/images/poster.jpg", spec.PosterFile);
|
||||
Assert.Equal("0x333333", spec.AccentColor);
|
||||
// Длина звука 12 с выравнивается вверх до кратности сегменту (5 с).
|
||||
Assert.Equal(15, spec.DurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_TemplateGone_ReturnsNull()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var asset = MediaAsset.RegisterGenerated("Заставка");
|
||||
var cache = BumperAsset.Create(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"sig",
|
||||
BumperRenderedText.ToJson(Lines),
|
||||
null,
|
||||
asset.Id
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.BumperAssets.Add(cache);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
// Блок удалили, пока ассет ждал рендера — рендерить нечем, и это не авария.
|
||||
Assert.Null(await NewLoader(db).LoadAsync(asset.Id, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Images;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Команды блока заставки. Блок общий для всех каналов, поэтому удаление используемого запрещено:
|
||||
/// иначе оно молча выключило бы заставки в чужих каналах.
|
||||
/// </summary>
|
||||
public class BumperTemplateHandlersTests
|
||||
{
|
||||
private static async Task<(TestDb Fixture, BumperTemplate Template)> SeedAsync()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var template = BumperTemplate.Create("Блок", "Текст 1");
|
||||
await using var seed = fixture.New();
|
||||
seed.BumperTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
return (fixture, template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_StartsFromNowNextPreset()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new CreateBumperTemplateCommandHandler(db).Handle(
|
||||
new CreateBumperTemplateCommand("Ночной"),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await using var verify = fixture.New();
|
||||
var template = await verify.BumperTemplates.Include(t => t.Variants).SingleAsync();
|
||||
var variant = Assert.Single(template.Variants);
|
||||
// Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас/Далее».
|
||||
Assert.Equal(
|
||||
["СЕЙЧАС", "{now.title}", "ДАЛЕЕ", "{next.title}"],
|
||||
variant.Lines.OrderBy(l => l.Position).Select(l => l.Text)
|
||||
);
|
||||
Assert.Equal(BumperFont.Sans, template.Font);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStyle_BumpsRevision()
|
||||
{
|
||||
var (fixture, template) = await SeedAsync();
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateBumperTemplateCommandHandler(db).Handle(
|
||||
new UpdateBumperTemplateCommand(
|
||||
template.Id,
|
||||
new BumperStyle(
|
||||
"Ночной",
|
||||
BumperFont.Serif,
|
||||
"0x111111",
|
||||
"0x222222",
|
||||
"0x333333",
|
||||
"black"
|
||||
)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await using var verify = fixture.New();
|
||||
var stored = await verify.BumperTemplates.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("Ночной", stored.Name);
|
||||
Assert.Equal(BumperFont.Serif, stored.Font);
|
||||
// Оформление входит в сигнатуру рендера: без роста ревизии в эфире остались бы старые кадры.
|
||||
Assert.Equal(1, stored.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStyle_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new UpdateBumperTemplateCommandHandler(db).Handle(
|
||||
new UpdateBumperTemplateCommand(
|
||||
Guid.NewGuid(),
|
||||
new BumperStyle("x", BumperFont.Sans, "a", "b", "c", "d")
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audio_SetAndClear_TouchStorageAndDuration()
|
||||
{
|
||||
var (fixture, template) = await SeedAsync();
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var set = await new SetBumperTemplateAudioCommandHandler(db).Handle(
|
||||
new SetBumperTemplateAudioCommand(template.Id, ".mp3", 12.5),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(set.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var verify = fixture.New())
|
||||
{
|
||||
var stored = await verify.BumperTemplates.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(".mp3", stored.AudioExtension);
|
||||
Assert.Equal(12.5, stored.AudioDurationSeconds);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var cleared = await new ClearBumperTemplateAudioCommandHandler(db, storage).Handle(
|
||||
new ClearBumperTemplateAudioCommand(template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(cleared.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Файл на диске должен уйти вместе с отметкой в базе.
|
||||
await storage.Received(1).DeleteAudioAsync(template.Id, Arg.Any<CancellationToken>());
|
||||
await using var final = fixture.New();
|
||||
Assert.Null((await final.BumperTemplates.AsNoTracking().SingleAsync()).AudioExtension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audio_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
|
||||
var set = await new SetBumperTemplateAudioCommandHandler(db).Handle(
|
||||
new SetBumperTemplateAudioCommand(Guid.NewGuid(), ".mp3", 1),
|
||||
CancellationToken.None
|
||||
);
|
||||
var cleared = await new ClearBumperTemplateAudioCommandHandler(db, storage).Handle(
|
||||
new ClearBumperTemplateAudioCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, set.Error);
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, cleared.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_SetAndClear_UseImageRegistry()
|
||||
{
|
||||
var (fixture, template) = await SeedAsync();
|
||||
var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png");
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Images.Add(image);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var unknownImage = await new SetBumperTemplateBackgroundCommandHandler(db).Handle(
|
||||
new SetBumperTemplateBackgroundCommand(template.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(ImageErrors.NotFound, unknownImage.Error);
|
||||
|
||||
var set = await new SetBumperTemplateBackgroundCommandHandler(db).Handle(
|
||||
new SetBumperTemplateBackgroundCommand(template.Id, image.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(set.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var verify = fixture.New())
|
||||
Assert.Equal(
|
||||
image.Id,
|
||||
(await verify.BumperTemplates.AsNoTracking().SingleAsync()).BackgroundImageId
|
||||
);
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var cleared = await new ClearBumperTemplateBackgroundCommandHandler(db).Handle(
|
||||
new ClearBumperTemplateBackgroundCommand(template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(cleared.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var final = fixture.New();
|
||||
Assert.Null((await final.BumperTemplates.AsNoTracking().SingleAsync()).BackgroundImageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var set = await new SetBumperTemplateBackgroundCommandHandler(db).Handle(
|
||||
new SetBumperTemplateBackgroundCommand(Guid.NewGuid(), Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
var cleared = await new ClearBumperTemplateBackgroundCommandHandler(db).Handle(
|
||||
new ClearBumperTemplateBackgroundCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, set.Error);
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, cleared.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RefusesUsedTemplate_AndRemovesFreeOne()
|
||||
{
|
||||
var (fixture, template) = await SeedAsync();
|
||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
junction
|
||||
.AddElement(JunctionElementKind.Bumper)
|
||||
.Update(
|
||||
new JunctionElementSettings(
|
||||
JunctionElementKind.Bumper,
|
||||
null,
|
||||
template.Id,
|
||||
null,
|
||||
JunctionAmountMode.Count,
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
1,
|
||||
null
|
||||
)
|
||||
);
|
||||
seed.JunctionTemplates.Add(junction);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var used = await new DeleteBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new DeleteBumperTemplateCommand(template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(BumperErrors.TemplateInUse, used.Error);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var stored = await db
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.SingleAsync(j => j.Id == junction.Id);
|
||||
stored.RemoveElement(stored.Elements[0].Id);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var removed = await new DeleteBumperTemplateCommandHandler(db, storage).Handle(
|
||||
new DeleteBumperTemplateCommand(template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(removed.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await storage.Received(1).DeleteAudioAsync(template.Id, Arg.Any<CancellationToken>());
|
||||
await using var final = fixture.New();
|
||||
Assert.Empty(final.BumperTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new DeleteBumperTemplateCommandHandler(
|
||||
db,
|
||||
Substitute.For<IBumperTemplateStorage>()
|
||||
).Handle(new DeleteBumperTemplateCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsTemplatesWithUsageCount()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var used = BumperTemplate.Create("Анонсовый", "Текст 1");
|
||||
used.UpdateStyle(
|
||||
new BumperStyle(
|
||||
"Анонсовый",
|
||||
BumperFont.Serif,
|
||||
"0x111111",
|
||||
"0x222222",
|
||||
"0x333333",
|
||||
"white"
|
||||
)
|
||||
);
|
||||
used.SetAudio(".mp3", 9);
|
||||
used.Variants[0]
|
||||
.SetLines([
|
||||
BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
|
||||
]);
|
||||
var free = BumperTemplate.Create("Блок", "Текст 1");
|
||||
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
foreach (var _ in Enumerable.Range(0, 2))
|
||||
junction
|
||||
.AddElement(JunctionElementKind.Bumper)
|
||||
.Update(
|
||||
new JunctionElementSettings(
|
||||
JunctionElementKind.Bumper,
|
||||
null,
|
||||
used.Id,
|
||||
null,
|
||||
JunctionAmountMode.Count,
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
1,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.BumperTemplates.AddRange(used, free);
|
||||
seed.JunctionTemplates.Add(junction);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var templates = await new ListBumperTemplatesQueryHandler(db).Handle(
|
||||
new ListBumperTemplatesQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Сортировка по имени: «Анонсовый» раньше «Блока».
|
||||
Assert.Equal(["Анонсовый", "Блок"], templates.Select(t => t.Name));
|
||||
var dto = templates[0];
|
||||
Assert.Equal(2, dto.UsageCount);
|
||||
Assert.Equal(BumperFont.Serif, dto.Font);
|
||||
Assert.True(dto.HasAudio);
|
||||
Assert.Equal(9, dto.AudioDurationSeconds);
|
||||
Assert.Equal("{next.title}", Assert.Single(Assert.Single(dto.Variants).Lines).Text);
|
||||
Assert.Equal(0, templates[1].UsageCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user