using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Isbn; using MaBibli.Shared.Dtos; using MaBibli.Shared.Isbn; namespace MaBibli.Tests; /// /// Lecture des réponses SRU « par auteur », sur des réponses réelles enregistrées le /// 2026-08-18 (25 notices chacune). Aucun accès réseau. /// /// /// Les deux fixtures sont choisies pour ce qu'elles ont de gênant : /// /// werber : bibliographie propre mais très redondante (rééditions en série) ; /// zola : bibliographie polluée — 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. /// /// 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))); } /// /// Le filtre critique : bib.author all "Émile Zola" rapproche « émile » et « zola » /// sur l'ensemble des auteurs d'une notice, et remonte donc l'œuvre de sa fille. /// [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)); } /// /// Le regroupement est ce qui rend l'écran utilisable : sans lui, « Les fourmis » occupe /// autant de lignes qu'il y a eu de retirages. /// [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 = """ 0 """; 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) ───────────────────────────────── /// /// ⚠️ 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"); }