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