}
+ @*
+ Le champ est focalisé à l'ouverture et valide sur Entrée : c'est tout ce qu'exige une
+ douchette USB, qui se présente au système comme un CLAVIER — elle « tape » les chiffres
+ puis Entrée. Aucune permission, aucun HTTPS, aucun décodage : c'est le contournement le
+ plus rentable du scan caméra, qui rate souvent sur la webcam d'un PC.
+ La recherche n'est délibérément PAS déclenchée à chaque frappe : une douchette tape trop
+ vite, et chaque caractère partirait en requête.
+ *@
@@ -51,6 +61,18 @@
}
+@*
+ Le scan enchaîne la recherche tout seul (décision actée). Encore faut-il que ça SE VOIE :
+ sans cet écran, on retombait sur la saisie avec un bouton « Chercher » intact, et l'on
+ croyait que le scan n'avait rien déclenché.
+*@
+@if (_etape == Etape.Recherche)
+{
+
+ Recherche de @FormatageIsbn.Afficher(_isbn) à la BnF, puis chez OpenLibrary…
+
+}
+
@foreach (var avertissement in _avertissements)
{
@avertissement
@@ -98,7 +125,7 @@
@candidat.Source
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
- trouvé via @candidat.IsbnInterroge
+ trouvé via @FormatageIsbn.Afficher(candidat.IsbnInterroge)
}
@@ -132,10 +159,17 @@
}
@code {
- private enum Etape { Saisie, Scan, Choix, Formulaire }
+ private enum Etape { Saisie, Scan, Recherche, Choix, Formulaire }
private Etape _etape = Etape.Saisie;
private string _isbn = string.Empty;
+ private ElementReference _champIsbn;
+
+ // Le focus ne se redonne qu'une fois par entrée dans l'étape de saisie : le reprendre à
+ // chaque rendu arracherait le curseur à l'utilisateur en pleine frappe.
+ private bool _focusAFaire = true;
+
+ private string? _messagePeriodique;
private bool _chargement;
private bool _enregistrement;
private string? _erreur;
@@ -146,6 +180,33 @@
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
+ ///
+ /// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
+ ///
+ ///
+ /// C'est ce qui rend une douchette USB utilisable sans rien d'autre : elle tape dans
+ /// le champ actif, quel qu'il soit. Sans focus automatique, l'utilisateur devrait cliquer
+ /// dans le champ avant chaque livre — et le gain sur le scan caméra disparaîtrait.
+ ///
+ protected override async Task OnAfterRenderAsync(bool premierRendu)
+ {
+ if (_etape != Etape.Saisie || !_focusAFaire)
+ {
+ return;
+ }
+
+ _focusAFaire = false;
+
+ try
+ {
+ await _champIsbn.FocusAsync();
+ }
+ catch (InvalidOperationException)
+ {
+ // L'élément a disparu entre le rendu et l'appel (navigation rapide) : sans intérêt.
+ }
+ }
+
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
@@ -156,16 +217,17 @@
private void OuvrirScanner()
{
_erreur = null;
+ _messagePeriodique = null;
_avertissements = [];
_etape = Etape.Scan;
}
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
- // ne retape jamais ce qui vient d'être scanné.
+ // ne retape jamais ce qui vient d'être scanné, et n'a aucun bouton à confirmer.
+ // Le code lu reste dans le champ : si le décodage était mauvais, il se corrige et se relance.
private async Task SurCodeDetecteAsync(string isbn)
{
_isbn = isbn;
- _etape = Etape.Saisie;
await ChercherAsync();
}
@@ -181,7 +243,9 @@
{
_chargement = true;
_erreur = null;
+ _messagePeriodique = null;
_avertissements = [];
+ _etape = Etape.Recherche;
try
{
@@ -190,12 +254,21 @@
if (resultat is null)
{
_erreur = $"« {_isbn} » n'est pas un ISBN valide.";
+ RevenirALaSaisie();
return;
}
_avertissements = resultat.Avertissements;
_candidats = resultat.Candidats;
+ // Un code de périodique (977) n'a JAMAIS de candidat : ce n'est pas un échec de
+ // recherche, c'est un code qui ne décrit pas un livre. Le dire avant tout le reste.
+ if (resultat.Periodique is { } revue)
+ {
+ DecrireLeMagazine(revue);
+ return;
+ }
+
if (_candidats.Count == 0)
{
// Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie.
@@ -213,9 +286,12 @@
_etape = Etape.Choix;
}
- catch (Exception ex)
+ catch (Exception)
{
- _erreur = $"La recherche a échoué : {ex.Message}";
+ _erreur = Reseau.EnLigne
+ ? "La recherche a échoué. Réessayez, ou saisissez la fiche à la main."
+ : "La recherche par ISBN interroge la BnF et OpenLibrary : indisponible hors ligne.";
+ RevenirALaSaisie();
}
finally
{
@@ -223,6 +299,43 @@
}
}
+ ///
+ /// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
+ ///
+ ///
+ /// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est
+ /// pré-rempli, mais pas l'ISBN — le code-barres d'une revue n'en est pas un, et le
+ /// ranger dans Livre.Isbn ferait échouer tout lookup ultérieur sur cette fiche.
+ ///
+ private void DecrireLeMagazine(PeriodiqueDetecte revue)
+ {
+ var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
+
+ _messagePeriodique =
+ $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : "
+ + "il commence par 977, réservé aux publications en série. "
+ + "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, "
+ + "sans ISBN.";
+
+ _saisie = new EnregistrementLivre
+ {
+ Titre = revue.Titre ?? string.Empty,
+ Editeur = revue.Editeur,
+ Format = Format.Physique,
+ Statut = Statut.ALire,
+ };
+
+ _erreur = null;
+ _etape = Etape.Formulaire;
+ }
+
+ /// Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.
+ private void RevenirALaSaisie()
+ {
+ _etape = Etape.Saisie;
+ _focusAFaire = true;
+ }
+
private void Choisir(CandidatLivre candidat)
{
_saisie = new EnregistrementLivre
@@ -242,10 +355,11 @@
private void Recommencer()
{
- _etape = Etape.Saisie;
_erreur = null;
+ _messagePeriodique = null;
_erreurFormulaire = null;
_candidats = [];
+ RevenirALaSaisie();
}
private async Task EnregistrerAsync()
diff --git a/MaBibli.Client/Pages/AjoutSouhait.razor b/MaBibli.Client/Pages/AjoutSouhait.razor
new file mode 100644
index 0000000..e3264d2
--- /dev/null
+++ b/MaBibli.Client/Pages/AjoutSouhait.razor
@@ -0,0 +1,336 @@
+@page "/souhaits/ajout"
+@inject ServiceLivresApi Api
+@inject EtatReseau Reseau
+@inject NavigationManager Navigation
+@implements IDisposable
+
+@*
+ Écran séparé, comme la fiche livre l'est de son édition : la page « Envies » ne montre plus
+ que la liste, et l'ajout a la place de proposer une vraie recherche.
+
+ Trois façons d'alimenter la liste, dans l'ordre où le besoin se présente :
+ — par titre/auteur, le cas NORMAL : on souhaite un livre qu'on n'a pas en main ;
+ — par ISBN, quand on l'a en main (en librairie, chez quelqu'un) ;
+ — à la main, quand aucune source ne connaît le livre.
+*@
+
+MaBibli — ajouter une envie
+
+
Ajouter une envie
+
+@if (!Reseau.EnLigne)
+{
+
+ Hors ligne : la recherche interroge la BnF et OpenLibrary, et l'enregistrement passe par
+ le serveur. Votre liste reste consultable.
+
+}
+
+
+
+
+
+
+
+@if (_mode == Mode.Titre)
+{
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+@if (_mode == Mode.Isbn)
+{
+
+
+
+
Le livre est en main : le code-barres suffit.
+
+
+
+
+
+}
+
+@if (_erreur is not null)
+{
+
@_erreur
+}
+
+@foreach (var avertissement in _avertissements)
+{
+
@avertissement
+}
+
+@if (_candidats.Count > 0)
+{
+ @* On ne choisit jamais à la place de l'utilisateur : ce sont l'éditeur, l'année et
+ l'auteur qui départagent des notices au titre identique. *@
+
+ @_candidats.Count notice@(_candidats.Count > 1 ? "s" : "") trouvée@(_candidats.Count > 1 ? "s" : "").
+ Choisissez celle qui correspond, ou complétez à la main.
+
+ ISBN @FormatageIsbn.Afficher(candidat.IsbnInterroge)
+
+ }
+
+
+
+
+ }
+
+}
+
+@if (_mode == Mode.Manuel)
+{
+ @* Le recours quand aucune source ne connaît le livre — un ouvrage étranger, un titre à
+ paraître, un livre d'occasion sans ISBN. Il ne disparaît jamais derrière la recherche. *@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+
+
+@code {
+ private enum Mode { Titre, Isbn, Manuel }
+
+ private Mode _mode = Mode.Titre;
+ private ElementReference _premierChamp;
+ private bool _focusAFaire = true;
+
+ private string _titreCherche = string.Empty;
+ private string _auteurCherche = string.Empty;
+ private string _isbn = string.Empty;
+
+ private EnregistrementSouhait _saisie = new();
+ private IReadOnlyList _candidats = [];
+ private IReadOnlyList _avertissements = [];
+ private bool _recherche;
+ private bool _ajout;
+ private string? _erreur;
+
+ protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
+
+ private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
+
+ public void Dispose() => Reseau.Change -= SurChangementReseau;
+
+ private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
+
+ ///
+ /// Focalise le premier champ à l'ouverture et à chaque changement de mode.
+ ///
+ ///
+ /// Même raison qu'à l'écran d'ajout par ISBN : on arrive ici pour taper, et le mode « Par
+ /// ISBN » doit rester utilisable à la douchette. Le drapeau évite de reprendre le focus à
+ /// chaque rendu, ce qui arracherait le curseur en pleine frappe.
+ ///
+ protected override async Task OnAfterRenderAsync(bool premierRendu)
+ {
+ if (!_focusAFaire)
+ {
+ return;
+ }
+
+ _focusAFaire = false;
+
+ try
+ {
+ await _premierChamp.FocusAsync();
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ }
+
+ private void Basculer(Mode mode)
+ {
+ _mode = mode;
+ _focusAFaire = true;
+ _erreur = null;
+ _candidats = [];
+ _avertissements = [];
+ }
+
+ private async Task SurTouche(KeyboardEventArgs e)
+ {
+ if (e.Key != "Enter")
+ {
+ return;
+ }
+
+ if (_mode == Mode.Titre && !string.IsNullOrWhiteSpace(_titreCherche))
+ {
+ await ChercherParTitreAsync();
+ }
+ else if (_mode == Mode.Isbn && !string.IsNullOrWhiteSpace(_isbn))
+ {
+ await ChercherParIsbnAsync();
+ }
+ }
+
+ private async Task ChercherParTitreAsync() =>
+ await ChercherAsync(() => Api.ChercherNoticesAsync(_titreCherche, _auteurCherche));
+
+ private async Task ChercherParIsbnAsync() =>
+ await ChercherAsync(() => Api.ChercherIsbnAsync(_isbn.Trim()));
+
+ private async Task ChercherAsync(Func> appel)
+ {
+ _recherche = true;
+ _erreur = null;
+ _candidats = [];
+ _avertissements = [];
+
+ try
+ {
+ var resultat = await appel();
+
+ if (resultat is null)
+ {
+ _erreur = Reseau.EnLigne
+ ? "La recherche n'a rien pu ramener. Réessayez, ou complétez à la main."
+ : "La recherche interroge la BnF : indisponible hors ligne.";
+ return;
+ }
+
+ _avertissements = resultat.Avertissements;
+
+ // Un code de périodique n'est pas un livre : on le dit ici aussi, plutôt que de
+ // laisser un « aucun résultat » qui enverrait chercher un défaut inexistant.
+ if (resultat.Periodique is { } revue)
+ {
+ var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
+ _erreur = $"Ce code désigne {nom} (ISSN {revue.Issn}), pas un livre.";
+ return;
+ }
+
+ _candidats = resultat.Candidats;
+
+ if (_candidats.Count == 0)
+ {
+ _erreur = "Aucune notice ne correspond. Vous pouvez l'ajouter à la main.";
+ }
+ }
+ finally
+ {
+ _recherche = false;
+ }
+ }
+
+ ///
+ /// Enregistre le candidat choisi, couverture comprise.
+ ///
+ ///
+ /// C'est ce qui remplit enfin LivreSouhaite.CoverUrl, jusqu'ici toujours vide : la
+ /// couverture vient d'OpenLibrary dès qu'un ISBN est connu. Sans ISBN il n'y en a pas, et
+ /// l'on n'en invente pas — la vignette retombe sur son substitut à initiale.
+ ///
+ private Task AjouterCandidatAsync(CandidatLivre candidat) =>
+ AjouterAsync(new EnregistrementSouhait
+ {
+ Titre = candidat.Titre,
+ Auteur = candidat.Auteur,
+ Editeur = candidat.Editeur,
+ Annee = candidat.Annee,
+ Isbn = candidat.IsbnInterroge,
+ CoverUrl = candidat.CoverUrl,
+ });
+
+ private async Task AjouterAsync(EnregistrementSouhait saisie)
+ {
+ _ajout = true;
+ _erreur = null;
+
+ var resultat = await Api.AjouterSouhaitAsync(saisie);
+
+ _ajout = false;
+
+ if (!resultat.EstOk)
+ {
+ _erreur = resultat.Erreur;
+ return;
+ }
+
+ // Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
+ Navigation.NavigateTo("souhaits");
+ }
+}
diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor
index 4fa749a..8bfec29 100644
--- a/MaBibli.Client/Pages/Auteurs.razor
+++ b/MaBibli.Client/Pages/Auteurs.razor
@@ -84,11 +84,6 @@ else
}
-
-
@code {
private IReadOnlyList? _auteurs;
private IReadOnlyList? _suggestions;
diff --git a/MaBibli.Client/Pages/Bibliographie.razor b/MaBibli.Client/Pages/Bibliographie.razor
index 571e5a7..6f82d41 100644
--- a/MaBibli.Client/Pages/Bibliographie.razor
+++ b/MaBibli.Client/Pages/Bibliographie.razor
@@ -1,5 +1,6 @@
@page "/auteurs/{AuteurId:int}/bibliographie"
@inject ServiceLivresApi Api
+@inject EtatReseau Reseau
MaBibli — bibliographie
@@ -12,23 +13,72 @@ else if (_erreur is not null)
{
Bibliographie
@_erreur
+
+ @* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait
+ recharger la page entière pour retenter. *@
+ @if (_reessayable)
+ {
+
+
+
+ }
}
else if (_bibliographie is { } biblio)
{
@biblio.Auteur.Nom
- @if (biblio.Avertissement is not null)
- {
-
@biblio.Avertissement
- }
+ @*
+ ⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
- @if (biblio.Oeuvres.Count == 0)
+ Avant correction, une BnF muette produisait une liste vide, que l'écran commentait
+ d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous
+ l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas
+ pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la
+ question au lieu de la laisser ouverte.
+
+ La règle : on n'explique une absence de résultats QUE si la source a répondu.
+ *@
+ @if (biblio.SourceMuette)
{
+
+ @biblio.Avertissement
+
+
+
+ La bibliographie n'a donc pas pu être établie : cet écran ne dit
+ rien de ce que cet auteur a écrit.
+
+
+
+
+
+ }
+ else if (biblio.Oeuvres.Count == 0)
+ {
+ @* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
+ l'expliquer. Le cas est fréquent et n'a rien d'anormal. *@
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
- étrangers : le dépôt légal français ne couvre que l'édition francophone.
+ étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
+ auteur traduit n'y a souvent pas de notice à son nom.
}
+ else if (biblio.Avertissement is not null)
+ {
+ @* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
+ quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@
+
@biblio.Avertissement
+ }
else
{
@* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité,
@@ -150,14 +200,14 @@ else if (_bibliographie is { } biblio)
}
}
-
+}
@code {
[Parameter]
@@ -166,6 +216,11 @@ else if (_bibliographie is { } biblio)
private BibliographieDto? _bibliographie;
private bool _seulementManquants;
private bool _enCours;
+ private bool _chargement;
+
+ /// Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.
+ private bool _reessayable;
+
private string? _erreur;
private string? _message;
@@ -179,6 +234,8 @@ else if (_bibliographie is { } biblio)
private async Task ChargerAsync()
{
_erreur = null;
+ _reessayable = false;
+ _chargement = true;
try
{
@@ -186,15 +243,32 @@ else if (_bibliographie is { } biblio)
if (_bibliographie is null)
{
+ // Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera
+ // pas apparaître.
_erreur = "Cet auteur n'existe pas dans votre bibliothèque.";
return;
}
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
}
- catch (Exception ex)
+ catch (Exception)
{
- _erreur = $"Impossible d'obtenir la bibliographie : {ex.Message}";
+ // ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found »
+ // du service worker que l'utilisateur n'a aucun moyen d'interpréter. La
+ // bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien
+ // à réparer, seulement à dire.
+ //
+ // Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ;
+ // une BnF muette, elle, revient en 200 avec un état, et se traite plus haut.
+ _erreur = Reseau.EnLigne
+ ? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre."
+ : "La bibliographie vient de la BnF : indisponible hors ligne.";
+
+ _reessayable = true;
+ }
+ finally
+ {
+ _chargement = false;
}
}
diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor
index a20f22a..50270f6 100644
--- a/MaBibli.Client/Pages/Catalogue.razor
+++ b/MaBibli.Client/Pages/Catalogue.razor
@@ -5,7 +5,21 @@
MaBibli — catalogue
-
Catalogue
+@*
+ Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments
+ occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change
+ rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
+ et replié serait invisible, et l'on conclurait que des livres ont disparu.
+*@
+
+
Catalogue
+
+
+
@if (_auteur is not null)
{
@@ -21,29 +35,44 @@
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
-
-
-
- @foreach (var format in Enum.GetValues())
+@if (_filtresOuverts)
+{
+
+ @* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien
+ et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les
+ ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@
+ @if (PlusieursFormats)
{
-
+
+
+ @foreach (var format in Enum.GetValues())
+ {
+
+ }
+
}
-
- @* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi
- ne remonte sous aucune valeur, il reste visible sous « Tous ». *@
-
-
- @foreach (var statut in Enum.GetValues())
- {
-
- }
+ @* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut
+ pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran
+ l'assume au lieu de laisser croire à une vue commune. *@
+
+ Le statut de lecture est le vôtre : filtrer dessus montre votre
+ lecture, pas celle du foyer.
+
-
+}
@if (_erreur is not null)
{
@@ -122,25 +151,27 @@ else if (_livres is not null)
}
-@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui
- mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche,
- filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@
+@*
+ Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
+ les onglets, en bas.
+
+ « Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
+ « Ajouter », qui la propose dès qu'un ISBN ne donne rien.
+
+ ⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
+ d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
+ désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
+*@
@if (Reseau.EnLigne)
{
- Ajouter par ISBN
- Saisie manuelle
+ Ajouter un livre
}
else
{
-
+ title="@EtatReseau.MotifHorsLigne">Ajouter un livre
}
- Prêts
- Auteurs
- Envies
@code {
@@ -155,8 +186,33 @@ else if (_livres is not null)
private Format? _format;
private Statut? _statut;
private bool _chargement;
+ private bool _filtresOuverts;
private string? _erreur;
+ ///
+ /// Formats réellement présents dans le fonds, ou null tant qu'on ne le sait pas.
+ ///
+ ///
+ /// ⚠️ Ne se calcule que sur un chargement sans aucun critère. Le déduire d'une liste
+ /// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche
+ /// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute
+ /// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre
+ /// inutile est bénin, en escamoter un qui sert ne l'est pas.
+ ///
+ private IReadOnlySet? _formatsDuFonds;
+
+ private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
+
+ ///
+ /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
+ ///
+ ///
+ /// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient
+ /// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
+ /// qui serait invisible une fois le panneau refermé.
+ ///
+ private int FiltresActifs => (_format is null ? 0 : 1) + (_statut is null ? 0 : 1);
+
// Une frappe au clavier annule la recherche précédente encore en vol.
private CancellationTokenSource _cts = new();
@@ -205,6 +261,13 @@ else if (_livres is not null)
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.Statut is null
+ && criteres.AuteurId is null;
+
private async Task ChargerAsync()
{
_cts.Cancel();
@@ -226,14 +289,21 @@ else if (_livres is not null)
};
_livres = await Api.ListerAsync(criteres, jeton);
+
+ if (EstSansCritere(criteres))
+ {
+ _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
+ }
}
catch (OperationCanceledException)
{
return; // Une recherche plus récente a pris le relais.
}
- catch (Exception ex)
+ catch (Exception)
{
- _erreur = $"Impossible de charger le catalogue : {ex.Message}";
+ _erreur = Reseau.EnLigne
+ ? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant."
+ : "Aucune donnée n'a encore pu être enregistrée sur cet appareil.";
}
finally
{
diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor
index 373c471..f80680d 100644
--- a/MaBibli.Client/Pages/FicheLivre.razor
+++ b/MaBibli.Client/Pages/FicheLivre.razor
@@ -115,9 +115,10 @@ else
Éditeur
@_livre.Editeur
}
+ @* Tirets à l'affichage seulement : la valeur stockée reste nue. *@
@if (!string.IsNullOrWhiteSpace(_livre.Isbn))
{
-
ISBN
@_livre.Isbn
+
ISBN
@FormatageIsbn.Afficher(_livre.Isbn)
}
@* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
@@ -166,7 +167,6 @@ else
}
- Catalogue
}
diff --git a/MaBibli.Client/Pages/Prets.razor b/MaBibli.Client/Pages/Prets.razor
index 7f56552..8c72444 100644
--- a/MaBibli.Client/Pages/Prets.razor
+++ b/MaBibli.Client/Pages/Prets.razor
@@ -73,10 +73,6 @@ else
}
-
- Catalogue
-
-
@code {
private IReadOnlyList? _prets;
private string? _erreur;
diff --git a/MaBibli.Client/Pages/Souhaits.razor b/MaBibli.Client/Pages/Souhaits.razor
index 238c1e4..69a7d5c 100644
--- a/MaBibli.Client/Pages/Souhaits.razor
+++ b/MaBibli.Client/Pages/Souhaits.razor
@@ -1,5 +1,7 @@
@page "/souhaits"
@inject ServiceLivresApi Api
+@inject EtatReseau Reseau
+@implements IDisposable
MaBibli — liste d'envies
@@ -24,56 +26,28 @@
}
else
{
-
-
Ajouter une envie
-
- @* Deux façons d'alimenter la liste : l'ISBN quand on a le livre en main (en librairie,
- chez quelqu'un), la saisie libre quand on n'a qu'un titre. La bibliographie d'un
- auteur, elle, alimente la liste depuis l'écran des auteurs. *@
-
-
-
-
-
-
-
Facultatif : les champs ci-dessous restent modifiables.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@if (_souhaits.Count == 0)
{
+ @* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
+ synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
+ donc les deux plutôt que d'affirmer la première — même règle que pour une fiche
+ absente de l'instantané. *@
- Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un
- auteur depuis la liste des auteurs.
+ @if (Reseau.EnLigne)
+ {
+
+ Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la
+ bibliographie d'un auteur depuis la liste des auteurs.
+
+ }
+ else
+ {
+
+ Aucune envie dans les données enregistrées sur cet appareil. Votre liste est
+ peut-être vide, ou simplement pas encore synchronisée : le retour du réseau
+ tranchera.
+
+ }
}
else
@@ -82,19 +56,85 @@ else
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
part en librairie, elle ne doit pas être au bout d'un défilement. *@
+ @*
+ ⚠️ L'export est produit PAR LE SERVEUR : hors-ligne il n'y a pas de fichier à
+ télécharger, et un lien laissé actif finirait sur une page d'erreur du navigateur.
+ Un lien ne se désactive pas — d'où la bascule vers un bouton inerte qui porte sa
+ raison, comme au catalogue.
+ La consolation est dite juste en dessous : l'usage que servait l'export « .txt »,
+ emporter sa liste en librairie, est désormais couvert par la liste elle-même.
+ *@
+ Les exports sont fabriqués par le serveur : ils reviendront avec le réseau.
+ La liste ci-dessous, elle, est celle enregistrée sur cet appareil — c'est
+ exactement ce qu'on emportait dans le fichier texte.
+
+ }
+
+ @if (_souhaits.Count > 1)
+ {
+
+ Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
+ sur ordinateur, vous pouvez aussi la faire glisser.
+
+ }
+
+ @*
+ ⚠️ Les flèches ne sont PAS un ornement à côté du glisser-déposer : le drag & drop
+ HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
+ que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
+ *@
- @foreach (var souhait in _souhaits)
+ @foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
{
-
@@ -138,93 +179,131 @@ else
}
}
+@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
+ fiche livre ne montre plus son formulaire. *@
- Retour au catalogue
- Auteurs
+ @if (Reseau.EnLigne)
+ {
+ Ajouter une envie
+ }
+ else
+ {
+
+ }
@code {
private IReadOnlyList? _souhaits;
- private EnregistrementSouhait _saisie = new();
- private string _isbn = string.Empty;
- private bool _ajout;
- private bool _recherche;
private bool _enCours;
+ private int? _glisse;
private string? _erreur;
- protected override Task OnInitializedAsync() => ChargerAsync();
+ protected override async Task OnInitializedAsync()
+ {
+ Reseau.Change += SurChangementReseau;
+ await ChargerAsync();
+ }
+ ///
+ /// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
+ ///
+ ///
+ /// On s'abonne à Change et jamais à SynchroChange : recharger écrit un
+ /// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin.
+ ///
+ private void SurChangementReseau() => _ = InvokeAsync(async () =>
+ {
+ await ChargerAsync();
+ StateHasChanged();
+ });
+
+ public void Dispose() => Reseau.Change -= SurChangementReseau;
+
+ /// Raison du blocage des écritures, ou null quand tout est possible.
+ private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
+
+ private const string MotifExport =
+ "L'export est produit par le serveur : indisponible hors ligne.";
+
+ ///
+ /// ⚠️ Aucun message HTTP brut ne doit remonter jusqu'ici. C'était le défaut d'origine :
+ /// hors-ligne, l'écran affichait « net_http_message_not_success_statuscode_reason, 404,
+ /// Not Found ». La liste passe désormais par le même repli sur instantané que le reste de
+ /// l'application, et une panne se dit en français.
+ ///
private async Task ChargerAsync()
{
try
{
+ _erreur = null;
_souhaits = await Api.ListerSouhaitsAsync();
}
- catch (Exception ex)
+ catch (Exception)
{
- _erreur = $"Impossible de charger la liste d'envies : {ex.Message}";
+ _souhaits ??= [];
+ _erreur = Reseau.EnLigne
+ ? "La liste d'envies n'a pas pu être chargée. Réessayez dans un instant."
+ : "Liste d'envies indisponible hors ligne : aucune donnée n'a encore pu être "
+ + "enregistrée sur cet appareil.";
}
}
///
- /// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary).
+ /// Déplace une envie d'une position à une autre, puis enregistre l'ordre complet.
///
///
- /// On retient le premier candidat sans demander de choisir : contrairement à l'ajout au
- /// catalogue, une envie ne fige pas d'édition — l'utilisateur achètera celle qu'il trouvera.
- /// Tous les champs restent modifiables.
+ /// La nouvelle suite est calculée localement et envoyée en entier : le serveur ne
+ /// reçoit pas « monte de un » mais « voici l'ordre ». C'est la même opération pour les
+ /// flèches et pour le glisser-déposer, donc un seul chemin à vérifier.
+ ///
+ /// La liste affichée est réordonnée avant l'aller-retour réseau : un déplacement qui
+ /// attendrait la réponse donnerait l'impression que la flèche n'a pas répondu. En cas
+ /// d'échec, le rechargement remet l'ordre du serveur.
+ ///
///
- private async Task ChercherIsbnAsync()
+ private async Task DeplacerAsync(int depuis, int vers)
{
- _recherche = true;
- _erreur = null;
-
- try
+ if (_souhaits is null || depuis == vers || vers < 0 || vers >= _souhaits.Count)
{
- var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
- var candidat = resultat?.Candidats.FirstOrDefault();
-
- if (candidat is null)
- {
- _erreur = $"Aucune notice trouvée pour « {_isbn.Trim()} ». Complétez à la main.";
- return;
- }
-
- _saisie.Titre = candidat.Titre;
- _saisie.Auteur = string.Join(", ", candidat.Auteurs);
- _saisie.Editeur = candidat.Editeur;
- _saisie.Annee = candidat.Annee;
- _saisie.Isbn = _isbn.Trim();
- _saisie.CoverUrl = candidat.CoverUrl;
- }
- catch (Exception ex)
- {
- _erreur = $"La recherche a échoué : {ex.Message}";
- }
- finally
- {
- _recherche = false;
- }
- }
-
- private async Task AjouterAsync()
- {
- _ajout = true;
- _erreur = null;
-
- var resultat = await Api.AjouterSouhaitAsync(_saisie);
-
- _ajout = false;
-
- if (!resultat.EstOk)
- {
- _erreur = resultat.Erreur;
return;
}
- _saisie = new EnregistrementSouhait();
- _isbn = string.Empty;
- await ChargerAsync();
+ var ordonnee = _souhaits.ToList();
+ var deplacee = ordonnee[depuis];
+ ordonnee.RemoveAt(depuis);
+ ordonnee.Insert(vers, deplacee);
+
+ _souhaits = ordonnee;
+ _enCours = true;
+
+ _erreur = await Api.ReordonnerSouhaitsAsync(ordonnee.Select(s => s.Id).ToList());
+
+ _enCours = false;
+
+ if (_erreur is not null)
+ {
+ // L'ordre affiché n'est pas celui du serveur : on reprend le sien plutôt que de
+ // laisser croire que le déplacement a été enregistré.
+ await ChargerAsync();
+ }
+ }
+
+ /// Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.
+ private async Task DeposerAsync(int cible)
+ {
+ if (_souhaits is null || _glisse is not { } id)
+ {
+ return;
+ }
+
+ var depuis = _souhaits.ToList().FindIndex(s => s.Id == id);
+ _glisse = null;
+
+ if (depuis >= 0)
+ {
+ await DeplacerAsync(depuis, cible);
+ }
}
private async Task SupprimerAsync(SouhaitDto souhait)
diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs
index 572755c..47f57c3 100644
--- a/MaBibli.Client/Services/CacheHorsLigne.cs
+++ b/MaBibli.Client/Services/CacheHorsLigne.cs
@@ -18,6 +18,22 @@ public static class ClesCache
public const string PretsEnCours = "prets-en-cours";
public const string Utilisateur = "utilisateur";
+
+ ///
+ /// La liste d'envies de l'utilisateur courant.
+ ///
+ ///
+ /// ⚠️ Cet instantané est le seul à contenir des données personnelles : le catalogue,
+ /// les auteurs et les prêts sont communs au foyer. Il n'est donc écrit qu'avec ce que le
+ /// serveur a bien voulu rendre à l'appelant — jamais la liste d'un autre, que l'API ne sait
+ /// de toute façon pas produire.
+ ///
+ /// Sa raison d'être : la liste s'emporte en librairie, exactement l'endroit où le
+ /// réseau manque. C'est le même usage que l'export .txt, lequel est produit côté
+ /// serveur et devient donc impossible hors-ligne.
+ ///
+ ///
+ public const string Souhaits = "souhaits";
}
/// Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.
diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs
index 5e0c4e0..32b917a 100644
--- a/MaBibli.Client/Services/ServiceLivresApi.cs
+++ b/MaBibli.Client/Services/ServiceLivresApi.cs
@@ -46,6 +46,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAsync(new CritereLivres(), ct);
await ListerAuteursAsync(ct);
await ListerPretsEnCoursAsync(ct);
+ await ListerSouhaitsAsync(ct);
await ObtenirUtilisateurAsync(ct);
return reseau.EnLigne;
}
@@ -434,6 +435,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAsync(new CritereLivres());
await ListerPretsEnCoursAsync();
await ListerAuteursAsync();
+ await ListerSouhaitsAsync();
}
catch (Exception)
{
@@ -513,20 +515,117 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
/// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait
/// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre.
///
- public async Task> ListerSouhaitsAsync(CancellationToken ct = default) =>
- await http.GetFromJsonAsync>("api/souhaits", Json, ct) ?? [];
-
- public async Task> AjouterSouhaitAsync(
- EnregistrementSouhait saisie, CancellationToken ct = default)
+ public async Task> ListerSouhaitsAsync(CancellationToken ct = default)
{
- var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
- return await InterpreterAsync(reponse, ct);
+ var (ok, souhaits) = await EssayerAsync(
+ () => http.GetFromJsonAsync>("api/souhaits", Json, ct));
+
+ if (ok)
+ {
+ await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []);
+ return souhaits ?? [];
+ }
+
+ var instantane = await LireCacheAsync>(ClesCache.Souhaits);
+ return instantane?.Donnees ?? [];
}
+ public async Task> AjouterSouhaitAsync(
+ EnregistrementSouhait saisie, CancellationToken ct = default) =>
+ await EcrireAsync(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
+
+ ///
+ /// Retire une envie. null si c'est fait, sinon le motif à afficher.
+ ///
+ ///
+ /// ⚠️ Ne pas passer par : la suppression répond
+ /// 204 No Content, et désérialiser un corps vide lèverait une exception là où tout
+ /// s'est bien passé. Même raison que pour la suppression d'un livre, dont ceci reprend
+ /// exactement la forme.
+ ///
public async Task SupprimerSouhaitAsync(int id, CancellationToken ct = default)
{
- var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
- return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
+ if (!reseau.EnLigne)
+ {
+ return EtatReseau.MotifHorsLigne;
+ }
+
+ try
+ {
+ var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
+ reseau.SignalerSuccesReseau();
+ return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
+ }
+ catch (HttpRequestException)
+ {
+ reseau.SignalerEchecReseau();
+ return EtatReseau.MotifHorsLigne;
+ }
+ }
+
+ ///
+ /// Fixe l'ordre de la liste d'envies. null si c'est fait, sinon le motif à afficher.
+ ///
+ ///
+ /// Envoie la liste entière des identifiants : c'est ce que réclame le glisser-déposer,
+ /// et les flèches s'y ramènent. Comme la suppression, elle répond 204 No Content et ne
+ /// passe donc pas par .
+ ///
+ public async Task ReordonnerSouhaitsAsync(
+ IReadOnlyList ids, CancellationToken ct = default)
+ {
+ if (!reseau.EnLigne)
+ {
+ return EtatReseau.MotifHorsLigne;
+ }
+
+ try
+ {
+ var reponse = await http.PutAsJsonAsync("api/souhaits/ordre", ids, Json, ct);
+ reseau.SignalerSuccesReseau();
+
+ if (!reponse.IsSuccessStatusCode)
+ {
+ return await MessageErreurAsync(reponse, ct);
+ }
+
+ // L'instantané porte l'ordre : sans ce rafraîchissement, une coupure juste après un
+ // déplacement rendrait la liste d'avant.
+ _ = RafraichirEnFondAsync();
+ return null;
+ }
+ catch (HttpRequestException)
+ {
+ reseau.SignalerEchecReseau();
+ return EtatReseau.MotifHorsLigne;
+ }
+ }
+
+ ///
+ /// Cherche des notices par titre, éventuellement restreint à un auteur.
+ ///
+ ///
+ /// Exige le réseau, comme le lookup ISBN : rien n'est mis en cache. Sert l'ajout
+ /// d'une envie quand on n'a pas le livre en main — c'est-à-dire le cas normal.
+ ///
+ public async Task ChercherNoticesAsync(
+ string titre, string? auteur, CancellationToken ct = default)
+ {
+ var parametres = $"titre={Uri.EscapeDataString(titre.Trim())}";
+ if (!string.IsNullOrWhiteSpace(auteur))
+ {
+ parametres += $"&auteur={Uri.EscapeDataString(auteur.Trim())}";
+ }
+
+ var (ok, resultat) = await EssayerAsync(async () =>
+ {
+ var reponse = await http.GetAsync($"api/notices?{parametres}", ct);
+ return reponse.IsSuccessStatusCode
+ ? await LireAsync(reponse, ct)
+ : null;
+ });
+
+ return ok ? resultat : null;
}
///
diff --git a/MaBibli.Client/_Imports.razor b/MaBibli.Client/_Imports.razor
index 0ca14fd..882ce93 100644
--- a/MaBibli.Client/_Imports.razor
+++ b/MaBibli.Client/_Imports.razor
@@ -13,3 +13,4 @@
@using MaBibli.Shared.Catalogue
@using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites
+@using MaBibli.Shared.Isbn
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 4ba52be..02a50ea 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -127,6 +127,10 @@ code {
--mb-fond-doux: #f4f5f7;
--mb-texte-doux: #5c6470;
--mb-danger: #a4242c;
+
+ /* Hauteur de la barre d'onglets (MainLayout). Partagée pour que les actions flottantes
+ se posent JUSTE au-dessus : deux barres fixes en bas d'écran se recouvriraient. */
+ --mb-onglets-hauteur: 2.6rem;
}
body {
@@ -291,11 +295,13 @@ body {
margin: 1rem 0;
}
+/* Cette barre ne porte plus que des ACTIONS : la navigation est passée dans les onglets.
+ Elle se pose au-dessus d'eux, jamais dessus. */
.actions-flottantes {
position: fixed;
left: 0;
right: 0;
- bottom: 0;
+ bottom: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom));
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
@@ -867,3 +873,65 @@ body {
font-weight: 600;
cursor: pointer;
}
+
+/* Un ISBN se relit chiffre à chiffre : une chasse fixe évite de confondre 1 et l,
+ 0 et O, et rend les tranches séparées par des tirets alignées d'une ligne à l'autre. */
+.code-isbn {
+ font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
+ white-space: nowrap;
+}
+
+/* Titre d'écran et son bouton de réglages sur la même ligne : le bouton « Filtrer » du
+ catalogue appartient au titre, pas au contenu. */
+.entete-page {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.entete-page .titre-page {
+ margin-bottom: 0.5rem;
+}
+
+/* --- Liste d'envies : ordre choisi --- */
+
+/* Colonne de déplacement à gauche de chaque envie. Les flèches sont larges à dessein :
+ c'est le seul moyen de réordonner au doigt, le glisser-déposer HTML5 n'existant pas
+ sur mobile. */
+.rang-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.15rem;
+ flex: 0 0 auto;
+}
+
+.rang-actions .bouton {
+ padding: 0.15rem 0.5rem;
+ line-height: 1.1;
+}
+
+.rang-numero {
+ font-size: 0.75rem;
+ color: var(--mb-texte-doux);
+ font-variant-numeric: tabular-nums;
+}
+
+/* La carte saisie s'efface pendant le glissé : sans ce repère, on ne sait plus laquelle
+ on déplace une fois le curseur loin de son point de départ. */
+.carte-glissee {
+ opacity: 0.4;
+}
+
+/* Trois manières de chercher, sur une ligne : réutilise l'apparence des segments du
+ catalogue plutôt que d'inventer un troisième style de bascule. */
+.onglets-secondaires {
+ display: flex;
+ gap: 0.25rem;
+ margin: 0.75rem 0;
+}
+
+.onglets-secondaires .segment {
+ flex: 1 1 0;
+}
diff --git a/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs
new file mode 100644
index 0000000..ef31890
--- /dev/null
+++ b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs
@@ -0,0 +1,30 @@
+namespace MaBibli.Shared.Dtos;
+
+///
+/// Ce qu'on sait d'un code-barres qui s'est révélé être celui d'un périodique
+/// (préfixe 977) et non d'un livre.
+///
+///
+/// Le modèle de données ne gère pas les périodiques : ni numéro, ni date de parution.
+/// Cet objet n'existe donc pas pour cataloguer une revue, mais pour dire à l'utilisateur ce
+/// qu'il vient de scanner au lieu de lui répondre « aucun résultat » — ce qui l'enverrait
+/// chercher un défaut là où il n'y en a pas.
+///
+public record PeriodiqueDetecte
+{
+ /// ISSN déduit du code-barres, forme canonique à tiret (2466-6718).
+ public required string Issn { get; init; }
+
+ ///
+ /// Titre de la revue d'après la BnF, ou null si elle ne connaît pas cet ISSN
+ /// ou n'a pas répondu. Le distinguer d'un titre trouvé est ce qui permet de ne pas
+ /// affirmer un nom qu'on n'a pas.
+ ///
+ public string? Titre { get; init; }
+
+ /// Éditeur de la revue, quand la notice en porte un.
+ public string? Editeur { get; init; }
+
+ /// Année de début de publication de la revue (et non du numéro scanné).
+ public string? Annee { get; init; }
+}
diff --git a/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs
index 0cd5af5..8d4c569 100644
--- a/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs
+++ b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs
@@ -23,4 +23,11 @@ public record ResultatLookupIsbn
/// Une source en panne ne fait pas échouer la cascade, elle laisse une trace ici.
///
public IReadOnlyList Avertissements { get; init; } = [];
+
+ ///
+ /// Renseigné uniquement quand le code scanné s'est révélé être celui d'un périodique
+ /// (préfixe 977). Dans ce cas est toujours vide : la cascade
+ /// ISBN n'a pas été lancée, elle ne pouvait pas aboutir.
+ ///
+ public PeriodiqueDetecte? Periodique { get; init; }
}
diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs
index bdac49b..7808d25 100644
--- a/MaBibli.Shared/Dtos/SouhaitDto.cs
+++ b/MaBibli.Shared/Dtos/SouhaitDto.cs
@@ -106,6 +106,34 @@ public record OeuvreBibliographie
public bool ADecouvrir => !Possede && !Souhaite;
}
+///
+/// Ce qu'il est advenu de l'interrogation de la BnF.
+///
+///
+/// ⚠️ Une source muette n'est pas une bibliographie vide, et la différence n'est pas
+/// cosmétique : sans cet état, un délai dépassé produisait une liste vide, que l'écran
+/// commentait d'un rassurant « la BnF ne connaît aucun livre de cet auteur ». On affirmait donc
+/// une chose qu'on n'avait pas pu vérifier. C'est ce que cet énuméré rend impossible.
+///
+/// Le motif est distingué parce qu'il ne demande pas la même chose à l'utilisateur : un délai
+/// dépassé se retente, une réponse illisible non.
+///
+///
+public enum EtatSourceBibliographie
+{
+ /// La BnF a répondu. La liste rendue est ce qu'elle sait, y compris si elle est vide.
+ Ok = 0,
+
+ /// La BnF n'a pas répondu à temps. Cas le plus fréquent, et le plus souvent passager.
+ DelaiDepasse = 1,
+
+ /// La BnF n'a pas pu être jointe, ou a répondu par une erreur.
+ Injoignable = 2,
+
+ /// La BnF a répondu, mais sa réponse n'était pas exploitable.
+ ReponseIllisible = 3,
+}
+
///
/// Bibliographie d'un auteur du catalogue, telle que la BnF la connaît.
///
@@ -134,4 +162,18 @@ public record BibliographieDto
/// Message à afficher quand la source n'a pas pu être interrogée normalement.
public string? Avertissement { get; init; }
+
+ ///
+ /// Ce qu'il est advenu de l'interrogation. ne veut
+ /// pas dire « des résultats », mais « une réponse ».
+ ///
+ public EtatSourceBibliographie Etat { get; init; }
+
+ ///
+ /// Vrai quand la BnF n'a pas répondu : la liste vide ne prouve alors rien.
+ ///
+ ///
+ /// C'est ce que l'interface doit consulter avant d'expliquer une absence de résultats.
+ ///
+ public bool SourceMuette => Etat != EtatSourceBibliographie.Ok;
}
diff --git a/MaBibli.Shared/Entites/LivreSouhaite.cs b/MaBibli.Shared/Entites/LivreSouhaite.cs
index b20fd3e..9d36dcd 100644
--- a/MaBibli.Shared/Entites/LivreSouhaite.cs
+++ b/MaBibli.Shared/Entites/LivreSouhaite.cs
@@ -75,6 +75,20 @@ public class LivreSouhaite
public DateTime DateAjout { get; set; }
+ ///
+ /// Rang dans la liste, du plus désiré (0) au moins désiré.
+ ///
+ ///
+ /// L'ordre est choisi à la main : c'est une préférence, elle ne se calcule pas. Il est
+ /// personnel comme le reste de la table — le rang n'a de sens qu'à l'intérieur de la
+ /// liste d'une personne, et deux utilisateurs numérotent la leur indépendamment.
+ ///
+ /// Une envie nouvelle se pose en fin de liste, jamais en tête : on vient de la noter,
+ /// on n'a pas dit qu'on la voulait plus que les autres. C'est à l'utilisateur de la remonter.
+ ///
+ ///
+ public int Rang { get; set; }
+
public void RecalculerFormes()
{
Titre = Titre.Trim();
diff --git a/MaBibli.Shared/Isbn/CodePeriodique.cs b/MaBibli.Shared/Isbn/CodePeriodique.cs
new file mode 100644
index 0000000..f9e3182
--- /dev/null
+++ b/MaBibli.Shared/Isbn/CodePeriodique.cs
@@ -0,0 +1,62 @@
+using System.Globalization;
+
+namespace MaBibli.Shared.Isbn;
+
+///
+/// Reconnaissance des codes-barres de périodiques et extraction de leur ISSN.
+///
+///
+/// Un livre porte un EAN-13 préfixé 978 ou 979 ; un magazine porte 977,
+/// qui encode un ISSN — c'est-à-dire un titre de revue, pas un ouvrage. Aucune
+/// recherche bib.isbn ne peut donc aboutir sur un tel code, et l'application répondait
+/// « aucun résultat » là où le code lui-même dit qu'on ne cherche pas au bon endroit.
+///
+/// Structure du code, vérifiée sur 9772466671438 le 2026-08-18 :
+/// 977 + les 7 premiers chiffres de l'ISSN (sa clé n'y figure pas, elle est
+/// recalculée) + 2 chiffres de parution + la clé EAN-13. Les deux chiffres de parution
+/// sont la raison pour laquelle un même ISSN couvre tous les numéros d'une revue : ils ne sont
+/// pas exploitables comme numéro d'édition fiable, et ne sont donc pas exposés ici.
+///
+///
+public static class CodePeriodique
+{
+ /// Préfixe EAN-13 réservé aux publications en série.
+ public const string Prefixe = "977";
+
+ ///
+ /// Vrai si (déjà normalisé) est un EAN-13 valide de périodique.
+ ///
+ public static bool EstCodePeriodique(string? ean) =>
+ IsbnUtils.EstIsbn13Valide(ean) && ean!.StartsWith(Prefixe, StringComparison.Ordinal);
+
+ ///
+ /// ISSN porté par le code-barres, sous sa forme canonique à tiret (2466-6718),
+ /// ou null si le code n'est pas celui d'un périodique.
+ ///
+ ///
+ /// La clé de l'ISSN n'est pas dans le code-barres : elle se recalcule (somme pondérée 8→2,
+ /// modulo 11, X si le complément vaut 10). C'est le même principe que la clé ISBN-10,
+ /// sur sept chiffres au lieu de neuf.
+ ///
+ public static string? IssnDepuis(string? ean)
+ {
+ if (!EstCodePeriodique(ean))
+ {
+ return null;
+ }
+
+ // Chiffres 4 à 10 du code : l'ISSN amputé de sa clé.
+ var corps = ean!.Substring(3, 7);
+
+ var somme = 0;
+ for (var i = 0; i < 7; i++)
+ {
+ somme += (corps[i] - '0') * (8 - i);
+ }
+
+ var cle = (11 - (somme % 11)) % 11;
+ var caractereCle = cle == 10 ? "X" : cle.ToString(CultureInfo.InvariantCulture);
+
+ return $"{corps[..4]}-{corps[4..]}{caractereCle}";
+ }
+}
diff --git a/MaBibli.Shared/Isbn/FormatageIsbn.cs b/MaBibli.Shared/Isbn/FormatageIsbn.cs
new file mode 100644
index 0000000..dca5bec
--- /dev/null
+++ b/MaBibli.Shared/Isbn/FormatageIsbn.cs
@@ -0,0 +1,205 @@
+namespace MaBibli.Shared.Isbn;
+
+///
+/// Découpage d'un ISBN en tranches séparées par des tirets, pour l'affichage seulement.
+///
+///
+/// 9782846391009 → 978-2-84639-100-9. La valeur stockée reste nue : ces
+/// tirets ne franchissent jamais la frontière de la base ni celle des requêtes aux sources
+/// (la BnF indexe l'ISBN sans tiret). Le seul point d'entrée est .
+///
+/// Le découpage dépend du groupe linguistique puis de l'éditeur, et les tranches
+/// sont publiées par l'agence internationale — elles ne se devinent pas. Les tables ci-dessous
+/// sont extraites du RangeMessage.xml officiel (isbn-international.org), version
+/// du 2026-08-18.
+///
+///
+/// Principe tenu : on n'affiche jamais une coupure qu'on ne sait pas juste. Le
+/// français (978-2) est découpé en entier, jusqu'à l'éditeur. Ailleurs, on s'arrête au
+/// groupe et à la clé — deux frontières toujours exactes — et l'on ne devine pas la limite
+/// éditeur/publication. Une tranche en moins se lit encore ; une tranche fausse trompe.
+///
+///
+public static class FormatageIsbn
+{
+ /// Longueur du groupe linguistique, d'après les 7 chiffres qui suivent 978.
+ private static readonly (int Debut, int Fin, int Longueur)[] GroupesSous978 =
+ [
+ (0000000, 5999999, 1),
+ (6000000, 6499999, 3),
+ (6500000, 6599999, 2),
+ (6600000, 6699999, 2),
+ (6999000, 6999999, 5),
+ (7000000, 7999999, 1),
+ (8000000, 9499999, 2),
+ (9500000, 9899999, 3),
+ (9900000, 9989999, 4),
+ (9990000, 9999999, 5),
+ ];
+
+ /// Idem sous 979 : seuls deux groupes existent à ce jour.
+ private static readonly (int Debut, int Fin, int Longueur)[] GroupesSous979 =
+ [
+ (1000000, 1599999, 2),
+ (8000000, 8999999, 1),
+ ];
+
+ ///
+ /// Longueur du numéro d'éditeur dans le groupe francophone, d'après les 7 chiffres
+ /// qui suivent 978-2.
+ ///
+ ///
+ /// C'est la seule table d'éditeurs embarquée, et c'est un choix : la collection est
+ /// majoritairement francophone, et les 19 tranches ci-dessous couvrent donc l'essentiel du
+ /// fonds. Embarquer les ~220 groupes du monde entier alourdirait le WASM téléchargé par le
+ /// navigateur pour un gain marginal.
+ ///
+ private static readonly (int Debut, int Fin, int Longueur)[] EditeursFrancophones =
+ [
+ (0000000, 1999999, 2),
+ (2000000, 3499999, 3),
+ (3500000, 3999999, 5),
+ (4000000, 4869999, 3),
+ (4870000, 4949999, 6),
+ (4950000, 4959999, 3),
+ (4960000, 4966999, 4),
+ (4967000, 4969999, 5),
+ (4970000, 5279999, 3),
+ (5280000, 5299999, 4),
+ (5300000, 6999999, 3),
+ (7000000, 8399999, 4),
+ (8400000, 8999999, 5),
+ (9000000, 9197999, 6),
+ (9198000, 9198099, 5),
+ (9198100, 9199429, 6),
+ (9199430, 9199689, 7),
+ (9199690, 9499999, 6),
+ (9500000, 9999999, 7),
+ ];
+
+ private const string GroupeFrancophone = "2";
+
+ ///
+ /// Rend l'ISBN lisible, tirets compris.
+ ///
+ ///
+ /// La forme découpée, ou l'entrée telle quelle si elle n'est pas un ISBN valide — on ne
+ /// découpe jamais ce qu'on n'a pas reconnu, un ISBN mal saisi doit se voir tel qu'il est.
+ ///
+ public static string Afficher(string? brut)
+ {
+ if (string.IsNullOrWhiteSpace(brut))
+ {
+ return string.Empty;
+ }
+
+ var isbn = IsbnUtils.Normaliser(brut);
+ if (!IsbnUtils.EstValide(isbn))
+ {
+ return brut.Trim();
+ }
+
+ var decoupe = isbn!.Length == 13 ? Decouper13(isbn) : Decouper10(isbn);
+ return decoupe ?? isbn;
+ }
+
+ private static string? Decouper13(string isbn)
+ {
+ var prefixe = isbn[..3];
+ var table = prefixe switch
+ {
+ "978" => GroupesSous978,
+ "979" => GroupesSous979,
+
+ // 977 (périodique) et tout autre préfixe EAN ne portent pas d'ISBN : rien à découper.
+ _ => null,
+ };
+
+ if (table is null || !TrouverLongueur(table, isbn.AsSpan(3), out var longueurGroupe))
+ {
+ return null;
+ }
+
+ var groupe = isbn.Substring(3, longueurGroupe);
+ var corps = isbn[(3 + longueurGroupe)..];
+
+ return prefixe + "-" + Assembler(groupe, corps, francophone: prefixe == "978" && groupe == GroupeFrancophone);
+ }
+
+ private static string? Decouper10(string isbn)
+ {
+ // Un ISBN-10 est un ISBN-13 en 978 amputé de son préfixe : les tranches de groupe
+ // sont donc exactement les mêmes.
+ if (!TrouverLongueur(GroupesSous978, isbn.AsSpan(), out var longueurGroupe))
+ {
+ return null;
+ }
+
+ var groupe = isbn[..longueurGroupe];
+ var corps = isbn[longueurGroupe..];
+
+ return Assembler(groupe, corps, francophone: groupe == GroupeFrancophone);
+ }
+
+ ///
+ /// Recolle groupe - [éditeur - publication] - clé à partir du corps de l'ISBN
+ /// (tout ce qui suit le groupe, clé comprise).
+ ///
+ private static string Assembler(string groupe, string corps, bool francophone)
+ {
+ var cle = corps[^1..];
+ var milieu = corps[..^1];
+
+ if (francophone
+ && TrouverLongueur(EditeursFrancophones, corps.AsSpan(), out var longueurEditeur)
+ // Il doit rester au moins un chiffre de publication : sinon la tranche est plus
+ // longue que le corps, et on afficherait une coupure absurde plutôt que rien.
+ && longueurEditeur < milieu.Length)
+ {
+ var editeur = milieu[..longueurEditeur];
+ var publication = milieu[longueurEditeur..];
+ return $"{groupe}-{editeur}-{publication}-{cle}";
+ }
+
+ return $"{groupe}-{milieu}-{cle}";
+ }
+
+ ///
+ /// Cherche la tranche contenant les 7 premiers chiffres de .
+ ///
+ ///
+ /// Les tables de l'agence sont toutes indexées sur 7 chiffres, complétés par des
+ /// zéros quand il en manque — c'est ce que fait le remplissage ci-dessous.
+ ///
+ private static bool TrouverLongueur(
+ (int Debut, int Fin, int Longueur)[] table, ReadOnlySpan chiffres, out int longueur)
+ {
+ longueur = 0;
+
+ var cle = 0;
+ for (var i = 0; i < 7; i++)
+ {
+ // Le X final d'un ISBN-10 n'est jamais dans les 7 premiers chiffres d'un corps
+ // exploitable, mais le vérifier coûte moins cher que d'y croire.
+ var c = i < chiffres.Length ? chiffres[i] : '0';
+ if (!char.IsDigit(c))
+ {
+ return false;
+ }
+
+ cle = (cle * 10) + (c - '0');
+ }
+
+ foreach (var (debut, fin, valeur) in table)
+ {
+ if (cle >= debut && cle <= fin)
+ {
+ longueur = valeur;
+ return true;
+ }
+ }
+
+ // Tranche non attribuée : on ne devine pas.
+ return false;
+ }
+}
diff --git a/MaBibli.Tests/BnfBibliographieParserTests.cs b/MaBibli.Tests/BnfBibliographieParserTests.cs
index 68c2f1d..b5d5eff 100644
--- a/MaBibli.Tests/BnfBibliographieParserTests.cs
+++ b/MaBibli.Tests/BnfBibliographieParserTests.cs
@@ -1,5 +1,7 @@
using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Isbn;
+using MaBibli.Shared.Dtos;
+using MaBibli.Shared.Isbn;
namespace MaBibli.Tests;
@@ -172,4 +174,121 @@ public class BnfBibliographieParserTests
[InlineData(null, null)]
public void Extrait_une_annee_triable_des_dates_BnF(string? date, int? attendu) =>
Assert.Equal(attendu, ServiceBibliographie.AnneeTriable(date));
-}
+
+ // ── Recherche par titre (liste d'envies) ─────────────────────────────────
+
+ ///
+ /// ⚠️ Le filtre de type est aussi indispensable ici qu'en bibliographie. Mesuré sur la
+ /// réponse réelle enregistrée : bib.title all "la bête humaine" croisé avec Zola
+ /// rend 20 notices, dont 12 films de Jean Renoir, un manuscrit et un multimédia.
+ /// Sans filtre, la liste d'envies proposerait d'acheter un DVD.
+ ///
+ [Fact]
+ public void Recherche_par_titre_ecarte_ce_qui_nest_pas_un_livre()
+ {
+ var candidats = BnfBibliographieParser.ParserRecherche(
+ Fixture.Lire("bnf-titre-bete-humaine.xml"));
+
+ // 6 « texte imprimé » sur 20 notices : tout le reste est écarté.
+ Assert.Equal(6, candidats.Count);
+ Assert.All(candidats, c => Assert.Equal(SourceMetadonnees.Bnf, c.Source));
+ Assert.All(candidats, c => Assert.False(string.IsNullOrWhiteSpace(c.Titre)));
+ }
+
+ ///
+ /// La couverture vient de l'ISBN de la notice, la BnF n'en fournissant aucune. Une notice
+ /// sans ISBN — cas courant pour les éditions anciennes — n'en a simplement pas.
+ ///
+ [Fact]
+ public void Recherche_par_titre_remonte_ISBN_et_couverture_quand_la_notice_en_porte_un()
+ {
+ var candidats = BnfBibliographieParser.ParserRecherche(
+ Fixture.Lire("bnf-titre-bete-humaine.xml"));
+
+ var avecIsbn = candidats.Where(c => c.IsbnInterroge is not null).ToList();
+ Assert.NotEmpty(avecIsbn);
+
+ Assert.All(avecIsbn, c =>
+ {
+ Assert.True(IsbnUtils.EstValide(c.IsbnInterroge));
+ Assert.Equal(IsbnUtils.UrlCouverture(c.IsbnInterroge!), c.CoverUrl);
+ });
+
+ // Sans ISBN, pas de couverture inventée.
+ Assert.All(
+ candidats.Where(c => c.IsbnInterroge is null),
+ c => Assert.Null(c.CoverUrl));
+ }
+
+ ///
+ /// Contrairement à la bibliographie, aucun post-filtre sur l'auteur : c'est
+ /// l'utilisateur qui choisit dans la liste, en voyant les auteurs de chaque notice.
+ ///
+ ///
+ /// Les deux notices vérifiées ici sont précisément celles que le post-filtre de
+ /// écarterait, et les garder est le bon choix
+ /// pour une liste d'envies :
+ ///
+ /// la bande dessinée signée Dobbs — une adaptation de « La bête humaine » se
+ /// souhaite parfaitement, et son auteur est affiché, donc rien ne trompe ;
+ /// la notice sans aucun dc:creator, qui n'aurait pu correspondre à
+ /// aucun auteur demandé.
+ ///
+ ///
+ [Fact]
+ public void Recherche_par_titre_ne_post_filtre_pas_lauteur()
+ {
+ var candidats = BnfBibliographieParser.ParserRecherche(
+ Fixture.Lire("bnf-titre-bete-humaine.xml"));
+
+ Assert.Contains(candidats, c => c.Auteurs.Any(a => a.Contains("Dobbs")));
+ Assert.Contains(candidats, c => c.Auteurs.Count == 0);
+ }
+
+ // ── Motifs d'échec lisibles ──────────────────────────────────────────────
+
+ ///
+ /// ⚠️ Ces messages atteignent l'utilisateur. Ils ne doivent porter aucun nom de
+ /// classe .NET : « BnF injoignable (TaskCanceledException) » est ce qui s'affichait, et
+ /// « TaskCanceledException » ne veut rien dire pour qui range ses livres.
+ ///
+ [Theory]
+ [InlineData(EtatSourceBibliographie.DelaiDepasse)]
+ [InlineData(EtatSourceBibliographie.Injoignable)]
+ [InlineData(EtatSourceBibliographie.ReponseIllisible)]
+ public void Le_motif_dechec_est_ecrit_pour_un_humain(EtatSourceBibliographie etat)
+ {
+ var message = MotifEchec(etat);
+
+ Assert.Contains("Robert A. Harper", message);
+ Assert.DoesNotContain("Exception", message, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("Task", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("Http", message, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Le délai dépassé se retente, et le message doit le dire : c'est le seul des trois qui
+ /// appelle une action immédiate de l'utilisateur.
+ ///
+ [Fact]
+ public void Un_delai_depasse_invite_a_reessayer()
+ => Assert.Contains("réessay", MotifEchec(EtatSourceBibliographie.DelaiDepasse),
+ StringComparison.OrdinalIgnoreCase);
+
+ /// Les trois motifs se distinguent : les confondre reviendrait à ne rien dire.
+ [Fact]
+ public void Les_trois_motifs_sont_distincts()
+ {
+ var motifs = new[]
+ {
+ EtatSourceBibliographie.DelaiDepasse,
+ EtatSourceBibliographie.Injoignable,
+ EtatSourceBibliographie.ReponseIllisible,
+ }.Select(MotifEchec).ToList();
+
+ Assert.Equal(motifs.Count, motifs.Distinct().Count());
+ }
+
+ private static string MotifEchec(EtatSourceBibliographie etat) =>
+ BnfClient.MotifDeSourceMuette(etat, "Robert A. Harper");
+}
\ No newline at end of file
diff --git a/MaBibli.Tests/CodePeriodiqueTests.cs b/MaBibli.Tests/CodePeriodiqueTests.cs
new file mode 100644
index 0000000..8203d3a
--- /dev/null
+++ b/MaBibli.Tests/CodePeriodiqueTests.cs
@@ -0,0 +1,183 @@
+using MaBibli.Api.Services.Isbn;
+using MaBibli.Shared.Dtos;
+using MaBibli.Shared.Isbn;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MaBibli.Tests;
+
+///
+/// Codes-barres de périodiques (préfixe 977) : reconnaissance, dérivation de l'ISSN,
+/// et lecture de la notice BnF correspondante.
+///
+public class CodePeriodiqueTests
+{
+ ///
+ /// Le cas remonté en usage réel : ce code renvoyait « aucun résultat », alors qu'il
+ /// n'a jamais pu décrire un livre.
+ ///
+ private const string EanMedor = "9772466671438";
+
+ [Theory]
+ [InlineData(EanMedor, true)]
+ [InlineData("9782070612758", false)] // Le Petit Prince : un vrai livre
+ [InlineData("9791234567896", false)] // préfixe 979, livre lui aussi
+ [InlineData("2253004227", false)] // ISBN-10 : trop court pour porter un préfixe
+ [InlineData("9772466671437", false)] // bon préfixe, clé EAN fausse
+ [InlineData(null, false)]
+ [InlineData("", false)]
+ public void Reconnait_les_codes_de_periodique(string? ean, bool attendu)
+ => Assert.Equal(attendu, CodePeriodique.EstCodePeriodique(ean));
+
+ ///
+ /// La clé de l'ISSN n'est pas dans le code-barres : elle se recalcule. Vérifié
+ /// contre la BnF le 2026-08-18 — l'ISSN 2466-6718 y désigne bien « Médor ».
+ ///
+ [Fact]
+ public void Deduit_lIssn_du_code_barres()
+ => Assert.Equal("2466-6718", CodePeriodique.IssnDepuis(EanMedor));
+
+ [Fact]
+ public void Ne_deduit_rien_dun_code_de_livre()
+ => Assert.Null(CodePeriodique.IssnDepuis("9782070612758"));
+
+ ///
+ /// Les deux chiffres qui précèdent la clé encodent la parution : deux numéros d'une même
+ /// revue doivent donner le même ISSN, sinon chaque magazine créerait une fiche.
+ ///
+ [Fact]
+ public void Deux_parutions_de_la_meme_revue_donnent_le_meme_Issn()
+ {
+ var issns = new[] { "9772466671438", "9772466671391" }
+ .Select(CodePeriodique.IssnDepuis)
+ .ToList();
+
+ Assert.Equal("2466-6718", issns[0]);
+ Assert.Equal(issns[0], issns[1]);
+ }
+
+ [Fact]
+ public void Notice_BnF_de_periodique_est_lue_et_nettoyee()
+ {
+ var revue = BnfSruParser.ParserPeriodique(
+ Fixture.Lire("bnf-issn-2466-6718.xml"), "2466-6718");
+
+ Assert.NotNull(revue);
+
+ // « Médor (Bruxelles) » : le parenthésé est un qualificatif de désambiguïsation,
+ // pas une partie du nom écrit sur la couverture.
+ Assert.Equal("Médor", revue!.Titre);
+ Assert.Equal("2466-6718", revue.Issn);
+ Assert.Equal("2015", revue.Annee);
+ }
+
+ [Fact]
+ public void Reponse_sans_notice_ne_produit_pas_de_titre_invente()
+ {
+ var vide = """
+
+ 0
+
+ """;
+
+ Assert.Null(BnfSruParser.ParserPeriodique(vide, "2466-6718"));
+ }
+
+ // ── La cascade ne doit pas partir du tout ────────────────────────────────
+
+ ///
+ /// Client BnF simulé : compte les appels ISBN pour prouver qu'aucun n'a lieu.
+ ///
+ private sealed class BnfFake(PeriodiqueDetecte? revue) : IBnfClient
+ {
+ public List IsbnInterroges { get; } = [];
+
+ public List IssnInterroges { get; } = [];
+
+ public Task<(IReadOnlyList, string?)> RechercherAsync(
+ string isbn, string? urlCouverture, CancellationToken ct = default)
+ {
+ IsbnInterroges.Add(isbn);
+ return Task.FromResult(((IReadOnlyList)[], (string?)null));
+ }
+
+ public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
+ string auteur, CancellationToken ct = default) =>
+ Task.FromResult((new ResultatBibliographie(), (string?)null));
+
+ public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
+ string issn, CancellationToken ct = default)
+ {
+ IssnInterroges.Add(issn);
+ return Task.FromResult((revue, (string?)null));
+ }
+
+ /// Recherche par titre : couverte par ses propres tests.
+ public Task<(IReadOnlyList, string?)> RechercherParTitreAsync(
+ string titre, string? auteur, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
+ }
+
+ private sealed class OpenLibraryFake : IOpenLibraryClient
+ {
+ public int Appels { get; private set; }
+
+ public Task<(IReadOnlyList, string?)> RechercherAsync(
+ string isbn, string? urlCouverture, CancellationToken ct = default)
+ {
+ Appels++;
+ return Task.FromResult(((IReadOnlyList)[], (string?)null));
+ }
+ }
+
+ ///
+ /// Le point du bug : la cascade s'exécutait en entier pour finir sur « aucun résultat ».
+ /// Elle ne doit désormais pas partir — seul l'ISSN est interrogé.
+ ///
+ [Fact]
+ public async Task Un_code_977_ne_declenche_aucune_recherche_de_livre()
+ {
+ var bnf = new BnfFake(new PeriodiqueDetecte { Issn = "2466-6718", Titre = "Médor" });
+ var ol = new OpenLibraryFake();
+ var service = new IsbnLookupService(bnf, ol, NullLogger.Instance);
+
+ var resultat = await service.RechercherAsync(EanMedor);
+
+ Assert.NotNull(resultat);
+ Assert.Empty(bnf.IsbnInterroges);
+ Assert.Equal(0, ol.Appels);
+ Assert.Equal(["2466-6718"], bnf.IssnInterroges);
+ Assert.Empty(resultat!.Candidats);
+ Assert.Equal("Médor", resultat.Periodique?.Titre);
+ }
+
+ ///
+ /// BnF muette : on connaît quand même l'ISSN, puisqu'il se déduit du code-barres.
+ /// Ne rien renvoyer ferait retomber l'écran sur « aucun résultat », le bug d'origine.
+ ///
+ [Fact]
+ public async Task BnF_muette_laisse_quand_meme_lIssn()
+ {
+ var service = new IsbnLookupService(
+ new BnfFake(null), new OpenLibraryFake(), NullLogger.Instance);
+
+ var resultat = await service.RechercherAsync(EanMedor);
+
+ Assert.Equal("2466-6718", resultat?.Periodique?.Issn);
+ Assert.Null(resultat?.Periodique?.Titre);
+ }
+
+ /// Un livre ordinaire n'est jamais pris pour un périodique.
+ [Fact]
+ public async Task Un_ISBN_de_livre_passe_par_la_cascade_normale()
+ {
+ var bnf = new BnfFake(null);
+ var service = new IsbnLookupService(
+ bnf, new OpenLibraryFake(), NullLogger.Instance);
+
+ var resultat = await service.RechercherAsync("9782070612758");
+
+ Assert.Null(resultat?.Periodique);
+ Assert.Empty(bnf.IssnInterroges);
+ Assert.NotEmpty(bnf.IsbnInterroges);
+ }
+}
diff --git a/MaBibli.Tests/ExportSouhaitsTests.cs b/MaBibli.Tests/ExportSouhaitsTests.cs
index 8c5dcda..b5bcac7 100644
--- a/MaBibli.Tests/ExportSouhaitsTests.cs
+++ b/MaBibli.Tests/ExportSouhaitsTests.cs
@@ -73,10 +73,27 @@ public class ExportSouhaitsTests
null,
Jour);
- Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2253004227)", texte);
+ // 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(" en poche seulement", texte);
}
+ ///
+ /// Le découpage du CSV ne sert pas qu'à la lisibilité : un ISBN nu de treize chiffres est
+ /// lu comme un nombre par Excel, qui l'affiche « 9,78225E+12 » et perd les derniers
+ /// chiffres. Les tirets en font un texte, et le numéro survit à l'ouverture du fichier.
+ ///
+ [Fact]
+ public void Le_csv_decoupe_lIsbn_pour_quExcel_ne_le_prenne_pas_pour_un_nombre()
+ {
+ var csv = ExportSouhaits.Csv(
+ [Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "9782253004226")]);
+
+ Assert.Contains("978-2-253-00422-6", csv);
+ Assert.DoesNotContain("9782253004226", csv);
+ }
+
[Fact]
public void Le_texte_le_dit_quand_la_liste_est_vide()
{
diff --git a/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml b/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml
new file mode 100644
index 0000000..acf04eb
--- /dev/null
+++ b/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml
@@ -0,0 +1,24 @@
+
+1.2
+
+1.2
+bib.issn all "2466-6718"
+
+1
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb45787531cMédor (Bruxelles)Médor : les yeux ouverts / [dir. publ. Laurence Jénard]Médor (Bruxelles)2015ISSN 24666718frepublication en série impriméeprinted serialtextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb45787531c
+1
+
+20190820
+20210416
+8.210089
+
+
+
+
diff --git a/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml b/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml
new file mode 100644
index 0000000..f655e8b
--- /dev/null
+++ b/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml
@@ -0,0 +1,291 @@
+
+1.2
+
+1.2
+bib.title all "la bete humaine" and bib.author all "zola"
+
+116
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb39770395mLa Bête humaine / [plaquette de présentation du film de Jean Renoir d'après le roman d'Emile Zola]Renoir, Jean (1894-1979). RéalisateurZola, Émile (1840-1902). Auteur adaptéParis film production (Paris)193816 p. : ill. ; 49 cmfretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb39770395m
+1
+
+20060222
+20160129
+19.750183
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb38223462mLa bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1981Numéro commercial : référence éditoriale VPV10241 cass. vidéo (VHS) (1 h 39 min) : n. et b. (SECAM)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb38223462m
+2
+
+19930317
+20120924
+19.369736
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb437350049La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurUniversal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt)2013Numéro commercial : EDV 1392 829 380-0Code à barres commercial : EAN 50505829380051 BD vidéo (1 h 39 min) : 4/3, n. et b., son. + 1 brochure (19 p. : ill. ; 17 cm)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb437350049
+3
+
+20131224
+20260414
+19.368761
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb43734223wLa bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurUniversal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt)2013Numéro commercial : EDV 1392 829 551-4Code à barres commercial : EAN 50505829551491 DVD vidéo monoface double couche zone 2 (1 h 35 min) : 4/3, n. et b. (PAL), son. + 1 brochure (19 p. : ill. ; 18 cm)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb43734223w
+4
+
+20131223
+20260414
+19.368483
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb487401586La Bête humaineZola, Émile (1840-1902)Editions Gallmeister (Paris)2026ISBN 9782351789643Code à barres commercial : EAN 97823517896431 vol. (484 p.) ; 20 cmfretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb487401586
+5
+
+20260326
+20260326
+18.988321
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb38397662rLa bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1998Collection : Vidéo classiqueNuméro commercial : référence éditoriale 27263Code à barres commercial : EAN 33391612726341 cass. vidéo (1 h 36 min) : n. et b., SECAM ; 1/2 pouce VHSfreimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb38397662r
+6
+
+19980608
+20120924
+18.954409
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb47104447bLa bête humaineZola, Émile (1840-1902). Auteur du texteBooks on Demand (Paris)2022ISBN 9782322437740Code à barres commercial : EAN 9782322437740non paginé ; 22 cmfretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb47104447b
+7
+
+20220923
+20240207
+18.93142
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb408956237La bête humaine / Roger Pillaudin ; adaptation radiophonique d'après Émile ZolaPillaudin, Roger. AdaptateurZola, Émile (1840-1902). Auteur adapté196535 ff. multigr. ; 27 cmfremanuscrit moderne ou document d'archivemodern manuscript or archivetextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb408956237
+8
+
+20080611
+20080612
+18.888391
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb40940932zLa bête humaine / Jean Renoir, réal., scénario ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux, Julien Carette, act.Renoir, Jean (1894-1979). RéalisateurZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurCarette, Julien (1897-1966). ActeurWarner home video [éd.] (London)2000Collection : World cinemaCode à barres commercial : EAN 50147803838901 cass. vidéo (VHS) (1 h 37 min) : n. et b. (PAL), son.freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb40940932z
+9
+
+20061116
+20260414
+18.866568
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb45582948nLa bête humaine / scénario, Dobbs ; dessin, Germano Giorgiani ; couleur, Isabelle Lebeau ; [d'après l'oeuvre d'Émile Zola]Dobbs (1972-....). Auteur du texteGiorgiani, Germano. IllustrateurZola, Émile (1840-1902). Auteur adaptéRobinson (Vanves)2018ISBN 9782012905313Code à barres commercial : EAN 97820129053131 vol. (81 p.) : ill. en coul. ; 27 cmfretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb45582948n
+10
+
+20180920
+20221205
+18.83721
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb409409261La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.The human beastRenoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteurthe Criterion collection [éd.] (Irvington, N.Y.)2006ISBN 0780026241Numéro commercial : 324Code à barres commercial : EAN 0374291738241 DVD vidéo monoface double couche zone 1 (1 h 36 min) : 4/3, n. et b. (NTSC), mono, surround (Dolby)image animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb409409261
+11
+
+20061116
+20120924
+18.757744
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb383634420La bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1997Collection : Vous comprenez le français ?Code à barres commercial : EAN 97820903245631 cass. vidéo (VHS) (1 h 40 min) : n. et b. (PAL), son. + 1 brochure (47 p.; 20 cm)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb383634420
+12
+
+19970509
+20251212
+18.566822
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb385406522La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1991Collection : Les génies du cinéma ; 121 cass. vidéo (VHS) (1 h 40 min) : n. et b. (SECAM)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb385406522
+13
+
+20010614
+20260414
+18.38218
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb38523388jLa bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1989Collection : L'âge d'or du cinémaNuméro commercial : référence éditoriale 28550181 cass. vidéo (VHS) (1 h 40 min) n. et b. (SECAM)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb38523388j
+14
+
+20010216
+20260414
+18.20326
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb38460416wLa bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). Acteur1989Collection : L'âge d'or du cinémaNuméro commercial : référence éditoriale 0990348Code à barres commercial : EAN 33460309903461 cass. vidéo (VHS) (1 h 36 min) : n. et b. (SECAM)freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb38460416w
+15
+
+19991123
+20120924
+18.202312
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb31690508jAutres chefs-d'oeuvre : Pot-Bouille, Germinal, La bête humaine / Émile Zola ; avec trente-deux aquarelles par Tibor Csernus, Fontanarosa, Jean Terles ; [Cartonnage par Paul Bonet]Zola, Émile (1840-1902). Auteur du texteCsernus, Tibor (1927-2007). IllustrateurTerles, Jean (1909-1976). IllustrateurFontanarosa, Lucien Joseph (1912-1975). IllustrateurFasquelle (Paris)Gallimard (Paris)1958807 p. : pl. et cartonnage ill. en coul. ; petit in-4fretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb31690508j
+16
+
+19970701
+20260318
+17.954374
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb472750729La bête humaine / Émile ZolaZola, Émile (1840-1902). Auteur du texteÉditions Ararauna (Lunel)2023Collection : Cycle Les Rougon-MacquartISBN 9782378848453Code à barres commercial : EAN 97823788484531 vol. (318 p.) ; 22 cmfretexte impriméprinted texttextCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb472750729
+17
+
+20230623
+20240207
+17.683867
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb389426109La grande illusion / Jean Renoir, réal., scénario, dial. ; Charles Spaak, scénario, dial. ; Joseph Kosma, comp. ; Jean Gabin, Dita Parlo, Pierre Fresnay... [et al.], act.. La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteKosma, Joseph (1905-1969). CompositeurSpaak, Charles (1903-1975). ScénaristeSpaak, Charles (1903-1975). DialoguisteZola, Émile (1840-1902). Auteur adaptéGabin, Jean (1904-1976). ActeurParlo, Dita (1906-1971). ActeurFresnay, Pierre (1897-1975). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurStudioCanal vidéo [éd.] (Boulogne-Billancourt)Universal music group [distrib.] (Paris)2003Collection : ClassiqueComprend : La grande illusion ; La bête humaineNuméro commercial : StudioCanal 196 909-9Code à barres commercial : EAN 32591196909932 DVD vidéo monofaces double couche toutes zones (1h 48 min, 1 h 33 min) : 4/3, n. et b. (PAL), son.fregerspaimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb389426109
+18
+
+20030121
+20211118
+17.638731
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb39205438zLa bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurRenoir, Jean (1894-1979). ScénaristeRenoir, Jean (1894-1979). DialoguisteZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurStudiocanal vidéo [éd.] (Issy-les-Moulineaux)Universal music [distrib.] (Paris)2004Collection : Acteurs, actrices de légendesNuméro commercial : Studiocanal vidéo 302 172-6Code à barres commercial : EAN 32591302172611 DVD monoface simple couche zone 2 (1 h 40 min) : n. et b. (PAL), son.freimage animéemoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb39205438z
+19
+
+20040702
+20260414
+17.539799
+
+
+
+dc
+xml
+
+http://catalogue.bnf.fr/ark:/12148/cb400818070La bête humaine / Jean Renoir, réal. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.Renoir, Jean (1894-1979). RéalisateurZola, Émile (1840-1902). Auteur adaptéKosma, Joseph (1905-1969). CompositeurGabin, Jean (1904-1976). ActeurSimon, Simone (1911-2005). ActeurLedoux, Fernand (1897-1993). ActeurM6 interactions (Neuilly-sur-Seine)Nouvelles messageries de la presse parisienne [distrib.] (Paris)2005Numéro commercial : NMPP M09353Code à barres commercial : EAN 37809353149971 fasc. (14 p.) : ill. en noir et en coul., couv. ill. ; 30 cm. - 1 DVD vidéo monoface double couche zone 2 (1 h 36 min) : n. et b. (PAL), son.fremultimédia multisupportkittextmoving imageCatalogue en ligne de la Bibliothèque nationale de FranceFrench National Library online Catalog
+
+ark:/12148/cb400818070
+20
+
+20051209
+20120924
+17.236567
+
+
+
+21
+
diff --git a/MaBibli.Tests/FormatageIsbnTests.cs b/MaBibli.Tests/FormatageIsbnTests.cs
new file mode 100644
index 0000000..a9e3930
--- /dev/null
+++ b/MaBibli.Tests/FormatageIsbnTests.cs
@@ -0,0 +1,86 @@
+using MaBibli.Shared.Isbn;
+
+namespace MaBibli.Tests;
+
+///
+/// Découpage des ISBN pour l'affichage.
+///
+///
+/// Les valeurs attendues ci-dessous ne sont pas devinées : elles ont été calculées à partir du
+/// RangeMessage.xml officiel de l'agence internationale (version du 2026-08-18), qui est
+/// aussi la source des tables embarquées.
+///
+public class FormatageIsbnTests
+{
+ ///
+ /// Le fonds francophone est découpé en entier, éditeur compris.
+ ///
+ [Theory]
+ [InlineData("9782846391009", "978-2-84639-100-9")] // le cas remonté en usage
+ [InlineData("9782070612758", "978-2-07-061275-8")] // Gallimard : éditeur sur 2 chiffres
+ [InlineData("9782253004226", "978-2-253-00422-6")] // Livre de poche : 3 chiffres
+ [InlineData("9782080704092", "978-2-08-070409-2")] // Flammarion : 2 chiffres
+ public void Decoupe_entierement_le_francophone(string nu, string attendu)
+ => Assert.Equal(attendu, FormatageIsbn.Afficher(nu));
+
+ ///
+ /// Un ISBN-10 est un ISBN-13 en 978 amputé de son préfixe : mêmes tranches,
+ /// une frontière de moins.
+ ///
+ [Theory]
+ [InlineData("2253004227", "2-253-00422-7")]
+ [InlineData("2080704095", "2-08-070409-5")]
+ public void Decoupe_aussi_les_ISBN10(string nu, string attendu)
+ => Assert.Equal(attendu, FormatageIsbn.Afficher(nu));
+
+ ///
+ /// Hors du francophone, on s'arrête au groupe et à la clé : deux frontières toujours
+ /// exactes. Le vrai découpage est 978-0-262-03384-8 — on ne l'invente pas, on
+ /// affiche une tranche de moins. Une coupure absente se lit encore, une coupure fausse trompe.
+ ///
+ [Theory]
+ [InlineData("9780262033848", "978-0-26203384-8")]
+ [InlineData("0262033844", "0-26203384-4")]
+ public void Ne_devine_pas_lediteur_hors_du_francophone(string nu, string attendu)
+ => Assert.Equal(attendu, FormatageIsbn.Afficher(nu));
+
+ ///
+ /// Ce qui n'est pas un ISBN valide ressort tel quel : un code mal saisi doit se voir
+ /// mal saisi, pas déguisé en ISBN par des tirets.
+ ///
+ [Theory]
+ [InlineData("9782846391008")] // clé fausse
+ [InlineData("978284639100")] // trop court
+ [InlineData("9772466671438")] // périodique : préfixe 977, aucun ISBN à découper
+ [InlineData("pas un isbn")]
+ public void Laisse_intact_ce_qui_nest_pas_un_ISBN(string brut)
+ => Assert.Equal(brut, FormatageIsbn.Afficher(brut));
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Vide_reste_vide(string? brut)
+ => Assert.Equal(string.Empty, FormatageIsbn.Afficher(brut));
+
+ ///
+ /// Un ISBN déjà découpé — recopié d'une page web, par exemple — se redécoupe à l'identique
+ /// plutôt que de cumuler les tirets.
+ ///
+ [Fact]
+ public void Est_idempotent()
+ {
+ var une = FormatageIsbn.Afficher("9782846391009");
+ Assert.Equal(une, FormatageIsbn.Afficher(une));
+ }
+
+ ///
+ /// ⚠️ Le contrat central : la valeur stockée reste nue. Ces tirets sont un habillage,
+ /// et la BnF indexe l'ISBN sans eux — les laisser filer dans une requête casserait le lookup.
+ ///
+ [Fact]
+ public void Le_decoupage_se_defait_par_Normaliser()
+ => Assert.Equal(
+ "9782846391009",
+ IsbnUtils.Normaliser(FormatageIsbn.Afficher("9782846391009")));
+}
diff --git a/MaBibli.Tests/IsbnLookupServiceTests.cs b/MaBibli.Tests/IsbnLookupServiceTests.cs
index 6468971..796bc2d 100644
--- a/MaBibli.Tests/IsbnLookupServiceTests.cs
+++ b/MaBibli.Tests/IsbnLookupServiceTests.cs
@@ -32,6 +32,19 @@ public class IsbnLookupServiceTests
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((new ResultatBibliographie(), (string?)null));
+
+ ///
+ /// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique)
+ /// est couvert par ses propres tests. L'interface l'exige, d'où ce stub.
+ ///
+ public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
+ string issn, CancellationToken ct = default) =>
+ Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
+
+ /// Recherche par titre : couverte par ses propres tests.
+ public Task<(IReadOnlyList, string?)> RechercherParTitreAsync(
+ string titre, string? auteur, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
}
private sealed class OpenLibraryFake : IOpenLibraryClient
@@ -197,5 +210,18 @@ public class IsbnLookupServiceTests
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((new ResultatBibliographie(), (string?)null));
+
+ ///
+ /// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique)
+ /// est couvert par ses propres tests. L'interface l'exige, d'où ce stub.
+ ///
+ public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
+ string issn, CancellationToken ct = default) =>
+ Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
+
+ /// Recherche par titre : couverte par ses propres tests.
+ public Task<(IReadOnlyList, string?)> RechercherParTitreAsync(
+ string titre, string? auteur, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
}
}
diff --git a/MaBibli.Tests/MigrationRangDesEnviesTests.cs b/MaBibli.Tests/MigrationRangDesEnviesTests.cs
new file mode 100644
index 0000000..5929c64
--- /dev/null
+++ b/MaBibli.Tests/MigrationRangDesEnviesTests.cs
@@ -0,0 +1,111 @@
+using MaBibli.Api.Data;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MaBibli.Tests;
+
+///
+/// La migration RangDesEnvies, appliquée pour de vrai à une base déjà peuplée.
+///
+///
+/// Ce test ne double pas ServiceSouhaitsTests : celui-ci crée son schéma avec
+/// EnsureCreated, qui ne joue aucune migration. Le remplissage de la colonne
+/// Rang n'y passerait donc jamais, et une erreur dans son SQL ne se verrait qu'en
+/// production — sur les listes d'envies réelles, qui se réordonneraient sous les yeux de leur
+/// propriétaire.
+///
+public class MigrationRangDesEnviesTests : IDisposable
+{
+ private const string AvantLeRang = "20260818052834_ListeDEnvies";
+
+ private readonly SqliteConnection _connexion;
+ private readonly MaBibliDbContext _db;
+
+ public MigrationRangDesEnviesTests()
+ {
+ // Connexion maintenue ouverte : une base SQLite « :memory: » disparaît avec elle.
+ _connexion = new SqliteConnection("Data Source=:memory:");
+ _connexion.Open();
+
+ _db = new MaBibliDbContext(
+ new DbContextOptionsBuilder().UseSqlite(_connexion).Options);
+ }
+
+ private IMigrator Migrateur => _db.GetInfrastructure().GetRequiredService();
+
+ ///
+ /// Le cœur du test : la migration doit reconduire l'ordre affiché jusqu'ici —
+ /// auteurs renseignés d'abord, puis par auteur, puis par titre — et repartir de zéro pour
+ /// chaque utilisateur, le rang étant personnel comme le reste de la table.
+ ///
+ [Fact]
+ public async Task Reconduit_lancien_ordre_daffichage_utilisateur_par_utilisateur()
+ {
+ await Migrateur.MigrateAsync(AvantLeRang);
+
+ // Insertion en SQL brut : à ce point du schéma, la colonne Rang n'existe pas encore,
+ // et l'entité C# la porte déjà. Passer par EF échouerait.
+ await ExecuterAsync("""
+ INSERT INTO LivresSouhaites
+ (Id, Utilisateur, Titre, TitreNormalise, Auteur, AuteurNormalise, DateAjout)
+ VALUES
+ (1, 'mathieu', 'Germinal', 'germinal', 'Émile Zola', 'emile zola', '2026-01-01'),
+ (2, 'mathieu', 'Sans nom', 'sans nom', NULL, '', '2026-01-02'),
+ (3, 'mathieu', 'Au bonheur', 'au bonheur', 'Émile Zola', 'emile zola', '2026-01-03'),
+ (4, 'mathieu', 'Les fourmis','les fourmis','Bernard Werber', 'bernard werber', '2026-01-04'),
+ (5, 'camille', 'Sa liste', 'sa liste', 'Bernard Werber', 'bernard werber', '2026-01-05'),
+ (6, 'camille', 'Sans nom', 'sans nom', NULL, '', '2026-01-06');
+ """);
+
+ await Migrateur.MigrateAsync();
+
+ // Werber avant Zola, « Au bonheur » avant « Germinal », et l'envie sans auteur en
+ // dernier : exactement ce que la liste montrait avant la colonne.
+ Assert.Equal(
+ [(0, "Les fourmis"), (1, "Au bonheur"), (2, "Germinal"), (3, "Sans nom")],
+ await RangsAsync("mathieu"));
+
+ // ⚠️ La numérotation de Camille repart de zéro : le rang n'a de sens qu'à l'intérieur
+ // d'une liste. Continuer celle de Mathieu ferait dépendre l'ordre de l'un de l'autre.
+ Assert.Equal([(0, "Sa liste"), (1, "Sans nom")], await RangsAsync("camille"));
+ }
+
+ /// Une base sans aucune envie doit traverser la migration sans broncher.
+ [Fact]
+ public async Task Sapplique_aussi_a_une_base_vide()
+ {
+ await Migrateur.MigrateAsync();
+
+ Assert.Empty(await _db.LivresSouhaites.ToListAsync());
+ }
+
+ private async Task ExecuterAsync(string sql)
+ {
+ await using var commande = _connexion.CreateCommand();
+ commande.CommandText = sql;
+ await commande.ExecuteNonQueryAsync();
+ }
+
+ private async Task> RangsAsync(string utilisateur)
+ {
+ // Contexte neuf : le précédent a pu mettre en cache le schéma d'avant la migration.
+ await using var db = new MaBibliDbContext(
+ new DbContextOptionsBuilder().UseSqlite(_connexion).Options);
+
+ return await db.LivresSouhaites
+ .Where(s => s.Utilisateur == utilisateur)
+ .OrderBy(s => s.Rang)
+ .Select(s => new ValueTuple(s.Rang, s.Titre))
+ .ToListAsync();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connexion.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/MaBibli.Tests/ServiceSouhaitsTests.cs b/MaBibli.Tests/ServiceSouhaitsTests.cs
index 7dd92f8..1e0f25b 100644
--- a/MaBibli.Tests/ServiceSouhaitsTests.cs
+++ b/MaBibli.Tests/ServiceSouhaitsTests.cs
@@ -52,6 +52,119 @@ public class ServiceSouhaitsTests : IDisposable
new EnregistrementSouhait { Titre = titre, Auteur = auteur, Isbn = isbn, Note = note },
utilisateur);
+ // ── Ordre choisi à la main ───────────────────────────────────────────────
+
+ private async Task> TitresAsync(string utilisateur = Mathieu) =>
+ (await _souhaits.ListerAsync(utilisateur)).Select(s => s.Titre).ToList();
+
+ ///
+ /// L'ordre d'affichage est celui de la liste, pas l'alphabet : classer une liste d'envies
+ /// par titre répondait à une question que personne ne se pose.
+ ///
+ [Fact]
+ public async Task Les_envies_sortent_dans_lordre_dajout_par_defaut()
+ {
+ await AjouterAsync("Zèbre", auteur: "Zoé Zed");
+ await AjouterAsync("Abricot", auteur: "Anne Alpha");
+
+ Assert.Equal(["Zèbre", "Abricot"], await TitresAsync());
+ }
+
+ [Fact]
+ public async Task Une_envie_nouvelle_se_pose_en_fin_de_liste()
+ {
+ await AjouterAsync("Premier");
+ await AjouterAsync("Deuxième");
+ await AjouterAsync("Troisième");
+
+ Assert.Equal(["Premier", "Deuxième", "Troisième"], await TitresAsync());
+ }
+
+ [Fact]
+ public async Task Reordonner_fixe_lordre_demande()
+ {
+ var a = (await AjouterAsync("Premier")).Souhait!.Id;
+ var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
+ var c = (await AjouterAsync("Troisième")).Souhait!.Id;
+
+ Assert.True(await _souhaits.ReordonnerAsync([c, a, b], Mathieu));
+
+ Assert.Equal(["Troisième", "Premier", "Deuxième"], await TitresAsync());
+ }
+
+ /// Réenvoyer le même ordre ne doit rien bouger.
+ [Fact]
+ public async Task Reordonner_est_idempotent()
+ {
+ var a = (await AjouterAsync("Premier")).Souhait!.Id;
+ var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
+
+ await _souhaits.ReordonnerAsync([b, a], Mathieu);
+ await _souhaits.ReordonnerAsync([b, a], Mathieu);
+
+ Assert.Equal(["Deuxième", "Premier"], await TitresAsync());
+ }
+
+ ///
+ /// ⚠️ Le cas qui perdrait des données : la liste du client est périmée (une envie ajoutée
+ /// depuis un autre appareil n'y figure pas). L'absente doit survivre, rangée à la
+ /// suite — mal classée vaut infiniment mieux que disparue.
+ ///
+ [Fact]
+ public async Task Une_envie_absente_de_lordre_envoye_est_conservee()
+ {
+ var a = (await AjouterAsync("Premier")).Souhait!.Id;
+ var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
+ await AjouterAsync("Ajoutée ailleurs");
+
+ await _souhaits.ReordonnerAsync([b, a], Mathieu);
+
+ Assert.Equal(["Deuxième", "Premier", "Ajoutée ailleurs"], await TitresAsync());
+ }
+
+ ///
+ /// ⚠️ L'invariant de la table : réordonner ne doit pas offrir un moyen détourné de toucher
+ /// à la liste de quelqu'un d'autre en devinant un identifiant.
+ ///
+ [Fact]
+ public async Task Reordonner_ne_touche_jamais_la_liste_dun_autre()
+ {
+ var sienne = (await AjouterAsync("À Camille", utilisateur: Camille)).Souhait!.Id;
+ var a = (await AjouterAsync("Premier", utilisateur: Mathieu)).Souhait!.Id;
+ var b = (await AjouterAsync("Deuxième", utilisateur: Mathieu)).Souhait!.Id;
+
+ // Mathieu glisse l'identifiant de Camille en tête de SA demande.
+ await _souhaits.ReordonnerAsync([sienne, b, a], Mathieu);
+
+ Assert.Equal(["Deuxième", "Premier"], await TitresAsync(Mathieu));
+ Assert.Equal(["À Camille"], await TitresAsync(Camille));
+ }
+
+ [Fact]
+ public async Task Reordonner_sans_identite_ne_fait_rien()
+ {
+ await AjouterAsync("Premier");
+
+ Assert.False(await _souhaits.ReordonnerAsync([1], null));
+ }
+
+ ///
+ /// Supprimer laisse des trous dans les rangs ; ils ne doivent pas déranger l'ordre, et le
+ /// prochain ajout doit tout de même atterrir en dernier.
+ ///
+ [Fact]
+ public async Task Une_suppression_ne_desordonne_pas_la_liste()
+ {
+ await AjouterAsync("Premier");
+ var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
+ await AjouterAsync("Troisième");
+
+ Assert.True(await _souhaits.SupprimerAsync(b, Mathieu));
+ await AjouterAsync("Quatrième");
+
+ Assert.Equal(["Premier", "Troisième", "Quatrième"], await TitresAsync());
+ }
+
// ── Portée personnelle ───────────────────────────────────────────────────
[Fact]
@@ -189,18 +302,28 @@ public class ServiceSouhaitsTests : IDisposable
Assert.Null(resultat.Souhait!.Isbn);
}
+ ///
+ /// ⚠️ Contrat changé le 2026-08-18 : la liste était classée par auteur puis titre,
+ /// elle suit désormais le rang choisi par son propriétaire. L'alphabet répondait à
+ /// une question que personne ne se pose devant une liste d'envies ; « qu'est-ce que je veux
+ /// le plus ? » en est une.
+ ///
+ /// L'ancien ordre n'est pas perdu pour autant : la migration RangDesEnvies le
+ /// reconduit dans les rangs initiaux, pour que les listes existantes ne se réordonnent pas
+ /// toutes seules. C'est MigrationRangDesEnviesTests qui le vérifie.
+ ///
+ ///
[Fact]
- public async Task La_liste_est_ordonnee_par_auteur_puis_titre()
+ public async Task La_liste_ne_se_classe_plus_par_ordre_alphabetique()
{
await AjouterAsync("Les fourmis", "Bernard Werber");
await AjouterAsync("Sans auteur", null);
await AjouterAsync("Germinal", "Émile Zola");
await AjouterAsync("Demain les chats", "Bernard Werber");
- var titres = (await _souhaits.ListerAsync(Mathieu)).Select(s => s.Titre).ToList();
-
- // Werber avant Zola, ses titres classés entre eux, et l'envie sans auteur en dernier.
- Assert.Equal(["Demain les chats", "Les fourmis", "Germinal", "Sans auteur"], titres);
+ Assert.Equal(
+ ["Les fourmis", "Sans auteur", "Germinal", "Demain les chats"],
+ await TitresAsync());
}
[Fact]
@@ -226,6 +349,108 @@ public class ServiceSouhaitsTests : IDisposable
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((BnfBibliographieParser.Parser(xml, auteur), (string?)null));
+
+ /// Les périodiques n'interviennent pas dans la bibliographie par auteur.
+ public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
+ string issn, CancellationToken ct = default) =>
+ Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
+
+ /// Recherche par titre : couverte par ses propres tests.
+ public Task<(IReadOnlyList, string?)> RechercherParTitreAsync(
+ string titre, string? auteur, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
+ }
+
+ ///
+ /// Client BnF qui ne répond pas : c'est le cas « Robert A. Harper » remonté en usage.
+ ///
+ private sealed class BnfMuetFake(EtatSourceBibliographie etat, string message) : IBnfClient
+ {
+ public Task<(IReadOnlyList, string?)> RechercherAsync(
+ string isbn, string? urlCouverture, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
+
+ public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
+ string auteur, CancellationToken ct = default) =>
+ Task.FromResult((new ResultatBibliographie { Etat = etat }, (string?)message));
+
+ public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
+ string issn, CancellationToken ct = default) =>
+ Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
+
+ public Task<(IReadOnlyList, string?)> RechercherParTitreAsync(
+ string titre, string? auteur, CancellationToken ct = default) =>
+ Task.FromResult(((IReadOnlyList)[], (string?)null));
+ }
+
+ ///
+ /// ⚠️ Le défaut que ce lot corrige. Une BnF muette rend une liste vide, exactement
+ /// comme un auteur réellement absent de son catalogue. Sans SourceMuette, l'écran
+ /// affirmait « la BnF ne connaît aucun livre de cet auteur » à propos d'une question qu'il
+ /// n'avait pas pu poser.
+ ///
+ [Theory]
+ [InlineData(EtatSourceBibliographie.DelaiDepasse)]
+ [InlineData(EtatSourceBibliographie.Injoignable)]
+ [InlineData(EtatSourceBibliographie.ReponseIllisible)]
+ public async Task Une_BnF_muette_ne_se_confond_pas_avec_un_auteur_sans_oeuvre(
+ EtatSourceBibliographie etat)
+ {
+ var biblio = await BibliographieAsync(new BnfMuetFake(etat, "La BnF n'a pas répondu."));
+
+ Assert.Empty(biblio.Oeuvres);
+ Assert.True(biblio.SourceMuette);
+ Assert.Equal(etat, biblio.Etat);
+ Assert.NotNull(biblio.Avertissement);
+ }
+
+ ///
+ /// Le pendant : la BnF a répondu, elle ne connaît simplement rien. Là, et là
+ /// seulement, l'écran a le droit d'expliquer l'absence.
+ ///
+ [Fact]
+ public async Task Une_BnF_qui_repond_sans_rien_connaitre_nest_pas_muette()
+ {
+ var biblio = await BibliographieAsync(new BnfBibliographieFake(ReponseVide));
+
+ Assert.Empty(biblio.Oeuvres);
+ Assert.False(biblio.SourceMuette);
+ Assert.Equal(EtatSourceBibliographie.Ok, biblio.Etat);
+ Assert.Null(biblio.Avertissement);
+ }
+
+ /// Une bibliographie qui a abouti n'est jamais signalée comme muette.
+ [Fact]
+ public async Task Une_bibliographie_obtenue_nest_pas_muette()
+ {
+ var biblio = await BibliographieAsync(
+ new BnfBibliographieFake(Fixture.Lire("bnf-auteur-werber.xml")));
+
+ Assert.NotEmpty(biblio.Oeuvres);
+ Assert.False(biblio.SourceMuette);
+ }
+
+ private const string ReponseVide = """
+
+ 0
+
+ """;
+
+ /// Bibliographie de Werber avec le client BnF fourni : seule la source varie.
+ ///
+ /// Bibliographie de Werber avec le client BnF fourni : seule la source varie.
+ /// L'auteur doit exister au catalogue, une bibliographie partant toujours d'un auteur à soi.
+ ///
+ private async Task BibliographieAsync(IBnfClient bnf)
+ {
+ await _catalogue.CreerAsync(
+ new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
+
+ var auteurs = new ServiceAuteurs(_db);
+ var service = new ServiceBibliographie(_db, bnf, auteurs);
+
+ var werber = (await auteurs.ListerAsync()).Single(a => a.Nom.Contains("Werber"));
+ return (await service.ObtenirAsync(werber.Id, Mathieu))!;
}
private async Task BibliographieWerberAsync(string utilisateur = Mathieu)