Files
mabibli/MaBibli.Tests/FiltreLivresLocalTests.cs
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

193 lines
8.1 KiB
C#

using MaBibli.Client.Services;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Tests;
/// <summary>
/// Recherche hors-ligne : le filtre appliqué dans le navigateur à l'instantané IndexedDB.
/// </summary>
/// <remarks>
/// L'essentiel de ces tests confronte <see cref="FiltreLivresLocal"/> à
/// <see cref="FiltreLivres"/>, 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.
/// </remarks>
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),
];
/// <summary>
/// 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.
/// </summary>
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<CritereLivres> 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" }));
}