Files
mabibli/MaBibli.Tests/FiltreLivresLocalTests.cs
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
2026-09-09 00:05:26 +02:00

243 lines
11 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, 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),
];
/// <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,
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<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" },
// 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" }));
}