Ajoute ISBN, tris et nouveautés d'auteur

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 13:49:08 +02:00
co-authored by Copilot
parent 359f4f59c6
commit 6b8f08ef26
10 changed files with 441 additions and 13 deletions
+24
View File
@@ -1786,6 +1786,30 @@ Une œuvre bibliographique non possédée n'a pas de fiche locale. Son titre est
qui déplie inline son ISBN et son nombre d'éditions, tandis que les œuvres possédées ouvrent
leur fiche via la carte partagée.
## Lot E — trouver ce qu'on veut souhaiter (2026-08-20)
L'écran `/souhaits/ajout` affiche désormais la couverture et l'ISBN formaté de chaque résultat
BnF. Le tri est local, sans nouvel appel réseau : date (récent en premier), titre ou éditeur.
L'ISBN reste nu dans les données envoyées et seul `FormatageIsbn.Afficher` intervient à l'écran.
La recherche par auteur expose un bouton **Nouveautés** depuis la bibliographie. Elle utilise un
endpoint dédié (`/api/auteurs/{id}/bibliographie/nouveautes`) afin de ne pas modifier le contrat
de la bibliographie générale. Les résultats sont les œuvres non possédées et non déjà souhaitées
dont la dernière édition BnF connue est postérieure à l'édition la plus récente possédée.
⚠️ Le représentant d'une œuvre reste la notice la plus ancienne : `OeuvreBibliographie.Annee`
reste donc cette date, tandis que `AnneeDerniereEdition` conserve le maximum des dates de toutes
les notices regroupées. C'est cette seconde valeur qui sert au filtre « paru depuis ». Le seuil
est recherché d'abord par ISBN (ISBN-13 et ISBN-10 sont rapprochés), puis par clé d'œuvre.
La bibliographie générale conserve son plafond de deux pages (200 notices). Pour les nouveautés,
la BnF est paginée jusqu'à **10 pages de 100 notices (1000 maximum)**, ou moins si le nombre
annoncé est inférieur. La première page donne le total, puis les pages nécessaires sont lancées
en parallèle. Ce bornage est volontaire : le SRU est classé par pertinence et ne permet pas de
déduire qu'une page suivante ne contient plus de date récente ; au-delà de 1000, la liste reste
un extrait signalé par `Tronquee`, sans boucle ni appels indéfinis. Une source muette conserve
les mêmes `EtatSourceBibliographie` et motifs que la bibliographie normale.
## Installation YunoHost — éprouvée en production le 2026-08-18
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
+5 -3
View File
@@ -20,9 +20,9 @@ Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé vo
La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées :
- **Plafond de 200 notices.** Suffisant pour un auteur contemporain (Werber : 203, Nothomb : 277),
très insuffisant pour un classique (Zola : 2692). L'écran le dit, mais on pourrait charger les
pages suivantes à la demande plutôt que d'annoncer un extrait.
- **Plafond de 200 notices.** Traité pour la recherche « nouveautés d'un auteur » du lot E :
elle charge jusqu'à 1000 notices avec un bornage explicite. La bibliographie générale reste
plafonnée à 200 et continue d'annoncer honnêtement qu'elle est tronquée.
- **OpenLibrary en second rideau.** La BnF ne connaît pas les auteurs étrangers non traduits ;
l'écran affiche alors une liste vide et l'explique. OpenLibrary expose les œuvres d'un auteur
(`/authors/{id}/works.json`) et pourrait prendre le relais — à ne faire que si le cas se
@@ -211,6 +211,8 @@ Aujourd'hui elle se consulte ; les cinq items la font servir à **constituer** u
## Lot E — Trouver ce qu'on veut souhaiter
✅ Traité le 2026-08-20 : voir `CLAUDE.md`, section « Lot E — trouver ce qu'on veut souhaiter ».
L'écran `/souhaits/ajout` rend aujourd'hui une liste plate.
- **E1. ISBN et couverture dans les résultats** de recherche. ⚠️ L'ISBN existe déjà dans la
@@ -180,6 +180,27 @@ public static class SouhaitsEndpoints
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapGet("/api/auteurs/{id:int}/bibliographie/nouveautes", async (
int id,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var bibliographie = await service.ObtenirNouveautesAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
})
.WithName("NouveautesAuteur")
.WithTags("Auteurs")
.WithSummary("Les œuvres de cet auteur parues depuis le livre le plus récent possédé.")
.WithDescription(
"La date du représentant d'une œuvre est celle de l'édition la plus ancienne ; "
+ "la sélection compare donc la date la plus récente des éditions connues. "
+ "La recherche étendue est bornée à 1000 notices BnF.")
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async (
int id,
MasquageBibliographie saisie,
@@ -2,6 +2,7 @@ using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -15,6 +16,9 @@ public interface IServiceBibliographie
/// </summary>
Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default);
Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
Task<bool> DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
@@ -101,6 +105,70 @@ public sealed class ServiceBibliographie(
};
}
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default)
{
var auteur = await auteurs.ObtenirAsync(auteurId, ct);
if (auteur is null)
{
return null;
}
var recherche = bnf is IBnfClientNouveautes etendu
? await etendu.RechercherParAuteurNouveautesAsync(auteur.Nom, ct)
: await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
var resultat = recherche.Resultat;
var possedes = await db.LivreAuteurs
.AsNoTracking()
.Where(la => la.AuteurId == auteurId)
.Select(la => new { la.LivreId, la.Livre!.Titre, la.Livre.Isbn })
.ToListAsync(ct);
var parTitre = new Dictionary<string, int>();
var parIsbn = new Dictionary<string, int>();
foreach (var livre in possedes)
{
var cle = CleOeuvre.Cle(livre.Titre);
if (cle.Length > 0)
{
parTitre.TryAdd(cle, livre.LivreId);
}
if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
parIsbn.TryAdd(livre.Isbn, livre.LivreId);
}
}
var souhaits = await SouhaitsAsync(utilisateur, ct);
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.ToList();
var seuil = AnneeSeuilNouveautes(
possedes.Select(l => (l.Titre, l.Isbn)), resultat.Notices);
// Une notice d'édition originale ne date pas la dernière édition de l'œuvre. Le
// regroupement conserve donc AnneeDerniereEdition, qui est la seule date pertinente
// pour répondre à « paru depuis ».
var nouveautes = FiltrerNouveautes(oeuvres, seuil);
return new BibliographieDto
{
Auteur = auteur,
Oeuvres = nouveautes,
NoticesAnnoncees = resultat.NombreAnnonce,
NoticesLues = resultat.NombreLu,
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
Avertissement = recherche.Avertissement,
Etat = resultat.Etat,
EstNouveautes = true,
AnneeSeuilNouveautes = seuil,
SeuilNouveautesInconnu = seuil is null && possedes.Count > 0,
};
}
public async Task<bool> MasquerAsync(
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
{
@@ -222,6 +290,12 @@ public sealed class ServiceBibliographie(
{
Titre = reference.Titre,
Annee = reference.Annee,
AnneeDerniereEdition = ordonnees
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value)
.DefaultIfEmpty()
.Max() is var annee && annee > 0 ? annee : null,
Editeur = reference.Editeur,
Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
NombreEditions = groupe.Count(),
@@ -258,6 +332,61 @@ public sealed class ServiceBibliographie(
};
}
public static int? AnneeSeuilNouveautes(
IEnumerable<(string Titre, string? Isbn)> possedes,
IReadOnlyList<NoticeAuteur> notices)
{
var annees = new List<int>();
foreach (var livre in possedes)
{
var correspondantes = !string.IsNullOrWhiteSpace(livre.Isbn)
? notices.Where(n => MemesIsbn(n.Isbn, livre.Isbn))
: notices.Where(n => n.CleTitre == CleOeuvre.Cle(livre.Titre));
annees.AddRange(correspondantes
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value));
}
return annees.Count == 0 ? null : annees.Max();
}
private static bool MemesIsbn(string? gauche, string? droite)
{
var a = IsbnUtils.Normaliser(gauche);
var b = IsbnUtils.Normaliser(droite);
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
return false;
}
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return IsbnUtils.TryConvertirEnIsbn10(a, out var a10)
&& string.Equals(a10, b, StringComparison.OrdinalIgnoreCase)
|| IsbnUtils.TryConvertirEnIsbn10(b, out var b10)
&& string.Equals(b10, a, StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyList<OeuvreBibliographie> FiltrerNouveautes(
IEnumerable<OeuvreBibliographie> oeuvres, int? seuil)
{
if (seuil is null)
{
return [];
}
return oeuvres
.Where(o => o.AnneeDerniereEdition > seuil && o.ADecouvrir)
.OrderByDescending(o => o.AnneeDerniereEdition)
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
/// <summary>
/// Année exploitable pour le tri, extraite d'une date BnF.
/// </summary>
+62 -1
View File
@@ -45,10 +45,19 @@ public interface IBnfClient
string titre, string? auteur, CancellationToken ct = default);
}
/// <summary>
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
/// </summary>
public interface IBnfClientNouveautes
{
Task<(ResultatBibliographie Resultat, string? Avertissement)>
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default);
}
/// <summary>
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
/// </summary>
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
{
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
public const string NomHttpClient = "bnf";
@@ -211,6 +220,12 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
public const int NoticesParPage = 100;
public const int PagesMaximum = 2;
public const int PagesMaximumNouveautes = 10;
public static int NombrePagesNouveautes(int nombreAnnonce) =>
Math.Min(
PagesMaximumNouveautes,
Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage));
public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default)
@@ -257,6 +272,52 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
}
}
public async Task<(ResultatBibliographie Resultat, string? Avertissement)>
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(auteur))
{
return (new ResultatBibliographie(), null);
}
try
{
var premiere = await LirePageAsync(auteur, 1, ct);
if (premiere is null)
{
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce);
var pages = new List<ResultatBibliographie> { premiere };
if (nombrePages > 1)
{
var suivantes = await Task.WhenAll(
Enumerable.Range(1, nombrePages - 1)
.Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));
pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!));
}
return (Fusionner(pages), null);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
}
}
/// <summary>
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
/// </summary>
+66
View File
@@ -98,6 +98,16 @@
Choisissez celle qui correspond, ou complétez à la main.
</p>
<div class="champ champ-tri">
<label class="champ-libelle" for="tri-resultats">Trier par</label>
<select id="tri-resultats" class="champ-saisie" value="@_tri"
@onchange="e => ChangerTri(e.Value?.ToString())">
<option value="@TriResultats.Date">Date (récent en premier)</option>
<option value="@TriResultats.Titre">Titre</option>
<option value="@TriResultats.Editeur">Éditeur</option>
</select>
</div>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
@@ -183,6 +193,7 @@
private EnregistrementSouhait _saisie = new();
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private TriResultats _tri = TriResultats.Date;
private bool _recherche;
private bool _ajout;
private string? _erreur;
@@ -195,6 +206,13 @@
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private enum TriResultats
{
Date,
Titre,
Editeur,
}
/// <summary>
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
/// </summary>
@@ -228,6 +246,7 @@
_erreur = null;
_candidats = [];
_avertissements = [];
_tri = TriResultats.Date;
}
private async Task SurTouche(KeyboardEventArgs e)
@@ -284,6 +303,7 @@
}
_candidats = resultat.Candidats;
_candidats = Trier(_candidats);
if (_candidats.Count == 0)
{
@@ -296,6 +316,52 @@
}
}
private void ChangerTri(string? valeur)
{
if (!Enum.TryParse<TriResultats>(valeur, out var tri))
{
return;
}
_tri = tri;
_candidats = Trier(_candidats);
}
private IReadOnlyList<CandidatLivre> Trier(IEnumerable<CandidatLivre> candidats) =>
_tri switch
{
TriResultats.Titre => candidats
.OrderBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Editeur, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
TriResultats.Editeur => candidats
.OrderBy(c => c.Editeur ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
_ => candidats
.OrderByDescending(c => AnneeTriable(c.Annee))
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
};
private static int AnneeTriable(string? date)
{
if (string.IsNullOrWhiteSpace(date))
{
return 0;
}
for (var i = 0; i + 4 <= date.Length; i++)
{
if (int.TryParse(date.AsSpan(i, 4), out var annee) && annee is >= 1000 and <= 2999)
{
return annee;
}
}
return 0;
}
/// <summary>
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
/// </summary>
+51 -3
View File
@@ -30,7 +30,16 @@ else if (_erreur is not null)
}
else if (_bibliographie is { } biblio)
{
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
<h1 class="titre-page">
@if (biblio.EstNouveautes)
{
<text>Nouveautés de @biblio.Auteur.Nom</text>
}
else
{
@biblio.Auteur.Nom
}
</h1>
@*
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
@@ -64,15 +73,29 @@ else if (_bibliographie is { } biblio)
</div>
}
else if (biblio.Oeuvres.Count == 0)
{
@if (biblio.EstNouveautes && biblio.SeuilNouveautesInconnu)
{
<p class="message-discret">
Impossible de dater le livre le plus récent que vous possédez dans les notices
reçues. La BnF a répondu, mais aucune nouveauté fiable ne peut être déduite.
</p>
}
else if (biblio.EstNouveautes)
{
<p class="message-discret">Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.</p>
}
else
{
@* 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. *@
l'expliquer. *@
<p class="message-discret">
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, et un
auteur traduit n'y a souvent pas de notice à son nom.
</p>
}
}
else if (biblio.Avertissement is not null)
{
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
@@ -92,6 +115,13 @@ else if (_bibliographie is { } biblio)
}
</p>
@if (biblio.EstNouveautes)
{
<p class="message-discret">
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
</p>
}
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
conditionnels dans une même phrase produit une ponctuation bancale dès qu'un des
fragments manque (« exister. .34 notices ont été écartées »). *@
@@ -290,6 +320,19 @@ else if (_bibliographie is { } biblio)
@if (_bibliographie is { } b)
{
<nav class="actions-flottantes">
@if (!b.EstNouveautes)
{
<a class="bouton bouton-discret"
href="@($"auteurs/{b.Auteur.Id}/bibliographie?nouveautes=true")">
Nouveautés
</a>
}
else
{
<a class="bouton bouton-discret" href="@($"auteurs/{b.Auteur.Id}/bibliographie")">
Toute la bibliographie
</a>
}
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
</nav>
}
@@ -298,6 +341,9 @@ else if (_bibliographie is { } biblio)
[Parameter]
public int AuteurId { get; set; }
[SupplyParameterFromQuery(Name = "nouveautes")]
public bool Nouveautes { get; set; }
private BibliographieDto? _bibliographie;
private int _modePossession;
private bool _filtresOuverts;
@@ -337,7 +383,9 @@ else if (_bibliographie is { } biblio)
try
{
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
_bibliographie = Nouveautes
? await Api.ObtenirNouveautesAsync(AuteurId)
: await Api.ObtenirBibliographieAsync(AuteurId);
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
if (_bibliographie is null)
@@ -775,6 +775,16 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return bibliographie;
}
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, CancellationToken ct = default)
{
var reponse = await http.GetAsync(
$"api/auteurs/{auteurId}/bibliographie/nouveautes", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<BibliographieDto>(reponse, ct);
}
public async Task<string?> MasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
+16
View File
@@ -97,6 +97,13 @@ public record OeuvreBibliographie
public string? Annee { get; init; }
/// <summary>Année la plus récente des éditions BnF réunies sous cette œuvre.</summary>
/// <remarks>
/// L'année affichée reste celle de l'édition la plus ancienne, qui représente l'œuvre.
/// Cette seconde valeur sert uniquement à rechercher les nouveautés.
/// </remarks>
public int? AnneeDerniereEdition { get; init; }
public string? Editeur { get; init; }
/// <summary>ISBN d'une des éditions relevées, quand la notice en portait un.</summary>
@@ -193,6 +200,15 @@ public record BibliographieDto
/// </summary>
public EtatSourceBibliographie Etat { get; init; }
/// <summary>Vrai quand la liste est limitée aux œuvres postérieures au seuil possédé.</summary>
public bool EstNouveautes { get; init; }
/// <summary>Année de publication la plus récente retrouvée pour un livre possédé.</summary>
public int? AnneeSeuilNouveautes { get; init; }
/// <summary>Vrai quand aucun ISBN ou titre possédé n'a permis de dater le seuil.</summary>
public bool SeuilNouveautesInconnu { get; init; }
/// <summary>
/// Vrai quand la BnF n'a pas répondu : la liste vide ne prouve alors <b>rien</b>.
/// </summary>
@@ -150,6 +150,57 @@ public class BnfBibliographieParserTests
}
}
[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()
{