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