Sortir le statut de lecture et l'auteur de la table Livre

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>
This commit is contained in:
mathieu
2026-08-18 02:22:46 +02:00
co-authored by Claude Opus 5
parent 4a8bc98f50
commit b594a4b2c1
32 changed files with 2856 additions and 164 deletions
+156 -18
View File
@@ -31,7 +31,7 @@ public class ServiceCatalogueTests : IDisposable
_db = new MaBibliDbContext(options);
_db.Database.EnsureCreated();
_service = new ServiceCatalogue(_db);
_service = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
}
private static EnregistrementLivre Saisie(
@@ -39,7 +39,7 @@ public class ServiceCatalogueTests : IDisposable
string? auteur = null,
string? isbn = null,
Format format = Format.Physique,
Statut statut = Statut.ALire) => new()
Statut? statut = null) => new()
{
Titre = titre,
Auteur = auteur,
@@ -58,6 +58,7 @@ public class ServiceCatalogueTests : IDisposable
Assert.True(livre.Id > 0);
Assert.Equal("Germinal", livre.Titre);
Assert.Equal("mathieu", livre.AjoutePar);
Assert.Equal(["Émile Zola"], livre.Auteurs.Select(a => a.Nom));
Assert.NotEqual(default, livre.DateAjout);
}
@@ -97,10 +98,10 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu");
await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu");
var tout = await _service.ListerAsync(new CritereLivres());
var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
Assert.Equal(2, tout.Count);
var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique });
var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }, "mathieu");
Assert.Equal(["Epub"], numeriques.Select(l => l.Titre));
}
@@ -110,13 +111,31 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" });
var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }, "mathieu");
Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre));
var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" });
var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }, "mathieu");
Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre));
}
/// <summary>
/// La recherche sans accents doit traverser SQLite, pas seulement LINQ-to-Objects : c'est là
/// que la colonne normalisée gagne son existence.
/// </summary>
[Theory]
[InlineData("emile")]
[InlineData("EMILE")]
[InlineData("Émile Zola")]
public async Task La_recherche_sans_accents_traverse_bien_SQLite(string terme)
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
var resultat = await _service.ListerAsync(new CritereLivres { Recherche = terme }, "mathieu");
Assert.Equal(["Germinal"], resultat.Select(l => l.Titre));
}
/// <summary>
/// <b>Invariant central</b> : la bibliothèque est commune. Des livres saisis par des comptes
/// différents doivent tous ressortir, quelle que soit la personne qui consulte.
@@ -128,7 +147,7 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Livre de Camille"), "camille");
await _service.CreerAsync(Saisie("Livre anonyme"), null);
var tout = await _service.ListerAsync(new CritereLivres());
var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
Assert.Equal(3, tout.Count);
Assert.Contains(tout, l => l.AjoutePar == "mathieu");
@@ -139,19 +158,20 @@ public class ServiceCatalogueTests : IDisposable
[Fact]
public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout()
{
var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur"), "camille")).Livre!;
var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur Initial"), "camille")).Livre!;
var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre
{
Titre = "Titre corrigé",
Auteur = "Auteur corrigé",
Auteur = "Auteur Corrigé",
Format = Format.Numerique,
Statut = Statut.Lu,
});
}, "camille");
Assert.Null(modifie.Erreur);
Assert.Equal("Titre corrigé", modifie.Livre!.Titre);
Assert.Equal(Format.Numerique, modifie.Livre.Format);
Assert.Equal(["Auteur Corrigé"], modifie.Livre.Auteurs.Select(a => a.Nom));
// La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre.
Assert.Equal("camille", modifie.Livre.AjoutePar);
@@ -161,7 +181,7 @@ public class ServiceCatalogueTests : IDisposable
[Fact]
public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable()
{
var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme"));
var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme"), "mathieu");
Assert.True(resultat.EstIntrouvable);
}
@@ -171,43 +191,161 @@ public class ServiceCatalogueTests : IDisposable
{
var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours);
var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours, "mathieu");
Assert.NotNull(apres);
Assert.Equal(Statut.EnCours, apres!.Statut);
Assert.Equal(cree.Titre, apres.Titre);
Assert.Equal(cree.Auteur, apres.Auteur);
Assert.Equal(cree.Auteurs.Select(a => a.Nom), apres.Auteurs.Select(a => a.Nom));
Assert.Equal(cree.AjoutePar, apres.AjoutePar);
}
/// <summary>
/// <b>La décision de cette phase, vérifiée de bout en bout.</b> Un seul livre, deux personnes,
/// deux statuts qui ne se marchent pas dessus.
/// </summary>
[Fact]
public async Task Deux_utilisateurs_ont_des_statuts_differents_sur_le_meme_livre()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
await _service.ChangerStatutAsync(livre.Id, Statut.Lu, "mathieu");
await _service.ChangerStatutAsync(livre.Id, Statut.EnCours, "camille");
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
// Une troisième personne n'a rien posé : pour elle le livre est « non commencé ».
Assert.Null((await _service.ObtenirAsync(livre.Id, "claire"))!.Statut);
// Une seule ligne par personne : rien ne s'empile à force de changer d'avis.
Assert.Equal(2, await _db.StatutsLecture.CountAsync(s => s.LivreId == livre.Id));
}
[Fact]
public async Task Un_livre_cree_sans_statut_nen_ecrit_aucun()
{
var livre = (await _service.CreerAsync(Saisie("Germinal"), "mathieu")).Livre!;
Assert.Null((await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Empty(_db.StatutsLecture);
}
[Fact]
public async Task Un_statut_nul_remet_le_livre_a_non_commence()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
Assert.Equal(Statut.Lu, livre.Statut);
var apres = await _service.ChangerStatutAsync(livre.Id, null, "mathieu");
Assert.Null(apres!.Statut);
Assert.Empty(_db.StatutsLecture);
}
/// <summary>Éditer un livre pose MON statut, jamais celui de la personne qui l'a saisi.</summary>
[Fact]
public async Task Modifier_un_livre_ne_touche_pas_au_statut_des_autres()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
await _service.ModifierAsync(
livre.Id, Saisie("Germinal", statut: Statut.EnCours), "camille");
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
}
[Fact]
public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null()
=> Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu));
=> Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu, "mathieu"));
[Fact]
public async Task Sans_identite_aucun_statut_nest_ecrit()
{
var livre = (await _service.CreerAsync(Saisie("Anonyme", statut: Statut.Lu), null)).Livre!;
Assert.Null(livre.Statut);
Assert.Empty(_db.StatutsLecture);
}
[Fact]
public async Task Un_livre_peut_avoir_plusieurs_auteurs_dans_lordre()
{
var livre = (await _service.CreerAsync(
Saisie("Introduction to Algorithms",
"Thomas H. Cormen; Charles E. Leiserson; Ronald L. Rivest; Clifford Stein"),
"mathieu")).Livre!;
Assert.Equal(
["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"],
livre.Auteurs.Select(a => a.Nom));
}
[Fact]
public async Task Les_variantes_sures_dun_auteur_partagent_la_meme_fiche()
{
var a = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
var b = (await _service.CreerAsync(Saisie("Nana", "Zola, Emile"), "mathieu")).Livre!;
var c = (await _service.CreerAsync(Saisie("La Curée", "E. Zola"), "mathieu")).Livre!;
var id = a.Auteurs.Single().Id;
Assert.Equal(id, b.Auteurs.Single().Id);
Assert.Equal(id, c.Auteurs.Single().Id);
Assert.Equal(1, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Un_patronyme_seul_ne_se_fond_pas_dans_un_nom_complet()
{
var complet = (await _service.CreerAsync(
Saisie("Pandora's Star", "Peter F. Hamilton"), "mathieu")).Livre!;
var seul = (await _service.CreerAsync(Saisie("Autre livre", "Hamilton"), "mathieu")).Livre!;
// Deux fiches distinctes : le rapprochement est ambigu, il sera proposé, pas appliqué.
Assert.NotEqual(complet.Auteurs.Single().Id, seul.Auteurs.Single().Id);
Assert.Equal(2, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Supprimer_retire_le_livre_du_catalogue()
{
var cree = (await _service.CreerAsync(Saisie("À jeter"), "mathieu")).Livre!;
var cree = (await _service.CreerAsync(Saisie("À jeter", "Auteur Jetable"), "mathieu")).Livre!;
Assert.True(await _service.SupprimerAsync(cree.Id));
Assert.Null(await _service.ObtenirAsync(cree.Id));
Assert.Empty(await _service.ListerAsync(new CritereLivres()));
Assert.Null(await _service.ObtenirAsync(cree.Id, "mathieu"));
Assert.Empty(await _service.ListerAsync(new CritereLivres(), "mathieu"));
// L'auteur n'a plus aucun livre : il ne doit pas rester à polluer l'écran des auteurs.
Assert.Empty(_db.Auteurs);
// Seconde suppression : plus rien à supprimer.
Assert.False(await _service.SupprimerAsync(cree.Id));
}
[Fact]
public async Task Un_auteur_encore_lu_par_un_autre_livre_survit_a_une_suppression()
{
var premier = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
await _service.CreerAsync(Saisie("Nana", "Émile Zola"), "mathieu");
await _service.SupprimerAsync(premier.Id);
Assert.Equal(1, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Obtenir_renvoie_la_fiche_complete()
{
var cree = (await _service.CreerAsync(
Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!;
var relu = await _service.ObtenirAsync(cree.Id);
var relu = await _service.ObtenirAsync(cree.Id, "mathieu");
Assert.NotNull(relu);
Assert.Equal("Le Horla", relu!.Titre);
Assert.Equal("9782080704092", relu.Isbn);
Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom));
}
public void Dispose()