Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.
1. Douchette USB, ISSN et enchaînement du scan
- Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
douchette USB, qui se présente au système comme un clavier. Remède
au scan caméra, qui rate sur la webcam d'un PC (optique, pas
décodage).
- Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
parfaitement valide), l'ISSN est déduit du code puis nommé via
bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
« aucun résultat ».
- L'enchaînement après scan existait déjà mais était invisible : une
étape « Recherche » affiche désormais le code interrogé.
2. ISBN affichés avec des tirets
Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
sont pas celles qu'on suppose. Le francophone est découpé en entier,
ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
Corrige au passage l'export CSV, où un ISBN nu était lu comme un
nombre par Excel.
3. Hors-ligne : la liste d'envies
ListerSouhaitsAsync était le seul point de lecture hors du dispositif
hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
instantané, écritures refusées, plus aucun message HTTP. Même défaut
corrigé sur la bibliographie.
4. Navigation par onglets
Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
d'actions ne portent plus que des actions. Filtres repliés derrière un
bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.
5. Liste d'envies : ordre, recherche, couvertures
Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
jusqu'ici : sans lui, les listes existantes se seraient réordonnées
toutes seules. Réordonnancement par flèches et glisser-déposer (le
drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
écran, avec recherche par titre (bib.title) et couvertures enfin
alimentées.
6. Bibliographie : une source muette n'est pas une liste vide
L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
s'excluent désormais, et un bouton Réessayer est offert.
Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
annoncées) ; c'était le même délai dépassé observé deux fois, pris
pour deux causes distinctes.
380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
8.8 KiB
C#
228 lines
8.8 KiB
C#
using MaBibli.Api.Services.Isbn;
|
|
using MaBibli.Shared.Dtos;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Enchaînement de la cascade BnF (ISBN-13) → BnF (ISBN-10) → OpenLibrary,
|
|
/// avec des sources simulées : aucun accès réseau.
|
|
/// </summary>
|
|
public class IsbnLookupServiceTests
|
|
{
|
|
private sealed class BnfFake : IBnfClient
|
|
{
|
|
public required Func<string, IReadOnlyList<CandidatLivre>> Reponse { get; init; }
|
|
|
|
public List<string> IsbnInterroges { get; } = [];
|
|
|
|
public string? Avertissement { get; init; }
|
|
|
|
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
|
string isbn, string? urlCouverture, CancellationToken ct = default)
|
|
{
|
|
IsbnInterroges.Add(isbn);
|
|
return Task.FromResult((Reponse(isbn), Avertissement));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN : la recherche par auteur n'y intervient
|
|
/// pas, mais l'interface l'exige. Elle est couverte par BnfBibliographieParserTests.
|
|
/// </summary>
|
|
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
|
|
string auteur, CancellationToken ct = default) =>
|
|
Task.FromResult((new ResultatBibliographie(), (string?)null));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
|
|
string issn, CancellationToken ct = default) =>
|
|
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
|
|
|
|
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
|
|
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
|
|
string titre, string? auteur, CancellationToken ct = default) =>
|
|
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
|
|
}
|
|
|
|
private sealed class OpenLibraryFake : IOpenLibraryClient
|
|
{
|
|
public required Func<string, IReadOnlyList<CandidatLivre>> Reponse { get; init; }
|
|
|
|
public List<string> IsbnInterroges { get; } = [];
|
|
|
|
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
|
string isbn, string? urlCouverture, CancellationToken ct = default)
|
|
{
|
|
IsbnInterroges.Add(isbn);
|
|
return Task.FromResult((Reponse(isbn), (string?)null));
|
|
}
|
|
}
|
|
|
|
private static CandidatLivre Notice(SourceMetadonnees source, string isbn) =>
|
|
new() { Titre = "Un titre", Source = source, IsbnInterroge = isbn };
|
|
|
|
private static IsbnLookupService Service(IBnfClient bnf, IOpenLibraryClient ol) =>
|
|
new(bnf, ol, NullLogger<IsbnLookupService>.Instance);
|
|
|
|
[Fact]
|
|
public async Task Sarrete_a_la_BnF_quand_lISBN13_suffit()
|
|
{
|
|
var bnf = new BnfFake { Reponse = isbn => [Notice(SourceMetadonnees.Bnf, isbn)] };
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("978-2-07-061275-8");
|
|
|
|
Assert.NotNull(r);
|
|
Assert.Equal("9782070612758", r.IsbnDemande);
|
|
Assert.Equal("2070612759", r.Isbn10);
|
|
Assert.Equal(["9782070612758"], bnf.IsbnInterroges);
|
|
Assert.Empty(ol.IsbnInterroges);
|
|
Assert.Single(r.Candidats);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reessaie_la_BnF_avec_lISBN10_converti()
|
|
{
|
|
// Le cas Germinal : rien en ISBN-13, tout en ISBN-10.
|
|
var bnf = new BnfFake
|
|
{
|
|
Reponse = isbn => isbn == "2253004227"
|
|
? [Notice(SourceMetadonnees.Bnf, isbn), Notice(SourceMetadonnees.Bnf, isbn)]
|
|
: [],
|
|
};
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
|
|
|
Assert.Equal(["9782253004226", "2253004227"], bnf.IsbnInterroges);
|
|
Assert.Empty(ol.IsbnInterroges);
|
|
Assert.Equal(2, r!.Candidats.Count);
|
|
Assert.All(r.Candidats, c => Assert.Equal("2253004227", c.IsbnInterroge));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bascule_sur_OpenLibrary_quand_la_BnF_ignore_les_deux_formes()
|
|
{
|
|
var bnf = new BnfFake { Reponse = _ => [] };
|
|
var ol = new OpenLibraryFake
|
|
{
|
|
Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)],
|
|
};
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("9780262033848");
|
|
|
|
Assert.Equal(["9780262033848", "0262033844"], bnf.IsbnInterroges);
|
|
// OpenLibrary est interrogée avec l'ISBN-13, la forme qu'elle indexe.
|
|
Assert.Equal(["9780262033848"], ol.IsbnInterroges);
|
|
Assert.Equal(SourceMetadonnees.OpenLibrary, Assert.Single(r!.Candidats).Source);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Une_BnF_en_panne_ne_fait_pas_echouer_la_cascade()
|
|
{
|
|
var bnf = new BnfFake { Reponse = _ => [], Avertissement = "BnF injoignable" };
|
|
var ol = new OpenLibraryFake { Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)] };
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
|
|
|
Assert.Single(r!.Candidats);
|
|
Assert.Equal(2, r.Avertissements.Count); // une par tentative BnF
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aucune_source_ne_trouve_rien()
|
|
{
|
|
var bnf = new BnfFake { Reponse = _ => [] };
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
|
|
|
Assert.NotNull(r);
|
|
Assert.Empty(r.Candidats);
|
|
Assert.Empty(r.Avertissements);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ISBN_en_979_ninterroge_la_BnF_quune_seule_fois()
|
|
{
|
|
var bnf = new BnfFake { Reponse = _ => [] };
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
var r = await Service(bnf, ol).RechercherAsync("9791023507027");
|
|
|
|
Assert.Null(r!.Isbn10);
|
|
Assert.Equal(["9791023507027"], bnf.IsbnInterroges);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("pas-un-isbn")]
|
|
[InlineData("9782253004225")] // clé fausse
|
|
[InlineData("123")]
|
|
public async Task ISBN_invalide_renvoie_null(string entree)
|
|
{
|
|
var bnf = new BnfFake { Reponse = _ => [] };
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
Assert.Null(await Service(bnf, ol).RechercherAsync(entree));
|
|
Assert.Empty(bnf.IsbnInterroges);
|
|
Assert.Empty(ol.IsbnInterroges);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task La_couverture_OpenLibrary_est_transmise_meme_pour_une_notice_BnF()
|
|
{
|
|
string? couvertureVue = null;
|
|
var bnf = new BnfFake { Reponse = _ => [] };
|
|
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
|
|
|
// On capture l'URL passée à la BnF via un client dédié.
|
|
var bnfCapture = new CaptureCouverture();
|
|
var r = await Service(bnfCapture, ol).RechercherAsync("9782253004226");
|
|
couvertureVue = bnfCapture.DerniereCouverture;
|
|
|
|
Assert.NotNull(r);
|
|
Assert.Equal(
|
|
"https://covers.openlibrary.org/b/isbn/9782253004226-L.jpg?default=false",
|
|
couvertureVue);
|
|
Assert.Empty(bnf.IsbnInterroges);
|
|
}
|
|
|
|
private sealed class CaptureCouverture : IBnfClient
|
|
{
|
|
public string? DerniereCouverture { get; private set; }
|
|
|
|
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
|
string isbn, string? urlCouverture, CancellationToken ct = default)
|
|
{
|
|
DerniereCouverture = urlCouverture;
|
|
return Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN : la recherche par auteur n'y intervient
|
|
/// pas, mais l'interface l'exige. Elle est couverte par BnfBibliographieParserTests.
|
|
/// </summary>
|
|
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
|
|
string auteur, CancellationToken ct = default) =>
|
|
Task.FromResult((new ResultatBibliographie(), (string?)null));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
|
|
string issn, CancellationToken ct = default) =>
|
|
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
|
|
|
|
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
|
|
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
|
|
string titre, string? auteur, CancellationToken ct = default) =>
|
|
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
|
|
}
|
|
}
|