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:
@@ -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` :
|
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.
|
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
|
**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
|
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,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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
|
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.
|
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">
|
<ul class="liste-livres">
|
||||||
@foreach (var entree in Entrees)
|
@foreach (var entree in Entrees)
|
||||||
{
|
{
|
||||||
@if (entree.Serie is { } serie)
|
@if (entree.EstGroupe)
|
||||||
{
|
{
|
||||||
<li class="groupe-serie" @key="serie">
|
<GroupeCatalogue Entree="entree" Replies="_replies" />
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
else
|
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>
|
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
|
||||||
private bool _grouper = true;
|
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>
|
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
|
||||||
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
||||||
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
|
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
|
||||||
|
|||||||
@@ -6,26 +6,44 @@ namespace MaBibli.Client.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
|
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
|
||||||
/// </summary>
|
/// </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
|
public sealed record EntreeCatalogue
|
||||||
{
|
{
|
||||||
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
|
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
|
||||||
public SerieDto? Serie { get; init; }
|
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; }
|
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;
|
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>
|
/// <summary>
|
||||||
/// Regroupe les livres du catalogue par série.
|
/// Regroupe les livres du catalogue par série, en respectant les cycles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Le bloc se place là où son premier tome serait tombé</b> dans l'ordre du catalogue
|
/// <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
|
/// (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
|
/// 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
|
/// 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>
|
/// <para>
|
||||||
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
|
/// ⚠️ <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
|
/// 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
|
/// <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.
|
/// tous les cas ; sa fiche livre, elle, les montre toutes.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public static class GroupementCatalogue
|
public static class GroupementCatalogue
|
||||||
{
|
{
|
||||||
@@ -51,45 +75,116 @@ public static class GroupementCatalogue
|
|||||||
}
|
}
|
||||||
|
|
||||||
var place = PlaceDesLivres(series);
|
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
|
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
|
||||||
// tome rencontré : c'est ce qui range le bloc là où l'ordre du catalogue l'attend.
|
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
|
||||||
var brouillons = new List<(SerieDto? Serie, List<(int Position, LivreDto Livre)> Livres)>();
|
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
|
||||||
var groupes = new Dictionary<int, List<(int Position, LivreDto Livre)>>();
|
var brouillons = new List<Noeud>();
|
||||||
|
var noeuds = new Dictionary<int, Noeud>();
|
||||||
|
|
||||||
foreach (var livre in livres)
|
foreach (var livre in livres)
|
||||||
{
|
{
|
||||||
if (!place.TryGetValue(livre.Id, out var appartenance))
|
if (!place.TryGetValue(livre.Id, out var appartenance))
|
||||||
{
|
{
|
||||||
brouillons.Add((null, [(0, livre)]));
|
brouillons.Add(new Noeud(null) { Livres = { (0, livre) } });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groupes.TryGetValue(appartenance.Serie.Id, out var tomes))
|
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
|
||||||
{
|
noeud.Livres.Add((appartenance.Position, livre));
|
||||||
tomes = [];
|
|
||||||
groupes[appartenance.Serie.Id] = tomes;
|
|
||||||
brouillons.Add((appartenance.Serie, tomes));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tomes.Add((appartenance.Position, livre));
|
return [.. brouillons.Select(Materialiser)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
|
||||||
[
|
private sealed class Noeud(SerieDto? serie)
|
||||||
.. brouillons.Select(b => new EntreeCatalogue
|
|
||||||
{
|
{
|
||||||
Serie = b.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 =
|
Livres =
|
||||||
[
|
[
|
||||||
.. b.Livres
|
.. noeud.Livres
|
||||||
.OrderBy(t => t.Position)
|
.OrderBy(t => t.Position)
|
||||||
.ThenBy(t => t.Livre.Id)
|
.ThenBy(t => t.Livre.Id)
|
||||||
.Select(t => t.Livre),
|
.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>
|
/// <summary>
|
||||||
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
|
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
|
||||||
|
|||||||
@@ -413,12 +413,39 @@ body {
|
|||||||
padding-left: 0.6rem;
|
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. */
|
/* Le nom mène à la série : c'est le seul endroit du catalogue qui montre aussi ce qui manque. */
|
||||||
.groupe-serie-titre {
|
.groupe-serie-titre {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
color: var(--mb-texte);
|
color: var(--mb-texte);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -23,10 +23,17 @@ public class GroupementCatalogueTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static SerieDto Serie(int id, string titre, params (int Position, int LivreId)[] tomes) =>
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Titre = titre,
|
Titre = titre,
|
||||||
|
SerieParenteId = parenteId,
|
||||||
|
Position = position,
|
||||||
Elements =
|
Elements =
|
||||||
[
|
[
|
||||||
.. tomes.Select(t => new ElementSerieDto
|
.. 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));
|
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)];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user