From 35cc5f5f75967e28aff11bd5436b36219d7c4ece Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 13:12:28 -0400 Subject: [PATCH] [ADD] csv: columns by header, closed exclu values, mandatory preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns associate by header, never by position, insensitive to case, accents and spaces, with the § 10.1 synonyms; two columns for one field associate neither and ask. The preview imports nothing: it lists valid rows, refused rows with their reason, each group of merged spellings with its counts, duplicates never merged, and global refusals, an open quote or a NUL included. A paste goes through the same parser, and the separator rule lives once, in lecture.js, whose refusal names its line. Checked: 185 CSV tests, eight spreadsheet-shaped fixtures; two reviews. --- FR --- [ADD] csv : colonnes par en-tête, valeurs d'exclu fermées, aperçu Les colonnes s'associent par en-tête, jamais par position, sans égard à la casse, aux accents ni aux espaces, avec les synonymes du § 10.1 ; deux colonnes d'un même champ ne s'associent pas, et le logiciel demande. L'aperçu n'importe rien : lignes valides, lignes refusées et leur motif, chaque groupe d'orthographes fondues et ses comptes, doublons jamais fondus, refus globaux, guillemet ouvert et nul compris. Le collage passe par le même analyseur, et la règle du séparateur ne s'écrit qu'une fois, dans lecture.js, dont le refus nomme sa ligne. Vérifié : 185 épreuves CSV, huit données de tableur ; deux revues. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- .gitattributes | 1 + src/csv/apercu.js | 314 +++++ src/csv/apercu.test.js | 1126 +++++++++++++++++ src/csv/colonnes.js | 87 ++ src/csv/colonnes.test.js | 180 +++ src/csv/lecture.js | 54 +- src/csv/lecture.test.js | 100 +- src/csv/normalisation.js | 45 + src/csv/normalisation.test.js | 110 ++ test/fixtures/csv/champ_cite.csv | 5 + test/fixtures/csv/derniere_ligne_sans_fin.csv | 3 + test/fixtures/csv/entete_vide.csv | 3 + test/fixtures/csv/espaces_autour.csv | 3 + test/fixtures/csv/ligne_vide_au_milieu.csv | 6 + test/fixtures/csv/marque_crlf.csv | 4 + test/fixtures/csv/utf16le_tabulation.csv | Bin 0 -> 262 bytes test/fixtures/csv/windows1252.csv | 4 + 17 files changed, 2016 insertions(+), 29 deletions(-) create mode 100644 src/csv/apercu.js create mode 100644 src/csv/apercu.test.js create mode 100644 src/csv/colonnes.js create mode 100644 src/csv/colonnes.test.js create mode 100644 src/csv/normalisation.js create mode 100644 src/csv/normalisation.test.js create mode 100644 test/fixtures/csv/champ_cite.csv create mode 100644 test/fixtures/csv/derniere_ligne_sans_fin.csv create mode 100644 test/fixtures/csv/entete_vide.csv create mode 100644 test/fixtures/csv/espaces_autour.csv create mode 100644 test/fixtures/csv/ligne_vide_au_milieu.csv create mode 100644 test/fixtures/csv/marque_crlf.csv create mode 100644 test/fixtures/csv/utf16le_tabulation.csv create mode 100644 test/fixtures/csv/windows1252.csv diff --git a/.gitattributes b/.gitattributes index 4b887bf..e3d801d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto eol=lf *.cmd text eol=crlf +test/fixtures/csv/** -text diff --git a/src/csv/apercu.js b/src/csv/apercu.js new file mode 100644 index 0000000..754c197 --- /dev/null +++ b/src/csv/apercu.js @@ -0,0 +1,314 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Aperçu d'un texte CSV décodé, venu d'un fichier ou d'un collage en bloc +// (§ 10.1, § 10.2). L'aperçu n'importe rien : il dit, ligne par ligne, ce que +// l'import ferait contre les participants existants. Un fichier passe d'abord +// par decoder ; un collage, qui ne porte pas d'octets, arrive ici tel quel ; +// à partir d'ici, les deux suivent le même chemin, et un second analyseur ne +// peut pas diverger du premier. +// +// La lecture suit un ordre fixe, et le premier refus global arrête tout : +// 1. un caractère nul refuse le texte, par la règle de decoder ; +// 2. choisirSeparateur choisit le séparateur ; un texte vide ou blanc est +// refusé AUCUN_ENTETE, tout autre texte sans séparateur +// SEPARATEUR_INTROUVABLE, au rang du défaut que choisirSeparateur +// nomme, sauf quand ce défaut est un guillemet resté ouvert : le texte +// se lit alors sous le candidat qu'il écarte, jusqu'au refus du point 4 ; +// 3. decouper rend les enregistrements ; l'en-tête est le premier non vide ; +// 4. un guillemet resté ouvert refuse le texte : ce qui le suit tiendrait +// dans un seul champ, et deux personnes se fondraient en une ; +// 5. aucun en-tête reconnu, puis aucune colonne associée à nom, refusent ; +// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne, +// valide ou refusée pour un seul motif ; zéro ligne valide refuse ; +// 7. les appartenances des lignes valides se réconcilient avec celles des +// existants, et les doublons se relèvent. +// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne +// revient dans le CSV des refus (§ 10.1). + +import { associer, reconnaitre } from './colonnes.js'; +import { contientNul } from './encodage.js'; +import { ErreurCsv } from './erreurs.js'; +import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js'; +import { cleNormalisee } from './normalisation.js'; + +/** + * Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste + * fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce + * que vaut une cellule vide pour une personne créée, non ; l'aperçu rend + * pourtant null pour une cellule vide, qu'une mise à jour lit comme « garder + * la valeur » (voir ChampsLus). + * + * @type {Map} + */ +export const VALEURS_EXCLU = new Map([ + ['oui', true], + ['o', true], + ['vrai', true], + ['1', true], + ['x', true], + ['non', false], + ['n', false], + ['faux', false], + ['0', false], + ['', false], +]); + +/** + * @typedef {Object} ChampsLus + * @property {string} nom + * @property {string|null} prenom + * @property {string|null} appartenance l'orthographe affichée de son groupe ; + * null pour une cellule vide, blanche ou faite de marques + * combinantes seules : sa clé normalisée est vide + * @property {string|null} courriel + * @property {string|null} titrePressenti + * @property {string|null} notes + * @property {boolean|null} exclu null pour une cellule vide ou une + * colonne absente : vide vaut non pour une personne créée, et laisse + * la valeur d'une personne existante qu'un import met à jour + * + * @typedef {Object} Apercu + * @property {string|null} separateur null pour un fichier à une colonne, + * et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous + * SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc + * @property {string[]} entetes tels qu'écrits + * @property {{colonnes: Object, ambigus: Array<{champ: string, rangs: number[]}>, + * nonReconnues: number[]}} association ce que rend associer + * @property {Array<{ligne: number, champs: ChampsLus, brut: string[]}>} lignes + * les lignes valides ; ligne : rang de l'enregistrement à partir de + * 1, l'en-tête compris ; champs : chaînes en NFC, blancs de bord + * retirés, vide → null ; brut : les champs tels que découpés + * @property {Array<{ligne: number, code: string, valeur: string|null, brut: string[]}>} refusees + * code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU ; valeur : la + * valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null + * pour les deux autres codes + * @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions + * chaque groupe d'au moins deux orthographes, existantes comprises, + * qui porte une ligne valide, rangé par sa première ligne ; + * orthographes dans l'ordre de rencontre, la première affichée ; + * lignes : rangs des lignes valides de cette orthographe, vide pour + * une orthographe que seuls des participants existants portent + * @property {Array<{cle: string, lignes: number[], existants: number[]}>} doublons + * chaque triplet (nom, prénom, appartenance) normalisé que portent au + * moins deux personnes, dont une ligne valide, rangé par sa première + * ligne ; cle : le triplet normalisé, en JSON ; existants : + * identifiants croissants + * @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal + * sous un refus global, lignes, fusions et doublons sont vides, et + * refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes + * refusées sont la cause + * @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de + * l'enregistrement où le guillemet s'ouvre ; sous + * SEPARATEUR_INTROUVABLE, celui de l'enregistrement dont le nombre + * de champs écarte le séparateur, quand choisirSeparateur le nomme ; + * null sinon + */ + +// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le +// refus, aucune ligne. +function refuser( + code, + { separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {}, + ligne = null, +) { + return { + separateur, + entetes, + association, + lignes: [], + refusees: [], + fusions: [], + doublons: [], + refusGlobal: code, + refusGlobalLigne: ligne, + }; +} + +// Valeur de la cellule de rang donné : NFC, blancs de bord retirés. Null quand +// le champ n'a pas de colonne, que l'enregistrement s'arrête avant elle, ou +// que la cellule est vide ou blanche. +function valeurDe(brut, rang) { + if (rang === undefined || rang >= brut.length) return null; + const valeur = brut[rang].normalize('NFC').trim(); + return valeur === '' ? null : valeur; +} + +// Vrai pour une appartenance qui en nomme une : une chaîne dont la clé +// normalisée n'est pas vide. Une appartenance vide, blanche ou faite de +// marques combinantes seules vaut absence (§ 10.1) : la clé de doublon la +// compte vide, comme une appartenance absente, et elle n'est l'orthographe +// d'aucun groupe. La réconciliation et la clé de doublon s'accordent ainsi +// sur chaque appartenance, lue ou existante. +const appartenanceNommee = (texte) => typeof texte === 'string' && cleNormalisee(texte) !== ''; + +// Lit un enregistrement de données selon les colonnes associées, largeur +// étant le nombre d'en-têtes. Rend { champs }, l'appartenance telle qu'écrite +// ou null quand elle n'en nomme aucune, ou le motif du refus { code, valeur }, +// un seul, dans cet ordre : un champ non vide au-delà de l'en-tête, qui dit +// une ligne décalée dont aucune valeur n'est sûre ; un nom vide ; une valeur +// d'« exclu » hors de la liste fermée. +function lireLigne(brut, largeur, colonnes) { + if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null }; + const lire = (champ) => valeurDe(brut, colonnes[champ]); + const nom = lire('nom'); + if (nom === null) return { code: 'NOM_ABSENT', valeur: null }; + const valeurExclu = lire('exclu'); + const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu)); + if (exclu === undefined) return { code: 'EXCLU_INCONNU', valeur: valeurExclu }; + const appartenance = lire('appartenance'); + return { + champs: { + nom, + prenom: lire('prenom'), + appartenance: appartenanceNommee(appartenance) ? appartenance : null, + courriel: lire('courriel'), + titrePressenti: lire('titre_pressenti'), + notes: lire('notes'), + exclu, + }, + }; +} + +// Réconcilie les appartenances (§ 10.1). Les orthographes de même clé +// normalisée forment un groupe, que représente la première rencontrée : +// celles des participants existants d'abord, dans l'ordre de existants, +// telles qu'enregistrées, puis celles des lignes valides, dans l'ordre du +// texte. Rend l'orthographe affichée du groupe d'un texte, et les fusions : +// chaque groupe d'au moins deux orthographes qui porte une ligne valide, +// rangé par sa première ligne. +function reconcilier(existants, valides) { + const groupes = new Map(); + const noter = (texte, ligne) => { + const cle = cleNormalisee(texte); + let groupe = groupes.get(cle); + if (groupe === undefined) { + groupe = { affichee: texte, orthographes: [], parTexte: new Map(), lignes: [] }; + groupes.set(cle, groupe); + } + let orthographe = groupe.parTexte.get(texte); + if (orthographe === undefined) { + orthographe = { texte, lignes: [] }; + groupe.orthographes.push(orthographe); + groupe.parTexte.set(texte, orthographe); + } + if (ligne !== null) { + orthographe.lignes.push(ligne); + groupe.lignes.push(ligne); + } + }; + for (const { appartenance } of existants) { + if (appartenanceNommee(appartenance)) noter(appartenance, null); + } + for (const { ligne, champs } of valides) { + if (champs.appartenance !== null) noter(champs.appartenance, ligne); + } + const fusions = [...groupes.values()] + .filter((groupe) => groupe.orthographes.length > 1 && groupe.lignes.length > 0) + .sort((a, b) => a.lignes[0] - b.lignes[0]) + .map(({ affichee, orthographes }) => ({ affichee, orthographes })); + return { afficheeDe: (texte) => groupes.get(cleNormalisee(texte)).affichee, fusions }; +} + +// Clé de doublon : le triplet (nom, prénom, appartenance), chacun par sa clé +// normalisée, un prénom ou une appartenance absents comptant comme vides, +// écrit en JSON pour qu'aucun texte ne se confonde avec une frontière. +const cleDoublon = (nom, prenom, appartenance) => + JSON.stringify([nom, prenom ?? '', appartenance ?? ''].map((texte) => cleNormalisee(texte))); + +// Doublons (§ 10.1) : chaque clé de doublon que portent au moins deux +// personnes, dont une ligne valide, avec les rangs de ses lignes et les +// identifiants de ses existants, rangée par sa première ligne. Un doublon est +// signalé, jamais fusionné : deux personnes peuvent porter le même nom. +function doublonsDe(existants, valides) { + const groupes = new Map(); + const groupeDe = (cle) => { + if (!groupes.has(cle)) groupes.set(cle, { cle, lignes: [], existants: [] }); + return groupes.get(cle); + }; + for (const { id, nom, prenom, appartenance } of existants) { + groupeDe(cleDoublon(nom, prenom, appartenance)).existants.push(id); + } + for (const { ligne, champs } of valides) { + groupeDe(cleDoublon(champs.nom, champs.prenom, champs.appartenance)).lignes.push(ligne); + } + return [...groupes.values()] + .filter((groupe) => groupe.lignes.length > 0 && groupe.lignes.length + groupe.existants.length > 1) + .sort((a, b) => a.lignes[0] - b.lignes[0]); +} + +/** + * Aperçu d'un texte décodé, import ou collage, contre les participants + * existants : rien n'est importé. + * + * @param {string} texte le texte que rend decoder, ou celui d'un collage + * @param {Object} [options] + * @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants] + * les participants existants, dans n'importe quel ordre : l'identifiant + * ordonne leurs orthographes + * @param {Object} [options.choix] { champ: rang } qui tranche + * une ambiguïté de l'en-tête (voir associer) + * @returns {Apercu} + * @throws {TypeError} quand texte n'est pas une chaîne : des octets passent + * d'abord par decoder + */ +export function apercevoir(texte, { participants = [], choix = {} } = {}) { + if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue'); + if (contientNul(texte)) return refuser('CARACTERE_NUL'); + + let separateur; + try { + ({ separateur } = choisirSeparateur(texte, reconnaitre)); + } catch (erreur) { + // Sur une chaîne, choisirSeparateur ne lève que SEPARATEUR_INTROUVABLE ; + // toute autre erreur est un défaut, qu'un refus de l'aperçu masquerait. + if (!(erreur instanceof ErreurCsv) || erreur.code !== 'SEPARATEUR_INTROUVABLE') throw erreur; + if (texte.trim() === '') return refuser('AUCUN_ENTETE'); + const { cause = null, ligne = null } = erreur.details; + if (cause !== 'GUILLEMET_OUVERT') return refuser('SEPARATEUR_INTROUVABLE', {}, ligne); + // Seul le guillemet resté ouvert écarte ce candidat : le texte se lit + // sous lui, et le refus GUILLEMET_OUVERT porte le séparateur, l'en-tête, + // l'association et le rang, comme au-delà de la fenêtre. + ({ separateur } = erreur.details); + } + + const { enregistrements, guillemetOuvert } = decouper(texte, separateur); + // Le séparateur, retenu ou écarté par le seul guillemet ouvert, a lu un + // enregistrement non vide dans la fenêtre du choix : l'en-tête existe. + const indiceEntete = enregistrements.findIndex((enregistrement) => !enregistrementVide(enregistrement)); + const entetes = enregistrements[indiceEntete]; + const association = associer(entetes, choix); + const lu = { separateur, entetes, association }; + if (guillemetOuvert) return refuser('GUILLEMET_OUVERT', lu, enregistrements.length); + if (association.nonReconnues.length === entetes.length) return refuser('AUCUN_ENTETE', lu); + if (association.colonnes.nom === undefined) return refuser('NOM_NON_ASSOCIE', lu); + + const valides = []; + const refusees = []; + for (let indice = indiceEntete + 1; indice < enregistrements.length; indice += 1) { + const brut = enregistrements[indice]; + if (enregistrementVide(brut)) continue; + const ligne = indice + 1; + const lecture = lireLigne(brut, entetes.length, association.colonnes); + if (lecture.champs === undefined) refusees.push({ ligne, code: lecture.code, valeur: lecture.valeur, brut }); + else valides.push({ ligne, champs: lecture.champs, brut }); + } + if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees }; + + const existants = [...participants].sort((a, b) => a.id - b.id); + const { afficheeDe, fusions } = reconcilier(existants, valides); + const lignes = valides.map(({ ligne, champs, brut }) => ({ + ligne, + champs: { ...champs, appartenance: champs.appartenance === null ? null : afficheeDe(champs.appartenance) }, + brut, + })); + return { + ...lu, + lignes, + refusees, + fusions, + doublons: doublonsDe(existants, lignes), + refusGlobal: null, + refusGlobalLigne: null, + }; +} diff --git a/src/csv/apercu.test.js b/src/csv/apercu.test.js new file mode 100644 index 0000000..ac71754 --- /dev/null +++ b/src/csv/apercu.test.js @@ -0,0 +1,1126 @@ +// © 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, []); + }); +}); diff --git a/src/csv/colonnes.js b/src/csv/colonnes.js new file mode 100644 index 0000000..c2ae692 --- /dev/null +++ b/src/csv/colonnes.js @@ -0,0 +1,87 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Association des colonnes d'un CSV aux champs d'un participant (§ 10.1). Une +// colonne s'associe par son en-tête, jamais par sa position : un tableur dont +// une colonne a été déplacée importerait sinon les noms dans les +// appartenances, sans rien signaler. Deux colonnes reconnues sous le même +// champ ne se départagent pas toutes seules ; le choix de l'opérateur tranche. + +import { cleEntete } from './normalisation.js'; + +/** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */ +export const CHAMPS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']); + +// En-têtes de chaque champ tels que le § 10.1 les écrit : son nom, puis ses +// synonymes. +const ENTETES_DES_CHAMPS = [ + ['nom', ['nom']], + ['prenom', ['prenom']], + ['appartenance', ['appartenance', 'organisation', 'entreprise', 'équipe']], + ['courriel', ['courriel']], + ['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']], + ['exclu', ['exclu']], + ['notes', ['notes']], +]; + +// Champ de chaque clé d'en-tête, la clé tirée de l'écriture du § 10.1 par la +// même règle que celle d'un en-tête lu. +const CHAMP_DE_CLE = new Map( + ENTETES_DES_CHAMPS.flatMap(([champ, entetes]) => entetes.map((entete) => [cleEntete(entete), champ])), +); + +/** + * Champ qu'un en-tête désigne, synonymes compris, sans égard à la casse, aux + * accents, aux espaces, à « _ » ni à « - » ; null pour un en-tête inconnu, + * comme « ligne » et « motif », les deux colonnes qu'ajoute le CSV des refus. + * + * @param {string} entete + * @returns {string|null} un élément de CHAMPS, ou null + */ +export function reconnaitre(entete) { + return CHAMP_DE_CLE.get(cleEntete(entete)) ?? null; +} + +/** + * Associe les colonnes aux champs par leurs en-têtes. Un rang de colonne est + * son indice dans entetes, à partir de 0. + * + * Un champ que reconnaît une seule colonne lui est associé. Un champ que + * reconnaissent plusieurs colonnes n'est associé à aucune et devient une + * ambiguïté, sauf quand choix[champ] désigne l'une d'elles : celle-là est + * associée, et l'ambiguïté levée. Un choix qui ne désigne pas l'une des + * colonnes en conflit d'un champ ambigu est sans effet. + * + * @param {string[]} entetes en-têtes tels qu'écrits + * @param {Object} [choix] { champ: rang } + * @returns {{ + * colonnes: Object, + * ambigus: Array<{champ: string, rangs: number[]}>, + * nonReconnues: number[], + * }} + * colonnes : { champ: rang } des champs associés, dans l'ordre de CHAMPS ; + * ambigus : dans l'ordre de CHAMPS, rangs croissants ; nonReconnues : rangs + * croissants des colonnes dont l'en-tête est inconnu, vide compris. + */ +export function associer(entetes, choix = {}) { + const rangsDesChamps = new Map(CHAMPS.map((champ) => [champ, []])); + const nonReconnues = []; + entetes.forEach((entete, rang) => { + const champ = reconnaitre(entete); + if (champ === null) nonReconnues.push(rang); + else rangsDesChamps.get(champ).push(rang); + }); + + const colonnes = {}; + const ambigus = []; + for (const champ of CHAMPS) { + const rangs = rangsDesChamps.get(champ); + if (rangs.length === 1) { + colonnes[champ] = rangs[0]; + } else if (rangs.length > 1) { + if (Object.hasOwn(choix, champ) && rangs.includes(choix[champ])) colonnes[champ] = choix[champ]; + else ambigus.push({ champ, rangs }); + } + } + return { colonnes, ambigus, nonReconnues }; +} diff --git a/src/csv/colonnes.test.js b/src/csv/colonnes.test.js new file mode 100644 index 0000000..a129ddd --- /dev/null +++ b/src/csv/colonnes.test.js @@ -0,0 +1,180 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'association des colonnes (§ 10.1) : les champs du CSV, la +// reconnaissance d'un en-tête et de ses synonymes, et l'association par +// en-tête, jamais par position, qui laisse sans colonne un champ que deux +// colonnes revendiquent tant qu'un choix ne tranche pas. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CHAMPS, associer, reconnaitre } from './colonnes.js'; + +describe('CHAMPS', () => { + test('les sept champs du § 10.1, dans leur ordre, figés', () => { + assert.deepEqual(CHAMPS, ['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']); + assert.ok(Object.isFrozen(CHAMPS)); + }); +}); + +describe('reconnaitre (§ 10.1)', () => { + test('chaque champ par son propre nom', () => { + assert.ok(CHAMPS.length > 0, 'aucun champ'); + for (const champ of CHAMPS) assert.equal(reconnaitre(champ), champ); + }); + + test('les synonymes : organisation, entreprise et équipe ; titre et rôle', () => { + assert.deepEqual( + ['organisation', 'entreprise', 'équipe', 'titre', 'rôle'].map((entete) => reconnaitre(entete)), + ['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti'], + ); + }); + + test('sans égard à la casse, aux accents ni aux espaces', () => { + const cas = [ + ['NOM', 'nom'], + [' Prénom ', 'prenom'], + ['Équipe', 'appartenance'], + ['titre', 'titre_pressenti'], + ['Rôle', 'titre_pressenti'], + ['E X C L U', 'exclu'], + ['Titre_Pressenti', 'titre_pressenti'], + ['titre pressenti', 'titre_pressenti'], + ['COURRIEL', 'courriel'], + ['Notes ', 'notes'], + ['ORGANISATION', 'appartenance'], + ['Entre-prise', 'appartenance'], + ['Equipe', 'appartenance'], + ]; + assert.deepEqual( + cas.map(([entete]) => reconnaitre(entete)), + cas.map(([, champ]) => champ), + ); + }); + + test('un en-tête inconnu rend null, dont « ligne » et « motif » du CSV des refus', () => { + const inconnus = [ + 'ligne', 'motif', '', ' ', 'nom de famille', 'société', 'prenoms', 'courriels', + 'organisations', 'titres', 'appartenances', 'exclus', 'note', 'titre.pressenti', + ]; + assert.deepEqual( + inconnus.map((entete) => reconnaitre(entete)), + inconnus.map(() => null), + ); + }); +}); + +describe('associer : par en-tête, jamais par position (§ 10.1)', () => { + test('chaque champ reconnu reçoit le rang de sa colonne, à partir de 0', () => { + assert.deepEqual(associer(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']), { + colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6 }, + ambigus: [], + nonReconnues: [], + }); + }); + + test('des colonnes déplacées donnent à chaque champ la même valeur', () => { + const entetes = ['nom', 'prenom', 'appartenance', 'courriel']; + const enregistrement = ['Ombrelle', 'Iris', 'Club des Merles', 'iris@exemple.test']; + // La colonne i du fichier déplacé est la colonne deplacement[i] de l'autre. + const deplacement = [3, 2, 0, 1]; + const entetesDeplaces = deplacement.map((rang) => entetes[rang]); + const enregistrementDeplace = deplacement.map((rang) => enregistrement[rang]); + // [champ, valeur] de chaque champ associé, dans l'ordre de CHAMPS. + const lire = ({ colonnes }, champs) => + CHAMPS.filter((champ) => colonnes[champ] !== undefined).map((champ) => [champ, champs[colonnes[champ]]]); + + const association = associer(entetes); + const associationDeplacee = associer(entetesDeplaces); + assert.deepEqual(associationDeplacee.colonnes, { nom: 2, prenom: 3, appartenance: 1, courriel: 0 }); + assert.deepEqual(lire(associationDeplacee, enregistrementDeplace), lire(association, enregistrement)); + assert.deepEqual(lire(association, enregistrement), [ + ['nom', 'Ombrelle'], + ['prenom', 'Iris'], + ['appartenance', 'Club des Merles'], + ['courriel', 'iris@exemple.test'], + ]); + }); + + test("colonnes suit l'ordre de CHAMPS, quel que soit celui du fichier", () => { + assert.equal( + JSON.stringify(associer(['notes', 'exclu', 'courriel', 'nom']).colonnes), + '{"nom":3,"courriel":2,"exclu":1,"notes":0}', + ); + }); + + test("organisation et entreprise ensemble : ni l'une ni l'autre, une ambiguïté sur appartenance", () => { + assert.deepEqual(associer(['nom', 'organisation', 'entreprise']), { + colonnes: { nom: 0 }, + ambigus: [{ champ: 'appartenance', rangs: [1, 2] }], + nonReconnues: [], + }); + }); + + test("le choix tranche l'ambiguïté, pour l'une comme pour l'autre colonne", () => { + const entetes = ['nom', 'organisation', 'entreprise']; + assert.deepEqual(associer(entetes, { appartenance: 2 }), { + colonnes: { nom: 0, appartenance: 2 }, + ambigus: [], + nonReconnues: [], + }); + assert.deepEqual(associer(entetes, { appartenance: 1 }).colonnes, { nom: 0, appartenance: 1 }); + }); + + test('des colonnes déplacées donnent la même ambiguïté, aux rangs déplacés', () => { + const entetes = ['entreprise', 'nom', 'organisation']; + assert.deepEqual(associer(entetes), { + colonnes: { nom: 1 }, + ambigus: [{ champ: 'appartenance', rangs: [0, 2] }], + nonReconnues: [], + }); + assert.deepEqual(associer(entetes, { appartenance: 0 }).colonnes, { nom: 1, appartenance: 0 }); + }); + + test("un choix qui ne désigne pas l'une des colonnes en conflit est sans effet", () => { + const entetes = ['nom', 'organisation', 'entreprise', 'inconnue']; + const sansChoix = associer(entetes); + assert.deepEqual(sansChoix.ambigus, [{ champ: 'appartenance', rangs: [1, 2] }]); + const choix = [ + { appartenance: 0 }, + { appartenance: 3 }, + { appartenance: 7 }, + { appartenance: '2' }, + { appartenance: null }, + { nom: 1 }, + { prenom: 3 }, + { inconnu: 1 }, + ]; + for (const un of choix) assert.deepEqual(associer(entetes, un), sansChoix, JSON.stringify(un)); + }); + + test("deux colonnes nom : ni l'une ni l'autre", () => { + assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), { + colonnes: { prenom: 1 }, + ambigus: [{ champ: 'nom', rangs: [0, 2] }], + nonReconnues: [], + }); + }); + + test("plusieurs conflits : les ambiguïtés dans l'ordre de CHAMPS, les rangs croissants", () => { + assert.deepEqual(associer(['titre', 'équipe', 'nom', 'rôle', 'organisation', 'entreprise']), { + colonnes: { nom: 2 }, + ambigus: [ + { champ: 'appartenance', rangs: [1, 4, 5] }, + { champ: 'titre_pressenti', rangs: [0, 3] }, + ], + nonReconnues: [], + }); + }); + + test('les colonnes non reconnues, en-tête vide compris, par rang croissant', () => { + assert.deepEqual(associer(['x', 'nom', '', 'ligne', ' ']), { + colonnes: { nom: 1 }, + ambigus: [], + nonReconnues: [0, 2, 3, 4], + }); + }); + + test('aucune colonne : rien à associer', () => { + assert.deepEqual(associer([]), { colonnes: {}, ambigus: [], nonReconnues: [] }); + }); +}); diff --git a/src/csv/lecture.js b/src/csv/lecture.js index 2cff81e..80d08b5 100644 --- a/src/csv/lecture.js +++ b/src/csv/lecture.js @@ -120,6 +120,33 @@ export function enregistrementVide(enregistrement) { return enregistrement.every((champ) => champ.trim() === ''); } +// Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est +// le premier enregistrement non vide parmi ceux que la fenêtre termine : un +// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement +// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête +// manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat +// n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le +// premier défaut qui écarte le candidat, ou null : un enregistrement non vide +// dont le nombre de champs diffère de celui de l'en-tête, à son rang ; à +// défaut, le guillemet resté ouvert, au rang où il s'ouvre. +function lireAvec(texte, candidat, reconnaitre) { + const { enregistrements, guillemetOuvert } = analyser(texte, candidat, FENETRE); + const termines = guillemetOuvert ? enregistrements.slice(0, -1) : enregistrements; + const entete = termines.find((e) => !enregistrementVide(e)); + if (entete === undefined || entete.length < 2) return null; + const ecart = termines.findIndex((e) => !enregistrementVide(e) && e.length !== entete.length); + let defaut = null; + if (ecart !== -1) defaut = { ligne: ecart + 1, cause: 'NOMBRE_DE_CHAMPS' }; + else if (guillemetOuvert) defaut = { ligne: enregistrements.length, cause: 'GUILLEMET_OUVERT' }; + return { separateur: candidat, reconnus: entete.filter((champ) => reconnaitre(champ)).length, defaut }; +} + +// La lecture qui l'emporte, de la meilleure jusqu'ici et d'une suivante dans +// l'ordre des candidats : celle qui reconnaît le plus d'en-têtes, la première +// à égalité. +const meilleure = (jusquIci, suivante) => + jusquIci === null || suivante.reconnus > jusquIci.reconnus ? suivante : jusquIci; + /** * Choisit le séparateur parmi « ; », « , » et la tabulation en lisant les vingt * premiers enregistrements avec chacun. Un candidat convient quand la lecture @@ -135,7 +162,13 @@ export function enregistrementVide(enregistrement) { * Quand aucun candidat ne convient et que la première ligne entière, ses blancs * de bord retirés, est un en-tête reconnu, le fichier n'a qu'une colonne : * separateur est null, et un nom qui porte une virgule reste entier. Sinon, - * ErreurCsv('SEPARATEUR_INTROUVABLE'). + * ErreurCsv('SEPARATEUR_INTROUVABLE'), dont les détails nomment ce qui écarte + * le candidat que le même départage désigne parmi ceux dont l'en-tête a au + * moins deux champs : { separateur, ligne, cause }, ligne étant le rang de + * l'enregistrement fautif et cause NOMBRE_DE_CHAMPS — le premier + * enregistrement non vide dont le nombre de champs diffère de celui de + * l'en-tête — ou GUILLEMET_OUVERT — sans écart, le guillemet resté ouvert, + * au rang où il s'ouvre. Sans un tel candidat, les détails sont vides. * * @param {string} texte * @param {(entete: string) => unknown} reconnaitre rend une valeur vraie pour @@ -143,23 +176,22 @@ export function enregistrementVide(enregistrement) { * tel qu'il est écrit, ou la première ligne entière d'un fichier à une * colonne, ses blancs de bord retirés * @returns {{ separateur: string|null }} - * @throws {ErreurCsv} SEPARATEUR_INTROUVABLE + * @throws {ErreurCsv} SEPARATEUR_INTROUVABLE, détails + * { separateur, ligne, cause } ou {} */ export function choisirSeparateur(texte, reconnaitre) { let retenu = null; + let ecarte = null; for (const candidat of CANDIDATS) { - const { enregistrements, guillemetOuvert } = analyser(texte, candidat, FENETRE); - if (guillemetOuvert) continue; - const pleins = enregistrements.filter((e) => !enregistrementVide(e)); - if (pleins.length === 0) continue; - const largeur = pleins[0].length; - if (largeur < 2 || pleins.some((e) => e.length !== largeur)) continue; - const reconnus = pleins[0].filter((entete) => reconnaitre(entete)).length; - if (retenu === null || reconnus > retenu.reconnus) retenu = { separateur: candidat, reconnus }; + const lecture = lireAvec(texte, candidat, reconnaitre); + if (lecture === null) continue; + if (lecture.defaut === null) retenu = meilleure(retenu, lecture); + else ecarte = meilleure(ecarte, lecture); } if (retenu !== null) return { separateur: retenu.separateur }; const premiere = analyser(texte, null, FENETRE).enregistrements.find((e) => !enregistrementVide(e)); if (premiere !== undefined && reconnaitre(premiere[0].trim())) return { separateur: null }; - throw new ErreurCsv('SEPARATEUR_INTROUVABLE'); + if (ecarte === null) throw new ErreurCsv('SEPARATEUR_INTROUVABLE'); + throw new ErreurCsv('SEPARATEUR_INTROUVABLE', { separateur: ecarte.separateur, ...ecarte.defaut }); } diff --git a/src/csv/lecture.test.js b/src/csv/lecture.test.js index afe172b..4aa3049 100644 --- a/src/csv/lecture.test.js +++ b/src/csv/lecture.test.js @@ -39,14 +39,21 @@ function erreurDe(fonction) { return assert.fail("rien n'a été levé"); } -// Vérifie que fonction lève le refus d'un séparateur introuvable. -function assertSeparateurIntrouvable(fonction) { +// Vérifie que fonction lève le refus d'un séparateur introuvable, avec ses +// détails : le candidat écarté, le rang de l'enregistrement fautif et la +// cause, ou rien quand aucun candidat ne donne à l'en-tête deux champs. +function assertSeparateurIntrouvable(fonction, details = {}) { const erreur = erreurDe(fonction); assert.ok(erreur instanceof ErreurCsv); assert.equal(erreur.code, 'SEPARATEUR_INTROUVABLE'); - assert.deepEqual(erreur.details, {}); + assert.deepEqual(erreur.details, details); } +// Détails d'un refus dont le candidat est écarté par le nombre de champs d'un +// enregistrement, ou par un guillemet resté ouvert. +const ecart = (separateur, ligne) => ({ separateur, ligne, cause: 'NOMBRE_DE_CHAMPS' }); +const ouvert = (separateur, ligne) => ({ separateur, ligne, cause: 'GUILLEMET_OUVERT' }); + // Enregistrements que decouper rend, une fois vérifié qu'aucun guillemet ne // reste ouvert. function enregistrementsDe(texte, separateur = ';') { @@ -304,22 +311,22 @@ describe('choisirSeparateur : le candidat retenu (§ 10.1)', () => { }; test('un enregistrement plus long au rang 20 écarte « ; », au rang 21 non', () => { - assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y;z;t'), reconnaitre)); + assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y;z;t'), reconnaitre), ecart(';', 20)); assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y;z;t'), reconnaitre), { separateur: ';' }); }); test('un enregistrement plus court au rang 20 écarte « ; », au rang 21 non', () => { - assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y'), reconnaitre)); + assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y'), reconnaitre), ecart(';', 20)); assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y'), reconnaitre), { separateur: ';' }); }); test('un guillemet resté ouvert depuis le rang 20 écarte « ; », depuis le rang 21 non', () => { // Le dernier champ de l'enregistrement avale la fin du fichier : le // nombre de champs reste trois, seul le guillemet ouvert écarte « ; ». - const ouvert = 'x;y;"ouvert'; - assert.deepEqual(largeursLues(texteAvec(20, ouvert), ';'), new Array(20).fill(3)); - assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, ouvert), reconnaitre)); - const texte = texteAvec(21, ouvert); + const guillemet = 'x;y;"ouvert'; + assert.deepEqual(largeursLues(texteAvec(20, guillemet), ';'), new Array(20).fill(3)); + assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, guillemet), reconnaitre), ouvert(';', 20)); + const texte = texteAvec(21, guillemet); assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' }); // Le découpage du fichier entier, lui, voit le guillemet resté ouvert. assert.equal(decouper(texte, ';').guillemetOuvert, true); @@ -359,23 +366,27 @@ describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => { }); test('un nombre de champs qui varie, pour chaque candidat : un enregistrement plus long', () => { - assertSeparateurIntrouvable(() => - choisirSeparateur('nom;prenom\nBenoît;Exemple;Groupe Azur\n', reconnaitre), + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\nBenoît;Exemple;Groupe Azur\n', reconnaitre), + ecart(';', 2), ); }); test("un nombre de champs qui varie, pour chaque candidat : un enregistrement plus court, jusqu'à un seul champ", () => { - assertSeparateurIntrouvable(() => - choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\n', reconnaitre), + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\n', reconnaitre), + ecart(';', 2), ); - assertSeparateurIntrouvable(() => - choisirSeparateur('nom;prenom\nBenoît;Exemple\nune ligne sans séparateur\n', reconnaitre), + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\nBenoît;Exemple\nune ligne sans séparateur\n', reconnaitre), + ecart(';', 3), ); }); test('un guillemet resté ouvert dans les premiers enregistrements, même quand le nombre de champs reste constant', () => { - assertSeparateurIntrouvable(() => - choisirSeparateur('nom;prenom\n"Benoît;Exemple\nOdile;Fictive\n', reconnaitre), + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\n"Benoît;Exemple\nOdile;Fictive\n', reconnaitre), + ouvert(';', 2), ); // Deux champs partout, mais le second de la dernière ligne avale la fin du // fichier : seul le guillemet resté ouvert écarte « ; ». @@ -383,13 +394,66 @@ describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => { const lu = decouper(texte, ';'); assert.deepEqual(lu.enregistrements.map((e) => e.length), [2, 2]); assert.equal(lu.guillemetOuvert, true); - assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre)); + assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2)); }); test('un texte vide, ou fait de lignes vides', () => { assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre)); assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre)); }); + + test("le refus nomme le premier défaut dans l'ordre du texte, à son rang, les enregistrements vides comptés", () => { + // Un enregistrement plus court passe avant le guillemet ouvert qui le suit. + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\nOdile;"Fictive\n', reconnaitre), + ecart(';', 2), + ); + // Une ligne vide et une ligne de séparateurs seuls ne rompent rien, mais + // comptent dans le rang : le numéro de ligne qu'un tableur donne. + assertSeparateurIntrouvable( + () => choisirSeparateur('\nnom;prenom\n\n;\nBenoît;Exemple;x\n', reconnaitre), + ecart(';', 5), + ); + // Sans écart, le guillemet ouvert, au rang de l'enregistrement où il + // s'ouvre ; ce qui le suit tient dans son champ. + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\nBenoît;Exemple\n\nOdile;"Fictive\nRémi;Témoin\n', reconnaitre), + ouvert(';', 4), + ); + }); + + test("parmi les candidats écartés, le refus nomme celui aux plus d'en-têtes reconnus, puis le premier dans l'ordre", () => { + // « ; » et « , » donnent deux champs à l'en-tête, et chacun s'écarte sur + // son propre enregistrement : « , » au rang 2, « ; » au rang 3. Sous « , », + // « prenom » est reconnu ; sous « ; », rien. + const texte = 'x;nom,prenom\na;b,c,d\ne;f;g\n'; + assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ecart(',', 2)); + assertSeparateurIntrouvable(() => choisirSeparateur(texte, () => false), ecart(';', 3)); + // Un guillemet en tête de ligne s'ouvre sous chaque candidat. + assertSeparateurIntrouvable(() => choisirSeparateur('a;b,c\n"d;e,f\n', () => false), ouvert(';', 2)); + assertSeparateurIntrouvable(() => choisirSeparateur('a,b\tc\n"d,e\tf\n', () => false), ouvert(',', 2)); + }); + + test("aucun candidat ne donne à l'en-tête deux champs : le refus ne nomme rien", () => { + // Une colonne d'en-tête inconnu ; un séparateur hors des candidats ; un + // guillemet ouvert dès l'en-tête, qui n'en termine aucun ; vingt lignes + // vides, qui laissent la fenêtre sans en-tête. + const textes = [ + 'Benoît Exemple\n"Odile Fictive\n', + 'nom|prenom\nBenoît|"Exemple\n', + 'nom;"prenom\nBenoît;Exemple\n', + `${'\n'.repeat(20)}nom;prenom\nBenoît;Exemple;x\n`, + ]; + for (const texte of textes) { + assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre)); + } + }); + + test('un fichier à une colonne dont la ligne entière est un en-tête reconnu passe avant un candidat écarté', () => { + // « ; » donne deux champs à l'en-tête, puis trois : il est écarté au rang 2. + const reconnaitreLigne = (entete) => entete === 'nom;prenom'; + assert.deepEqual(choisirSeparateur('nom;prenom\nBenoît;Exemple;x\n', reconnaitreLigne), { separateur: null }); + }); }); describe("de l'octet à l'enregistrement : un CSV tel qu'un tableur l'écrit", () => { diff --git a/src/csv/normalisation.js b/src/csv/normalisation.js new file mode 100644 index 0000000..bcebe6f --- /dev/null +++ b/src/csv/normalisation.js @@ -0,0 +1,45 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Clés de comparaison des textes saisis (§ 10.1). Deux textes qu'un lecteur +// tient pour le même — casse, accents, blancs mis à part — ont la même clé. +// La réconciliation des appartenances et la clé de doublon comparent par la +// même clé : deux règles divergeraient sans qu'aucune épreuve de l'une ne le +// voie. La clé sert à comparer, jamais à afficher. + +// Toute marque combinante : accents, cédille, ogonek, marques englobantes. +const MARQUES = /\p{M}/gu; + +// Une suite de blancs : espace, tabulation, fins de ligne, espaces +// insécables et typographiques, marque d'ordre d'octets. +const BLANCS = /\s+/gu; + +// Ce qu'une clé d'en-tête ignore en plus : l'espace, le trait de +// soulignement et le trait d'union. +const SEPARATEURS_D_ENTETE = /[ _-]/g; + +/** + * Clé de comparaison d'un texte : décomposition canonique (NFD), marques + * combinantes retirées, minuscules sans égard à la langue, chaque suite de + * blancs réduite à une espace, blancs de bord retirés. Une lettre sans + * décomposition canonique, comme « œ » ou une ligature, reste ; la clé d'une + * clé est elle-même. + * + * @param {string} texte + * @returns {string} + */ +export function cleNormalisee(texte) { + return texte.normalize('NFD').replace(MARQUES, '').toLowerCase().replace(BLANCS, ' ').trim(); +} + +/** + * Clé d'un en-tête de colonne : la clé normalisée, sans espace, « _ » ni « - », + * si bien que « Titre-Pressenti », « titre pressenti » et « titre_pressenti » + * se reconnaissent ensemble. + * + * @param {string} texte + * @returns {string} + */ +export function cleEntete(texte) { + return cleNormalisee(texte).replace(SEPARATEURS_D_ENTETE, ''); +} diff --git a/src/csv/normalisation.test.js b/src/csv/normalisation.test.js new file mode 100644 index 0000000..21676d6 --- /dev/null +++ b/src/csv/normalisation.test.js @@ -0,0 +1,110 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des clés de comparaison (§ 10.1) : la clé normalisée, que +// partagent la réconciliation des appartenances et la clé de doublon, et la +// clé d'en-tête, qui en dérive. Les lettres décomposées et les blancs autres +// que l'espace s'écrivent par leur point de code : un éditeur qui recompose +// une lettre ou remplace un caractère invisible changerait l'épreuve sans +// qu'elle le montre. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { cleEntete, cleNormalisee } from './normalisation.js'; + +describe('cleNormalisee (§ 10.1)', () => { + test('la casse et les accents sont ignorés', () => { + for (const texte of ['Coopérative Bleue', 'COOPÉRATIVE BLEUE', 'coopérative bleue', 'Cooperative Bleue']) { + assert.equal(cleNormalisee(texte), 'cooperative bleue', texte); + } + assert.equal(cleNormalisee('Ça gèle à Noël, où ?'), 'ca gele a noel, ou ?'); + }); + + test('une lettre accentuée, composée ou décomposée, donne la même clé', () => { + const composee = 'Th\u{E9}o'; + const decomposee = 'The\u{301}o'; + assert.notEqual(composee, decomposee); + assert.equal(cleNormalisee(composee), 'theo'); + assert.equal(cleNormalisee(decomposee), 'theo'); + }); + + test('toute marque combinante est retirée, pas seulement les accents du français', () => { + // Cédille, rond en chef, tréma, double accent aigu, ogonek, puis un + // cercle englobant, marque combinante qui n'est pas un accent. + assert.equal(cleNormalisee('Gar\u{E7}on'), 'garcon'); + assert.equal(cleNormalisee('\u{C5}ngstr\u{F6}m'), 'angstrom'); + assert.equal(cleNormalisee('\u{150}rs\u{105}'), 'orsa'); + assert.equal(cleNormalisee('a\u{20DD}b'), 'ab'); + }); + + test('la décomposition est canonique : une ligature ou une lettre sans décomposition reste', () => { + assert.equal(cleNormalisee('\u{152}uvre'), '\u{153}uvre'); + assert.equal(cleNormalisee('\u{FB01}n'), '\u{FB01}n'); + assert.equal(cleNormalisee('\u{141}ukasz'), '\u{142}ukasz'); + }); + + test('les blancs se réduisent à une espace, les bouts retirés', () => { + assert.equal(cleNormalisee(' cooperative bleue '), 'cooperative bleue'); + assert.equal(cleNormalisee('club\tdes\u{A0}merles\r\nnord'), 'club des merles nord'); + assert.equal(cleNormalisee('\u{202F}chorale\u{3000}du\u{2009}givre\u{FEFF}'), 'chorale du givre'); + }); + + test("le trait d'union, le trait de soulignement et l'espace restent : seule la clé d'en-tête les retire", () => { + // La réconciliation des appartenances et la clé de doublon comparent par + // cette clé : « Club-des-Merles » et « Club des Merles » y restent deux. + assert.equal(cleNormalisee('Club-des-Merles'), 'club-des-merles'); + assert.equal(cleNormalisee('Club_des_Merles'), 'club_des_merles'); + assert.equal(cleNormalisee('Clubdes Merles'), 'clubdes merles'); + assert.notEqual(cleNormalisee('Val-Brume'), cleNormalisee('Val Brume')); + }); + + test('un texte vide ou blanc donne la clé vide', () => { + for (const texte of ['', ' ', '\t\r\n', '\u{A0}\u{3000}\u{202F}']) { + assert.equal(cleNormalisee(texte), '', JSON.stringify(texte)); + } + }); + + test('les minuscules ne suivent aucune langue', () => { + // Une minuscule turque ferait de I un ı sans point. + assert.equal(cleNormalisee('IRIS'), 'iris'); + // I à point suscrit : le point est une marque, retirée. + assert.equal(cleNormalisee('\u{130}LE'), 'ile'); + }); + + test('la clé est un point fixe : la normaliser de nouveau ne la change pas', () => { + for (const texte of ['Coopérative Bleue', ' The\u{301}o PERVENCHE ', '\u{130}LE', 'a\u{20DD}b', '\u{152}UVRE']) { + const cle = cleNormalisee(texte); + assert.equal(cleNormalisee(cle), cle, texte); + } + }); +}); + +describe('cleEntete (§ 10.1)', () => { + test('sans égard à la casse, aux accents, aux espaces, à « _ » ni à « - »', () => { + const cas = [ + ['NOM', 'nom'], + [' Prénom ', 'prenom'], + ['Équipe', 'equipe'], + ['Rôle', 'role'], + ['E X C L U', 'exclu'], + ['titre_pressenti', 'titrepressenti'], + ['Titre-Pressenti', 'titrepressenti'], + ['titre pressenti', 'titrepressenti'], + ['titre\u{A0}_\u{9}pressenti', 'titrepressenti'], + [' Ti_tre--Pres sen-ti ', 'titrepressenti'], + ]; + assert.deepEqual( + cas.map(([entete]) => cleEntete(entete)), + cas.map(([, cle]) => cle), + ); + }); + + test('seuls les blancs, « _ » et « - » sont retirés', () => { + assert.equal(cleEntete('titre.pressenti'), 'titre.pressenti'); + assert.equal(cleEntete('nom/prenom'), 'nom/prenom'); + assert.equal(cleEntete('titre\u{2013}pressenti'), 'titre\u{2013}pressenti'); + }); + + test('un en-tête vide ou fait de blancs, de « _ » et de « - » donne la clé vide', () => { + for (const entete of ['', ' ', '_-_', ' - ']) assert.equal(cleEntete(entete), '', JSON.stringify(entete)); + }); +}); diff --git a/test/fixtures/csv/champ_cite.csv b/test/fixtures/csv/champ_cite.csv new file mode 100644 index 0000000..89fffb1 --- /dev/null +++ b/test/fixtures/csv/champ_cite.csv @@ -0,0 +1,5 @@ +nom;prenom;appartenance;notes +Ombrelle;Iris;"Club ""Les Merles"" ; section nord";"dit ""bonjour"" ; puis part" +Pervenche;Théo;Chorale du Givre;"première ligne +deuxième ligne" +Sarcelle;Ondine;;fin diff --git a/test/fixtures/csv/derniere_ligne_sans_fin.csv b/test/fixtures/csv/derniere_ligne_sans_fin.csv new file mode 100644 index 0000000..8ba045d --- /dev/null +++ b/test/fixtures/csv/derniere_ligne_sans_fin.csv @@ -0,0 +1,3 @@ +nom;prenom;appartenance +Ombrelle;Iris;Club des Merles +Bruyère;Anouk;Chorale du Givre \ No newline at end of file diff --git a/test/fixtures/csv/entete_vide.csv b/test/fixtures/csv/entete_vide.csv new file mode 100644 index 0000000..4c714ef --- /dev/null +++ b/test/fixtures/csv/entete_vide.csv @@ -0,0 +1,3 @@ +nom;prenom;;appartenance +Ombrelle;Iris;note sans en-tête;Club des Merles +Pervenche;Théo;; diff --git a/test/fixtures/csv/espaces_autour.csv b/test/fixtures/csv/espaces_autour.csv new file mode 100644 index 0000000..6adc24d --- /dev/null +++ b/test/fixtures/csv/espaces_autour.csv @@ -0,0 +1,3 @@ + nom ; prenom ; appartenance ; courriel ; exclu + Ombrelle ; Iris ; Club des Merles ; iris@exemple.test ; Oui +Pervenche ; Théo ; ; ; diff --git a/test/fixtures/csv/ligne_vide_au_milieu.csv b/test/fixtures/csv/ligne_vide_au_milieu.csv new file mode 100644 index 0000000..13e8e8b --- /dev/null +++ b/test/fixtures/csv/ligne_vide_au_milieu.csv @@ -0,0 +1,6 @@ +nom;prenom;appartenance +Ombrelle;Iris;Club des Merles + +Pervenche;Théo; +;; +Sarcelle;Ondine;Chorale du Givre diff --git a/test/fixtures/csv/marque_crlf.csv b/test/fixtures/csv/marque_crlf.csv new file mode 100644 index 0000000..fe6653f --- /dev/null +++ b/test/fixtures/csv/marque_crlf.csv @@ -0,0 +1,4 @@ +nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes +Ombrelle;Iris;Club des Merles;iris.ombrelle@exemple.test;animation;non; +Pervenche;Théo;;theo.pervenche@exemple.test;;;arrive tard +Sarcelle;Ondine;Chorale du Givre;;;oui; diff --git a/test/fixtures/csv/utf16le_tabulation.csv b/test/fixtures/csv/utf16le_tabulation.csv new file mode 100644 index 0000000000000000000000000000000000000000..607b5f2bf241a6879736b683beecd8a18c1ec6f5 GIT binary patch literal 262 zcmXw!F%H5o5CoSpNIZbf$}4CQ6$JtbK41dKkpe^s=DoD}1Tc3|3fs4Pd%Ne~_o1im zD%7geRTGNfRuS&yY@}0B6|OL@*FtmcnLYbtqsIGNP))bcMoGt61_xc}1r*D$#-Vp* zh5t