Files
mabibli/MaBibli.Tests/ExportSouhaitsTests.cs
T
mathieuandClaude Opus 5 4fd67e4e3b Auteurs cherchables et triables, filtre de prêt, rang dans les exports, couvertures d'envies
Auteurs
- Recherche et tri (par nom, ou du plus présent au moins présent), en mémoire : la liste
  est déjà chargée en entier — c'est elle qui sert l'instantané hors-ligne — et la filtrer
  côté serveur aurait rendu la page inutilisable sans réseau.
- Trois colonnes dont une seule est élastique : compteur à gauche sur une largeur figée,
  nom au milieu, actions à droite. Les compteurs et les boutons s'alignent alors d'une
  ligne à l'autre au lieu de suivre la longueur du nom.

Catalogue
- Filtre « À la maison / Prêtés », posé dans les deux implémentations (serveur et
  hors-ligne) et dans le test qui les confronte, avec un cas de prêt CLOS — c'est
  exactement là que les deux auraient pu diverger en silence.
- La ligne disparaît tant que rien n'est prêté, comme celles des formats et des types.

Envies
- ⚠️ Les couvertures manquaient parce que seul l'écran « ajouter une envie » remplissait
  CoverUrl : la bibliographie et les tomes manquants transmettent un ISBN mais aucune
  image. Le repli est posé à la LECTURE, donc il vaut aussi pour les envies déjà
  enregistrées, sans migration.
- Les exports .txt et .csv portent le rang d'envie. Le .txt en a le plus besoin : son
  groupement par auteur détruit précisément cet ordre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:42:13 +02:00

229 lines
8.1 KiB
C#

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");
// Le crochet porte le rang d'envie, que le groupement par auteur ferait sinon
// disparaître : la liste s'emporte pour savoir par quoi commencer.
Assert.Equal(" - [1] La prophétie des abeilles", lignes[debutWerber + 1]);
Assert.Equal(" - [3] 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);
// ISBN découpé : c'est un numéro qu'on épelle à un libraire. La valeur passée est nue
// (« 2253004227 »), les tirets n'existent qu'à la sortie.
Assert.Contains(" - [1] Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte);
Assert.Contains(" en poche seulement", texte);
}
/// <summary>
/// Le découpage du CSV ne sert pas qu'à la lisibilité : <b>un ISBN nu de treize chiffres est
/// lu comme un nombre par Excel</b>, qui l'affiche « 9,78225E+12 » et perd les derniers
/// chiffres. Les tirets en font un texte, et le numéro survit à l'ouverture du fichier.
/// </summary>
[Fact]
public void Le_csv_decoupe_lIsbn_pour_quExcel_ne_le_prenne_pas_pour_un_nombre()
{
var csv = ExportSouhaits.Csv(
[Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "9782253004226")]);
Assert.Contains("978-2-253-00422-6", csv);
Assert.DoesNotContain("9782253004226", csv);
}
[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("Rang;Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]);
Assert.Equal("1;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("1;\"Titre; piégé\";", ligne);
Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne);
// Huit colonnes une fois l'échappement respecté, pas neuf.
Assert.Equal(8, ColonnesCsv(ligne).Count);
}
/// <summary>
/// Le rang imprimé est la <b>position dans la liste reçue</b>, qui vient déjà triée par
/// <c>Rang</c>. Le texte la regroupe par auteur, ce qui casse l'ordre à l'affichage : le
/// crochet est alors la seule trace de « par quoi commencer ».
/// </summary>
[Fact]
public void Le_rang_suit_lordre_recu_et_non_lalphabet()
{
IReadOnlyList<SouhaitDto> liste =
[
Souhait(7, "Zorro", "Émile Zola"),
Souhait(3, "Abécédaire", "Émile Zola"),
];
var texte = ExportSouhaits.Texte(liste, null, Jour);
Assert.Contains(" - [1] Zorro", texte);
Assert.Contains(" - [2] Abécédaire", texte);
// Le CSV, lui, garde l'ordre : le rang y est croissant ligne après ligne.
var lignes = ExportSouhaits.Csv(liste).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("1;Zorro;", lignes[1]);
Assert.StartsWith("2;Abécédaire;", lignes[2]);
}
[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;
}
}