Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
14 KiB
C#
345 lines
14 KiB
C#
using MaBibli.Api.Services.Catalogue;
|
|
using MaBibli.Api.Services.Isbn;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Isbn;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Lecture des réponses SRU « par auteur », sur des réponses <b>réelles</b> enregistrées le
|
|
/// 2026-08-18 (25 notices chacune). Aucun accès réseau.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Les deux fixtures sont choisies pour ce qu'elles ont de gênant :
|
|
/// <list type="bullet">
|
|
/// <item><c>werber</c> : bibliographie propre mais très redondante (rééditions en série) ;</item>
|
|
/// <item><c>zola</c> : bibliographie <b>polluée</b> — l'index auteur de la BnF y mêle les
|
|
/// ouvrages de sa fille Denise Le Blond-Zola, qui parlent de lui sans qu'il les ait écrits.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public class BnfBibliographieParserTests
|
|
{
|
|
private const string Werber = "bnf-auteur-werber.xml";
|
|
private const string Zola = "bnf-auteur-zola.xml";
|
|
|
|
[Fact]
|
|
public void Lit_le_nombre_de_notices_annonce_par_la_BnF()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
|
|
// La BnF en annonce bien plus qu'elle n'en renvoie : c'est ce qui permet à l'écran de
|
|
// dire honnêtement qu'il ne montre qu'un extrait.
|
|
Assert.True(
|
|
resultat.NombreAnnonce > resultat.NombreLu,
|
|
$"annoncé {resultat.NombreAnnonce}, lu {resultat.NombreLu}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Retient_les_notices_de_l_auteur_demande()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
|
|
Assert.NotEmpty(resultat.Notices);
|
|
Assert.All(
|
|
resultat.Notices,
|
|
n => Assert.Contains(n.Auteurs, a => a.Contains("Werber", StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le filtre critique : <c>bib.author all "Émile Zola"</c> rapproche « émile » et « zola »
|
|
/// sur l'ensemble des auteurs d'une notice, et remonte donc l'œuvre de sa fille.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ecarte_les_ouvrages_ecrits_par_quelqu_un_d_autre()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Zola), "Émile Zola");
|
|
|
|
Assert.True(
|
|
resultat.EcarteesAutreAuteur > 0,
|
|
"la fixture Zola doit contenir des notices d'un autre auteur, sinon elle ne teste rien");
|
|
|
|
// Denise Le Blond-Zola a écrit sur son père ; ses livres ne sont pas de lui.
|
|
Assert.DoesNotContain(
|
|
resultat.Notices,
|
|
n => n.Auteurs.Any(a => a.Contains("Le Blond", StringComparison.OrdinalIgnoreCase)));
|
|
|
|
Assert.All(
|
|
resultat.Notices,
|
|
n => Assert.Contains(
|
|
n.Auteurs,
|
|
a => MaBibli.Shared.Textes.RapprochementAuteurs.SontLeMemeAuteur(a, "Émile Zola")));
|
|
}
|
|
|
|
[Fact]
|
|
public void Tolere_la_forme_bibliographique_du_nom()
|
|
{
|
|
// La notice porte « Werber, Bernard (1961-....). Auteur du texte » : c'est le nettoyage
|
|
// ISBD puis le rapprochement d'auteurs qui doivent le ramener à « Bernard Werber ».
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "werber, bernard");
|
|
|
|
Assert.NotEmpty(resultat.Notices);
|
|
}
|
|
|
|
[Fact]
|
|
public void Extrait_l_ISBN_du_champ_identifier()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
|
|
var avecIsbn = resultat.Notices.Where(n => !string.IsNullOrWhiteSpace(n.Isbn)).ToList();
|
|
|
|
Assert.NotEmpty(avecIsbn);
|
|
|
|
// Le préfixe « ISBN » du dc:identifier ne doit pas rester collé au numéro.
|
|
Assert.All(avecIsbn, n => Assert.DoesNotContain("ISBN", n.Isbn!, StringComparison.OrdinalIgnoreCase));
|
|
Assert.All(avecIsbn, n => Assert.True(n.Isbn!.Length is 10 or 13, n.Isbn));
|
|
}
|
|
|
|
[Fact]
|
|
public void Coupe_la_mention_de_responsabilite_du_titre()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
|
|
// « Troisième humanité / Bernard Werber » → « Troisième humanité ».
|
|
Assert.All(resultat.Notices, n => Assert.DoesNotContain(" / ", n.Titre));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le regroupement est ce qui rend l'écran utilisable : sans lui, « Les fourmis » occupe
|
|
/// autant de lignes qu'il y a eu de retirages.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Regroupe_les_reeditions_sous_une_seule_oeuvre()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
var oeuvres = ServiceBibliographie.Regrouper(resultat).ToList();
|
|
|
|
Assert.True(
|
|
oeuvres.Count < resultat.Notices.Count,
|
|
$"{resultat.Notices.Count} notices devraient donner moins de {resultat.Notices.Count} œuvres");
|
|
|
|
// Aucune œuvre en double après regroupement.
|
|
var cles = oeuvres.Select(o => MaBibli.Shared.Textes.CleOeuvre.Cle(o.Titre)).ToList();
|
|
Assert.Equal(cles.Count, cles.Distinct().Count());
|
|
|
|
// Au moins une œuvre a bien réuni plusieurs notices, sinon le test ne prouve rien.
|
|
Assert.Contains(oeuvres, o => o.NombreEditions > 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retient_l_edition_la_plus_ancienne_comme_representant()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
|
|
foreach (var oeuvre in ServiceBibliographie.Regrouper(resultat).Where(o => o.NombreEditions > 1))
|
|
{
|
|
var cle = MaBibli.Shared.Textes.CleOeuvre.Cle(oeuvre.Titre);
|
|
|
|
var anneesDuGroupe = resultat.Notices
|
|
.Where(n => n.CleTitre == cle)
|
|
.Select(n => ServiceBibliographie.AnneeTriable(n.Annee))
|
|
.Where(a => a is not null)
|
|
.Select(a => a!.Value)
|
|
.ToList();
|
|
|
|
if (anneesDuGroupe.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Assert.Equal(anneesDuGroupe.Min(), ServiceBibliographie.AnneeTriable(oeuvre.Annee));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Conserve_aussi_la_date_de_la_derniere_edition()
|
|
{
|
|
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
|
var oeuvre = ServiceBibliographie.Regrouper(resultat)
|
|
.First(o => o.NombreEditions > 1
|
|
&& o.AnneeDerniereEdition is not null
|
|
&& o.Annee is not null);
|
|
|
|
var cle = MaBibli.Shared.Textes.CleOeuvre.Cle(oeuvre.Titre);
|
|
var maximum = resultat.Notices
|
|
.Where(n => n.CleTitre == cle)
|
|
.Select(n => ServiceBibliographie.AnneeTriable(n.Annee))
|
|
.Where(a => a is not null)
|
|
.Max();
|
|
|
|
Assert.Equal(maximum, oeuvre.AnneeDerniereEdition);
|
|
Assert.True(oeuvre.AnneeDerniereEdition >= ServiceBibliographie.AnneeTriable(oeuvre.Annee));
|
|
}
|
|
|
|
[Fact]
|
|
public void Les_nouveautes_comparent_la_derniere_edition_et_non_le_representant()
|
|
{
|
|
var anciennes = new OeuvreBibliographie
|
|
{
|
|
Titre = "Ancienne œuvre",
|
|
Annee = "1980",
|
|
AnneeDerniereEdition = 2024,
|
|
};
|
|
var recente = new OeuvreBibliographie
|
|
{
|
|
Titre = "Nouvelle œuvre",
|
|
Annee = "2023",
|
|
AnneeDerniereEdition = 2023,
|
|
};
|
|
|
|
var resultat = ServiceBibliographie.FiltrerNouveautes([anciennes, recente], 2023);
|
|
|
|
Assert.Single(resultat);
|
|
Assert.Equal("Ancienne œuvre", resultat[0].Titre);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 1)]
|
|
[InlineData(203, 3)]
|
|
[InlineData(1000, 10)]
|
|
[InlineData(2692, 10)]
|
|
public void La_recherche_de_nouveautes_est_bornee_a_dix_pages(
|
|
int annonces, int pages) =>
|
|
Assert.Equal(pages, BnfClient.NombrePagesNouveautes(annonces));
|
|
|
|
[Fact]
|
|
public void Une_reponse_vide_ne_fait_pas_echouer_la_lecture()
|
|
{
|
|
const string vide = """
|
|
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
|
|
<srw:numberOfRecords>0</srw:numberOfRecords>
|
|
</srw:searchRetrieveResponse>
|
|
""";
|
|
|
|
var resultat = BnfBibliographieParser.Parser(vide, "Personne");
|
|
|
|
Assert.Empty(resultat.Notices);
|
|
Assert.Equal(0, resultat.NombreAnnonce);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("2012", 2012)]
|
|
[InlineData("[DL 2012]", 2012)]
|
|
[InlineData("cop. 1991", 1991)]
|
|
[InlineData("19..?", null)]
|
|
[InlineData("", null)]
|
|
[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) ─────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// ⚠️ Le filtre de type est aussi indispensable ici qu'en bibliographie. Mesuré sur la
|
|
/// réponse réelle enregistrée : <c>bib.title all "la bête humaine"</c> croisé avec Zola
|
|
/// rend 20 notices, dont <b>12 films</b> de Jean Renoir, un manuscrit et un multimédia.
|
|
/// Sans filtre, la liste d'envies proposerait d'acheter un DVD.
|
|
/// </summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contrairement à la bibliographie, <b>aucun post-filtre sur l'auteur</b> : c'est
|
|
/// l'utilisateur qui choisit dans la liste, en voyant les auteurs de chaque notice.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Les deux notices vérifiées ici sont précisément celles que le post-filtre de
|
|
/// <see cref="BnfBibliographieParser.Parser"/> écarterait, et les garder est le bon choix
|
|
/// pour une liste d'envies :
|
|
/// <list type="bullet">
|
|
/// <item>la <b>bande dessinée signée Dobbs</b> — une adaptation de « La bête humaine » se
|
|
/// souhaite parfaitement, et son auteur est affiché, donc rien ne trompe ;</item>
|
|
/// <item>la notice <b>sans aucun <c>dc:creator</c></b>, qui n'aurait pu correspondre à
|
|
/// aucun auteur demandé.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
[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 ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// ⚠️ Ces messages <b>atteignent l'utilisateur</b>. 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Un_delai_depasse_invite_a_reessayer()
|
|
=> Assert.Contains("réessay", MotifEchec(EtatSourceBibliographie.DelaiDepasse),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Les trois motifs se distinguent : les confondre reviendrait à ne rien dire.</summary>
|
|
[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");
|
|
} |