using MaBibli.Client.Services; using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; namespace MaBibli.Tests; /// /// Recherche hors-ligne : le filtre appliqué dans le navigateur à l'instantané IndexedDB. /// /// /// L'essentiel de ces tests confronte à /// , celui qui s'exécute côté base. Ce sont deux implémentations /// distinctes — l'une sur des entités aux colonnes normalisées, l'autre sur des DTO qui n'en ont /// pas — et rien n'empêcherait leurs comportements de diverger en silence. Une bibliothèque qui /// ne se cherche pas de la même façon selon qu'on a du réseau ou non serait pire qu'un cache /// absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque. /// public class FiltreLivresLocalTests { private const string Lecteur = "mathieu"; 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, TypeDocument type = TypeDocument.NonPrecise, string? preteA = null, bool pretClos = false, string? couverture = null, string? isbn = null) { var livre = new Livre { Id = id, Titre = titre, Format = format, TypeDocument = type, AjoutePar = Lecteur, CoverUrl = couverture, Isbn = isbn, }; livre.RecalculerFormes(); if (auteur is not null) { livre.Auteurs.Add(new LivreAuteur { LivreId = id, AuteurId = auteur.Id, Auteur = auteur }); } if (statut is { } valeur) { livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = Lecteur, Statut = valeur }); } if (preteA is not null) { // ⚠️ Un prêt CLOS ne doit pas compter comme « dehors ». C'est le point où les deux // implémentations pourraient diverger sans qu'on s'en aperçoive : le serveur regarde // DateRetour, le client un PreteA que le serveur a déjà résolu. livre.Prets.Add(new Pret { LivreId = id, Emprunteur = preteA, DatePret = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateRetour = pretClos ? new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc) : null, }); } return livre; } private static readonly Livre[] Entites = [ // ⚠️ Germinal est rangé sous son ISBN-10, comme tout ce qui a paru avant 2007 — alors // qu'un code-barres se lit toujours en 13. C'est le cas que la recherche par code doit // rattraper, sans quoi tout le fonds ancien serait introuvable à la douchette. Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman, couverture: "https://exemple.test/germinal.jpg", isbn: "2253004227"), Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee, isbn: "9782070612758"), Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman, preteA: "Marie", couverture: "https://exemple.test/horla.jpg"), Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null, preteA: "Paul", pretClos: true), // ⚠️ Couverture VIDE, pas absente. C'est le cas où les deux implémentations pourraient // diverger sans qu'on le voie : le serveur écrit `IsNullOrEmpty` en SQL, le client en C#. Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire, couverture: ""), Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), ]; /// /// Ce que l'API renvoie, et donc ce qui est rangé dans IndexedDB : le statut y est déjà /// résolu pour l'utilisateur courant, le client n'a plus personne à désigner. /// private static readonly LivreDto[] Instantane = Entites .Select(l => new LivreDto { Id = l.Id, Titre = l.Titre, Format = l.Format, TypeDocument = l.TypeDocument, DateAjout = DateTime.UtcNow, Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut, // Ce que l'API expose : l'emprunteur du prêt EN COURS, rien de l'historique clos. PreteA = l.Prets.FirstOrDefault(p => p.DateRetour is null)?.Emprunteur, CoverUrl = l.CoverUrl, Isbn = l.Isbn, Auteurs = l.Auteurs .Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom }) .ToList(), }) .ToArray(); public static TheoryData Criteres => [ new CritereLivres(), new CritereLivres { Recherche = "germinal" }, new CritereLivres { Recherche = "GERMINAL" }, new CritereLivres { Recherche = "emile" }, // sans accent → doit trouver « Émile » new CritereLivres { Recherche = "Émile" }, new CritereLivres { Recherche = "zola emile" }, // ordre inversé → clé de regroupement new CritereLivres { Recherche = "bete" }, new CritereLivres { Recherche = "oeuvres" }, new CritereLivres { Recherche = "introuvable" }, // Recherche par CODE : elle exclut la recherche par mots, et confronte les deux formes // d'ISBN. Un fonds ancien est rangé en 10, une douchette lit toujours en 13. new CritereLivres { Recherche = "9782253004226" }, // 13 scanné → fiche rangée en 10 new CritereLivres { Recherche = "2253004227" }, // 10 tapé tel quel new CritereLivres { Recherche = "978-2-07-061275-8" }, // tirets de l'affichage new CritereLivres { Recherche = "9782070612758" }, new CritereLivres { Recherche = "97820706" }, // fragment : préfixe d'un ISBN new CritereLivres { Recherche = "9999999999999" }, // aucun livre new CritereLivres { Recherche = "9782070612758", Format = Format.Physique }, new CritereLivres { SansCouverture = true }, new CritereLivres { SansCouverture = false }, // Croisements : le critère doit se composer avec les autres, pas les remplacer. new CritereLivres { SansCouverture = true, Format = Format.Physique }, new CritereLivres { SansCouverture = true, Recherche = "zola" }, new CritereLivres { SansCouverture = false, Statut = Statut.Lu }, new CritereLivres { SansCouverture = true, Prete = false }, new CritereLivres { Format = Format.Physique }, new CritereLivres { Format = Format.Numerique }, new CritereLivres { TypeDocument = TypeDocument.Roman }, new CritereLivres { TypeDocument = TypeDocument.BandeDessinee }, // « Non précisé » est une valeur filtrable, pas l'absence de filtre. new CritereLivres { TypeDocument = TypeDocument.NonPrecise }, new CritereLivres { Statut = Statut.Lu }, new CritereLivres { Statut = Statut.ALire }, new CritereLivres { AuteurId = 1 }, new CritereLivres { AuteurId = 2 }, new CritereLivres { AuteurId = 99 }, new CritereLivres { Prete = true }, new CritereLivres { Prete = false }, new CritereLivres { Prete = false, Format = Format.Physique }, new CritereLivres { Prete = true, Recherche = "horla" }, new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire }, new CritereLivres { Recherche = "zola", TypeDocument = TypeDocument.BandeDessinee, Format = Format.Numerique, }, ]; [Theory] [MemberData(nameof(Criteres))] public void Le_filtre_hors_ligne_rend_exactement_ce_que_rend_le_serveur(CritereLivres criteres) { var serveur = FiltreLivres.Appliquer(Entites.AsQueryable(), criteres, Lecteur) .Select(l => l.Id) .ToList(); var local = FiltreLivresLocal.Appliquer(Instantane, criteres) .Select(l => l.Id) .ToList(); // Séquences comparées, pas ensembles : le tri fait partie du contrat. Assert.Equal(serveur, local); } [Fact] public void Le_catalogue_entier_est_cherchable_pas_seulement_ce_qui_etait_affiche() { // Le cœur de la décision « IndexedDB plutôt que cache du service worker » : une recherche // jamais tapée en ligne doit rendre un résultat hors-ligne. var resultat = FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "horla" }); Assert.Equal(3, Assert.Single(resultat).Id); } [Fact] public void La_recherche_ignore_les_accents_dans_les_deux_sens() { Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "bete humaine" })); Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "Bête humaine" })); Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "education" })); } [Fact] public void Le_tri_est_alphabetique_et_insensible_aux_accents() => Assert.Equal( ["Bel-Ami", "Germinal", "L'Éducation sentimentale", "La Bête humaine", "Le Horla", "Œuvres complètes"], FiltreLivresLocal.Appliquer(Instantane, new CritereLivres()).Select(l => l.Titre)); [Fact] public void Un_livre_sans_statut_ne_remonte_sous_aucun_statut_mais_reste_visible() { Assert.DoesNotContain( FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Statut = Statut.Lu }), l => l.Id == 4); Assert.Contains( FiltreLivresLocal.Appliquer(Instantane, new CritereLivres()), l => l.Id == 4); } [Fact] public void Un_code_barres_retrouve_un_livre_range_sous_son_isbn_10() { // Le geste visé : le livre est en main, la douchette tape son EAN-13 dans la recherche. var resultat = FiltreLivresLocal.Appliquer( Instantane, new CritereLivres { Recherche = "9782253004226" }); Assert.Equal(1, Assert.Single(resultat).Id); } [Fact] public void Un_titre_numerique_reste_une_recherche_de_mots() { // ⚠️ Sans le seuil de huit chiffres, « 1984 » remonterait tous les livres dont l'ISBN // contient ces quatre chiffres — ici Le Petit Prince, dont l'ISBN se termine par 1275 8. Assert.Empty(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "0612" })); Assert.Empty(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "2070612" })); } [Fact] public void Un_instantane_vide_ne_fait_pas_echouer_la_recherche() => Assert.Empty(FiltreLivresLocal.Appliquer([], new CritereLivres { Recherche = "zola" })); }