Implement collection suggestions and bulk show addition features
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 5m12s

Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 05:39:23 +03:00
parent 1a7f73a5bd
commit 5ad28b2966
39 changed files with 2710 additions and 10 deletions
@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Library;
/// <summary>
/// Сборка франшиз: предложения по метаданным и по названиям плюс массовое добавление в коллекцию.
/// Ошибка здесь означает чужой фильм в середине франшизы, поэтому осторожность важнее полноты.
/// </summary>
public class CollectionSuggestionsTests
{
[Fact]
public async Task SuggestsFranchise_FromMetadata()
{
var fixture = new TestDb();
var first = Movie("Гарри Поттер и философский камень", 2001, franchise: "1241");
var second = Movie("Гарри Поттер и тайная комната", 2002, franchise: "1241");
var other = Movie("Совсем другое", 1999, franchise: "777");
await SeedAsync(fixture, first, second, other);
var suggestions = await BuildAsync(fixture);
var franchise = Assert.Single(
suggestions,
s => s.Source == CollectionSuggestionSource.Metadata
);
Assert.Equal("Франшиза 1241", franchise.Name);
// Порядок — по году: это и есть порядок просмотра.
Assert.Equal([first.Id, second.Id], franchise.Parts.Select(p => p.ShowId));
}
[Fact]
public async Task SuggestsByName_WhenMetadataMissing()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976);
var second = Movie("Рокки 2", 1979);
await SeedAsync(fixture, first, second);
var suggestions = await BuildAsync(fixture);
var guess = Assert.Single(
suggestions,
s => s.Source == CollectionSuggestionSource.NameGuess
);
Assert.Equal(2, guess.Parts.Count);
}
[Fact]
public async Task DoesNotGuess_OnShortCommonStart()
{
// «Человек-паук» и «Человек дождя» — не франшиза, хотя начало совпадает.
var fixture = new TestDb();
await SeedAsync(fixture, Movie("Человек дождя", 1988), Movie("Человек-слон", 1980));
var suggestions = await BuildAsync(fixture);
Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess);
}
[Fact]
public async Task SkipsShows_AlreadyInCollections()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976, franchise: "1241");
var second = Movie("Рокки 2", 1979, franchise: "1241");
var collection = Collection.Create("Рокки");
collection.AddShow(first.Id);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(first, second);
seed.Collections.Add(collection);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
// Одна часть уже разобрана — на франшизу оставшегося не хватает.
Assert.Empty(suggestions);
}
[Fact]
public async Task AddShowsToCollection_CreatesNewAndOrdersByYear()
{
var fixture = new TestDb();
var later = Movie("Вторая", 1979);
var earlier = Movie("Первая", 1976);
await SeedAsync(fixture, later, earlier);
await using var db = fixture.New();
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
new AddShowsToCollectionCommand([later.Id, earlier.Id], NewCollectionName: "Франшиза"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var collection = check.Collections.Include(c => c.Items).Single();
Assert.Equal(2, result.Value.Added);
Assert.Equal(
[earlier.Id, later.Id],
collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId)
);
}
private static Show Movie(string name, int? year, string? franchise = null)
{
var show = Show.Create(name, ShowKind.Single);
if (franchise is not null)
show.ApplyMetadata(
"tmdb",
Guid.NewGuid().ToString("N"),
null,
year,
null,
new MetadataFranchise(franchise, $"Франшиза {franchise}")
);
else
show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
return show;
}
private static async Task SeedAsync(TestDb fixture, params Show[] shows)
{
await using var seed = fixture.New();
seed.Shows.AddRange(shows);
await seed.SaveChangesAsync(CancellationToken.None);
}
private static async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(TestDb fixture)
{
await using var db = fixture.New();
return await new CollectionSuggestionBuilder(db).BuildAsync(CancellationToken.None);
}
}