Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le navigateur alors que SQLite vit sur le serveur, et le service worker ne met en cache que les assets. Sans travail explicite, l'application demarre hors-ligne et affiche une bibliotheque vide. - js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des bascules online/offline. Aucune logique metier. - CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau combinant navigator.onLine (fiable seulement par sa negation) avec le sort reel des appels HTTP. - ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est ce qui rend la recherche hors-ligne possible sur tout le fonds. - FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui confronte les deux implementations sur les memes donnees. - Interface : pastille et bandeau d'etat avec la date de synchronisation, et actions d'ecriture desactivees avec leur raison plutot que boutons morts. - js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker est le seul cache-busting du projet. L'enregistrement journalise desormais ses echecs, et un bandeau propose la nouvelle version sans attendre la fermeture de tous les onglets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.3 KiB
C#
156 lines
6.3 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)
|
|
{
|
|
var livre = new Livre { Id = id, Titre = titre, Format = format, 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),
|
|
Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire),
|
|
Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours),
|
|
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,
|
|
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 { 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 },
|
|
];
|
|
|
|
[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" }));
|
|
}
|