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) { var livre = new Livre { Id = id, Titre = titre, Format = format, TypeDocument = type, AjoutePar = Lecteur, }; 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 = [ Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman), Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee), Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman, preteA: "Marie"), Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null, preteA: "Paul", pretClos: true), Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), 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, 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" }, 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_instantane_vide_ne_fait_pas_echouer_la_recherche() => Assert.Empty(FiltreLivresLocal.Appliquer([], new CritereLivres { Recherche = "zola" })); }