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 0000000..607b5f2 Binary files /dev/null and b/test/fixtures/csv/utf16le_tabulation.csv differ diff --git a/test/fixtures/csv/windows1252.csv b/test/fixtures/csv/windows1252.csv new file mode 100644 index 0000000..bc06c9b --- /dev/null +++ b/test/fixtures/csv/windows1252.csv @@ -0,0 +1,4 @@ +nom;prenom;appartenance;notes +Ombrelle;Iris;Chœur de l’Anse;café à 8 h +Pervenche;Théo;Club des Merles;"dit ""oui"" +puis part"