From 8b8fe3be6e210bbc4836f76f54131878247201d7 Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 13:59:06 +0200 Subject: [PATCH] Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie frontière : toute lecture filtre dessus. Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux filtres mesurés sur des réponses réelles sont indispensables — le type de document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs : « Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola. Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51 œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui désigne une édition et non une œuvre ; ses limites sont dites à l'écran. Export en deux formats, tous deux du texte sans dépendance : .txt groupé par auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le tableur. Co-Authored-By: Claude Opus 5 --- MaBibli.Api/Data/MaBibliDbContext.cs | 31 ++ .../20260818052834_ListeDEnvies.Designer.cs | 301 +++++++++++++++ .../Migrations/20260818052834_ListeDEnvies.cs | 56 +++ .../MaBibliDbContextModelSnapshot.cs | 55 +++ MaBibli.Api/Endpoints/SouhaitsEndpoints.cs | 162 ++++++++ MaBibli.Api/Program.cs | 5 + .../Catalogue/ServiceBibliographie.cs | 208 ++++++++++ .../Services/Isbn/BnfBibliographieParser.cs | 209 ++++++++++ MaBibli.Api/Services/Isbn/BnfClient.cs | 106 +++++ .../Services/Souhaits/ExportSouhaits.cs | 177 +++++++++ .../Services/Souhaits/ServiceSouhaits.cs | 170 +++++++++ MaBibli.Client/Pages/Auteurs.razor | 5 + MaBibli.Client/Pages/Bibliographie.razor | 245 ++++++++++++ MaBibli.Client/Pages/Catalogue.razor | 1 + MaBibli.Client/Pages/Souhaits.razor | 241 ++++++++++++ MaBibli.Client/Services/ServiceLivresApi.cs | 57 +++ MaBibli.Client/wwwroot/css/app.css | 96 +++++ MaBibli.Shared/Dtos/SouhaitDto.cs | 137 +++++++ MaBibli.Shared/Entites/LivreSouhaite.cs | 85 +++++ MaBibli.Shared/Textes/CleOeuvre.cs | 74 ++++ MaBibli.Tests/BnfBibliographieParserTests.cs | 175 +++++++++ MaBibli.Tests/CleOeuvreTests.cs | 82 ++++ MaBibli.Tests/ExportSouhaitsTests.cs | 185 +++++++++ MaBibli.Tests/Fixtures/bnf-auteur-werber.xml | 361 ++++++++++++++++++ MaBibli.Tests/Fixtures/bnf-auteur-zola.xml | 361 ++++++++++++++++++ MaBibli.Tests/IsbnLookupServiceTests.cs | 16 + MaBibli.Tests/ServiceSouhaitsTests.cs | 338 ++++++++++++++++ 27 files changed, 3939 insertions(+) create mode 100644 MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.cs create mode 100644 MaBibli.Api/Endpoints/SouhaitsEndpoints.cs create mode 100644 MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs create mode 100644 MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs create mode 100644 MaBibli.Api/Services/Souhaits/ExportSouhaits.cs create mode 100644 MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs create mode 100644 MaBibli.Client/Pages/Bibliographie.razor create mode 100644 MaBibli.Client/Pages/Souhaits.razor create mode 100644 MaBibli.Shared/Dtos/SouhaitDto.cs create mode 100644 MaBibli.Shared/Entites/LivreSouhaite.cs create mode 100644 MaBibli.Shared/Textes/CleOeuvre.cs create mode 100644 MaBibli.Tests/BnfBibliographieParserTests.cs create mode 100644 MaBibli.Tests/CleOeuvreTests.cs create mode 100644 MaBibli.Tests/ExportSouhaitsTests.cs create mode 100644 MaBibli.Tests/Fixtures/bnf-auteur-werber.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-auteur-zola.xml create mode 100644 MaBibli.Tests/ServiceSouhaitsTests.cs diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 49ed2b7..53935b1 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -17,6 +17,13 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo public DbSet Prets => Set(); + /// + /// Liste d'envies, personnelle. Table séparée des : un livre + /// souhaité n'est pas un livre possédé, et ne doit apparaître ni au catalogue, ni dans les + /// compteurs, ni dans les prêts. + /// + public DbSet LivresSouhaites => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -90,6 +97,30 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo refus.HasIndex(r => new { r.AuteurAId, r.AuteurBId }).IsUnique(); }); + modelBuilder.Entity(souhait => + { + souhait.Property(s => s.Utilisateur).IsRequired(); + souhait.Property(s => s.Titre).IsRequired(); + souhait.Property(s => s.TitreNormalise).IsRequired(); + + // Jamais nullable : SQLite tient deux NULL pour distincts, et l'unicité ci-dessous + // laisserait alors passer autant de doublons qu'on veut sur les envies sans auteur. + souhait.Property(s => s.AuteurNormalise).IsRequired().HasDefaultValue(string.Empty); + + // Toute lecture part de là : la liste est personnelle, on la parcourt par personne. + souhait.HasIndex(s => s.Utilisateur, "IX_LivresSouhaites_Utilisateur"); + + // Une même œuvre ne s'ajoute qu'une fois par personne — l'écran de bibliographie + // rend le double clic facile. L'auteur fait partie de la clé : deux auteurs peuvent + // avoir publié deux « Œuvres » différentes, et l'un ne doit pas bloquer l'autre. + // Deux personnes peuvent en revanche souhaiter le même livre, d'où l'utilisateur + // en tête de l'index. + souhait.HasIndex( + s => new { s.Utilisateur, s.TitreNormalise, s.AuteurNormalise }, + "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + }); + modelBuilder.Entity(pret => { pret.Property(p => p.Emprunteur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.Designer.cs b/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.Designer.cs new file mode 100644 index 0000000..ce19c4d --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.Designer.cs @@ -0,0 +1,301 @@ +// +using System; +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + [DbContext(typeof(MaBibliDbContext))] + [Migration("20260818052834_ListeDEnvies")] + partial class ListeDEnvies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.cs b/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.cs new file mode 100644 index 0000000..ac731db --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818052834_ListeDEnvies.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class ListeDEnvies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LivresSouhaites", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Utilisateur = table.Column(type: "TEXT", nullable: false), + Titre = table.Column(type: "TEXT", nullable: false), + TitreNormalise = table.Column(type: "TEXT", nullable: false), + Auteur = table.Column(type: "TEXT", nullable: true), + AuteurNormalise = table.Column(type: "TEXT", nullable: false, defaultValue: ""), + Editeur = table.Column(type: "TEXT", nullable: true), + Annee = table.Column(type: "TEXT", nullable: true), + Isbn = table.Column(type: "TEXT", nullable: true), + CoverUrl = table.Column(type: "TEXT", nullable: true), + Note = table.Column(type: "TEXT", nullable: true), + DateAjout = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LivresSouhaites", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_LivresSouhaites_Utilisateur", + table: "LivresSouhaites", + column: "Utilisateur"); + + migrationBuilder.CreateIndex( + name: "IX_LivresSouhaites_Utilisateur_Oeuvre", + table: "LivresSouhaites", + columns: new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LivresSouhaites"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 9ba0d29..7ac7812 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -104,6 +104,61 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("LivreAuteurs"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => { b.Property("Id") diff --git a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs new file mode 100644 index 0000000..190aca7 --- /dev/null +++ b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs @@ -0,0 +1,162 @@ +using System.Text; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Identite; +using MaBibli.Api.Services.Souhaits; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Endpoints; + +public static class SouhaitsEndpoints +{ + /// + /// La liste d'envies et son export. + /// + /// + /// Aucun point d'entrée n'accepte de nom d'utilisateur, et il ne doit jamais y en + /// avoir : la liste rendue est toujours celle de l'appelant, identifié par les en-têtes + /// SSOwat. C'est la même règle que pour les statuts de lecture, et elle est ici encore plus + /// nette — le sens de la fonctionnalité est de préparer un cadeau sans que l'autre le voie. + /// + public static IEndpointRouteBuilder MapSouhaitsEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/souhaits").WithTags("Liste d'envies"); + + groupe.MapGet("/", async ( + IServiceSouhaits service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct))) + .WithName("ListerSouhaits") + .WithSummary("La liste d'envies de l'appelant, groupée par auteur.") + .WithDescription( + "Personnelle : deux membres du foyer obtiennent deux listes différentes. " + + "Sans identité, la liste est vide.") + .Produces>(); + + groupe.MapPost("/", async ( + EnregistrementSouhait saisie, + IServiceSouhaits service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var resultat = await service.AjouterAsync( + saisie, utilisateurs.Obtenir().Identifiant, ct); + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Created($"/api/souhaits/{resultat.Souhait!.Id}", resultat.Souhait); + }) + .WithName("AjouterSouhait") + .WithSummary("Ajoute un livre à la liste d'envies de l'appelant.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest); + + groupe.MapDelete("/{id:int}", async ( + int id, + IServiceSouhaits service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var supprime = await service.SupprimerAsync( + id, utilisateurs.Obtenir().Identifiant, ct); + + return supprime ? Results.NoContent() : Results.NotFound(); + }) + .WithName("SupprimerSouhait") + .WithSummary("Retire un livre de la liste d'envies de l'appelant.") + .WithDescription( + "404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas " + + "sont volontairement indiscernables.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + // Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt + // s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se + // répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les + // produire, ni pour les lire. + groupe.MapGet("/export.txt", ( + IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) => + ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct)) + .WithName("ExporterSouhaitsTexte") + .WithSummary("Liste d'envies en texte lisible, groupée par auteur.") + .ExcludeFromDescription(); + + groupe.MapGet("/export.csv", ( + IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) => + ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct)) + .WithName("ExporterSouhaitsCsv") + .WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.") + .ExcludeFromDescription(); + + return routes; + } + + /// + /// Produit le fichier d'export et le renvoie en téléchargement. + /// + /// + /// Le nom de fichier passé à Results.File pose un Content-Disposition: + /// attachment : le navigateur enregistre au lieu d'afficher, y compris sur mobile où un + /// .txt s'ouvrirait sinon dans l'onglet. C'est ce qui rend l'export réellement + /// « emportable ». + /// + /// Le fichier est produit ici plutôt que côté client : le client Blazor n'a pas la liste + /// complète sous la main, et un export généré par le serveur reste correct même si l'écran + /// affiche une vue filtrée. + /// + /// + private static async Task ExporterAsync( + FormatExportSouhaits format, + IServiceSouhaits service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) + { + var utilisateur = utilisateurs.Obtenir(); + var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct); + var maintenant = DateTime.Now; + + var contenu = ExportSouhaits.Produire( + format, souhaits, utilisateur.Affichage, maintenant); + + // Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le + // fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait + // pour être lu tel quel, et certains lecteurs simples affichent le BOM comme un + // caractère parasite en tête de la première ligne. + var corps = Encoding.UTF8.GetBytes(contenu); + var octets = format == FormatExportSouhaits.Csv + ? [.. ExportSouhaits.Bom, .. corps] + : corps; + + return Results.File( + octets, + ExportSouhaits.TypeMime(format), + ExportSouhaits.NomFichier(format, maintenant)); + } + + /// Bibliographie d'un auteur du catalogue, d'après la BnF. + public static IEndpointRouteBuilder MapBibliographieEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapGet("/api/auteurs/{id:int}/bibliographie", async ( + int id, + IServiceBibliographie service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var bibliographie = await service.ObtenirAsync( + id, utilisateurs.Obtenir().Identifiant, ct); + + return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie); + }) + .WithName("BibliographieAuteur") + .WithTags("Auteurs") + .WithSummary("Ce que la BnF connaît de cet auteur, confronté à l'étagère.") + .WithDescription( + "« Possédé » vaut pour tout le foyer, « souhaité » seulement pour l'appelant. " + + "Le rapprochement se fait par titre normalisé : il peut manquer un livre " + + "possédé sous un autre titre, d'où le fait qu'aucune ligne ne soit masquée.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + return routes; + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 286cc1c..96b200f 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -4,6 +4,7 @@ using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Prets; +using MaBibli.Api.Services.Souhaits; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -16,6 +17,8 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé // configuré en développement (il n'y a pas de portail YunoHost en local). @@ -73,6 +76,8 @@ app.MapIsbnEndpoints(); app.MapLivresEndpoints(); app.MapAuteursEndpoints(); app.MapPretsEndpoints(); +app.MapSouhaitsEndpoints(); +app.MapBibliographieEndpoints(); app.MapIdentiteEndpoints(); app.MapFallbackToFile("index.html"); diff --git a/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs new file mode 100644 index 0000000..575e4d5 --- /dev/null +++ b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs @@ -0,0 +1,208 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Textes; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Catalogue; + +public interface IServiceBibliographie +{ + /// + /// Bibliographie d'un auteur du catalogue, confrontée à ce que l'on possède et à ce que + /// l'appelant souhaite déjà. null si l'auteur n'existe pas. + /// + Task ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default); +} + +/// +/// Bibliographie d'un auteur : ce que la BnF connaît de lui, mis en regard de l'étagère. +/// +/// +/// Trois sources se rencontrent ici, et elles n'ont pas la même portée : +/// +/// la BnF, source externe, ni possédée ni personnelle ; +/// le catalogue, commun au foyer — « possédé » veut dire possédé par tout le +/// monde ; +/// la liste d'envies, personnelle — « déjà souhaité » ne vaut que pour l'appelant. +/// +/// Deux membres du foyer voient donc la même colonne « possédé » et deux colonnes « souhaité » +/// différentes. C'est voulu, et c'est ce qui permet d'offrir un livre à l'autre. +/// +public sealed class ServiceBibliographie( + MaBibliDbContext db, IBnfClient bnf, IServiceAuteurs auteurs) : IServiceBibliographie +{ + public async Task ObtenirAsync( + int auteurId, string? utilisateur, CancellationToken ct = default) + { + var auteur = await auteurs.ObtenirAsync(auteurId, ct); + if (auteur is null) + { + return null; + } + + var (resultat, avertissement) = await bnf.RechercherParAuteurAsync(auteur.Nom, ct); + + // Ce que l'on possède DE CET AUTEUR. On ne compare pas à tout le catalogue : deux auteurs + // peuvent avoir écrit deux livres homonymes, et griser l'un à cause de l'autre serait un + // faux positif — bien plus gênant qu'un oubli, puisqu'il détourne d'un achat justifié. + 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(); + var parIsbn = new Dictionary(); + + foreach (var livre in possedes) + { + var cle = CleOeuvre.Cle(livre.Titre); + if (cle.Length > 0) + { + parTitre.TryAdd(cle, livre.LivreId); + } + + // L'ISBN ne sert qu'en confirmation : il ne rapproche que la même édition exacte, + // alors que le titre rapproche l'œuvre. Il rattrape en revanche le cas où le titre + // saisi à la main diverge de celui de la BnF. + if (!string.IsNullOrWhiteSpace(livre.Isbn)) + { + parIsbn.TryAdd(livre.Isbn, livre.LivreId); + } + } + + var souhaits = await SouhaitsAsync(utilisateur, ct); + + var oeuvres = Regrouper(resultat) + .Select(o => Confronter(o, parTitre, parIsbn, souhaits)) + .OrderByDescending(o => AnneeTriable(o.Annee)) + .ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + return new BibliographieDto + { + Auteur = auteur, + Oeuvres = oeuvres, + NoticesAnnoncees = resultat.NombreAnnonce, + NoticesLues = resultat.NombreLu, + NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre, + Avertissement = avertissement, + }; + } + + /// Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie. + private async Task> SouhaitsAsync(string? utilisateur, CancellationToken ct) + { + if (utilisateur is null) + { + return []; + } + + var lignes = await db.LivresSouhaites + .AsNoTracking() + .Where(s => s.Utilisateur == utilisateur) + .Select(s => new { s.Id, s.TitreNormalise }) + .ToListAsync(ct); + + var index = new Dictionary(); + foreach (var ligne in lignes) + { + index.TryAdd(ligne.TitreNormalise, ligne.Id); + } + + return index; + } + + /// + /// Réunit les rééditions sous une seule œuvre. + /// + /// + /// C'est le cœur de l'utilisabilité de l'écran : sans regroupement, Bernard Werber occupe + /// 183 lignes dont « Les fourmis » onze fois. Après regroupement, 51 lignes. + /// + /// Le représentant retenu est la notice la plus ancienne : c'est l'édition originale, + /// donc l'année qui situe l'œuvre dans la carrière de l'auteur, et non celle du dernier + /// retirage en poche. Le titre et l'éditeur suivent la même notice pour rester cohérents, + /// mais l'ISBN est pris sur n'importe quelle notice qui en porte un : les éditions + /// anciennes n'en ont souvent aucun, et un ISBN de réédition reste utile pour commander. + /// + /// + public static IEnumerable Regrouper(ResultatBibliographie resultat) => + resultat.Notices + .Where(n => n.CleTitre.Length > 0) + .GroupBy(n => n.CleTitre) + .Select(groupe => + { + var ordonnees = groupe + .OrderBy(n => AnneeTriable(n.Annee) ?? int.MaxValue) + .ToList(); + + var reference = ordonnees[0]; + + return new OeuvreBibliographie + { + Titre = reference.Titre, + Annee = reference.Annee, + Editeur = reference.Editeur, + Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)), + NombreEditions = groupe.Count(), + }; + }); + + private static OeuvreBibliographie Confronter( + OeuvreBibliographie oeuvre, + IReadOnlyDictionary parTitre, + IReadOnlyDictionary parIsbn, + IReadOnlyDictionary souhaits) + { + var cle = CleOeuvre.Cle(oeuvre.Titre); + + int? livreId = parTitre.TryGetValue(cle, out var idTitre) ? idTitre : null; + + if (livreId is null + && !string.IsNullOrWhiteSpace(oeuvre.Isbn) + && parIsbn.TryGetValue(oeuvre.Isbn, out var idIsbn)) + { + livreId = idIsbn; + } + + int? souhaitId = souhaits.TryGetValue(cle, out var idSouhait) ? idSouhait : null; + + return oeuvre with + { + Possede = livreId is not null, + LivreId = livreId, + Souhaite = souhaitId is not null, + SouhaitId = souhaitId, + }; + } + + /// + /// Année exploitable pour le tri, extraite d'une date BnF. + /// + /// + /// dc:date n'est pas un nombre : on rencontre « 2012 », mais aussi « [DL 2012] », + /// « cop. 1991 » ou « 19..? ». On prend la première suite de quatre chiffres, et + /// null s'il n'y en a pas — une date illisible ne doit pas devenir l'an zéro et + /// remonter en tête de liste. + /// + public static int? AnneeTriable(string? date) + { + if (string.IsNullOrWhiteSpace(date)) + { + return null; + } + + for (var i = 0; i + 4 <= date.Length; i++) + { + var tranche = date.AsSpan(i, 4); + if (int.TryParse(tranche, out var annee) && annee is >= 1000 and <= 2999) + { + return annee; + } + } + + return null; + } +} diff --git a/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs b/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs new file mode 100644 index 0000000..6b41c0c --- /dev/null +++ b/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs @@ -0,0 +1,209 @@ +using System.Xml.Linq; +using MaBibli.Shared.Textes; + +namespace MaBibli.Api.Services.Isbn; + +/// Une notice BnF réduite à ce qui sert à bâtir une bibliographie. +public sealed record NoticeAuteur +{ + public required string Titre { get; init; } + + /// Clé d'œuvre du titre : c'est elle qui réunit les rééditions. + public required string CleTitre { get; init; } + + public string? Annee { get; init; } + + public string? Editeur { get; init; } + + public string? Isbn { get; init; } + + /// Auteurs nettoyés de la notice, tous rôles confondus. + public IReadOnlyList Auteurs { get; init; } = []; +} + +/// Résultat de la lecture d'une réponse SRU « par auteur ». +public sealed record ResultatBibliographie +{ + public IReadOnlyList Notices { get; init; } = []; + + /// Nombre de notices annoncé par la BnF, tous types confondus. + public int NombreAnnonce { get; init; } + + /// Notices lues dans cette réponse, avant tout filtrage. + public int NombreLu { get; init; } + + /// Notices écartées parce qu'elles ne décrivent pas un livre (disque, jeu, film…). + public int EcarteesTypeNonLivre { get; init; } + + /// Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé. + public int EcarteesAutreAuteur { get; init; } +} + +/// +/// Lecture d'une réponse SRU de la BnF interrogée par auteur +/// (bib.author all "…", recordSchema=dublincore). +/// +/// +/// Fonction pure, testée sur des réponses réelles enregistrées dans Fixtures/. +/// +/// Deux filtres, mesurés sur des réponses réelles le 2026-08-18, sont indispensables. +/// Sans eux l'écran de bibliographie ment à l'utilisateur. +/// +/// +/// +/// Le type de document. L'index auteur ne distingue pas un livre d'un livre audio, d'un +/// jeu vidéo ou d'un spectacle. Sur 200 notices de Bernard Werber : 180 imprimés, mais aussi +/// 13 enregistrements sonores, 3 images animées, 2 jeux multimédias et 1 spectacle. Une liste +/// d'envies de livres n'a pas à proposer d'acheter un jeu. +/// +/// +/// L'auteur réel de la notice. C'est le filtre critique, et il n'est pas +/// théorique. bib.author all "Émile Zola" rapproche les mots « émile » et « zola » sur +/// l'ensemble des auteurs d'une notice : un livre signé d'un « Émile » quelconque et d'un +/// « Zola » quelconque remonte donc aussi. Mesuré sur 200 notices d'« Émile Zola » : 67 sont +/// écartées, dont toute l'œuvre de sa fille Denise Le Blond-Zola (« Émile Zola raconté par +/// sa fille », « Zola et Cézanne »), qui parle de lui sans qu'il l'ait écrite. Les proposer à +/// l'achat comme du Zola serait faux. +/// +/// +/// +/// Ce second filtre a un coût assumé : il écarte aussi les adaptations dont l'auteur n'est +/// pas le scénariste — les bandes dessinées tirées de Werber sont signées Corbeyran, et +/// disparaissent (17 notices sur 200). C'est le bon compromis : afficher l'œuvre d'un tiers sous +/// le nom de l'auteur est une erreur visible et gênante, en omettre une adaptation ne l'est pas. +/// +/// +public static class BnfBibliographieParser +{ + private static readonly XNamespace Srw = "http://www.loc.gov/zing/srw/"; + private static readonly XNamespace Dc = "http://purl.org/dc/elements/1.1/"; + private static readonly XNamespace Xml = "http://www.w3.org/XML/1998/namespace"; + + /// + /// Types dc:type (en français) considérés comme des livres. + /// + /// + /// Liste positive et non liste d'exclusion : les types inconnus sont écartés. Une + /// bibliographie tronquée vaut mieux qu'une bibliographie qui propose un DVD. + /// + private static readonly HashSet TypesLivre = + [ + NormalisationTexte.Normaliser("texte imprimé"), + NormalisationTexte.Normaliser("ressource électronique"), + ]; + + /// Préfixe des dc:identifier qui portent un ISBN : « ISBN 2226086366 ». + private const string PrefixeIsbn = "ISBN "; + + /// + /// Convertit une réponse SRU en notices exploitables. + /// + /// Corps de la réponse SRU. + /// + /// Nom de l'auteur dont on veut la bibliographie. Chaque notice doit compter cet auteur + /// parmi les siens, au sens de — la même règle que celle + /// qui regroupe les auteurs du catalogue, donc tolérante à la casse, aux accents, à l'ordre + /// « Nom, Prénom » et aux initiales. + /// + public static ResultatBibliographie Parser(string xml, string auteurDemande) + { + var doc = XDocument.Parse(xml); + + var annonce = doc.Descendants(Srw + "numberOfRecords").FirstOrDefault()?.Value; + var notices = new List(); + int lues = 0, horsType = 0, autreAuteur = 0; + + foreach (var record in doc.Descendants(Srw + "record")) + { + var data = record.Element(Srw + "recordData"); + if (data is null) + { + continue; + } + + lues++; + + if (!EstUnLivre(data)) + { + horsType++; + continue; + } + + var auteurs = Tous(data, "creator") + .Select(NettoyageIsbd.Auteur) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a!) + .Distinct() + .ToList(); + + if (!auteurs.Any(a => RapprochementAuteurs.SontLeMemeAuteur(a, auteurDemande))) + { + autreAuteur++; + continue; + } + + var titre = NettoyageIsbd.Titre(Premier(data, "title")); + titre = NettoyageIsbd.RetirerAuteurEnFin(titre, auteurs); + + if (titre is null) + { + continue; + } + + notices.Add(new NoticeAuteur + { + Titre = titre, + CleTitre = CleOeuvre.Cle(titre), + Annee = Premier(data, "date"), + Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), + Isbn = Isbn(data), + Auteurs = auteurs, + }); + } + + return new ResultatBibliographie + { + Notices = notices, + NombreAnnonce = int.TryParse(annonce, out var n) ? n : lues, + NombreLu = lues, + EcarteesTypeNonLivre = horsType, + EcarteesAutreAuteur = autreAuteur, + }; + } + + /// + /// La notice décrit-elle un livre ? + /// + /// + /// Seuls les dc:type en français sont consultés : les variantes anglaises sont plus + /// grossières (« text » couvre aussi bien un imprimé qu'un enregistrement transcrit). + /// + private static bool EstUnLivre(XElement data) => + data.Descendants(Dc + "type") + .Where(t => (string?)t.Attribute(Xml + "lang") == "fre") + .Select(t => NormalisationTexte.Normaliser(t.Value)) + .Any(TypesLivre.Contains); + + /// + /// ISBN de la notice, extrait des dc:identifier. + /// + /// + /// Le Dublin Core BnF mélange dans ce champ l'ark du catalogue et, quand il existe, l'ISBN + /// préfixé du mot « ISBN ». Mesuré : 84 notices sur 100 en portent un, presque toujours sous + /// sa forme à 10 chiffres — la BnF indexant l'ISBN tel qu'imprimé (voir CLAUDE.md). + /// + private static string? Isbn(XElement data) => + Tous(data, "identifier") + .Where(v => v.StartsWith(PrefixeIsbn, StringComparison.OrdinalIgnoreCase)) + .Select(v => v[PrefixeIsbn.Length..].Trim()) + .FirstOrDefault(v => v.Length > 0); + + private static string? Premier(XElement data, string nomLocal) => + Tous(data, nomLocal).FirstOrDefault(); + + private static IEnumerable Tous(XElement data, string nomLocal) => + data.Descendants(Dc + nomLocal) + .Select(e => e.Value?.Trim()) + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => v!); +} diff --git a/MaBibli.Api/Services/Isbn/BnfClient.cs b/MaBibli.Api/Services/Isbn/BnfClient.cs index 1bdce1d..7671b44 100644 --- a/MaBibli.Api/Services/Isbn/BnfClient.cs +++ b/MaBibli.Api/Services/Isbn/BnfClient.cs @@ -11,6 +11,16 @@ public interface IBnfClient /// Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( string isbn, string? urlCouverture, CancellationToken ct = default); + + /// + /// Interroge le SRU par auteur pour bâtir une bibliographie. + /// + /// + /// Renvoie un résultat vide et un Avertissement si la BnF est injoignable : comme + /// pour le lookup ISBN, une source indisponible ne doit pas faire échouer l'écran. + /// + Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default); } /// @@ -59,4 +69,100 @@ public sealed class BnfClient(HttpClient http, ILogger logger) : IBnf return ([], $"Réponse BnF illisible pour {isbn}."); } } + + /// + /// Notices lues par page, et nombre de pages : au plus 200 notices. + /// + /// + /// Ce plafond est un arbitrage, pas une limite de l'API. Mesuré le 2026-08-18 : une requête + /// coûte ~0,85 s, et les deux pages sont donc lancées en parallèle. Aller au-delà + /// serait long pour un gain nul dans la plupart des cas — Bernard Werber tient en 203 + /// notices, Amélie Nothomb en 277 — et resterait de toute façon insuffisant pour un classique + /// très réédité : Émile Zola en compte 2692. Comme le SRU classe par pertinence, les deux + /// premières pages contiennent l'essentiel de l'œuvre ; le reste est de la réédition. + /// + /// L'interface doit signaler que la liste est un extrait quand la BnF en annonce plus + /// (BibliographieDto.Tronquee) : une bibliographie partielle présentée comme complète + /// ferait croire à tort qu'un livre n'existe pas. + /// + /// + public const int NoticesParPage = 100; + + public const int PagesMaximum = 2; + + public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(auteur)) + { + return (new ResultatBibliographie(), null); + } + + try + { + // Les pages sont lancées ensemble : à ~0,85 s l'unité, les enchaîner doublerait + // l'attente de l'écran pour rien. + var pages = await Task.WhenAll( + Enumerable.Range(0, PagesMaximum) + .Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct))); + + var lues = pages.Where(p => p is not null).Select(p => p!).ToList(); + if (lues.Count == 0) + { + return (new ResultatBibliographie(), $"La BnF n'a pas répondu pour « {auteur} »."); + } + + return (Fusionner(lues), null); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur); + return (new ResultatBibliographie(), $"BnF injoignable ({ex.GetType().Name}) pour « {auteur} »."); + } + catch (System.Xml.XmlException ex) + { + logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur); + return (new ResultatBibliographie(), $"Réponse BnF illisible pour « {auteur} »."); + } + } + + private async Task LirePageAsync( + string auteur, int premiereNotice, CancellationToken ct) + { + // bib.author : index auteur du catalogue général, vérifié le 2026-08-18. + // « all » exige que tous les mots soient présents — mais sur l'ENSEMBLE des auteurs de la + // notice, d'où le post-filtre de BnfBibliographieParser. + var url = "api/SRU" + + "?version=1.2" + + "&operation=searchRetrieve" + + $"&query={Uri.EscapeDataString($"bib.author all \"{auteur}\"")}" + + "&recordSchema=dublincore" + + $"&maximumRecords={NoticesParPage}" + + $"&startRecord={premiereNotice}"; + + using var reponse = await http.GetAsync(url, ct); + if (!reponse.IsSuccessStatusCode) + { + logger.LogWarning( + "BnF a répondu {Code} pour l'auteur {Auteur} (notice {Debut})", + (int)reponse.StatusCode, auteur, premiereNotice); + return null; + } + + var xml = await reponse.Content.ReadAsStringAsync(ct); + return BnfBibliographieParser.Parser(xml, auteur); + } + + /// Recolle les pages en un seul résultat, en additionnant les compteurs. + private static ResultatBibliographie Fusionner(IReadOnlyList pages) => + new() + { + Notices = pages.SelectMany(p => p.Notices).ToList(), + + // Le total annoncé est le même sur chaque page : on le prend, on ne l'additionne pas. + NombreAnnonce = pages.Max(p => p.NombreAnnonce), + NombreLu = pages.Sum(p => p.NombreLu), + EcarteesTypeNonLivre = pages.Sum(p => p.EcarteesTypeNonLivre), + EcarteesAutreAuteur = pages.Sum(p => p.EcarteesAutreAuteur), + }; } diff --git a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs new file mode 100644 index 0000000..1df7979 --- /dev/null +++ b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs @@ -0,0 +1,177 @@ +using System.Globalization; +using System.Text; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Souhaits; + +/// +/// Sérialisation de la liste d'envies en fichier emportable. +/// +/// +/// Fonctions pures : elles prennent une liste et rendent une chaîne, sans base ni HTTP. +/// C'est ce qui permet de vérifier le contenu exact du fichier en test plutôt que de se fier à +/// une inspection visuelle. +/// +/// Aucune dépendance, ni côté production ni côté lecture. IDEES.md demandait un format +/// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte. +/// +/// +public static class ExportSouhaits +{ + /// + /// Séparateur du CSV : le point-virgule, pas la virgule. + /// + /// + /// Le CSV n'a qu'un seul travail — s'ouvrir proprement dans un tableur. Or Excel en locale + /// française attend le point-virgule comme séparateur de liste : un fichier à virgules y + /// atterrit intégralement dans la colonne A, ce qui rend l'export inutilisable pour l'usage + /// visé (se répartir des achats avant un anniversaire). La collection étant francophone, + /// c'est ce cas-là qu'il faut servir. Les titres contenant un point-virgule restent corrects : + /// ils sont entre guillemets, conformément à RFC 4180. + /// + public const char SeparateurCsv = ';'; + + /// + /// Marque d'ordre des octets, indispensable en tête du CSV. + /// + /// + /// Sans elle, Excel lit un CSV en codage hérité et affiche « Émile Zola ». Sur une liste de + /// livres français, à peu près chaque ligne serait touchée. Le BOM ne gêne aucun autre + /// lecteur (LibreOffice, tableurs en ligne, éditeurs de texte). + /// + public static readonly byte[] Bom = Encoding.UTF8.GetPreamble(); + + public static string NomFichier(FormatExportSouhaits format, DateTime maintenant) => + $"liste-envies-{maintenant:yyyy-MM-dd}.{(format == FormatExportSouhaits.Csv ? "csv" : "txt")}"; + + public static string TypeMime(FormatExportSouhaits format) => + format == FormatExportSouhaits.Csv ? "text/csv; charset=utf-8" : "text/plain; charset=utf-8"; + + public static string Produire( + FormatExportSouhaits format, IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) => + format switch + { + FormatExportSouhaits.Csv => Csv(souhaits), + _ => Texte(souhaits, proprietaire, maintenant), + }; + + /// + /// Liste lisible telle quelle, groupée par auteur — la version qu'on emporte en librairie. + /// + /// + /// Le groupement par auteur n'est pas cosmétique : c'est ainsi qu'une librairie est rangée. + /// Une liste à plat obligerait à parcourir tout le fichier pour chaque rayon. + /// + public static string Texte(IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) + { + var sortie = new StringBuilder(); + + sortie.Append("Liste d'envies"); + if (!string.IsNullOrWhiteSpace(proprietaire)) + { + sortie.Append(" de ").Append(proprietaire); + } + + sortie.Append(" — ").Append(maintenant.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)).Append('\n'); + sortie.Append(new string('=', 40)).Append('\n'); + + if (souhaits.Count == 0) + { + sortie.Append("\n(aucun livre dans la liste)\n"); + return sortie.ToString(); + } + + sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); + + // « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins + // exploitables en rayon, elles ne doivent pas ouvrir le fichier. + var groupes = souhaits + .GroupBy(s => string.IsNullOrWhiteSpace(s.Auteur) ? null : s.Auteur!.Trim()) + .OrderBy(g => g.Key is null) + .ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase); + + foreach (var groupe in groupes) + { + sortie.Append('\n').Append(groupe.Key ?? "Auteur non précisé").Append('\n'); + + foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase)) + { + sortie.Append(" - ").Append(souhait.Titre); + + // Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on + // épelle à un libraire, et ça reste lisible sur un écran de téléphone. + var precisions = new[] { souhait.Editeur, souhait.Annee, Isbn(souhait.Isbn) } + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToList(); + + if (precisions.Count > 0) + { + sortie.Append(" (").Append(string.Join(", ", precisions)).Append(')'); + } + + sortie.Append('\n'); + + if (!string.IsNullOrWhiteSpace(souhait.Note)) + { + sortie.Append(" ").Append(souhait.Note.Trim()).Append('\n'); + } + } + } + + return sortie.ToString(); + } + + /// Tableau ouvrable dans un tableur, une envie par ligne. + public static string Csv(IReadOnlyList souhaits) + { + var sortie = new StringBuilder(); + + Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le"); + + foreach (var souhait in souhaits) + { + Ligne( + sortie, + souhait.Titre, + souhait.Auteur, + souhait.Editeur, + souhait.Annee, + souhait.Isbn, + souhait.Note, + souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + return sortie.ToString(); + } + + private static string? Isbn(string? isbn) => + string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {isbn}"; + + private static void Ligne(StringBuilder sortie, params string?[] champs) + { + sortie.AppendJoin(SeparateurCsv, champs.Select(Echapper)); + + // CRLF : RFC 4180 le prescrit, et c'est ce qu'attendent les tableurs sous Windows. + sortie.Append("\r\n"); + } + + /// + /// Échappement RFC 4180 : guillemets doublés, champ encadré dès qu'il contient un + /// séparateur, un guillemet ou un saut de ligne. + /// + /// + /// Une note libre peut contenir n'importe quoi : sans échappement, un simple « offert ; à + /// rendre » décalerait toutes les colonnes suivantes. + /// + private static string Echapper(string? champ) + { + var valeur = champ ?? string.Empty; + + var doitEncadrer = valeur.Contains(SeparateurCsv) + || valeur.Contains('"') + || valeur.Contains('\n') + || valeur.Contains('\r'); + + return doitEncadrer ? $"\"{valeur.Replace("\"", "\"\"")}\"" : valeur; + } +} diff --git a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs new file mode 100644 index 0000000..b9b284a --- /dev/null +++ b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs @@ -0,0 +1,170 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using MaBibli.Shared.Isbn; +using MaBibli.Shared.Textes; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Souhaits; + +/// Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur. +public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur) +{ + public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null); + + public static ResultatSouhait Invalide(string message) => new(null, message); +} + +public interface IServiceSouhaits +{ + Task> ListerAsync(string? utilisateur, CancellationToken ct = default); + + Task AjouterAsync( + EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); + + Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default); +} + +/// +/// La liste d'envies, personnelle. +/// +/// +/// ⚠️ Toute méthode filtre sur , sans exception. C'est la +/// différence de fond avec ServiceCatalogue, dont les lectures ne filtrent jamais sur +/// AjoutePar : le catalogue est commun, la liste d'envies ne l'est pas. Une envie qui +/// fuiterait vers un autre membre du foyer gâcherait exactement ce que la fonctionnalité sert +/// à préparer. +/// +/// Sans identité (ni en-tête SSOwat ni utilisateur simulé), il n'y a pas de liste : on ne rend +/// rien et on n'écrit rien, plutôt que de rattacher des envies à un propriétaire inventé. +/// +/// +public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits +{ + public async Task> ListerAsync( + string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return []; + } + + var souhaits = await db.LivresSouhaites + .AsNoTracking() + .Where(s => s.Utilisateur == utilisateur) + .OrderBy(s => s.AuteurNormalise == string.Empty) + .ThenBy(s => s.AuteurNormalise) + .ThenBy(s => s.TitreNormalise) + .ToListAsync(ct); + + return souhaits.Select(Projeter).ToList(); + } + + public async Task AjouterAsync( + EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return ResultatSouhait.Invalide( + "Impossible d'ajouter une envie sans savoir à qui elle appartient."); + } + + if (string.IsNullOrWhiteSpace(saisie.Titre)) + { + return ResultatSouhait.Invalide("Le titre est obligatoire."); + } + + // L'ISBN reste facultatif — on souhaite souvent une œuvre sans avoir choisi son édition — + // mais s'il est saisi il doit tenir debout, comme pour un livre du catalogue. + string? isbn = null; + if (!string.IsNullOrWhiteSpace(saisie.Isbn)) + { + var normalise = IsbnUtils.Normaliser(saisie.Isbn); + if (!IsbnUtils.EstValide(normalise)) + { + return ResultatSouhait.Invalide( + $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas."); + } + + isbn = normalise; + } + + var souhait = new LivreSouhaite + { + Utilisateur = utilisateur, + Titre = saisie.Titre, + Auteur = saisie.Auteur, + Editeur = Vide(saisie.Editeur), + Annee = Vide(saisie.Annee), + Isbn = isbn, + CoverUrl = Vide(saisie.CoverUrl), + Note = Vide(saisie.Note), + DateAjout = DateTime.UtcNow, + }; + + souhait.RecalculerFormes(); + + // Le doublon est refusé avec un message plutôt que laissé à l'index unique : l'écran de + // bibliographie rend le double clic facile, et « UNIQUE constraint failed » ne veut rien + // dire pour l'utilisateur. + var deja = await db.LivresSouhaites.AnyAsync( + s => s.Utilisateur == utilisateur + && s.TitreNormalise == souhait.TitreNormalise + && s.AuteurNormalise == souhait.AuteurNormalise, + ct); + + if (deja) + { + return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies."); + } + + db.LivresSouhaites.Add(souhait); + await db.SaveChangesAsync(ct); + + return ResultatSouhait.Ok(Projeter(souhait)); + } + + public async Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return false; + } + + // Le filtre sur l'utilisateur fait partie de la CLÉ de recherche, pas d'une vérification + // ultérieure : ainsi personne ne peut supprimer l'envie d'un autre en devinant son + // identifiant. Une envie inexistante et une envie appartenant à un autre sont + // indiscernables de l'extérieur, ce qui est bien le comportement voulu. + var souhait = await db.LivresSouhaites + .FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct); + + if (souhait is null) + { + return false; + } + + db.LivresSouhaites.Remove(souhait); + await db.SaveChangesAsync(ct); + return true; + } + + private static string? Vide(string? valeur) => + string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); + + /// + /// Projection vers le DTO. Sans le champ Utilisateur : il ne sort jamais de + /// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle. + /// + internal static SouhaitDto Projeter(LivreSouhaite souhait) => new() + { + Id = souhait.Id, + Titre = souhait.Titre, + Auteur = souhait.Auteur, + Editeur = souhait.Editeur, + Annee = souhait.Annee, + Isbn = souhait.Isbn, + CoverUrl = souhait.CoverUrl, + Note = souhait.Note, + DateAjout = souhait.DateAjout, + }; +} diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index 0760dc8..cb72cc4 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -70,6 +70,10 @@ else
  • @auteur.Nom @Livres(auteur.NombreDeLivres) + @* Le declencheur decrit dans IDEES.md : depuis un auteur qu'on a deja, voir + tout ce qu'il a ecrit et marquer ce qui manque. *@ + Bibliographie
  • } @@ -77,6 +81,7 @@ else @code { diff --git a/MaBibli.Client/Pages/Bibliographie.razor b/MaBibli.Client/Pages/Bibliographie.razor new file mode 100644 index 0000000..571e5a7 --- /dev/null +++ b/MaBibli.Client/Pages/Bibliographie.razor @@ -0,0 +1,245 @@ +@page "/auteurs/{AuteurId:int}/bibliographie" +@inject ServiceLivresApi Api + +MaBibli — bibliographie + +@if (_bibliographie is null && _erreur is null) +{ +

    Bibliographie

    +

    Interrogation de la BnF…

    +} +else if (_erreur is not null) +{ +

    Bibliographie

    + +} +else if (_bibliographie is { } biblio) +{ +

    @biblio.Auteur.Nom

    + + @if (biblio.Avertissement is not null) + { + + } + + @if (biblio.Oeuvres.Count == 0) + { +

    + 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. +

    + } + else + { + @* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité, + la liste n'est qu'un extrait : le taire ferait croire qu'un livre absent n'existe pas. *@ +

    + @biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "") + — @_possedees déjà chez vous +

    + + @* 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 »). *@ +

    + @if (biblio.Tronquee) + { + + D'après le catalogue de la BnF, à partir de @biblio.NoticesLues notices + sur les @biblio.NoticesAnnoncees qu'elle annonce : + cette liste est un extrait, classé par pertinence. + Un livre absent d'ici peut très bien exister. + + } + else + { + + D'après le catalogue de la BnF, sur + @biblio.NoticesLues notice@(biblio.NoticesLues > 1 ? "s" : ""). + + } +

    + + @if (biblio.NoticesEcartees > 0) + { +

    + @biblio.NoticesEcartees notice@(biblio.NoticesEcartees > 1 ? "s ont" : " a") + été écartée@(biblio.NoticesEcartees > 1 ? "s" : "") : autre support (livre audio, + jeu) ou ouvrage signé de quelqu'un d'autre. +

    + } + + @if (_message is not null) + { + + } + + @* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne + sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@ +
    +
    + + +
    +
    + +
      + @foreach (var oeuvre in Affichees(biblio)) + { + @* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@ +
    • +
      +

      + @if (oeuvre is { Possede: true, LivreId: { } livreId }) + { + @oeuvre.Titre + } + else + { + @oeuvre.Titre + } +

      + +

      + @if (!string.IsNullOrWhiteSpace(oeuvre.Annee)) + { + @oeuvre.Annee + } + @if (!string.IsNullOrWhiteSpace(oeuvre.Editeur)) + { + @oeuvre.Editeur + } + @if (oeuvre.NombreEditions > 1) + { + @oeuvre.NombreEditions éditions + } + + @if (oeuvre.Possede) + { + Déjà chez vous + } + else if (oeuvre.Souhaite) + { + Dans vos envies + } +

      +
      + + @if (oeuvre.ADecouvrir) + { + + } +
    • + } +
    + + @* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire + franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il + possède n'est pas grisé. *@ +

    + Le rapprochement avec votre bibliothèque se fait par titre. Un livre + que vous possédez sous un titre différent (traduction, intégrale, édition retitrée) + n'apparaîtra donc pas comme possédé. Rien n'est masqué : à vous de trancher. +

    + } +} + + + +@code { + [Parameter] + public int AuteurId { get; set; } + + private BibliographieDto? _bibliographie; + private bool _seulementManquants; + private bool _enCours; + private string? _erreur; + private string? _message; + + private int _possedees; + + protected override async Task OnParametersSetAsync() => await ChargerAsync(); + + private IEnumerable Affichees(BibliographieDto biblio) => + _seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres; + + private async Task ChargerAsync() + { + _erreur = null; + + try + { + _bibliographie = await Api.ObtenirBibliographieAsync(AuteurId); + + if (_bibliographie is null) + { + _erreur = "Cet auteur n'existe pas dans votre bibliothèque."; + return; + } + + _possedees = _bibliographie.Oeuvres.Count(o => o.Possede); + } + catch (Exception ex) + { + _erreur = $"Impossible d'obtenir la bibliographie : {ex.Message}"; + } + } + + /// + /// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait. + /// + /// + /// L'auteur enregistré est celui de l'écran, pas celui de la notice : c'est lui que + /// l'utilisateur a en tête, et c'est sous ce nom qu'il retrouvera l'envie dans l'export. + /// + /// Après l'ajout, la bibliographie est relue plutôt que corrigée en mémoire : c'est + /// le serveur qui sait rapprocher l'envie de l'œuvre, et refaire ce calcul ici le + /// dupliquerait — avec le risque qu'il diverge. + /// + /// + private async Task SouhaiterAsync(OeuvreBibliographie oeuvre) + { + if (_bibliographie is null) + { + return; + } + + _enCours = true; + _message = null; + + var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait + { + Titre = oeuvre.Titre, + Auteur = _bibliographie.Auteur.Nom, + Editeur = oeuvre.Editeur, + Annee = oeuvre.Annee, + + // L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois + // épuisée. Il est repris tel quel : il aide à commander, sans engager le choix. + Isbn = oeuvre.Isbn, + }); + + _enCours = false; + + if (!resultat.EstOk) + { + _message = resultat.Erreur; + return; + } + + await ChargerAsync(); + } +} diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index 945471f..7f9c907 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -126,6 +126,7 @@ else if (_livres is not null) Saisie manuelle Prêts Auteurs + Envies @code { diff --git a/MaBibli.Client/Pages/Souhaits.razor b/MaBibli.Client/Pages/Souhaits.razor new file mode 100644 index 0000000..238c1e4 --- /dev/null +++ b/MaBibli.Client/Pages/Souhaits.razor @@ -0,0 +1,241 @@ +@page "/souhaits" +@inject ServiceLivresApi Api + +MaBibli — liste d'envies + +

    Liste d'envies

    + +@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas. + Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@ +

    + Les livres que vous aimeriez acquérir. Cette liste est la vôtre : + les autres membres du foyer ont la leur, et ne voient pas celle-ci. + Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque. +

    + +@if (_erreur is not null) +{ + +} + +@if (_souhaits is null) +{ +

    Chargement…

    +} +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) + { +

    + Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un + auteur depuis la liste des auteurs. +

    + } + else + { +

    @_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")

    + + @* 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. *@ + + +
      + @foreach (var souhait in _souhaits) + { +
    • + + +
      +

      @souhait.Titre

      + + @if (!string.IsNullOrWhiteSpace(souhait.Auteur)) + { +

      @souhait.Auteur

      + } + +

      + @if (!string.IsNullOrWhiteSpace(souhait.Editeur)) + { + @souhait.Editeur + } + @if (!string.IsNullOrWhiteSpace(souhait.Annee)) + { + @souhait.Annee + } + @if (!string.IsNullOrWhiteSpace(souhait.Isbn)) + { + ISBN @souhait.Isbn + } +

      + + @if (!string.IsNullOrWhiteSpace(souhait.Note)) + { +

      @souhait.Note

      + } +
      + + +
    • + } +
    + } +} + + + +@code { + private IReadOnlyList? _souhaits; + private EnregistrementSouhait _saisie = new(); + private string _isbn = string.Empty; + private bool _ajout; + private bool _recherche; + private bool _enCours; + private string? _erreur; + + protected override Task OnInitializedAsync() => ChargerAsync(); + + private async Task ChargerAsync() + { + try + { + _souhaits = await Api.ListerSouhaitsAsync(); + } + catch (Exception ex) + { + _erreur = $"Impossible de charger la liste d'envies : {ex.Message}"; + } + } + + /// + /// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary). + /// + /// + /// 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. + /// + private async Task ChercherIsbnAsync() + { + _recherche = true; + _erreur = null; + + try + { + 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(); + } + + private async Task SupprimerAsync(SouhaitDto souhait) + { + _enCours = true; + _erreur = await Api.SupprimerSouhaitAsync(souhait.Id); + _enCours = false; + + if (_erreur is null) + { + await ChargerAsync(); + } + } +} diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 4f468f8..ba2f160 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -231,6 +231,63 @@ public sealed class ServiceLivresApi(HttpClient http) }; } + // ───────────────────────────────────────────────────────────────────────── + // Liste d'envies et bibliographie par auteur. + // Bloc ajouté en fin de classe pour rester bien délimité. + // ───────────────────────────────────────────────────────────────────────── + + /// + /// La liste d'envies de l'utilisateur courant. + /// + /// + /// 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) + { + var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + 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); + } + + /// + /// Adresse de téléchargement d'un export. + /// + /// + /// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un + /// Content-Disposition: attachment et le navigateur enregistre. Passer par + /// obligerait à fabriquer une URL d'objet en JavaScript pour un + /// résultat identique. + /// + /// ⚠️ Le lien doit porter l'attribut download : sans lui, le routeur Blazor + /// intercepte le clic comme une navigation interne et affiche sa page « introuvable » au + /// lieu de laisser le navigateur télécharger. + /// + /// + public static string UrlExportSouhaits(FormatExportSouhaits format) => + format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt"; + + /// + /// Bibliographie d'un auteur d'après la BnF. null si l'auteur n'existe pas. + /// + public async Task ObtenirBibliographieAsync( + int auteurId, CancellationToken ct = default) + { + var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct); + return reponse.StatusCode == HttpStatusCode.NotFound + ? null + : await LireAsync(reponse, ct); + } + private sealed record ErreurApi(string? Erreur); } diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index eb474d4..a4f64a1 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -738,3 +738,99 @@ body { margin: 0; font-size: 0.95rem; } + +/* ───────────────────────────────────────────────────────────────────────────── + Liste d'envies et bibliographie par auteur. + Mobile d'abord : une colonne, l'action à droite du texte, rien qui déborde. + ───────────────────────────────────────────────────────────────────────────── */ + +.bloc-souhait-ajout { + background: var(--mb-fond-doux); + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 1rem; +} + +/* Le champ ISBN et son bouton sur une ligne : le bouton ne doit pas se rétrécir + au point de devenir intouchable au pouce quand le champ est large. */ +.ligne-isbn { + display: flex; + gap: 0.5rem; +} + +.ligne-isbn .champ-saisie { + flex: 1; + min-width: 0; +} + +.ligne-isbn .bouton { + flex-shrink: 0; +} + +.actions-export { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.liste-souhaits, +.liste-bibliographie { + list-style: none; + margin: 0; + padding: 0; +} + +.carte-souhait, +.carte-oeuvre { + display: flex; + gap: 0.75rem; + align-items: flex-start; + padding: 0.75rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +/* Le corps prend la place restante et « min-width: 0 » l'autorise à rétrécir : + sans lui, un titre long pousse le bouton hors de l'écran sur mobile. */ +.carte-souhait .carte-corps, +.carte-oeuvre .carte-corps { + flex: 1; + min-width: 0; +} + +.carte-souhait .bouton, +.carte-oeuvre .bouton { + flex-shrink: 0; + align-self: center; +} + +.souhait-note { + margin: 0.25rem 0 0; + color: var(--mb-texte-doux); + font-size: 0.9rem; + font-style: italic; +} + +/* Le grisage des livres déjà possédés : c'est l'information principale de l'écran + de bibliographie. L'opacité seule suffirait visuellement, mais elle ne dit rien + à un lecteur d'écran — d'où l'étiquette « Déjà chez vous » qui l'accompagne + toujours dans le balisage. */ +.oeuvre-possedee { + opacity: 0.55; +} + +.oeuvre-possedee .carte-titre { + font-weight: 400; +} + +.etiquette-possede { + background: #e3ece3; + color: #2f5d34; + font-weight: 600; +} + +.etiquette-souhaite { + background: #e6e6f5; + color: #3b3b7a; + font-weight: 600; +} diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs new file mode 100644 index 0000000..bdac49b --- /dev/null +++ b/MaBibli.Shared/Dtos/SouhaitDto.cs @@ -0,0 +1,137 @@ +using System.Text.Json.Serialization; + +namespace MaBibli.Shared.Dtos; + +/// +/// Une envie telle qu'exposée par l'API. +/// +/// +/// Il n'y a aucun champ « utilisateur », et il ne doit jamais y en avoir : l'API ne rend +/// que les envies de l'appelant, qu'elle identifie par les en-têtes SSOwat. Exposer le +/// propriétaire laisserait croire qu'on peut demander celles d'un autre. +/// +public record SouhaitDto +{ + public required int Id { get; init; } + + public required string Titre { get; init; } + + public string? Auteur { get; init; } + + public string? Editeur { get; init; } + + public string? Annee { get; init; } + + public string? Isbn { get; init; } + + public string? CoverUrl { get; init; } + + public string? Note { get; init; } + + public required DateTime DateAjout { get; init; } +} + +/// Charge utile d'ajout d'une envie. +/// +/// Comme , elle ne porte pas d'identité : le propriétaire est +/// déterminé par le serveur. +/// +public record EnregistrementSouhait +{ + public string Titre { get; set; } = string.Empty; + + public string? Auteur { get; set; } + + public string? Editeur { get; set; } + + public string? Annee { get; set; } + + public string? Isbn { get; set; } + + public string? CoverUrl { get; set; } + + public string? Note { get; set; } +} + +/// Formats d'export de la liste d'envies. +/// +/// Les deux usages décrits dans IDEES.md ne demandent pas la même chose : « l'emporter en +/// librairie » veut un texte qui se lit tel quel sur un téléphone, « la partager avant un +/// anniversaire » finit souvent dans un tableur pour se répartir les achats. Ni l'un ni l'autre +/// n'exige de dépendance : ce sont deux fichiers texte produits par du string. +/// +public enum FormatExportSouhaits +{ + /// Liste lisible telle quelle, groupée par auteur. + Texte = 0, + + /// Tableau ouvrable dans un tableur. + Csv = 1, +} + +/// Une œuvre de la bibliographie d'un auteur, confrontée à ce que l'on possède déjà. +public record OeuvreBibliographie +{ + public required string Titre { get; init; } + + public string? Annee { get; init; } + + public string? Editeur { get; init; } + + /// ISBN d'une des éditions relevées, quand la notice en portait un. + public string? Isbn { get; init; } + + /// + /// Nombre de notices BnF réunies sous cette œuvre — c'est-à-dire le nombre de rééditions + /// relevées. Sert à repérer les œuvres majeures d'un auteur très réédité. + /// + public int NombreEditions { get; init; } + + /// + /// Vrai si un livre du catalogue porte le même titre d'œuvre. + /// Rapprochement par titre, donc faillible — voir CleOeuvre. + /// + public bool Possede { get; init; } + + /// Le livre possédé, s'il y en a un : de quoi ouvrir sa fiche. + public int? LivreId { get; init; } + + /// Vrai si l'appelant a déjà cette œuvre dans sa liste d'envies. + public bool Souhaite { get; init; } + + public int? SouhaitId { get; init; } + + /// Ni possédé, ni déjà souhaité : ce qui reste à découvrir. + [JsonIgnore] + public bool ADecouvrir => !Possede && !Souhaite; +} + +/// +/// Bibliographie d'un auteur du catalogue, telle que la BnF la connaît. +/// +/// +/// Les compteurs ne sont pas décoratifs : ils disent à l'utilisateur ce qu'il regarde. Sur un +/// auteur très réédité (Émile Zola : 2692 notices), on ne montre qu'un extrait classé par +/// pertinence, et le taire ferait passer une liste partielle pour une bibliographie complète. +/// +public record BibliographieDto +{ + public required AuteurDto Auteur { get; init; } + + public IReadOnlyList Oeuvres { get; init; } = []; + + /// Nombre total de notices que la BnF déclare pour cet auteur. + public int NoticesAnnoncees { get; init; } + + /// Nombre de notices réellement lues (plafonné). + public int NoticesLues { get; init; } + + /// Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé. + public int NoticesEcartees { get; init; } + + /// Vrai si la BnF en annonce plus qu'on n'en a lu : la liste est un extrait. + public bool Tronquee => NoticesAnnoncees > NoticesLues; + + /// Message à afficher quand la source n'a pas pu être interrogée normalement. + public string? Avertissement { get; init; } +} diff --git a/MaBibli.Shared/Entites/LivreSouhaite.cs b/MaBibli.Shared/Entites/LivreSouhaite.cs new file mode 100644 index 0000000..b20fd3e --- /dev/null +++ b/MaBibli.Shared/Entites/LivreSouhaite.cs @@ -0,0 +1,85 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Shared.Entites; + +/// +/// Un livre que l'on aimerait acquérir. Personnel : chacun sa liste. +/// +/// +/// Pourquoi une entité séparée plutôt qu'un statut de plus sur ? +/// Un livre souhaité n'est pas un livre possédé. S'il était une ligne de Livres, il +/// entrerait mécaniquement dans le catalogue, dans les compteurs, dans les prêts et dans le +/// cache hors-ligne — et il faudrait ajouter « et qui n'est pas souhaité » à chaque +/// lecture du catalogue. Un invariant qu'il faut réécrire à chaque requête finit toujours par +/// être oublié quelque part ; ici l'inventaire et l'envie ne se croisent tout simplement pas, +/// parce qu'ils ne vivent pas dans la même table. +/// +/// C'est aussi la seule forme qui accepte une envie sans ISBN ni édition arrêtée : on +/// souhaite une œuvre (« le dernier Werber »), on possède un exemplaire. Un souhait n'a donc +/// ni ni couverture obligatoire, et ne peut pas être prêté. +/// +/// +/// La portée personnelle suit , dont la documentation annonçait déjà +/// ce voisinage : est une frontière, contrairement à +/// . On ne lit jamais la liste d'envies de quelqu'un d'autre — +/// c'est précisément ce qui permet d'offrir un livre sans vendre la mèche. +/// +/// +public class LivreSouhaite +{ + public int Id { get; set; } + + /// + /// YNH_USER du propriétaire de l'envie. Frontière : toute lecture filtre dessus. + /// + public string Utilisateur { get; set; } = string.Empty; + + public string Titre { get; set; } = string.Empty; + + /// + /// Titre mis à plat, servant à la fois au dédoublonnage et au rapprochement avec le + /// catalogue (« ai-je déjà ce livre ? »). Recalculé à chaque écriture. + /// + public string TitreNormalise { get; set; } = string.Empty; + + /// + /// Auteur en texte libre, volontairement pas une clé étrangère vers . + /// + /// + /// La table Auteurs décrit qui est dans la bibliothèque : y insérer les auteurs + /// souhaités les ferait apparaître sur l'écran des auteurs avec « 0 livre », et exposerait la + /// liste d'envies d'une personne à tout le foyer — l'inverse de ce qu'on veut. Le texte libre + /// survit en outre à la disparition de l'auteur du catalogue. + /// + public string? Auteur { get; set; } + + /// + /// Auteur mis à plat. Jamais null (chaîne vide si l'auteur est inconnu) : + /// SQLite considère deux NULL comme distincts, et l'index unique laisserait alors + /// passer des doublons d'envies sans auteur. + /// + public string AuteurNormalise { get; set; } = string.Empty; + + public string? Editeur { get; set; } + + /// Année de publication, telle que la source la donne. Texte : la BnF écrit « 2012 », mais aussi « [DL 2012] ». + public string? Annee { get; set; } + + /// ISBN d'une édition repérée, quand il y en a une. Facultatif : on souhaite une œuvre, pas forcément une édition. + public string? Isbn { get; set; } + + public string? CoverUrl { get; set; } + + /// Note libre : « demandé à Noël », « en poche seulement », « chez Untel ». + public string? Note { get; set; } + + public DateTime DateAjout { get; set; } + + public void RecalculerFormes() + { + Titre = Titre.Trim(); + TitreNormalise = CleOeuvre.Cle(Titre); + Auteur = string.IsNullOrWhiteSpace(Auteur) ? null : Auteur.Trim(); + AuteurNormalise = RapprochementAuteurs.Cle(Auteur); + } +} diff --git a/MaBibli.Shared/Textes/CleOeuvre.cs b/MaBibli.Shared/Textes/CleOeuvre.cs new file mode 100644 index 0000000..fb2e62e --- /dev/null +++ b/MaBibli.Shared/Textes/CleOeuvre.cs @@ -0,0 +1,74 @@ +namespace MaBibli.Shared.Textes; + +/// +/// Clé d'identification d'une œuvre à partir d'un titre, indépendamment de l'édition. +/// +/// +/// C'est le rapprochement le moins mauvais dont on dispose, et il faut être clair sur ses limites. +/// +/// Pourquoi pas l'ISBN ? Parce qu'il désigne une édition, pas une œuvre : le poche +/// de 1998, le grand format de 1991 et la réédition de 2015 portent trois ISBN différents pour le +/// même roman. Mesuré sur la bibliographie BnF de Bernard Werber : 183 notices de livres pour +/// 51 œuvres, chacune revenant en moyenne trois fois sous trois ISBN. Comparer les ISBN +/// répondrait donc « vous ne l'avez pas » à propos d'un livre posé sur l'étagère, ce qui est le +/// pire résultat possible pour une liste d'envies. +/// +/// +/// Ce que la clé fait. Elle coupe la mention de responsabilité ISBD (« / ») puis +/// le sous-titre (« : »), et met le reste à plat. C'est ce qui réunit +/// « Les thanatonautes », « Les thanatonautes : roman » et +/// « Les thanatonautes / Bernard Werber ». +/// +/// +/// Ce qu'elle rate, et qu'il ne faut pas prétendre autrement : +/// +/// Deux œuvres distinctes partageant un titre principal sont confondues (« Œuvres », +/// « Nouvelles »). Le risque est borné : la comparaison n'a lieu qu'à auteur donné. +/// Un tome n'est pas rapproché de son recueil : « Troisième humanité » et +/// « Troisième humanité. Tome 1 » restent deux lignes. Le point n'est pas coupé, faute de savoir +/// distinguer « Tome 1 » d'un titre qui contient légitimement un point. +/// Un titre retraduit ou changé au fil des rééditions n'est pas rapproché du tout. +/// Une œuvre possédée à l'intérieur d'une intégrale n'est pas repérée : l'intégrale a son +/// propre titre. +/// +/// Conséquence assumée : l'écran de bibliographie peut afficher comme « à découvrir » un livre +/// déjà présent sous un autre titre. Il ne fait donc que griser ce qu'il reconnaît, sans +/// jamais masquer une ligne — un faux négatif se voit et se corrige d'un coup d'œil, alors qu'une +/// ligne masquée à tort serait invisible. +/// +/// +public static class CleOeuvre +{ + /// Sépare le titre de la mention de responsabilité : « Germinal / Émile Zola ». + private const string SeparateurResponsabilite = " / "; + + /// Sépare le titre de son sous-titre : « Les thanatonautes : roman ». + private const string SeparateurSousTitre = " : "; + + /// + /// Clé comparable d'un titre : « Les thanatonautes : roman » → les thanatonautes. + /// + public static string Cle(string? titre) + { + var s = titre?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return string.Empty; + } + + s = Tronquer(s, SeparateurResponsabilite); + var sansSousTitre = Tronquer(s, SeparateurSousTitre); + + // Un titre qui n'est QUE son sous-titre (« : roman ») ne doit pas se réduire à rien : + // mieux vaut alors garder la chaîne entière que produire une clé vide, qui se + // confondrait avec toutes les autres clés vides. + var retenu = NormalisationTexte.Normaliser(sansSousTitre); + return retenu.Length > 0 ? retenu : NormalisationTexte.Normaliser(s); + } + + private static string Tronquer(string s, string separateur) + { + var i = s.IndexOf(separateur, StringComparison.Ordinal); + return i >= 0 ? s[..i].Trim() : s; + } +} diff --git a/MaBibli.Tests/BnfBibliographieParserTests.cs b/MaBibli.Tests/BnfBibliographieParserTests.cs new file mode 100644 index 0000000..68c2f1d --- /dev/null +++ b/MaBibli.Tests/BnfBibliographieParserTests.cs @@ -0,0 +1,175 @@ +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.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 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)); +} diff --git a/MaBibli.Tests/CleOeuvreTests.cs b/MaBibli.Tests/CleOeuvreTests.cs new file mode 100644 index 0000000..edb33e4 --- /dev/null +++ b/MaBibli.Tests/CleOeuvreTests.cs @@ -0,0 +1,82 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Tests; + +/// +/// Clé d'œuvre : ce qu'elle réunit, et surtout ce qu'elle rate. +/// +/// +/// Les cas d'échec sont testés au même titre que les réussites : ils documentent la limite du +/// rapprochement, que l'interface annonce à l'utilisateur. Si l'un d'eux se met à passer un +/// jour, c'est que la règle a changé — et le message affiché doit changer avec. +/// +public class CleOeuvreTests +{ + [Theory] + // Les trois formes rencontrées pour un même roman de Werber. + [InlineData("Les thanatonautes", "Les thanatonautes : roman")] + [InlineData("Les thanatonautes", "Les thanatonautes / Bernard Werber")] + [InlineData("Les thanatonautes : roman", "Les thanatonautes / Bernard Werber")] + // Casse et accents, comme partout ailleurs dans le projet. + [InlineData("Germinal", "GERMINAL")] + public void Reunit_les_variantes_d_un_meme_titre(string a, string b) => + Assert.Equal(CleOeuvre.Cle(a), CleOeuvre.Cle(b)); + + [Theory] + [InlineData("Germinal", "La bête humaine")] + // Un tome n'est pas rapproché de l'œuvre entière : le point n'est pas coupé. + [InlineData("Troisième humanité", "Troisième humanité. Tome 1")] + public void Distingue_deux_oeuvres_differentes(string a, string b) => + Assert.NotEqual(CleOeuvre.Cle(a), CleOeuvre.Cle(b)); + + /// + /// Limite assumée : deux œuvres distinctes partageant leur titre principal se confondent. + /// Le risque est borné, la comparaison n'ayant lieu qu'à auteur donné. + /// + [Fact] + public void Confond_deux_oeuvres_partageant_le_titre_principal() => + Assert.Equal( + CleOeuvre.Cle("Nouvelles : premier recueil"), + CleOeuvre.Cle("Nouvelles : second recueil")); + + /// + /// Limite héritée de , pas propre à la clé d'œuvre : + /// la ligature « œ » n'est pas ramenée à « oe ». + /// + /// + /// NormalisationTexte décompose en NFD, qui sépare les accents mais laisse les + /// ligatures intactes — seul NFKD les défait. Conséquence concrète : L'Œuvre de Zola, + /// que la BnF écrit avec la ligature, n'est pas rapproché d'un « L'oeuvre » saisi au clavier. + /// + /// Le défaut ne vient pas d'ici et ne se corrige pas ici : la même fonction alimente la + /// recherche du catalogue et la clé unique des auteurs, et en changer déplacerait ces deux + /// invariants. Consigné dans IDEES.md ; ce test existe pour que la limite soit visible plutôt + /// que découverte en usage. + /// + /// + [Fact] + public void Ne_reduit_pas_la_ligature_oe() => + Assert.NotEqual(CleOeuvre.Cle("L'Œuvre"), CleOeuvre.Cle("L'oeuvre")); + + /// + /// Limite assumée : un titre retraduit n'est pas rapproché du tout. + /// + [Fact] + public void Ne_rapproche_pas_un_titre_retraduit() => + Assert.NotEqual(CleOeuvre.Cle("Le Seigneur des anneaux"), CleOeuvre.Cle("The Lord of the Rings")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Rend_une_cle_vide_pour_une_saisie_vide(string? titre) => + Assert.Equal(string.Empty, CleOeuvre.Cle(titre)); + + /// + /// Un titre réduit à son seul sous-titre ne doit pas produire une clé vide : elle se + /// confondrait avec toutes les autres clés vides. + /// + [Fact] + public void Ne_vide_pas_un_titre_qui_commence_par_le_separateur() => + Assert.NotEqual(string.Empty, CleOeuvre.Cle(": roman")); +} diff --git a/MaBibli.Tests/ExportSouhaitsTests.cs b/MaBibli.Tests/ExportSouhaitsTests.cs new file mode 100644 index 0000000..8c5dcda --- /dev/null +++ b/MaBibli.Tests/ExportSouhaitsTests.cs @@ -0,0 +1,185 @@ +using System.Text; +using MaBibli.Api.Services.Souhaits; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Tests; + +/// +/// Contenu exact des fichiers d'export. Fonctions pures : rien à simuler. +/// +public class ExportSouhaitsTests +{ + private static readonly DateTime Jour = new(2026, 8, 18, 10, 0, 0, DateTimeKind.Utc); + + private static SouhaitDto Souhait( + int id, string titre, string? auteur = null, string? editeur = null, + string? annee = null, string? isbn = null, string? note = null) => + new() + { + Id = id, + Titre = titre, + Auteur = auteur, + Editeur = editeur, + Annee = annee, + Isbn = isbn, + Note = note, + DateAjout = Jour, + }; + + [Fact] + public void Le_texte_groupe_les_livres_par_auteur() + { + var texte = ExportSouhaits.Texte( + [ + Souhait(1, "La prophétie des abeilles", "Bernard Werber"), + Souhait(2, "Germinal", "Émile Zola"), + Souhait(3, "Les fourmis", "Bernard Werber"), + ], + "Mathieu", + Jour); + + Assert.Contains("Liste d'envies de Mathieu — 18/08/2026", texte); + Assert.Contains("3 livres", texte); + + // Un seul en-tête par auteur, et ses deux titres regroupés dessous. + var lignes = texte.Split('\n'); + Assert.Single(lignes, l => l == "Bernard Werber"); + + var debutWerber = Array.IndexOf(lignes, "Bernard Werber"); + Assert.Equal(" - La prophétie des abeilles", lignes[debutWerber + 1]); + Assert.Equal(" - Les fourmis", lignes[debutWerber + 2]); + + // Werber avant Zola : les auteurs sont classés alphabétiquement. + Assert.True(debutWerber < Array.IndexOf(lignes, "Émile Zola")); + } + + [Fact] + public void Le_texte_range_les_livres_sans_auteur_en_dernier() + { + var texte = ExportSouhaits.Texte( + [Souhait(1, "Titre orphelin"), Souhait(2, "Germinal", "Émile Zola")], + null, + Jour); + + Assert.True(texte.IndexOf("Émile Zola", StringComparison.Ordinal) + < texte.IndexOf("Auteur non précisé", StringComparison.Ordinal)); + } + + [Fact] + public void Le_texte_porte_les_precisions_utiles_en_librairie() + { + var texte = ExportSouhaits.Texte( + [Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "2253004227", "en poche seulement")], + null, + Jour); + + Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2253004227)", texte); + Assert.Contains(" en poche seulement", texte); + } + + [Fact] + public void Le_texte_le_dit_quand_la_liste_est_vide() + { + var texte = ExportSouhaits.Texte([], "Mathieu", Jour); + + Assert.Contains("(aucun livre dans la liste)", texte); + } + + [Fact] + public void Le_csv_porte_un_en_tete_et_une_ligne_par_envie() + { + var csv = ExportSouhaits.Csv([Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885")]); + + var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal("Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]); + Assert.Equal("Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]); + } + + /// + /// Sans échappement, une note contenant le séparateur décalerait toutes les colonnes + /// suivantes — et une note est du texte parfaitement libre. + /// + [Fact] + public void Le_csv_echappe_le_separateur_et_les_guillemets() + { + var csv = ExportSouhaits.Csv( + [Souhait(1, "Titre; piégé", note: "il a dit \"oui\"")]); + + var ligne = csv.Split("\r\n")[1]; + + Assert.StartsWith("\"Titre; piégé\";", ligne); + Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne); + + // Sept colonnes une fois l'échappement respecté, pas huit. + Assert.Equal(7, ColonnesCsv(ligne).Count); + } + + [Fact] + public void Le_csv_echappe_les_sauts_de_ligne() + { + var csv = ExportSouhaits.Csv([Souhait(1, "Titre", note: "ligne 1\nligne 2")]); + + Assert.Contains("\"ligne 1\nligne 2\"", csv); + } + + /// + /// Sans BOM, Excel affiche « Émile Zola » — sur un fonds français, à peu près chaque ligne. + /// + [Fact] + public void Le_bom_precede_le_csv() + { + var octets = ExportSouhaits.Bom + .Concat(Encoding.UTF8.GetBytes(ExportSouhaits.Csv([Souhait(1, "Émile")]))) + .ToArray(); + + Assert.Equal([0xEF, 0xBB, 0xBF], octets[..3]); + } + + [Theory] + [InlineData(FormatExportSouhaits.Texte, "liste-envies-2026-08-18.txt", "text/plain; charset=utf-8")] + [InlineData(FormatExportSouhaits.Csv, "liste-envies-2026-08-18.csv", "text/csv; charset=utf-8")] + public void Le_fichier_est_date_et_typé(FormatExportSouhaits format, string nom, string mime) + { + Assert.Equal(nom, ExportSouhaits.NomFichier(format, Jour)); + Assert.Equal(mime, ExportSouhaits.TypeMime(format)); + } + + /// Découpe une ligne CSV en respectant les guillemets (lecteur minimal, pour le test). + private static List ColonnesCsv(string ligne) + { + var colonnes = new List(); + var courante = new StringBuilder(); + var dansGuillemets = false; + + for (var i = 0; i < ligne.Length; i++) + { + var c = ligne[i]; + + if (c == '"') + { + if (dansGuillemets && i + 1 < ligne.Length && ligne[i + 1] == '"') + { + courante.Append('"'); + i++; + continue; + } + + dansGuillemets = !dansGuillemets; + continue; + } + + if (c == ExportSouhaits.SeparateurCsv && !dansGuillemets) + { + colonnes.Add(courante.ToString()); + courante.Clear(); + continue; + } + + courante.Append(c); + } + + colonnes.Add(courante.ToString()); + return colonnes; + } +} diff --git a/MaBibli.Tests/Fixtures/bnf-auteur-werber.xml b/MaBibli.Tests/Fixtures/bnf-auteur-werber.xml new file mode 100644 index 0000000..b227369 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-auteur-werber.xml @@ -0,0 +1,361 @@ + +1.2 + +1.2 +bib.author all "Bernard Werber" + +203 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43683323s Troisième humanité / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 2012 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43683323s +1 + +20131002 +20131002 +12.24525 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43745025g Troisième humanité / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Éd. France loisirs (Paris) 2013 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43745025g +2 + +20140116 +20140116 +12.24525 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb42777895t Troisième humanité / Bernard Werber Werber, Bernard (1961-....). Auteur du texte A. Michel (Paris) 2012 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb42777895t +3 + +20121024 +20121024 +12.24525 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43683332r Troisième humanité / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 2012 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43683332r +4 + +20131002 +20131002 +12.2451105 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb39269846w Nous, les dieux / Bernard Werber Werber, Bernard (1961-....). Auteur du texte A. Michel (Paris) 2004 24 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb39269846w +5 + +20041103 +20071205 +12.2451105 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb40039497k Nous, les dieux / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Éd. France loisirs (Paris) 2005 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb40039497k +6 + +20050922 +20081030 +12.2451105 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb47615775q L'encyclopédie du savoir relatif et absolu / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Albin Michel (Paris) 2018 24 et 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb47615775q +7 + +20241211 +20241211 +12.244974 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb39900329q Nous, les dieux : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 2004 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb39900329q +8 + +20050106 +20071205 +12.244974 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb444898718 Troisième humanité / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Livre de poche (Paris) 2015 Collection : Le livre de poche 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb444898718 +9 + +20160118 +20160118 +12.244841 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb409564505 Nous, les dieux / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Librairie générale française (Paris) 2006 Collection : Le livre de poche 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb409564505 +10 + +20061215 +20061215 +12.244579 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35551566h Le jour des fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 1992 463 p. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35551566h +11 + +19930209 +19930223 +12.244083 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb357335589 Les thanatonautes / Bernard Werber Werber, Bernard (1961-....). Auteur du texte France loisirs (Paris) 1994 ISBN 2724280946 450 p. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb357335589 +12 + +19950130 +19950209 +12.243847 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35720318k Les thanatonautes : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 1994 450 p. : couv. ill. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35720318k +13 + +19941114 +19941201 +12.243847 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43699999b Les fourmis / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Livre de poche jeunesse (Paris) 2013 Collection : Le livre de poche. Jeunesse. Fictions 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43699999b +14 + +20131025 +20210730 +12.243847 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35828640p La révolution des fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 1996 533 p. : couv. ill. en coul. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35828640p +15 + +19960806 +19960906 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb36193471k Le livre du voyage / Bernard Werber Werber, Bernard (1961-....). Auteur du texte le Grand livre du mois (Paris) 1997 127 p. : couv. ill. en coul. ; 22 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb36193471k +16 + +19980108 +19980206 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb358187920 La révolution des fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte A. Michel (Paris) 1996 ISBN 2226086366 533 p. : couv. ill. en coul. ; 24 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb358187920 +17 + +19960620 +19960712 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb361686335 La révolution des fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte France loisirs (Paris) 1997 ISBN 2744104000 533 p. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb361686335 +18 + +19970611 +19970711 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35492416k Les fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte France loisirs (Paris) 1991 ISBN 2724265696 351 p. ; 25 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35492416k +19 + +19920127 +20160128 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35540108s Le jour des fourmis : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte A. Michel (Paris) 1992 ISBN 2226061185 463 p. : couv. ill. en coul. ; 24 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35540108s +20 + +19921204 +19921222 +12.243619 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb370268608 Le livre du voyage / Bernard Werber Werber, Bernard (1961-....). Auteur du texte France loisirs (Paris) 1998 ISBN 2744115509 127 p. : jaquette ill. ; 22 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb370268608 +21 + +19980921 +19990402 +12.243397 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb42606801s Le rire du Cyclope / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Éd. France loisirs (Paris) 2011 ISBN 9782298045680 Code à barres commercial : EAN 9782298045680 1 vol. (816 p.) : jaquette ill. en coul. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb42606801s +22 + +20120221 +20120329 +12.243397 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb45372631j Demain les chats : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Éditions France loisirs (Paris) 2017 ISBN 9782298122015 Code à barres commercial : EAN 9782298122015 1 vol. (335 p.) ; 20 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb45372631j +23 + +20171018 +20171207 +12.243397 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35807212p Les thanatonautes : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Librairie générale française (Paris) 1996 Collection : Le livre de poche ; 13922 ISBN 225313922X 502 p. : couv. ill. ; 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35807212p +24 + +19960417 +19960517 +12.243397 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb45159572s Demain les chats : roman / Bernard Werber Werber, Bernard (1961-....). Auteur du texte Éditions France loisirs (Paris) 2016 ISBN 9782298119732 Code à barres commercial : EAN 9782298119732 1 vol. (307 p.) ; 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb45159572s +25 + +20161117 +20161222 +12.243397 + + + +26 + diff --git a/MaBibli.Tests/Fixtures/bnf-auteur-zola.xml b/MaBibli.Tests/Fixtures/bnf-auteur-zola.xml new file mode 100644 index 0000000..690a1f5 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-auteur-zola.xml @@ -0,0 +1,361 @@ + +1.2 + +1.2 +bib.author all "Emile Zola" + +2692 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb32359518t Zola und die Tiere... / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte I. Graetz-Verlag (Berlin-Charlottenburg) 1933 Collection : Entr'act-Bücherei ; 3 Zola, Émile (1840-1902) In-8°, 40 p., pl. ger texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb32359518t +1 + +19970701 +20191219 +11.365902 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb370961796 Émile Zola raconté par sa fille / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte B. Grasset (Paris) 2000 Zola, Émile (1840-1902) ISBN 2246172632 280 p. : couv. ill. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb370961796 +2 + +20000322 +20190914 +11.36502 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb38668184t Zola et Cézanne d'après une correspondance retrouvée Le Blond-Zola, Denise (1889-1942). Auteur du texte 1931 Pag. 39-58 ; In-8 ° fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb38668184t +3 + +20041209 +20041209 +11.364924 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb33073483k Émile Zola raconté par sa fille / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte Cercle du bibliophile (Levallois-Perret) 1966 Collection : Cercle du bibliophile Zola, Émile (1840-1902) 1 vol. (290 p.) : pl., fac-sim. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb33073483k +4 + +19970701 +20130418 +11.364755 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb34870862h Émile Zola raconté par sa fille / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte B. Grasset (Paris) 1986 Zola, Émile (1840-1902) ISBN 2246172624 289 p. : couv. ill. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb34870862h +5 + +19871004 +20190915 +11.36381 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb32359517g Émile Zola raconté par sa fille / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte Fasquelle, éditeur (Paris) 1931 Zola, Émile (1840-1902) 1 vol. (266 p. et portrait) ; in-8 fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb32359517g +6 + +19970701 +20170503 +11.363017 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb386685362 Paul Alexis, ami des peintres, bohême et critique d'art Le Blond-Zola, Denise (1889-1942). Auteur du texte Le mercure de France (Paris) 1939 Alexis, Paul (1847-1901) 1 pièce (paginé 293-303) ; in-8 fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb386685362 +7 + +20041209 +20070301 +11.362901 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb45696031s Émile Zola raconté par sa fille / Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte Bernard Grasset (Paris) 2019 Collection : Les cahiers rouges Zola, Émile (1840-1902) ISBN 9782246820376 Code à barres commercial : EAN 9782246820376 1 vol. (299 p.) ; 19 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb45696031s +8 + +20190326 +20190914 +11.361762 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb34430876p Bulletin de la Société littéraire des amis d'Émile Zola Société littéraire des amis d'Émile Zola. Auteur du texte [s.n.] (Paris) 1922-1938 ISSN 11536284 Zola, Émile (1840-1902) -- Critique et interprétation Naturalisme (mouvement littéraire) -- Critique et interprétation fre publication en série imprimée printed serial text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb34430876p +9 + +19900919 +20210317 +11.290642 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb343484129 Les Cahiers naturalistes (Paris) Les Cahiers naturalistes : bulletin officiel de la Société littéraire des amis d'Emile Zola Société littéraire des amis d'Émile Zola. Auteur du texte / Autrice du texte Grasset-Fasquelle (Paris) 1955 ISSN 00080365 Zola, Émile (1840-1902) -- Critique et interprétation Naturalisme (mouvement littéraire) -- Critique et interprétation Dreyfus, Affaire (1894-1906) fre publication en série imprimée printed serial text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb343484129 +10 + +19860728 +20190919 +11.28979 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb323595164 Émile Zola / par Denise Le Blond-Zola Le Blond-Zola, Denise (1889-1942). Auteur du texte F. Bernouard (Paris) 1927 Zola, Émile (1840-1902) In-8°, 38 p., couv. en coul. fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb323595164 +11 + +19970701 +20130418 +11.288839 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb320764010 Formes mortelles du botulisme / Dr François Émile-Zola,... Émile-Zola, François (1917-1989). Auteur du texte 1946 92 p. ; In-8° (245 x 160) fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb320764010 +12 + +19970701 +20180606 +11.266872 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35887568s Réponse à la pl ̤. de la R ̤. L. les Jacobins sur les recrutement, discipline et régénération maç ̤. ; (et) Rapport sur l'amnistie générale... / L ̤. Emile Zola n° 382 Loge Émile Zola (Paris). Auteur du texte l'Emancipatrice (Paris) 1927 12 p. ; 27 cm texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35887568s +13 + +19901220 +19901220 +11.172066 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35311116p La Question de l'imprimerie mac ̤. : rapport présenté... / à la L ̤. Emile Zola... ; [par le] F ̤. Frédéric Tizorin,.. Tizorin, Frédéric. Rédacteur Loge Émile Zola (Paris). Auteur du texte l'Emancipatrice (Paris) 1910 15 p. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35311116p +14 + +19910212 +20151202 +11.106373 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb30082301p Lycée de Rennes. Le centenaire de Michelet, discours prononcé dans la salle des fêtes du lycée, le 13 juillet 1898 / par M. Benaerts,... Benaerts, Louis (1868-1941). Auteur du texte Lycée et collège Émile Zola (Rennes). Éditeur scientifique impr. des Arts et manufactures (Rennes) 1898 Michelet, Jules (1798-1874) -- Anniversaires In-8° , 35 p. fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb30082301p +15 + +19950501 +20180314 +10.988168 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb33902955k Rapport sur la création de cours obligatoires d'instruction et d'éducation pour l'adolescence, par le F @ Henry-Marx,... / G @ L @ D @ F @ L @ Emile-Zola Marx, Henry. Auteur du texte Loge Émile Zola (Paris). Auteur du texte Impr. L'Emancipatrice (Paris) 1912 In-8°, 11 p. fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb33902955k +16 + +19970701 +20200513 +10.98049 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb412345498 Mes étés à Brienne / Brigitte Émile-Zola ; préface d'Alain Pagès... Émile-Zola, Brigitte (1944-....). Auteur du texte Pagès, Alain (1950-....). Préfacier Éd. du Frisson esthétique (agneaux) 2008 Collection : Collection Sources Zola, Émile (1840-1902) -- Famille Zola, Émile (1840-1902) -- Résidences et lieux familiers -- Brienne-le-Château (Aube, France) ISBN 9782917440001 Code à barres commercial : EAN 9782917440001 1 vol. (189 p.) : ill., couv. ill. ; 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb412345498 +17 + +20080404 +20230110 +10.976976 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb31241888c Université de France. Collège royal de Rennes. Discours prononcé à la distribution solennelle des prix, le 13 août 1845. [Signé : Antonin Rondelet] Rondelet, Antonin (1823-1893). Auteur du texte Lycée et collège Émile Zola (Rennes). Éditeur scientifique impr. de L. Boitel (Lyon) 1845 In-8° , 15 p. fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb31241888c +18 + +19970701 +19970701 +10.89491 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb452689930 Contes à Ninon et Nouveaux contes à Ninon / Émile Zola... ; précédés d'une Vie de l'auteur par Denise Le Blond-Zola Zola, Émile (1840-1902). Auteur du texte Le Blond-Zola, Denise (1889-1942). Préfacier typogr. François Bernouard (Paris) 1927 1 vol. ; in-8 fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb452689930 +19 + +20170511 +20170511 +10.85401 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb34978585f Zola et l'affiche : exposition du 5 octobre 1986 au 29 mars 1987, Maison d'Émile Zola... Musée Émile Zola (Médan, Yvelines). Collaborateur Association du Musée Émile Zola (Médan) 1986 Zola, Émile (1840-1902) -- Illustrations Affiches de cinéma -- France Roman français -- Affiches [Exposition. Médan. 1986/1987] 19 f. fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb34978585f +20 + +19881216 +20010220 +10.828315 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb346101384 "Amoco Cadiz" / Émile Zola Zola, Émile (auteur prétendu). Auteur du texte Baudouin (Paris) 1978 Non paginé ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb346101384 +21 + +19860717 +20100925 +10.777203 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43908217n Lettres à Alexandrine : 1876-1901 / Émile Zola ; édition établie, présentée et annotée par Brigitte Émile-Zola et Alain Pagès ; avec la collaboration de Céline Grenaud-Tostain, Sophie Guermès, Jean-Sébastien Macke... [et al.] Zola, Émile (1840-1902). Auteur du texte Émile-Zola, Brigitte (1944-....). Éditeur scientifique Pagès, Alain (1950-....). Éditeur scientifique Zola, Alexandrine (1839-1925). Destinataire de lettres Gallimard (Paris) 2014 ISBN 9782070139217 Code à barres commercial : EAN 9782070139217 1 vol. (813 p.) ; 23 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43908217n +22 + +20141003 +20250612 +10.722156 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb332677407 Catalogue des ouvrages légués par M. Saint-Albin Reynaud,... Médiathèque centrale d'agglomération Émile Zola (Montpellier). Éditeur scientifique imp. de Grollier (Montpellier) 1881 [Collection. Saint-Albin Reynaud] In-8° fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb332677407 +23 + +19970701 +19970701 +10.693977 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb349977019 Les Fables de La Fontaine : [exposition], Musée Fabre, 2 mars-15 avril 1984, Ville de Montpellier, Bibliothèque municipale Médiathèque centrale d'agglomération Émile Zola (Montpellier). Auteur du texte Bibliothèque municipale (Montpellier) 1984 La Fontaine, Jean de (1621-1695). Fables [Exposition. Montpellier. 1984] [50] p. : ill., couv. ill. ; 21 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb349977019 +24 + +19890214 +20190912 +10.693712 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb35749705x Rabelais à la Bibliothèque municipale : [coffret de 16 cartes postales] / Bibliothèque municipale Médiathèque centrale d'agglomération Émile Zola (Montpellier). Auteur du texte Bibliothèque municipale (Montpellier) 1994 Cavalier, Calixte (1820-1888) -- Bibliothèque Rabelais, François (1494?-1553) 16 cartes postales dans un coffret : ill. en noir et en coul. ; 16 cm + avec une notice introductive fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb35749705x +25 + +19950524 +20190919 +10.693217 + + + +26 + diff --git a/MaBibli.Tests/IsbnLookupServiceTests.cs b/MaBibli.Tests/IsbnLookupServiceTests.cs index 8c40359..6468971 100644 --- a/MaBibli.Tests/IsbnLookupServiceTests.cs +++ b/MaBibli.Tests/IsbnLookupServiceTests.cs @@ -24,6 +24,14 @@ public class IsbnLookupServiceTests IsbnInterroges.Add(isbn); return Task.FromResult((Reponse(isbn), Avertissement)); } + + /// + /// 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. + /// + public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default) => + Task.FromResult((new ResultatBibliographie(), (string?)null)); } private sealed class OpenLibraryFake : IOpenLibraryClient @@ -181,5 +189,13 @@ public class IsbnLookupServiceTests DerniereCouverture = urlCouverture; return Task.FromResult(((IReadOnlyList)[], (string?)null)); } + + /// + /// 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. + /// + public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default) => + Task.FromResult((new ResultatBibliographie(), (string?)null)); } } diff --git a/MaBibli.Tests/ServiceSouhaitsTests.cs b/MaBibli.Tests/ServiceSouhaitsTests.cs new file mode 100644 index 0000000..7dd92f8 --- /dev/null +++ b/MaBibli.Tests/ServiceSouhaitsTests.cs @@ -0,0 +1,338 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Isbn; +using MaBibli.Api.Services.Souhaits; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Liste d'envies, sur une base SQLite en mémoire : l'unicité « une œuvre par personne » +/// n'existe qu'en base, elle ne se vérifierait pas sur un faux dépôt. +/// +/// +/// L'invariant central testé ici est la portée personnelle : deux utilisateurs ne doivent +/// jamais se voir. C'est l'exact contraire du catalogue, commun au foyer — et une régression y +/// serait discrète, puisque tout continuerait de « marcher » avec un seul compte. +/// +public class ServiceSouhaitsTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServiceSouhaits _souhaits; + private readonly ServiceCatalogue _catalogue; + + private const string Mathieu = "mathieu"; + private const string Camille = "camille"; + + public ServiceSouhaitsTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + + _souhaits = new ServiceSouhaits(_db); + _catalogue = new ServiceCatalogue(_db, new ServiceAuteurs(_db)); + } + + private Task AjouterAsync( + string titre, string? auteur = "Bernard Werber", string utilisateur = Mathieu, + string? isbn = null, string? note = null) => + _souhaits.AjouterAsync( + new EnregistrementSouhait { Titre = titre, Auteur = auteur, Isbn = isbn, Note = note }, + utilisateur); + + // ── Portée personnelle ─────────────────────────────────────────────────── + + [Fact] + public async Task Deux_utilisateurs_ont_deux_listes_distinctes() + { + await AjouterAsync("Les fourmis", utilisateur: Mathieu); + await AjouterAsync("Germinal", "Émile Zola", Camille); + + var deMathieu = await _souhaits.ListerAsync(Mathieu); + var deCamille = await _souhaits.ListerAsync(Camille); + + Assert.Equal("Les fourmis", Assert.Single(deMathieu).Titre); + Assert.Equal("Germinal", Assert.Single(deCamille).Titre); + } + + [Fact] + public async Task Deux_utilisateurs_peuvent_souhaiter_le_meme_livre() + { + var premier = await AjouterAsync("Les fourmis", utilisateur: Mathieu); + var second = await AjouterAsync("Les fourmis", utilisateur: Camille); + + // L'unicité porte sur (utilisateur, œuvre) : elle ne doit pas empêcher deux personnes + // d'avoir la même envie — c'est même le cas normal dans un foyer. + Assert.Null(premier.Erreur); + Assert.Null(second.Erreur); + } + + [Fact] + public async Task On_ne_supprime_pas_l_envie_d_un_autre() + { + var souhait = await AjouterAsync("Les fourmis", utilisateur: Mathieu); + + var supprime = await _souhaits.SupprimerAsync(souhait.Souhait!.Id, Camille); + + Assert.False(supprime); + Assert.Single(await _souhaits.ListerAsync(Mathieu)); + } + + [Fact] + public async Task Sans_identite_il_n_y_a_ni_liste_ni_ecriture() + { + var ajout = await AjouterAsync("Les fourmis", utilisateur: null!); + + Assert.NotNull(ajout.Erreur); + Assert.Empty(await _souhaits.ListerAsync(null)); + } + + // ── Cloisonnement d'avec le catalogue ──────────────────────────────────── + + /// + /// L'invariant qui justifie l'entité séparée : une envie n'entre ni dans le catalogue, ni + /// dans les compteurs, ni dans la table des auteurs. + /// + [Fact] + public async Task Une_envie_n_apparait_pas_dans_le_catalogue() + { + await AjouterAsync("La prophétie des abeilles", "Bernard Werber"); + + var catalogue = await _catalogue.ListerAsync(new CritereLivres(), Mathieu); + + Assert.Empty(catalogue); + } + + [Fact] + public async Task Une_envie_ne_cree_pas_de_fiche_auteur() + { + await AjouterAsync("La prophétie des abeilles", "Bernard Werber"); + + // Sinon « Bernard Werber » apparaîtrait sur l'écran des auteurs avec « 0 livre », + // et trahirait au passage la liste d'envies de son propriétaire. + Assert.Empty(await new ServiceAuteurs(_db).ListerAsync()); + } + + [Fact] + public async Task Une_envie_recherchee_dans_le_catalogue_ne_remonte_pas() + { + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu); + await AjouterAsync("La prophétie des abeilles", "Bernard Werber"); + + var trouves = await _catalogue.ListerAsync( + new CritereLivres { Recherche = "Werber" }, Mathieu); + + Assert.Equal("Les fourmis", Assert.Single(trouves).Titre); + } + + // ── Validation et doublons ─────────────────────────────────────────────── + + [Fact] + public async Task Le_titre_est_obligatoire() + { + var resultat = await AjouterAsync(" "); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task La_meme_oeuvre_ne_s_ajoute_pas_deux_fois() + { + await AjouterAsync("Les thanatonautes"); + + // Variante d'écriture du même titre : c'est exactement ce que produit un double clic + // sur deux lignes de bibliographie qui n'ont pas été regroupées côté BnF. + var doublon = await AjouterAsync("Les thanatonautes : roman"); + + Assert.NotNull(doublon.Erreur); + Assert.Single(await _souhaits.ListerAsync(Mathieu)); + } + + [Fact] + public async Task Deux_auteurs_peuvent_avoir_une_oeuvre_de_meme_titre() + { + var premier = await AjouterAsync("Nouvelles", "Bernard Werber"); + var second = await AjouterAsync("Nouvelles", "Émile Zola"); + + Assert.Null(premier.Erreur); + Assert.Null(second.Erreur); + Assert.Equal(2, (await _souhaits.ListerAsync(Mathieu)).Count); + } + + [Fact] + public async Task Un_ISBN_invalide_est_refuse() + { + var resultat = await AjouterAsync("Les fourmis", isbn: "1234567890123"); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task L_ISBN_reste_facultatif() + { + var resultat = await AjouterAsync("Les fourmis", isbn: null); + + Assert.Null(resultat.Erreur); + Assert.Null(resultat.Souhait!.Isbn); + } + + [Fact] + public async Task La_liste_est_ordonnee_par_auteur_puis_titre() + { + 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); + } + + [Fact] + public async Task Une_envie_supprimee_disparait() + { + var souhait = await AjouterAsync("Les fourmis"); + + Assert.True(await _souhaits.SupprimerAsync(souhait.Souhait!.Id, Mathieu)); + Assert.Empty(await _souhaits.ListerAsync(Mathieu)); + } + + // ── Bibliographie : confrontation à l'étagère ──────────────────────────── + + /// + /// Client BnF simulé : ces tests portent sur le rapprochement, pas sur le réseau. + /// + private sealed class BnfBibliographieFake(string xml) : 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((BnfBibliographieParser.Parser(xml, auteur), (string?)null)); + } + + private async Task BibliographieWerberAsync(string utilisateur = Mathieu) + { + var auteurs = new ServiceAuteurs(_db); + var service = new ServiceBibliographie( + _db, new BnfBibliographieFake(Fixture.Lire("bnf-auteur-werber.xml")), auteurs); + + var werber = (await auteurs.ListerAsync()).Single(a => a.Nom.Contains("Werber")); + return (await service.ObtenirAsync(werber.Id, utilisateur))!; + } + + [Fact] + public async Task La_bibliographie_grise_les_livres_deja_possedes() + { + // Titre saisi sans le sous-titre « : roman » que porte la notice BnF : c'est le cas + // courant, et c'est précisément ce que la clé d'œuvre doit rattraper. + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les thanatonautes", Auteur = "Bernard Werber" }, Mathieu); + + var biblio = await BibliographieWerberAsync(); + + var oeuvre = biblio.Oeuvres.Single( + o => o.Titre.StartsWith("Les thanatonautes", StringComparison.OrdinalIgnoreCase)); + + Assert.True(oeuvre.Possede); + Assert.NotNull(oeuvre.LivreId); + + // Les autres restent à découvrir : posséder un livre n'en grise pas d'autres. + Assert.Contains(biblio.Oeuvres, o => o.ADecouvrir); + } + + [Fact] + public async Task La_bibliographie_signale_ce_qui_est_deja_souhaite() + { + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu); + + var avant = await BibliographieWerberAsync(); + var cible = avant.Oeuvres.First(o => o.ADecouvrir); + + await AjouterAsync(cible.Titre, "Bernard Werber"); + + var apres = await BibliographieWerberAsync(); + var revue = apres.Oeuvres.Single(o => o.Titre == cible.Titre); + + Assert.True(revue.Souhaite); + Assert.False(revue.ADecouvrir); + } + + /// + /// « Possédé » vaut pour le foyer, « souhaité » seulement pour l'appelant : c'est ce qui + /// permet d'offrir un livre à quelqu'un sans qu'il le voie venir. + /// + [Fact] + public async Task Le_souhait_d_un_autre_n_apparait_pas_dans_ma_bibliographie() + { + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu); + + var biblio = await BibliographieWerberAsync(Mathieu); + var cible = biblio.Oeuvres.First(o => o.ADecouvrir); + + await AjouterAsync(cible.Titre, "Bernard Werber", Camille); + + var vueDeMathieu = await BibliographieWerberAsync(Mathieu); + var vueDeCamille = await BibliographieWerberAsync(Camille); + + Assert.False(vueDeMathieu.Oeuvres.Single(o => o.Titre == cible.Titre).Souhaite); + Assert.True(vueDeCamille.Oeuvres.Single(o => o.Titre == cible.Titre).Souhaite); + } + + [Fact] + public async Task Un_auteur_inconnu_n_a_pas_de_bibliographie() + { + var service = new ServiceBibliographie( + _db, new BnfBibliographieFake(""), new ServiceAuteurs(_db)); + + Assert.Null(await service.ObtenirAsync(4242, Mathieu)); + } + + /// + /// Le livre d'un homonyme ne doit pas griser l'œuvre d'un autre : la comparaison se fait + /// à auteur donné, pas sur tout le catalogue. + /// + [Fact] + public async Task Un_livre_d_un_autre_auteur_ne_grise_rien() + { + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu); + + // Un homonyme parfait, mais signé de quelqu'un d'autre. + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les thanatonautes", Auteur = "Éric Corbeyran" }, Mathieu); + + var biblio = await BibliographieWerberAsync(); + + Assert.False( + biblio.Oeuvres + .Single(o => o.Titre.StartsWith("Les thanatonautes", StringComparison.OrdinalIgnoreCase)) + .Possede); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}