Replie les blocs de série au catalogue, cycle par cycle

Le catalogue ne rangeait un livre que sous sa série directe : il n'y avait
qu'un seul niveau, donc rien à replier « à chaque niveau ». Le regroupement
devient un arbre — un cycle coiffe ses séries, comme sur /series — et chaque
nœud porte sa bascule.

Tout reste déplié par défaut ; un bloc replié annonce ce qu'il contient, et
l'état de repli appartient à l'écran, sans quoi une frappe dans la recherche
le perdrait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-22 01:34:04 +02:00
co-authored by Claude Opus 5
parent 7d2f3dc5d9
commit e85867014c
6 changed files with 411 additions and 48 deletions
+44
View File
@@ -3320,6 +3320,50 @@ n'apparaît qu'une fois et toujours sous la même, la liste plate sans séries,
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.
### Les blocs se replient, et un cycle replie chacun de ses niveaux (2026-08-22)
Demandé dans la foulée : « pouvoir réduire les groupes et ne garder que le nom de la série ; si
plusieurs niveaux, permettre de réduire chaque niveau ».
⚠️ **Le catalogue ne connaissait qu'un seul niveau** : le regroupement rangeait chaque livre sous
sa série *directe*, sans jamais remonter au cycle. Il n'y avait donc pas de « chaque niveau » à
replier — c'est le regroupement lui-même qui a dû devenir un arbre, `EntreeCatalogue` portant
désormais des `SousEntrees`. Un cycle se lit maintenant au catalogue comme sur `/series`.
| Décision | Pourquoi |
|---|---|
| **Tout est déplié par défaut**, on replie ce dont on ne veut pas | Règle déjà tenue par l'arbre des séries. L'inverse obligerait à ouvrir chaque bloc pour retrouver un livre qu'on voyait très bien avant |
| Le bloc **replié annonce toujours son compte** (« 3 tomes ») | Sans lui, le compteur du haut (« 6 livres ») paraîtrait mentir |
| L'état de repli vit dans l'**écran**, pas dans les blocs | Le catalogue se relit **à chaque frappe** dans la recherche : un état porté par les composants se perdrait à chaque caractère. Vérifié — le repli survit à une recherche et à son effacement |
| Non persistant d'une visite à l'autre | Confort de lecture, pas propriété de la série — même règle que `/series` |
| Le cycle se place à la place du **premier tome de sa descendance** | Prolongement exact de la règle de placement déjà actée : l'ordre général du catalogue ne bouge pas |
| Les sous-séries suivent leur **ordre de lecture** (`Serie.Position`), les tomes le leur | Deux écrans ne doivent pas donner deux ordres |
⚠️ **Seuls les niveaux qui portent quelque chose existent.** Un cycle de cinq séries dont une
seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la chaîne qui
mène aux livres présents. Un niveau vide serait une indentation qui ne se replierait sur rien —
et le titre d'une série dont on ne possède rien.
⚠️ La remontée de parenté est **bornée par le nombre de séries**, comme celle du serveur : une
boucle résiduelle en base ferait sinon tourner le regroupement sans fin. Mieux vaut un bloc rangé
un cran trop haut qu'un catalogue qui ne s'affiche plus. Un test le verrouille.
⚠️ **La bascule est un `<button>` À CÔTÉ du lien, jamais dedans** : un bouton imbriqué dans un
`<a>` n'est pas du HTML valide, et le clic partirait vers la série au lieu de replier. Le nom
reste un vrai lien vers `/series/{id}`.
⚠️ **`aria-expanded="@Deplie"` ne fait pas ce qu'on croit** : Blazor traite un booléen comme un
attribut de *présence* et rend `aria-expanded=""`, que les lecteurs d'écran ne savent pas lire.
L'état doit être écrit en toutes lettres (`@(Deplie ? "true" : "false")`). Constaté ici, mesuré
dans le navigateur — **le même défaut subsiste ailleurs** (`ArbreSerie`, le bouton « Filtrer » du
catalogue), non corrigé faute d'être dans la demande.
**Vérifié en exécution** (375 px puis 320 px) : le cycle *La Légende de Drizzt* coiffe ses deux
sous-séries, chaque niveau se replie et se déplie indépendamment, l'état d'un sous-niveau survit
au repli de son cycle, le compteur reste « 6 livres » dans tous les cas, le repli survit à une
recherche (« salvatore ») et à son effacement, le nom mène toujours à `/series/6`, aucun
débordement horizontal (`scrollWidth` = 320) et aucune erreur de console. 627 tests au vert.
**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,
@@ -0,0 +1,84 @@
@using MaBibli.Client.Services
@*
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
série de séries : chaque niveau porte sa propre bascule.
⚠️ Tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. C'est la règle déjà
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
livre qu'on voyait très bien avant.
⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le
compteur du haut du catalogue paraîtrait mentir.
*@
<li class="groupe-serie" @key="Entree.Serie!.Id">
<div class="groupe-serie-entete">
@* ⚠️ « aria-expanded="@Deplie" » ne marche PAS : Blazor traite un booléen comme un attribut
de présence et rend « aria-expanded="" ». L'état doit être écrit en toutes lettres. *@
<button type="button" class="groupe-serie-plier"
aria-expanded="@(Deplie ? "true" : "false")"
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Entree.Serie!.Titre} »")"
@onclick="Basculer">
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
</button>
@* Le nom mène à la SÉRIE : c'est le seul écran qui montre aussi les tomes manquants,
ce que le catalogue ne peut pas faire. *@
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
<span class="groupe-serie-compte">
@Entree.NombreLivres tome@(Entree.NombreLivres > 1 ? "s" : "")
</span>
</a>
</div>
@if (Deplie)
{
@if (Entree.SousEntrees.Count > 0)
{
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" />
}
</ul>
}
@if (Entree.Livres.Count > 0)
{
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
}
}
</li>
@code {
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non dépliés : le défaut est de tout montrer.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries :
/// c'est un confort de lecture, pas une propriété de la série. Il survit en revanche aux
/// rechargements du catalogue (frappe dans la recherche, filtre), le jeu appartenant à
/// l'écran et non au bloc.
/// </remarks>
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Replies.Add(Entree.Serie!.Id))
{
Replies.Remove(Entree.Serie!.Id);
}
}
}
+18 -18
View File
@@ -157,29 +157,18 @@ else if (_livres is not null)
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.
Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
toujours ce qu'il contient.
*@
<ul class="liste-livres">
@foreach (var entree in Entrees)
{
@if (entree.Serie is { } serie)
@if (entree.EstGroupe)
{
<li class="groupe-serie" @key="serie">
<a class="groupe-serie-titre" href="@($"series/{serie.Id}")">
<span class="groupe-serie-nom">@serie.Titre</span>
<span class="groupe-serie-compte">
@entree.Livres.Count tome@(entree.Livres.Count > 1 ? "s" : "")
</span>
</a>
<ul class="liste-livres">
@foreach (var livre in entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
</li>
<GroupeCatalogue Entree="entree" Replies="_replies" />
}
else
{
@@ -236,6 +225,17 @@ else if (_livres is not null)
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
private bool _grouper = true;
/// <summary>
/// Blocs repliés à la main, par identifiant de série. Tout est déplié par défaut.
/// </summary>
/// <remarks>
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
/// lecture, pas une propriété de la série, même règle que l'arbre des séries.
/// </remarks>
private readonly HashSet<int> _replies = [];
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
private IReadOnlyList<EntreeCatalogue> Entrees =>
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
+125 -30
View File
@@ -6,26 +6,44 @@ namespace MaBibli.Client.Services;
/// <summary>
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
/// </summary>
/// <remarks>
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une <b>série de séries</b>
/// (<c>Serie.SerieParenteId</c>), et le catalogue le montre tel quel depuis le 2026-08-22 —
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
/// </remarks>
public sealed record EntreeCatalogue
{
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
public SerieDto? Serie { get; init; }
/// <summary>Les livres de l'entrée : un seul hors série, les tomes visibles sinon.</summary>
/// <summary>Les livres rattachés <b>directement</b> à cette série ; un seul hors série.</summary>
public required IReadOnlyList<LivreDto> Livres { get; init; }
/// <summary>Les séries contenues, quand ce bloc est un cycle. Vide sinon.</summary>
public IReadOnlyList<EntreeCatalogue> SousEntrees { get; init; } = [];
public bool EstGroupe => Serie is not null;
/// <summary>
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
/// </summary>
/// <remarks>
/// C'est ce que le bloc annonce, y compris <b>replié</b> : replier ne doit pas faire
/// perdre le compte de ce qu'on vient de cacher.
/// </remarks>
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
}
/// <summary>
/// Regroupe les livres du catalogue par série.
/// Regroupe les livres du catalogue par série, en respectant les cycles.
/// </summary>
/// <remarks>
/// <b>Le bloc se place là où son premier tome serait tombé</b> 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'<b>ordre de lecture</b>, seul ordre qui ait un sens pour une saga — c'est
/// même la raison d'être de <c>ElementSerie.Position</c>.
/// même la raison d'être de <c>ElementSerie.Position</c>, et les sous-séries d'un cycle
/// suivent le leur (<c>Serie.Position</c>), comme sur l'écran des séries.
/// <para>
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
@@ -39,6 +57,12 @@ public sealed record EntreeCatalogue
/// <b>première par ordre alphabétique</b> — un critère explicable, à défaut d'être le bon dans
/// tous les cas ; sa fiche livre, elle, les montre toutes.
/// </para>
/// <para>
/// ⚠️ <b>Seuls les niveaux qui portent quelque chose existent.</b> Un cycle de cinq séries dont
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
/// replierait sur rien.
/// </para>
/// </remarks>
public static class GroupementCatalogue
{
@@ -51,46 +75,117 @@ public static class GroupementCatalogue
}
var place = PlaceDesLivres(series);
var parId = series.ToDictionary(s => s.Id);
// 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<int, List<(int Position, LivreDto Livre)>>();
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
var brouillons = new List<Noeud>();
var noeuds = new Dictionary<int, Noeud>();
foreach (var livre in livres)
{
if (!place.TryGetValue(livre.Id, out var appartenance))
{
brouillons.Add((null, [(0, livre)]));
brouillons.Add(new Noeud(null) { Livres = { (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));
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
noeud.Livres.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),
],
}),
];
return [.. brouillons.Select(Materialiser)];
}
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
private sealed class Noeud(SerieDto? serie)
{
public SerieDto? Serie { get; } = serie;
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
public List<Noeud> Enfants { get; } = [];
}
/// <summary>
/// La chaîne de la racine jusqu'à <paramref name="serie"/>, cycle compris.
/// </summary>
/// <remarks>
/// ⚠️ La remontée est <b>bornée par le nombre de séries</b>, comme celle du serveur : une
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
/// </remarks>
private static List<SerieDto> Ascendance(SerieDto serie, Dictionary<int, SerieDto> parId)
{
var chaine = new List<SerieDto> { serie };
var vues = new HashSet<int> { serie.Id };
var courante = serie;
while (courante.SerieParenteId is { } parenteId
&& parId.TryGetValue(parenteId, out var parente)
&& vues.Add(parente.Id)
&& chaine.Count <= parId.Count)
{
chaine.Add(parente);
courante = parente;
}
chaine.Reverse();
return chaine;
}
/// <summary>Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.</summary>
private static Noeud Descendre(
List<SerieDto> chaine, Dictionary<int, Noeud> noeuds, List<Noeud> brouillons)
{
Noeud? parent = null;
foreach (var serie in chaine)
{
if (!noeuds.TryGetValue(serie.Id, out var noeud))
{
noeud = new Noeud(serie);
noeuds[serie.Id] = noeud;
if (parent is null)
{
brouillons.Add(noeud);
}
else
{
parent.Enfants.Add(noeud);
}
}
parent = noeud;
}
return parent!;
}
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
{
Serie = noeud.Serie,
Livres =
[
.. noeud.Livres
.OrderBy(t => t.Position)
.ThenBy(t => t.Livre.Id)
.Select(t => t.Livre),
],
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
SousEntrees =
[
.. noeud.Enfants
.OrderBy(e => e.Serie!.Position)
.ThenBy(e => e.Serie!.Id)
.Select(Materialiser),
],
};
/// <summary>
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
/// </summary>
+27
View File
@@ -413,12 +413,39 @@ body {
padding-left: 0.6rem;
}
/* La bascule et le nom sur une même ligne. « min-width: 0 » à chaque étage : la ligne est
elle-même dans une cascade de flex, et sans lui un titre de série long fait défiler la page
entière le piège du lot M, puis de l'arbre des séries. */
.groupe-serie-entete {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
/* La bascule est un BOUTON à côté du lien, jamais dedans : un bouton imbriqué dans un <a>
n'est pas du HTML valide, et le clic irait à la série au lieu de replier. */
.groupe-serie-plier {
flex: 0 0 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
padding: 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
background: transparent;
border: 0;
cursor: pointer;
}
/* 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;
min-width: 0;
padding: 0.35rem 0;
color: var(--mb-texte);
text-decoration: none;
+113
View File
@@ -23,10 +23,17 @@ public class GroupementCatalogueTests
};
private static SerieDto Serie(int id, string titre, params (int Position, int LivreId)[] tomes) =>
SousSerie(id, titre, null, 0, tomes);
private static SerieDto SousSerie(
int id, string titre, int? parenteId, int position,
params (int Position, int LivreId)[] tomes) =>
new()
{
Id = id,
Titre = titre,
SerieParenteId = parenteId,
Position = position,
Elements =
[
.. tomes.Select(t => new ElementSerieDto
@@ -133,4 +140,110 @@ public class GroupementCatalogueTests
Assert.Equal(["L'Éclat de cristal"], Assert.Single(entrees).Livres.Select(l => l.Titre));
}
/// <summary>
/// ⚠️ Le cas du 2026-08-22 : un cycle est une série de séries, et le catalogue doit le
/// montrer — sans quoi « replier chaque niveau » n'aurait qu'un seul niveau à replier.
/// </summary>
[Fact]
public void Un_cycle_coiffe_les_series_qu_il_contient()
{
List<LivreDto> catalogue = [Livre(1, "Terre natale"), Livre(2, "L'Éclat de cristal")];
List<SerieDto> series =
[
Serie(1, "La Légende de Drizzt"),
SousSerie(3, "La Trilogie des Elfes noirs", 1, 1, (0, 2)),
SousSerie(2, "L'Elfe noir", 1, 0, (0, 1)),
];
var cycle = Assert.Single(GroupementCatalogue.Grouper(catalogue, series));
Assert.Equal("La Légende de Drizzt", cycle.Serie!.Titre);
Assert.Empty(cycle.Livres);
Assert.Equal(2, cycle.NombreLivres);
// Les sous-séries suivent leur ordre de LECTURE dans le cycle, pas l'alphabet.
Assert.Collection(
cycle.SousEntrees,
e =>
{
Assert.Equal("L'Elfe noir", e.Serie!.Titre);
Assert.Equal(["Terre natale"], e.Livres.Select(l => l.Titre));
},
e =>
{
Assert.Equal("La Trilogie des Elfes noirs", e.Serie!.Titre);
Assert.Equal(["L'Éclat de cristal"], e.Livres.Select(l => l.Titre));
});
}
/// <summary>
/// Le bloc du cycle se pose là où tombe son premier tome, comme celui d'une série simple :
/// l'ordre général du catalogue ne bouge pas.
/// </summary>
[Fact]
public void Le_cycle_prend_la_place_du_premier_tome_de_sa_descendance()
{
List<LivreDto> catalogue =
[
Livre(1, "Dracula"),
Livre(2, "Terre natale"),
Livre(3, "Dune"),
Livre(4, "L'Éclat de cristal"),
];
List<SerieDto> series =
[
Serie(1, "Drizzt"),
SousSerie(2, "L'Elfe noir", 1, 0, (0, 2)),
SousSerie(3, "Les Elfes noirs", 1, 1, (0, 4)),
];
Assert.Collection(
GroupementCatalogue.Grouper(catalogue, series),
e => Assert.Equal("Dracula", Assert.Single(e.Livres).Titre),
e => Assert.Equal("Drizzt", e.Serie!.Titre),
e => Assert.Equal("Dune", Assert.Single(e.Livres).Titre));
}
/// <summary>
/// ⚠️ Un niveau qui ne porte rien n'existe pas : ce serait une indentation qui ne se
/// replierait sur rien, et un titre de série dont aucun tome n'est au catalogue.
/// </summary>
[Fact]
public void Les_series_sans_tome_visible_ne_font_pas_de_niveau()
{
List<SerieDto> series =
[
Serie(1, "Cycle"),
SousSerie(2, "Vide", 1, 0, (0, 99)),
SousSerie(3, "Garnie", 1, 1, (0, 5)),
];
var cycle = Assert.Single(GroupementCatalogue.Grouper([Livre(5, "Un tome")], series));
Assert.Equal("Garnie", Assert.Single(cycle.SousEntrees).Serie!.Titre);
}
/// <summary>
/// Une parenté qui boucle en base ne doit pas faire tourner le regroupement sans fin :
/// même garde-fou que la remontée du serveur, bornée par le nombre de séries.
/// </summary>
[Fact]
public void Une_parente_qui_boucle_ne_bloque_pas_le_catalogue()
{
List<SerieDto> series =
[
SousSerie(1, "A", 2, 0, (0, 1)),
SousSerie(2, "B", 1, 0),
];
var entrees = GroupementCatalogue.Grouper([Livre(1, "Un livre")], series);
Assert.Equal([1], entrees.SelectMany(EntreesEtDescendance).SelectMany(e => e.Livres).Select(l => l.Id));
}
private static IEnumerable<EntreeCatalogue> EntreesEtDescendance(EntreeCatalogue entree) =>
[entree, .. entree.SousEntrees.SelectMany(EntreesEtDescendance)];
}