Files
mabibli/MaBibli.Tests/FiltreLivresTests.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

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 CLAUDE.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);
}
}