diff --git a/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs b/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs new file mode 100644 index 0000000..6ce4cc8 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs @@ -0,0 +1,60 @@ +using NSubstitute; +using TeleWave.Application.Admin.Users.ListUsers; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Admin; + +/// +/// Список пользователей: хендлер только перекладывает параметры запроса в порт — Identity живёт +/// вне IAppDbContext. Проверяется именно перекладка: потерянный фильтр здесь означал бы +/// заблокированных вперемешку с активными. +/// +public class ListUsersTests +{ + [Fact] + public async Task PassesEveryFilterToIdentityService() + { + var identity = Substitute.For(); + var roleId = Guid.NewGuid(); + var page = new PagedList([], 0, 2, 25); + identity + .ListUsersAsync(Arg.Any(), Arg.Any()) + .Returns(page); + + var result = await new ListUsersQueryHandler(identity).Handle( + new ListUsersQuery(2, 25, "иван", roleId, IsBlocked: true, Sort: "created", Desc: true), + CancellationToken.None + ); + + Assert.Same(page, result); + await identity + .Received(1) + .ListUsersAsync( + new UserListFilter(2, 25, "иван", roleId, true, "created", true), + Arg.Any() + ); + } + + [Fact] + public async Task DefaultsSortAndDirection_WhenNotAsked() + { + var identity = Substitute.For(); + identity + .ListUsersAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedList([], 0, 1, 20)); + + await new ListUsersQueryHandler(identity).Handle( + new ListUsersQuery(1, 20, null, null, null), + CancellationToken.None + ); + + await identity + .Received(1) + .ListUsersAsync( + Arg.Is(f => f.Sort == null && !f.Desc && f.Search == null), + Arg.Any() + ); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs b/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs new file mode 100644 index 0000000..8543c37 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs @@ -0,0 +1,182 @@ +using TeleWave.Application.Library.Collections.GetCollection; +using TeleWave.Application.Library.Collections.ListCollections; +using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks; +using TeleWave.Application.Library.Interstitials.ListInterstitials; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Справочные списки библиотеки: коллекции и ролики. Считают они немного, но каждое число на экране +/// админа приходит отсюда — сериал внутри франшизы, длительность блока рекламы, статус обработки. +/// +public class LibraryQueriesTests +{ + /// Шоу с готовыми ассетами: без длительностей в списках нечего показывать. + private static (Show Show, List Assets) Playable( + string name, + ShowKind kind, + int episodes, + int minutes + ) + { + var show = Show.Create(name, kind); + var assets = new List(); + + for (var i = 0; i < episodes; i++) + { + var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(minutes), + 6, + minutes * 10, + 1920, + 1080, + "h264", + "aac", + $"assets/{name}-{i}" + ) + ); + assets.Add(asset); + show.AddEpisode(asset.Id); + } + + return (show, assets); + } + + [Fact] + public async Task Collections_CountUnitsNotParts() + { + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 10, 25); + var film = Playable("Фильм", ShowKind.Single, 1, 100); + + var collection = Collection.Create("Франшиза", "Описание"); + collection.AddShow(film.Show.Id); + collection.AddShow(series.Show.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange([.. series.Assets, .. film.Assets]); + seed.Shows.AddRange(series.Show, film.Show); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var list = await new ListCollectionsQueryHandler(db).Handle( + new ListCollectionsQuery(), + CancellationToken.None + ); + + var summary = Assert.Single(list); + Assert.Equal("Франшиза", summary.Name); + Assert.Equal(2, summary.ItemCount); + // Единиц больше, чем частей: сериал внутри коллекции разворачивается в свои серии. + Assert.Equal(11, summary.UnitCount); + + var detail = await new GetCollectionQueryHandler(db).Handle( + new GetCollectionQuery(collection.Id), + CancellationToken.None + ); + + Assert.True(detail.IsSuccess); + // Порядок частей — заданный, а не алфавитный: франшиза смотрится по порядку выхода. + Assert.Equal(["Фильм", "Сериал"], detail.Value.Items.Select(i => i.ShowName)); + Assert.Equal(10, detail.Value.Items[1].EpisodeCount); + } + + [Fact] + public async Task GetCollection_FailsForUnknownId() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new GetCollectionQueryHandler(db).Handle( + new GetCollectionQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task Interstitials_ShowDurationAndStatus() + { + var fixture = new TestDb(); + var ready = Playable("Реклама", ShowKind.Interstitial, 1, 1); + // Ролик без медиа: заведён, но файла к нему ещё не приложили. + var empty = Show.Create("Пустой ролик", ShowKind.Interstitial); + var notAClip = Playable("Кино", ShowKind.Single, 1, 100); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange([.. ready.Assets, .. notAClip.Assets]); + seed.Shows.AddRange(ready.Show, empty, notAClip.Show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var list = await new ListInterstitialsQueryHandler(db).Handle( + new ListInterstitialsQuery(), + CancellationToken.None + ); + + // В списке роликов только ролики — полнометражка сюда не попадает. + Assert.Equal(2, list.Count); + + var withMedia = list.Single(i => i.Name == "Реклама"); + Assert.Equal(MediaAssetStatus.Ready, withMedia.AssetStatus); + Assert.Equal(60, withMedia.DurationSeconds); + + var withoutMedia = list.Single(i => i.Name == "Пустой ролик"); + Assert.Null(withoutMedia.MediaAssetId); + Assert.Null(withoutMedia.AssetStatus); + Assert.Null(withoutMedia.DurationSeconds); + } + + [Fact] + public async Task Blocks_AreCollectionsMadeOfClipsOnly() + { + var fixture = new TestDb(); + var first = Playable("Ролик 1", ShowKind.Interstitial, 1, 1); + var second = Playable("Ролик 2", ShowKind.Interstitial, 1, 2); + var film = Playable("Кино", ShowKind.Single, 1, 100); + + var block = Collection.Create("Рекламный блок"); + block.AddShow(first.Show.Id); + block.AddShow(second.Show.Id); + + // Смешанная коллекция — франшиза, а не блок: она живёт на своём экране. + var mixed = Collection.Create("Смешанная"); + mixed.AddShow(first.Show.Id); + mixed.AddShow(film.Show.Id); + + // Пустая коллекция блоком тоже не считается: ставить в эфир из неё нечего. + var empty = Collection.Create("Пустая"); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange([.. first.Assets, .. second.Assets, .. film.Assets]); + seed.Shows.AddRange(first.Show, second.Show, film.Show); + seed.Collections.AddRange(block, mixed, empty); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var blocks = await new ListInterstitialBlocksQueryHandler(db).Handle( + new ListInterstitialBlocksQuery(), + CancellationToken.None + ); + + var only = Assert.Single(blocks); + Assert.Equal("Рекламный блок", only.Name); + Assert.Equal(2, only.ItemCount); + Assert.Equal(180, only.DurationSeconds); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs new file mode 100644 index 0000000..9e07dd3 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Options; +using TeleWave.Infrastructure.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Сигнальная очередь конвейера и предел параллельности. Мелочь, но на первой держится реакция +/// обработчика на новый файл, а на втором — оценка «сколько ещё ждать». +/// +public class MediaQueueTests +{ + [Fact] + public async Task Queue_WakesUpOnce_ForAWholeBurstOfSignals() + { + var queue = new MediaProcessingQueue(); + + queue.Enqueue(Guid.NewGuid()); + queue.Enqueue(Guid.NewGuid()); + queue.Enqueue(Guid.NewGuid()); + + // Первый вызов возвращается сразу и дренирует накопленное: работу обработчик всё равно + // берёт из БД пачкой, и три сигнала не должны означать три круга. + await queue.WaitAsync(CancellationToken.None); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + // Очередь пуста — следующий вызов ждёт нового сигнала, а не возвращается снова. + await Assert.ThrowsAnyAsync(async () => + await queue.WaitAsync(cts.Token) + ); + } + + [Theory] + [InlineData(4, 4)] + [InlineData(0, 1)] + [InlineData(-2, 1)] + public void Limits_NeverReportLessThanOneTranscode(int configured, int expected) + { + var limits = new MediaProcessingLimits( + Options.Create(new MediaOptions { MaxParallelTranscodes = configured }) + ); + + // Ноль и отрицательное в настройках — не повод делить оценку очереди на ноль. + Assert.Equal(expected, limits.MaxParallelTranscodes); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs new file mode 100644 index 0000000..2271ccd --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs @@ -0,0 +1,271 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata; +using TeleWave.Application.Metadata.RefreshEpisodes; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +/// +/// Довыгрузка метаданных серий: что берётся за номер серии, что происходит с кадром и когда +/// операция отказывается работать вовсе. +/// +public class RefreshEpisodesTests +{ + private static EpisodeMetadata Meta(string title, string? stillUrl = null) => + new(title, "Описание", stillUrl, new DateOnly(2005, 3, 20)); + + private static (IMetadataProviderResolver Resolver, IMetadataProvider Provider) ProviderFor( + string key + ) + { + var provider = Substitute.For(); + var resolver = Substitute.For(); + resolver.Resolve(key).Returns(provider); + return (resolver, provider); + } + + private static RefreshShowEpisodesMetadataCommandHandler Handler( + IAppDbContext db, + IMetadataProviderResolver resolver, + IImageDownloader? downloader = null, + IImageStore? store = null + ) => + new( + db, + resolver, + downloader ?? Substitute.For(), + store ?? Substitute.For() + ); + + /// Сериал с привязкой к источнику и одной серией на готовом ассете. + private static (Show Show, MediaAsset Asset) Linked(string fileName, bool withNumbers) + { + var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); + var show = Show.Create("Симпсоны", ShowKind.Series); + show.ApplyMetadata("tmdb", "456", null, 2005, null); + var episode = show.AddEpisode(asset.Id); + if (withNumbers) + episode.SetNumbers(16, 21); + return (show, asset); + } + + [Fact] + public async Task AppliesMetadata_AndSavesStillToRegistry() + { + var fixture = new TestDb(); + var (show, asset) = Linked("s16e21.mkv", withNumbers: true); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var (resolver, provider) = ProviderFor("tmdb"); + provider + .GetEpisodeAsync("456", 16, 21, Arg.Any()) + .Returns(Meta("The Father, the Son", "https://img/still.jpg")); + + var downloader = Substitute.For(); + downloader + .DownloadAsync("https://img/still.jpg", Arg.Any()) + .Returns(new DownloadedImage([1, 2, 3], ".jpg")); + var store = Substitute.For(); + + await using var db = fixture.New(); + var result = await Handler(db, resolver, downloader, store) + .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + + await using var check = fixture.New(); + var stored = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None); + var episode = stored.Episodes.Single(); + Assert.Equal("The Father, the Son", episode.Title); + Assert.NotNull(episode.StillImageId); + // Кадр попадает в общий реестр изображений, а не в своё файловое поле. + Assert.Equal(1, await check.Images.CountAsync(CancellationToken.None)); + await store + .Received(1) + .SaveAsync( + episode.StillImageId!.Value, + ".jpg", + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task TakesNumbersFromFileName_WhenNotSet() + { + var fixture = new TestDb(); + var (show, asset) = Linked("Simpsons.S16E21.1080p.mkv", withNumbers: false); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var (resolver, provider) = ProviderFor("tmdb"); + provider + .GetEpisodeAsync("456", 16, 21, Arg.Any()) + .Returns(Meta("Разобрано по имени")); + + await using var db = fixture.New(); + var result = await Handler(db, resolver) + .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + Assert.Equal(1, result.Value); + + await using var check = fixture.New(); + var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single(); + // Разобранные номера тут же сохраняются: второй раз имя файла разбирать незачем. + Assert.Equal(16, episode.Season); + Assert.Equal(21, episode.Episode); + } + + [Fact] + public async Task SkipsEpisode_WhenNumbersUnknownOrSourceSilent() + { + var fixture = new TestDb(); + var unparsable = MediaAsset.Register("случайное имя.mkv", ".mkv", MediaSource.Upload); + var known = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload); + var show = Show.Create("Сериал", ShowKind.Series); + show.ApplyMetadata("tmdb", "1", null, null, null); + show.AddEpisode(unparsable.Id); + show.AddEpisode(known.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange(unparsable, known); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var (resolver, provider) = ProviderFor("tmdb"); + // Источник знает не всё: на распознанную серию он отвечает пустотой. + provider + .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((EpisodeMetadata?)null); + + await using var db = fixture.New(); + var result = await Handler(db, resolver) + .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value); + // К нераспознанной серии источник не ходит вовсе — незачем. + await provider + .Received(1) + .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task KeepsMetadata_WhenStillCannotBeDownloaded() + { + var fixture = new TestDb(); + var (show, asset) = Linked("s01e01.mkv", withNumbers: true); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var (resolver, provider) = ProviderFor("tmdb"); + provider + .GetEpisodeAsync("456", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Meta("Без кадра", "https://img/broken.jpg")); + + var downloader = Substitute.For(); + downloader + .DownloadAsync(Arg.Any(), Arg.Any()) + .Returns((DownloadedImage?)null); + + await using var db = fixture.New(); + var result = await Handler(db, resolver, downloader) + .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + // Недоступный кадр не повод отменять текст: серия обновлена, картинки просто нет. + Assert.Equal(1, result.Value); + + await using var check = fixture.New(); + var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single(); + Assert.Equal("Без кадра", episode.Title); + Assert.Null(episode.StillImageId); + Assert.Equal(0, await check.Images.CountAsync(CancellationToken.None)); + } + + [Fact] + public async Task Fails_WhenShowIsMissingOrNotSeriesOrUnlinked() + { + var fixture = new TestDb(); + var movie = Show.Create("Фильм", ShowKind.Single); + var unlinked = Show.Create("Сериал без источника", ShowKind.Series); + + await using (var seed = fixture.New()) + { + seed.Shows.AddRange(movie, unlinked); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var (resolver, _) = ProviderFor("tmdb"); + + await using var db = fixture.New(); + var handler = Handler(db, resolver); + + var missing = await handler.Handle( + new RefreshShowEpisodesMetadataCommand(Guid.NewGuid()), + CancellationToken.None + ); + var notSeries = await handler.Handle( + new RefreshShowEpisodesMetadataCommand(movie.Id), + CancellationToken.None + ); + var noSource = await handler.Handle( + new RefreshShowEpisodesMetadataCommand(unlinked.Id), + CancellationToken.None + ); + + Assert.False(missing.IsSuccess); + Assert.Equal(MetadataErrors.EpisodesForSeriesOnly, notSeries.Error); + Assert.Equal(MetadataErrors.NoLinkedSource, noSource.Error); + } + + [Fact] + public async Task Fails_WhenLinkedProviderIsNotConfigured() + { + var fixture = new TestDb(); + var show = Show.Create("Сериал", ShowKind.Series); + show.ApplyMetadata("omdb", "tt1", null, null, null); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = Substitute.For(); + // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null. + resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null); + + await using var db = fixture.New(); + var result = await Handler(db, resolver) + .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); + + Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs new file mode 100644 index 0000000..2902053 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs @@ -0,0 +1,89 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata; +using TeleWave.Application.Metadata.SearchShows; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +/// +/// Поиск кандидатов в источнике. Внешняя сеть — единственная часть метаданных, которая падает +/// регулярно, поэтому проверяется в первую очередь она. +/// +public class SearchShowMetadataTests +{ + private static SearchShowMetadataQueryHandler Handler(IMetadataProvider? provider) + { + var resolver = Substitute.For(); + // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null. + resolver.Resolve(Arg.Any()).Returns(provider); + return new SearchShowMetadataQueryHandler(resolver); + } + + [Fact] + public async Task ReturnsWhatProviderFound() + { + var provider = Substitute.For(); + provider + .SearchShowsAsync("Симпсоны", ShowKind.Series, Arg.Any()) + .Returns([new MetadataCandidate("456", "The Simpsons", 1989, null, null)]); + + var result = await Handler(provider) + .Handle( + new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal("456", Assert.Single(result.Value).ExternalId); + } + + [Fact] + public async Task ReturnsNothing_ForBlankQuery_WithoutCallingProvider() + { + var provider = Substitute.For(); + + var result = await Handler(provider) + .Handle( + new SearchShowMetadataQuery("tmdb", " ", ShowKind.Single), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value); + await provider + .DidNotReceive() + .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Fails_WhenProviderIsNotConfigured() + { + var result = await Handler(null) + .Handle( + new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series), + CancellationToken.None + ); + + Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error); + } + + [Fact] + public async Task Fails_WhenSourceIsUnreachableOrRejectsTheKey() + { + var provider = Substitute.For(); + provider + .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>(_ => throw new HttpRequestException("401")); + + var result = await Handler(provider) + .Handle( + new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series), + CancellationToken.None + ); + + // Сетевой сбой и неверный ключ — управляемая ошибка, а не исключение наружу. + Assert.Equal(MetadataErrors.ProviderRequestFailed, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs new file mode 100644 index 0000000..68a9cdd --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs @@ -0,0 +1,117 @@ +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.FindGroupCandidates; +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 FindGroupCandidatesTests +{ + private static (Show Show, MediaAsset Asset) Playable(string name, int minutes) + { + var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(minutes), + 6, + minutes * 10, + 1920, + 1080, + "h264", + "aac", + $"assets/{name}" + ) + ); + var show = Show.Create(name, ShowKind.Single); + show.AddEpisode(asset.Id); + return (show, asset); + } + + private static async Task + >> FindAsync(TestDb fixture, FindGroupCandidatesQuery query) + { + await using var db = fixture.New(); + return await new FindGroupCandidatesQueryHandler( + db, + new GroupFilterMatcher(db), + new GroupElementResolver(db) + ).Handle(query, CancellationToken.None); + } + + [Fact] + public async Task MarksWhatIsAlreadyInGroup_AndFiltersByUnitDuration() + { + var fixture = new TestDb(); + var shortFilm = Playable("Короткий", 20); + var longFilm = Playable("Длинный", 120); + var inside = Playable("Уже внутри", 100); + + var group = Group.Create("Кино"); + group.SetFilter(new GroupFilter(UnitMinutesMin: 60).ToJson()); + group.AddElement(GroupElementKind.Show, inside.Show.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange(shortFilm.Asset, longFilm.Asset, inside.Asset); + seed.Shows.AddRange(shortFilm.Show, longFilm.Show, inside.Show); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var result = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id)); + + Assert.True(result.IsSuccess); + // Короткий отсечён по длине единицы: правило меряет саму серию, а не сумму по шоу. + Assert.Equal(["Длинный", "Уже внутри"], result.Value.Select(c => c.ElementName)); + Assert.False(result.Value.Single(c => c.ElementName == "Длинный").AlreadyInGroup); + Assert.True(result.Value.Single(c => c.ElementName == "Уже внутри").AlreadyInGroup); + } + + [Fact] + public async Task UsesFilterFromQuery_WhenGivenExplicitly() + { + var fixture = new TestDb(); + var film = Playable("Кино", 100); + // У группы своего правила нет — редактор крутит фильтр до сохранения. + var group = Group.Create("Черновик"); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(film.Asset); + seed.Shows.Add(film.Show); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var withFilter = await FindAsync( + fixture, + new FindGroupCandidatesQuery(group.Id, new GroupFilter(ShowKinds: [ShowKind.Single])) + ); + var withoutFilter = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id)); + + Assert.Single(withFilter.Value); + // Без правила подбирать не из чего — и это отказ, а не пустой список. + Assert.False(withoutFilter.IsSuccess); + Assert.Equal(GroupErrors.FilterNotSet, withoutFilter.Error); + } + + [Fact] + public async Task Fails_WhenGroupIsGone() + { + var fixture = new TestDb(); + + var result = await FindAsync(fixture, new FindGroupCandidatesQuery(Guid.NewGuid())); + + Assert.False(result.IsSuccess); + Assert.Equal(GroupErrors.NotFound, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs new file mode 100644 index 0000000..3b1e8d3 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs @@ -0,0 +1,198 @@ +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.GetTemplate; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Сетка глазами редактора: слои по приоритету, слоты с именами групп и признаки состояния — +/// есть ли неприменённые правки и есть ли к чему откатываться. +/// +public class GetTemplateTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task ReturnsLayersByPriority_WithGroupNamesAndState() + { + var fixture = new TestDb(); + var group = Group.Create("Кино"); + var channel = Channel.Create("Первый", "first", T0); + channel.UpdateTimeSettings(1, 180, new TimeOnly(6, 0)); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + var top = template.AddLayer("Выходные", 20); + var slot = top.AddSlot("Кино в прайм", new TimeOnly(20, 0), 120, Daypart.Prime, weekday: 6); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(), + null, + SlotBlockMode.Count, + 1, + OverflowPolicy.ContinueNext + ) + ); + template.MarkChanged(); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetChannelTemplateQueryHandler(db).Handle( + new GetChannelTemplateQuery(channel.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var dto = result.Value; + + // Слои идут сверху вниз: первым тот, что перекрывает остальные. + Assert.Equal(["Выходные", "Фон"], dto.Layers.Select(l => l.Name)); + Assert.Equal(new TimeOnly(6, 0), dto.DayStartTime); + Assert.Equal(180, dto.UtcOffsetMinutes); + + var stored = Assert.Single(dto.Layers[0].Slots); + // Имя группы приезжает вместе со слотом: инспектор показывает его без второго запроса. + Assert.Equal("Кино", stored.GroupName); + Assert.Equal(6, stored.Weekday); + Assert.Equal(SlotStrategyType.RandomWithCooldown, stored.Strategy!.Type); + Assert.Equal(14, stored.Strategy.CooldownDays); + + // Правка эфир не двигает — она поднимает ревизию, и плашка об этом видна в шапке канала. + Assert.True(dto.HasPendingChanges); + Assert.Equal(1, dto.Revision); + Assert.Equal(0, dto.AppliedRevision); + // Сетку ни разу не применяли — откатывать не к чему, и кнопки быть не должно. + Assert.False(dto.CanRestore); + } + + [Fact] + public async Task CanRestore_AfterFirstApply() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "first", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + template.MarkApplied(TemplateSnapshot.Capture(template).ToJson()); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetChannelTemplateQueryHandler(db).Handle( + new GetChannelTemplateQuery(channel.Id), + CancellationToken.None + ); + + Assert.True(result.Value.CanRestore); + Assert.False(result.Value.HasPendingChanges); + } + + [Fact] + public async Task Fails_WhenChannelOrTemplateIsMissing() + { + var fixture = new TestDb(); + var channel = Channel.Create("Без сетки", "no-grid", T0); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var handler = new GetChannelTemplateQueryHandler(db); + + var noChannel = await handler.Handle( + new GetChannelTemplateQuery(Guid.NewGuid()), + CancellationToken.None + ); + var noTemplate = await handler.Handle( + new GetChannelTemplateQuery(channel.Id), + CancellationToken.None + ); + + Assert.False(noChannel.IsSuccess); + Assert.False(noTemplate.IsSuccess); + } + + [Fact] + public async Task ElementResolver_ExpandsCollectionIntoItsParts() + { + // Коллекция в группе — одна позиция, но эфира она даёт столько же, сколько все её части. + var fixture = new TestDb(); + var film = Show.Create("Часть 1", ShowKind.Single); + film.SetAudience(ShowAudience.Pg13); + var sequel = Show.Create("Часть 2", ShowKind.Single); + sequel.SetAudience(ShowAudience.R); + var assets = new List(); + + foreach (var show in new[] { film, sequel }) + { + var asset = MediaAsset.Register($"{show.Name}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(90), + 6, + 900, + 1920, + 1080, + "h264", + "aac", + $"assets/{show.Name}" + ) + ); + assets.Add(asset); + show.AddEpisode(asset.Id); + } + + var collection = Collection.Create("Франшиза"); + collection.AddShow(film.Id); + collection.AddShow(sequel.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange(assets); + seed.Shows.AddRange(film, sequel); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var info = await new GroupElementResolver(db).ResolveAsync( + [(GroupElementKind.Collection, collection.Id), (GroupElementKind.Show, film.Id)], + CancellationToken.None + ); + + var franchise = info[(GroupElementKind.Collection, collection.Id)]; + Assert.Equal("Франшиза", franchise.Name); + Assert.Equal(2, franchise.UnitCount); + Assert.Equal(TimeSpan.FromMinutes(180), franchise.TotalDuration); + // Рейтинг франшизы — самый строгий среди частей: по нему её отсеивают в детское время. + Assert.Equal(ShowAudience.R, franchise.Audience); + + var single = info[(GroupElementKind.Show, film.Id)]; + Assert.Equal(1, single.UnitCount); + Assert.Equal(ShowKind.Single, single.ShowKind); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs new file mode 100644 index 0000000..58588c3 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs @@ -0,0 +1,233 @@ +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Generate; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Витрина автосборки: каталог профилей, предпросмотр плана и его перевод в DTO. Предпросмотр — +/// то, по чему админ принимает решение, поэтому проверяется отдельно от самой раскладки. +/// +public class GridPlanPreviewTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static PlannedSlot Slot( + string title, + SlotKind kind, + SlotBlockMode blockMode, + int blockValue, + int? weekday = null + ) => + new( + GridPlanLayer.Main, + weekday, + new TimeOnly(20, 0), + 120, + title, + Daypart.Prime, + kind, + kind == SlotKind.Content ? Guid.NewGuid() : null, + kind == SlotKind.Content ? title : null, + blockMode, + blockValue, + null, + null, + IsAnchor: false + ); + + [Fact] + public async Task Profiles_AreListed_WithReferenceAndDescription() + { + var result = await new ListGridProfilesQueryHandler().Handle( + new ListGridProfilesQuery(), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + // Каждый профиль должен объяснять себя: имя, на что похоже и для какой библиотеки годится. + Assert.Equal(GridProfiles.All.Count, result.Value.Count); + Assert.All( + result.Value, + profile => + { + Assert.False(string.IsNullOrWhiteSpace(profile.Name)); + Assert.False(string.IsNullOrWhiteSpace(profile.Reference)); + Assert.False(string.IsNullOrWhiteSpace(profile.Description)); + } + ); + Assert.Contains(result.Value, p => p.Kind == GridProfileKind.Animation); + } + + [Fact] + public void Mapper_DescribesEveryBlockKind() + { + var plan = new GridPlan( + GridProfiles.Get(GridProfileKind.Mixed), + GridGenerationMode.Rebuild, + [ + Slot("Сериалы", SlotKind.Content, SlotBlockMode.Count, 3), + Slot("Кино", SlotKind.Content, SlotBlockMode.FillSlot, 1), + Slot("Полоса", SlotKind.Content, SlotBlockMode.Duration, 90, weekday: 6), + Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1), + Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1), + ], + ["замечание"], + SlotsToRemove: 4, + FallbackGroupId: Guid.NewGuid(), + FallbackGroupName: "Фон", + FreeMinutes: 10080, + CoveredMinutes: 5040 + ); + + var dto = plan.ToDto(); + + Assert.Equal(GridProfileKind.Mixed, dto.Profile); + Assert.Equal(plan.Profile.Name, dto.ProfileName); + Assert.Equal(4, dto.SlotsToRemove); + Assert.Equal("Фон", dto.FallbackGroupName); + Assert.Equal(["замечание"], dto.Notes); + + // Описание блока — то, что читает админ вместо режима и числа. + Assert.Equal( + ["3 подряд", "до конца слота", "90 мин", "повтор", "конец вещания"], + dto.Slots.Select(s => s.Block) + ); + // Время приезжает строкой «ЧЧ:ММ»: в предпросмотре оно только показывается. + Assert.All(dto.Slots, slot => Assert.Equal("20:00", slot.Start)); + Assert.Equal(6, dto.Slots[2].Weekday); + Assert.Null(dto.Slots[0].Weekday); + // У неконтентных слотов группы нет — показывать в колонке нечего. + Assert.Null(dto.Slots[3].GroupName); + Assert.Equal("Сериалы", dto.Slots[0].GroupName); + } + + [Fact] + public async Task Preview_ReturnsPlanForProfile() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + + await using var db = fixture.New(); + var result = await new PreviewGeneratedGridQueryHandler( + new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + ).Handle( + new PreviewGeneratedGridQuery( + channelId, + GridProfileKind.Kids, + GridGenerationMode.Rebuild + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(GridProfileKind.Kids, result.Value.Profile); + Assert.NotEmpty(result.Value.Slots); + // Пересборка сносит то, что было, и это видно до нажатия. + Assert.True(result.Value.SlotsToRemove >= 0); + Assert.Equal(7 * GridCoverage.MinutesInDay, result.Value.FreeMinutes); + // У детского профиля ночь — конец вещания, и в предпросмотре он подписан именно так. + Assert.Contains(result.Value.Slots, s => s.Block == "конец вещания"); + } + + [Fact] + public async Task Preview_FailsWhenChannelIsGone() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new PreviewGeneratedGridQueryHandler( + new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + ).Handle( + new PreviewGeneratedGridQuery( + Guid.NewGuid(), + GridProfileKind.Mixed, + GridGenerationMode.FillGaps + ), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + [Fact] + public void Validator_RejectsUnknownProfileAndMode() + { + var validator = new GenerateGridCommandValidator(); + + Assert.True( + validator + .Validate( + new GenerateGridCommand( + Guid.NewGuid(), + GridProfileKind.Sitcom, + GridGenerationMode.Rebuild + ) + ) + .IsValid + ); + // Значения enum приходят с клиента строкой — за пределами каталога они бессмысленны. + Assert.False( + validator + .Validate( + new GenerateGridCommand( + Guid.NewGuid(), + (GridProfileKind)42, + GridGenerationMode.FillGaps + ) + ) + .IsValid + ); + } + + /// Канал с одной группой мягкого рейтинга — чтобы детскому профилю было чем закрыться. + private static async Task SeedAsync(TestDb fixture) + { + var show = Show.Create("Мультсериал", ShowKind.Series); + show.SetAudience(ShowAudience.G); + var assets = new List(); + + for (var i = 0; i < 20; i++) + { + var asset = MediaAsset.Register($"e{i}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(22), + 6, + 220, + 1920, + 1080, + "h264", + "aac", + $"assets/e{i}" + ) + ); + assets.Add(asset); + show.AddEpisode(asset.Id); + } + + var group = Group.Create("Мультфильмы"); + group.AddElement(GroupElementKind.Show, show.Id); + + var channel = Channel.Create("Детский", "kids", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.MediaAssets.AddRange(assets); + seed.Shows.Add(show); + seed.Groups.Add(group); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return channel.Id; + } +}