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
{
-
+ @*
+ 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. *@
+
- @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
{