From 97fa77287f1edbf97571ae8c1b75802304a8dc04 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 06:08:23 +0300 Subject: [PATCH] Refactor collection and group suggestion handling to improve name comparison logic Updated the CreateCollectionFromSuggestionCommandHandler and CreateGroupFromSuggestionCommandHandler to compare names in memory rather than using SQL queries for case-insensitive checks. This change addresses potential discrepancies with SQL's case sensitivity, particularly with special characters. Additionally, modified unit tests to use array syntax for assertions, enhancing readability and consistency across the test suite. --- ...eCollectionFromSuggestionCommandHandler.cs | 19 +++++++--- ...CreateGroupFromSuggestionCommandHandler.cs | 19 ++++++---- .../Library/ListShowsTests.cs | 4 +-- .../Media/UploadsCleanupTests.cs | 9 +++-- .../Metadata/OmdbMetadataProviderTests.cs | 4 +-- .../Metadata/TmdbMetadataProviderTests.cs | 4 +-- .../Programming/TemplateEditingTests.cs | 2 +- .../Streaming/ListPublicChannelsTests.cs | 5 +-- .../src/features/admin/media/episode-parse.ts | 6 ++-- .../src/features/admin/shows/ShowsPanel.tsx | 36 +++++++++++-------- 10 files changed, 67 insertions(+), 41 deletions(-) diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs index 1a9cacf..f75e3f7 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs @@ -21,11 +21,20 @@ public sealed class CreateCollectionFromSuggestionCommandHandler( if (suggestion is null) return Result.Failure(CollectionErrors.SuggestionNotFound); - var taken = await dbContext.Collections.AnyAsync( - c => c.Name.ToLower() == suggestion.Name.ToLower(), - cancellationToken - ); - if (taken) + // Сравниваем имена в памяти, а не запросом: коллекций десятки, зато проверка идёт по тем же + // правилам, что и пометка «уже есть» в списке предложений. SQL-регистронезависимость + // (lower()) в тонких местах вроде «ё» с ней расходится, и предложение то показывалось бы + // доступным, то отбивалось конфликтом. + var names = await dbContext.Collections.Select(c => c.Name).ToListAsync(cancellationToken); + if ( + names.Any(name => + string.Equals( + name.Trim(), + suggestion.Name, + StringComparison.CurrentCultureIgnoreCase + ) + ) + ) return Result.Failure(CollectionErrors.SuggestionAlreadyCreated); var collection = Collection.Create(suggestion.Name); diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs index 45ff68f..09bce39 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs @@ -22,12 +22,19 @@ public sealed class CreateGroupFromSuggestionCommandHandler( if (suggestion is null) return Result.Failure(GroupErrors.SuggestionNotFound); - // Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. - var taken = await dbContext.Groups.AnyAsync( - g => g.Name.ToLower() == suggestion.Name.ToLower(), - cancellationToken - ); - if (taken) + // Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. Сравниваем + // в памяти, а не запросом: групп десятки, зато проверка идёт по тем же правилам, что и + // пометка «уже есть» в списке предложений, — SQL-регистронезависимость с ней расходится. + var names = await dbContext.Groups.Select(g => g.Name).ToListAsync(cancellationToken); + if ( + names.Any(name => + string.Equals( + name.Trim(), + suggestion.Name, + StringComparison.CurrentCultureIgnoreCase + ) + ) + ) return Result.Failure(GroupErrors.SuggestionAlreadyCreated); var group = Group.Create(suggestion.Name); diff --git a/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs index d795a67..d81feeb 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs @@ -58,7 +58,7 @@ public class ListShowsTests ); // Ролики-врезки живут на своей странице и в общей библиотеке только мешали бы. - Assert.Equal(new[] { "Автофильм", "Сериал" }, shows.Select(s => s.Name).ToArray()); + Assert.Equal(["Автофильм", "Сериал"], shows.Select(s => s.Name).ToArray()); } [Fact] @@ -77,7 +77,7 @@ public class ListShowsTests Assert.Equal(2, series.SeasonCount); Assert.True(series.HasPoster); Assert.Equal("Боевик", series.PrimaryGenre); - Assert.Equal(new[] { "Драма" }, series.OtherGenres.ToArray()); + Assert.Equal(["Драма"], series.OtherGenres.ToArray()); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs index 87bb948..0391c14 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs @@ -54,8 +54,13 @@ public class UploadsCleanupTests : IDisposable [Fact] public void Sweep_SurvivesMissingDirectory() { - // Каталога может не быть на самом первом старте — это не повод падать. - UploadsCleaner.Sweep(Paths(), TimeSpan.FromHours(24), NullLogger.Instance); + // Каталога может не быть на самом первом старте — это не повод падать. И заводить его + // уборка тоже не должна: каталоги создаёт хранилище, когда в них появляется нужда. + var paths = Paths(); + + UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance); + + Assert.False(Directory.Exists(paths.UploadsDir)); } /// diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs index 0b4de30..511201a 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs @@ -79,7 +79,7 @@ public class OmdbMetadataProviderTests Assert.Equal("Сериал", show.Title); Assert.Equal(2001, show.Year); Assert.Equal("описание", show.Overview); - Assert.Equal(new[] { "Action", "Sci-Fi" }, show.Genres); + Assert.Equal(["Action", "Sci-Fi"], show.Genres); Assert.Equal("TV-14", show.ContentRating); } @@ -185,7 +185,7 @@ public class OmdbMetadataProviderTests var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None); - Assert.Equal(new[] { 1, 2, 3 }, seasons); + Assert.Equal([1, 2, 3], seasons); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs index 0eac00c..bfe1852 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs @@ -97,7 +97,7 @@ public class TmdbMetadataProviderTests Assert.Equal("Сериал", show.Title); Assert.Equal(2001, show.Year); // Идентификатор впереди названия, безымянный жанр — только идентификатором. - Assert.Equal(new[] { "tmdb:28", "Боевик", "Драма", "tmdb:99" }, show.Genres); + Assert.Equal(["tmdb:28", "Боевик", "Драма", "tmdb:99"], show.Genres); // US приоритетнее RU, хотя в ответе идёт вторым. Assert.Equal("TV-14", show.ContentRating); } @@ -236,7 +236,7 @@ public class TmdbMetadataProviderTests var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None); - Assert.Equal(new[] { 1, 2 }, seasons); + Assert.Equal([1, 2], seasons); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs index 3ec3f70..0611b0d 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs @@ -360,7 +360,7 @@ public class TemplateEditingTests Assert.Equal(30, layer.Priority); Assert.False(layer.IsEnabled); var stored = LayerApplicability.FromJson(layer.ApplicabilityJson); - Assert.Equal(new[] { 5, 6 }, stored?.Weekdays); + Assert.Equal([5, 6], stored?.Weekdays); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs index 365e764..7f11132 100644 --- a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs @@ -47,10 +47,7 @@ public class ListPublicChannelsTests ); // Ненумерованный уходит в конец: на телевизоре порядок задаёт номер. - Assert.Equal( - new[] { "Первый", "Второй", "Аврора" }, - channels.Select(c => c.Name).ToArray() - ); + Assert.Equal(["Первый", "Второй", "Аврора"], channels.Select(c => c.Name).ToArray()); Assert.Null(channels[^1].Number); Assert.All(channels, c => Assert.Null(c.CurrentShowId)); } diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index 84170fe..3c4ff75 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -73,9 +73,11 @@ export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpiso * Диапазон сезонов в имени: «Season.3-4», «S01-S03». Такая папка накрывает несколько сезонов, и * подставлять из неё один номер нельзя — честнее не угадывать вовсе. */ +// Разделитель вокруг тире — без самого дефиса: иначе «-» подходит и под разделитель, и под тире, +// и движок перебирает варианты впустую (у Sonar это «super-linear backtracking»). const SEASON_RANGES = [ - /(?:s|season|сезон[ы]?)[\s._-]*\d{1,2}[\s._-]*[-–][\s._-]*(?:s|season)?\d{1,2}/i, - /\d{1,2}[\s._-]*[-–][\s._-]*\d{1,2}[\s._-]*сезон/i, + /(?:s|season|сезоны?)[\s._-]*\d{1,2}[\s._]*[-–][\s._]*(?:s|season)?\d{1,2}/i, + /\d{1,2}[\s._]*[-–][\s._]*\d{1,2}[\s._-]*сезон/i, ] /** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */ diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 0325384..77c507d 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -74,6 +74,24 @@ export function ShowsPanel() { const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all }) const onError = useApiError() + // Отметки страницей и по одной. Вынесены из разметки: три вложенных стрелки прямо в onChange + // читаются хуже, чем имя действия. + const pageAllSelected = + pageItems.length > 0 && pageItems.every((show) => selected.includes(show.id)) + + const togglePage = (checked: boolean) => + setSelected((current) => { + const ids = pageItems.map((show) => show.id) + return checked + ? [...new Set([...current, ...ids])] + : current.filter((id) => !ids.includes(id)) + }) + + const toggleShow = (showId: string) => + setSelected((current) => + current.includes(showId) ? current.filter((id) => id !== showId) : [...current, showId], + ) + const createMutation = useMutation({ mutationFn: () => createShow({ @@ -184,14 +202,8 @@ export function ShowsPanel() { 0 && pageItems.every((s) => selected.includes(s.id))} - onChange={(e) => - setSelected((current) => - e.target.checked - ? [...new Set([...current, ...pageItems.map((s) => s.id)])] - : current.filter((id) => !pageItems.some((s) => s.id === id)), - ) - } + checked={pageAllSelected} + onChange={(e) => togglePage(e.target.checked)} /> - setSelected((current) => - current.includes(show.id) - ? current.filter((id) => id !== show.id) - : [...current, show.id], - ) - } + onChange={() => toggleShow(show.id)} />