diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index bbdf50b..13f37cd 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -31,6 +31,7 @@ public static class LivresEndpoints TypeDocument? typeDocument, Statut? statut, int? auteurId, + bool? prete, IServiceCatalogue service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) => @@ -42,13 +43,14 @@ public static class LivresEndpoints TypeDocument = typeDocument, Statut = statut, AuteurId = auteurId, + Prete = prete, }; var utilisateur = utilisateurs.Obtenir().Identifiant; return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct)); }) .WithName("ListerLivres") - .WithSummary("Liste le catalogue, filtrable par format, type de document, statut et auteur ; cherchable sur titre et auteurs.") + .WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; cherchable sur titre et auteurs.") .WithDescription( "Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. " diff --git a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs index 923b9f0..1697a36 100644 --- a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs @@ -84,6 +84,13 @@ public static class ExportSouhaits sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); + // ⚠️ Le groupement par auteur DÉTRUIT l'ordre d'envie : c'est le prix, assumé, d'une + // liste rangée comme une librairie. Le rang est donc réinscrit devant chaque titre, + // faute de quoi le fichier emporté ne dit plus par quoi commencer — précisément + // l'information que l'écran de réordonnancement sert à établir. + var rangs = Rangs(souhaits); + sortie.Append("(le chiffre entre crochets est votre ordre d'envie, 1 = le plus désiré)\n"); + // « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins // exploitables en rayon, elles ne doivent pas ouvrir le fichier. var groupes = souhaits @@ -97,7 +104,7 @@ public static class ExportSouhaits foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase)) { - sortie.Append(" - ").Append(souhait.Titre); + sortie.Append(" - [").Append(rangs[souhait.Id]).Append("] ").Append(souhait.Titre); // Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on // épelle à un libraire, et ça reste lisible sur un écran de téléphone. @@ -127,12 +134,18 @@ public static class ExportSouhaits { var sortie = new StringBuilder(); - Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le"); + // Le rang ouvre le tableau : c'est la colonne sur laquelle on trie dans un tableur, et + // celle qui répond à « par quoi commence-t-on ? » quand on se répartit des achats. + // Le CSV suit déjà cet ordre, mais un ordre implicite se perd au premier tri par titre. + Ligne(sortie, "Rang", "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le"); + + var rangs = Rangs(souhaits); foreach (var souhait in souhaits) { Ligne( sortie, + rangs[souhait.Id].ToString(CultureInfo.InvariantCulture), souhait.Titre, souhait.Auteur, souhait.Editeur, @@ -145,6 +158,18 @@ public static class ExportSouhaits return sortie.ToString(); } + /// + /// Rang affiché de chaque envie, à partir de 1. + /// + /// + /// Déduit de la position dans la liste reçue, et non lu sur une propriété du DTO : + /// ListerAsync trie déjà par Rang, et la colonne en base peut comporter des + /// trous (une suppression ne renumérote pas). Ce qu'on veut imprimer est « troisième de ma + /// liste », pas la valeur brute stockée. + /// + private static Dictionary Rangs(IReadOnlyList souhaits) => + souhaits.Select((s, i) => (s.Id, Rang: i + 1)).ToDictionary(x => x.Id, x => x.Rang); + /// /// ISBN découpé pour la ligne texte : c'est un numéro qu'on épelle à un libraire, /// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres. diff --git a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs index 8d569dd..f6b6b3b 100644 --- a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs @@ -330,7 +330,21 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits Editeur = souhait.Editeur, Annee = souhait.Annee, Isbn = souhait.Isbn, - CoverUrl = souhait.CoverUrl, + + // ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie. + // + // Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le + // composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie » + // remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie + // d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais + // aucune image. + // + // Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour + // les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN, + // pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on + // applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà. + CoverUrl = souhait.CoverUrl + ?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)), Note = souhait.Note, DateAjout = souhait.DateAjout, Possede = livreId is not null, diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index 88e1564..81515a5 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -67,21 +67,71 @@ else if (_auteurs.Count == 0) } else { -

@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")

+ @* + Recherche et tri d'une seule pièce : la question qu'on se pose devant une liste + d'auteurs est soit « où est untel ? » (recherche), soit « qui ai-je le plus ? » (tri). + + ⚠️ Tout se fait en mémoire, sans nouvel appel : la liste des auteurs est déjà + entièrement chargée — c'est elle qui sert l'instantané hors-ligne — et la filtrer + côté serveur aurait rendu la page inutilisable sans réseau. La normalisation passe + par NormalisationTexte, comme partout : « emile » retrouve « Émile ». + *@ +
+ +
+ +
+ + +
+ + @* Le compteur dit ce qu'on regarde : sur une liste filtrée, afficher le total ferait + croire que la recherche n'a rien fait. *@ +

+ @Affiches.Count auteur@(Affiches.Count > 1 ? "s" : "") + @if (Affiches.Count != _auteurs.Count) + { + sur @_auteurs.Count + } +

+ + @if (Affiches.Count == 0) + { +

Aucun auteur ne correspond à « @_recherche ».

+ }
    - @foreach (var auteur in _auteurs) + @foreach (var auteur in Affiches) { + @* + Trois colonnes, dont une seule est élastique. + + Le nombre de livres passe à GAUCHE du nom, sur une largeur figée de trois + chiffres : les compteurs s'alignent alors les uns sous les autres et se + comparent d'un coup d'œil. Trois chiffres suffisent — personne ne possède + mille livres d'un même auteur. Les actions se rangent tout à droite, elles + aussi sur une largeur constante, pour que seul le nom absorbe la place + restante au lieu de décaler les boutons ligne après ligne. + *@
  • - @auteur.Nom - @Livres(auteur.NombreDeLivres) + + @auteur.NombreDeLivres + @Livres(auteur.NombreDeLivres) + + + @auteur.Nom + + @* Le declencheur decrit dans IDEES.md : depuis un auteur qu'on a deja, voir tout ce qu'il a ecrit et marquer ce qui manque. *@ Bibliographie @if (_auteurRenomme == auteur.Id) { - + } +
  • }
@@ -127,7 +178,19 @@ else } @code { + /// Les deux façons de trier une liste d'auteurs, et il n'y en a pas de troisième. + private enum TriAuteurs + { + /// Ordre alphabétique : « où est untel ? ». + Nom = 0, + + /// Du plus présent au moins présent : « qui ai-je le plus ? ». + NombreDeLivres = 1, + } + private IReadOnlyList? _auteurs; + private string _recherche = string.Empty; + private TriAuteurs _tri = TriAuteurs.Nom; private IReadOnlyList? _suggestions; private bool _enCours; private bool _renommageEnCours; @@ -153,6 +216,47 @@ else private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}"; + private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty; + + /// + /// La liste telle qu'elle s'affiche : filtrée par la recherche, puis triée. + /// + /// + /// La recherche est une sous-chaîne du nom normalisé, comme celle du catalogue : + /// « zola » retrouve « Émile Zola », et « emile » aussi. Elle ne cherche pas mot à mot, + /// contrairement au rapprochement d'auteurs, qui répond à une autre question — « ces deux + /// noms désignent-ils la même personne ? » — et n'a rien à faire ici. + /// + /// Le tri par nom est ordinal sur la forme normalisée, pour ne pas dépendre de la + /// culture du navigateur : c'est la même règle que FiltreLivresLocal, et elle évite + /// qu'un appareil classe « Éluard » avant « Duras » et un autre après. + /// + /// + private IReadOnlyList Affiches + { + get + { + if (_auteurs is null) + { + return []; + } + + var terme = NormalisationTexte.Normaliser(_recherche); + var retenus = string.IsNullOrEmpty(terme) + ? _auteurs + : _auteurs.Where(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme)).ToList(); + + return _tri == TriAuteurs.NombreDeLivres + ? retenus + .OrderByDescending(a => a.NombreDeLivres) + .ThenBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal) + .ToList() + : retenus + .OrderBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal) + .ToList(); + } + } + private async Task ChargerAsync() { _erreur = null; diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index 752a6a7..77a70e2 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -86,9 +86,33 @@ } + @* + Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran + « Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre + moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle + qu'on se pose avant de promettre un livre à quelqu'un. + + ⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien + n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se + déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des + livres à la maison escamoterait le filtre sans qu'on puisse l'élargir. + *@ + @if (QuelqueChoseEstPrete) + { +
+ + + +
+ } +

Le statut de lecture est le vôtre : filtrer dessus montre votre - lecture, pas celle du foyer. + lecture, pas celle du foyer. Le prêt, lui, est commun.

} @@ -157,6 +181,7 @@ else if (_livres is not null) private Format? _format; private TypeDocument? _type; private Statut? _statut; + private bool? _prete; private bool _chargement; private bool _filtresOuverts; private string? _erreur; @@ -180,6 +205,14 @@ else if (_livres is not null) private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1; + /// + /// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas. + /// + /// Mêmes précautions que les formats : dans le doute, la ligne reste affichée. + private bool? _fondsAvecPret; + + private bool QuelqueChoseEstPrete => _fondsAvecPret is not false; + /// /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». /// @@ -189,14 +222,15 @@ else if (_livres is not null) /// qui serait invisible une fois le panneau refermé. /// private int FiltresActifs => - (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1); + (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1) + + (_prete is null ? 0 : 1); // Une frappe au clavier annule la recherche précédente encore en vol. private CancellationTokenSource _cts = new(); private bool EstFiltre => !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null - || _statut is not null || AuteurId is not null; + || _statut is not null || _prete is not null || AuteurId is not null; /// /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du @@ -245,12 +279,19 @@ else if (_livres is not null) return ChargerAsync(); } + private Task FiltrerPret(bool? prete) + { + _prete = prete; + return ChargerAsync(); + } + /// Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte. private static bool EstSansCritere(CritereLivres criteres) => string.IsNullOrWhiteSpace(criteres.Recherche) && criteres.Format is null && criteres.TypeDocument is null && criteres.Statut is null + && criteres.Prete is null && criteres.AuteurId is null; private async Task ChargerAsync() @@ -271,6 +312,7 @@ else if (_livres is not null) Format = _format, TypeDocument = _type, Statut = _statut, + Prete = _prete, AuteurId = AuteurId, }; @@ -280,6 +322,7 @@ else if (_livres is not null) { _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); _typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet(); + _fondsAvecPret = _livres.Any(l => l.PreteA is not null); } } catch (OperationCanceledException) diff --git a/MaBibli.Client/Services/FiltreLivresLocal.cs b/MaBibli.Client/Services/FiltreLivresLocal.cs index 46a78ce..970c9ee 100644 --- a/MaBibli.Client/Services/FiltreLivresLocal.cs +++ b/MaBibli.Client/Services/FiltreLivresLocal.cs @@ -53,6 +53,14 @@ public static class FiltreLivresLocal livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId)); } + if (criteres.Prete is { } prete) + { + // LivreDto.PreteA porte l'emprunteur du prêt EN COURS, ou null : le serveur a déjà + // écarté les prêts clos. C'est exactement ce que le filtre serveur exprime par + // « une ligne sans date de retour », d'où l'équivalence des deux implémentations. + livres = livres.Where(l => (l.PreteA is not null) == prete); + } + var terme = NormalisationTexte.Normaliser(criteres.Recherche); if (terme.Length > 0) { diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 1d7ad7a..02f3d0f 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -95,6 +95,12 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat parametres.Add($"auteurId={auteurId}"); } + if (criteres.Prete is { } prete) + { + // Minuscules : c'est ce que le liage de ASP.NET Core attend d'un bool?. + parametres.Add($"prete={(prete ? "true" : "false")}"); + } + var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync>(url, Json, ct)); diff --git a/MaBibli.Client/_Imports.razor b/MaBibli.Client/_Imports.razor index 882ce93..eb6938d 100644 --- a/MaBibli.Client/_Imports.razor +++ b/MaBibli.Client/_Imports.razor @@ -14,3 +14,4 @@ @using MaBibli.Shared.Dtos @using MaBibli.Shared.Entites @using MaBibli.Shared.Isbn +@using MaBibli.Shared.Textes diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 3058c64..6d76691 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -248,6 +248,10 @@ body { /* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */ +/* Le segment « Prêtés » reprend la couleur de l'étiquette de prêt, comme les segments de + statut reprennent celle de leur pastille : le filtre et la carte doivent se répondre. */ +.segment.segment-prete.segment-actif { background: #8a5a00; border-color: #8a5a00; } + .segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; } .segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; } @@ -739,12 +743,50 @@ body { display: flex; gap: 0.5rem; align-items: center; - justify-content: space-between; min-height: 2.75rem; padding: 0.4rem 0; border-bottom: 1px solid var(--mb-bord); } +/* + Trois colonnes, dont une seule est élastique. + + ⚠️ C'est la largeur FIGÉE des deux colonnes extrêmes qui fait tout l'intérêt : sans elle, + chaque ligne plaçait son compteur et ses boutons à un endroit différent, au gré de la + longueur du nom, et l'œil devait relire chaque ligne entière. Trois chiffres suffisent au + compteur — personne ne possède mille livres d'un même auteur. +*/ +.auteur-compte { + flex: 0 0 2.5rem; + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 0.85rem; + font-weight: 600; + color: var(--mb-texte-doux); +} + +.auteur-nom { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.auteur-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.4rem; +} + +/* Le champ de renommage s'insère dans la colonne d'actions : il doit pouvoir rétrécir, sinon + il pousse « Enregistrer » hors de l'écran sur un téléphone. */ +.champ-auteur-renomme { + min-width: 0; + max-width: 10rem; +} + .rapprochements { margin-bottom: 1.5rem; padding: 0.75rem; diff --git a/MaBibli.Shared/Catalogue/CritereLivres.cs b/MaBibli.Shared/Catalogue/CritereLivres.cs index 8c62199..b5c020d 100644 --- a/MaBibli.Shared/Catalogue/CritereLivres.cs +++ b/MaBibli.Shared/Catalogue/CritereLivres.cs @@ -46,4 +46,20 @@ public record CritereLivres /// Ne garder que les livres d'un auteur — le « cliquer sur un auteur » d'IDEES.md. public int? AuteurId { get; init; } + + /// + /// true = ce qui est dehors, false = ce qui est à la maison, null = tout. + /// + /// + /// ⚠️ Contrairement à , ce critère est commun au foyer : un prêt + /// n'appartient à personne en particulier, et aucune identité n'entre dans son évaluation. + /// C'est la même dissymétrie que partout ailleurs — le livre absent l'est pour tout le monde, + /// sa lecture ne l'est que pour soi. + /// + /// L'écran « Prêts » répond déjà à « qu'est-ce qui est dehors ? ». Ce critère-ci répond à + /// l'autre moitié de la question, celle qu'aucun écran ne servait : « qu'est-ce que j'ai + /// réellement sous la main ? », par exemple avant de promettre un livre à quelqu'un. + /// + /// + public bool? Prete { get; init; } } diff --git a/MaBibli.Shared/Catalogue/FiltreLivres.cs b/MaBibli.Shared/Catalogue/FiltreLivres.cs index 8befa00..40020aa 100644 --- a/MaBibli.Shared/Catalogue/FiltreLivres.cs +++ b/MaBibli.Shared/Catalogue/FiltreLivres.cs @@ -54,6 +54,16 @@ public static class FiltreLivres source = source.Where(l => l.Auteurs.Any(la => la.AuteurId == auteurId)); } + if (criteres.Prete is { } prete) + { + // Un prêt EN COURS est une ligne sans date de retour : l'historique clos ne compte + // pas. Aucune identité n'intervient — le prêt est commun au foyer, contrairement au + // statut de lecture juste au-dessus. + source = prete + ? source.Where(l => l.Prets.Any(p => p.DateRetour == null)) + : source.Where(l => !l.Prets.Any(p => p.DateRetour == null)); + } + var terme = NormalisationTexte.Normaliser(criteres.Recherche); if (terme.Length > 0) { diff --git a/MaBibli.Tests/ExportSouhaitsTests.cs b/MaBibli.Tests/ExportSouhaitsTests.cs index b5bcac7..2a7d453 100644 --- a/MaBibli.Tests/ExportSouhaitsTests.cs +++ b/MaBibli.Tests/ExportSouhaitsTests.cs @@ -46,8 +46,10 @@ public class ExportSouhaitsTests Assert.Single(lignes, l => l == "Bernard Werber"); var debutWerber = Array.IndexOf(lignes, "Bernard Werber"); - Assert.Equal(" - La prophétie des abeilles", lignes[debutWerber + 1]); - Assert.Equal(" - Les fourmis", lignes[debutWerber + 2]); + // Le crochet porte le rang d'envie, que le groupement par auteur ferait sinon + // disparaître : la liste s'emporte pour savoir par quoi commencer. + Assert.Equal(" - [1] La prophétie des abeilles", lignes[debutWerber + 1]); + Assert.Equal(" - [3] Les fourmis", lignes[debutWerber + 2]); // Werber avant Zola : les auteurs sont classés alphabétiquement. Assert.True(debutWerber < Array.IndexOf(lignes, "Émile Zola")); @@ -75,7 +77,7 @@ public class ExportSouhaitsTests // ISBN découpé : c'est un numéro qu'on épelle à un libraire. La valeur passée est nue // (« 2253004227 »), les tirets n'existent qu'à la sortie. - Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte); + Assert.Contains(" - [1] Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte); Assert.Contains(" en poche seulement", texte); } @@ -109,8 +111,8 @@ public class ExportSouhaitsTests var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); - Assert.Equal("Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]); - Assert.Equal("Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]); + Assert.Equal("Rang;Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]); + Assert.Equal("1;Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]); } /// @@ -125,11 +127,35 @@ public class ExportSouhaitsTests var ligne = csv.Split("\r\n")[1]; - Assert.StartsWith("\"Titre; piégé\";", ligne); + Assert.StartsWith("1;\"Titre; piégé\";", ligne); Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne); - // Sept colonnes une fois l'échappement respecté, pas huit. - Assert.Equal(7, ColonnesCsv(ligne).Count); + // Huit colonnes une fois l'échappement respecté, pas neuf. + Assert.Equal(8, ColonnesCsv(ligne).Count); + } + + /// + /// Le rang imprimé est la position dans la liste reçue, qui vient déjà triée par + /// Rang. Le texte la regroupe par auteur, ce qui casse l'ordre à l'affichage : le + /// crochet est alors la seule trace de « par quoi commencer ». + /// + [Fact] + public void Le_rang_suit_lordre_recu_et_non_lalphabet() + { + IReadOnlyList liste = + [ + Souhait(7, "Zorro", "Émile Zola"), + Souhait(3, "Abécédaire", "Émile Zola"), + ]; + + var texte = ExportSouhaits.Texte(liste, null, Jour); + Assert.Contains(" - [1] Zorro", texte); + Assert.Contains(" - [2] Abécédaire", texte); + + // Le CSV, lui, garde l'ordre : le rang y est croissant ligne après ligne. + var lignes = ExportSouhaits.Csv(liste).Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.StartsWith("1;Zorro;", lignes[1]); + Assert.StartsWith("2;Abécédaire;", lignes[2]); } [Fact] diff --git a/MaBibli.Tests/FiltreLivresLocalTests.cs b/MaBibli.Tests/FiltreLivresLocalTests.cs index 8ca7e16..25818fb 100644 --- a/MaBibli.Tests/FiltreLivresLocalTests.cs +++ b/MaBibli.Tests/FiltreLivresLocalTests.cs @@ -32,7 +32,8 @@ public class FiltreLivresLocalTests private static Livre Livre( int id, string titre, Auteur? auteur, Format format, Statut? statut, - TypeDocument type = TypeDocument.NonPrecise) + TypeDocument type = TypeDocument.NonPrecise, + string? preteA = null, bool pretClos = false) { var livre = new Livre { @@ -50,6 +51,20 @@ public class FiltreLivresLocalTests livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = Lecteur, Statut = valeur }); } + if (preteA is not null) + { + // ⚠️ Un prêt CLOS ne doit pas compter comme « dehors ». C'est le point où les deux + // implémentations pourraient diverger sans qu'on s'en aperçoive : le serveur regarde + // DateRetour, le client un PreteA que le serveur a déjà résolu. + livre.Prets.Add(new Pret + { + LivreId = id, + Emprunteur = preteA, + DatePret = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DateRetour = pretClos ? new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc) : null, + }); + } + return livre; } @@ -57,8 +72,9 @@ public class FiltreLivresLocalTests [ Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman), Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee), - Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman), - Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null), + Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman, + preteA: "Marie"), + Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null, preteA: "Paul", pretClos: true), Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), ]; @@ -76,6 +92,9 @@ public class FiltreLivresLocalTests TypeDocument = l.TypeDocument, DateAjout = DateTime.UtcNow, Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut, + + // Ce que l'API expose : l'emprunteur du prêt EN COURS, rien de l'historique clos. + PreteA = l.Prets.FirstOrDefault(p => p.DateRetour is null)?.Emprunteur, Auteurs = l.Auteurs .Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom }) .ToList(), @@ -104,6 +123,10 @@ public class FiltreLivresLocalTests new CritereLivres { AuteurId = 1 }, new CritereLivres { AuteurId = 2 }, new CritereLivres { AuteurId = 99 }, + new CritereLivres { Prete = true }, + new CritereLivres { Prete = false }, + new CritereLivres { Prete = false, Format = Format.Physique }, + new CritereLivres { Prete = true, Recherche = "horla" }, new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire }, new CritereLivres {