From ddc60898da32c2b60b50478d239ac8efa65b75a9 Mon Sep 17 00:00:00 2001 From: LIMONIER Mathieu Date: Thu, 20 Aug 2026 13:27:49 +0200 Subject: [PATCH] =?UTF-8?q?Impl=C3=A9mente=20C1=20=C3=A0=20C5=20pour=20les?= =?UTF-8?q?=20s=C3=A9ries=20et=20cycles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 26 +++ IDEES.md | 4 +- MaBibli.Api/Endpoints/SeriesEndpoints.cs | 8 + MaBibli.Api/Services/Series/ServiceSeries.cs | 36 +++- MaBibli.Client/Composants/ArbreSerie.razor | 121 +++++++++++ MaBibli.Client/Pages/Serie.razor | 207 ++++++++++++++++++- MaBibli.Client/Pages/Series.razor | 46 ++--- MaBibli.Client/Services/ServiceLivresApi.cs | 7 + MaBibli.Shared/Dtos/SerieDto.cs | 9 + MaBibli.Shared/Dtos/TotauxSeries.cs | 50 +++++ MaBibli.Tests/ServiceSeriesTests.cs | 15 ++ MaBibli.Tests/TotauxSeriesTests.cs | 50 +++++ 12 files changed, 534 insertions(+), 45 deletions(-) create mode 100644 MaBibli.Client/Composants/ArbreSerie.razor create mode 100644 MaBibli.Shared/Dtos/TotauxSeries.cs create mode 100644 MaBibli.Tests/TotauxSeriesTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 670242d..1d7980c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`), 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`. diff --git a/IDEES.md b/IDEES.md index 20d850d..78649e7 100644 --- a/IDEES.md +++ b/IDEES.md @@ -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 diff --git a/MaBibli.Api/Endpoints/SeriesEndpoints.cs b/MaBibli.Api/Endpoints/SeriesEndpoints.cs index 0989cea..5ddbe99 100644 --- a/MaBibli.Api/Endpoints/SeriesEndpoints.cs +++ b/MaBibli.Api/Endpoints/SeriesEndpoints.cs @@ -83,6 +83,14 @@ public static class SeriesEndpoints .Produces() .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() + .Produces(StatusCodes.Status404NotFound); + groupe.MapPut("/elements/{elementId:int}/livre", async ( int elementId, RattachementLivre rattachement, diff --git a/MaBibli.Api/Services/Series/ServiceSeries.cs b/MaBibli.Api/Services/Series/ServiceSeries.cs index b95aefe..358baa4 100644 --- a/MaBibli.Api/Services/Series/ServiceSeries.cs +++ b/MaBibli.Api/Services/Series/ServiceSeries.cs @@ -42,6 +42,9 @@ public interface IServiceSeries Task ReordonnerAsync( int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default); + Task ReordonnerSousSeriesAsync( + int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default); + /// Bascule un tome manquant vers la liste d'envies de l'appelant. Task 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 ReordonnerSousSeriesAsync( + int serieId, IReadOnlyList 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 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); } diff --git a/MaBibli.Client/Composants/ArbreSerie.razor b/MaBibli.Client/Composants/ArbreSerie.razor new file mode 100644 index 0000000..2499c93 --- /dev/null +++ b/MaBibli.Client/Composants/ArbreSerie.razor @@ -0,0 +1,121 @@ +@using MaBibli.Shared.Dtos + +
  • +
    + + @Serie.Titre + @Avancement() + @if (ActionsActives) + { + + + } +
    + + @if (Deplie) + { +
    + @if (Serie.Elements.Count == 0) + { +

    Aucun tome recensé.

    + } + else + { + @foreach (var element in Serie.Elements) + { + @if (element.LivreId is { } livreId + && Livres.FirstOrDefault(l => l.Id == livreId) is { } livre) + { + + } + else + { +

    + @element.Titre + Pas dans la bibliothèque +

    + } + } + } + + @if (Enfants.Count > 0) + { +
      + @foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id))) + { + + } +
    + } +
    + } +
  • + +@code { + [Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!; + [Parameter, EditorRequired] public IReadOnlyList Toutes { get; set; } = []; + [Parameter, EditorRequired] public IReadOnlyList Livres { get; set; } = []; + [Parameter, EditorRequired] public IReadOnlyDictionary Totaux { get; set; } = new Dictionary(); + [Parameter] public IReadOnlySet Chemin { get; set; } = new HashSet(); + [Parameter] public ISet Deplies { get; set; } = new HashSet(); + [Parameter] public bool ActionsActives { get; set; } + [Parameter] public string? MotifBlocage { get; set; } + [Parameter] public EventCallback OnDeplacer { get; set; } + + private List Soeurs => Toutes + .Where(s => s.SerieParenteId == Serie.SerieParenteId) + .OrderBy(s => s.Position) + .ThenBy(s => s.Id) + .ToList(); + + private List 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 CheminAvecSerie => new HashSet(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; +} diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor index 9daaf8d..55f7371 100644 --- a/MaBibli.Client/Pages/Serie.razor +++ b/MaBibli.Client/Pages/Serie.razor @@ -34,7 +34,8 @@ else } else { - @_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque. + var total = Total(_serie); + @total.Possedes sur @total.Total tomes dans la bibliothèque. }

    @@ -50,19 +51,19 @@ else

    @_message

    } - @* 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) {

    Séries de ce cycle

      @foreach (var fille in _filles) { -
    • - @fille.Titre - - @fille.NombrePossedes sur @fille.Elements.Count - -
    • + }
    } @@ -149,6 +150,12 @@ else @onclick="() => OuvrirRattachement(element.Id)"> Je l'ai + + } else { @@ -183,6 +190,47 @@ else } } + + @if (_lookupElement?.Id == element.Id) + { +
    + + + @if (_lookupCandidats.Count > 0) + { + @foreach (var candidat in _lookupCandidats) + { +
    +
    +

    @candidat.Titre

    +

    @candidat.Auteur

    +

    + @(candidat.Editeur ?? "éditeur inconnu") + · @(candidat.Annee ?? "année inconnue") +

    + + +
    +
    + } + } +
    + } } @@ -312,6 +360,9 @@ else private SerieDto? _serie; private SerieDto? _parente; private List _filles = []; + private IReadOnlyDictionary _totaux = + new Dictionary(); + private readonly HashSet _deplies = []; private bool _chargement = true; private bool _enCours; @@ -326,8 +377,13 @@ else private int? _rattachement; private string _recherche = string.Empty; private IReadOnlyList _resultats = []; + private ElementSerieDto? _lookupElement; + private string _lookupIsbn = string.Empty; + private IReadOnlyList _lookupCandidats = []; + private bool _lookupEnCours; private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + private IReadOnlySet CheminRacine => new HashSet { 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) diff --git a/MaBibli.Client/Pages/Series.razor b/MaBibli.Client/Pages/Series.razor index 56547ff..7b302e7 100644 --- a/MaBibli.Client/Pages/Series.razor +++ b/MaBibli.Client/Pages/Series.razor @@ -50,30 +50,18 @@ else
      @foreach (var serie in Racines) { -
    • - @serie.Titre -

      @Avancement(serie)

      - - @* Un cycle contient des séries : elles se voient d'ici, sans ouvrir le cycle. *@ - @if (Filles(serie.Id) is { Count: > 0 } filles) - { -
        - @foreach (var fille in filles) - { -
      • - @fille.Titre - @Avancement(fille) -
      • - } -
      - } -
    • + }
    } @code { private IReadOnlyList? _series; + private IReadOnlyList _livres = []; + private IReadOnlyDictionary _totaux = + new Dictionary(); + private readonly HashSet _deplies = []; private string _nouveau = string.Empty; private bool _enCours; private string? _erreur; @@ -84,21 +72,6 @@ else private IEnumerable Racines => _series?.Where(s => s.SerieParenteId is null) ?? []; - private List Filles(int parenteId) => - _series?.Where(s => s.SerieParenteId == parenteId).OrderBy(s => s.Position).ToList() ?? []; - - /// - /// « 4 tomes sur 7 » : le compteur est l'information principale de l'écran. - /// - /// - /// 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. - /// - 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) { diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 327c09c..8368504 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -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> ReordonnerSousSeriesAsync( + int serieId, IReadOnlyList ids, CancellationToken ct = default) => + await EcrireAsync( + () => http.PutAsJsonAsync( + $"api/series/{serieId}/sous-series/ordre", + new OrdreSeries { Ids = [.. ids] }, Json, ct), ct); + /// Met un tome manquant dans ma liste d'envies — la série, elle, est commune. public async Task> SouhaiterTomeAsync( int elementId, CancellationToken ct = default) => diff --git a/MaBibli.Shared/Dtos/SerieDto.cs b/MaBibli.Shared/Dtos/SerieDto.cs index 3607292..e908dd6 100644 --- a/MaBibli.Shared/Dtos/SerieDto.cs +++ b/MaBibli.Shared/Dtos/SerieDto.cs @@ -93,3 +93,12 @@ public record OrdreElementsSerie { public List Ids { get; set; } = []; } + +/// Nouvel ordre des séries directement contenues dans un cycle. +public record OrdreSeries +{ + public List Ids { get; set; } = []; +} + +/// Déplacement d'une série dans l'ordre de lecture de son cycle. +public readonly record struct DeplacementSerie(int SerieId, int ParenteId, int Delta); diff --git a/MaBibli.Shared/Dtos/TotauxSeries.cs b/MaBibli.Shared/Dtos/TotauxSeries.cs new file mode 100644 index 0000000..e835dd0 --- /dev/null +++ b/MaBibli.Shared/Dtos/TotauxSeries.cs @@ -0,0 +1,50 @@ +namespace MaBibli.Shared.Dtos; + +/// Calcul des compteurs récursifs de l'arbre des séries. +public static class TotauxSeries +{ + public static IReadOnlyDictionary Calculer( + IReadOnlyList 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(); + var enCours = new HashSet(); + + (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; + } +} diff --git a/MaBibli.Tests/ServiceSeriesTests.cs b/MaBibli.Tests/ServiceSeriesTests.cs index 5710843..8ee2450 100644 --- a/MaBibli.Tests/ServiceSeriesTests.cs +++ b/MaBibli.Tests/ServiceSeriesTests.cs @@ -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() { diff --git a/MaBibli.Tests/TotauxSeriesTests.cs b/MaBibli.Tests/TotauxSeriesTests.cs new file mode 100644 index 0000000..ea5fe79 --- /dev/null +++ b/MaBibli.Tests/TotauxSeriesTests.cs @@ -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]); + } +}