Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
11 KiB
C#
304 lines
11 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("(aucune envie 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;Type;Titre;Auteur;Éditeur;Année;ISBN / ISSN;Note;Ajouté le", lignes[0]);
|
|
Assert.Equal("1;Livre;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;Livre;\"Titre; piégé\";", ligne);
|
|
Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne);
|
|
|
|
// Neuf colonnes une fois l'échappement respecté, pas dix.
|
|
Assert.Equal(9, 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;Livre;Zorro;", lignes[1]);
|
|
Assert.StartsWith("2;Livre;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));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// La table sœur : les revues souhaitées doivent traverser les DEUX exports.
|
|
// C'est là que le sujet se rate — un fichier emporté sans ses revues ne le dit pas.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private static RevueSouhaiteeDto Revue(
|
|
int id, string titre, string? numero = null, string? issn = null, string? note = null) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
Titre = titre,
|
|
Numero = numero,
|
|
Issn = issn,
|
|
Note = note,
|
|
DateAjout = Jour,
|
|
};
|
|
|
|
[Fact]
|
|
public void Le_texte_porte_les_revues_dans_une_section_apres_les_livres()
|
|
{
|
|
var texte = ExportSouhaits.Texte(
|
|
[Souhait(1, "Germinal", "Émile Zola")],
|
|
[Revue(1, "Médor", "43", "2466-6718"), Revue(2, "XXI")],
|
|
"Mathieu",
|
|
Jour);
|
|
|
|
Assert.Contains("1 livre", texte);
|
|
Assert.Contains("2 revues", texte);
|
|
|
|
// ⚠️ Section à part, APRÈS les livres : une revue n'a pas d'auteur, elle n'a donc rien
|
|
// à faire dans un fichier groupé par auteur — et surtout pas sous « Auteur non précisé »,
|
|
// qui désigne des livres dont l'auteur est simplement inconnu.
|
|
Assert.True(texte.IndexOf("Émile Zola", StringComparison.Ordinal)
|
|
< texte.IndexOf("Médor", StringComparison.Ordinal));
|
|
|
|
Assert.Contains(" - [1] Médor n° 43 (ISSN 2466-6718)", texte);
|
|
|
|
// Sans numéro, on n'invente pas de « n° » : c'est la revue entière qui est souhaitée.
|
|
Assert.Contains(" - [2] XXI", texte);
|
|
Assert.DoesNotContain("XXI n°", texte);
|
|
}
|
|
|
|
[Fact]
|
|
public void Le_texte_porte_les_revues_meme_sans_aucun_livre()
|
|
{
|
|
var texte = ExportSouhaits.Texte([], [Revue(1, "Médor", "43")], null, Jour);
|
|
|
|
Assert.DoesNotContain("(aucune envie dans la liste)", texte);
|
|
Assert.Contains(" - [1] Médor n° 43", texte);
|
|
}
|
|
|
|
/// <summary>
|
|
/// La colonne « Type » n'est pas décorative : sans elle, une revue et un livre deviennent
|
|
/// indiscernables dès que le tableur est trié par titre, et la colonne des codes mêle deux
|
|
/// numéros de nature différente sans le dire.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Le_csv_distingue_les_revues_des_livres_et_les_range_ensuite()
|
|
{
|
|
var csv = ExportSouhaits.Csv(
|
|
[Souhait(1, "Germinal", "Émile Zola")],
|
|
[Revue(1, "Médor", "43", "2466-6718", "au kiosque de la gare")]);
|
|
|
|
var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
Assert.StartsWith("1;Livre;Germinal;", lignes[1]);
|
|
Assert.Equal("1;Revue;Médor n° 43;;;;2466-6718;au kiosque de la gare;2026-08-18", lignes[2]);
|
|
|
|
// Le nombre de colonnes est le même pour les deux natures, sinon le tableau se décale.
|
|
Assert.Equal(ColonnesCsv(lignes[1]).Count, ColonnesCsv(lignes[2]).Count);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|