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); // 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(" - Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte); Assert.Contains(" en poche seulement", texte); } /// /// Le découpage du CSV ne sert pas qu'à la lisibilité : un ISBN nu de treize chiffres est /// lu comme un nombre par Excel, 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. /// [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("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; } }