[ADD] csv: columns by header, closed exclu values, mandatory preview

Columns associate by header, never by position, insensitive to case,
accents and spaces, with the § 10.1 synonyms; two columns for one field
associate neither and ask. The preview imports nothing: it lists valid
rows, refused rows with their reason, each group of merged spellings
with its counts, duplicates never merged, and global refusals, an open
quote or a NUL included. A paste goes through the same parser, and the
separator rule lives once, in lecture.js, whose refusal names its line.

Checked: 185 CSV tests, eight spreadsheet-shaped fixtures; two reviews.

--- FR ---

[ADD] csv : colonnes par en-tête, valeurs d'exclu fermées, aperçu

Les colonnes s'associent par en-tête, jamais par position, sans égard à
la casse, aux accents ni aux espaces, avec les synonymes du § 10.1 ;
deux colonnes d'un même champ ne s'associent pas, et le logiciel
demande. L'aperçu n'importe rien : lignes valides, lignes refusées et
leur motif, chaque groupe d'orthographes fondues et ses comptes,
doublons jamais fondus, refus globaux, guillemet ouvert et nul compris.
Le collage passe par le même analyseur, et la règle du séparateur ne
s'écrit qu'une fois, dans lecture.js, dont le refus nomme sa ligne.

Vérifié : 185 épreuves CSV, huit données de tableur ; deux revues.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 13:12:28 -04:00
parent fdaa79ed32
commit 35cc5f5f75
17 changed files with 2016 additions and 29 deletions

1
.gitattributes vendored
View file

@ -1,2 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
test/fixtures/csv/** -text

314
src/csv/apercu.js Normal file
View file

@ -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<string, boolean>}
*/
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<string, number>, 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<string, number>} [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,
};
}

1126
src/csv/apercu.test.js Normal file

File diff suppressed because it is too large Load diff

87
src/csv/colonnes.js Normal file
View file

@ -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<string, number>} [choix] { champ: rang }
* @returns {{
* colonnes: Object<string, number>,
* 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 };
}

180
src/csv/colonnes.test.js Normal file
View file

@ -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: [] });
});
});

View file

@ -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 });
}

View file

@ -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", () => {

45
src/csv/normalisation.js Normal file
View file

@ -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, '');
}

View file

@ -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));
});
});

5
test/fixtures/csv/champ_cite.csv vendored Normal file
View file

@ -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
1 nom prenom appartenance notes
2 Ombrelle Iris Club "Les Merles" ; section nord dit "bonjour" ; puis part
3 Pervenche Théo Chorale du Givre première ligne deuxième ligne
4 Sarcelle Ondine fin

View file

@ -0,0 +1,3 @@
nom;prenom;appartenance
Ombrelle;Iris;Club des Merles
Bruyère;Anouk;Chorale du Givre
1 nom prenom appartenance
2 Ombrelle Iris Club des Merles
3 Bruyère Anouk Chorale du Givre

3
test/fixtures/csv/entete_vide.csv vendored Normal file
View file

@ -0,0 +1,3 @@
nom;prenom;;appartenance
Ombrelle;Iris;note sans en-tête;Club des Merles
Pervenche;Théo;;
1 nom prenom appartenance
2 Ombrelle Iris note sans en-tête Club des Merles
3 Pervenche Théo

3
test/fixtures/csv/espaces_autour.csv vendored Normal file
View file

@ -0,0 +1,3 @@
 nom ; prenom ; appartenance ; courriel ; exclu
Ombrelle ; Iris ; Club des Merles ; iris@exemple.test ; Oui
Pervenche ; Théo ; ; ;
1 nom prenom appartenance courriel exclu
2 Ombrelle Iris Club des Merles iris@exemple.test Oui
3 Pervenche  Théo

View file

@ -0,0 +1,6 @@
nom;prenom;appartenance
Ombrelle;Iris;Club des Merles
Pervenche;Théo;
;;
Sarcelle;Ondine;Chorale du Givre
1 nom prenom appartenance
2 Ombrelle Iris Club des Merles
3 Pervenche Théo
4
5 Sarcelle Ondine Chorale du Givre

4
test/fixtures/csv/marque_crlf.csv vendored Normal file
View file

@ -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;
1 nom prenom appartenance courriel titre_pressenti exclu notes
2 Ombrelle Iris Club des Merles iris.ombrelle@exemple.test animation non
3 Pervenche Théo theo.pervenche@exemple.test arrive tard
4 Sarcelle Ondine Chorale du Givre oui

BIN
test/fixtures/csv/utf16le_tabulation.csv vendored Normal file

Binary file not shown.
1 nom prenom appartenance notes
2 Ombrelle Iris Chœur de l’Anse café à 8 h
3 Pervenche Théo Club des Merles dit "oui" puis part

4
test/fixtures/csv/windows1252.csv vendored Normal file
View file

@ -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"
1 nom prenom appartenance notes
2 Ombrelle Iris Chœur de l’Anse café à 8 h
3 Pervenche Théo Club des Merles dit "oui" puis part