diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd24523..516923b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -137,15 +137,22 @@ jobs: # Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его. # Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture). + # + # Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик + # покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar + # покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер + # подбирает их маской. - name: Test + coverage if: env.SONAR_TOKEN != '' - run: > - dotnet test backend/TeleWave.slnx - -c Release - --no-build - --collect:"XPlat Code Coverage;Format=opencover" - --results-directory backend/coverage - --logger "console;verbosity=normal" + run: | + for project in Domain Application Integration; do + dotnet test "backend/tests/TeleWave.$project.Tests" \ + -c Release \ + --no-build \ + --collect:"XPlat Code Coverage;Format=opencover" \ + --results-directory backend/coverage \ + --logger "console;verbosity=normal" + done - name: End analysis if: env.SONAR_TOKEN != '' diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs index edf92e6..f681b43 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -71,9 +71,16 @@ public static partial class BumperPlaceholders [GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)] private static partial Regex ExtraSpaces(); + /// Разделители, которые остаются висеть, когда подставлять оказалось нечего. + private static readonly char[] DanglingSeparators = ['—', '–', '-', ':', '·', ',', ';', '/']; + /// - /// Подставляет значения. Неизвестное значение даёт пустую строку: «ДАЛЕЕ В {next.time}» без - /// следующей программы должно схлопнуться в «ДАЛЕЕ», а не показать дыру в кадре. + /// Подставляет значения. Неизвестное значение даёт пустую строку, а осиротевшие разделители + /// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС», + /// а не «СЕЙЧАС —». + /// + /// Висящие предлоги («ДАЛЕЕ В») при этом остаются: отличить предлог от слова в тексте, который + /// пишет человек на любом языке, нельзя, а угадывать в эфире — хуже, чем оставить как есть. /// 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(); } /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить. diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs new file mode 100644 index 0000000..c3a9624 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs @@ -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; + +/// +/// Подстановка плейсхолдеров. Правило одно и важное: неизвестное значение схлопывается вместе +/// с осиротевшим разделителем, а не оставляет дыру в кадре. +/// +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 + { + 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)); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs new file mode 100644 index 0000000..9aa9f60 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs @@ -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; + +/// +/// Подстановка ассетов заставкам после сборки ленты: текст с подставленными плейсхолдерами, +/// кэш по сигнатуре содержимого и выбор подблока. Всё это невозможно проверить на планировщике — +/// пара соседей и время показа известны только здесь. +/// +public class BumperResolverTests +{ + private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero); + + private sealed class FakeQueue : IBumperRenderQueue + { + public List 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; + } + + /// Лента «программа → заставка → программа»: ровно тот случай, ради которого всё и есть. + private static List 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? 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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs new file mode 100644 index 0000000..f88fba1 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs @@ -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; + +/// +/// Восстановление спецификации для фонового рендерера. Текст берётся из кэша как есть: время показа +/// и пара соседей задним числом не восстанавливаются, а ffmpeg запускается уже после записи ленты. +/// +public class BumperSpecLoaderTests +{ + private static readonly IReadOnlyList 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(), + images ?? Substitute.For(), + 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(); + storage.AudioPath(template.Id, ".mp3").Returns("/media/bumpers/audio.mp3"); + var images = Substitute.For(); + 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)); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs new file mode 100644 index 0000000..71ac6fa --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs @@ -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; + +/// +/// Команды блока заставки. Блок общий для всех каналов, поэтому удаление используемого запрещено: +/// иначе оно молча выключило бы заставки в чужих каналах. +/// +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(); + + 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()); + 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(); + + 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(); + 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()); + 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() + ).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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridJunctionPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridJunctionPreviewTests.cs new file mode 100644 index 0000000..886243c --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridJunctionPreviewTests.cs @@ -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; + +/// +/// Разворачивание стыка генератором: то, что происходит между шаблоном стыка в базе и чистым +/// планировщиком. Проверяется сухим прогоном — он ничего не пишет, но проходит тот же сбор входа. +/// +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(), 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; + } + + /// Канал с одним слотом на сутки, группой из двух серий и группой роликов для врезок. + 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 JunctionWithElementsAsync( + AppDbContext db, + Guid junctionId + ) => db.JunctionTemplates.Include(j => j.Elements).SingleAsync(j => j.Id == junctionId); +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs new file mode 100644 index 0000000..617a99f --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs @@ -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; + +/// +/// Команды стыка. Стык общий для всех каналов, поэтому проверки здесь про две вещи: источник врезки +/// должен существовать, а правка — помечать изменёнными все шаблоны, которые на этот стык ссылаются. +/// +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 + ); + + /// Канал со своей сеткой и слотом, который ссылается на стык, — минимум для проверки отметок. + 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 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)); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionTemplateTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionTemplateTests.cs new file mode 100644 index 0000000..95ea195 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionTemplateTests.cs @@ -0,0 +1,207 @@ +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +/// +/// Шаблон стыка: порядок врезок и развилки. Главный инвариант — врезки одной развилки лежат +/// непрерывным отрезком: развилка занимает одно место в цепочке, и разбросанные участники +/// не имели бы смысла. +/// +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); + } +}