Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
7.1 KiB
C#
199 lines
7.1 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Api.Services.Catalogue;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Table des auteurs : regroupement automatique, suggestions et fusions.
|
|
/// </summary>
|
|
public class ServiceAuteursTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection _connexion;
|
|
private readonly MaBibliDbContext _db;
|
|
private readonly ServiceAuteurs _auteurs;
|
|
private readonly ServiceCatalogue _catalogue;
|
|
|
|
public ServiceAuteursTests()
|
|
{
|
|
_connexion = new SqliteConnection("Data Source=:memory:");
|
|
_connexion.Open();
|
|
|
|
_db = new MaBibliDbContext(
|
|
new DbContextOptionsBuilder<MaBibliDbContext>().UseSqlite(_connexion).Options);
|
|
_db.Database.EnsureCreated();
|
|
|
|
_auteurs = new ServiceAuteurs(_db);
|
|
_catalogue = new ServiceCatalogue(_db, _auteurs);
|
|
}
|
|
|
|
private async Task<LivreDto> AjouterAsync(string titre, string auteurs) =>
|
|
(await _catalogue.CreerAsync(
|
|
new EnregistrementLivre { Titre = titre, Auteur = auteurs }, "mathieu")).Livre!;
|
|
|
|
[Fact]
|
|
public async Task Un_auteur_nest_cree_quune_fois()
|
|
{
|
|
await AjouterAsync("Germinal", "Émile Zola");
|
|
await AjouterAsync("Nana", "Émile Zola");
|
|
|
|
var liste = await _auteurs.ListerAsync();
|
|
var zola = Assert.Single(liste);
|
|
Assert.Equal("Émile Zola", zola.Nom);
|
|
Assert.Equal(2, zola.NombreDeLivres);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Le_nom_daffichage_reste_celui_de_la_premiere_saisie()
|
|
{
|
|
await AjouterAsync("Germinal", "Émile Zola");
|
|
await AjouterAsync("Nana", "ZOLA, emile");
|
|
|
|
// Les deux livres pointent la même fiche, sous le nom d'origine : l'accentuation correcte
|
|
// ne doit pas être écrasée par une saisie plus négligée.
|
|
Assert.Equal("Émile Zola", Assert.Single(await _auteurs.ListerAsync()).Nom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aucune_suggestion_sur_des_auteurs_sans_rapport()
|
|
{
|
|
await AjouterAsync("Germinal", "Émile Zola");
|
|
await AjouterAsync("Le Horla", "Guy de Maupassant");
|
|
|
|
Assert.Empty(await _auteurs.SuggestionsAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_patronyme_seul_est_propose_vers_le_nom_complet()
|
|
{
|
|
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
|
|
await AjouterAsync("Un autre", "Hamilton");
|
|
|
|
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
|
|
|
|
// La fusion efface la source : c'est le nom le moins complet qui doit disparaître.
|
|
Assert.Equal("Hamilton", suggestion.Source.Nom);
|
|
Assert.Equal("Peter F. Hamilton", suggestion.Cible.Nom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Accepter_un_rapprochement_reunit_les_livres()
|
|
{
|
|
var complet = await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
|
|
var court = await AjouterAsync("Judas Unchained", "Hamilton");
|
|
|
|
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
|
|
Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
|
|
var restant = Assert.Single(await _auteurs.ListerAsync());
|
|
Assert.Equal("Peter F. Hamilton", restant.Nom);
|
|
Assert.Equal(2, restant.NombreDeLivres);
|
|
|
|
// Les deux livres remontent maintenant sous le même auteur.
|
|
var groupe = await _catalogue.ListerAsync(new CritereLivres { AuteurId = restant.Id }, "mathieu");
|
|
Assert.Equal([complet.Id, court.Id], groupe.Select(l => l.Id).Order());
|
|
|
|
// Et la suggestion ne peut plus revenir : l'une des deux fiches n'existe plus.
|
|
Assert.Empty(await _auteurs.SuggestionsAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refuser_un_rapprochement_le_fait_taire_definitivement()
|
|
{
|
|
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
|
|
await AjouterAsync("Un autre", "Hamilton");
|
|
|
|
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
|
|
Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
|
|
Assert.Empty(await _auteurs.SuggestionsAsync());
|
|
|
|
// Les deux auteurs restent bien distincts : refuser n'est pas fusionner.
|
|
Assert.Equal(2, (await _auteurs.ListerAsync()).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_refus_vaut_dans_les_deux_sens()
|
|
{
|
|
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
|
|
await AjouterAsync("Un autre", "Hamilton");
|
|
|
|
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
|
|
|
|
// Refus enregistré dans l'ordre inverse de celui proposé : il doit compter quand même.
|
|
Assert.True(await _auteurs.RefuserAsync(suggestion.Cible.Id, suggestion.Source.Id));
|
|
Assert.Empty(await _auteurs.SuggestionsAsync());
|
|
|
|
// Idempotent : redire non ne doit pas empiler les lignes.
|
|
Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
Assert.Equal(1, await _db.RapprochementsRefuses.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fusionner_un_livre_cosigne_ne_cree_pas_de_lien_en_double()
|
|
{
|
|
// Le même livre signé par les deux variantes : après fusion il ne doit rester qu'un lien.
|
|
var livre = await AjouterAsync("Œuvre commune", "Peter F. Hamilton; Hamilton");
|
|
Assert.Equal(2, livre.Auteurs.Count);
|
|
|
|
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
|
|
Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
|
|
var relu = await _catalogue.ObtenirAsync(livre.Id, "mathieu");
|
|
Assert.Single(relu!.Auteurs);
|
|
Assert.Equal(1, await _db.LivreAuteurs.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fusionner_avec_un_auteur_inexistant_echoue_sans_rien_casser()
|
|
{
|
|
var livre = await AjouterAsync("Germinal", "Émile Zola");
|
|
var zola = livre.Auteurs.Single().Id;
|
|
|
|
Assert.False(await _auteurs.FusionnerAsync(zola, 4242));
|
|
Assert.False(await _auteurs.FusionnerAsync(zola, zola));
|
|
Assert.Equal(1, await _db.Auteurs.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Retirer_un_auteur_dun_livre_supprime_sa_fiche_devenue_vide()
|
|
{
|
|
var livre = await AjouterAsync("Germinal", "Émile Zola");
|
|
|
|
await _catalogue.ModifierAsync(
|
|
livre.Id,
|
|
new EnregistrementLivre { Titre = "Germinal", Auteur = "Guy de Maupassant" },
|
|
"mathieu");
|
|
|
|
var restant = Assert.Single(await _auteurs.ListerAsync());
|
|
Assert.Equal("Guy de Maupassant", restant.Nom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Une_saisie_sans_lettre_ne_cree_pas_dauteur()
|
|
{
|
|
await AjouterAsync("Sans auteur", " ; ; .,; ");
|
|
|
|
Assert.Empty(await _auteurs.ListerAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Le_meme_auteur_saisi_deux_fois_ne_donne_quun_lien()
|
|
{
|
|
var livre = await AjouterAsync("Germinal", "Émile Zola; ZOLA, Emile");
|
|
|
|
Assert.Single(livre.Auteurs);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_db.Dispose();
|
|
_connexion.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|