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>
193 lines
8.1 KiB
C#
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" }));
|
|
}
|