diff --git a/backend/tests/TeleWave.Application.Tests/Library/AddShowsToCollectionTests.cs b/backend/tests/TeleWave.Application.Tests/Library/AddShowsToCollectionTests.cs
new file mode 100644
index 0000000..d1558a5
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Library/AddShowsToCollectionTests.cs
@@ -0,0 +1,144 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Library.Collections.AddShowsToCollection;
+using TeleWave.Application.Library.Genres;
+using TeleWave.Application.Metadata;
+using TeleWave.Application.Metadata.EnrichShows;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Library;
+
+/// Массовые операции над отмеченными шоу: раскладка по франшизам и обогащение.
+public class AddShowsToCollectionTests
+{
+ [Fact]
+ public async Task AddsToExistingCollection_SkippingThoseAlreadyIn()
+ {
+ var fixture = new TestDb();
+ var inside = Show.Create("Уже внутри", ShowKind.Single);
+ var outside = Show.Create("Ещё нет", ShowKind.Single);
+ var collection = Collection.Create("Франшиза");
+ collection.AddShow(inside.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(inside, outside);
+ seed.Collections.Add(collection);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var result = await new AddShowsToCollectionCommandHandler(db).Handle(
+ new AddShowsToCollectionCommand([inside.Id, outside.Id], collection.Id),
+ CancellationToken.None
+ );
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ // Повторное добавление молча пропускается: падать из-за одного повтора бессмысленно.
+ Assert.Equal(1, result.Value.Added);
+ Assert.Equal("Франшиза", result.Value.Name);
+
+ await using var check = fixture.New();
+ Assert.Equal(2, check.Collections.Include(c => c.Items).Single().Items.Count);
+ }
+
+ [Fact]
+ public async Task Fails_WhenCollectionIsGone()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Single);
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var result = await new AddShowsToCollectionCommandHandler(db).Handle(
+ new AddShowsToCollectionCommand([show.Id], Guid.NewGuid()),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Fails_WhenNoneOfTheShowsExist()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await new AddShowsToCollectionCommandHandler(db).Handle(
+ new AddShowsToCollectionCommand([Guid.NewGuid()], NewCollectionName: "Франшиза"),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Enrich_FailsWhenProviderUnavailable()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+ var resolver = Substitute.For();
+ // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null, и без этого
+ // «источник не настроен» не воспроизводится.
+ resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null);
+
+ var result = await new EnrichShowsMetadataCommandHandler(
+ db,
+ resolver,
+ new ShowMetadataApplier(
+ db,
+ resolver,
+ Substitute.For(),
+ Substitute.For(),
+ new GenreMatcher(db)
+ )
+ ).Handle(new EnrichShowsMetadataCommand([Guid.NewGuid()], "tmdb"), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Enrich_IgnoresInterstitials()
+ {
+ // Ролики метаданными не обогащаются: у них нет ни года, ни постера.
+ var fixture = new TestDb();
+ var clip = Show.Create("Ролик", ShowKind.Interstitial);
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(clip);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var provider = Substitute.For();
+ var resolver = Substitute.For();
+ resolver.Resolve("tmdb").Returns(provider);
+
+ await using var db = fixture.New();
+ var result = await new EnrichShowsMetadataCommandHandler(
+ db,
+ resolver,
+ new ShowMetadataApplier(
+ db,
+ resolver,
+ Substitute.For(),
+ Substitute.For(),
+ new GenreMatcher(db)
+ )
+ ).Handle(new EnrichShowsMetadataCommand([clip.Id], "tmdb"), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(0, result.Value.Applied);
+ Assert.Empty(result.Value.Skipped);
+ await provider
+ .DidNotReceive()
+ .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Library/ShowUsageTests.cs b/backend/tests/TeleWave.Application.Tests/Library/ShowUsageTests.cs
new file mode 100644
index 0000000..8e135b5
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Library/ShowUsageTests.cs
@@ -0,0 +1,144 @@
+using TeleWave.Application.Library.GetShowUsage;
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Library;
+
+///
+/// «Где используется» для шоу: удаление отбивается ошибкой «используется», и без этого списка
+/// непонятно, кем именно.
+///
+public class ShowUsageTests
+{
+ [Fact]
+ public async Task ReportsExplicitGroupMembership()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Series);
+ var group = Group.Create("Явная");
+ group.AddElement(GroupElementKind.Show, show.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var usage = await GetAsync(fixture, show.Id);
+
+ var reference = Assert.Single(usage.Groups);
+ Assert.Equal("Явная", reference.Name);
+ Assert.False(reference.ViaRule);
+ }
+
+ [Fact]
+ public async Task ReportsMembershipByRule()
+ {
+ // У динамической группы шоу входит без своей позиции — для «можно ли удалять» это то же самое.
+ var fixture = new TestDb();
+ var genre = Genre.Create("Мультфильм", "animation");
+ var show = Show.Create("Шоу", ShowKind.Series);
+ show.SetGenres([genre.Id]);
+
+ var group = Group.Create("По правилу");
+ group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
+ group.SetMode(GroupMode.Dynamic);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.Add(genre);
+ seed.Shows.Add(show);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var usage = await GetAsync(fixture, show.Id);
+
+ Assert.True(Assert.Single(usage.Groups).ViaRule);
+ }
+
+ [Fact]
+ public async Task ReportsCollectionsAndGroupsReachedThroughThem()
+ {
+ // В группу шоу попадает и через франшизу, целиком — это тоже использование.
+ var fixture = new TestDb();
+ var show = Show.Create("Часть", ShowKind.Single);
+ var collection = Collection.Create("Франшиза");
+ collection.AddShow(show.Id);
+ var group = Group.Create("С франшизой");
+ group.AddElement(GroupElementKind.Collection, collection.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ seed.Collections.Add(collection);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var usage = await GetAsync(fixture, show.Id);
+
+ Assert.Equal("Франшиза", Assert.Single(usage.Collections).Name);
+ Assert.Equal("С франшизой", Assert.Single(usage.Groups).Name);
+ }
+
+ [Fact]
+ public async Task ReportsChannelsWithScheduledEntries()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Series);
+ var channel = Channel.Create("Первый", "one", DateTimeOffset.UtcNow);
+ var start = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ seed.Channels.Add(channel);
+ seed.ScheduleEntries.Add(
+ ScheduleEntry.Program(
+ channel.Id,
+ Guid.NewGuid(),
+ start,
+ start.AddMinutes(30),
+ show.Id,
+ 0
+ )
+ );
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var usage = await GetAsync(fixture, show.Id);
+
+ Assert.Equal("Первый", Assert.Single(usage.Channels).Name);
+ }
+
+ [Fact]
+ public async Task UnknownShow_ReturnsNotFound()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await new GetShowUsageQueryHandler(db, GroupServices.Dynamic(db)).Handle(
+ new GetShowUsageQuery(Guid.NewGuid()),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ private static async Task GetAsync(TestDb fixture, Guid showId)
+ {
+ await using var db = fixture.New();
+ var result = await new GetShowUsageQueryHandler(db, GroupServices.Dynamic(db)).Handle(
+ new GetShowUsageQuery(showId),
+ CancellationToken.None
+ );
+ Assert.True(result.IsSuccess);
+ return result.Value;
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/ShowMetadataApplierTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/ShowMetadataApplierTests.cs
new file mode 100644
index 0000000..1eb4740
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Metadata/ShowMetadataApplierTests.cs
@@ -0,0 +1,170 @@
+using System.Text;
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Library.Genres;
+using TeleWave.Application.Metadata;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Images;
+using TeleWave.Domain.Library;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Metadata;
+
+///
+/// Применение метаданных: постер в реестр изображений, жанры, франшиза и отказы. Общий путь для
+/// точечного и массового применения — расхождение между ними отлаживать было бы нечем.
+///
+public class ShowMetadataApplierTests
+{
+ [Fact]
+ public async Task RegistersPoster_InImageRegistry()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Single);
+ await SeedAsync(fixture, show);
+
+ var downloader = Substitute.For();
+ downloader
+ .DownloadAsync("https://poster", Arg.Any())
+ .Returns(new DownloadedImage(Encoding.UTF8.GetBytes("img"), ".jpg"));
+ var store = Substitute.For();
+
+ await using var db = fixture.New();
+ var result = await Applier(db, Meta(posterUrl: "https://poster"), downloader, store)
+ .ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
+ Assert.True(result.IsSuccess);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await using var check = fixture.New();
+ var image = check.Images.Single();
+ Assert.Equal(ImageCategory.ShowPoster, image.Category);
+ Assert.Equal(image.Id, check.Shows.Single().PosterImageId);
+ await store
+ .Received(1)
+ .SaveAsync(image.Id, ".jpg", Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task KeepsPoster_WhenDownloadFails()
+ {
+ // Источник отдал ссылку, но картинка не скачалась — это не повод валить применение.
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Single);
+ await SeedAsync(fixture, show);
+
+ var downloader = Substitute.For();
+ downloader
+ .DownloadAsync(Arg.Any(), Arg.Any())
+ .Returns((DownloadedImage?)null);
+
+ await using var db = fixture.New();
+ var result = await Applier(db, Meta(posterUrl: "https://poster"), downloader)
+ .ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.Empty(db.Images);
+ }
+
+ [Fact]
+ public async Task RemembersFranchise_FromSource()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Рокки", ShowKind.Single);
+ await SeedAsync(fixture, show);
+
+ await using var db = fixture.New();
+ await Applier(db, Meta(franchise: new MetadataFranchise("1241", "Рокки")))
+ .ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await using var check = fixture.New();
+ var stored = check.Shows.Single();
+ Assert.Equal("1241", stored.FranchiseExternalId);
+ Assert.Equal("Рокки", stored.FranchiseName);
+ }
+
+ [Fact]
+ public async Task Fails_WhenProviderUnavailable()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Single);
+ await SeedAsync(fixture, show);
+
+ await using var db = fixture.New();
+ var resolver = Substitute.For();
+ var applier = new ShowMetadataApplier(
+ db,
+ resolver,
+ Substitute.For(),
+ Substitute.For(),
+ new GenreMatcher(db)
+ );
+
+ var result = await applier.ApplyAsync(
+ await LoadAsync(db, show.Id),
+ "tmdb",
+ "42",
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Fails_WhenSourceKnowsNothing()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Шоу", ShowKind.Single);
+ await SeedAsync(fixture, show);
+
+ await using var db = fixture.New();
+ var result = await Applier(db, meta: null)
+ .ApplyAsync(await LoadAsync(db, show.Id), "tmdb", "42", CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ private static ShowMetadata Meta(
+ string? posterUrl = null,
+ MetadataFranchise? franchise = null
+ ) => new("42", "Шоу", 2000, "описание", posterUrl, [], null, franchise);
+
+ private static ShowMetadataApplier Applier(
+ Infrastructure.Persistence.AppDbContext db,
+ ShowMetadata? meta,
+ IImageDownloader? downloader = null,
+ IImageStore? store = null
+ )
+ {
+ var provider = Substitute.For();
+ provider
+ .GetShowAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(meta);
+ var resolver = Substitute.For();
+ resolver.Resolve("tmdb").Returns(provider);
+
+ return new ShowMetadataApplier(
+ db,
+ resolver,
+ downloader ?? Substitute.For(),
+ store ?? Substitute.For(),
+ new GenreMatcher(db)
+ );
+ }
+
+ private static async Task SeedAsync(TestDb fixture, Show show)
+ {
+ await using var seed = fixture.New();
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ private static async Task LoadAsync(
+ Infrastructure.Persistence.AppDbContext db,
+ Guid showId
+ ) =>
+ await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstAsync(
+ db.Shows,
+ s => s.Id == showId
+ );
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs
new file mode 100644
index 0000000..2b4c298
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs
@@ -0,0 +1,166 @@
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Groups.GetGroup;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Карточка группы. Главное свойство — показанное совпадает с тем, что уйдёт в эфир: состав
+/// считается на месте, а не читается из кэша статистики.
+///
+public class GetGroupTests
+{
+ [Fact]
+ public async Task StaticGroup_ReturnsItemsWithIdsAndStats()
+ {
+ var fixture = new TestDb();
+ var asset = ReadyAsset(TimeSpan.FromMinutes(30));
+ var show = Show.Create("Шоу", ShowKind.Series);
+ show.AddEpisode(asset.Id);
+
+ var group = Group.Create("Статическая");
+ group.AddElement(GroupElementKind.Show, show.Id);
+
+ await SeedAsync(fixture, group, [show], [asset]);
+
+ var dto = await GetAsync(fixture, group.Id);
+
+ var item = Assert.Single(dto.Items);
+ // У позиции статической группы есть своя строка — её можно двигать и взвешивать.
+ Assert.NotNull(item.Id);
+ Assert.False(item.Pinned);
+ Assert.Equal("Шоу", item.ElementName);
+ Assert.Equal(1, dto.UnitCount);
+ Assert.Equal(TimeSpan.FromMinutes(30).TotalSeconds, dto.TotalDurationSeconds);
+ Assert.Empty(dto.Excluded);
+ }
+
+ [Fact]
+ public async Task DynamicGroup_ShowsRuleMatchesWithoutItemId()
+ {
+ var fixture = new TestDb();
+ var genre = Genre.Create("Мультфильм", "animation");
+ var byRule = Show.Create("По правилу", ShowKind.Series);
+ byRule.SetGenres([genre.Id]);
+ var pinned = Show.Create("Закреплённое", ShowKind.Single);
+
+ var group = Group.Create("Динамическая");
+ group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
+ group.AddElement(GroupElementKind.Show, pinned.Id);
+ group.SetMode(GroupMode.Dynamic);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.Add(genre);
+ seed.Shows.AddRange(byRule, pinned);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var dto = await GetAsync(fixture, group.Id);
+
+ Assert.Equal(GroupMode.Dynamic, dto.Mode);
+ var first = dto.Items[0];
+ Assert.True(first.Pinned);
+ Assert.NotNull(first.Id);
+
+ var second = dto.Items[1];
+ Assert.False(second.Pinned);
+ // Найденному правилом строки в таблице не отвечает — значит, ни веса, ни порядка.
+ Assert.Null(second.Id);
+ }
+
+ [Fact]
+ public async Task DynamicGroup_ListsExclusionsSeparately()
+ {
+ // Иначе непонятно, почему шоу нет в составе, хотя правило его находит.
+ var fixture = new TestDb();
+ var genre = Genre.Create("Мультфильм", "animation");
+ var dropped = Show.Create("Исключённое", ShowKind.Series);
+ dropped.SetGenres([genre.Id]);
+
+ var group = Group.Create("Динамическая");
+ group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
+ group.SetMode(GroupMode.Dynamic);
+ group.Exclude(GroupElementKind.Show, dropped.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.Add(genre);
+ seed.Shows.Add(dropped);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var dto = await GetAsync(fixture, group.Id);
+
+ Assert.Empty(dto.Items);
+ var excluded = Assert.Single(dto.Excluded);
+ Assert.Equal("Исключённое", excluded.ElementName);
+ }
+
+ [Fact]
+ public async Task MissingElement_ShowsDashInsteadOfFailing()
+ {
+ // Элемент мог исчезнуть из библиотеки между чисткой и чтением — экран группы падать не должен.
+ var fixture = new TestDb();
+ var group = Group.Create("Со следом");
+ group.AddElement(GroupElementKind.Show, Guid.NewGuid());
+ await SeedAsync(fixture, group, [], []);
+
+ var dto = await GetAsync(fixture, group.Id);
+
+ Assert.Equal("—", Assert.Single(dto.Items).ElementName);
+ }
+
+ [Fact]
+ public async Task UnknownGroup_ReturnsNotFound()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await Handler(db)
+ .Handle(new GetGroupQuery(Guid.NewGuid()), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ private static MediaAsset ReadyAsset(TimeSpan duration)
+ {
+ var asset = MediaAsset.Register($"{Guid.NewGuid():N}.mkv", ".mkv", MediaSource.Upload);
+ asset.MarkProcessing();
+ asset.MarkReady(
+ new MediaReadyInfo(duration, 2, 900, 1920, 1080, "h264", "aac", "assets/x")
+ );
+ return asset;
+ }
+
+ private static async Task SeedAsync(
+ TestDb fixture,
+ Group group,
+ Show[] shows,
+ MediaAsset[] assets
+ )
+ {
+ await using var seed = fixture.New();
+ seed.MediaAssets.AddRange(assets);
+ seed.Shows.AddRange(shows);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ private static GetGroupQueryHandler Handler(Infrastructure.Persistence.AppDbContext db) =>
+ new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
+
+ private static async Task GetAsync(TestDb fixture, Guid groupId)
+ {
+ await using var db = fixture.New();
+ var result = await Handler(db).Handle(new GetGroupQuery(groupId), CancellationToken.None);
+ Assert.True(result.IsSuccess);
+ return result.Value;
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs
new file mode 100644
index 0000000..a43c0ce
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs
@@ -0,0 +1,215 @@
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Правило набора: по нему ищет кандидатов карточка группы и по нему же считается состав
+/// динамической группы. Каждое поле проверяем отдельно — ошибка в любом уводит в эфир не то.
+///
+public class GroupFilterMatcherTests
+{
+ [Fact]
+ public async Task ShowKinds_NarrowSelection()
+ {
+ var fixture = new TestDb();
+ var series = Show.Create("Сериал", ShowKind.Series);
+ var movie = Show.Create("Фильм", ShowKind.Single);
+ await SeedAsync(fixture, series, movie);
+
+ var matched = await MatchAsync(fixture, new GroupFilter(ShowKinds: [ShowKind.Single]));
+
+ Assert.Equal([(GroupElementKind.Show, movie.Id)], matched);
+ }
+
+ [Fact]
+ public async Task MaxAudience_KeepsUnrated()
+ {
+ // Источники проставляют рейтинг далеко не всему: выбрасывать непроставленное значило бы
+ // оставить от «мультики не строже PG» горстку голливудского.
+ var fixture = new TestDb();
+ var kids = Show.Create("Детское", ShowKind.Series);
+ kids.SetAudience(ShowAudience.G);
+ var adult = Show.Create("Взрослое", ShowKind.Series);
+ adult.SetAudience(ShowAudience.R);
+ var unrated = Show.Create("Без рейтинга", ShowKind.Series);
+ await SeedAsync(fixture, kids, adult, unrated);
+
+ var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));
+
+ Assert.Contains((GroupElementKind.Show, kids.Id), matched);
+ Assert.Contains((GroupElementKind.Show, unrated.Id), matched);
+ Assert.DoesNotContain((GroupElementKind.Show, adult.Id), matched);
+ }
+
+ [Fact]
+ public async Task MinAudience_DropsUnrated()
+ {
+ // У нижней границы правило обратное: «R и старше» с неизвестным рейтингом означало бы
+ // мультики в ночном слоте — ровно ту ошибку, ради которой границу и заводят.
+ var fixture = new TestDb();
+ var adult = Show.Create("Взрослое", ShowKind.Series);
+ adult.SetAudience(ShowAudience.R);
+ var unrated = Show.Create("Без рейтинга", ShowKind.Series);
+ await SeedAsync(fixture, adult, unrated);
+
+ var matched = await MatchAsync(fixture, new GroupFilter(MinAudience: ShowAudience.R));
+
+ Assert.Equal([(GroupElementKind.Show, adult.Id)], matched);
+ }
+
+ [Fact]
+ public async Task Year_BoundsAreInclusive_AndDropUnknown()
+ {
+ var fixture = new TestDb();
+ var inRange = WithYear(Show.Create("В диапазоне", ShowKind.Single), 2000);
+ var older = WithYear(Show.Create("Старое", ShowKind.Single), 1999);
+ var unknown = Show.Create("Без года", ShowKind.Single);
+ await SeedAsync(fixture, inRange, older, unknown);
+
+ var matched = await MatchAsync(fixture, new GroupFilter(YearMin: 2000, YearMax: 2000));
+
+ Assert.Equal([(GroupElementKind.Show, inRange.Id)], matched);
+ }
+
+ [Fact]
+ public async Task Genres_MatchAnyOfSelected()
+ {
+ var fixture = new TestDb();
+ var action = Genre.Create("Боевик", "action");
+ var drama = Genre.Create("Драма", "drama");
+ var first = Show.Create("Боевик", ShowKind.Single);
+ first.SetGenres([action.Id]);
+ var second = Show.Create("Драма", ShowKind.Single);
+ second.SetGenres([drama.Id]);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.AddRange(action, drama);
+ seed.Shows.AddRange(first, second);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var matched = await MatchAsync(fixture, new GroupFilter(GenreIds: [action.Id]));
+
+ Assert.Equal([(GroupElementKind.Show, first.Id)], matched);
+ }
+
+ [Fact]
+ public async Task Collection_MatchesOnlyWhenEveryPartDoes()
+ {
+ // Иначе во «взрослую ночь» через франшизу просочилось бы детское.
+ var fixture = new TestDb();
+ var kids = Show.Create("Детская часть", ShowKind.Single);
+ kids.SetAudience(ShowAudience.G);
+ var adult = Show.Create("Взрослая часть", ShowKind.Single);
+ adult.SetAudience(ShowAudience.R);
+
+ var pure = Collection.Create("Только детское");
+ pure.AddShow(kids.Id);
+ var mixed = Collection.Create("Смешанная");
+ mixed.AddShow(kids.Id);
+ mixed.AddShow(adult.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(kids, adult);
+ seed.Collections.AddRange(pure, mixed);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));
+
+ Assert.Contains((GroupElementKind.Collection, pure.Id), matched);
+ Assert.DoesNotContain((GroupElementKind.Collection, mixed.Id), matched);
+ }
+
+ [Fact]
+ public async Task ElementKinds_LimitWhatIsSearched()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Фильм", ShowKind.Single);
+ var collection = Collection.Create("Франшиза");
+ collection.AddShow(show.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ seed.Collections.Add(collection);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var onlyCollections = await MatchAsync(
+ fixture,
+ new GroupFilter(ElementKinds: [GroupElementKind.Collection])
+ );
+
+ Assert.Equal([(GroupElementKind.Collection, collection.Id)], onlyCollections);
+ }
+
+ [Fact]
+ public void UnitDuration_ChecksAverageUnit_AndKeepsUnprocessed()
+ {
+ var filter = new GroupFilter(UnitMinutesMin: 20, UnitMinutesMax: 30);
+
+ // Сериал из двух серий по 22 минуты — подходит по средней длине единицы, а не по сумме.
+ Assert.True(
+ GroupFilterMatcher.MatchesUnitDuration(
+ filter,
+ Element(units: 2, total: TimeSpan.FromMinutes(44))
+ )
+ );
+ Assert.False(
+ GroupFilterMatcher.MatchesUnitDuration(
+ filter,
+ Element(units: 1, total: TimeSpan.FromMinutes(120))
+ )
+ );
+ Assert.False(
+ GroupFilterMatcher.MatchesUnitDuration(
+ filter,
+ Element(units: 1, total: TimeSpan.FromMinutes(5))
+ )
+ );
+ // Ещё не обработанное длительности не имеет — такое фильтр не отбрасывает.
+ Assert.True(
+ GroupFilterMatcher.MatchesUnitDuration(filter, Element(units: 0, total: TimeSpan.Zero))
+ );
+ // Без границ проверять нечего.
+ Assert.True(
+ GroupFilterMatcher.MatchesUnitDuration(
+ new GroupFilter(),
+ Element(units: 1, total: TimeSpan.FromMinutes(120))
+ )
+ );
+ }
+
+ private static GroupElementInfo Element(int units, TimeSpan total) =>
+ new("Элемент", units, total, ShowKind.Series, null, null, null);
+
+ private static Show WithYear(Show show, int year)
+ {
+ show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
+ return show;
+ }
+
+ private static async Task SeedAsync(TestDb fixture, params Show[] shows)
+ {
+ await using var seed = fixture.New();
+ seed.Shows.AddRange(shows);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ private static async Task> MatchAsync(
+ TestDb fixture,
+ GroupFilter filter
+ )
+ {
+ await using var db = fixture.New();
+ return await new GroupFilterMatcher(db).MatchAsync(filter, CancellationToken.None);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs
new file mode 100644
index 0000000..0843b4f
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs
@@ -0,0 +1,203 @@
+using TeleWave.Application.Library.Collections.Suggest;
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Groups.Suggest;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Отдача предложений наружу: уже созданное помечается, но из списка не пропадает — исчезнувшее
+/// без следа предложение читается как сбой, а «уже есть» объясняет, почему кнопки нет.
+///
+public class SuggestionQueriesTests
+{
+ [Fact]
+ public async Task Groups_MarkExistingByName()
+ {
+ var fixture = new TestDb();
+ var genre = Genre.Create("Мультфильм", "animation");
+ var first = WithGenre(Show.Create("A", ShowKind.Series), genre);
+ var second = WithGenre(Show.Create("B", ShowKind.Series), genre);
+ // Регистр не важен: в слоте две «мультфильм» всё равно неразличимы.
+ var existing = Group.Create("мультфильм");
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.Add(genre);
+ seed.Shows.AddRange(first, second);
+ seed.Groups.Add(existing);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var suggestions = await new SuggestGroupsQueryHandler(
+ db,
+ new GroupSuggestionBuilder(db, new GroupElementResolver(db))
+ ).Handle(new SuggestGroupsQuery(), CancellationToken.None);
+
+ var genreSuggestion = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre);
+ Assert.True(genreSuggestion.AlreadyExists);
+ Assert.Equal(2, genreSuggestion.ShowCount);
+ }
+
+ [Fact]
+ public async Task Groups_MarkExistingByFilter()
+ {
+ // Группу могли переименовать — правило всё равно то же самое.
+ var fixture = new TestDb();
+ var genre = Genre.Create("Мультфильм", "animation");
+ var first = WithGenre(Show.Create("A", ShowKind.Series), genre);
+ var second = WithGenre(Show.Create("B", ShowKind.Series), genre);
+ var renamed = Group.Create("Своё название");
+ renamed.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
+
+ await using (var seed = fixture.New())
+ {
+ seed.Genres.Add(genre);
+ seed.Shows.AddRange(first, second);
+ seed.Groups.Add(renamed);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var suggestions = await new SuggestGroupsQueryHandler(
+ db,
+ new GroupSuggestionBuilder(db, new GroupElementResolver(db))
+ ).Handle(new SuggestGroupsQuery(), CancellationToken.None);
+
+ Assert.True(
+ Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre).AlreadyExists
+ );
+ }
+
+ [Fact]
+ public async Task Groups_EmptyLibrary_SuggestsNothing()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var suggestions = await new SuggestGroupsQueryHandler(
+ db,
+ new GroupSuggestionBuilder(db, new GroupElementResolver(db))
+ ).Handle(new SuggestGroupsQuery(), CancellationToken.None);
+
+ Assert.Empty(suggestions);
+ }
+
+ [Fact]
+ public async Task Collections_MarkExistingAndCarryParts()
+ {
+ var fixture = new TestDb();
+ var first = Movie("Рокки", 1976);
+ var second = Movie("Рокки 2", 1979);
+ var existing = Collection.Create("рокки");
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(first, second);
+ seed.Collections.Add(existing);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var suggestions = await new SuggestCollectionsQueryHandler(
+ db,
+ new CollectionSuggestionBuilder(db)
+ ).Handle(new SuggestCollectionsQuery(), CancellationToken.None);
+
+ var suggestion = Assert.Single(suggestions);
+ Assert.True(suggestion.AlreadyExists);
+ // Состав едет наружу целиком: по нему видно, промахнулась ли догадка.
+ Assert.Equal([first.Id, second.Id], suggestion.Parts.Select(p => p.ShowId));
+ Assert.Equal(1976, suggestion.Parts[0].Year);
+ }
+
+ [Fact]
+ public async Task Collections_EmptyLibrary_SuggestsNothing()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var suggestions = await new SuggestCollectionsQueryHandler(
+ db,
+ new CollectionSuggestionBuilder(db)
+ ).Handle(new SuggestCollectionsQuery(), CancellationToken.None);
+
+ Assert.Empty(suggestions);
+ }
+
+ [Fact]
+ public async Task Collections_CreateFromSuggestion_FailsOnUnknownKey()
+ {
+ var fixture = new TestDb();
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(Movie("Рокки", 1976), Movie("Рокки 2", 1979));
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var result = await new CreateCollectionFromSuggestionCommandHandler(
+ db,
+ new CollectionSuggestionBuilder(db)
+ ).Handle(
+ new CreateCollectionFromSuggestionCommand("name:нет-такого"),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Collections_CreateFromSuggestion_BuildsFranchiseAndRefusesDuplicate()
+ {
+ var fixture = new TestDb();
+ var first = Movie("Рокки", 1976);
+ var second = Movie("Рокки 2", 1979);
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(first, second);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var handler = new CreateCollectionFromSuggestionCommandHandler(
+ db,
+ new CollectionSuggestionBuilder(db)
+ );
+
+ var created = await handler.Handle(
+ new CreateCollectionFromSuggestionCommand("name:рокки"),
+ CancellationToken.None
+ );
+ Assert.True(created.IsSuccess);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await using var check = fixture.New();
+ var collection = check.Collections.Single();
+ Assert.Equal("Рокки", collection.Name);
+
+ // Повторное создание — конфликт: одноимённые франшизы неразличимы.
+ var again = await handler.Handle(
+ new CreateCollectionFromSuggestionCommand("name:рокки"),
+ CancellationToken.None
+ );
+ Assert.False(again.IsSuccess);
+ }
+
+ private static Show WithGenre(Show show, Genre genre)
+ {
+ show.SetGenres([genre.Id]);
+ return show;
+ }
+
+ private static Show Movie(string name, int year)
+ {
+ var show = Show.Create(name, ShowKind.Single);
+ show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
+ return show;
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Validators/BulkOperationValidatorsTests.cs b/backend/tests/TeleWave.Application.Tests/Validators/BulkOperationValidatorsTests.cs
new file mode 100644
index 0000000..0404eff
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Validators/BulkOperationValidatorsTests.cs
@@ -0,0 +1,99 @@
+using TeleWave.Application.Library.BulkTagShows;
+using TeleWave.Application.Library.Collections.AddShowsToCollection;
+using TeleWave.Application.Programming.Groups.UpdateGroup;
+using TeleWave.Domain.Library;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Validators;
+
+///
+/// Валидаторы массовых операций. Они ловят не злой умысел, а недосмотр вызывающей стороны: команда,
+/// которая ничего не меняет или не говорит куда складывать, — это ошибка UI, и падать она должна
+/// понятно.
+///
+public class BulkOperationValidatorsTests
+{
+ [Fact]
+ public void BulkTag_RequiresShowsAndSomethingToApply()
+ {
+ var validator = new BulkTagShowsCommandValidator();
+
+ Assert.True(
+ validator
+ .Validate(new BulkTagShowsCommand([Guid.NewGuid()], GenreIds: [Guid.NewGuid()]))
+ .IsValid
+ );
+ Assert.True(
+ validator
+ .Validate(
+ new BulkTagShowsCommand(
+ [Guid.NewGuid()],
+ Audience: ShowAudience.Pg,
+ SetAudience: true
+ )
+ )
+ .IsValid
+ );
+
+ // Ни жанров, ни рейтинга — применять нечего.
+ Assert.False(validator.Validate(new BulkTagShowsCommand([Guid.NewGuid()])).IsValid);
+ // Пустой список отмеченных.
+ Assert.False(
+ validator.Validate(new BulkTagShowsCommand([], GenreIds: [Guid.NewGuid()])).IsValid
+ );
+ }
+
+ [Fact]
+ public void AddShowsToCollection_RequiresTarget()
+ {
+ var validator = new AddShowsToCollectionCommandValidator();
+ var shows = new[] { Guid.NewGuid() };
+
+ Assert.True(
+ validator.Validate(new AddShowsToCollectionCommand(shows, Guid.NewGuid())).IsValid
+ );
+ Assert.True(
+ validator
+ .Validate(new AddShowsToCollectionCommand(shows, NewCollectionName: "Франшиза"))
+ .IsValid
+ );
+
+ // Ни существующей коллекции, ни имени новой — складывать некуда.
+ Assert.False(validator.Validate(new AddShowsToCollectionCommand(shows)).IsValid);
+ Assert.False(
+ validator
+ .Validate(new AddShowsToCollectionCommand(shows, NewCollectionName: " "))
+ .IsValid
+ );
+ Assert.False(
+ validator.Validate(new AddShowsToCollectionCommand([], Guid.NewGuid())).IsValid
+ );
+ Assert.False(
+ validator
+ .Validate(
+ new AddShowsToCollectionCommand(shows, NewCollectionName: new string('x', 257))
+ )
+ .IsValid
+ );
+ }
+
+ [Fact]
+ public void UpdateGroup_ChecksNameAndDescription()
+ {
+ var validator = new UpdateGroupCommandValidator();
+
+ Assert.True(
+ validator
+ .Validate(new UpdateGroupCommand(Guid.NewGuid(), "Боевики", null, null))
+ .IsValid
+ );
+ Assert.False(
+ validator.Validate(new UpdateGroupCommand(Guid.NewGuid(), " ", null, null)).IsValid
+ );
+ Assert.False(
+ validator
+ .Validate(new UpdateGroupCommand(Guid.NewGuid(), new string('x', 257), null, null))
+ .IsValid
+ );
+ }
+}
diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs
index 6454fbd..f79cbcc 100644
--- a/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs
+++ b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs
@@ -101,4 +101,93 @@ public class GroupTests
Assert.Equal(TimeSpan.FromHours(18), group.TotalDuration);
Assert.Equal(at, group.StatsComputedAt);
}
+
+ [Fact]
+ public void SetMode_ToDynamic_KeepsItemsAsPinned()
+ {
+ // Смена режима не должна вымывать из эфира то, что уже стояло в слотах.
+ var group = Group.Create("Боевики");
+ var show = Guid.NewGuid();
+ group.AddElement(GroupElementKind.Show, show);
+
+ group.SetMode(GroupMode.Dynamic);
+
+ Assert.Equal(GroupMode.Dynamic, group.Mode);
+ Assert.Equal(GroupItemRole.Pinned, group.Items.Single().Role);
+ }
+
+ [Fact]
+ public void SetMode_BackToStatic_DropsExclusionsAndUnpins()
+ {
+ var group = Group.Create("Боевики");
+ group.SetMode(GroupMode.Dynamic);
+ group.AddElement(GroupElementKind.Show, Guid.NewGuid());
+ group.Exclude(GroupElementKind.Show, Guid.NewGuid());
+
+ group.SetMode(GroupMode.Static);
+
+ // Исключать в статической группе нечего: состав и есть список.
+ var item = Assert.Single(group.Items);
+ Assert.Equal(GroupItemRole.Member, item.Role);
+ }
+
+ [Fact]
+ public void SetMode_SameMode_ChangesNothing()
+ {
+ var group = Group.Create("Боевики");
+ group.AddElement(GroupElementKind.Show, Guid.NewGuid());
+
+ group.SetMode(GroupMode.Static);
+
+ Assert.Equal(GroupItemRole.Member, group.Items.Single().Role);
+ }
+
+ [Fact]
+ public void AddElement_InDynamicGroup_PinsByDefault()
+ {
+ // Иначе правило вымело бы позицию на первом же обращении.
+ var group = Group.Create("Боевики");
+ group.SetMode(GroupMode.Dynamic);
+
+ var item = group.AddElement(GroupElementKind.Show, Guid.NewGuid());
+
+ Assert.Equal(GroupItemRole.Pinned, item!.Role);
+ }
+
+ [Fact]
+ public void Exclude_TurnsPinnedIntoExclusion()
+ {
+ // Держать на один элемент обе противоположные поправки нельзя.
+ var group = Group.Create("Боевики");
+ group.SetMode(GroupMode.Dynamic);
+ var show = Guid.NewGuid();
+ group.AddElement(GroupElementKind.Show, show);
+
+ Assert.True(group.Exclude(GroupElementKind.Show, show));
+
+ Assert.Equal(GroupItemRole.Excluded, group.Items.Single().Role);
+ }
+
+ [Fact]
+ public void Exclude_InStaticGroup_IsRefused()
+ {
+ var group = Group.Create("Боевики");
+
+ Assert.False(group.Exclude(GroupElementKind.Show, Guid.NewGuid()));
+ Assert.Empty(group.Items);
+ }
+
+ [Fact]
+ public void RemoveExclusion_ReturnsElementToTheRule()
+ {
+ var group = Group.Create("Боевики");
+ group.SetMode(GroupMode.Dynamic);
+ var show = Guid.NewGuid();
+ group.Exclude(GroupElementKind.Show, show);
+
+ Assert.True(group.RemoveExclusion(GroupElementKind.Show, show));
+ Assert.Empty(group.Items);
+ // Снимать нечего — второй раз это уже не поправка.
+ Assert.False(group.RemoveExclusion(GroupElementKind.Show, show));
+ }
}