Files
mabibli/MaBibli.Tests/FiltreLivresLocalTests.cs
T
mathieuandClaude Opus 5 49eb8e1c98 Distinguer romans et BD par un type de document
Format disait physique ou numérique, jamais roman ou BD. Trois valeurs
seulement : une valeur s'ajoute sans migration, la renommer une fois posée
sur des centaines de fiches, non.

Le défaut est « non précisé », pas « roman » : les fiches existantes n'ont
pas été saisies avec cette question en tête, et un AddColumn suffit
justement parce que la valeur par défaut ne prétend rien. L'interface
n'affiche donc que ce qui a été choisi, comme pour le format.

Filtre ajouté aux DEUX implémentations, serveur et hors-ligne, avec quatre
jeux de critères de plus dans le test qui les confronte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:45:37 +02:00

170 lines
7.0 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)
{
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 });
}
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),
Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null),
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,
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 { 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" }));
}