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>
224 lines
8.1 KiB
C#
224 lines
8.1 KiB
C#
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Entites;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Filtrage et recherche du catalogue. Purement en mémoire : ni base, ni réseau.
|
|
/// </summary>
|
|
public class FiltreLivresTests
|
|
{
|
|
private static Auteur Auteur(int id, string nom)
|
|
{
|
|
var auteur = new Auteur { Id = id, Nom = nom };
|
|
auteur.RecalculerFormes();
|
|
return auteur;
|
|
}
|
|
|
|
private static readonly Auteur Zola = Auteur(1, "Émile Zola");
|
|
private static readonly Auteur Maupassant = Auteur(2, "Guy de Maupassant");
|
|
|
|
private static Livre Livre(
|
|
int id, string titre, Auteur? auteur, Format format, Statut? statut, string? ajoutePar,
|
|
string? lecteur = "mathieu")
|
|
{
|
|
var livre = new Livre
|
|
{
|
|
Id = id,
|
|
Titre = titre,
|
|
Format = format,
|
|
AjoutePar = ajoutePar,
|
|
};
|
|
|
|
livre.RecalculerFormes();
|
|
|
|
if (auteur is not null)
|
|
{
|
|
livre.Auteurs.Add(new LivreAuteur { LivreId = id, AuteurId = auteur.Id, Auteur = auteur });
|
|
}
|
|
|
|
if (statut is { } valeur && lecteur is not null)
|
|
{
|
|
livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = lecteur, Statut = valeur });
|
|
}
|
|
|
|
return livre;
|
|
}
|
|
|
|
// « mathieu » a un statut sur les quatre livres ; « camille » n'en a aucun.
|
|
private static readonly Livre[] Catalogue =
|
|
[
|
|
Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, "mathieu"),
|
|
Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, "camille"),
|
|
Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, "camille"),
|
|
Livre(4, "Bel-Ami", null, Format.Numerique, Statut.Lu, null),
|
|
];
|
|
|
|
private static List<Livre> Filtrer(CritereLivres criteres, string? utilisateur = "mathieu") =>
|
|
FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres, utilisateur).ToList();
|
|
|
|
[Fact]
|
|
public void Sans_critere_tout_le_catalogue_remonte()
|
|
=> Assert.Equal(4, Filtrer(new CritereLivres()).Count);
|
|
|
|
[Fact]
|
|
public void Le_tri_est_alphabetique_sur_le_titre()
|
|
=> Assert.Equal(
|
|
["Bel-Ami", "Germinal", "La Bête humaine", "Le Horla"],
|
|
Filtrer(new CritereLivres()).Select(l => l.Titre));
|
|
|
|
[Theory]
|
|
[InlineData(Format.Physique, 2)]
|
|
[InlineData(Format.Numerique, 2)]
|
|
public void Le_filtre_format_ne_garde_que_ce_format(Format format, int attendu)
|
|
{
|
|
var resultat = Filtrer(new CritereLivres { Format = format });
|
|
|
|
Assert.Equal(attendu, resultat.Count);
|
|
Assert.All(resultat, l => Assert.Equal(format, l.Format));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Statut.ALire, 1)]
|
|
[InlineData(Statut.EnCours, 1)]
|
|
[InlineData(Statut.Lu, 2)]
|
|
public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu)
|
|
=> Assert.Equal(attendu, Filtrer(new CritereLivres { Statut = statut }).Count);
|
|
|
|
/// <summary>
|
|
/// <b>Le statut est personnel.</b> Le même filtre, la même bibliothèque, une autre personne :
|
|
/// rien ne remonte, parce que « camille » n'a posé aucun statut.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Le_filtre_statut_ne_voit_que_le_statut_de_lappelant()
|
|
{
|
|
Assert.Equal(2, Filtrer(new CritereLivres { Statut = Statut.Lu }, "mathieu").Count);
|
|
Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, "camille"));
|
|
|
|
// Sans filtre, en revanche, camille voit toute la bibliothèque : elle est commune.
|
|
Assert.Equal(4, Filtrer(new CritereLivres(), "camille").Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sans_identite_aucun_statut_ne_correspond()
|
|
=> Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, null));
|
|
|
|
[Fact]
|
|
public void La_recherche_porte_sur_le_titre()
|
|
=> Assert.Equal(["Germinal"], Filtrer(new CritereLivres { Recherche = "germ" }).Select(l => l.Titre));
|
|
|
|
[Fact]
|
|
public void La_recherche_porte_aussi_sur_lauteur()
|
|
=> Assert.Equal(
|
|
["Germinal", "La Bête humaine"],
|
|
Filtrer(new CritereLivres { Recherche = "zola" }).Select(l => l.Titre));
|
|
|
|
/// <summary>
|
|
/// Le besoin exprimé dans IDEES.md : « Emile » doit trouver « Émile ». Il n'y a pas de
|
|
/// collation SQLite pour ça, seule la colonne normalisée le permet.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("emile")]
|
|
[InlineData("Émile")]
|
|
[InlineData("EMILE ZOLA")]
|
|
[InlineData("zola, émile")]
|
|
public void La_recherche_ignore_les_accents_sur_lauteur(string terme)
|
|
=> Assert.Equal(
|
|
["Germinal", "La Bête humaine"],
|
|
Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre));
|
|
|
|
[Theory]
|
|
[InlineData("bete")]
|
|
[InlineData("BÊTE")]
|
|
public void La_recherche_ignore_les_accents_sur_le_titre(string terme)
|
|
=> Assert.Equal(["La Bête humaine"], Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre));
|
|
|
|
[Theory]
|
|
[InlineData("HORLA")]
|
|
[InlineData("horla")]
|
|
[InlineData(" Horla ")]
|
|
public void La_recherche_ignore_la_casse_et_les_espaces_autour(string terme)
|
|
=> Assert.Single(Filtrer(new CritereLivres { Recherche = terme }));
|
|
|
|
[Fact]
|
|
public void Un_livre_sans_auteur_ne_fait_pas_echouer_la_recherche()
|
|
{
|
|
// Le livre 4 n'a aucun auteur : la clause sur les auteurs doit le traverser sans exploser.
|
|
var resultat = Filtrer(new CritereLivres { Recherche = "bel" });
|
|
|
|
Assert.Equal(["Bel-Ami"], resultat.Select(l => l.Titre));
|
|
}
|
|
|
|
[Fact]
|
|
public void Le_filtre_par_auteur_regroupe_ses_livres()
|
|
=> Assert.Equal(
|
|
["Germinal", "La Bête humaine"],
|
|
Filtrer(new CritereLivres { AuteurId = Zola.Id }).Select(l => l.Titre));
|
|
|
|
[Fact]
|
|
public void Les_criteres_se_combinent()
|
|
{
|
|
var resultat = Filtrer(new CritereLivres
|
|
{
|
|
Recherche = "zola",
|
|
Format = Format.Physique,
|
|
Statut = Statut.Lu,
|
|
});
|
|
|
|
Assert.Equal(["Germinal"], resultat.Select(l => l.Titre));
|
|
}
|
|
|
|
[Fact]
|
|
public void Une_recherche_vide_equivaut_a_aucune_recherche()
|
|
{
|
|
Assert.Equal(4, Filtrer(new CritereLivres { Recherche = " " }).Count);
|
|
Assert.Equal(4, Filtrer(new CritereLivres { Recherche = "" }).Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Invariant le plus important du projet.</b> La bibliothèque est commune au foyer :
|
|
/// une lecture ne doit JAMAIS être restreinte à ce qu'a saisi l'utilisateur courant.
|
|
/// Ce test échouerait au moindre filtre ajouté sur <c>AjoutePar</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void La_lecture_ne_filtre_jamais_sur_AjoutePar()
|
|
{
|
|
var resultat = Filtrer(new CritereLivres());
|
|
|
|
// Les livres des trois provenances (deux comptes + un sans trace) sont tous là.
|
|
Assert.Contains(resultat, l => l.AjoutePar == "mathieu");
|
|
Assert.Contains(resultat, l => l.AjoutePar == "camille");
|
|
Assert.Contains(resultat, l => l.AjoutePar is null);
|
|
Assert.Equal(Catalogue.Length, resultat.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Second verrou sur le même invariant, par le type : aucun critère de consultation ne doit
|
|
/// permettre d'exprimer un cloisonnement par utilisateur. Si quelqu'un ajoute une propriété
|
|
/// « AjoutePar » à <see cref="CritereLivres"/>, ce test le lui rappelle.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Les_criteres_nexposent_aucun_champ_dutilisateur()
|
|
{
|
|
var proprietes = typeof(CritereLivres)
|
|
.GetProperties()
|
|
.Select(p => p.Name)
|
|
.ToArray();
|
|
|
|
Assert.DoesNotContain(proprietes, nom => nom.Contains("Ajoute", StringComparison.OrdinalIgnoreCase));
|
|
Assert.DoesNotContain(proprietes, nom => nom.Contains("Utilisateur", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Troisième verrou, sur la décision inverse : <see cref="Livre"/> ne doit plus jamais porter
|
|
/// de colonne <c>Statut</c>, qui redeviendrait commune à tout le foyer.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Livre_ne_porte_aucun_statut_commun()
|
|
{
|
|
var statut = typeof(Livre).GetProperty("Statut");
|
|
|
|
Assert.Null(statut);
|
|
}
|
|
}
|