gestion_table_tournante_libre/src/csv/apercu.test.js

1127 lines
49 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'aperçu d'un import ou d'un collage (§ 10.1, § 10.2, § 14.10) :
// les lignes valides et leurs champs, les lignes refusées et leur motif, les
// refus globaux, la valeur d'« exclu », le rapport des orthographes fondues,
// les doublons signalés et jamais fusionnés, le même aperçu pour un texte
// collé et pour le même texte décodé d'un fichier, et les données d'épreuve
// de test/fixtures/csv/, chacune un CSV tel qu'un tableur l'écrit. Les lettres
// décomposées et les blancs autres que l'espace s'écrivent par leur point de
// code.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { contientNul, decoder } from './encodage.js';
import { cleNormalisee } from './normalisation.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url);
// Texte d'un CSV : chaque ligne donnée, terminée par CRLF.
const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join('');
// Vingt patronymes d'épreuve, inventés.
const PATRONYMES = [
'Ardoise', 'Brindille', 'Châtaigne', 'Dune', 'Écorce', 'Fougère', 'Galet', 'Houle', 'Iode', 'Jonc',
'Lichen', 'Mousse', 'Nacre', 'Ocre', 'Prêle', 'Quartz', 'Roseau', 'Sauge', 'Tourbe', 'Ulve',
];
// Les lignes qui suivent l'en-tête dans la fenêtre du choix du séparateur,
// ses vingt premiers enregistrements : une ligne par patronyme, rendue par
// ligne ; dix-neuf par défaut, moins pour placer un enregistrement choisi
// dans la fenêtre. Un enregistrement dont le nombre de champs diffère de
// celui de l'en-tête n'est une ligne de l'aperçu qu'au-delà, à partir du
// rang 21 : dans la fenêtre, il écarte le séparateur, et le texte entier est
// refusé à son rang.
const fenetre = (ligne, nombre = 19) => PATRONYMES.slice(0, nombre).map(ligne);
// Participant existant, ses autres champs vides.
const existant = (id, nom, prenom, appartenance) => ({
id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false,
});
// Champs d'une ligne valide, dans l'ordre de l'aperçu ; un champ omis vaut null.
const champs = ({
nom, prenom = null, appartenance = null, courriel = null, titrePressenti = null, notes = null, exclu = null,
}) => ({ nom, prenom, appartenance, courriel, titrePressenti, notes, exclu });
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
// refus, aucune ligne.
const refusGlobal = (
code,
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null,
) => ({
separateur,
entetes,
association,
lignes: [],
refusees: [],
fusions: [],
doublons: [],
refusGlobal: code,
refusGlobalLigne: ligne,
});
// Octets d'une donnée d'épreuve.
const lireFixture = (nom) => readFileSync(new URL(nom, FIXTURES));
// Fins de ligne des octets d'un texte à un octet par unité : les CRLF, puis
// les LF et CR isolés.
function finsDeLigne(octets) {
let crlf = 0;
let isolees = 0;
for (let i = 0; i < octets.length; i += 1) {
if (octets[i] === 0x0d && octets[i + 1] === 0x0a) {
crlf += 1;
i += 1;
} else if (octets[i] === 0x0a || octets[i] === 0x0d) {
isolees += 1;
}
}
return { crlf, isolees };
}
// Rang et champs de chaque ligne valide.
const lignesDe = (apercu) => apercu.lignes.map(({ ligne, champs: lus }) => [ligne, lus]);
describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
test("chaque partie de l'aperçu : séparateur, en-têtes tels qu'écrits, association, lignes", () => {
const texte = csv(
'Nom;Prénom;Équipe;Courriel;Rôle;Exclu;Notes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;arrive tôt',
'Pervenche;Théo;;;;oui;',
);
assert.deepEqual(apercevoir(texte), {
separateur: ';',
entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'],
association: {
colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6 },
ambigus: [],
nonReconnues: [],
},
lignes: [
{
ligne: 2,
champs: champs({
nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test',
titrePressenti: 'animation', notes: 'arrive tôt', exclu: false,
}),
brut: ['Ombrelle', 'Iris', 'Club des Merles', 'iris@exemple.test', 'animation', 'non', 'arrive tôt'],
},
{
ligne: 3,
champs: champs({ nom: 'Pervenche', prenom: 'Théo', exclu: true }),
brut: ['Pervenche', 'Théo', '', '', '', 'oui', ''],
},
],
refusees: [],
fusions: [],
doublons: [],
refusGlobal: null,
refusGlobalLigne: null,
});
});
test("les champs d'une ligne, dans l'ordre du participant, quel que soit celui des colonnes", () => {
const [ligne] = apercevoir(csv('notes;exclu;titre;courriel;équipe;prénom;nom', 'n;oui;t;c@exemple.test;a;p;Ombrelle'))
.lignes;
assert.equal(
JSON.stringify(ligne.champs),
'{"nom":"Ombrelle","prenom":"p","appartenance":"a","courriel":"c@exemple.test","titrePressenti":"t","notes":"n","exclu":true}',
);
});
test("un champ : NFC, bouts retirés, vide ou blanc → null ; le brut reste tel qu'écrit", () => {
const texte = csv(
'nom;prenom;appartenance;courriel;notes',
' Ombrelle ;The\u{301}o; Club des Merles ; \u{A0} ;"ligne un\nligne deux "',
);
const [ligne] = apercevoir(texte).lignes;
assert.deepEqual(
ligne.champs,
champs({ nom: 'Ombrelle', prenom: 'Th\u{E9}o', appartenance: 'Club des Merles', notes: 'ligne un\nligne deux' }),
);
assert.deepEqual(ligne.brut, [' Ombrelle ', 'The\u{301}o', ' Club des Merles ', ' \u{A0} ', 'ligne un\nligne deux ']);
});
test('un champ sans colonne vaut null', () => {
assert.deepEqual(lignesDe(apercevoir(csv('nom', 'Ombrelle'))), [[2, champs({ nom: 'Ombrelle' })]]);
});
test("l'en-tête est le premier enregistrement non vide ; les rangs comptent les lignes qui le précèdent", () => {
const apercu = apercevoir(csv('', ';', 'nom;prenom', 'Ombrelle;Iris'));
assert.deepEqual(apercu.entetes, ['nom', 'prenom']);
assert.deepEqual(lignesDe(apercu), [[4, champs({ nom: 'Ombrelle', prenom: 'Iris' })]]);
});
test('un enregistrement vide, blanc ou fait de séparateurs est ignoré, et garde son rang', () => {
const apercu = apercevoir(csv('nom;prenom', 'Ombrelle;Iris', '', ';', ' ;\u{A0}\t', 'Pervenche;Théo'));
assert.deepEqual(apercu.lignes.map(({ ligne }) => ligne), [2, 6]);
assert.deepEqual(apercu.refusees, []);
assert.equal(apercu.refusGlobal, null);
});
test("le choix tranche une ambiguïté de l'en-tête, et les lignes en suivent", () => {
const texte = csv('nom;organisation;entreprise', 'Ombrelle;Club des Merles;Chorale du Givre');
const sansChoix = apercevoir(texte);
assert.deepEqual(sansChoix.association.ambigus, [{ champ: 'appartenance', rangs: [1, 2] }]);
assert.equal(sansChoix.refusGlobal, null);
assert.equal(sansChoix.lignes[0].champs.appartenance, null);
const avecChoix = apercevoir(texte, { choix: { appartenance: 2 } });
assert.deepEqual(avecChoix.association, { colonnes: { nom: 0, appartenance: 2 }, ambigus: [], nonReconnues: [] });
assert.equal(avecChoix.lignes[0].champs.appartenance, 'Chorale du Givre');
});
test("un texte qui n'est pas une chaîne est refusé par un TypeError : des octets passent par decoder", () => {
// Un objet String se lirait comme sa chaîne ; les autres entrées lèveraient
// plus loin, par accident, un TypeError d'un autre texte.
const entrees = [
new TextEncoder().encode('nom\nOmbrelle\n'),
new String('nom\nOmbrelle\n'),
['nom', 'Ombrelle'],
undefined,
null,
42,
];
for (const entree of entrees) {
assert.throws(() => apercevoir(entree), { name: 'TypeError', message: 'texte : chaîne attendue' });
}
});
test('apercevoir ne modifie ni les participants ni le choix', () => {
const participants = Object.freeze([
Object.freeze(existant(7, 'Givre', null, 'cooperative bleue')),
Object.freeze(existant(3, 'Houle', null, 'Coopérative Bleue')),
]);
const choix = Object.freeze({ appartenance: 2 });
const texte = csv('nom;organisation;entreprise', 'Givre;;COOPÉRATIVE BLEUE');
const apercu = apercevoir(texte, { participants, choix });
assert.equal(apercu.lignes[0].champs.appartenance, 'Coopérative Bleue');
assert.deepEqual(participants.map(({ id }) => id), [7, 3]);
});
test("l'ordre des participants donnés ne compte pas : l'identifiant ordonne", () => {
const participants = [
existant(3, 'Houle', null, 'Coopérative Bleue'),
existant(7, 'Givre', null, 'cooperative bleue'),
existant(5, 'Ombrelle', 'Iris', null),
];
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;COOPÉRATIVE BLEUE', 'Givre;;cooperative bleue');
const apercu = apercevoir(texte, { participants });
assert.deepEqual(apercevoir(texte, { participants: [...participants].reverse() }), apercu);
assert.equal(apercu.fusions[0].affichee, 'Coopérative Bleue');
assert.deepEqual(apercu.doublons, [{ cle: '["givre","","cooperative bleue"]', lignes: [3], existants: [7] }]);
});
});
describe('apercevoir : la ligne refusée, le reste importé (§ 10.1)', () => {
test('un nom vide ou blanc refuse la ligne : NOM_ABSENT', () => {
const apercu = apercevoir(csv('prenom;nom;appartenance', 'Iris;;Club des Merles', 'Théo; \u{A0}\t;', 'Ondine;Sarcelle;'));
assert.deepEqual(apercu.refusees, [
{ ligne: 2, code: 'NOM_ABSENT', valeur: null, brut: ['Iris', '', 'Club des Merles'] },
{ ligne: 3, code: 'NOM_ABSENT', valeur: null, brut: ['Théo', ' \u{A0}\t', ''] },
]);
assert.deepEqual(lignesDe(apercu), [[4, champs({ nom: 'Sarcelle', prenom: 'Ondine' })]]);
assert.equal(apercu.refusGlobal, null);
});
test('un enregistrement trop court pour porter le nom refuse la ligne : NOM_ABSENT', () => {
const apercu = apercevoir(csv('prenom;nom', ...fenetre((patronyme) => `x;${patronyme}`), 'Iris'));
assert.equal(apercu.separateur, ';');
assert.deepEqual(apercu.refusees, [{ ligne: 21, code: 'NOM_ABSENT', valeur: null, brut: ['Iris'] }]);
assert.equal(apercu.lignes.length, 19);
});
test("un champ non vide au-delà de l'en-tête refuse la ligne : CHAMPS_EN_TROP", () => {
const apercu = apercevoir(csv(
'nom;prenom',
...fenetre((patronyme) => `${patronyme};x`),
'Ombrelle;Iris;Club des Merles',
'Pervenche;Théo;; \t',
'Sarcelle',
));
assert.equal(apercu.separateur, ';');
assert.deepEqual(apercu.refusees, [
{ ligne: 21, code: 'CHAMPS_EN_TROP', valeur: null, brut: ['Ombrelle', 'Iris', 'Club des Merles'] },
]);
// Un champ en trop vide ou blanc ne perd rien ; un enregistrement plus
// court laisse ses champs manquants à null.
assert.deepEqual(apercu.lignes.slice(-2), [
{ ligne: 22, champs: champs({ nom: 'Pervenche', prenom: 'Théo' }), brut: ['Pervenche', 'Théo', '', ' \t'] },
{ ligne: 23, champs: champs({ nom: 'Sarcelle' }), brut: ['Sarcelle'] },
]);
});
test('un seul motif par ligne : champs en trop, puis nom absent, puis exclu inconnu', () => {
const apercu = apercevoir(csv(
'nom;exclu',
...fenetre((patronyme) => `${patronyme};non`),
';peut-être;en trop',
';peut-être',
'Ombrelle;peut-être',
));
assert.deepEqual(
apercu.refusees.map(({ ligne, code }) => [ligne, code]),
[[21, 'CHAMPS_EN_TROP'], [22, 'NOM_ABSENT'], [23, 'EXCLU_INCONNU']],
);
});
});
describe('apercevoir : « exclu », une liste fermée (§ 10.1)', () => {
test('VALEURS_EXCLU : oui, o, vrai, 1 et x valent oui ; non, n, faux, 0 et vide valent non', () => {
assert.deepEqual(
[...VALEURS_EXCLU],
[
['oui', true], ['o', true], ['vrai', true], ['1', true], ['x', true],
['non', false], ['n', false], ['faux', false], ['0', false], ['', false],
],
);
for (const [cle] of VALEURS_EXCLU) assert.equal(cleNormalisee(cle), cle);
});
test('Oui, X, 0 et vide sont admis ; « peut-être » refuse la ligne, sa valeur nommée', () => {
const apercu = apercevoir(csv(
'nom;exclu',
'Ardoise;Oui',
'Brindille;X',
'Châtaigne;0',
'Dune;',
'Écorce;peut-être',
'Fougère; \u{A0}',
));
assert.deepEqual(
apercu.lignes.map(({ ligne, champs: lus }) => [ligne, lus.exclu]),
[[2, true], [3, true], [4, false], [5, null], [7, null]],
);
assert.deepEqual(apercu.refusees, [
{ ligne: 6, code: 'EXCLU_INCONNU', valeur: 'peut-être', brut: ['Écorce', 'peut-être'] },
]);
});
test('chaque valeur admise, sans égard à la casse, aux accents ni aux blancs de bord', () => {
const cas = [
['OUI', true], [' o ', true], ['Vrai', true], ['1', true], ['x', true], ['Ouï', true],
['NON', false], ['N', false], ['Faux', false], ['0', false], ['\tnon\u{A0}', false],
];
const apercu = apercevoir(csv('nom;exclu', ...cas.map(([valeur], rang) => `${PATRONYMES[rang]};${valeur}`)));
assert.deepEqual(apercu.refusees, []);
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.exclu), cas.map(([, exclu]) => exclu));
});
test("toute autre valeur refuse la ligne, sa valeur nommée sans ses blancs de bord", () => {
const valeurs = ['yes', 'true', 'oui.', '2', 'nn', '-', 'exclu', 'o u i', ' non merci '];
const apercu = apercevoir(csv('nom;exclu', ...valeurs.map((valeur, rang) => `${PATRONYMES[rang]};${valeur}`)));
assert.deepEqual(
apercu.refusees.map(({ ligne, code, valeur }) => [ligne, code, valeur]),
valeurs.map((valeur, rang) => [rang + 2, 'EXCLU_INCONNU', valeur.trim()]),
);
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE');
});
});
describe("apercevoir : les orthographes d'une appartenance (§ 10.1, § 14.10)", () => {
test("le rapport nomme chaque fusion : trois orthographes, leurs lignes, l'existante affichée", () => {
const participants = [existant(1, 'Ombrelle', 'Iris', 'Coopérative Bleue')];
const apercu = apercevoir(
csv(
'nom;prenom;appartenance',
'Pervenche;Théo;cooperative bleue',
'Sarcelle;Ondine;COOPÉRATIVE BLEUE',
'Bruyère;Anouk;cooperative bleue',
),
{ participants },
);
assert.deepEqual(apercu.fusions, [
{
affichee: 'Coopérative Bleue',
orthographes: [
{ texte: 'Coopérative Bleue', lignes: [] },
{ texte: 'cooperative bleue', lignes: [2, 4] },
{ texte: 'COOPÉRATIVE BLEUE', lignes: [3] },
],
},
]);
assert.deepEqual(
apercu.lignes.map(({ champs: lus }) => lus.appartenance),
['Coopérative Bleue', 'Coopérative Bleue', 'Coopérative Bleue'],
);
});
test("sans participant existant, la première orthographe du fichier s'affiche", () => {
const apercu = apercevoir(csv(
'nom;appartenance',
'Ombrelle;club des merles',
'Pervenche;Club des Merles',
'Sarcelle; CLUB DES MERLES ',
));
assert.deepEqual(apercu.fusions, [
{
affichee: 'club des merles',
orthographes: [
{ texte: 'club des merles', lignes: [2] },
{ texte: 'Club des Merles', lignes: [3] },
{ texte: 'CLUB DES MERLES', lignes: [4] },
],
},
]);
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [
'club des merles', 'club des merles', 'club des merles',
]);
});
test("une orthographe répétée, ou la même en NFC et en NFD, n'est pas une fusion", () => {
const apercu = apercevoir(csv(
'nom;appartenance',
'Ombrelle;Club des Merles',
'Pervenche;Club des Merles',
'Sarcelle;Chorale d\u{2019}\u{E9}t\u{E9}',
'Bruyère;Chorale d\u{2019}e\u{301}te\u{301}',
));
assert.deepEqual(apercu.fusions, []);
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [
'Club des Merles', 'Club des Merles', 'Chorale d\u{2019}\u{E9}t\u{E9}', 'Chorale d\u{2019}\u{E9}t\u{E9}',
]);
});
test("une appartenance vide vaut absence, jamais une appartenance nommée « »", () => {
const participants = [existant(1, 'Houle', null, null), existant(2, 'Givre', null, '')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;', 'Pervenche; ', 'Sarcelle;\u{A0}'), { participants });
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [null, null, null]);
assert.deepEqual(apercu.fusions, []);
});
test("une appartenance blanche ou faite de marques combinantes seules vaut absence, dans le fichier comme chez un existant", () => {
// Sa clé normalisée est vide, celle que la clé de doublon donne à une
// appartenance absente : elle n'est l'orthographe d'aucun groupe.
const participants = [existant(1, 'Houle', null, ' '), existant(2, 'Givre', null, '\u{301}')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;\u{301}', 'Pervenche;\u{301}\u{20DD}', 'Givre;'), {
participants,
});
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [null, null, null]);
assert.deepEqual(apercu.fusions, []);
assert.deepEqual(apercu.doublons, [{ cle: '["givre","",""]', lignes: [4], existants: [2] }]);
});
test("un trait d'union, un trait de soulignement ou une espace de moins distinguent deux appartenances", () => {
// La clé d'en-tête les ignore, la clé normalisée non : la réconciliation
// compare par la seconde, comme la clé de doublon.
const orthographes = ['Club des Merles', 'Club-des-Merles', 'Club_des_Merles', 'Clubdes Merles', 'Val-Brume', 'Val Brume'];
const apercu = apercevoir(csv('nom;appartenance', ...orthographes.map((orthographe, rang) => `${PATRONYMES[rang]};${orthographe}`)));
assert.deepEqual(apercu.fusions, []);
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), orthographes);
});
test("les orthographes existantes d'abord, par identifiant croissant, puis celles du fichier", () => {
const participants = [existant(7, 'Givre', null, 'cooperative bleue'), existant(3, 'Houle', null, 'Coopérative Bleue')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;COOPÉRATIVE BLEUE'), { participants });
assert.deepEqual(apercu.fusions, [
{
affichee: 'Coopérative Bleue',
orthographes: [
{ texte: 'Coopérative Bleue', lignes: [] },
{ texte: 'cooperative bleue', lignes: [] },
{ texte: 'COOPÉRATIVE BLEUE', lignes: [2] },
],
},
]);
});
test("l'orthographe existante s'affiche telle qu'elle est enregistrée", () => {
const participants = [existant(4, 'Houle', null, 'Club des Merles ')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;club des merles'), { participants });
assert.equal(apercu.lignes[0].champs.appartenance, 'Club des Merles ');
assert.equal(apercu.fusions[0].affichee, 'Club des Merles ');
});
test("un groupe sans ligne du fichier n'est pas une fusion de l'import", () => {
const participants = [existant(3, 'Houle', null, 'Coopérative Bleue'), existant(7, 'Givre', null, 'cooperative bleue')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;Club des Merles'), { participants });
assert.deepEqual(apercu.fusions, []);
});
test("la ligne qui reprend l'orthographe existante n'ajoute pas d'orthographe", () => {
const participants = [existant(1, 'Houle', null, 'Club des Merles')];
const apercu = apercevoir(csv('nom;appartenance', 'Ombrelle;Club des Merles'), { participants });
assert.deepEqual(apercu.fusions, []);
assert.equal(apercu.lignes[0].champs.appartenance, 'Club des Merles');
});
test('deux groupes, rangés par leur première ligne du fichier', () => {
const participants = [existant(1, 'Houle', null, 'chorale du givre')];
const apercu = apercevoir(
csv(
'nom;appartenance',
'Ombrelle;Club des Merles',
'Pervenche;Chorale du Givre',
'Sarcelle;club des merles',
'Bruyère;CHORALE DU GIVRE',
),
{ participants },
);
assert.deepEqual(apercu.fusions, [
{
affichee: 'Club des Merles',
orthographes: [
{ texte: 'Club des Merles', lignes: [2] },
{ texte: 'club des merles', lignes: [4] },
],
},
{
affichee: 'chorale du givre',
orthographes: [
{ texte: 'chorale du givre', lignes: [] },
{ texte: 'Chorale du Givre', lignes: [3] },
{ texte: 'CHORALE DU GIVRE', lignes: [5] },
],
},
]);
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [
'Club des Merles', 'chorale du givre', 'Club des Merles', 'chorale du givre',
]);
});
test("une ligne refusée ne prend part à aucun groupe", () => {
const apercu = apercevoir(csv(
'nom;appartenance;exclu',
'Ombrelle;CLUB DES MERLES;peut-être',
'Pervenche;Club des Merles;',
'Sarcelle;club des merles;',
));
assert.deepEqual(apercu.fusions, [
{
affichee: 'Club des Merles',
orthographes: [
{ texte: 'Club des Merles', lignes: [3] },
{ texte: 'club des merles', lignes: [4] },
],
},
]);
});
});
describe('apercevoir : les doublons, signalés et jamais fusionnés (§ 10.1)', () => {
test('deux lignes du même triplet normalisé : un doublon, et deux lignes restent deux', () => {
const apercu = apercevoir(csv(
'nom;prenom;appartenance',
'Ombrelle;Iris;Club des Merles',
'OMBRELLE; iris ;club des merles',
'Ombrelle;Iris;Chorale du Givre',
));
assert.deepEqual(apercu.lignes.map(({ ligne }) => ligne), [2, 3, 4]);
assert.deepEqual(apercu.doublons, [
{ cle: '["ombrelle","iris","club des merles"]', lignes: [2, 3], existants: [] },
]);
});
test("une ligne du triplet d'un participant existant : ses identifiants, croissants", () => {
const participants = [existant(9, 'Ombrelle', 'Iris', 'Club des Merles'), existant(4, 'ombrelle', 'IRIS', 'club des merles')];
const apercu = apercevoir(csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles'), { participants });
assert.deepEqual(apercu.doublons, [
{ cle: '["ombrelle","iris","club des merles"]', lignes: [2], existants: [4, 9] },
]);
assert.equal(apercu.lignes.length, 1);
});
test("un triplet qui diffère par le prénom ou par l'appartenance n'est pas un doublon", () => {
const participants = [existant(1, 'Ombrelle', 'Ondine', null)];
const apercu = apercevoir(
csv(
'nom;prenom;appartenance',
'Ombrelle;Iris;Club des Merles',
'Ombrelle;Iris;Chorale du Givre',
'Ombrelle;;Club des Merles',
'Ombrelle;Ondine;Club des Merles',
),
{ participants },
);
assert.deepEqual(apercu.doublons, []);
});
test("un nom seul : le prénom et l'appartenance absents comptent comme vides", () => {
const participants = [existant(2, 'Givre', '', null)];
const apercu = apercevoir(csv('nom', 'Givre', 'givre ', 'Houle'), { participants });
assert.equal(apercu.separateur, null);
assert.deepEqual(apercu.doublons, [{ cle: '["givre","",""]', lignes: [2, 3], existants: [2] }]);
});
test('les doublons, rangés par leur première ligne du fichier, non par leur premier existant', () => {
const participants = [existant(1, 'Givre', 'Théo', null)];
const apercu = apercevoir(csv('nom;prenom', 'Houle;Iris', 'Givre;Théo', 'Givre;The\u{301}o', 'Houle;Iris'), {
participants,
});
assert.deepEqual(apercu.doublons, [
{ cle: '["houle","iris",""]', lignes: [2, 5], existants: [] },
{ cle: '["givre","theo",""]', lignes: [3, 4], existants: [1] },
]);
});
test("un triplet propre aux participants existants n'est pas un doublon de l'import ; une ligne refusée non plus", () => {
const participants = [existant(1, 'Givre', null, null), existant(2, 'Givre', null, null)];
const apercu = apercevoir(csv('nom;exclu', 'Houle;non', 'Houle;peut-être'), { participants });
assert.deepEqual(apercu.doublons, []);
});
});
describe('apercevoir : la réconciliation et la clé de doublon, une seule forme normalisée (§ 10.1)', () => {
test('deux personnes du même nom et du même prénom forment un doublon si et seulement si leurs appartenances se fondent', () => {
// Chaque orthographe éprouve une dimension de la clé : casse, accent
// composé ou décomposé, blancs, trait d'union, trait de soulignement,
// absence. L'appartenance affichée d'une ligne désigne son groupe ; une
// appartenance absente n'en a aucun, et la clé de doublon la compte vide.
const orthographes = [
'Club des Merles', 'CLUB DES MERLES', ' club des\u{A0}merles ', 'Clu\u{301}b des Merles', 'Club-des-Merles',
'Club_des_Merles', 'Clubdes Merles', 'Val-Brume', 'Val Brume', 'VAL BRUME', '', ' ', '\u{301}',
];
const participants = [existant(1, 'Ombrelle', 'Iris', 'club des merles')];
const apercu = apercevoir(
csv('nom;prenom;appartenance', ...orthographes.map((orthographe) => `Ombrelle;Iris;${orthographe}`)),
{ participants },
);
// Les groupes attendus : la propriété ne se vérifie pas sur du vide.
assert.deepEqual(apercu.lignes.map(({ champs: lus }) => lus.appartenance), [
'club des merles', 'club des merles', 'club des merles', 'club des merles', 'Club-des-Merles',
'Club_des_Merles', 'Clubdes Merles', 'Val-Brume', 'Val Brume', 'Val Brume', null, null, null,
]);
const doublonDe = new Map();
apercu.doublons.forEach(({ lignes }, rang) => {
for (const ligne of lignes) doublonDe.set(ligne, rang);
});
for (const a of apercu.lignes) {
const avecExistant = apercu.doublons.some(({ lignes, existants }) => lignes.includes(a.ligne) && existants.includes(1));
assert.equal(avecExistant, a.champs.appartenance === 'club des merles', `ligne ${a.ligne} et l'existant`);
for (const b of apercu.lignes) {
if (b.ligne <= a.ligne) continue;
const memeDoublon = doublonDe.has(a.ligne) && doublonDe.get(a.ligne) === doublonDe.get(b.ligne);
assert.equal(memeDoublon, a.champs.appartenance === b.champs.appartenance, `lignes ${a.ligne} et ${b.ligne}`);
}
}
});
});
describe('apercevoir : les refus globaux (§ 10.1)', () => {
test("aucun en-tête reconnu : AUCUN_ENTETE, les en-têtes et l'association rendus", () => {
assert.deepEqual(
apercevoir(csv('personne;groupe', 'Ombrelle;Club des Merles')),
refusGlobal('AUCUN_ENTETE', {
separateur: ';',
entetes: ['personne', 'groupe'],
association: { colonnes: {}, ambigus: [], nonReconnues: [0, 1] },
}),
);
});
test('un texte vide ou blanc : AUCUN_ENTETE', () => {
for (const texte of ['', '\r\n', ' \t\r\n\r\n\u{A0}']) {
assert.deepEqual(apercevoir(texte), refusGlobal('AUCUN_ENTETE'), JSON.stringify(texte));
}
});
test('aucune colonne associée à nom : NOM_NON_ASSOCIE', () => {
assert.deepEqual(
apercevoir(csv('prenom;appartenance', 'Iris;Club des Merles')),
refusGlobal('NOM_NON_ASSOCIE', {
separateur: ';',
entetes: ['prenom', 'appartenance'],
association: { colonnes: { prenom: 0, appartenance: 1 }, ambigus: [], nonReconnues: [] },
}),
);
});
test('deux colonnes nom : NOM_NON_ASSOCIE, tant que le choix ne tranche pas', () => {
const texte = csv('Nom;NOM;prenom', 'Ombrelle;Givre;Iris');
assert.deepEqual(
apercevoir(texte),
refusGlobal('NOM_NON_ASSOCIE', {
separateur: ';',
entetes: ['Nom', 'NOM', 'prenom'],
association: { colonnes: { prenom: 2 }, ambigus: [{ champ: 'nom', rangs: [0, 1] }], nonReconnues: [] },
}),
);
const tranche = apercevoir(texte, { choix: { nom: 1 } });
assert.equal(tranche.refusGlobal, null);
assert.deepEqual(tranche.lignes, [
{ ligne: 2, champs: champs({ nom: 'Givre', prenom: 'Iris' }), brut: ['Ombrelle', 'Givre', 'Iris'] },
]);
});
test('zéro ligne valide : AUCUNE_LIGNE_VALIDE, les lignes refusées rendues', () => {
assert.deepEqual(apercevoir(csv('nom;exclu', ';oui', 'Ardoise;peut-être')), {
...refusGlobal('AUCUNE_LIGNE_VALIDE', {
separateur: ';',
entetes: ['nom', 'exclu'],
association: { colonnes: { nom: 0, exclu: 1 }, ambigus: [], nonReconnues: [] },
}),
refusees: [
{ ligne: 2, code: 'NOM_ABSENT', valeur: null, brut: ['', 'oui'] },
{ ligne: 3, code: 'EXCLU_INCONNU', valeur: 'peut-être', brut: ['Ardoise', 'peut-être'] },
],
});
});
test("l'en-tête seul, ou suivi de lignes vides : AUCUNE_LIGNE_VALIDE", () => {
for (const texte of [csv('nom;prenom'), 'nom;prenom', csv('nom'), csv('nom;prenom', '', ';', ' ; ')]) {
const apercu = apercevoir(texte);
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE', JSON.stringify(texte));
assert.deepEqual(apercu.refusees, []);
}
});
test("un guillemet ouvert jusqu'à la fin : GUILLEMET_OUVERT, au rang où il s'ouvre", () => {
// Au-delà de la fenêtre du séparateur : sans le refus, Pervenche entrerait
// dans les notes d'Ombrelle, deux personnes fondues en une.
const texte = csv(
'nom;notes',
...fenetre((patronyme) => `${patronyme};x`),
'Ombrelle;"arrive tard',
'Pervenche;rien à signaler',
);
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['nom', 'notes'],
association: { colonnes: { nom: 0, notes: 1 }, ambigus: [], nonReconnues: [] },
},
21,
),
);
});
test("un guillemet ouvert l'emporte sur un en-tête inconnu : le texte est faux avant d'être mal nommé", () => {
for (const rang of [2, 21]) {
const texte = csv('personne;groupe', ...fenetre((patronyme) => `${patronyme};x`, rang - 2), 'Ombrelle;"arrive tard');
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['personne', 'groupe'],
association: { colonnes: {}, ambigus: [], nonReconnues: [0, 1] },
},
rang,
),
String(rang),
);
}
});
test("un guillemet ouvert dans un fichier à une colonne : GUILLEMET_OUVERT", () => {
assert.deepEqual(
apercevoir(csv('nom', 'Ombrelle', '"Pervenche', 'Sarcelle')),
refusGlobal(
'GUILLEMET_OUVERT',
{ separateur: null, entetes: ['nom'], association: { colonnes: { nom: 0 }, ambigus: [], nonReconnues: [] } },
3,
),
);
});
test("un guillemet ouvert dans la fenêtre du séparateur : GUILLEMET_OUVERT, au rang où il s'ouvre", () => {
// choisirSeparateur écarte chaque candidat, l'un pour son guillemet
// ouvert, les autres pour leur champ unique. Sans le refus, Pervenche
// entrerait dans les notes d'Ombrelle.
for (const separateur of [';', ',', '\t']) {
const ligne = (...valeurs) => valeurs.join(separateur);
const texte = csv(ligne('nom', 'prenom', 'notes'), ligne('Ombrelle', 'Iris', '"arrive tard'), ligne('Pervenche', 'Théo', ''));
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur,
entetes: ['nom', 'prenom', 'notes'],
association: { colonnes: { nom: 0, prenom: 1, notes: 2 }, ambigus: [], nonReconnues: [] },
},
2,
),
JSON.stringify(texte),
);
}
});
test("le même refus de part et d'autre de la fenêtre : GUILLEMET_OUVERT au rang 2, au rang 20, au rang 21", () => {
for (const rang of [2, 20, 21]) {
const texte = csv('nom;notes', ...fenetre((patronyme) => `${patronyme};x`, rang - 2), 'Ombrelle;"arrive tard');
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['nom', 'notes'],
association: { colonnes: { nom: 0, notes: 1 }, ambigus: [], nonReconnues: [] },
},
rang,
),
String(rang),
);
}
});
test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => {
for (const rang of [2, 21]) {
const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x');
const apercu = apercevoir(texte, { choix: { appartenance: 2 } });
assert.equal(apercu.refusGlobal, 'GUILLEMET_OUVERT', String(rang));
assert.deepEqual(apercu.association, { colonnes: { nom: 0, appartenance: 2 }, ambigus: [], nonReconnues: [] }, String(rang));
}
});
test("le refus suit la lecture dont l'en-tête porte le plus d'en-têtes reconnus, puis l'ordre « ; », « , », tabulation", () => {
// Collé d'un tableur, un en-tête qui porte un « ; » compte deux champs
// sous « ; » aussi, aucun reconnu : lu ainsi, le texte serait refusé au
// rang 2, là où le défaut est le guillemet du rang 3.
assert.deepEqual(
apercevoir(csv('nom\tprenom\tnotes; remarques', 'Ombrelle\tIris\tx', 'Pervenche\tThéo\t"arrive tard')),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: '\t',
entetes: ['nom', 'prenom', 'notes; remarques'],
association: { colonnes: { nom: 0, prenom: 1 }, ambigus: [], nonReconnues: [2] },
},
3,
),
);
// À égalité, aucun en-tête reconnu, « ; » passe avant « , ».
assert.deepEqual(
apercevoir(csv('personne;groupe,section', 'Ombrelle;"arrive tard')),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['personne', 'groupe,section'],
association: { colonnes: {}, ambigus: [], nonReconnues: [0, 1] },
},
2,
),
);
});
test("un enregistrement de la fenêtre qui n'a pas le nombre de champs de l'en-tête : SEPARATEUR_INTROUVABLE, à son rang", () => {
// Le § 10.1 veut le même nombre de champs partout dans les vingt premiers
// enregistrements : aucun séparateur ne convient, le texte entier est
// refusé, et le refus nomme la ligne à corriger. Au-delà, le même
// enregistrement n'est qu'une ligne refusée, ou complétée de null.
const cas = [
[csv('nom;prenom;notes', 'Ombrelle;Iris;arrive tard; vers 20 h', 'Pervenche;Théo;'), 2],
[csv('nom;prenom;appartenance', 'Ombrelle;Iris', 'Pervenche;Théo;Club des Merles'), 2],
[csv('nom;prenom;notes', 'Ombrelle;Iris;', 'Pervenche;Théo;arrive tard; vers 20 h', 'Sarcelle;Ondine;'), 3],
[csv('nom;prenom', 'Ombrelle'), 2],
// Un enregistrement vide ne compte pas, avant l'en-tête ni après.
[csv('', 'nom;prenom', '', ';;', 'Ombrelle;Iris', 'Pervenche'), 6],
// Le premier défaut dans l'ordre du texte : l'enregistrement court
// passe avant le guillemet ouvert qui le suit.
[csv('nom;prenom;notes', 'Ombrelle;Iris', 'Pervenche;Théo;"arrive tard'), 2],
// Le dernier enregistrement de la fenêtre.
[csv('nom;prenom', ...fenetre((patronyme) => `${patronyme};x`, 18), 'Ombrelle;Iris;Club des Merles'), 20],
];
for (const [texte, rang] of cas) {
assert.deepEqual(apercevoir(texte), refusGlobal('SEPARATEUR_INTROUVABLE', {}, rang), JSON.stringify(texte));
}
});
test("aucune lecture ne donne à l'en-tête deux champs : SEPARATEUR_INTROUVABLE, sans rang", () => {
// Une colonne unique d'en-tête inconnu, guillemet ouvert compris : aucune
// lecture à un champ ne désigne de séparateur ; un séparateur hors des
// trois candidats ; un guillemet ouvert dès l'en-tête, qui n'en laisse
// aucun terminé ; vingt lignes vides, qui laissent la fenêtre sans
// en-tête, et ce qui les suit sans effet sur le refus.
const vingtLignesVides = Array.from({ length: 20 }, () => '');
const textes = [
csv('personnes', 'Ombrelle'),
csv('personnes', '"Ombrelle'),
csv('nom|prenom', 'Ombrelle|"Iris'),
csv('nom;"prenom', 'Ombrelle;Iris'),
csv(...vingtLignesVides, 'nom;prenom', 'Ombrelle;Iris;Club des Merles'),
csv(...vingtLignesVides, 'nom;prenom', 'Ombrelle;"Iris'),
];
for (const texte of textes) {
assert.deepEqual(apercevoir(texte), refusGlobal('SEPARATEUR_INTROUVABLE'), JSON.stringify(texte));
}
});
test("des lignes vides insérées avant un guillemet ouvert ne changent pas le refus, en deçà de la fenêtre comme au-delà", () => {
// Une ligne vide ne compte ni pour le choix du séparateur ni pour
// l'aperçu : elle repousse d'un rang ce qui la suit. De zéro à quarante
// lignes vides, le guillemet passe des vingt enregistrements que lit
// choisirSeparateur, où il écarte le séparateur et où le refus nomme ce
// qui l'écarte, à l'au-delà, où le texte se lit sous le séparateur
// retenu. Les deux rendent le même refus : le guillemet à son rang, ou le
// défaut qui le précède au sien. Chaque cas éprouve une part de la règle
// du § 10.1 : les trois candidats, et eux seuls ; leur ordre ; le
// départage par les en-têtes reconnus ; un en-tête d'au moins deux
// champs ; le même nombre de champs partout.
const lu = (separateur, entetes, colonnes, nonReconnues = []) => ({
separateur,
entetes,
association: { colonnes, ambigus: [], nonReconnues },
});
// L'espace et chaque signe ASCII qui n'est ni une lettre, ni un chiffre,
// ni le guillemet, ni un candidat : l'en-tête n'a qu'un champ, inconnu,
// et le guillemet, au milieu d'un champ, n'ouvre rien.
const autresSignes = Array.from({ length: 0x7f - 0x20 }, (_, i) => String.fromCharCode(0x20 + i)).filter(
(signe) => !/[\p{L}\p{N}";,]/u.test(signe),
);
const cas = [
...[';', ',', '\t'].map((signe) => ({
avant: [`nom${signe}prenom`, `Pervenche${signe}Théo`],
guillemet: `Ombrelle${signe}"Iris`,
code: 'GUILLEMET_OUVERT',
etabli: lu(signe, ['nom', 'prenom'], { nom: 0, prenom: 1 }),
})),
...autresSignes.map((signe) => ({
avant: [`nom${signe}prenom`, `Pervenche${signe}Théo`],
guillemet: `Ombrelle${signe}"Iris`,
code: 'SEPARATEUR_INTROUVABLE',
})),
// L'en-tête a deux champs sous « ; » comme sous « , », aucun reconnu :
// « ; » passe avant « , ». Un guillemet en tête de ligne s'ouvre sous
// chaque candidat.
{
avant: ['personne;groupe,section', 'Pervenche;Théo,x'],
guillemet: '"Ombrelle;y,z',
code: 'GUILLEMET_OUVERT',
etabli: lu(';', ['personne', 'groupe,section'], {}, [0, 1]),
},
// Sous « , », l'en-tête porte un en-tête reconnu, sous « ; » aucun :
// « , » l'emporte.
{
avant: ['personne;nom,prenom', 'Pervenche;Théo,x'],
guillemet: '"Ombrelle;y,z',
code: 'GUILLEMET_OUVERT',
etabli: lu(',', ['personne;nom', 'prenom'], { prenom: 1 }, [0]),
},
// Un enregistrement plus court, puis plus long, que l'en-tête : le refus
// reste au rang de l'enregistrement, quel que soit celui du guillemet.
{ avant: ['nom;prenom;notes', 'Pervenche;Théo'], guillemet: 'Ombrelle;Iris;"x', code: 'SEPARATEUR_INTROUVABLE', rang: 2 },
{ avant: ['nom;prenom', 'Pervenche;Théo;x'], guillemet: 'Ombrelle;"Iris', code: 'SEPARATEUR_INTROUVABLE', rang: 2 },
// Une colonne unique : reconnue, elle se lit sans séparateur ; inconnue,
// rien ne se lit.
{ avant: ['nom', 'Pervenche'], guillemet: '"Ombrelle', code: 'GUILLEMET_OUVERT', etabli: lu(null, ['nom'], { nom: 0 }) },
{ avant: ['personnes', 'Pervenche'], guillemet: '"Ombrelle', code: 'SEPARATEUR_INTROUVABLE' },
];
for (const { avant, guillemet, code, etabli = {}, rang = null } of cas) {
for (let vides = 0; vides <= 40; vides += 1) {
const texte = csv(...avant, ...new Array(vides).fill(''), guillemet);
const rangDuRefus = code === 'GUILLEMET_OUVERT' ? avant.length + vides + 1 : rang;
assert.deepEqual(
apercevoir(texte),
refusGlobal(code, etabli, rangDuRefus),
`${JSON.stringify(avant[0])} … ${JSON.stringify(guillemet)}, ${vides} lignes vides`,
);
}
}
});
test("un caractère nul refuse le texte entier : CARACTERE_NUL, avant toute lecture", () => {
const textes = [
'\u{0}',
csv('nom\u{0};prenom', 'Ombrelle;Iris'),
csv('nom;prenom', 'Ombrelle;Iris\u{0}'),
`${csv('nom;prenom', 'Ombrelle;Iris')}\u{0}`,
csv('nom;notes', 'Ombrelle;"ouvert\u{0}'),
];
for (const texte of textes) {
assert.deepEqual(apercevoir(texte), refusGlobal('CARACTERE_NUL'), JSON.stringify(texte));
}
});
test('le caractère nul suit la règle de contientNul, la même que pour un fichier décodé', () => {
const textes = [
csv('nom', 'Ombrelle\u{0}'),
csv('nom', 'Ombrelle\u{2400}'),
csv('nom', 'Ombrelle\\0'),
csv('nom', 'Ombrelle0'),
csv('nom', 'Ombrelle\u{1}\u{7F}'),
csv('\u{0}nom', 'Ombrelle'),
];
const refuses = textes.map((texte) => apercevoir(texte).refusGlobal === 'CARACTERE_NUL');
assert.deepEqual(refuses, textes.map((texte) => contientNul(texte)));
assert.deepEqual(refuses, [true, false, false, false, false, true]);
});
});
// Le texte qu'un collage depuis un tableur donne : tabulations, CRLF, et une
// cellule à deux lignes, citée. C'est aussi le texte du fichier
// utf16le_tabulation.csv, l'enregistrement « Texte Unicode » d'un tableur.
const COLLAGE = csv(
'nom\tprenom\tappartenance\tnotes',
'Ombrelle\tIris\tCh\u{153}ur de l\u{2019}Anse\tcaf\u{E9} \u{E0} 8 h',
'Pervenche\tTh\u{E9}o\tClub des Merles\t"dit ""oui""\r\npuis part"',
);
// Ses lignes, que rend aussi le même contenu en windows-1252, séparé par « ; ».
const LIGNES_DU_COLLAGE = [
{
ligne: 2,
champs: champs({
nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Ch\u{153}ur de l\u{2019}Anse', notes: 'caf\u{E9} \u{E0} 8 h',
}),
brut: ['Ombrelle', 'Iris', 'Ch\u{153}ur de l\u{2019}Anse', 'caf\u{E9} \u{E0} 8 h'],
},
{
ligne: 3,
champs: champs({
nom: 'Pervenche', prenom: 'Th\u{E9}o', appartenance: 'Club des Merles', notes: 'dit "oui"\r\npuis part',
}),
brut: ['Pervenche', 'Th\u{E9}o', 'Club des Merles', 'dit "oui"\r\npuis part'],
},
];
describe("apercevoir : le collage et l'import, un seul analyseur (§ 10.2, § 14.10)", () => {
test("le texte collé et le même texte décodé d'un fichier rendent le même aperçu", () => {
const fichier = decoder(lireFixture('utf16le_tabulation.csv'));
assert.equal(fichier.encodage, 'utf-16le');
assert.equal(fichier.texte, COLLAGE);
const colle = apercevoir(COLLAGE);
assert.equal(colle.separateur, '\t');
assert.deepEqual(colle.lignes, LIGNES_DU_COLLAGE);
assert.deepEqual(apercevoir(fichier.texte), colle);
const marque = decoder(Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(COLLAGE)]));
assert.equal(marque.encodage, 'utf-8-bom');
assert.deepEqual(apercevoir(marque.texte), colle);
});
test('le même contenu en windows-1252, séparé par « ; », rend les mêmes lignes', () => {
const octets = lireFixture('windows1252.csv');
assert.deepEqual(finsDeLigne(octets), { crlf: 4, isolees: 0 });
const { texte, encodage } = decoder(octets);
assert.equal(encodage, 'windows-1252');
const apercu = apercevoir(texte);
assert.equal(apercu.separateur, ';');
assert.deepEqual(apercu.lignes, LIGNES_DU_COLLAGE);
assert.deepEqual(apercu.entetes, apercevoir(COLLAGE).entetes);
});
});
// Noms des fichiers de test/fixtures/csv/ que ce fichier éprouve, chacun dans
// son épreuve ci-dessous.
const DONNEES = [
'champ_cite.csv',
'derniere_ligne_sans_fin.csv',
'entete_vide.csv',
'espaces_autour.csv',
'ligne_vide_au_milieu.csv',
'marque_crlf.csv',
'utf16le_tabulation.csv',
'windows1252.csv',
];
// Aperçu d'une donnée d'épreuve, une fois vérifié que decoder la lit en
// UTF-8 avec marque, et que ses octets ne portent que des fins de ligne CRLF :
// git ne les a pas converties.
function apercuDe(nom) {
const octets = lireFixture(nom);
assert.equal(finsDeLigne(octets).isolees, 0, `${nom} : fin de ligne isolée`);
const { texte, encodage } = decoder(octets);
assert.equal(encodage, 'utf-8-bom', nom);
return apercevoir(texte);
}
describe("apercevoir : les données d'épreuve, un CSV tel qu'un tableur l'écrit", () => {
test("chaque donnée d'épreuve de test/fixtures/csv/ est éprouvée ici, et il y en a", () => {
const presentes = readdirSync(FIXTURES).sort();
assert.ok(presentes.length > 0, "aucune donnée d'épreuve");
assert.deepEqual(presentes, DONNEES);
});
test('marque UTF-8 et CRLF : sept colonnes, chaque fin de ligne lue, la marque hors du premier en-tête', () => {
assert.deepEqual(finsDeLigne(lireFixture('marque_crlf.csv')), { crlf: 4, isolees: 0 });
const apercu = apercuDe('marque_crlf.csv');
assert.equal(apercu.separateur, ';');
assert.deepEqual(apercu.entetes, ['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
assert.deepEqual(lignesDe(apercu), [
[2, champs({
nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris.ombrelle@exemple.test',
titrePressenti: 'animation', exclu: false,
})],
[3, champs({ nom: 'Pervenche', prenom: 'Théo', courriel: 'theo.pervenche@exemple.test', notes: 'arrive tard' })],
[4, champs({ nom: 'Sarcelle', prenom: 'Ondine', appartenance: 'Chorale du Givre', exclu: true })],
]);
assert.deepEqual(apercu.refusees, []);
assert.equal(apercu.refusGlobal, null);
});
test('une dernière ligne sans fin de ligne est lue en entier', () => {
const octets = lireFixture('derniere_ligne_sans_fin.csv');
assert.notEqual(octets[octets.length - 1], 0x0a);
assert.deepEqual(finsDeLigne(octets), { crlf: 2, isolees: 0 });
assert.deepEqual(lignesDe(apercuDe('derniere_ligne_sans_fin.csv')), [
[2, champs({ nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' })],
[3, champs({ nom: 'Bruyère', prenom: 'Anouk', appartenance: 'Chorale du Givre' })],
]);
});
test('une ligne vide et une ligne de séparateurs au milieu : ignorées, les rangs suivants gardés', () => {
const apercu = apercuDe('ligne_vide_au_milieu.csv');
assert.deepEqual(lignesDe(apercu), [
[2, champs({ nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' })],
[4, champs({ nom: 'Pervenche', prenom: 'Théo' })],
[6, champs({ nom: 'Sarcelle', prenom: 'Ondine', appartenance: 'Chorale du Givre' })],
]);
assert.deepEqual(apercu.refusees, []);
});
test('un champ cité qui porte des guillemets doublés et le séparateur ; un autre sur deux lignes', () => {
const apercu = apercuDe('champ_cite.csv');
assert.deepEqual(lignesDe(apercu), [
[2, champs({
nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club "Les Merles" ; section nord',
notes: 'dit "bonjour" ; puis part',
})],
[3, champs({
nom: 'Pervenche', prenom: 'Théo', appartenance: 'Chorale du Givre', notes: 'première ligne\r\ndeuxième ligne',
})],
[4, champs({ nom: 'Sarcelle', prenom: 'Ondine', notes: 'fin' })],
]);
assert.deepEqual(apercu.refusees, []);
});
test('des espaces autour des valeurs et des en-têtes : retirés des champs, gardés dans le brut', () => {
const apercu = apercuDe('espaces_autour.csv');
assert.deepEqual(apercu.entetes, [' nom ', ' prenom ', ' appartenance ', ' courriel ', ' exclu ']);
assert.deepEqual(apercu.association.colonnes, { nom: 0, prenom: 1, appartenance: 2, courriel: 3, exclu: 4 });
assert.deepEqual(apercu.lignes, [
{
ligne: 2,
champs: champs({
nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test', exclu: true,
}),
brut: [' Ombrelle ', ' Iris ', ' Club des Merles ', ' iris@exemple.test ', ' Oui '],
},
{
ligne: 3,
champs: champs({ nom: 'Pervenche', prenom: 'Théo' }),
brut: ['Pervenche\u{A0}', ' Théo ', ' ', ' ', ' '],
},
]);
});
test("une colonne d'en-tête vide : non reconnue, sa valeur ignorée, la ligne importée", () => {
const apercu = apercuDe('entete_vide.csv');
assert.deepEqual(apercu.association, {
colonnes: { nom: 0, prenom: 1, appartenance: 3 },
ambigus: [],
nonReconnues: [2],
});
assert.deepEqual(apercu.lignes, [
{
ligne: 2,
champs: champs({ nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' }),
brut: ['Ombrelle', 'Iris', 'note sans en-tête', 'Club des Merles'],
},
{ ligne: 3, champs: champs({ nom: 'Pervenche', prenom: 'Théo' }), brut: ['Pervenche', 'Théo', '', ''] },
]);
assert.deepEqual(apercu.refusees, []);
});
});