Implémente C1 à C5 pour les séries et cycles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 13:27:49 +02:00
co-authored by Copilot
parent 3d5e73838e
commit ddc60898da
12 changed files with 534 additions and 45 deletions
+26
View File
@@ -1908,3 +1908,29 @@ Points à réévaluer en cours de route, sans blocage :
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.
## Lot C — séries, cycles et arborescence (2026-08-20)
Le cycle affiche désormais un arbre de profondeur non bornée par la base : `ArbreSerie.razor`
descend récursivement les séries à partir de la liste plate de `GET /api/series`. Le chemin déjà
rendu est transmis au composant afin qu'une boucle résiduelle en base coupe le rendu au lieu de
provoquer une récursion Razor infinie ; le garde-fou serveur de remontée reste borné par le nombre
de séries.
Les séries directement contenues dans un cycle se réordonnent par flèches via
`PUT /api/series/{id}/sous-series/ordre`. Les tomes conservent leur propre ordre et la règle
hors-ligne reste la même : les boutons d'écriture restent visibles mais désactivés avec
`EtatReseau.MotifHorsLigne`.
Le pliage/dépliage est un état local de l'écran (`HashSet<int>`), volontairement non persistant :
il s'agit d'un confort de lecture, pas d'une propriété de la série. Une série repliée montre son
résumé ; une série dépliée montre ses tomes et ses descendants.
Les compteurs « possédés / total » sont calculés côté client par `TotauxSeries`, en sommant
récursivement les éléments directs et ceux de tous les descendants. Le calcul est lui aussi
protégé contre une boucle.
Depuis une place manquante, un ISBN passe par la cascade BnF puis OpenLibrary existante. Le
candidat choisi peut créer un livre du catalogue puis rattacher la place, ou créer une envie avec
les métadonnées complètes, notamment l'auteur trouvé par le lookup. Les revues (préfixe `977`) ne
font pas partie de ce flux : `ElementSerie.LivreId` ne pointe que vers `Livre`.
+3 -1
View File
@@ -153,7 +153,7 @@ divergeront comme le filtre a divergé (voir A2).
---
## Lot C — Séries, cycles et arborescence
## Lot C — Séries, cycles et arborescence — traité le 2026-08-20
Le lot le plus structurant, et le seul qui touche au modèle.
@@ -181,6 +181,8 @@ Le lot le plus structurant, et le seul qui touche au modèle.
rattacher un numéro de revue à une série demanderait un second lien nullable, ou une parenté
commune. À instruire avant de promettre — ce n'est pas le même travail que le reste du lot.
✅ Traité : voir `CLAUDE.md` (C1 à C5). Les revues restent explicitement hors périmètre de C5.
---
## Lot D — La bibliographie devient un écran de travail
+8
View File
@@ -83,6 +83,14 @@ public static class SeriesEndpoints
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}/sous-series/ordre", async (
int id, OrdreSeries ordre, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ReordonnerSousSeriesAsync(id, ordre.Ids, ct)))
.WithName("ReordonnerSousSeries")
.WithSummary("Fixe l'ordre de lecture des séries directement contenues dans un cycle.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/elements/{elementId:int}/livre", async (
int elementId,
RattachementLivre rattachement,
+34 -2
View File
@@ -42,6 +42,9 @@ public interface IServiceSeries
Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
/// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default);
@@ -321,6 +324,37 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
{
if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
{
return ResultatSerie.Introuvable;
}
var enfants = await db.Series
.Where(s => s.SerieParenteId == serieId)
.ToListAsync(ct);
var parId = enfants.ToDictionary(s => s.Id);
var position = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var enfant))
{
enfant.Position = position++;
}
}
foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
{
oublie.Position = position++;
}
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default)
{
@@ -343,8 +377,6 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits
// ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
// (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
// membres peuvent vouloir le même tome manquant, chacun dans sa liste.
// Aucun auteur n'est transmis : une position dans une saga n'en porte pas, et en
// inventer un fausserait le rapprochement avec le catalogue.
return await souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
}
+121
View File
@@ -0,0 +1,121 @@
@using MaBibli.Shared.Dtos
<li class="carte-serie arbre-serie" @key="Serie.Id">
<div class="arbre-serie-entete">
<button type="button" class="bouton bouton-discret"
aria-expanded="@Deplie"
@onclick="Basculer">
@(Deplie ? "▼" : "▶")
</button>
<a class="carte-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
<span class="carte-details">@Avancement()</span>
@if (ActionsActives)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_index == 0)" title="@MotifBlocage"
aria-label="@($"Monter « {Serie.Titre} »")"
@onclick="Monter">▲</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
aria-label="@($"Descendre « {Serie.Titre} »")"
@onclick="Descendre">▼</button>
}
</div>
@if (Deplie)
{
<div class="arbre-serie-detail">
@if (Serie.Elements.Count == 0)
{
<p class="message-discret">Aucun tome recensé.</p>
}
else
{
@foreach (var element in Serie.Elements)
{
@if (element.LivreId is { } livreId
&& Livres.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
else
{
<p class="carte-tome tome-manquant">
@element.Titre
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
</p>
}
}
}
@if (Enfants.Count > 0)
{
<ul class="liste-sous-series">
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
{
<ArbreSerie Serie="enfant" Toutes="Toutes" Livres="Livres"
Totaux="Totaux" Chemin="@CheminAvecSerie"
ActionsActives="ActionsActives"
MotifBlocage="MotifBlocage"
Deplies="Deplies"
OnDeplacer="OnDeplacer" />
}
</ul>
}
</div>
}
</li>
@code {
[Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<SerieDto> Toutes { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<LivreDto> Livres { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyDictionary<int, (int Possedes, int Total)> Totaux { get; set; } = new Dictionary<int, (int, int)>();
[Parameter] public IReadOnlySet<int> Chemin { get; set; } = new HashSet<int>();
[Parameter] public ISet<int> Deplies { get; set; } = new HashSet<int>();
[Parameter] public bool ActionsActives { get; set; }
[Parameter] public string? MotifBlocage { get; set; }
[Parameter] public EventCallback<DeplacementSerie> OnDeplacer { get; set; }
private List<SerieDto> Soeurs => Toutes
.Where(s => s.SerieParenteId == Serie.SerieParenteId)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
private List<SerieDto> Enfants => Toutes
.Where(s => s.SerieParenteId == Serie.Id)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
private bool Deplie => Deplies.Contains(Serie.Id);
private IReadOnlySet<int> CheminAvecSerie => new HashSet<int>(Chemin) { Serie.Id };
private string Avancement()
{
var total = Totaux.TryGetValue(Serie.Id, out var valeur)
? valeur
: (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
return total.Total == 0
? "Aucun tome recensé"
: $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
}
private void Basculer()
{
if (!Deplies.Add(Serie.Id))
{
Deplies.Remove(Serie.Id);
}
}
private Task Monter() => Deplacer(-1);
private Task Descendre() => Deplacer(1);
private Task Deplacer(int delta) =>
Serie.SerieParenteId is { } parent
? OnDeplacer.InvokeAsync(new DeplacementSerie(Serie.Id, parent, delta))
: Task.CompletedTask;
}
+199 -8
View File
@@ -34,7 +34,8 @@ else
}
else
{
<text>@_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque.</text>
var total = Total(_serie);
<text>@total.Possedes sur @total.Total tomes dans la bibliothèque.</text>
}
</p>
@@ -50,19 +51,19 @@ else
<p class="message-avertissement" role="status">@_message</p>
}
@* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
@* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient
malgré tout une boucle. *@
@if (!ModeEdition && _filles.Count > 0)
{
<h2 class="titre-section">Séries de ce cycle</h2>
<ul class="liste-sous-series">
@foreach (var fille in _filles)
{
<li @key="fille.Id">
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
<span class="carte-details">
@fille.NombrePossedes sur @fille.Elements.Count
</span>
</li>
<ArbreSerie Serie="fille" Toutes="_toutes" Livres="_livresCatalogue"
Totaux="_totaux" Deplies="_deplies"
Chemin="@CheminRacine"
ActionsActives="Reseau.EnLigne" MotifBlocage="@MotifBlocage"
OnDeplacer="DeplacerSousSerieAsync" />
}
</ul>
}
@@ -149,6 +150,12 @@ else
@onclick="() => OuvrirRattachement(element.Id)">
Je l'ai
</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirLookup(element)">
Ajouter par ISBN
</button>
}
else
{
@@ -183,6 +190,47 @@ else
}
</div>
}
@if (_lookupElement?.Id == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="text" inputmode="numeric"
placeholder="ISBN du tome…"
@bind="_lookupIsbn" @bind:event="oninput"
@onkeydown="SurToucheLookup" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_lookupIsbn))"
title="@MotifBlocage" @onclick="ChercherLookupAsync">
@(_lookupEnCours ? "Recherche…" : "Préremplir")
</button>
@if (_lookupCandidats.Count > 0)
{
@foreach (var candidat in _lookupCandidats)
{
<div class="carte-candidat">
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
<p class="carte-auteur">@candidat.Auteur</p>
<p class="carte-details">
@(candidat.Editeur ?? "éditeur inconnu")
· @(candidat.Annee ?? "année inconnue")
</p>
<button type="button" class="bouton bouton-principal"
disabled="@_enCours"
@onclick="() => AjouterCommeLivreAsync(candidat)">
Ajouter comme livre
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => AjouterCommeEnvieAsync(candidat)">
Ajouter comme envie
</button>
</div>
</div>
}
}
</div>
}
</div>
</li>
}
@@ -312,6 +360,9 @@ else
private SerieDto? _serie;
private SerieDto? _parente;
private List<SerieDto> _filles = [];
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
new Dictionary<int, (int, int)>();
private readonly HashSet<int> _deplies = [];
private bool _chargement = true;
private bool _enCours;
@@ -326,8 +377,13 @@ else
private int? _rattachement;
private string _recherche = string.Empty;
private IReadOnlyList<LivreDto> _resultats = [];
private ElementSerieDto? _lookupElement;
private string _lookupIsbn = string.Empty;
private IReadOnlyList<CandidatLivre> _lookupCandidats = [];
private bool _lookupEnCours;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };
private bool ModeEdition =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
@@ -410,6 +466,7 @@ else
: null;
_filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
_totaux = TotauxSeries.Calculer(_toutes);
_titre = _serie.Titre;
_parenteId = _serie.SerieParenteId ?? 0;
@@ -478,6 +535,140 @@ else
}
}
private (int Possedes, int Total) Total(SerieDto serie) =>
_totaux.TryGetValue(serie.Id, out var total)
? total
: (serie.NombrePossedes, serie.Elements.Count);
private async Task DeplacerSousSerieAsync(DeplacementSerie mouvement)
{
var soeurs = _toutes
.Where(s => s.SerieParenteId == mouvement.ParenteId)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
var index = soeurs.FindIndex(s => s.Id == mouvement.SerieId);
var cible = index + mouvement.Delta;
if (index < 0 || cible < 0 || cible >= soeurs.Count)
{
return;
}
var ids = soeurs.Select(s => s.Id).ToList();
(ids[index], ids[cible]) = (ids[cible], ids[index]);
await EcrireAsync(() => Api.ReordonnerSousSeriesAsync(mouvement.ParenteId, ids));
if (_erreur is null)
{
await ChargerAsync();
}
}
private void OuvrirLookup(ElementSerieDto element)
{
_lookupElement = element;
_lookupIsbn = string.Empty;
_lookupCandidats = [];
_erreur = null;
}
private async Task SurToucheLookup(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_lookupIsbn))
{
await ChercherLookupAsync();
}
}
private async Task ChercherLookupAsync()
{
_lookupEnCours = true;
_erreur = null;
_lookupCandidats = [];
var resultat = await Api.ChercherIsbnAsync(_lookupIsbn);
_lookupEnCours = false;
if (resultat is null)
{
_erreur = Reseau.EnLigne
? "La recherche n'a rien pu ramener."
: EtatReseau.MotifHorsLigne;
return;
}
if (resultat.Periodique is not null)
{
_erreur = "Les revues ne peuvent pas être rattachées à une série.";
return;
}
_lookupCandidats = resultat.Candidats;
if (_lookupCandidats.Count == 0)
{
_erreur = "Aucune notice ne correspond à cet ISBN.";
}
}
private async Task AjouterCommeEnvieAsync(CandidatLivre candidat)
{
_enCours = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
{
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
Annee = candidat.Annee,
Isbn = candidat.IsbnInterroge,
CoverUrl = candidat.CoverUrl,
});
_enCours = false;
_erreur = resultat.Erreur;
if (resultat.EstOk)
{
_message = $"« {candidat.Titre} » est dans votre liste d'envies.";
_lookupElement = null;
}
}
private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
{
if (_lookupElement is null)
{
return;
}
_enCours = true;
_erreur = null;
var saisie = new EnregistrementLivre
{
Isbn = candidat.IsbnInterroge,
Titre = candidat.Titre,
Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
Editeur = candidat.Editeur,
CoverUrl = candidat.CoverUrl,
};
var resultat = await Api.CreerAsync(saisie);
_enCours = false;
if (resultat.Doublons is not null)
{
_erreur = resultat.Doublons.Message;
return;
}
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
if (_erreur is null)
{
_lookupElement = null;
}
}
private string? _message;
private async Task DeplacerAsync(int depuis, int vers)
+12 -34
View File
@@ -50,30 +50,18 @@ else
<ul class="liste-series">
@foreach (var serie in Racines)
{
<li class="carte-serie" @key="serie.Id">
<a class="carte-titre" href="@($"series/{serie.Id}")">@serie.Titre</a>
<p class="carte-details">@Avancement(serie)</p>
@* Un cycle contient des séries : elles se voient d'ici, sans ouvrir le cycle. *@
@if (Filles(serie.Id) is { Count: > 0 } filles)
{
<ul class="liste-sous-series">
@foreach (var fille in filles)
{
<li @key="fille.Id">
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
<span class="carte-details">@Avancement(fille)</span>
</li>
}
</ul>
}
</li>
<ArbreSerie Serie="serie" Toutes="_series" Livres="_livres"
Totaux="_totaux" Deplies="_deplies" />
}
</ul>
}
@code {
private IReadOnlyList<SerieDto>? _series;
private IReadOnlyList<LivreDto> _livres = [];
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
new Dictionary<int, (int, int)>();
private readonly HashSet<int> _deplies = [];
private string _nouveau = string.Empty;
private bool _enCours;
private string? _erreur;
@@ -84,21 +72,6 @@ else
private IEnumerable<SerieDto> Racines =>
_series?.Where(s => s.SerieParenteId is null) ?? [];
private List<SerieDto> Filles(int parenteId) =>
_series?.Where(s => s.SerieParenteId == parenteId).OrderBy(s => s.Position).ToList() ?? [];
/// <summary>
/// « 4 tomes sur 7 » : le compteur est l'information principale de l'écran.
/// </summary>
/// <remarks>
/// Une série sans aucun tome ne se décrit pas par « 0 sur 0 », qui ressemble à une erreur :
/// elle dit ce qu'il faut faire.
/// </remarks>
private static string Avancement(SerieDto serie) =>
serie.Elements.Count == 0
? "Aucun tome recensé"
: $"{serie.NombrePossedes} tome{(serie.NombrePossedes > 1 ? "s" : "")} sur {serie.Elements.Count}";
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
@@ -111,7 +84,12 @@ else
protected override Task OnInitializedAsync() => ChargerAsync();
private async Task ChargerAsync() => _series = await Api.ListerSeriesAsync();
private async Task ChargerAsync()
{
_series = await Api.ListerSeriesAsync();
_livres = await Api.ListerAsync(new CritereLivres());
_totaux = TotauxSeries.Calculer(_series);
}
private async Task SurTouche(KeyboardEventArgs e)
{
@@ -790,6 +790,13 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
() => http.PutAsJsonAsync(
$"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/{serieId}/sous-series/ordre",
new OrdreSeries { Ids = [.. ids] }, Json, ct), ct);
/// <summary>Met un tome manquant dans <b>ma</b> liste d'envies — la série, elle, est commune.</summary>
public async Task<ResultatApi<SouhaitDto>> SouhaiterTomeAsync(
int elementId, CancellationToken ct = default) =>
+9
View File
@@ -93,3 +93,12 @@ public record OrdreElementsSerie
{
public List<int> Ids { get; set; } = [];
}
/// <summary>Nouvel ordre des séries directement contenues dans un cycle.</summary>
public record OrdreSeries
{
public List<int> Ids { get; set; } = [];
}
/// <summary>Déplacement d'une série dans l'ordre de lecture de son cycle.</summary>
public readonly record struct DeplacementSerie(int SerieId, int ParenteId, int Delta);
+50
View File
@@ -0,0 +1,50 @@
namespace MaBibli.Shared.Dtos;
/// <summary>Calcul des compteurs récursifs de l'arbre des séries.</summary>
public static class TotauxSeries
{
public static IReadOnlyDictionary<int, (int Possedes, int Total)> Calculer(
IReadOnlyList<SerieDto> series)
{
var parentees = series
.Where(s => s.SerieParenteId is not null)
.GroupBy(s => s.SerieParenteId!.Value)
.ToDictionary(g => g.Key, g => g.ToList());
var resultat = new Dictionary<int, (int Possedes, int Total)>();
var enCours = new HashSet<int>();
(int Possedes, int Total) CalculerUne(SerieDto serie)
{
if (resultat.TryGetValue(serie.Id, out var connu))
{
return connu;
}
if (!enCours.Add(serie.Id))
{
return (0, 0);
}
var total = (serie.NombrePossedes, serie.Elements.Count);
if (parentees.TryGetValue(serie.Id, out var filles))
{
foreach (var fille in filles)
{
var sousTotal = CalculerUne(fille);
total = (total.Item1 + sousTotal.Possedes, total.Item2 + sousTotal.Total);
}
}
enCours.Remove(serie.Id);
resultat[serie.Id] = total;
return total;
}
foreach (var serie in series)
{
CalculerUne(serie);
}
return resultat;
}
}
+15
View File
@@ -177,6 +177,21 @@ public class ServiceSeriesTests : IDisposable
Assert.NotNull(boucle.Erreur);
}
[Fact]
public async Task Lordre_des_sous_series_se_reordonne_sans_toucher_aux_tomes()
{
var cycle = await SerieAsync("Cycle");
var une = await SerieAsync("Une", cycle.Id);
var deux = await SerieAsync("Deux", cycle.Id);
var apres = (await _series.ReordonnerSousSeriesAsync(cycle.Id, [deux.Id, une.Id])).Serie!;
Assert.Equal([deux.Id, une.Id],
(await _series.ListerAsync()).Where(s => s.SerieParenteId == cycle.Id)
.OrderBy(s => s.Position).Select(s => s.Id));
Assert.Empty(apres.Elements);
}
[Fact]
public async Task Supprimer_un_cycle_ne_detruit_pas_les_series_quil_contient()
{
+50
View File
@@ -0,0 +1,50 @@
using MaBibli.Shared.Dtos;
namespace MaBibli.Tests;
public class TotauxSeriesTests
{
[Fact]
public void Les_totaux_sont_sommes_recursivement()
{
var feuilles = new[]
{
new SerieDto
{
Id = 2, Titre = "Une", SerieParenteId = 1,
Elements = [new ElementSerieDto { Id = 20, Titre = "Possédé", LivreId = 5 }]
},
new SerieDto
{
Id = 3, Titre = "Deux", SerieParenteId = 1,
Elements = [new ElementSerieDto { Id = 30, Titre = "Manquant" }]
},
new SerieDto { Id = 1, Titre = "Cycle" },
};
var totaux = TotauxSeries.Calculer(feuilles);
Assert.Equal((1, 2), totaux[1]);
Assert.Equal((1, 1), totaux[2]);
Assert.Equal((0, 1), totaux[3]);
}
[Fact]
public void Une_boucle_dans_les_donnees_ne_fait_pas_boucler_le_calcul()
{
var series = new[]
{
new SerieDto { Id = 1, Titre = "Un", SerieParenteId = 2 },
new SerieDto
{
Id = 2, Titre = "Deux", SerieParenteId = 1,
Elements = [new ElementSerieDto { Id = 20, Titre = "Tome" }]
},
};
var totaux = TotauxSeries.Calculer(series);
Assert.Equal((0, 1), totaux[1]);
Assert.Equal((0, 1), totaux[2]);
}
}