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:
@@ -71,9 +71,16 @@ public static partial class BumperPlaceholders
|
||||
[GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ExtraSpaces();
|
||||
|
||||
/// <summary>Разделители, которые остаются висеть, когда подставлять оказалось нечего.</summary>
|
||||
private static readonly char[] DanglingSeparators = ['—', '–', '-', ':', '·', ',', ';', '/'];
|
||||
|
||||
/// <summary>
|
||||
/// Подставляет значения. Неизвестное значение даёт пустую строку: «ДАЛЕЕ В {next.time}» без
|
||||
/// следующей программы должно схлопнуться в «ДАЛЕЕ», а не показать дыру в кадре.
|
||||
/// Подставляет значения. Неизвестное значение даёт пустую строку, а осиротевшие разделители
|
||||
/// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
|
||||
/// а не «СЕЙЧАС —».
|
||||
///
|
||||
/// Висящие предлоги («ДАЛЕЕ В») при этом остаются: отличить предлог от слова в тексте, который
|
||||
/// пишет человек на любом языке, нельзя, а угадывать в эфире — хуже, чем оставить как есть.
|
||||
/// </summary>
|
||||
public static string Resolve(string text, BumperContext context)
|
||||
{
|
||||
@@ -82,7 +89,7 @@ public static partial class BumperPlaceholders
|
||||
|
||||
var resolved = TokenPattern()
|
||||
.Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty);
|
||||
return ExtraSpaces().Replace(resolved, " ").Trim();
|
||||
return ExtraSpaces().Replace(resolved, " ").Trim().Trim(DanglingSeparators).Trim();
|
||||
}
|
||||
|
||||
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым
|
||||
/// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа.
|
||||
/// </summary>
|
||||
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<IBumperRenderQueue>(), 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;
|
||||
}
|
||||
|
||||
/// <summary>Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок.</summary>
|
||||
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<JunctionTemplate> JunctionWithElementsAsync(
|
||||
AppDbContext db,
|
||||
Guid junctionId
|
||||
) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.Junctions;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Команды стыка. Стык общий для всех каналов, поэтому проверки здесь про две вещи: источник врезки
|
||||
/// должен существовать, а правка — помечать изменёнными все шаблоны, которые на этот стык ссылаются.
|
||||
/// </summary>
|
||||
public class JunctionHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static JunctionElementInput Input(
|
||||
JunctionElementKind kind = JunctionElementKind.Ad,
|
||||
Guid? groupId = null,
|
||||
Guid? bumperTemplateId = null,
|
||||
Guid? bumperVariantId = null,
|
||||
int amountValue = 2,
|
||||
bool isRequired = false,
|
||||
string? choiceKey = null,
|
||||
int choiceWeight = 1,
|
||||
JunctionConditions? conditions = null
|
||||
) =>
|
||||
new(
|
||||
kind,
|
||||
groupId,
|
||||
bumperTemplateId,
|
||||
bumperVariantId,
|
||||
JunctionAmountMode.Count,
|
||||
amountValue,
|
||||
isRequired,
|
||||
choiceKey,
|
||||
choiceWeight,
|
||||
conditions
|
||||
);
|
||||
|
||||
/// <summary>Канал со своей сеткой и слотом, который ссылается на стык, — минимум для проверки отметок.</summary>
|
||||
private static async Task<(
|
||||
TestDb Fixture,
|
||||
Guid JunctionId,
|
||||
Guid TemplateId,
|
||||
Guid GroupId
|
||||
)> SeedAsync()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Реклама");
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = template.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,
|
||||
null,
|
||||
junction.Id
|
||||
)
|
||||
);
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.JunctionTemplates.Add(junction);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
// Шаблон создаётся уже «изменённым» — снимаем отметку, иначе проверять нечего.
|
||||
template.MarkApplied("{}");
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return (fixture, junction.Id, template.Id, group.Id);
|
||||
}
|
||||
|
||||
private static Task<bool> HasPendingChangesAsync(AppDbContext db, Guid templateId) =>
|
||||
db
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Where(t => t.Id == templateId)
|
||||
.Select(t => t.Revision != t.AppliedRevision)
|
||||
.FirstAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AddsGlobalJunction()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new CreateJunctionCommandHandler(db).Handle(
|
||||
new CreateJunctionCommand(" Ночь "),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await using var verify = fixture.New();
|
||||
var junction = await verify.JunctionTemplates.SingleAsync();
|
||||
Assert.Equal("Ночь", junction.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_RenamesAndMarksReferencingTemplates()
|
||||
{
|
||||
var (fixture, junctionId, templateId, _) = await SeedAsync();
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateJunctionCommandHandler(db).Handle(
|
||||
new UpdateJunctionCommand(junctionId, "Ночь", 180),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await using var verify = fixture.New();
|
||||
var junction = await verify.JunctionTemplates.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("Ночь", junction.Name);
|
||||
Assert.Equal(180, junction.MaxTotalSeconds);
|
||||
// Стык общий: чужой канал не должен молча поехать по новым врезкам без применения.
|
||||
Assert.True(await HasPendingChangesAsync(verify, templateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_UnknownJunction_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new UpdateJunctionCommandHandler(db).Handle(
|
||||
new UpdateJunctionCommand(Guid.NewGuid(), "Ночь", null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RefusesWhenUsedBySlotOrDefault()
|
||||
{
|
||||
var (fixture, junctionId, templateId, _) = await SeedAsync();
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var used = await new DeleteJunctionCommandHandler(db).Handle(
|
||||
new DeleteJunctionCommand(junctionId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.JunctionInUse, used.Error);
|
||||
}
|
||||
|
||||
// Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя.
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var template = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.SingleAsync(t => t.Id == templateId);
|
||||
var slot = template.Layers.SelectMany(l => l.Slots).Single();
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
null,
|
||||
null,
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy
|
||||
)
|
||||
);
|
||||
template.SetDefaultJunction(junctionId);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var asDefault = await new DeleteJunctionCommandHandler(db).Handle(
|
||||
new DeleteJunctionCommand(junctionId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.JunctionInUse, asDefault.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesUnusedJunction()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var junction = JunctionTemplate.Create("Ночь");
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.JunctionTemplates.Add(junction);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new DeleteJunctionCommandHandler(db).Handle(
|
||||
new DeleteJunctionCommand(junction.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await using var verify = fixture.New();
|
||||
Assert.Empty(verify.JunctionTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAndRemoveElement_TouchReferencingTemplates()
|
||||
{
|
||||
var (fixture, junctionId, templateId, _) = await SeedAsync();
|
||||
|
||||
Guid elementId;
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var added = await new AddJunctionElementCommandHandler(db).Handle(
|
||||
new AddJunctionElementCommand(junctionId, JunctionElementKind.Ad),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(added.IsSuccess);
|
||||
elementId = added.Value;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var verify = fixture.New())
|
||||
Assert.True(await HasPendingChangesAsync(verify, templateId));
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var removed = await new RemoveJunctionElementCommandHandler(db).Handle(
|
||||
new RemoveJunctionElementCommand(junctionId, elementId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(removed.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var missing = await new RemoveJunctionElementCommandHandler(db).Handle(
|
||||
new RemoveJunctionElementCommand(junctionId, elementId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.JunctionElementNotFound, missing.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateElement_RequiresExistingSource()
|
||||
{
|
||||
var (fixture, junctionId, _, groupId) = await SeedAsync();
|
||||
var bumper = BumperTemplate.Create("Блок", "Текст 1");
|
||||
Guid elementId;
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
db.BumperTemplates.Add(bumper);
|
||||
var junction = await db
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.SingleAsync(j => j.Id == junctionId);
|
||||
elementId = junction.AddElement(JunctionElementKind.Ad).Id;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var context = fixture.New();
|
||||
var handler = new UpdateJunctionElementCommandHandler(context);
|
||||
|
||||
var noGroup = await handler.Handle(
|
||||
new UpdateJunctionElementCommand(junctionId, elementId, Input()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.JunctionGroupRequired, noGroup.Error);
|
||||
|
||||
var unknownGroup = await handler.Handle(
|
||||
new UpdateJunctionElementCommand(junctionId, elementId, Input(groupId: Guid.NewGuid())),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.GroupNotFound, unknownGroup.Error);
|
||||
|
||||
var noBumper = await handler.Handle(
|
||||
new UpdateJunctionElementCommand(
|
||||
junctionId,
|
||||
elementId,
|
||||
Input(kind: JunctionElementKind.Bumper)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(BumperErrors.TemplateNotFound, noBumper.Error);
|
||||
|
||||
var alienVariant = await handler.Handle(
|
||||
new UpdateJunctionElementCommand(
|
||||
junctionId,
|
||||
elementId,
|
||||
Input(
|
||||
kind: JunctionElementKind.Bumper,
|
||||
bumperTemplateId: bumper.Id,
|
||||
bumperVariantId: Guid.NewGuid()
|
||||
)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(BumperErrors.VariantNotFound, alienVariant.Error);
|
||||
|
||||
var ok = await handler.Handle(
|
||||
new UpdateJunctionElementCommand(
|
||||
junctionId,
|
||||
elementId,
|
||||
Input(
|
||||
groupId: groupId,
|
||||
isRequired: true,
|
||||
conditions: new JunctionConditions(true, 30, [Daypart.Prime], null, 40)
|
||||
)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
await context.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var element = await verify.JunctionElements.AsNoTracking().SingleAsync();
|
||||
var conditions = JunctionConditions.FromJson(element.ConditionsJson);
|
||||
Assert.Equal(groupId, element.GroupId);
|
||||
Assert.True(element.IsRequired);
|
||||
Assert.Equal(40, conditions!.Chance);
|
||||
Assert.Equal([Daypart.Prime], conditions.Dayparts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateElement_UnknownElement_ReturnsNotFound()
|
||||
{
|
||||
var (fixture, junctionId, _, groupId) = await SeedAsync();
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateJunctionElementCommandHandler(db).Handle(
|
||||
new UpdateJunctionElementCommand(junctionId, Guid.NewGuid(), Input(groupId: groupId)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.JunctionElementNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reorder_AppliesOrderAndForkMembership()
|
||||
{
|
||||
var (fixture, junctionId, _, _) = await SeedAsync();
|
||||
Guid first;
|
||||
Guid second;
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var junction = await db
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.SingleAsync(j => j.Id == junctionId);
|
||||
first = junction.AddElement(JunctionElementKind.Ad).Id;
|
||||
second = junction.AddElement(JunctionElementKind.Promo).Id;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
// Перетаскивание меняет порядок и принадлежность к развилке одним жестом.
|
||||
var result = await new ReorderJunctionCommandHandler(db).Handle(
|
||||
new ReorderJunctionCommand(
|
||||
junctionId,
|
||||
[
|
||||
new JunctionElementOrder(second, "fork"),
|
||||
new JunctionElementOrder(first, "fork"),
|
||||
]
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var elements = await verify
|
||||
.JunctionElements.AsNoTracking()
|
||||
.OrderBy(e => e.Position)
|
||||
.ToListAsync();
|
||||
Assert.Equal(second, elements[0].Id);
|
||||
Assert.All(elements, e => Assert.Equal("fork", e.ChoiceKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reorder_UnknownJunction_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new ReorderJunctionCommandHandler(db).Handle(
|
||||
new ReorderJunctionCommand(Guid.NewGuid(), []),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ResolvesNamesAndChannelUsage()
|
||||
{
|
||||
var (fixture, junctionId, templateId, groupId) = await SeedAsync();
|
||||
var bumper = BumperTemplate.Create("Блок", "Текст 1");
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
db.BumperTemplates.Add(bumper);
|
||||
var junction = await db
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.SingleAsync(j => j.Id == junctionId);
|
||||
junction.Update("Прайм", 240);
|
||||
junction
|
||||
.AddElement(JunctionElementKind.Ad)
|
||||
.Update(
|
||||
new JunctionElementSettings(
|
||||
JunctionElementKind.Ad,
|
||||
groupId,
|
||||
null,
|
||||
null,
|
||||
JunctionAmountMode.Count,
|
||||
2,
|
||||
true,
|
||||
null,
|
||||
1,
|
||||
null
|
||||
)
|
||||
);
|
||||
junction
|
||||
.AddElement(JunctionElementKind.Bumper)
|
||||
.Update(
|
||||
new JunctionElementSettings(
|
||||
JunctionElementKind.Bumper,
|
||||
null,
|
||||
bumper.Id,
|
||||
bumper.Variants[0].Id,
|
||||
JunctionAmountMode.Count,
|
||||
1,
|
||||
false,
|
||||
"fork",
|
||||
3,
|
||||
new JunctionConditions(OnlyOnElementChange: true).ToJson()
|
||||
)
|
||||
);
|
||||
|
||||
// Второй канал ссылается на тот же стык — счётчик обязан это увидеть.
|
||||
var other = Channel.Create("Второй", "two", T0);
|
||||
var otherTemplate = ScheduleTemplate.Create(other.Id, "Сетка 2");
|
||||
otherTemplate.SetDefaultJunction(junctionId);
|
||||
other.SetTemplate(otherTemplate.Id);
|
||||
db.Channels.Add(other);
|
||||
db.ScheduleTemplates.Add(otherTemplate);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var read = fixture.New();
|
||||
var junctions = await new ListJunctionsQueryHandler(read).Handle(
|
||||
new ListJunctionsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var dto = Assert.Single(junctions);
|
||||
Assert.Equal("Прайм", dto.Name);
|
||||
Assert.Equal(240, dto.MaxTotalSeconds);
|
||||
Assert.Equal(2, dto.ChannelUsageCount);
|
||||
Assert.Equal("Реклама", dto.Elements[0].GroupName);
|
||||
Assert.Equal("Блок", dto.Elements[1].BumperTemplateName);
|
||||
Assert.Equal("Текст 1", dto.Elements[1].BumperVariantName);
|
||||
Assert.Equal("fork", dto.Elements[1].ChoiceKey);
|
||||
Assert.True(dto.Elements[1].Conditions!.OnlyOnElementChange);
|
||||
Assert.Equal(templateId, templateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conditions_RoundTripAndDaypartFilter()
|
||||
{
|
||||
var conditions = new JunctionConditions(
|
||||
true,
|
||||
30,
|
||||
[Daypart.Prime, Daypart.Night],
|
||||
new JunctionTimeWindow(new TimeOnly(23, 0), new TimeOnly(6, 0)),
|
||||
40
|
||||
);
|
||||
|
||||
var restored = JunctionConditions.FromJson(conditions.ToJson())!;
|
||||
|
||||
// Сравниваем по полям: у записи со списком внутри равенство ссылочное.
|
||||
Assert.Equal(conditions.OnlyOnElementChange, restored.OnlyOnElementChange);
|
||||
Assert.Equal(conditions.MinMinutesBetween, restored.MinMinutesBetween);
|
||||
Assert.Equal(conditions.Dayparts, restored.Dayparts);
|
||||
Assert.Equal(conditions.TimeWindow, restored.TimeWindow);
|
||||
Assert.Equal(conditions.Chance, restored.Chance);
|
||||
Assert.True(restored.AllowsDaypart(Daypart.Prime));
|
||||
Assert.False(restored.AllowsDaypart(Daypart.Morning));
|
||||
// Пустой список дейпартов не ограничивает ничего — иначе врезка молча исчезла бы из эфира.
|
||||
Assert.True(new JunctionConditions().AllowsDaypart(Daypart.Morning));
|
||||
Assert.Null(JunctionConditions.FromJson("{не json}"));
|
||||
Assert.Null(JunctionConditions.FromJson(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Domain.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Шаблон стыка: порядок врезок и развилки. Главный инвариант — врезки одной развилки лежат
|
||||
/// непрерывным отрезком: развилка занимает одно место в цепочке, и разбросанные участники
|
||||
/// не имели бы смысла.
|
||||
/// </summary>
|
||||
public class JunctionTemplateTests
|
||||
{
|
||||
private static JunctionElementSettings Settings(
|
||||
JunctionElementKind kind = JunctionElementKind.Ad,
|
||||
Guid? groupId = null,
|
||||
Guid? bumperTemplateId = null,
|
||||
Guid? bumperVariantId = null,
|
||||
JunctionAmountMode amountMode = JunctionAmountMode.Count,
|
||||
int amountValue = 1,
|
||||
bool isRequired = false,
|
||||
string? choiceKey = null,
|
||||
int choiceWeight = 1,
|
||||
string? conditionsJson = null
|
||||
) =>
|
||||
new(
|
||||
kind,
|
||||
groupId,
|
||||
bumperTemplateId,
|
||||
bumperVariantId,
|
||||
amountMode,
|
||||
amountValue,
|
||||
isRequired,
|
||||
choiceKey,
|
||||
choiceWeight,
|
||||
conditionsJson
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void Create_TrimsName_AndStartsEmpty()
|
||||
{
|
||||
var junction = JunctionTemplate.Create(" Прайм ");
|
||||
|
||||
Assert.Equal("Прайм", junction.Name);
|
||||
Assert.Null(junction.MaxTotalSeconds);
|
||||
Assert.Empty(junction.Elements);
|
||||
Assert.NotEqual(Guid.Empty, junction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_DropsNonPositiveCap()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
|
||||
junction.Update(" Ночь ", 120);
|
||||
Assert.Equal("Ночь", junction.Name);
|
||||
Assert.Equal(120, junction.MaxTotalSeconds);
|
||||
|
||||
junction.Update("Ночь", 0);
|
||||
Assert.Null(junction.MaxTotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddElement_AppendsWithNextPosition()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
|
||||
var first = junction.AddElement(JunctionElementKind.Ad);
|
||||
var second = junction.AddElement(JunctionElementKind.Bumper);
|
||||
|
||||
Assert.Equal(0, first.Position);
|
||||
Assert.Equal(1, second.Position);
|
||||
Assert.Equal(second, junction.FindElement(second.Id));
|
||||
Assert.Null(junction.FindElement(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveElement_RenumbersRest()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var first = junction.AddElement(JunctionElementKind.Ad);
|
||||
var second = junction.AddElement(JunctionElementKind.Promo);
|
||||
var third = junction.AddElement(JunctionElementKind.Filler);
|
||||
|
||||
Assert.True(junction.RemoveElement(first.Id));
|
||||
Assert.False(junction.RemoveElement(Guid.NewGuid()));
|
||||
|
||||
Assert.Equal(0, second.Position);
|
||||
Assert.Equal(1, third.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_PutsRequestedFirst_AndKeepsRestBehind()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var first = junction.AddElement(JunctionElementKind.Ad);
|
||||
var second = junction.AddElement(JunctionElementKind.Promo);
|
||||
var third = junction.AddElement(JunctionElementKind.Filler);
|
||||
|
||||
junction.Reorder([third.Id, first.Id]);
|
||||
|
||||
Assert.Equal(0, third.Position);
|
||||
Assert.Equal(1, first.Position);
|
||||
// Не упомянутые остаются позади, сохраняя относительный порядок.
|
||||
Assert.Equal(2, second.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_PullsForkMembersTogether()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||
var promo = junction.AddElement(JunctionElementKind.Promo);
|
||||
var filler = junction.AddElement(JunctionElementKind.Filler);
|
||||
ad.SetChoice("fork", 1);
|
||||
filler.SetChoice("fork", 3);
|
||||
|
||||
junction.Reorder([ad.Id, promo.Id, filler.Id]);
|
||||
|
||||
// Развилка приезжает к первому упоминанию метки — между ними чужой врезке места нет.
|
||||
Assert.Equal(0, ad.Position);
|
||||
Assert.Equal(1, filler.Position);
|
||||
Assert.Equal(2, promo.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_MovesWholeFork_ByAnyMember()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var bumper = junction.AddElement(JunctionElementKind.Bumper);
|
||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||
var promo = junction.AddElement(JunctionElementKind.Promo);
|
||||
ad.SetChoice("fork", 1);
|
||||
promo.SetChoice("fork", 1);
|
||||
|
||||
// Тащим за второго участника — развилка переезжает целиком.
|
||||
junction.Reorder([promo.Id, bumper.Id]);
|
||||
|
||||
Assert.Equal(0, promo.Position);
|
||||
Assert.Equal(1, ad.Position);
|
||||
Assert.Equal(2, bumper.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementUpdate_KeepsOnlySourceOfItsKind()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var element = junction.AddElement(JunctionElementKind.Ad);
|
||||
var group = Guid.NewGuid();
|
||||
var template = Guid.NewGuid();
|
||||
var variant = Guid.NewGuid();
|
||||
|
||||
element.Update(Settings(groupId: group, bumperTemplateId: template, amountValue: 4));
|
||||
Assert.Equal(group, element.GroupId);
|
||||
// Ссылка от чужого типа потом читалась бы генератором как настройка.
|
||||
Assert.Null(element.BumperTemplateId);
|
||||
Assert.Equal(4, element.AmountValue);
|
||||
|
||||
element.Update(
|
||||
Settings(
|
||||
kind: JunctionElementKind.Bumper,
|
||||
groupId: group,
|
||||
bumperTemplateId: template,
|
||||
bumperVariantId: variant
|
||||
)
|
||||
);
|
||||
Assert.Null(element.GroupId);
|
||||
Assert.Equal(template, element.BumperTemplateId);
|
||||
Assert.Equal(variant, element.BumperVariantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementUpdate_ClampsValues_AndBlankStringsBecomeNull()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var element = junction.AddElement(JunctionElementKind.Ad);
|
||||
|
||||
element.Update(
|
||||
Settings(
|
||||
groupId: Guid.NewGuid(),
|
||||
amountValue: 0,
|
||||
choiceKey: " ",
|
||||
choiceWeight: -5,
|
||||
conditionsJson: " "
|
||||
)
|
||||
);
|
||||
|
||||
Assert.Equal(1, element.AmountValue);
|
||||
Assert.Null(element.ChoiceKey);
|
||||
Assert.Equal(0, element.ChoiceWeight);
|
||||
Assert.Null(element.ConditionsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetChoice_TrimsKey_AndClampsWeight()
|
||||
{
|
||||
var junction = JunctionTemplate.Create("Прайм");
|
||||
var element = junction.AddElement(JunctionElementKind.Ad);
|
||||
|
||||
element.SetChoice(" fork ", -2);
|
||||
Assert.Equal("fork", element.ChoiceKey);
|
||||
Assert.Equal(0, element.ChoiceWeight);
|
||||
|
||||
element.SetChoice(null, 3);
|
||||
Assert.Null(element.ChoiceKey);
|
||||
Assert.Equal(3, element.ChoiceWeight);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user