Files
mabibli/MaBibli.Tests/ServiceRenormalisationTests.cs
T
mathieuandClaude Opus 5 94f0ef15c0 Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages
`Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait
comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour
`TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre,
d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt
que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ».

Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce
champ n'est pas un nombre mais une phrase décrivant le support, et les notices
déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv.
ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc
étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout
le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à
la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le
voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) »,
qui est une pagination de contribution, ne vaut rien. La valeur reste proposée
dans un champ modifiable, et rien n'est déduit pour un ebook.

Lot X — éditer une envie, et souhaiter une revue
`PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans
ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur
l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur
l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure
— l'envie d'un autre est introuvable (404), jamais refusée (403).

⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un
ajout ne peut pas : renommer une envie en une autre déjà présente répond par un
message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409,
contrairement au doublon du catalogue : là-bas posséder deux exemplaires est
légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à
confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée.

⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran
n'offre de champ « URL de couverture » pour une envie (décision actée), donc un
remplacement inconditionnel l'aurait effacée à la première faute de frappe
corrigée.

`RevueSouhaitee` est une table sœur, et non des colonnes de plus sur
`LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro,
deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans
devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut
vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret,
seul code du projet rangé ainsi.

Le coût de la table sœur est payé partout où il devait l'être : affichage,
`.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment
une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son
rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que
personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas
les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres
dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri
par titre rendrait revues et livres indiscernables, et la colonne des codes
mêlerait ISBN et ISSN en silence.

`ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision
déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien
quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y
échapperait.

`RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de
`GET /api/couvertures`.

Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie
d'un autre en 404, et les deux exports portant bien les deux moitiés.
602 tests au vert (552 au départ), aucun avertissement de compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:02:09 +02:00

391 lines
14 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Shared.Entites;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MaBibli.Tests;
/// <summary>
/// Rattrapage des colonnes normalisées après une migration.
/// </summary>
/// <remarks>
/// Les données sont écrites <b>telles que la migration SQL les laisse</b> : normalisation
/// approchée (<c>lower()</c> ne touche pas aux accents), donc variantes d'un même auteur encore
/// éclatées. C'est exactement l'état que ce service doit savoir rattraper.
/// </remarks>
public class ServiceRenormalisationTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
public ServiceRenormalisationTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
_db = new MaBibliDbContext(
new DbContextOptionsBuilder<MaBibliDbContext>().UseSqlite(_connexion).Options);
_db.Database.EnsureCreated();
}
private Task ExecuterAsync() =>
new ServiceRenormalisation(_db, NullLogger<ServiceRenormalisation>.Instance).ExecuterAsync();
/// <summary>Écrit un auteur comme la migration le ferait : formes en <c>lower()</c> brut.</summary>
private Auteur AjouterHerite(string nom, params string[] titres)
{
var auteur = new Auteur
{
Nom = nom,
NomNormalise = nom.ToLowerInvariant(),
CleRegroupement = nom.ToLowerInvariant(),
};
_db.Auteurs.Add(auteur);
foreach (var titre in titres)
{
var livre = new Livre { Titre = titre, TitreNormalise = titre.ToLowerInvariant() };
_db.Livres.Add(livre);
livre.Auteurs.Add(new LivreAuteur { Auteur = auteur });
}
_db.SaveChanges();
return auteur;
}
[Fact]
public async Task Les_titres_perdent_leurs_accents_dans_la_colonne_normalisee()
{
AjouterHerite("Émile Zola", "La Bête humaine");
await ExecuterAsync();
Assert.Equal("la bete humaine", _db.Livres.Single().TitreNormalise);
}
[Fact]
public async Task Les_variantes_daccent_et_dordre_sont_reunies()
{
AjouterHerite("Emile Zola", "Germinal");
AjouterHerite("Émile Zola", "Nana");
AjouterHerite("ZOLA, Émile", "La Curée");
await ExecuterAsync();
var zola = Assert.Single(_db.Auteurs);
Assert.Equal(3, await _db.LivreAuteurs.CountAsync(la => la.AuteurId == zola.Id));
}
/// <summary>
/// Le nom qui survit est celui qui se lit le mieux. Sans arbitrage, c'est l'ordre — arbitraire —
/// des identifiants issus de la migration qui déciderait, et la bibliothèque perdrait ses
/// accents au profit de la saisie la plus négligée.
/// </summary>
[Theory]
// L'ordre est celui des identifiants après migration : le résultat doit en être indépendant.
[InlineData("Emile Zola|Émile Zola")]
[InlineData("Émile Zola|Emile Zola")]
[InlineData("EMILE ZOLA|ZOLA, Émile|Émile Zola")]
[InlineData("ZOLA, Émile|Émile Zola")]
public async Task Le_nom_conserve_est_le_plus_presentable(string variantes)
{
foreach (var variante in variantes.Split('|'))
{
AjouterHerite(variante, $"Livre de {variante}");
}
await ExecuterAsync();
Assert.Equal("Émile Zola", Assert.Single(_db.Auteurs).Nom);
}
/// <summary>
/// Les initiales échappent à la clé de regroupement. Sans second tour, une base héritée
/// resterait éclatée là où une saisie neuve aurait été réunie d'emblée.
/// </summary>
[Fact]
public async Task Les_initiales_abregees_sont_reunies_sous_le_nom_le_plus_complet()
{
AjouterHerite("P.F. Hamilton", "Pandora's Star");
AjouterHerite("Peter F. Hamilton", "Judas Unchained");
await ExecuterAsync();
var hamilton = Assert.Single(_db.Auteurs);
Assert.Equal("Peter F. Hamilton", hamilton.Nom);
Assert.Equal(2, await _db.LivreAuteurs.CountAsync());
}
/// <summary>Le cas ambigu reste ambigu : il sera proposé, pas appliqué.</summary>
[Fact]
public async Task Un_patronyme_seul_nest_jamais_absorbe()
{
AjouterHerite("Hamilton", "Great North Road");
AjouterHerite("Peter F. Hamilton", "Pandora's Star");
await ExecuterAsync();
Assert.Equal(2, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Un_livre_cosigne_par_deux_variantes_ne_garde_quun_lien()
{
var livre = new Livre { Titre = "Œuvre commune", TitreNormalise = "œuvre commune" };
_db.Livres.Add(livre);
_db.SaveChanges();
foreach (var nom in new[] { "Emile Zola", "Émile Zola" })
{
var auteur = new Auteur
{
Nom = nom,
NomNormalise = nom.ToLowerInvariant(),
CleRegroupement = nom.ToLowerInvariant(),
};
_db.Auteurs.Add(auteur);
_db.LivreAuteurs.Add(new LivreAuteur { LivreId = livre.Id, Auteur = auteur });
}
_db.SaveChanges();
await ExecuterAsync();
Assert.Single(_db.Auteurs);
Assert.Equal(1, await _db.LivreAuteurs.CountAsync());
}
[Fact]
public async Task Un_second_passage_ne_change_plus_rien()
{
AjouterHerite("Emile Zola", "Germinal");
AjouterHerite("Émile Zola", "Nana");
await ExecuterAsync();
var apresPremier = _db.Auteurs.Single();
var nom = apresPremier.Nom;
var cle = apresPremier.CleRegroupement;
await ExecuterAsync();
var apresSecond = Assert.Single(_db.Auteurs);
Assert.Equal(nom, apresSecond.Nom);
Assert.Equal(cle, apresSecond.CleRegroupement);
Assert.Equal(2, await _db.LivreAuteurs.CountAsync());
}
[Fact]
public async Task Une_base_vide_ne_pose_aucun_probleme()
{
await ExecuterAsync();
Assert.Empty(_db.Auteurs);
Assert.Empty(_db.Livres);
}
// ── Rattrapage des tables plus récentes ─────────────────────────────────
//
// Envies, séries, revues et numéros calculent leurs formes À L'ÉCRITURE : sans ce passage,
// un changement de règle de normalisation — comme le développement des ligatures le
// 2026-08-19 — laisserait ces lignes sur leur ancienne clé, donc introuvables par la
// nouvelle. Trois de ces colonnes portent en plus une UNICITÉ, et une collision se paierait
// au démarrage du serveur.
[Fact]
public async Task Une_envie_ecrite_avant_le_changement_de_regle_est_rattrapee()
{
// Forme telle qu'elle aurait été calculée avant le développement des ligatures.
_db.LivresSouhaites.Add(new LivreSouhaite
{
Utilisateur = "mathieu",
Titre = "L'Œuvre",
TitreNormalise = "l œuvre",
Auteur = "Émile Zola",
AuteurNormalise = "emile zola",
});
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("l oeuvre", _db.LivresSouhaites.Single().TitreNormalise);
}
[Fact]
public async Task Une_envie_de_revue_ecrite_avant_le_changement_de_regle_est_rattrapee()
{
// Formes telles qu'elles auraient été calculées avant le développement des ligatures,
// et ISSN saisi sans son tiret — deux rattrapages, deux mécanismes distincts.
_db.RevuesSouhaitees.Add(new RevueSouhaitee
{
Utilisateur = "mathieu",
Titre = "Cœur",
TitreNormalise = "cœur",
Numero = "Hors-Série 7",
NumeroNormalise = "hors-série 7",
Issn = "24666718",
});
await _db.SaveChangesAsync();
await ExecuterAsync();
var envie = _db.RevuesSouhaitees.Single();
Assert.Equal("coeur", envie.TitreNormalise);
Assert.Equal("hors serie 7", envie.NumeroNormalise);
// ⚠️ L'ISSN ne suit pas la clé : sur une ligne au titre inchangé, il ne serait jamais
// canonisé si ce rattrapage n'existait pas à part.
Assert.Equal("2466-6718", envie.Issn);
}
/// <summary>
/// ⚠️ Une collision d'unicité au démarrage empêcherait le serveur de se lancer. La règle est
/// la même que partout ailleurs : la ligne la plus ancienne prend la nouvelle forme, les
/// suivantes gardent l'ancienne. Mal normalisée vaut infiniment mieux que supprimée.
/// </summary>
[Fact]
public async Task Deux_envies_de_revue_que_la_nouvelle_regle_confond_survivent_toutes_les_deux()
{
_db.RevuesSouhaitees.AddRange(
new RevueSouhaitee
{
Utilisateur = "mathieu",
Titre = "Cœur",
TitreNormalise = "cœur",
},
new RevueSouhaitee
{
Utilisateur = "mathieu",
Titre = "Coeur",
TitreNormalise = "coeur",
});
await _db.SaveChangesAsync();
await ExecuterAsync();
var formes = _db.RevuesSouhaitees.OrderBy(e => e.Id).Select(e => e.TitreNormalise).ToList();
// La seconde garde « coeur » (elle l'avait déjà), la première ne peut pas la prendre.
Assert.Equal(2, formes.Count);
Assert.Equal("cœur", formes[0]);
Assert.Equal("coeur", formes[1]);
}
[Fact]
public async Task Une_serie_et_une_revue_sont_rattrapees_aussi()
{
_db.Series.Add(new Serie { Titre = "Les Sœurs", TitreNormalise = "les sœurs" });
_db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("les soeurs", _db.Series.Single().TitreNormalise);
Assert.Equal("coeur", _db.Revues.Single().TitreNormalise);
}
/// <summary>
/// ⚠️ Le cas qui empêcherait le serveur de démarrer.
/// </summary>
/// <remarks>
/// La nouvelle règle rend identiques deux séries qui ne l'étaient pas. L'index unique
/// refuserait l'écriture, et l'exception surviendrait <b>au démarrage</b> : l'application ne
/// se lancerait plus du tout. La plus ancienne prend la nouvelle forme, la suivante garde
/// l'ancienne — mal normalisée vaut infiniment mieux que supprimée, ou qu'un serveur mort.
/// </remarks>
[Fact]
public async Task Une_collision_dunicite_ne_fait_pas_echouer_le_demarrage()
{
_db.Series.Add(new Serie { Titre = "L'Œuvre", TitreNormalise = "l œuvre" });
_db.Series.Add(new Serie { Titre = "L'oeuvre", TitreNormalise = "l oeuvre" });
await _db.SaveChangesAsync();
await ExecuterAsync();
var series = _db.Series.OrderBy(s => s.Id).ToList();
Assert.Equal(2, series.Count);
Assert.Equal(["l oeuvre", "l œuvre"], series.Select(s => s.TitreNormalise).Order());
}
/// <summary>
/// Une ligne déjà correcte ne doit pas se bloquer elle-même.
/// </summary>
/// <remarks>
/// Le jeu des clés occupées part de l'état actuel de la base : sans la libération de sa
/// propre clé, toute ligne dont la forme change verrait sa nouvelle valeur « déjà prise »
/// dès qu'elle est égale à son ancienne — et rien ne serait jamais corrigé.
/// </remarks>
[Fact]
public async Task Le_rattrapage_reste_idempotent()
{
_db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" });
await _db.SaveChangesAsync();
await ExecuterAsync();
await ExecuterAsync();
Assert.Equal("coeur", _db.Revues.Single().TitreNormalise);
}
/// <summary>
/// Les ISSN saisis avant le lot K2 prennent leur tiret au démarrage suivant, ce qui les rend
/// enfin rapprochables d'un code-barres et de <c>bib.issn</c>.
/// </summary>
[Fact]
public async Task Les_issn_existants_prennent_leur_tiret()
{
_db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("2466-6718", _db.Revues.Single().Issn);
}
/// <summary>
/// ⚠️ L'unicité de l'ISSN est un index <b>partiel</b> en base : deux fiches que la
/// canonisation rendrait identiques feraient échouer l'écriture — <i>au démarrage</i>, donc
/// avant que l'application ne réponde. La seconde garde donc sa forme, et rien ne meurt.
/// </summary>
[Fact]
public async Task Deux_issn_qui_se_rejoignent_ne_tuent_pas_le_demarrage()
{
_db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "2466-6718" });
_db.Revues.Add(new Revue { Titre = "Doublon", TitreNormalise = "doublon", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
var issn = _db.Revues.OrderBy(r => r.Id).Select(r => r.Issn).ToList();
// Les deux lignes survivent : mal normalisée vaut infiniment mieux que supprimée.
Assert.Equal(["2466-6718", "24666718"], issn);
}
/// <summary>
/// Une revue <b>sans</b> ISSN n'entre pas dans le jeu des clés : sans quoi la deuxième
/// bloquerait la première, tous les <c>NULL</c> se confondant en une seule valeur.
/// </summary>
[Fact]
public async Task Les_revues_sans_issn_ne_se_bloquent_pas_entre_elles()
{
_db.Revues.Add(new Revue { Titre = "Une", TitreNormalise = "une" });
_db.Revues.Add(new Revue { Titre = "Deux", TitreNormalise = "deux" });
_db.Revues.Add(new Revue { Titre = "Trois", TitreNormalise = "trois", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("2466-6718", _db.Revues.Single(r => r.Titre == "Trois").Issn);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}