L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi. C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue », et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux, gardent la valeur tapée : découper à la frappe se battrait avec le curseur. `ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le mécanisme générique confondant tous les NULL en une seule clé, alors que l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà prise garde la sienne : le serveur doit démarrer. La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image présente en ligne et absente hors-ligne. Les articles à la une vivent dans une table à part et non dans `Theme` : un thème est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution. Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro. Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait coupé en deux par la virgule. Un PUT sur le numéro était indispensable : on note un numéro le jour où on le range et on en recopie le sommaire plus tard. Sans lui, couverture et une n'auraient existé qu'à la création, c'est-à-dire jamais. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
12 KiB
C#
331 lines
12 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_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);
|
|
}
|
|
}
|