Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
using System.Text;
|
||||
using MaBibli.Api.Services.Souhaits;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contenu <b>exact</b> des fichiers d'export. Fonctions pures : rien à simuler.
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sans échappement, une note contenant le séparateur décalerait toutes les colonnes
|
||||
/// suivantes — et une note est du texte parfaitement libre.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sans BOM, Excel affiche « Émile Zola » — sur un fonds français, à peu près chaque ligne.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Découpe une ligne CSV en respectant les guillemets (lecteur minimal, pour le test).</summary>
|
||||
private static List<string> ColonnesCsv(string ligne)
|
||||
{
|
||||
var colonnes = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user