diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 802dae6..cd24523 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -62,8 +62,6 @@ jobs: tests: runs-on: ubuntu-latest needs: [build-backend, build-frontend] - permissions: - contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 @@ -72,44 +70,16 @@ jobs: - name: Restore working-directory: backend run: dotnet restore TeleWave.slnx - - name: Test + coverage + # Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover), + # а бейдж покрытия в README берётся из SonarCloud. + - name: Test working-directory: backend run: > dotnet test TeleWave.slnx -c Release --no-restore - --collect:"XPlat Code Coverage" - --results-directory ./coverage --logger "console;verbosity=normal" - # Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и - # force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL. - - name: Coverage badge - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - working-directory: backend - env: - TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }} - run: | - dotnet tool install --global dotnet-reportgenerator-globaltool - export PATH="$PATH:$HOME/.dotnet/tools" - reportgenerator \ - "-reports:coverage/**/coverage.cobertura.xml" \ - "-targetdir:coverage/badges" \ - "-reporttypes:Badges" \ - "-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \ - "-classfilters:-System.*" - BADGE=coverage/badges/badge_linecoverage.svg - test -f "$BADGE" || { echo "badge not generated"; exit 1; } - WORK=$(mktemp -d) - cp "$BADGE" "$WORK/coverage.svg" - cd "$WORK" - git init -q -b badges - git add coverage.svg - git -c user.name=gitea-actions -c user.email=actions@local \ - commit -q -m "coverage badge [skip ci]" - git push -f \ - "https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges - # Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты # и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана # на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте. diff --git a/README.md b/README.md index 4d04617..ee1d0da 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # TeleWave [![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml) -[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml) [![quality gate](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=alert_status&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=coverage&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=bugs&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=code_smells&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=security_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=sqale_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) **TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из хранилища на сервере, админ управляет каналами и пользователями. diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs new file mode 100644 index 0000000..2c9d5b9 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.Options; +using NSubstitute; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Streaming; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Images; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Application.Tests.Broadcast; + +/// +/// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине +/// сегмента HLS, а названия «из/в» берутся из групп этого канала — иначе превью показывало бы +/// случайные шоу из библиотеки. +/// +public class BumperPreviewTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static RenderBumperPreviewCommandHandler Handler( + AppDbContext db, + IBumperRenderer? renderer = null, + IBumperTemplateStorage? storage = null, + IImageStore? imageStore = null + ) => + new( + db, + renderer ?? Substitute.For(), + storage ?? Substitute.For(), + imageStore ?? Substitute.For(), + Options.Create(new BumperOptions()), + Options.Create(new StreamingOptions { SegmentSeconds = 5 }) + ); + + [Fact] + public async Task Render_UnknownChannel_ReturnsNotFound() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await Handler(db) + .Handle( + new RenderBumperPreviewCommand(Guid.NewGuid(), Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.NotFound, result.Error); + } + + [Fact] + public async Task Render_UnknownTemplate_ReturnsBumperTemplateNotFound() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await Handler(db) + .Handle( + new RenderBumperPreviewCommand(channel.Id, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error); + } + + [Fact] + public async Task Render_DrawsEveryVariant_WithChannelShowNamesAndAlignedDuration() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var template = channel.BumperTemplates[0]; + var second = template.AddVariant("Второй подблок"); + + // Шоу канала: слот сетки ссылается на группу, в группе — шоу. + var show = Show.Create("Наше шоу", ShowKind.Series); + var group = Group.Create("Кино"); + group.AddElement(GroupElementKind.Show, show.Id); + var grid = ScheduleTemplate.Create(channel.Id, "Сетка"); + var slot = grid.Background!.AddSlot("Круглосуточно", new TimeOnly(6, 0), 24 * 60); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + channel.SetTemplate(grid.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(grid); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var renderer = Substitute.For(); + await using var db = fixture.New(); + var result = await Handler(db, renderer) + .Handle( + new RenderBumperPreviewCommand(channel.Id, template.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + // Оба подблока отрисованы, каждый — в свой ассет-превью. + await renderer + .Received(1) + .RenderAsync( + BumperPreview.AssetId(template.Variants[0].Id), + Arg.Any(), + Arg.Any() + ); + await renderer + .Received(1) + .RenderAsync( + BumperPreview.AssetId(second.Id), + Arg.Any(), + Arg.Any() + ); + + var specs = renderer + .ReceivedCalls() + .Select(c => c.GetArguments()[1]) + .OfType() + .ToList(); + // Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек. + Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds)); + Assert.All(specs, s => Assert.Equal("Наше шоу", s.NowTitle)); + // Второго шоу у канала нет — подставляется заглушка. + Assert.All(specs, s => Assert.Equal("Второе шоу", s.NextTitle)); + Assert.All(specs, s => Assert.Null(s.BackgroundFile)); + Assert.All(specs, s => Assert.Null(s.PosterFile)); + } + + [Fact] + public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var template = channel.BumperTemplates[0]; + var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png"); + template.SetBackgroundImage(image.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Images.Add(image); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var renderer = Substitute.For(); + var imageStore = Substitute.For(); + imageStore.ResolvePath(image.Id, ".png").Returns("/data/images/bg.png"); + + await using var db = fixture.New(); + var result = await Handler(db, renderer, imageStore: imageStore) + .Handle( + new RenderBumperPreviewCommand(channel.Id, template.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var spec = renderer + .ReceivedCalls() + .Select(c => c.GetArguments()[1]) + .OfType() + .First(); + Assert.Equal("/data/images/bg.png", spec.BackgroundFile); + // Шоу у канала нет — обе подписи заглушечные. + Assert.Equal("Первое шоу", spec.NowTitle); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs new file mode 100644 index 0000000..29ad45e --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs @@ -0,0 +1,82 @@ +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.GetChannel; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using Xunit; + +namespace TeleWave.Application.Tests.Broadcast; + +/// +/// Карточка канала: собственные свойства канала плюс блоки заставки с подблоками. Сетка сюда не +/// входит — она запрашивается отдельно. +/// +public class ChannelDetailsTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task GetChannel_MapsSettingsAndBumperTemplates() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var fillerId = Guid.NewGuid(); + var logoId = Guid.NewGuid(); + channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: true, fillerId); + channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0)); + channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.Random); + channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25); + // Второй блок должен приехать после дефолтного — порядок задаёт позиция. + var extra = channel.AddBumperTemplate("Ночной"); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetChannelQueryHandler(db).Handle( + new GetChannelQuery(channel.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var dto = result.Value; + Assert.Equal("one", dto.Slug); + Assert.True(dto.IsEnabled); + Assert.Equal(3, dto.Number); + Assert.Equal(120, dto.UtcOffsetMinutes); + Assert.Equal(new TimeOnly(5, 0), dto.DayStartTime); + Assert.Equal(fillerId, dto.FillerAssetId); + Assert.True(dto.BumpersEnabled); + Assert.Equal(BumperFont.Serif, dto.Bumper.Font); + Assert.Equal(BumperSelection.Random, dto.Bumper.Selection); + Assert.Equal(logoId, dto.Viewer.LogoImageId); + Assert.Equal(LogoCorner.BottomLeft, dto.Viewer.LogoCorner); + Assert.Equal(0.4, dto.Viewer.LogoOpacity); + Assert.True(dto.Viewer.ShowClock); + Assert.Equal(0.25, dto.Viewer.AnalogFilterStrength); + + Assert.Equal(2, dto.BumperTemplates.Count); + Assert.True(dto.BumperTemplates[0].IsDefault); + Assert.Equal(extra.Id, dto.BumperTemplates[1].Id); + Assert.Equal("Ночной", dto.BumperTemplates[1].Name); + // Своего звука у свежего блока нет — джингл синтезируется. + Assert.False(dto.BumperTemplates[1].HasAudio); + Assert.NotEmpty(dto.BumperTemplates[0].Variants); + } + + [Fact] + public async Task GetChannel_UnknownId_ReturnsNotFound() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new GetChannelQueryHandler(db).Handle( + new GetChannelQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.NotFound, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs new file mode 100644 index 0000000..d795a67 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs @@ -0,0 +1,121 @@ +using TeleWave.Application.Library.ListShows; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Список библиотеки: он же считает сезоны (номера в модели не хранятся — только в именах файлов) +/// и подставляет названия жанров, поэтому проверяем именно сводку, а не факт выборки. +/// +public class ListShowsTests +{ + private sealed record Seeded(TestDb Db, Guid ActionGenreId, Guid DramaGenreId, Guid SeriesId); + + private static async Task SeedAsync() + { + var fixture = new TestDb(); + var action = Genre.Create("Боевик", "action"); + var drama = Genre.Create("Драма", "drama"); + + var series = Show.Create("Сериал", ShowKind.Series); + series.SetGenres([action.Id, drama.Id], action.Id); + series.SetPosterImage(Guid.NewGuid()); + var assets = new[] + { + MediaAsset.Register("Serial.S01E01.mkv", ".mkv", MediaSource.Upload), + MediaAsset.Register("Serial.S01E02.mkv", ".mkv", MediaSource.Upload), + MediaAsset.Register("Serial.S02E01.mkv", ".mkv", MediaSource.Upload), + // Имя без метки сезона: серия считается, сезон — нет. + MediaAsset.Register("Serial-bonus.mkv", ".mkv", MediaSource.Upload), + }; + foreach (var asset in assets) + series.AddEpisode(asset.Id); + + var film = Show.Create("Автофильм", ShowKind.Single); + var bumper = Show.Create("Ролик", ShowKind.Interstitial); + + await using var seed = fixture.New(); + seed.Genres.AddRange(action, drama); + seed.MediaAssets.AddRange(assets); + seed.Shows.AddRange(series, film, bumper); + await seed.SaveChangesAsync(CancellationToken.None); + + return new Seeded(fixture, action.Id, drama.Id, series.Id); + } + + [Fact] + public async Task List_ReturnsContentSortedByName_WithoutInterstitials() + { + var seeded = await SeedAsync(); + await using var db = seeded.Db.New(); + + var shows = await new ListShowsQueryHandler(db).Handle( + new ListShowsQuery(), + CancellationToken.None + ); + + // Ролики-врезки живут на своей странице и в общей библиотеке только мешали бы. + Assert.Equal(new[] { "Автофильм", "Сериал" }, shows.Select(s => s.Name).ToArray()); + } + + [Fact] + public async Task List_CountsEpisodesAndDistinctSeasons() + { + var seeded = await SeedAsync(); + await using var db = seeded.Db.New(); + + var shows = await new ListShowsQueryHandler(db).Handle( + new ListShowsQuery(), + CancellationToken.None + ); + + var series = shows.Single(s => s.Id == seeded.SeriesId); + Assert.Equal(4, series.EpisodeCount); + Assert.Equal(2, series.SeasonCount); + Assert.True(series.HasPoster); + Assert.Equal("Боевик", series.PrimaryGenre); + Assert.Equal(new[] { "Драма" }, series.OtherGenres.ToArray()); + } + + [Fact] + public async Task List_ByGenre_KeepsOnlyShowsWithIt() + { + var seeded = await SeedAsync(); + await using var db = seeded.Db.New(); + + var byDrama = await new ListShowsQueryHandler(db).Handle( + new ListShowsQuery(seeded.DramaGenreId), + CancellationToken.None + ); + var byUnknown = await new ListShowsQueryHandler(db).Handle( + new ListShowsQuery(Guid.NewGuid()), + CancellationToken.None + ); + + // Жанр ищется среди всех, а не только основного. + Assert.Equal(seeded.SeriesId, Assert.Single(byDrama).Id); + Assert.Empty(byUnknown); + } + + [Fact] + public async Task List_Interstitials_ReturnsOnlyThem() + { + var seeded = await SeedAsync(); + await using var db = seeded.Db.New(); + + var shows = await new ListShowsQueryHandler(db).Handle( + new ListShowsQuery(Interstitials: true), + CancellationToken.None + ); + + var only = Assert.Single(shows); + Assert.Equal("Ролик", only.Name); + Assert.Equal(ShowKind.Interstitial, only.Kind); + Assert.Equal(0, only.SeasonCount); + Assert.Null(only.PrimaryGenre); + Assert.False(only.HasPoster); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs new file mode 100644 index 0000000..0b4de30 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Tests.TestSupport; +using TeleWave.Domain.Library; +using TeleWave.Infrastructure.Metadata; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +/// +/// Разбор ответов OMDb. Особенности источника, ради которых тесты и написаны: «N/A» вместо +/// отсутствующего значения, флаг Response вместо кода ошибки и жанры одной строкой. +/// +public class OmdbMetadataProviderTests +{ + private static OmdbMetadataProvider Provider(FakeHttpClientFactory http) => + new( + http, + Options.Create(new MetadataOptions { Omdb = new OmdbOptions { ApiKey = "key" } }) + ); + + [Fact] + public async Task SearchShows_KeepsOnlyShowsWithId() + { + var http = FakeHttpClientFactory.Json( + """ + {"Search":[ + {"imdbID":"tt1","Title":"Сериал","Year":"2001–2005","Type":"series","Poster":"http://p/1.jpg"}, + {"imdbID":"tt2","Title":"Серия","Type":"episode"}, + {"Title":"Без идентификатора","Type":"movie"}, + {"imdbID":"tt3","Title":"Фильм","Year":"N/A","Type":"movie","Poster":"N/A"} + ]} + """ + ); + + var found = await Provider(http) + .SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None); + + Assert.Contains("type=series", http.LastUrl); + Assert.Equal(2, found.Count); + Assert.Equal("tt1", found[0].ExternalId); + Assert.Equal(2001, found[0].Year); + Assert.Equal("http://p/1.jpg", found[0].PosterUrl); + Assert.Equal(ShowKind.Series, found[0].Kind); + // Отдельные серии просачиваются в выдачу и шоу не годятся, запись без imdbID — тоже. + Assert.Equal("tt3", found[1].ExternalId); + Assert.Equal(ShowKind.Single, found[1].Kind); + Assert.Null(found[1].Year); + Assert.Null(found[1].PosterUrl); + } + + [Fact] + public async Task SearchShows_Single_AsksForMovies() + { + var http = FakeHttpClientFactory.Json("{}"); + + var found = await Provider(http) + .SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None); + + Assert.Contains("type=movie", http.LastUrl); + Assert.Empty(found); + } + + [Fact] + public async Task GetShow_ReadsRatingAndSplitsGenres() + { + var http = FakeHttpClientFactory.Json( + """ + {"Response":"True","Title":"Сериал","Year":"2001–2005","Plot":"описание", + "Poster":"http://p/1.jpg","Genre":"Action, Sci-Fi","Rated":"TV-14"} + """ + ); + + var show = await Provider(http) + .GetShowAsync("tt1", ShowKind.Series, CancellationToken.None); + + Assert.Contains("i=tt1", http.LastUrl); + Assert.NotNull(show); + Assert.Equal("tt1", show.ExternalId); + Assert.Equal("Сериал", show.Title); + Assert.Equal(2001, show.Year); + Assert.Equal("описание", show.Overview); + Assert.Equal(new[] { "Action", "Sci-Fi" }, show.Genres); + Assert.Equal("TV-14", show.ContentRating); + } + + [Fact] + public async Task GetShow_TreatsNotAvailableAsMissing() + { + var http = FakeHttpClientFactory.Json( + """{"Response":"True","Title":"N/A","Plot":"N/A","Poster":"N/A","Genre":"N/A","Rated":"N/A"}""" + ); + + var show = await Provider(http) + .GetShowAsync("tt1", ShowKind.Single, CancellationToken.None); + + Assert.NotNull(show); + Assert.Equal("—", show.Title); + Assert.Null(show.Overview); + Assert.Null(show.PosterUrl); + Assert.Null(show.ContentRating); + Assert.Empty(show.Genres!); + } + + [Fact] + public async Task GetShow_ReturnsNull_WhenResponseIsFalse() + { + var http = FakeHttpClientFactory.Json( + """{"Response":"False","Error":"Movie not found!"}""" + ); + + var show = await Provider(http) + .GetShowAsync("tt0", ShowKind.Series, CancellationToken.None); + + Assert.Null(show); + } + + [Fact] + public async Task GetShow_ReturnsNull_WhenRequestFails() + { + var show = await Provider(FakeHttpClientFactory.Failing()) + .GetShowAsync("tt1", ShowKind.Series, CancellationToken.None); + + Assert.Null(show); + } + + [Fact] + public async Task GetEpisode_ReadsTitleAndReleaseDate() + { + var http = FakeHttpClientFactory.Json( + """ + {"Response":"True","Title":"Серия","Plot":"о серии","Poster":"http://p/s.jpg", + "Released":"07 Jun 2001"} + """ + ); + + var episode = await Provider(http).GetEpisodeAsync("tt1", 2, 3, CancellationToken.None); + + Assert.Contains("Season=2", http.LastUrl); + Assert.Contains("Episode=3", http.LastUrl); + Assert.NotNull(episode); + Assert.Equal("Серия", episode.Title); + Assert.Equal("http://p/s.jpg", episode.StillUrl); + Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate); + } + + [Fact] + public async Task GetEpisode_ReturnsNull_WhenResponseIsFalse() + { + var http = FakeHttpClientFactory.Json("""{"Response":"False"}"""); + + var episode = await Provider(http).GetEpisodeAsync("tt1", 9, 9, CancellationToken.None); + + Assert.Null(episode); + } + + [Fact] + public async Task GetSeasonEpisodeCount_CountsEpisodes() + { + var http = FakeHttpClientFactory.Json( + """{"Response":"True","Episodes":[{"Episode":"1"},{"Episode":"2"}]}""" + ); + + var count = await Provider(http) + .GetSeasonEpisodeCountAsync("tt1", 2, CancellationToken.None); + + Assert.Equal(2, count); + } + + [Fact] + public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSeasonMissing() + { + var http = FakeHttpClientFactory.Json("""{"Response":"True"}"""); + + var count = await Provider(http) + .GetSeasonEpisodeCountAsync("tt1", 9, CancellationToken.None); + + Assert.Null(count); + } + + [Fact] + public async Task GetSeasonNumbers_NumbersSeasonsContinuously() + { + // Перечня сезонов OMDb не отдаёт — только их количество строкой. + var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"3"}"""); + + var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None); + + Assert.Equal(new[] { 1, 2, 3 }, seasons); + } + + [Fact] + public async Task GetSeasonNumbers_ReturnsEmpty_WhenTotalIsNotANumber() + { + var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"N/A"}"""); + + var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None); + + Assert.Empty(seasons); + } + + [Fact] + public async Task GetSeasonNumbers_ReturnsEmpty_WhenResponseIsFalse() + { + var http = FakeHttpClientFactory.Json("""{"Response":"False"}"""); + + var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None); + + Assert.Empty(seasons); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs new file mode 100644 index 0000000..0eac00c --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Tests.TestSupport; +using TeleWave.Domain.Library; +using TeleWave.Infrastructure.Metadata; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +/// +/// Разбор ответов TMDb. Наружу тесты не ходят — HTTP подменён, проверяем и собранный адрес +/// (сериалы и фильмы живут в разных пространствах API), и то, что из ответа вычитано. +/// +public class TmdbMetadataProviderTests +{ + private static TmdbMetadataProvider Provider(FakeHttpClientFactory http) => + new( + http, + Options.Create( + new MetadataOptions + { + Language = "ru-RU", + Tmdb = new TmdbOptions { ApiKey = "key" }, + } + ) + ); + + [Fact] + public async Task SearchShows_Series_ReadsNameAndFirstAirDate() + { + var http = FakeHttpClientFactory.Json( + """ + {"results":[ + {"id":42,"name":"Сериал","first_air_date":"2001-05-06","overview":"о чём-то","poster_path":"/p.jpg"}, + {"name":"без идентификатора"} + ]} + """ + ); + + var found = await Provider(http) + .SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None); + + Assert.Contains("/search/tv", http.LastUrl); + Assert.Contains("language=ru-RU", http.LastUrl); + // Запись без id пропущена: обратиться по ней всё равно не получится. + var candidate = Assert.Single(found); + Assert.Equal("42", candidate.ExternalId); + Assert.Equal("Сериал", candidate.Title); + Assert.Equal(2001, candidate.Year); + Assert.Equal("о чём-то", candidate.Overview); + Assert.Equal("https://image.tmdb.org/t/p/w500/p.jpg", candidate.PosterUrl); + Assert.Equal(ShowKind.Series, candidate.Kind); + } + + [Fact] + public async Task SearchShows_Single_UsesMovieNamespace() + { + var http = FakeHttpClientFactory.Json( + """{"results":[{"id":7,"title":"Фильм","release_date":"1999-03-31"}]}""" + ); + + var found = await Provider(http) + .SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None); + + Assert.Contains("/search/movie", http.LastUrl); + var candidate = Assert.Single(found); + Assert.Equal("Фильм", candidate.Title); + Assert.Equal(1999, candidate.Year); + Assert.Null(candidate.PosterUrl); + Assert.Equal(ShowKind.Single, candidate.Kind); + } + + [Fact] + public async Task SearchShows_ReturnsEmpty_WhenNoResultsSection() + { + var found = await Provider(FakeHttpClientFactory.Json("{}")) + .SearchShowsAsync("ничего", ShowKind.Series, CancellationToken.None); + + Assert.Empty(found); + } + + [Fact] + public async Task GetShow_Series_ReadsGenresAndContentRating() + { + var http = FakeHttpClientFactory.Json( + """ + {"name":"Сериал","first_air_date":"2001-05-06","overview":"описание","poster_path":"/p.jpg", + "genres":[{"id":28,"name":"Боевик"},{"name":"Драма"},{"id":99}], + "content_ratings":{"results":[{"iso_3166_1":"RU","rating":"16+"},{"iso_3166_1":"US","rating":"TV-14"}]}} + """ + ); + + var show = await Provider(http).GetShowAsync("42", ShowKind.Series, CancellationToken.None); + + Assert.Contains("/tv/42", http.LastUrl); + Assert.Contains("append_to_response=content_ratings", http.LastUrl); + Assert.NotNull(show); + Assert.Equal("Сериал", show.Title); + Assert.Equal(2001, show.Year); + // Идентификатор впереди названия, безымянный жанр — только идентификатором. + Assert.Equal(new[] { "tmdb:28", "Боевик", "Драма", "tmdb:99" }, show.Genres); + // US приоритетнее RU, хотя в ответе идёт вторым. + Assert.Equal("TV-14", show.ContentRating); + } + + [Fact] + public async Task GetShow_Movie_PrefersTheatricalCertification() + { + var http = FakeHttpClientFactory.Json( + """ + {"title":"Фильм","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[ + {"type":4,"certification":"R"}, + {"type":3,"certification":"PG-13"} + ]}]}} + """ + ); + + var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None); + + Assert.Contains("/movie/7", http.LastUrl); + Assert.Contains("append_to_response=release_dates", http.LastUrl); + Assert.Equal("PG-13", show?.ContentRating); + } + + [Fact] + public async Task GetShow_Movie_FallsBackToAnyReleaseCertification() + { + var http = FakeHttpClientFactory.Json( + """ + {"title":"Фильм","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[ + {"type":5,"certification":"NC-17"} + ]}]}} + """ + ); + + var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None); + + Assert.Equal("NC-17", show?.ContentRating); + } + + [Fact] + public async Task GetShow_Movie_SkipsCountriesWithoutCertification() + { + var http = FakeHttpClientFactory.Json( + """ + {"title":"Фильм","release_dates":{"results":[ + {"iso_3166_1":"US"}, + {"iso_3166_1":"US","release_dates":[{"type":3,"certification":" "}]}, + {"iso_3166_1":"RU","release_dates":[{"type":3,"certification":"18+"}]} + ]}} + """ + ); + + var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None); + + // У США записи есть, но сертификации в них нет — ответом становится следующая страна. + Assert.Equal("18+", show?.ContentRating); + } + + [Fact] + public async Task GetShow_WithoutOptionalFields_FallsBackToPlaceholders() + { + var show = await Provider(FakeHttpClientFactory.Json("{}")) + .GetShowAsync("42", ShowKind.Series, CancellationToken.None); + + Assert.NotNull(show); + Assert.Equal("42", show.ExternalId); + Assert.Equal("—", show.Title); + Assert.Null(show.Year); + Assert.Null(show.PosterUrl); + Assert.Null(show.ContentRating); + Assert.Empty(show.Genres!); + } + + [Fact] + public async Task GetShow_ReturnsNull_WhenRequestFails() + { + var show = await Provider(FakeHttpClientFactory.Failing()) + .GetShowAsync("42", ShowKind.Series, CancellationToken.None); + + Assert.Null(show); + } + + [Fact] + public async Task GetEpisode_ReadsStillAndAirDate() + { + var http = FakeHttpClientFactory.Json( + """{"name":"Серия","overview":"о серии","still_path":"/s.jpg","air_date":"2001-06-07"}""" + ); + + var episode = await Provider(http).GetEpisodeAsync("42", 2, 3, CancellationToken.None); + + Assert.Contains("/tv/42/season/2/episode/3", http.LastUrl); + Assert.NotNull(episode); + Assert.Equal("Серия", episode.Title); + Assert.Equal("о серии", episode.Overview); + Assert.Equal("https://image.tmdb.org/t/p/w300/s.jpg", episode.StillUrl); + Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate); + } + + [Fact] + public async Task GetEpisode_ReturnsNull_WhenRequestFails() + { + var episode = await Provider(FakeHttpClientFactory.Failing()) + .GetEpisodeAsync("42", 1, 1, CancellationToken.None); + + Assert.Null(episode); + } + + [Fact] + public async Task GetSeasonEpisodeCount_CountsEpisodes() + { + var http = FakeHttpClientFactory.Json("""{"episodes":[{"id":1},{"id":2},{"id":3}]}"""); + + var count = await Provider(http) + .GetSeasonEpisodeCountAsync("42", 2, CancellationToken.None); + + Assert.Contains("/tv/42/season/2", http.LastUrl); + Assert.Equal(3, count); + } + + [Fact] + public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSectionMissing() + { + var count = await Provider(FakeHttpClientFactory.Json("{}")) + .GetSeasonEpisodeCountAsync("42", 9, CancellationToken.None); + + Assert.Null(count); + } + + [Fact] + public async Task GetSeasonNumbers_SkipsSpecialsAndSorts() + { + var http = FakeHttpClientFactory.Json( + """{"seasons":[{"season_number":2},{"season_number":0},{"season_number":1},{"name":"без номера"}]}""" + ); + + var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None); + + Assert.Equal(new[] { 1, 2 }, seasons); + } + + [Fact] + public async Task GetSeasonNumbers_ReturnsEmpty_WhenRequestFails() + { + var seasons = await Provider(FakeHttpClientFactory.Failing()) + .GetSeasonNumbersAsync("42", CancellationToken.None); + + Assert.Empty(seasons); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs new file mode 100644 index 0000000..31d05d8 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs @@ -0,0 +1,229 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Programming.Planning.Trace; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// «Почему это здесь»: трейс пишется в момент генерации и разбирается вместе с текущим состоянием +/// сетки. Слот мог быть с тех пор изменён или удалён — подписи тогда пустеют, но ответ остаётся. +/// +public class EntryTraceTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 18, 0, 0, TimeSpan.Zero); + + /// Те же настройки, что при записи трейса генератором. + private static readonly JsonSerializerOptions TraceJson = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + [Fact] + public async Task Trace_UnknownEntry_ReturnsNotFound() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new GetEntryTraceQueryHandler(db).Handle( + new GetEntryTraceQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.NotFound, result.Error); + } + + [Fact] + public async Task Trace_ResolvesSlotLayerGroupAndJunction() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var group = Group.Create("Кино"); + var collection = Collection.Create("Франшиза"); + var show = Show.Create("Фильм", ShowKind.Single); + var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload); + + var junction = JunctionTemplate.Create(channel.Id, "Прайм"); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var layer = template.AddLayer("Прайм", 10); + var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(), + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext, + JunctionAfterId: junction.Id + ) + ); + + var trace = new PlanTrace( + slot.Id, + SlotKind.Content, + GroupElementKind.Show, + show.Id, + SlotStrategyKind.RandomWithCooldown, + CandidatesAfterCooldown: 4, + DriftMinutes: 3, + Snapped: true + ); + var entry = ScheduleEntry.FromSlot( + channel.Id, + asset.Id, + ScheduleEntryKind.Program, + T0, + T0.AddMinutes(90), + new ScheduleEntryOrigin( + show.Id, + 2, + slot.Id, + JsonSerializer.Serialize(trace, TraceJson), + collection.Id + ) + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.Collections.Add(collection); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.JunctionTemplates.Add(junction); + seed.ScheduleTemplates.Add(template); + seed.ScheduleEntries.Add(entry); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetEntryTraceQueryHandler(db).Handle( + new GetEntryTraceQuery(entry.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var dto = result.Value; + Assert.Equal("Фильм", dto.ShowName); + Assert.Equal(2, dto.EpisodeIndex); + Assert.Equal("Франшиза", dto.CollectionName); + Assert.Equal("Прайм", dto.LayerName); + Assert.Equal(10, dto.LayerPriority); + Assert.Equal("Вечернее кино", dto.SlotTitle); + Assert.Equal(new TimeOnly(20, 0), dto.SlotTargetStart); + Assert.Equal(120, dto.SlotDurationMinutes); + Assert.Equal("Кино", dto.GroupName); + Assert.Equal(SlotStrategyKind.RandomWithCooldown, dto.Strategy); + // Остывание берётся из текущей стратегии слота, а число кандидатов — из трейса. + Assert.Equal(14, dto.CooldownDays); + Assert.Equal(4, dto.CandidatesAfterCooldown); + Assert.Equal(3, dto.DriftMinutes); + Assert.True(dto.Snapped); + Assert.Equal("Прайм", dto.JunctionName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("{не json}")] + public async Task Trace_WithoutUsableTrace_ReturnsBareEntry(string? traceJson) + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var show = Show.Create("Фильм", ShowKind.Single); + var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload); + var entry = ScheduleEntry.FromSlot( + channel.Id, + asset.Id, + ScheduleEntryKind.Program, + T0, + T0.AddMinutes(90), + new ScheduleEntryOrigin(show.Id, 1, null, traceJson) + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add(entry); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetEntryTraceQueryHandler(db).Handle( + new GetEntryTraceQuery(entry.Id), + CancellationToken.None + ); + + // Старая запись (или битый трейс) — ответ есть, подписи пустые. + Assert.True(result.IsSuccess); + Assert.Equal("Фильм", result.Value.ShowName); + Assert.Null(result.Value.LayerName); + Assert.Null(result.Value.SlotTitle); + Assert.Null(result.Value.Strategy); + Assert.Equal(0, result.Value.DriftMinutes); + Assert.False(result.Value.Snapped); + } + + [Fact] + public async Task Trace_WithDeletedSlot_KeepsWhatTraceRemembers() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload); + var trace = new PlanTrace( + Guid.NewGuid(), + SlotKind.Repeat, + null, + null, + SlotStrategyKind.Sequential, + null, + DriftMinutes: 0, + Snapped: false + ); + var entry = ScheduleEntry.FromSlot( + channel.Id, + asset.Id, + ScheduleEntryKind.Program, + T0, + T0.AddMinutes(30), + new ScheduleEntryOrigin(null, null, null, JsonSerializer.Serialize(trace, TraceJson)) + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add(entry); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetEntryTraceQueryHandler(db).Handle( + new GetEntryTraceQuery(entry.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + // Слота уже нет — из трейса остаётся его тип и стратегия, остальное пустеет. + Assert.Equal(SlotKind.Repeat, result.Value.SlotKind); + Assert.Equal(SlotStrategyKind.Sequential, result.Value.Strategy); + Assert.Null(result.Value.SlotTitle); + Assert.Null(result.Value.GroupName); + Assert.Null(result.Value.JunctionName); + Assert.Null(result.Value.ShowName); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs new file mode 100644 index 0000000..3ec3f70 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs @@ -0,0 +1,532 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.CreateSlot; +using TeleWave.Application.Programming.Templates.DeleteSlot; +using TeleWave.Application.Programming.Templates.GetTemplate; +using TeleWave.Application.Programming.Templates.Layers; +using TeleWave.Application.Programming.Templates.UpdateSlot; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам — +/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию». +/// +public class TemplateEditingTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static SlotInput Input( + string title = "Вечернее кино", + TimeOnly? start = null, + int minutes = 120, + SlotKind kind = SlotKind.Content, + Guid? groupId = null, + RepeatSource? repeat = null, + int? weekday = null + ) => + new( + title, + weekday, + start ?? new TimeOnly(20, 0), + minutes, + Daypart.Prime, + kind, + groupId, + new SlotStrategy(SlotStrategyType.Sequential), + repeat, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext, + IsAnchor: true, + MaxDriftMinutes: 10, + SnapToMinutes: 15 + ); + + /// Канал с сеткой: один обычный слой поверх фонового плюс группа контента. + private sealed record Fixture( + TestDb Db, + Guid ChannelId, + Guid TemplateId, + Guid LayerId, + Guid BackgroundLayerId, + Guid GroupId + ); + + private static async Task SeedAsync() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var group = Group.Create("Кино"); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var layer = template.AddLayer("Прайм", 10); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return new Fixture( + fixture, + channel.Id, + template.Id, + layer.Id, + template.Background!.Id, + group.Id + ); + } + + private static async Task AddSlotAsync(Fixture f, SlotInput input) + { + await using var db = f.Db.New(); + var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand(f.LayerId, input), + CancellationToken.None + ); + Assert.True(created.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return created.Value; + } + + private static async Task LoadSlotAsync(Fixture f, Guid slotId) + { + await using var db = f.Db.New(); + return await db.Slots.AsNoTracking().FirstAsync(s => s.Id == slotId); + } + + private static async Task RevisionAsync(Fixture f) + { + await using var db = f.Db.New(); + return ( + await db.ScheduleTemplates.AsNoTracking().FirstAsync(t => t.Id == f.TemplateId) + ).Revision; + } + + [Fact] + public async Task CreateSlot_UnknownLayer_ReturnsLayerNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.LayerNotFound, result.Error); + } + + [Fact] + public async Task CreateSlot_StoresTimingAndContent_AndBumpsRevision() + { + var f = await SeedAsync(); + + var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId, weekday: 3)); + + var slot = await LoadSlotAsync(f, slotId); + Assert.Equal("Вечернее кино", slot.Title); + Assert.Equal(3, slot.Weekday); + Assert.Equal(new TimeOnly(20, 0), slot.TargetStart); + Assert.Equal(120, slot.TargetDurationMinutes); + Assert.Equal(Daypart.Prime, slot.Daypart); + Assert.Equal(f.GroupId, slot.GroupId); + Assert.True(slot.IsAnchor); + Assert.Equal(15, slot.SnapToMinutes); + Assert.NotNull(SlotStrategy.FromJson(slot.StrategyJson)); + // Правка сетки эфир не двигает — только помечает шаблон изменённым. + Assert.Equal(1, await RevisionAsync(f)); + } + + [Fact] + public async Task CreateSlot_ContentWithoutGroup_ReturnsGroupRequired() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand(f.LayerId, Input(groupId: null)), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.GroupRequired, result.Error); + } + + [Fact] + public async Task CreateSlot_UnknownGroup_ReturnsGroupNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.GroupNotFound, result.Error); + } + + [Fact] + public async Task CreateSlot_RepeatWithoutSource_ReturnsRepeatSourceRequired() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.RepeatSourceRequired, result.Error); + } + + [Fact] + public async Task CreateSlot_Repeat_KeepsSourceAndDropsGroup() + { + var f = await SeedAsync(); + var repeat = new RepeatSource(1, new TimeOnly(20, 0), 120); + + var slotId = await AddSlotAsync( + f, + Input(kind: SlotKind.Repeat, groupId: f.GroupId, repeat: repeat) + ); + + var slot = await LoadSlotAsync(f, slotId); + // Группа к повтору отношения не имеет: домен гасит поля чужого типа слота. + Assert.Null(slot.GroupId); + Assert.Equal(repeat, RepeatSource.FromJson(slot.RepeatSourceJson)); + } + + [Fact] + public async Task CreateSlot_OverlappingInSameLayer_ReturnsSlotsOverlap() + { + var f = await SeedAsync(); + await AddSlotAsync(f, Input(groupId: f.GroupId)); + + await using var db = f.Db.New(); + var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + new CreateSlotCommand( + f.LayerId, + Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId) + ), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.SlotsOverlap, result.Error); + } + + [Fact] + public async Task UpdateSlot_UnknownSlot_ReturnsSlotNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle( + new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.SlotNotFound, result.Error); + } + + [Fact] + public async Task UpdateSlot_MovesSlot_WithoutCountingItAsOverlap() + { + var f = await SeedAsync(); + var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId)); + + await using (var db = f.Db.New()) + { + // Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой. + var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle( + new UpdateSlotCommand( + slotId, + Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId) + ), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + var slot = await LoadSlotAsync(f, slotId); + Assert.Equal("Ночное кино", slot.Title); + Assert.Equal(new TimeOnly(21, 0), slot.TargetStart); + Assert.Equal(2, await RevisionAsync(f)); + } + + [Fact] + public async Task DeleteSlot_RemovesSlot_OrReportsNotFound() + { + var f = await SeedAsync(); + var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId)); + + await using (var db = f.Db.New()) + { + var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle( + new DeleteSlotCommand(Guid.NewGuid()), + CancellationToken.None + ); + Assert.Equal(TemplateErrors.SlotNotFound, missing.Error); + } + + await using (var db = f.Db.New()) + { + var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle( + new DeleteSlotCommand(slotId), + CancellationToken.None + ); + Assert.True(deleted.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = f.Db.New(); + Assert.False(await verify.Slots.AnyAsync(s => s.Id == slotId)); + } + + [Fact] + public async Task CreateLayer_UnknownTemplate_ReturnsNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new CreateLayerCommandHandler(db).Handle( + new CreateLayerCommand(Guid.NewGuid(), "Лето", 20), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.NotFound, result.Error); + } + + [Fact] + public async Task CreateLayer_AddsLayerToTemplate() + { + var f = await SeedAsync(); + + Guid layerId; + await using (var db = f.Db.New()) + { + var result = await new CreateLayerCommandHandler(db).Handle( + new CreateLayerCommand(f.TemplateId, "Лето", 20), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + layerId = result.Value; + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = f.Db.New(); + var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == layerId); + Assert.Equal("Лето", layer.Name); + Assert.Equal(20, layer.Priority); + Assert.False(layer.IsBackground); + } + + [Fact] + public async Task UpdateLayer_StoresApplicability_OrReportsNotFound() + { + var f = await SeedAsync(); + var applicability = new LayerApplicability(Weekdays: [5, 6]); + + await using (var db = f.Db.New()) + { + var missing = await new UpdateLayerCommandHandler(db).Handle( + new UpdateLayerCommand(Guid.NewGuid(), "Нет", 5, null, true), + CancellationToken.None + ); + Assert.Equal(TemplateErrors.LayerNotFound, missing.Error); + } + + await using (var db = f.Db.New()) + { + var result = await new UpdateLayerCommandHandler(db).Handle( + new UpdateLayerCommand(f.LayerId, "Выходные", 30, applicability, false), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = f.Db.New(); + var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == f.LayerId); + Assert.Equal("Выходные", layer.Name); + Assert.Equal(30, layer.Priority); + Assert.False(layer.IsEnabled); + var stored = LayerApplicability.FromJson(layer.ApplicabilityJson); + Assert.Equal(new[] { 5, 6 }, stored?.Weekdays); + } + + [Fact] + public async Task DeleteLayer_RefusesBackground_AndRemovesOrdinary() + { + var f = await SeedAsync(); + + await using (var db = f.Db.New()) + { + var background = await new DeleteLayerCommandHandler(db).Handle( + new DeleteLayerCommand(f.BackgroundLayerId), + CancellationToken.None + ); + // Без фонового слоя первую же дыру в сетке нечем закрыть. + Assert.Equal(TemplateErrors.BackgroundLayerCannotBeDeleted, background.Error); + } + + await using (var db = f.Db.New()) + { + var missing = await new DeleteLayerCommandHandler(db).Handle( + new DeleteLayerCommand(Guid.NewGuid()), + CancellationToken.None + ); + Assert.Equal(TemplateErrors.LayerNotFound, missing.Error); + } + + await using (var db = f.Db.New()) + { + var deleted = await new DeleteLayerCommandHandler(db).Handle( + new DeleteLayerCommand(f.LayerId), + CancellationToken.None + ); + Assert.True(deleted.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = f.Db.New(); + Assert.False(await verify.GridLayers.AnyAsync(l => l.Id == f.LayerId)); + } + + [Fact] + public async Task UpdateTemplate_UnknownTemplate_ReturnsNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new UpdateTemplateCommandHandler(db).Handle( + new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.NotFound, result.Error); + } + + [Fact] + public async Task UpdateTemplate_UnknownFallbackGroup_ReturnsGroupNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new UpdateTemplateCommandHandler(db).Handle( + new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null), + CancellationToken.None + ); + + Assert.Equal(TemplateErrors.GroupNotFound, result.Error); + } + + [Fact] + public async Task UpdateTemplate_StoresFallbackAndRules() + { + var f = await SeedAsync(); + var rules = new PlanningRules( + MaxAudienceByTime: + [ + new AudienceWindow( + new TimeOnly(6, 0), + new TimeOnly(21, 0), + Domain.Library.ShowAudience.Pg + ), + ], + MaxRepeatsInWindow: new RepeatLimitRule(7, 2) + ); + + await using (var db = f.Db.New()) + { + var result = await new UpdateTemplateCommandHandler(db).Handle( + new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = f.Db.New(); + var template = await verify + .ScheduleTemplates.AsNoTracking() + .FirstAsync(t => t.Id == f.TemplateId); + Assert.Equal("Новая сетка", template.Name); + Assert.Equal(f.GroupId, template.FallbackGroupId); + var storedRules = PlanningRules.FromJson(template.RulesJson); + Assert.Equal(rules.MaxRepeatsInWindow, storedRules?.MaxRepeatsInWindow); + Assert.Equal(rules.MaxAudienceByTime, storedRules?.MaxAudienceByTime); + Assert.True(template.HasPendingChanges); + } + + [Fact] + public async Task GetChannelTemplate_UnknownChannel_ReturnsNotFound() + { + var f = await SeedAsync(); + await using var db = f.Db.New(); + + var result = await new GetChannelTemplateQueryHandler(db).Handle( + new GetChannelTemplateQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.NotFound, result.Error); + } + + [Fact] + public async Task GetChannelTemplate_ChannelWithoutGrid_ReturnsTemplateNotFound() + { + var fixture = new TestDb(); + var channel = Channel.Create("Без сетки", "nogrid", T0); + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + 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.Equal(ChannelErrors.TemplateNotFound, result.Error); + } + + [Fact] + public async Task GetChannelTemplate_OrdersLayersByPriority_AndResolvesGroupNames() + { + var f = await SeedAsync(); + var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId)); + + await using var db = f.Db.New(); + var result = await new GetChannelTemplateQueryHandler(db).Handle( + new GetChannelTemplateQuery(f.ChannelId), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var dto = result.Value; + Assert.Equal(f.TemplateId, dto.Id); + Assert.True(dto.HasPendingChanges); + // Приоритетный слой идёт первым, фоновый — последним. + Assert.Equal( + new[] { f.LayerId, f.BackgroundLayerId }, + dto.Layers.Select(l => l.Id).ToList() + ); + Assert.True(dto.Layers[^1].IsBackground); + + var slot = Assert.Single(dto.Layers[0].Slots); + Assert.Equal(slotId, slot.Id); + Assert.Equal("Кино", slot.GroupName); + Assert.Equal(SlotStrategyType.Sequential, slot.Strategy?.Type); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs new file mode 100644 index 0000000..0e18dfd --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs @@ -0,0 +1,295 @@ +using TeleWave.Application.Broadcast; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Validate; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Проверки сетки до генерации: пустые группы, пересечения слотов, недостижимое остывание, +/// конфликт с детским временем и непокрытые интервалы суток. +/// +public class ValidateTemplateTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// Слот на сутки целиком — им закрываем сетку, когда дыры не тема теста. + private static Slot FullDaySlot(GridLayer layer, Guid? groupId, TimeOnly dayStart) + { + var slot = layer.AddSlot("Круглосуточно", dayStart, 24 * 60); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + groupId, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + return slot; + } + + private static void Fill( + Slot slot, + Guid? groupId, + SlotStrategy? strategy = null, + SlotKind kind = SlotKind.Content + ) => + slot.UpdateContent( + new SlotContent( + slot.Title, + kind, + groupId, + (strategy ?? new SlotStrategy(SlotStrategyType.Sequential)).ToJson(), + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + + private static async Task> ValidateAsync( + TestDb fixture, + Guid channelId + ) + { + await using var db = fixture.New(); + var result = await new ValidateTemplateQueryHandler(db).Handle( + new ValidateTemplateQuery(channelId), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + return result.Value; + } + + [Fact] + public async Task Validate_UnknownChannel_ReturnsNotFound() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new ValidateTemplateQueryHandler(db).Handle( + new ValidateTemplateQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.NotFound, result.Error); + } + + [Fact] + public async Task Validate_ChannelWithoutGrid_ReturnsTemplateNotFound() + { + var fixture = new TestDb(); + var channel = Channel.Create("Без сетки", "nogrid", T0); + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new ValidateTemplateQueryHandler(db).Handle( + new ValidateTemplateQuery(channel.Id), + CancellationToken.None + ); + + Assert.Equal(ChannelErrors.TemplateNotFound, result.Error); + } + + [Fact] + public async Task Validate_EmptyGrid_ReportsGapForEveryDay() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + // Семь суток без единого слота — семь дыр «весь день». + var gaps = issues.Where(i => i.Kind == TemplateIssueKind.GridGap).ToList(); + Assert.Equal(7, gaps.Count); + Assert.All(gaps, g => Assert.Contains("весь день", g.Details)); + Assert.All(gaps, g => Assert.Equal(TemplateIssueSeverity.Error, g.Severity)); + } + + [Fact] + public async Task Validate_SlotWithoutGroup_ReportsGroupMissing() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + FullDaySlot(template.Background!, null, channel.DayStartTime); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupMissing); + // Сутки закрыты слотом — дыр быть не должно. + Assert.DoesNotContain(issues, i => i.Kind == TemplateIssueKind.GridGap); + } + + [Fact] + public async Task Validate_EmptyGroup_ReportsGroupEmpty() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var group = Group.Create("Пустая"); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + FullDaySlot(template.Background!, group.Id, channel.DayStartTime); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + var issue = Assert.Single(issues, i => i.Kind == TemplateIssueKind.GroupEmpty); + Assert.Contains("Пустая", issue.Details); + } + + [Fact] + public async Task Validate_SmallGroupWithCooldown_ReportsBothWarnings() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var group = Group.Create("Маленькая"); + group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime); + // Ежедневный слот — 7 выходов в неделю на 3 позиции, да ещё с двухнедельным остыванием. + Fill( + slot, + group.Id, + new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14) + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupTooSmall); + Assert.Contains(issues, i => i.Kind == TemplateIssueKind.CooldownUnreachable); + Assert.All( + issues.Where(i => i.Kind != TemplateIssueKind.GridGap), + i => Assert.Equal(TemplateIssueSeverity.Warning, i.Severity) + ); + } + + [Fact] + public async Task Validate_OverlappingSlotsInOneLayer_AreReported() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + var group = Group.Create("Кино"); + group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + FullDaySlot(template.Background!, group.Id, channel.DayStartTime); + var layer = template.AddLayer("Прайм", 10); + var first = layer.AddSlot("Кино", new TimeOnly(20, 0), 120); + Fill(first, group.Id); + var second = layer.AddSlot("Сериал", new TimeOnly(21, 0), 60); + Fill(second, group.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + // Внутри слоя приоритетов нет: второй слот молча пропал бы при генерации. + var overlap = Assert.Single(issues, i => i.Kind == TemplateIssueKind.SlotOverlap); + Assert.Equal(second.Id, overlap.SlotId); + Assert.Equal(layer.Id, overlap.LayerId); + } + + [Fact] + public async Task Validate_AdultContentInKidsWindow_ReportsAudienceConflict() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + + var mild = Show.Create("Мультик", ShowKind.Single); + mild.SetAudience(ShowAudience.G); + var harsh = Show.Create("Ужастик", ShowKind.Single); + harsh.SetAudience(ShowAudience.R); + // Строжайший рейтинг группы приходит через коллекцию: франшиза идёт целиком. + var collection = Collection.Create("Франшиза"); + collection.AddShow(harsh.Id); + + var group = Group.Create("Вечернее"); + group.AddElement(GroupElementKind.Show, mild.Id); + group.AddElement(GroupElementKind.Collection, collection.Id); + group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime); + slot.UpdateTiming( + null, + new TimeOnly(10, 0), + 24 * 60, + Daypart.Day, + isAnchor: false, + 5, + null + ); + template.SetRules( + new PlanningRules( + MaxAudienceByTime: + [ + new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(21, 0), ShowAudience.Pg), + ] + ).ToJson() + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Groups.Add(group); + seed.Collections.Add(collection); + seed.Shows.AddRange(mild, harsh); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var issues = await ValidateAsync(fixture, channel.Id); + + var conflict = Assert.Single(issues, i => i.Kind == TemplateIssueKind.AudienceConflict); + Assert.Contains("Вечернее", conflict.Details); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Settings/SiteSettingsTests.cs b/backend/tests/TeleWave.Application.Tests/Settings/SiteSettingsTests.cs new file mode 100644 index 0000000..facffc7 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Settings/SiteSettingsTests.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Settings.GetSiteSettings; +using TeleWave.Application.Settings.UpdateSiteSettings; +using TeleWave.Application.Tests.Support; +using TeleWave.Infrastructure.Settings; +using Xunit; + +namespace TeleWave.Application.Tests.Settings; + +/// +/// Глобальные настройки сайта поверх key-value таблицы: значения по умолчанию, upsert (второй раз +/// пишем в ту же строку, а не заводим новую) и нормализация списка языков. +/// +public class SiteSettingsTests +{ + [Fact] + public async Task Get_WithoutStoredValues_ReturnsDefaults() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var dto = await new GetSiteSettingsQueryHandler(new SiteSettings(db)).Handle( + new GetSiteSettingsQuery(), + CancellationToken.None + ); + + // Открытая регистрация и переключение по номерам по умолчанию выключены. + Assert.False(dto.RegistrationEnabled); + Assert.False(dto.ChannelNumbersEnabled); + Assert.Equal(string.Empty, dto.PreferredAudioLanguages); + } + + [Fact] + public async Task Update_StoresValues_AndNormalizesLanguages() + { + var fixture = new TestDb(); + + await using (var db = fixture.New()) + { + var result = await new UpdateSiteSettingsCommandHandler(new SiteSettings(db)).Handle( + new UpdateSiteSettingsCommand(true, " RUS , ,eng ", true), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + // Сохранение — за UnitOfWorkBehavior команды, в тесте делаем его руками. + await db.SaveChangesAsync(CancellationToken.None); + } + + await using (var db = fixture.New()) + { + var dto = await new GetSiteSettingsQueryHandler(new SiteSettings(db)).Handle( + new GetSiteSettingsQuery(), + CancellationToken.None + ); + Assert.True(dto.RegistrationEnabled); + Assert.True(dto.ChannelNumbersEnabled); + Assert.Equal("rus,eng", dto.PreferredAudioLanguages); + } + } + + [Fact] + public async Task Update_Twice_RewritesSameRows() + { + var fixture = new TestDb(); + + foreach (var enabled in new[] { true, false }) + { + await using var db = fixture.New(); + await new UpdateSiteSettingsCommandHandler(new SiteSettings(db)).Handle( + new UpdateSiteSettingsCommand(enabled, "rus", enabled), + CancellationToken.None + ); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = fixture.New(); + var settings = new SiteSettings(verify); + // Ключей ровно три — повторная запись обновляет строку, а не добавляет вторую. + Assert.Equal(3, await verify.AppSettings.CountAsync()); + Assert.False(await settings.IsRegistrationEnabledAsync(CancellationToken.None)); + Assert.Equal("rus", await settings.GetPreferredAudioLanguagesAsync(CancellationToken.None)); + Assert.False(await settings.AreChannelNumbersEnabledAsync(CancellationToken.None)); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs new file mode 100644 index 0000000..365e764 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs @@ -0,0 +1,128 @@ +using TeleWave.Application.Streaming.ListPublicChannels; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Streaming; + +/// +/// Витрина зрителя: только включённые каналы, порядок по номеру и обложка того, что идёт прямо +/// сейчас. Выключенный канал не должен просачиваться в публичную выдачу. +/// +public class ListPublicChannelsTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task List_ReturnsEnabledChannelsOrderedByNumber() + { + var fixture = new TestDb(); + var numbered = Channel.Create("Второй", "two", T0); + numbered.UpdateTimeSettings( + 2, + Channel.DefaultUtcOffsetMinutes, + Channel.DefaultDayStartTime + ); + var first = Channel.Create("Первый", "one", T0); + first.UpdateTimeSettings(1, Channel.DefaultUtcOffsetMinutes, Channel.DefaultDayStartTime); + var unnumbered = Channel.Create("Аврора", "aurora", T0); + var disabled = Channel.Create("Выключенный", "off", T0); + + foreach (var channel in new[] { numbered, first, unnumbered }) + channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, null); + disabled.UpdateSettings("Выключенный", isEnabled: false, bumpersEnabled: false, null); + + await using (var seed = fixture.New()) + { + seed.Channels.AddRange(numbered, first, unnumbered, disabled); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var channels = await new ListPublicChannelsQueryHandler(db).Handle( + new ListPublicChannelsQuery(), + CancellationToken.None + ); + + // Ненумерованный уходит в конец: на телевизоре порядок задаёт номер. + Assert.Equal( + new[] { "Первый", "Второй", "Аврора" }, + channels.Select(c => c.Name).ToArray() + ); + Assert.Null(channels[^1].Number); + Assert.All(channels, c => Assert.Null(c.CurrentShowId)); + } + + [Fact] + public async Task List_AddsCurrentProgramCover() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: false, null); + var logoId = Guid.NewGuid(); + channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3); + + var show = Show.Create("Сейчас в эфире", ShowKind.Series); + show.SetPosterImage(Guid.NewGuid()); + var asset = MediaAsset.Register("now.mkv", ".mkv", MediaSource.Upload); + var now = DateTimeOffset.UtcNow; + var current = ScheduleEntry.Program( + channel.Id, + asset.Id, + now.AddMinutes(-5), + now.AddMinutes(25), + show.Id, + 0 + ); + // Прошедшая запись того же канала не должна перебить текущую. + var past = ScheduleEntry.Program( + channel.Id, + asset.Id, + now.AddHours(-2), + now.AddHours(-1), + Guid.NewGuid(), + 0 + ); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.AddRange(current, past); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var channels = await new ListPublicChannelsQueryHandler(db).Handle( + new ListPublicChannelsQuery(), + CancellationToken.None + ); + + var dto = Assert.Single(channels); + Assert.Equal(show.Id, dto.CurrentShowId); + Assert.Equal("Сейчас в эфире", dto.CurrentShowName); + Assert.Equal(show.PosterImageId, dto.CurrentShowPosterImageId); + Assert.Equal(logoId, dto.LogoImageId); + Assert.Equal(LogoCorner.TopRight, dto.LogoCorner); + Assert.Equal(0.5, dto.LogoOpacity); + Assert.True(dto.ShowClock); + Assert.Equal(0.3, dto.AnalogFilterStrength); + } + + [Fact] + public async Task List_WithoutEnabledChannels_ReturnsEmpty() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var channels = await new ListPublicChannelsQueryHandler(db).Handle( + new ListPublicChannelsQuery(), + CancellationToken.None + ); + + Assert.Empty(channels); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/TestSupport/FakeHttpClientFactory.cs b/backend/tests/TeleWave.Application.Tests/TestSupport/FakeHttpClientFactory.cs new file mode 100644 index 0000000..e26f8ee --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/TestSupport/FakeHttpClientFactory.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Text; + +namespace TeleWave.Application.Tests.TestSupport; + +/// +/// Фабрика HTTP-клиентов поверх подставного обработчика: наружу запрос не уходит, ответ выбирается +/// по URL. Запрошенные адреса копятся в — по ним проверяем, что провайдер собрал +/// правильный запрос (сегмент tv/movie, язык, append_to_response). +/// +internal sealed class FakeHttpClientFactory(Func respond) + : IHttpClientFactory +{ + public List Urls { get; } = []; + + /// Один и тот же JSON на любой запрос — самый частый случай в тестах. + public static FakeHttpClientFactory Json(string json) => new(_ => Ok(json)); + + /// Ответ-ошибка: провайдеры обязаны деградировать в null/пустой список. + public static FakeHttpClientFactory Failing( + HttpStatusCode status = HttpStatusCode.ServiceUnavailable + ) => new(_ => new HttpResponseMessage(status) { Content = new StringContent("{}") }); + + public static HttpResponseMessage Ok(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + public HttpClient CreateClient(string name) => + new( + new StubHandler(url => + { + Urls.Add(url); + return respond(url); + }) + ); + + /// Последний запрошенный адрес — короче, чем каждый раз доставать из списка. + public string LastUrl => Urls.Count > 0 ? Urls[^1] : string.Empty; + + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => Task.FromResult(respond(request.RequestUri!.ToString())); + } +} diff --git a/frontend/package.json b/frontend/package.json index a7befbd..c0371a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@hookform/resolvers": "^5.4.0", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-select": "^2.3.2", "@radix-ui/react-slot": "^1.3.0", "@tanstack/react-query": "^5.101.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 868550e..8cab254 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.11 version: 2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': + specifier: 1.1.14 + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-select': specifier: ^2.3.2 version: 2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -352,6 +355,9 @@ packages: '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -368,6 +374,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.13': resolution: {integrity: sha512-Q8xYqNRFObXPmi45bFSkGdM2JA5hjBeYGFqzg6lZR3wp6b+cciraVq8DdneTabtws+uOYqjmsvmKOufk/M9Cyg==} peerDependencies: @@ -381,6 +400,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-compose-refs@1.1.4': resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==} peerDependencies: @@ -390,6 +418,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.2.1': resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==} peerDependencies: @@ -421,6 +458,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dismissable-layer@1.1.10': + resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.17': resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==} peerDependencies: @@ -434,6 +484,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-guards@1.1.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-guards@1.1.5': resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==} peerDependencies: @@ -456,6 +515,28 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-id@1.1.3': resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==} peerDependencies: @@ -478,6 +559,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.14': + resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.7': + resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.3.5': resolution: {integrity: sha512-6hLng2Rs45IZcvZ31BNvMeaFEMMEbtsog4xPcb8LZrGfyoV9fdAXqB9UKhLpTHtL0+E2DhSr6VW54Tehd6ksAQ==} peerDependencies: @@ -504,6 +611,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.9': resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==} peerDependencies: @@ -517,6 +650,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.8': resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==} peerDependencies: @@ -543,6 +689,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.3.1': resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==} peerDependencies: @@ -552,6 +707,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.3': resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} peerDependencies: @@ -561,6 +725,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.5': resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==} peerDependencies: @@ -570,6 +743,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.4': resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==} peerDependencies: @@ -579,6 +761,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.3': resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==} peerDependencies: @@ -597,6 +797,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.3': resolution: {integrity: sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==} peerDependencies: @@ -606,6 +815,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.3': resolution: {integrity: sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==} peerDependencies: @@ -628,6 +846,9 @@ packages: '@types/react-dom': optional: true + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} @@ -1735,6 +1956,8 @@ snapshots: '@radix-ui/number@1.1.3': {} + '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -1746,6 +1969,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) @@ -1758,12 +1990,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-context@1.1.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.8)': dependencies: react: 19.2.8 @@ -1799,6 +2043,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -1812,6 +2069,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.8)': dependencies: react: 19.2.8 @@ -1829,6 +2092,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) @@ -1845,6 +2126,47 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/rect': 1.1.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1873,6 +2195,26 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) @@ -1882,6 +2224,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) @@ -1921,6 +2272,13 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -1929,12 +2287,26 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -1944,6 +2316,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) @@ -1951,6 +2330,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.8)': dependencies: react: 19.2.8 @@ -1963,6 +2355,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/rect': 1.1.3 @@ -1970,6 +2369,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) @@ -1986,6 +2392,8 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/rect@1.1.1': {} + '@radix-ui/rect@1.1.3': {} '@rolldown/binding-android-arm64@1.1.5': diff --git a/frontend/src/features/admin/collections/CollectionDetail.tsx b/frontend/src/features/admin/collections/CollectionDetail.tsx index dd65d5d..4168e0d 100644 --- a/frontend/src/features/admin/collections/CollectionDetail.tsx +++ b/frontend/src/features/admin/collections/CollectionDetail.tsx @@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next' import { imageUrl } from '@/features/admin/images/api' import { ImageGallery } from '@/features/admin/images/ImageGallery' import { listShows } from '@/features/admin/shows/api' +import { ShowPicker } from '@/features/admin/shows/ShowPicker' import { qk } from '@/shared/api/query-keys' import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { addCollectionShow, getCollection, @@ -165,18 +165,14 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri

{t('admin.collections.parts')}

- + {/* Только те, кого в коллекции ещё нет: повторно добавлять некого. */} + + + + )} + + + ) +} + +function Option({ + active, + selected, + label, + hint, + onSelect, +}: Readonly<{ + active: boolean + selected: boolean + label: string + hint?: string + onSelect: () => void +}>) { + return ( + + ) +} diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 6ad84fd..f91be59 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -129,6 +129,7 @@ export const en = { parts: 'Parts', units: 'Units', addShow: 'Add show', + pickShow: 'Pick a show', empty: 'Collection is empty', orderHint: 'Drag to set the order of the parts — that is the order they air in.', }, @@ -295,6 +296,12 @@ export const en = { originalName: 'Original name (eng)', kind: 'Kind', kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' }, + pickerPlaceholder: 'Pick a show', + pickerSearch: 'Search by name…', + pickerEmpty: 'Nothing found', + pickerCreate: 'Create series “{{name}}”', + pickerCreateSingleHint: 'Create as a movie', + pickerCreated: 'Show created', audience: 'Rating', audienceUnset: 'Not rated', audiences: { diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 217dee4..97d7761 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -129,6 +129,7 @@ export const ru = { parts: 'Части', units: 'Единиц', addShow: 'Добавить шоу', + pickShow: 'Выберите шоу', empty: 'Коллекция пуста', orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.', }, @@ -296,6 +297,12 @@ export const ru = { originalName: 'Оригинальное название (eng)', kind: 'Тип', kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' }, + pickerPlaceholder: 'Выберите шоу', + pickerSearch: 'Поиск по названию…', + pickerEmpty: 'Ничего не найдено', + pickerCreate: 'Создать сериал «{{name}}»', + pickerCreateSingleHint: 'Создать как полнометражку', + pickerCreated: 'Шоу создано', audience: 'Рейтинг', audienceUnset: 'Не проставлен', audiences: { diff --git a/frontend/src/shared/ui/popover.tsx b/frontend/src/shared/ui/popover.tsx new file mode 100644 index 0000000..1166f5a --- /dev/null +++ b/frontend/src/shared/ui/popover.tsx @@ -0,0 +1,24 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover' +import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Popover = PopoverPrimitive.Root +export const PopoverTrigger = PopoverPrimitive.Trigger +export const PopoverAnchor = PopoverPrimitive.Anchor + +export const PopoverContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName