From 7d2f3dc5d90fdd983b80e27f591ccaf34eda8672 Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 22 Aug 2026 00:16:52 +0200 Subject: [PATCH] =?UTF-8?q?Rassemble=20au=20catalogue=20les=20tomes=20qui?= =?UTF-8?q?=20racontent=20la=20m=C3=AAme=20histoire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les livres d'une même série se suivaient au hasard de l'alphabet, et rien ne disait qu'ils allaient ensemble. Ils tiennent maintenant sous le nom de leur série, à la place qu'occupait le premier d'entre eux — l'ordre général ne bouge donc pas — et ce nom mène à la série, seul écran qui montre aussi les tomes qui manquent. Le regroupement ne cache ni ne duplique rien : une bascule le défait, et un livre rattaché à deux séries ne paraît qu'une fois, toujours sous la même. La règle est une fonction pure, avec ses tests. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 57 ++++++++ MaBibli.Client/Pages/Catalogue.razor | 98 ++++++++++++- .../Services/GroupementCatalogue.cs | 123 ++++++++++++++++ MaBibli.Client/wwwroot/css/app.css | 49 +++++++ MaBibli.Tests/GroupementCatalogueTests.cs | 136 ++++++++++++++++++ 5 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 MaBibli.Client/Services/GroupementCatalogue.cs create mode 100644 MaBibli.Tests/GroupementCatalogueTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index b12cda6..996c4fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3270,6 +3270,63 @@ corrigeant « Christophe » en « Christophé », livre enregistré et relu par rôles (`Scénario`, `Dessin`) et ses deux thèmes, fiche rouverte en édition avec ses listes garnies, aucun débordement horizontal. +## Le catalogue rassemble les tomes d'une série (2026-08-22) + +Demandé en usage : « grouper les livres/BD d'une même série ensemble avec le nom de la série ; +si on clique dessus, ça envoie à la série ». + +### Deux décisions prises avec l'utilisateur + +| Question | Décision | Pourquoi | +|---|---|---| +| Où se place le bloc ? | **À la place de son premier tome** dans l'ordre du catalogue | L'ordre général ne bouge pas : on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». Grouper en tête ou en fin aurait défait l'alphabet là où l'on cherche un livre à sa lettre | +| Peut-on s'en passer ? | **Bascule « Grouper les livres d'une même série »**, active par défaut | Décochée, on retrouve exactement le catalogue d'avant. Elle **défait le regroupement**, elle ne masque aucun livre — c'est la lecture retenue sur les deux possibles | + +⚠️ **La bascule n'entre pas dans le compteur du bouton « Filtrer ».** Ce compteur existe parce +qu'un filtre replié rendrait des livres invisibles sans qu'on sache pourquoi ; grouper ne cache +rien, et se voit dans la liste elle-même. Compter un réglage inoffensif diluerait l'avertissement +que le compteur porte. + +### Ce que le regroupement ne fait pas + +- **Il ne masque ni ne déplace hors de la liste.** Un livre écarté par un filtre reste écarté, un + livre visible reste visible, et le compteur en tête continue de dire des **livres** (« 6 + livres »), pas des blocs. Un bloc ne montre donc que les tomes que la liste contient — sur une + recherche, « 1 tome ». +- **Il ne va pas chercher les tomes manquants** : le catalogue ne connaît que ce qu'on possède. + C'est précisément pourquoi le nom du bloc **mène à la série**, seul écran qui montre les trous. +- ⚠️ **Il ne duplique jamais un livre.** Le modèle autorise un livre dans plusieurs séries (pas + d'unicité sur `LivreId` seul) ; deux cartes du même exemplaire feraient mentir le compteur. La + série retenue est la **première par ordre alphabétique** — critère explicable et surtout + **stable**, là où « celle que l'API a rendue en premier » changerait le nom du bloc d'un + chargement à l'autre. La fiche livre, elle, montre toutes ses séries. +- **Un seul tome possédé forme quand même un bloc** : c'est ce qui dit à quoi ce livre appartient. + +### Les séries ne sont lues qu'une fois par visite + +⚠️ Le catalogue se recharge **à chaque frappe** dans la recherche. Y joindre une lecture des +séries ferait une requête par caractère pour une liste qui ne bouge pas pendant qu'on tape. Elles +sont donc lues à l'arrivée sur l'écran et à chaque bascule du réseau. Prix assumé : un tome +rattaché depuis un autre écran n'apparaît groupé qu'au prochain passage au catalogue. + +**Une lecture ratée ne coûte rien** : sans série, la liste s'affiche à plat — l'écran d'avant. +Hors-ligne, elle retombe d'elle-même sur l'instantané `series`, déjà tenu à jour. + +### Le regroupement est une fonction pure, donc éprouvable + +`Services/GroupementCatalogue.cs` ne dépend d'aucun composant : six tests portent sur ce qui a +été **décidé** — la place du bloc, l'ordre de lecture à l'intérieur, le livre à deux séries qui +n'apparaît qu'une fois et toujours sous la même, la liste plate sans séries, le bloc à un seul +tome, et le filtre qui ne se laisse pas défaire. C'est le même parti que `FiltreLivresLocal` : +ce qui tient une règle sort du balisage. + +**Vérifié en exécution** (320 px puis 1280 px, base de développement garnie de trois tomes et +d'un livre hors série) : blocs « L'Elfe noir » (2 tomes, dans l'ordre de **lecture** — l'inverse +de l'alphabet) et « La Trilogie des Elfes noirs » (1 tome) posés à la place de leur premier tome, +compteur toujours « 6 livres », bascule qui défait et refait le groupement sans qu'aucune carte +n'apparaisse ni ne disparaisse (6 dans les deux cas), recherche « terre natale » → « 1 livre » et +un bloc à un tome, clic sur le nom → `/series/8`. Aucun débordement horizontal. 623 tests au vert. + ## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21) La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index a0157d2..1e196ea 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -110,6 +110,22 @@ } + @* + ⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur + du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle + se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série. + + Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat. + *@ + @if (_series is { Count: > 0 }) + { + + } +

Le statut de lecture est le vôtre : filtrer dessus montre votre lecture, pas celle du foyer. Le prêt, lui, est commun. @@ -136,12 +152,41 @@ else if (_livres is not null) {

@_livres.Count livre@(_livres.Count > 1 ? "s" : "")

+ @* + Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre + eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on + cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on + ne possède pas — ce que le catalogue, lui, ne peut pas montrer. + *@ } @@ -175,6 +220,25 @@ else if (_livres is not null) public int? AuteurId { get; set; } private IReadOnlyList? _livres; + + /// + /// Les séries du foyer, pour regrouper leurs tomes. null = pas encore lues. + /// + /// + /// ⚠️ Elles ne sont lues qu'une fois par visite (et à chaque bascule du réseau), pas à + /// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et + /// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas + /// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît + /// groupé qu'au prochain passage ici. + /// + private IReadOnlyList? _series; + + /// Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors. + private bool _grouper = true; + + /// Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés. + private IReadOnlyList Entrees => + GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null); private AuteurDto? _auteur; private int? _auteurCharge; private string _recherche = string.Empty; @@ -238,14 +302,40 @@ else if (_livres is not null) /// protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + /// + /// Lit les séries, et se contente de ne pas grouper si elles manquent. + /// + /// + /// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à + /// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même + /// sur l'instantané « series ». + /// + private async Task ChargerSeriesAsync() + { + try + { + _series = await Api.ListerSeriesAsync(); + } + catch (Exception) + { + _series = null; + } + } + private void SurChangementReseau() => _ = InvokeAsync(async () => { + await ChargerSeriesAsync(); await ChargerAsync(); StateHasChanged(); }); protected override async Task OnParametersSetAsync() { + if (_series is null) + { + await ChargerSeriesAsync(); + } + if (_auteurCharge != AuteurId) { _auteurCharge = AuteurId; diff --git a/MaBibli.Client/Services/GroupementCatalogue.cs b/MaBibli.Client/Services/GroupementCatalogue.cs new file mode 100644 index 0000000..6ee205f --- /dev/null +++ b/MaBibli.Client/Services/GroupementCatalogue.cs @@ -0,0 +1,123 @@ +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Textes; + +namespace MaBibli.Client.Services; + +/// +/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom. +/// +public sealed record EntreeCatalogue +{ + /// La série qui coiffe le bloc, ou null pour un livre seul. + public SerieDto? Serie { get; init; } + + /// Les livres de l'entrée : un seul hors série, les tomes visibles sinon. + public required IReadOnlyList Livres { get; init; } + + public bool EstGroupe => Serie is not null; +} + +/// +/// Regroupe les livres du catalogue par série. +/// +/// +/// Le bloc se place là où son premier tome serait tombé dans l'ordre du catalogue +/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et +/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les +/// tomes suivent l'ordre de lecture, seul ordre qui ait un sens pour une saga — c'est +/// même la raison d'être de ElementSerie.Position. +/// +/// ⚠️ Rien n'est jamais masqué ni déplacé hors de la liste. Un livre filtré reste absent, +/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de +/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache +/// pas. +/// +/// +/// ⚠️ Un livre peut appartenir à plusieurs séries (le modèle l'autorise, sans unicité sur +/// LivreId seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le +/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la +/// première par ordre alphabétique — un critère explicable, à défaut d'être le bon dans +/// tous les cas ; sa fiche livre, elle, les montre toutes. +/// +/// +public static class GroupementCatalogue +{ + public static IReadOnlyList Grouper( + IReadOnlyList livres, IReadOnlyList? series) + { + if (series is null || series.Count == 0) + { + return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })]; + } + + var place = PlaceDesLivres(series); + + // Les tomes s'accumulent dans le brouillon du groupe, créé à la position de son PREMIER + // tome rencontré : c'est ce qui range le bloc là où l'ordre du catalogue l'attend. + var brouillons = new List<(SerieDto? Serie, List<(int Position, LivreDto Livre)> Livres)>(); + var groupes = new Dictionary>(); + + foreach (var livre in livres) + { + if (!place.TryGetValue(livre.Id, out var appartenance)) + { + brouillons.Add((null, [(0, livre)])); + continue; + } + + if (!groupes.TryGetValue(appartenance.Serie.Id, out var tomes)) + { + tomes = []; + groupes[appartenance.Serie.Id] = tomes; + brouillons.Add((appartenance.Serie, tomes)); + } + + tomes.Add((appartenance.Position, livre)); + } + + return + [ + .. brouillons.Select(b => new EntreeCatalogue + { + Serie = b.Serie, + Livres = + [ + .. b.Livres + .OrderBy(t => t.Position) + .ThenBy(t => t.Livre.Id) + .Select(t => t.Livre), + ], + }), + ]; + } + + /// + /// À quelle série — et à quelle place — appartient chaque livre rattaché. + /// + /// + /// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le + /// TryAdd garde donc la première : sans cet ordre, la série retenue pour un + /// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de + /// rien de compréhensible. + /// + private static Dictionary PlaceDesLivres( + IReadOnlyList series) + { + var place = new Dictionary(); + + foreach (var serie in series + .OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal) + .ThenBy(s => s.Id)) + { + foreach (var element in serie.Elements) + { + if (element.LivreId is { } livreId) + { + place.TryAdd(livreId, (serie, element.Position)); + } + } + } + + return place; + } +} diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 4df390e..65306f1 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -402,6 +402,55 @@ body { padding: 0; } +/* --- Livres d'une même série, rassemblés au catalogue (2026-08-22) --- + Le bloc se pose là où son premier tome serait tombé dans l'ordre du catalogue : il ne + déplace donc rien, il rassemble. Le trait à gauche est ce qui fait lire les cartes comme + « dedans » plutôt que comme la suite de la liste. */ + +.groupe-serie { + margin: 0.5rem 0; + border-left: 3px solid var(--mb-accent); + padding-left: 0.6rem; +} + +/* Le nom mène à la série : c'est le seul endroit du catalogue qui montre aussi ce qui manque. */ +.groupe-serie-titre { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.4rem; + padding: 0.35rem 0; + color: var(--mb-texte); + text-decoration: none; + font-weight: 600; +} + +.groupe-serie-titre:hover .groupe-serie-nom, +.groupe-serie-titre:focus-visible .groupe-serie-nom { + text-decoration: underline; +} + +.groupe-serie-nom { + min-width: 0; + overflow-wrap: anywhere; +} + +.groupe-serie-compte { + color: var(--mb-texte-doux); + font-size: 0.85rem; + font-weight: 400; +} + +/* Une case à cocher parmi des rangées de segments : elle doit se lire comme un réglage, pas + comme un filtre de plus. */ +.bascule { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.25rem 0; + font-size: 0.9rem; +} + .carte-livre, .carte-candidat { display: flex; diff --git a/MaBibli.Tests/GroupementCatalogueTests.cs b/MaBibli.Tests/GroupementCatalogueTests.cs new file mode 100644 index 0000000..f47efb5 --- /dev/null +++ b/MaBibli.Tests/GroupementCatalogueTests.cs @@ -0,0 +1,136 @@ +using MaBibli.Client.Services; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Tests; + +/// +/// Regroupement des tomes d'une série au catalogue (2026-08-22). +/// +/// +/// L'écran n'est pas éprouvable ici, mais la règle l'est : ces tests portent sur ce qui a été +/// décidé — la place du bloc, l'ordre des tomes à l'intérieur, et le fait que rien ne +/// disparaisse ni ne se duplique. +/// +public class GroupementCatalogueTests +{ + private static LivreDto Livre(int id, string titre) => + new() + { + Id = id, + Titre = titre, + Format = MaBibli.Shared.Entites.Format.Physique, + DateAjout = DateTime.UtcNow, + }; + + private static SerieDto Serie(int id, string titre, params (int Position, int LivreId)[] tomes) => + new() + { + Id = id, + Titre = titre, + Elements = + [ + .. tomes.Select(t => new ElementSerieDto + { + Id = t.LivreId, + Position = t.Position, + Titre = $"tome {t.Position}", + LivreId = t.LivreId, + }), + ], + }; + + /// + /// ⚠️ Le cas qui a tranché la décision : le bloc se pose à la place du PREMIER tome, pour + /// que l'ordre général du catalogue ne bouge pas. + /// + [Fact] + public void Le_bloc_prend_la_place_de_son_premier_tome() + { + List catalogue = + [ + Livre(1, "Dracula"), + Livre(2, "L'Éclat de cristal"), + Livre(3, "Dune"), + Livre(4, "Terre natale"), + ]; + + var entrees = GroupementCatalogue.Grouper( + catalogue, [Serie(9, "La Légende de Drizzt", (1, 2), (0, 4))]); + + Assert.Collection( + entrees, + e => Assert.Equal("Dracula", Assert.Single(e.Livres).Titre), + e => + { + Assert.Equal("La Légende de Drizzt", e.Serie!.Titre); + + // À l'intérieur, l'ordre est celui de LECTURE, pas celui du catalogue. + Assert.Equal(["Terre natale", "L'Éclat de cristal"], e.Livres.Select(l => l.Titre)); + }, + e => Assert.Equal("Dune", Assert.Single(e.Livres).Titre)); + } + + /// Aucun livre ne disparaît, aucun n'apparaît deux fois : c'est la règle du catalogue. + [Fact] + public void Le_regroupement_ne_perd_ni_ne_duplique_aucun_livre() + { + List catalogue = [Livre(1, "A"), Livre(2, "B"), Livre(3, "C")]; + + // Le livre 2 est rattaché à DEUX séries — le modèle l'autorise. + var entrees = GroupementCatalogue.Grouper( + catalogue, [Serie(1, "Zeta", (0, 2)), Serie(2, "Alpha", (0, 2), (1, 3))]); + + Assert.Equal([1, 2, 3], entrees.SelectMany(e => e.Livres).Select(l => l.Id).Order()); + } + + /// + /// ⚠️ La série retenue est la première par ordre alphabétique, et non celle que l'API a + /// rendue en premier : sans cela, le bloc changerait de nom d'un chargement à l'autre. + /// + [Fact] + public void Un_livre_de_deux_series_est_range_sous_la_premiere_alphabetiquement() + { + var entrees = GroupementCatalogue.Grouper( + [Livre(2, "B")], [Serie(1, "Zeta", (0, 2)), Serie(2, "Alpha", (0, 2))]); + + Assert.Equal("Alpha", Assert.Single(entrees).Serie!.Titre); + } + + /// Sans série connue — hors-ligne sans instantané, ou groupement défait — la liste est plate. + [Fact] + public void Sans_series_la_liste_reste_celle_du_catalogue() + { + List catalogue = [Livre(1, "A"), Livre(2, "B")]; + + var entrees = GroupementCatalogue.Grouper(catalogue, null); + + Assert.All(entrees, e => Assert.Null(e.Serie)); + Assert.Equal(["A", "B"], entrees.Select(e => e.Livres[0].Titre)); + } + + /// + /// Un seul tome possédé forme quand même un bloc : c'est ce qui dit à quoi ce livre + /// appartient, et ce qui mène à la série où figurent les tomes manquants. + /// + [Fact] + public void Un_seul_tome_possede_forme_quand_meme_un_bloc() + { + var entrees = GroupementCatalogue.Grouper( + [Livre(4, "Terre natale")], [Serie(9, "L'Elfe noir", (0, 4), (1, 5))]); + + Assert.Equal("L'Elfe noir", Assert.Single(entrees).Serie!.Titre); + } + + /// + /// Les tomes filtrés restent absents : le regroupement rassemble ce que la liste contient, + /// il ne va pas rechercher ce qu'un filtre a écarté. + /// + [Fact] + public void Un_filtre_ne_ramene_pas_les_tomes_ecartes() + { + var entrees = GroupementCatalogue.Grouper( + [Livre(5, "L'Éclat de cristal")], [Serie(9, "Drizzt", (0, 4), (1, 5))]); + + Assert.Equal(["L'Éclat de cristal"], Assert.Single(entrees).Livres.Select(l => l.Titre)); + } +}