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.
This commit is contained in:
+14
-5
@@ -21,11 +21,20 @@ public sealed class CreateCollectionFromSuggestionCommandHandler(
|
|||||||
if (suggestion is null)
|
if (suggestion is null)
|
||||||
return Result.Failure<Guid>(CollectionErrors.SuggestionNotFound);
|
return Result.Failure<Guid>(CollectionErrors.SuggestionNotFound);
|
||||||
|
|
||||||
var taken = await dbContext.Collections.AnyAsync(
|
// Сравниваем имена в памяти, а не запросом: коллекций десятки, зато проверка идёт по тем же
|
||||||
c => c.Name.ToLower() == suggestion.Name.ToLower(),
|
// правилам, что и пометка «уже есть» в списке предложений. SQL-регистронезависимость
|
||||||
cancellationToken
|
// (lower()) в тонких местах вроде «ё» с ней расходится, и предложение то показывалось бы
|
||||||
);
|
// доступным, то отбивалось конфликтом.
|
||||||
if (taken)
|
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<Guid>(CollectionErrors.SuggestionAlreadyCreated);
|
return Result.Failure<Guid>(CollectionErrors.SuggestionAlreadyCreated);
|
||||||
|
|
||||||
var collection = Collection.Create(suggestion.Name);
|
var collection = Collection.Create(suggestion.Name);
|
||||||
|
|||||||
+13
-6
@@ -22,12 +22,19 @@ public sealed class CreateGroupFromSuggestionCommandHandler(
|
|||||||
if (suggestion is null)
|
if (suggestion is null)
|
||||||
return Result.Failure<Guid>(GroupErrors.SuggestionNotFound);
|
return Result.Failure<Guid>(GroupErrors.SuggestionNotFound);
|
||||||
|
|
||||||
// Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы.
|
// Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. Сравниваем
|
||||||
var taken = await dbContext.Groups.AnyAsync(
|
// в памяти, а не запросом: групп десятки, зато проверка идёт по тем же правилам, что и
|
||||||
g => g.Name.ToLower() == suggestion.Name.ToLower(),
|
// пометка «уже есть» в списке предложений, — SQL-регистронезависимость с ней расходится.
|
||||||
cancellationToken
|
var names = await dbContext.Groups.Select(g => g.Name).ToListAsync(cancellationToken);
|
||||||
);
|
if (
|
||||||
if (taken)
|
names.Any(name =>
|
||||||
|
string.Equals(
|
||||||
|
name.Trim(),
|
||||||
|
suggestion.Name,
|
||||||
|
StringComparison.CurrentCultureIgnoreCase
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
return Result.Failure<Guid>(GroupErrors.SuggestionAlreadyCreated);
|
return Result.Failure<Guid>(GroupErrors.SuggestionAlreadyCreated);
|
||||||
|
|
||||||
var group = Group.Create(suggestion.Name);
|
var group = Group.Create(suggestion.Name);
|
||||||
|
|||||||
@@ -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]
|
[Fact]
|
||||||
@@ -77,7 +77,7 @@ public class ListShowsTests
|
|||||||
Assert.Equal(2, series.SeasonCount);
|
Assert.Equal(2, series.SeasonCount);
|
||||||
Assert.True(series.HasPoster);
|
Assert.True(series.HasPoster);
|
||||||
Assert.Equal("Боевик", series.PrimaryGenre);
|
Assert.Equal("Боевик", series.PrimaryGenre);
|
||||||
Assert.Equal(new[] { "Драма" }, series.OtherGenres.ToArray());
|
Assert.Equal(["Драма"], series.OtherGenres.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -54,8 +54,13 @@ public class UploadsCleanupTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Sweep_SurvivesMissingDirectory()
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ public class OmdbMetadataProviderTests
|
|||||||
Assert.Equal("Сериал", show.Title);
|
Assert.Equal("Сериал", show.Title);
|
||||||
Assert.Equal(2001, show.Year);
|
Assert.Equal(2001, show.Year);
|
||||||
Assert.Equal("описание", show.Overview);
|
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);
|
Assert.Equal("TV-14", show.ContentRating);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ public class OmdbMetadataProviderTests
|
|||||||
|
|
||||||
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
|
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { 1, 2, 3 }, seasons);
|
Assert.Equal([1, 2, 3], seasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public class TmdbMetadataProviderTests
|
|||||||
Assert.Equal("Сериал", show.Title);
|
Assert.Equal("Сериал", show.Title);
|
||||||
Assert.Equal(2001, show.Year);
|
Assert.Equal(2001, show.Year);
|
||||||
// Идентификатор впереди названия, безымянный жанр — только идентификатором.
|
// Идентификатор впереди названия, безымянный жанр — только идентификатором.
|
||||||
Assert.Equal(new[] { "tmdb:28", "Боевик", "Драма", "tmdb:99" }, show.Genres);
|
Assert.Equal(["tmdb:28", "Боевик", "Драма", "tmdb:99"], show.Genres);
|
||||||
// US приоритетнее RU, хотя в ответе идёт вторым.
|
// US приоритетнее RU, хотя в ответе идёт вторым.
|
||||||
Assert.Equal("TV-14", show.ContentRating);
|
Assert.Equal("TV-14", show.ContentRating);
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ public class TmdbMetadataProviderTests
|
|||||||
|
|
||||||
var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None);
|
var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { 1, 2 }, seasons);
|
Assert.Equal([1, 2], seasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ public class TemplateEditingTests
|
|||||||
Assert.Equal(30, layer.Priority);
|
Assert.Equal(30, layer.Priority);
|
||||||
Assert.False(layer.IsEnabled);
|
Assert.False(layer.IsEnabled);
|
||||||
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
|
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
|
||||||
Assert.Equal(new[] { 5, 6 }, stored?.Weekdays);
|
Assert.Equal([5, 6], stored?.Weekdays);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -47,10 +47,7 @@ public class ListPublicChannelsTests
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ненумерованный уходит в конец: на телевизоре порядок задаёт номер.
|
// Ненумерованный уходит в конец: на телевизоре порядок задаёт номер.
|
||||||
Assert.Equal(
|
Assert.Equal(["Первый", "Второй", "Аврора"], channels.Select(c => c.Name).ToArray());
|
||||||
new[] { "Первый", "Второй", "Аврора" },
|
|
||||||
channels.Select(c => c.Name).ToArray()
|
|
||||||
);
|
|
||||||
Assert.Null(channels[^1].Number);
|
Assert.Null(channels[^1].Number);
|
||||||
Assert.All(channels, c => Assert.Null(c.CurrentShowId));
|
Assert.All(channels, c => Assert.Null(c.CurrentShowId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpiso
|
|||||||
* Диапазон сезонов в имени: «Season.3-4», «S01-S03». Такая папка накрывает несколько сезонов, и
|
* Диапазон сезонов в имени: «Season.3-4», «S01-S03». Такая папка накрывает несколько сезонов, и
|
||||||
* подставлять из неё один номер нельзя — честнее не угадывать вовсе.
|
* подставлять из неё один номер нельзя — честнее не угадывать вовсе.
|
||||||
*/
|
*/
|
||||||
|
// Разделитель вокруг тире — без самого дефиса: иначе «-» подходит и под разделитель, и под тире,
|
||||||
|
// и движок перебирает варианты впустую (у Sonar это «super-linear backtracking»).
|
||||||
const SEASON_RANGES = [
|
const SEASON_RANGES = [
|
||||||
/(?:s|season|сезон[ы]?)[\s._-]*\d{1,2}[\s._-]*[-–][\s._-]*(?:s|season)?\d{1,2}/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,
|
/\d{1,2}[\s._]*[-–][\s._]*\d{1,2}[\s._-]*сезон/i,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */
|
/** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ export function ShowsPanel() {
|
|||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||||
const onError = useApiError()
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
createShow({
|
createShow({
|
||||||
@@ -184,14 +202,8 @@ export function ShowsPanel() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={t('admin.shows.bulk.selectPage')}
|
aria-label={t('admin.shows.bulk.selectPage')}
|
||||||
checked={pageItems.length > 0 && pageItems.every((s) => selected.includes(s.id))}
|
checked={pageAllSelected}
|
||||||
onChange={(e) =>
|
onChange={(e) => togglePage(e.target.checked)}
|
||||||
setSelected((current) =>
|
|
||||||
e.target.checked
|
|
||||||
? [...new Set([...current, ...pageItems.map((s) => s.id)])]
|
|
||||||
: current.filter((id) => !pageItems.some((s) => s.id === id)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<SortHeader
|
<SortHeader
|
||||||
@@ -248,13 +260,7 @@ export function ShowsPanel() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={show.name}
|
aria-label={show.name}
|
||||||
checked={selected.includes(show.id)}
|
checked={selected.includes(show.id)}
|
||||||
onChange={() =>
|
onChange={() => toggleShow(show.id)}
|
||||||
setSelected((current) =>
|
|
||||||
current.includes(show.id)
|
|
||||||
? current.filter((id) => id !== show.id)
|
|
||||||
: [...current, show.id],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user