[ADD] csv: decode in four steps, choose the separator, cut records
Bytes decode in the order of § 10.1: UTF-8 mark, UTF-16 mark, a NUL in the first 4 KiB without a mark refused with its remedy, then strict UTF-8, then windows-1252. A mark its bytes contradict, and any decoded NUL, are refused: decoded, they would give names no later check sees. The separator is the candidate among ";", "," and tab that leaves no quote open and gives one field count above one on the first twenty records; a lone recognised header reads as one column. Records follow RFC 4180 and are numbered as a spreadsheet numbers its rows. Checked: 89 tests, 60 mutants killed; review found no behaviour defect. --- FR --- [ADD] csv : décodage en quatre temps, choix du séparateur, découpage Les octets se décodent dans l'ordre du § 10.1 : marque UTF-8, marque UTF-16, octet nul dans les 4 Kio sans marque refusé avec son remède, UTF-8 strict, puis windows-1252. Une marque contredite par ses octets, et tout nul décodé, sont refusés : décodés, ils donneraient des noms qu'aucun contrôle ne verrait. Le séparateur parmi « ; », « , » et la tabulation ne laisse aucun guillemet ouvert et donne un même nombre de champs, plus d'un, sur vingt enregistrements ; un en-tête seul reconnu se lit en une colonne. Découpage RFC 4180, rangs comme dans un tableur. Vérifié : 89 épreuves, 60 mutants tués ; aucun défaut à la revue. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
parent
b9090508ff
commit
dd088f2dd9
5 changed files with 1169 additions and 0 deletions
130
src/csv/encodage.js
Normal file
130
src/csv/encodage.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Décodage des octets d'un fichier CSV en texte (§ 10.1). Une détection qui se
|
||||||
|
// trompe ne fait échouer aucun calcul : un nom décodé de travers se découvre
|
||||||
|
// sur le plan imprimé. La détection suit donc quatre temps dans un ordre fixe,
|
||||||
|
// et n'emploie que les décodeurs que nomme le § 10.1. Un fichier que ces temps
|
||||||
|
// ne lisent pas sûrement est refusé, et le refus nomme le remède.
|
||||||
|
|
||||||
|
import { ErreurCsv } from './erreurs.js';
|
||||||
|
|
||||||
|
// Quatre kibioctets : la part du fichier où se cherche un octet nul.
|
||||||
|
const OCTETS_SONDES = 4096;
|
||||||
|
|
||||||
|
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
|
||||||
|
const MARQUE_UTF16LE = [0xff, 0xfe];
|
||||||
|
const MARQUE_UTF16BE = [0xfe, 0xff];
|
||||||
|
|
||||||
|
// Vrai quand octets s'ouvre sur marque ; un fichier plus court qu'elle ne la
|
||||||
|
// porte pas.
|
||||||
|
const commencePar = (octets, marque) => marque.every((octet, i) => octets[i] === octet);
|
||||||
|
|
||||||
|
// Refus d'encodage de code donné. Chaque refus a ses propres détails, et tous
|
||||||
|
// renvoient au même remède : un fichier réenregistré en UTF-8.
|
||||||
|
const refuser = (code) => new ErreurCsv(code, { remede: 'ENREGISTRER_EN_UTF8' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai quand le texte porte le caractère nul (U+0000), qu'aucun CSV ne porte.
|
||||||
|
* decoder refuse le texte qu'il décode quand il en porte un ; la même fonction
|
||||||
|
* contrôle un texte qui n'est pas passé par decoder.
|
||||||
|
*
|
||||||
|
* @param {string} texte
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function contientNul(texte) {
|
||||||
|
return texte.includes('\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Octets de l'entrée : un Uint8Array, dont un Buffer, tel quel ; un ArrayBuffer
|
||||||
|
// par une vue sur lui. Les deux se reconnaissent à leur étiquette de type, non
|
||||||
|
// par instanceof, que fait échouer un tampon venu d'un autre contexte
|
||||||
|
// d'exécution. Tout autre argument est refusé : new Uint8Array en ferait un
|
||||||
|
// fichier vide, d'une promesse non attendue comme d'un Blob.
|
||||||
|
function enOctets(entree) {
|
||||||
|
const genre = Object.prototype.toString.call(entree);
|
||||||
|
if (genre === '[object Uint8Array]') return entree;
|
||||||
|
if (genre === '[object ArrayBuffer]') return new Uint8Array(entree);
|
||||||
|
throw new TypeError('octets : Uint8Array ou ArrayBuffer attendu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte UTF-16 des octets, la marque déjà retirée ; encodage est 'utf-16le' ou
|
||||||
|
// 'utf-16be', l'étiquette même du décodeur. Le décodage est strict : un nombre
|
||||||
|
// impair d'octets ou un substitut isolé lève UTF16_INVALIDE, là où le décodeur
|
||||||
|
// sèmerait des caractères de remplacement dans les noms.
|
||||||
|
function lireUtf16(octets, encodage) {
|
||||||
|
try {
|
||||||
|
return { texte: new TextDecoder(encodage, { fatal: true }).decode(octets), encodage };
|
||||||
|
} catch {
|
||||||
|
throw refuser('UTF16_INVALIDE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte UTF-8 des octets, ou null quand ils ne sont pas de l'UTF-8 valide.
|
||||||
|
function lireUtf8(octets) {
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(octets);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les quatre temps de decoder, sans le contrôle du caractère nul.
|
||||||
|
function lireOctets(octets) {
|
||||||
|
if (commencePar(octets, MARQUE_UTF8)) {
|
||||||
|
const texte = lireUtf8(octets.subarray(MARQUE_UTF8.length));
|
||||||
|
if (texte === null) throw refuser('UTF8_INVALIDE');
|
||||||
|
return { texte, encodage: 'utf-8-bom' };
|
||||||
|
}
|
||||||
|
if (commencePar(octets, MARQUE_UTF16LE)) {
|
||||||
|
return lireUtf16(octets.subarray(MARQUE_UTF16LE.length), 'utf-16le');
|
||||||
|
}
|
||||||
|
if (commencePar(octets, MARQUE_UTF16BE)) {
|
||||||
|
return lireUtf16(octets.subarray(MARQUE_UTF16BE.length), 'utf-16be');
|
||||||
|
}
|
||||||
|
if (octets.subarray(0, OCTETS_SONDES).includes(0)) throw refuser('UTF16_SANS_MARQUE');
|
||||||
|
const texte = lireUtf8(octets);
|
||||||
|
if (texte !== null) return { texte, encodage: 'utf-8' };
|
||||||
|
// windows-1252 donne un caractère à chaque octet : le repli n'échoue jamais.
|
||||||
|
return { texte: new TextDecoder('windows-1252').decode(octets), encodage: 'windows-1252' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décode les octets d'un fichier CSV et rend le texte avec l'encodage retenu,
|
||||||
|
* en quatre temps, dans cet ordre :
|
||||||
|
* 1. la marque d'ordre d'octets UTF-8, qui déclare l'encodage : des octets
|
||||||
|
* qui ne sont pas de l'UTF-8 valide sont refusés ;
|
||||||
|
* 2. la marque UTF-16, LE puis BE, qui déclare l'encodage : des octets qui ne
|
||||||
|
* sont pas de l'UTF-16 valide, nombre impair d'octets ou substitut isolé,
|
||||||
|
* sont refusés ;
|
||||||
|
* 3. sans marque, un octet nul parmi les 4 096 premiers octets : refus ;
|
||||||
|
* 4. UTF-8 strict, sinon windows-1252.
|
||||||
|
* La marque n'entre pas dans le texte rendu. Quel que soit le temps qui a
|
||||||
|
* décodé, un texte qui porte le caractère nul est refusé.
|
||||||
|
*
|
||||||
|
* Le troisième temps précède le quatrième parce qu'un texte UTF-16 sans marque
|
||||||
|
* se lit sans erreur dans l'un ou l'autre des décodeurs suivants : en UTF-8
|
||||||
|
* quand il n'a pas d'accent, chaque octet nul s'y lisant comme le caractère
|
||||||
|
* nul, en windows-1252 quand il en a, ce décodeur acceptant tout octet. Le
|
||||||
|
* texte rendu serait entrelardé de caractères nuls, sans qu'aucun calcul
|
||||||
|
* n'échoue. Le refus du caractère nul rattrape ce que ce temps ne voit pas :
|
||||||
|
* un nul au-delà des 4 096 premiers octets, et un texte que sa marque fait lire
|
||||||
|
* sans erreur, comme l'UTF-32 LE, dont la marque FF FE 00 00 commence par celle
|
||||||
|
* de l'UTF-16 LE.
|
||||||
|
*
|
||||||
|
* Sous la marque UTF-8, un repli sur windows-1252 changerait chaque accent de la
|
||||||
|
* partie valide en caractères parasites, sans qu'aucun calcul n'échoue : le
|
||||||
|
* fichier est refusé.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array|ArrayBuffer} octets l'un ou l'autre, de n'importe quel
|
||||||
|
* contexte d'exécution
|
||||||
|
* @returns {{ texte: string, encodage: 'utf-8-bom'|'utf-16le'|'utf-16be'|'utf-8'|'windows-1252' }}
|
||||||
|
* @throws {ErreurCsv} UTF16_SANS_MARQUE, UTF16_INVALIDE, UTF8_INVALIDE ou
|
||||||
|
* CARACTERE_NUL, chacun de détails { remede: 'ENREGISTRER_EN_UTF8' }
|
||||||
|
* @throws {TypeError} quand octets n'est ni un Uint8Array ni un ArrayBuffer
|
||||||
|
*/
|
||||||
|
export function decoder(octets) {
|
||||||
|
const lu = lireOctets(enOctets(octets));
|
||||||
|
if (contientNul(lu.texte)) throw refuser('CARACTERE_NUL');
|
||||||
|
return lu;
|
||||||
|
}
|
||||||
353
src/csv/encodage.test.js
Normal file
353
src/csv/encodage.test.js
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du décodage d'un fichier CSV (§ 10.1, § 14.10) : les cinq
|
||||||
|
// encodages que rend decoder, les quatre temps dans leur ordre, les quatre
|
||||||
|
// refus et leur remède — UTF-16 sans marque, UTF-16 invalide sous sa marque,
|
||||||
|
// UTF-8 invalide sous la marque UTF-8, caractère nul dans le texte décodé —,
|
||||||
|
// la frontière des quatre premiers kibioctets, la forme de l'entrée, y
|
||||||
|
// compris un tampon venu d'un autre contexte d'exécution. Les octets se
|
||||||
|
// construisent ici sans passer par un décodeur : UTF-8 par TextEncoder,
|
||||||
|
// UTF-16 et UTF-32 unité par unité, windows-1252 octet par octet.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { contientNul, decoder } from './encodage.js';
|
||||||
|
import { ErreurCsv } from './erreurs.js';
|
||||||
|
|
||||||
|
const BENOIT = 'Benoît';
|
||||||
|
|
||||||
|
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
|
||||||
|
|
||||||
|
const enUtf8 = (texte) => new TextEncoder().encode(texte);
|
||||||
|
|
||||||
|
// Les octets de tete, puis ceux de octets.
|
||||||
|
const avec = (tete, octets) => Uint8Array.from([...tete, ...octets]);
|
||||||
|
|
||||||
|
// Octets UTF-16 de texte, une unité à la fois : ordre vaut 'le' (octet de
|
||||||
|
// poids faible d'abord) ou 'be' ; la marque d'ordre d'octets ouvre le fichier
|
||||||
|
// quand avecMarque est vrai.
|
||||||
|
function enUtf16(texte, ordre, avecMarque = true) {
|
||||||
|
const octets = [];
|
||||||
|
if (avecMarque) octets.push(...(ordre === 'be' ? [0xfe, 0xff] : [0xff, 0xfe]));
|
||||||
|
for (let i = 0; i < texte.length; i += 1) {
|
||||||
|
const unite = texte.charCodeAt(i);
|
||||||
|
const poids = [unite >> 8, unite & 0xff];
|
||||||
|
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Octets UTF-32 de texte, un point de code à la fois, précédés de la marque
|
||||||
|
// d'ordre d'octets : FF FE 00 00 pour 'le', 00 00 FE FF pour 'be'.
|
||||||
|
function enUtf32(texte, ordre) {
|
||||||
|
const octets = ordre === 'be' ? [0x00, 0x00, 0xfe, 0xff] : [0xff, 0xfe, 0x00, 0x00];
|
||||||
|
for (const caractere of texte) {
|
||||||
|
const point = caractere.codePointAt(0);
|
||||||
|
const poids = [point >>> 24, (point >> 16) & 0xff, (point >> 8) & 0xff, point & 0xff];
|
||||||
|
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Octets windows-1252 de texte : un caractère Latin-1 garde son code, les deux
|
||||||
|
// signes de 0x80 à 0x9F que les épreuves emploient ont le leur ; tout autre
|
||||||
|
// caractère fait échouer l'épreuve plutôt que de s'écrire de travers.
|
||||||
|
const WINDOWS_1252_PARTICULIERS = new Map([
|
||||||
|
['€', 0x80],
|
||||||
|
['’', 0x92],
|
||||||
|
]);
|
||||||
|
const enWindows1252 = (texte) =>
|
||||||
|
Uint8Array.from(texte, (caractere) => {
|
||||||
|
const code = WINDOWS_1252_PARTICULIERS.get(caractere) ?? caractere.codePointAt(0);
|
||||||
|
assert.ok(code < 0x100, `« ${caractere} » n'existe pas en windows-1252`);
|
||||||
|
return code;
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
|
||||||
|
function erreurDe(fonction) {
|
||||||
|
try {
|
||||||
|
fonction();
|
||||||
|
} catch (erreur) {
|
||||||
|
return erreur;
|
||||||
|
}
|
||||||
|
return assert.fail("rien n'a été levé");
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'erreur que lève decoder sur ces octets.
|
||||||
|
const refusDe = (octets) => erreurDe(() => decoder(octets));
|
||||||
|
|
||||||
|
// Vérifie que erreur est le refus de code donné, remède compris : tout refus
|
||||||
|
// d'encodage renvoie au même remède.
|
||||||
|
function assertRefus(erreur, code) {
|
||||||
|
assert.ok(erreur instanceof ErreurCsv);
|
||||||
|
assert.equal(erreur.code, code);
|
||||||
|
assert.deepEqual(erreur.details, { remede: 'ENREGISTRER_EN_UTF8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decoder : les cinq encodages (§ 10.1)', () => {
|
||||||
|
const CAS = [
|
||||||
|
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8(BENOIT)), 'utf-8-bom'],
|
||||||
|
['UTF-8 sans marque', enUtf8(BENOIT), 'utf-8'],
|
||||||
|
['UTF-16 LE avec marque', enUtf16(BENOIT, 'le'), 'utf-16le'],
|
||||||
|
['UTF-16 BE avec marque', enUtf16(BENOIT, 'be'), 'utf-16be'],
|
||||||
|
['windows-1252', enWindows1252(BENOIT), 'windows-1252'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [libelle, octets, encodage] of CAS) {
|
||||||
|
test(`« Benoît » en ${libelle} : le texte, sans marque, et l'encodage retenu`, () => {
|
||||||
|
assert.deepEqual(decoder(octets), { texte: BENOIT, encodage });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('windows-1252 : les octets 0x80 à 0x9F sont des signes, non des caractères de commande', () => {
|
||||||
|
const texte = 'l’équipe à 5 €';
|
||||||
|
assert.deepEqual(decoder(enWindows1252(texte)), { texte, encodage: 'windows-1252' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UTF-8 : un caractère hors du plan de base et les accents d'un texte plus long", () => {
|
||||||
|
const texte = 'nom;prenom\r\nÉlodie Témoin;Noël 𝄞\r\n';
|
||||||
|
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UTF-16 : une paire de substitution d'un caractère hors du plan de base, en LE comme en BE", () => {
|
||||||
|
const texte = 'Odile 𝄞';
|
||||||
|
assert.deepEqual(decoder(enUtf16(texte, 'le')), { texte, encodage: 'utf-16le' });
|
||||||
|
assert.deepEqual(decoder(enUtf16(texte, 'be')), { texte, encodage: 'utf-16be' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fichier vide, ou réduit à sa marque, donne un texte vide', () => {
|
||||||
|
assert.deepEqual(decoder(new Uint8Array(0)), { texte: '', encodage: 'utf-8' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from(MARQUE_UTF8)), { texte: '', encodage: 'utf-8-bom' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xff, 0xfe])), { texte: '', encodage: 'utf-16le' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0xff])), { texte: '', encodage: 'utf-16be' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une marque incomplète n'en est pas une : ses octets restent du texte", () => {
|
||||||
|
assert.deepEqual(decoder(enWindows1252('ïle Fictive')), { texte: 'ïle Fictive', encodage: 'windows-1252' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb])), { texte: 'ï»', encodage: 'windows-1252' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb, 0x78])), { texte: 'ï»x', encodage: 'windows-1252' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xff, 0x61])), { texte: 'ÿa', encodage: 'windows-1252' });
|
||||||
|
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0x61])), { texte: 'þa', encodage: 'windows-1252' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un ArrayBuffer se décode comme les octets qu'il porte", () => {
|
||||||
|
const octets = avec(MARQUE_UTF8, enUtf8(BENOIT));
|
||||||
|
assert.deepEqual(decoder(octets.buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une vue sur une partie d'un tampon plus grand se décode seule, sans les octets voisins", () => {
|
||||||
|
const tampon = avec([0x78, 0x78], avec(MARQUE_UTF8, avec(enUtf8(BENOIT), [0x79, 0x79])));
|
||||||
|
const vue = new Uint8Array(tampon.buffer, 2, MARQUE_UTF8.length + enUtf8(BENOIT).length);
|
||||||
|
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decoder : la forme de l'entrée", () => {
|
||||||
|
// Les octets de « Benoît » en UTF-8 avec marque, écrits dans le contexte
|
||||||
|
// d'exécution neuf, qui a ses propres ArrayBuffer et Uint8Array.
|
||||||
|
const OCTETS_ECRITS = JSON.stringify([...avec(MARQUE_UTF8, enUtf8(BENOIT))]);
|
||||||
|
|
||||||
|
test("un ArrayBuffer d'un autre contexte d'exécution se décode : instanceof ne le reconnaît pas", () => {
|
||||||
|
const tampon = runInNewContext(`new Uint8Array(${OCTETS_ECRITS}).buffer`);
|
||||||
|
assert.ok(!(tampon instanceof ArrayBuffer));
|
||||||
|
assert.deepEqual(decoder(tampon), { texte: BENOIT, encodage: 'utf-8-bom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un Uint8Array d'un autre contexte d'exécution se décode", () => {
|
||||||
|
const vue = runInNewContext(`new Uint8Array(${OCTETS_ECRITS})`);
|
||||||
|
assert.ok(!(vue instanceof Uint8Array));
|
||||||
|
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un Buffer de Node, sous-classe de Uint8Array, se décode', () => {
|
||||||
|
const buffer = Buffer.from(avec(MARQUE_UTF8, enUtf8(BENOIT)));
|
||||||
|
assert.deepEqual(decoder(buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une entrée qui n'est pas des octets est refusée par un TypeError, non lue comme un fichier vide", () => {
|
||||||
|
// Une promesse (arrayBuffer() non attendu), un Blob (le fichier même), une
|
||||||
|
// chaîne, rien : new Uint8Array en ferait des octets en nombre nul.
|
||||||
|
const entrees = [
|
||||||
|
'abc',
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
Promise.resolve(new Uint8Array(0)),
|
||||||
|
new Blob(['abc']),
|
||||||
|
[0xef, 0xbb, 0xbf],
|
||||||
|
];
|
||||||
|
for (const entree of entrees) {
|
||||||
|
assert.throws(() => decoder(entree), TypeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decoder : un UTF-16 sans marque est refusé, avec son remède (§ 10.1)', () => {
|
||||||
|
test('« Benoît » en UTF-16 LE sans marque : refus, remède nommé', () => {
|
||||||
|
assertRefus(refusDe(enUtf16(BENOIT, 'le', false)), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('« Benoît » en UTF-16 BE sans marque : refus, remède nommé', () => {
|
||||||
|
assertRefus(refusDe(enUtf16(BENOIT, 'be', false)), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un contenu sans accent est de l'UTF-8 valide en UTF-16 : refusé de même, non lu en texte entrelardé de caractères nuls", () => {
|
||||||
|
// Chaque octet nul s'y décode comme le caractère nul : l'ordre à trois
|
||||||
|
// temps rendrait « n\0o\0m\0… » sans rien refuser.
|
||||||
|
const octets = enUtf16('nom;prenom\r\nBenoit Exemple;Odile', 'le', false);
|
||||||
|
assert.doesNotThrow(() => new TextDecoder('utf-8', { fatal: true }).decode(octets));
|
||||||
|
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avec un accent, le texte n'est plus de l'UTF-8 valide : l'ordre à trois temps le rendrait en windows-1252, nuls compris — refusé", () => {
|
||||||
|
const octets = enUtf16(BENOIT, 'le', false);
|
||||||
|
assert.throws(() => new TextDecoder('utf-8', { fatal: true }).decode(octets), TypeError);
|
||||||
|
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'octet nul n'est cherché sans marque que dans les 4 096 premiers octets ; au-delà, c'est le caractère nul qui est refusé", () => {
|
||||||
|
const octets = new Uint8Array(4097).fill(0x61);
|
||||||
|
octets[4095] = 0;
|
||||||
|
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
|
||||||
|
octets[4095] = 0x61;
|
||||||
|
octets[4096] = 0;
|
||||||
|
assertRefus(refusDe(octets), 'CARACTERE_NUL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la sonde commence au premier octet : « A » en UTF-16 BE sans marque, un seul octet nul', () => {
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0x00, 0x41)), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un UTF-16 à marque ne se confond pas avec le refus : ses octets nuls ne le refusent pas', () => {
|
||||||
|
const octets = enUtf16('nom;prenom', 'le');
|
||||||
|
assert.ok(octets.slice(0, 4096).includes(0));
|
||||||
|
assert.deepEqual(decoder(octets), { texte: 'nom;prenom', encodage: 'utf-16le' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decoder : un UTF-16 sous sa marque doit être valide (§ 10.1)', () => {
|
||||||
|
test("un nombre impair d'octets : refus UTF16_INVALIDE, remède nommé", () => {
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x62)), 'UTF16_INVALIDE');
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0x00, 0x61, 0x00)), 'UTF16_INVALIDE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un substitut isolé, haut ou bas, en LE comme en BE : refus UTF16_INVALIDE', () => {
|
||||||
|
// Haut suivi d'une lettre, bas seul, haut en fin de fichier.
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xd8, 0x61, 0x00)), 'UTF16_INVALIDE');
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xdc)), 'UTF16_INVALIDE');
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x00, 0xd8)), 'UTF16_INVALIDE');
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xd8, 0x00, 0x00, 0x61)), 'UTF16_INVALIDE');
|
||||||
|
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xdc, 0x00)), 'UTF16_INVALIDE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une paire de substitution valide reste lue', () => {
|
||||||
|
assert.deepEqual(decoder(Uint8Array.of(0xff, 0xfe, 0x34, 0xd8, 0x1e, 0xdd)), {
|
||||||
|
texte: '𝄞',
|
||||||
|
encodage: 'utf-16le',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decoder : la marque UTF-8 déclare l'encodage (§ 10.1)", () => {
|
||||||
|
test("une marque UTF-8 suivie d'octets windows-1252 : refus UTF8_INVALIDE, remède nommé", () => {
|
||||||
|
assertRefus(refusDe(avec(MARQUE_UTF8, enWindows1252(BENOIT))), 'UTF8_INVALIDE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une partie valide en UTF-8 puis un octet isolé : refusée, non lue en windows-1252 où chaque accent de la partie valide deviendrait des caractères parasites", () => {
|
||||||
|
const corps = [...enUtf8('Benoît Exemple;Odile Fictive\r\n'), 0xe9];
|
||||||
|
// Sans la marque, les mêmes octets se lisent en windows-1252 : « î », en
|
||||||
|
// UTF-8 C3 AE, y devient « î », sans que rien n'échoue.
|
||||||
|
const sansMarque = decoder(Uint8Array.from(corps));
|
||||||
|
assert.equal(sansMarque.encodage, 'windows-1252');
|
||||||
|
assert.ok(sansMarque.texte.startsWith('Benoît'));
|
||||||
|
assertRefus(refusDe(avec(MARQUE_UTF8, corps)), 'UTF8_INVALIDE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une séquence UTF-8 coupée en fin de fichier : refus UTF8_INVALIDE', () => {
|
||||||
|
assertRefus(refusDe(avec(MARQUE_UTF8, [...enUtf8('Beno'), 0xc3])), 'UTF8_INVALIDE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decoder : un caractère nul dans le texte décodé est refusé (§ 10.1)', () => {
|
||||||
|
test("un UTF-32 LE, que sa marque FF FE 00 00 fait prendre pour de l'UTF-16 LE : refus CARACTERE_NUL, non un texte entrelardé de caractères nuls", () => {
|
||||||
|
const octets = enUtf32('nom;prenom\r\nBenoît Exemple;Odile', 'le');
|
||||||
|
// Sous la marque UTF-16 LE, ces octets se lisent sans erreur, nuls compris.
|
||||||
|
assert.deepEqual([...octets.slice(0, 2)], [0xff, 0xfe]);
|
||||||
|
assert.ok(new TextDecoder('utf-16le').decode(octets).includes('\0'));
|
||||||
|
assertRefus(refusDe(octets), 'CARACTERE_NUL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un UTF-32 BE ouvre sur des octets nuls : refusé comme un UTF-16 sans marque', () => {
|
||||||
|
assertRefus(refusDe(enUtf32('nom;prenom', 'be')), 'UTF16_SANS_MARQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4 097 octets dont le dernier est nul, donc hors de la sonde des 4 096
|
||||||
|
// premiers : de l'UTF-8 valide, ou, avec un octet isolé en tête, du
|
||||||
|
// windows-1252.
|
||||||
|
function octetsAvecNulLoin(invalideEnUtf8) {
|
||||||
|
const octets = new Uint8Array(4097).fill(0x61);
|
||||||
|
if (invalideEnUtf8) octets[0] = 0xe9;
|
||||||
|
octets[4096] = 0;
|
||||||
|
return octets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un caractère nul est refusé quel que soit le temps qui a décodé le texte.
|
||||||
|
// Sous une marque, les premiers octets portent le caractère nul, ou, en
|
||||||
|
// UTF-16, des octets nuls : la marque prime sur la recherche de l'octet nul,
|
||||||
|
// et le refus nomme le caractère, non un UTF-16 sans marque.
|
||||||
|
const AVEC_NUL = [
|
||||||
|
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8('a\0b'))],
|
||||||
|
['UTF-16 LE avec marque', enUtf16('a\0b', 'le')],
|
||||||
|
['UTF-16 BE avec marque', enUtf16('a\0b', 'be')],
|
||||||
|
['UTF-8 sans marque, le nul hors de la sonde', octetsAvecNulLoin(false)],
|
||||||
|
['windows-1252, le nul hors de la sonde', octetsAvecNulLoin(true)],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [libelle, octets] of AVEC_NUL) {
|
||||||
|
test(`${libelle} : un caractère nul dans le texte décodé est refusé, remède nommé`, () => {
|
||||||
|
assertRefus(refusDe(octets), 'CARACTERE_NUL');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('seul le caractère nul est refusé : les autres caractères de commande passent', () => {
|
||||||
|
const texte = 'a\tb\r\nc\u0001d\u007f';
|
||||||
|
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque refus porte ses propres détails', () => {
|
||||||
|
const premier = refusDe(enUtf16(BENOIT, 'le', false));
|
||||||
|
const second = refusDe(enUtf16(BENOIT, 'le', false));
|
||||||
|
assert.notEqual(premier.details, second.details);
|
||||||
|
premier.details.remede = 'AUTRE';
|
||||||
|
assert.equal(second.details.remede, 'ENREGISTRER_EN_UTF8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contientNul', () => {
|
||||||
|
test("vrai quand le texte porte U+0000, où qu'il soit", () => {
|
||||||
|
for (const texte of ['\0', 'a\0', '\0a', 'a\0b', 'nom;prenom\r\nBenoît\0;Exemple']) {
|
||||||
|
assert.equal(contientNul(texte), true, JSON.stringify(texte));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('faux sans U+0000, les autres caractères de commande, le chiffre zéro et la barre oblique inverse comprises', () => {
|
||||||
|
for (const texte of ['', 'Benoît', 'a\tb\r\nc\u0001d\u007f', '0', '\\0', '\u2400']) {
|
||||||
|
assert.equal(contientNul(texte), false, JSON.stringify(texte));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErreurCsv', () => {
|
||||||
|
test('porte son code, ses détails et un message en JSON', () => {
|
||||||
|
const erreur = new ErreurCsv('UN_CODE', { cle: 1 });
|
||||||
|
assert.ok(erreur instanceof Error);
|
||||||
|
assert.equal(erreur.name, 'ErreurCsv');
|
||||||
|
assert.equal(erreur.code, 'UN_CODE');
|
||||||
|
assert.deepEqual(erreur.details, { cle: 1 });
|
||||||
|
assert.equal(erreur.message, 'UN_CODE {"cle":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans détails, ses détails sont un objet vide', () => {
|
||||||
|
assert.deepEqual(new ErreurCsv('SANS_DETAIL').details, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/csv/erreurs.js
Normal file
21
src/csv/erreurs.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Erreurs que lève la lecture d'un CSV. Le code d'une ErreurCsv nomme le
|
||||||
|
// refus ; ses détails portent ce que l'appelant lit pour agir, comme le
|
||||||
|
// remède d'un refus d'encodage. Le message, code et détails en JSON, sert au
|
||||||
|
// diagnostic et n'est pas un texte affiché, que fournit la table des libellés
|
||||||
|
// (§ 14.6).
|
||||||
|
|
||||||
|
export class ErreurCsv extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param {Object} [details]
|
||||||
|
*/
|
||||||
|
constructor(code, details = {}) {
|
||||||
|
super(`${code} ${JSON.stringify(details)}`);
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ErreurCsv.prototype.name = 'ErreurCsv';
|
||||||
165
src/csv/lecture.js
Normal file
165
src/csv/lecture.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Lecture d'un texte CSV déjà décodé (§ 10.1, § 10.2) : sa découpe en
|
||||||
|
// enregistrements et le choix de son séparateur. L'import d'un fichier et le
|
||||||
|
// collage en bloc passent par ces mêmes fonctions : le texte qu'elles reçoivent
|
||||||
|
// n'a plus d'origine, et un second analyseur « simplifié » divergerait du
|
||||||
|
// premier sur les accents et les guillemets.
|
||||||
|
|
||||||
|
import { ErreurCsv } from './erreurs.js';
|
||||||
|
|
||||||
|
const GUILLEMET = '"';
|
||||||
|
|
||||||
|
// Candidats du séparateur, dans l'ordre qui départage deux candidats à égalité
|
||||||
|
// de tout le reste.
|
||||||
|
const CANDIDATS = [';', ',', '\t'];
|
||||||
|
|
||||||
|
// Nombre d'enregistrements que le choix du séparateur lit.
|
||||||
|
const FENETRE = 20;
|
||||||
|
|
||||||
|
// Parcourt texte en un seul passage et rend ses enregistrements, avec l'état
|
||||||
|
// des guillemets à la fin de la lecture. La lecture s'arrête après limite
|
||||||
|
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture
|
||||||
|
// s'arrêtant hors d'un champ cité.
|
||||||
|
//
|
||||||
|
// La découpe suit RFC 4180, avec ces précisions :
|
||||||
|
// - un guillemet ouvre un champ cité quand il en est le premier caractère ;
|
||||||
|
// ailleurs, c'est un caractère comme un autre ;
|
||||||
|
// - dans un champ cité, le séparateur et les fins de ligne font partie du
|
||||||
|
// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui
|
||||||
|
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ;
|
||||||
|
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
|
||||||
|
// fin ;
|
||||||
|
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
|
||||||
|
// qui suit la dernière fin de ligne ne fait pas d'enregistrement ;
|
||||||
|
// - un séparateur null ne sépare rien : chaque enregistrement est un champ ;
|
||||||
|
// - aucun blanc n'est retiré.
|
||||||
|
function analyser(texte, separateur, limite) {
|
||||||
|
const enregistrements = [];
|
||||||
|
let enregistrement = [];
|
||||||
|
let champ = '';
|
||||||
|
let cite = false;
|
||||||
|
let debutDeChamp = true;
|
||||||
|
let debutDeLigne = true;
|
||||||
|
for (let i = 0; i < texte.length; i += 1) {
|
||||||
|
const c = texte[i];
|
||||||
|
if (cite) {
|
||||||
|
if (c !== GUILLEMET) {
|
||||||
|
champ += c;
|
||||||
|
} else if (texte[i + 1] === GUILLEMET) {
|
||||||
|
champ += GUILLEMET;
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
cite = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '\n' || c === '\r') {
|
||||||
|
if (c === '\r' && texte[i + 1] === '\n') i += 1;
|
||||||
|
enregistrement.push(champ);
|
||||||
|
enregistrements.push(enregistrement);
|
||||||
|
if (enregistrements.length >= limite) return { enregistrements, guillemetOuvert: false };
|
||||||
|
enregistrement = [];
|
||||||
|
champ = '';
|
||||||
|
debutDeChamp = true;
|
||||||
|
debutDeLigne = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debutDeLigne = false;
|
||||||
|
if (c === separateur) {
|
||||||
|
enregistrement.push(champ);
|
||||||
|
champ = '';
|
||||||
|
debutDeChamp = true;
|
||||||
|
} else if (c === GUILLEMET && debutDeChamp) {
|
||||||
|
cite = true;
|
||||||
|
} else {
|
||||||
|
champ += c;
|
||||||
|
debutDeChamp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!debutDeLigne) {
|
||||||
|
enregistrement.push(champ);
|
||||||
|
enregistrements.push(enregistrement);
|
||||||
|
}
|
||||||
|
return { enregistrements, guillemetOuvert: cite };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
|
||||||
|
* guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de
|
||||||
|
* ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ
|
||||||
|
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas
|
||||||
|
* d'enregistrement. Aucun blanc n'est retiré des champs.
|
||||||
|
*
|
||||||
|
* Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris :
|
||||||
|
* le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs
|
||||||
|
* lignes ne fait qu'un enregistrement ; la découpe ne rend aucun numéro de
|
||||||
|
* ligne du texte.
|
||||||
|
*
|
||||||
|
* @param {string} texte
|
||||||
|
* @param {string|null} separateur un caractère, ou null pour un fichier à une
|
||||||
|
* colonne, dont chaque ligne est un seul champ
|
||||||
|
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
|
||||||
|
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le
|
||||||
|
* dernier enregistrement porte alors le texte lu jusque-là.
|
||||||
|
*/
|
||||||
|
export function decouper(texte, separateur) {
|
||||||
|
return analyser(texte, separateur, Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai quand chaque champ de l'enregistrement est vide ou réduit à des blancs :
|
||||||
|
* une ligne vide, une ligne de séparateurs seuls, une ligne de blancs. Le choix
|
||||||
|
* du séparateur ne compte pas ces enregistrements.
|
||||||
|
*
|
||||||
|
* @param {string[]} enregistrement
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function enregistrementVide(enregistrement) {
|
||||||
|
return enregistrement.every((champ) => champ.trim() === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choisit le séparateur parmi « ; », « , » et la tabulation en lisant les vingt
|
||||||
|
* premiers enregistrements avec chacun. Un candidat convient quand la lecture
|
||||||
|
* s'achève sans guillemet ouvert et que les enregistrements non vides ont tous
|
||||||
|
* le même nombre de champs, supérieur à 1 : sur un fichier séparé par des
|
||||||
|
* virgules, « ; » donne lui aussi un nombre de champs constant, un seul, et la
|
||||||
|
* constance ne suffit pas.
|
||||||
|
*
|
||||||
|
* À égalité, le candidat dont le premier enregistrement non vide compte le plus
|
||||||
|
* d'en-têtes reconnus l'emporte ; à égalité encore, le premier dans l'ordre
|
||||||
|
* « ; », « , », tabulation.
|
||||||
|
*
|
||||||
|
* 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').
|
||||||
|
*
|
||||||
|
* @param {string} texte
|
||||||
|
* @param {(entete: string) => unknown} reconnaitre rend une valeur vraie pour
|
||||||
|
* un en-tête connu ; reçoit chaque champ du premier enregistrement non vide
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export function choisirSeparateur(texte, reconnaitre) {
|
||||||
|
let retenu = 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 };
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
}
|
||||||
500
src/csv/lecture.test.js
Normal file
500
src/csv/lecture.test.js
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la lecture d'un texte CSV (§ 10.1, § 14.10) : la découpe en
|
||||||
|
// enregistrements — guillemets, guillemets doublés, fins de ligne, ligne vide,
|
||||||
|
// guillemet resté ouvert —, le choix du séparateur sur les vingt premiers
|
||||||
|
// enregistrements, et la propriété du § 14.12 qui le retrouve dans un tableau
|
||||||
|
// rendu par construction. Les en-têtes se reconnaissent ici par un double de
|
||||||
|
// l'épreuve : le choix ne dépend que du verdict, non de la manière de le
|
||||||
|
// rendre.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fc from 'fast-check';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { decoder } from './encodage.js';
|
||||||
|
import { ErreurCsv } from './erreurs.js';
|
||||||
|
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
|
||||||
|
|
||||||
|
const SEPARATEURS = [';', ',', '\t'];
|
||||||
|
|
||||||
|
// Double de la reconnaissance d'en-têtes que reçoit choisirSeparateur : les
|
||||||
|
// trois noms de colonne, tels quels.
|
||||||
|
const ENTETES = new Set(['nom', 'prenom', 'appartenance']);
|
||||||
|
const reconnaitre = (entete) => ENTETES.has(entete);
|
||||||
|
|
||||||
|
// Une reconnaissance rend un booléen, ou le nom du champ reconnu et null
|
||||||
|
// sinon : choisirSeparateur n'en lit que la valeur de vérité.
|
||||||
|
const RECONNAISSANCES = [
|
||||||
|
['qui rend un booléen', reconnaitre],
|
||||||
|
['qui rend le champ reconnu ou null', (entete) => (ENTETES.has(entete) ? entete : null)],
|
||||||
|
];
|
||||||
|
|
||||||
|
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
|
||||||
|
function erreurDe(fonction) {
|
||||||
|
try {
|
||||||
|
fonction();
|
||||||
|
} catch (erreur) {
|
||||||
|
return erreur;
|
||||||
|
}
|
||||||
|
return assert.fail("rien n'a été levé");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie que fonction lève le refus d'un séparateur introuvable.
|
||||||
|
function assertSeparateurIntrouvable(fonction) {
|
||||||
|
const erreur = erreurDe(fonction);
|
||||||
|
assert.ok(erreur instanceof ErreurCsv);
|
||||||
|
assert.equal(erreur.code, 'SEPARATEUR_INTROUVABLE');
|
||||||
|
assert.deepEqual(erreur.details, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrements que decouper rend, une fois vérifié qu'aucun guillemet ne
|
||||||
|
// reste ouvert.
|
||||||
|
function enregistrementsDe(texte, separateur = ';') {
|
||||||
|
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
|
||||||
|
assert.equal(guillemetOuvert, false);
|
||||||
|
return enregistrements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre de champs de chaque enregistrement que donne le séparateur, guillemet
|
||||||
|
// resté ouvert ou non.
|
||||||
|
const largeursLues = (texte, separateur) => decouper(texte, separateur).enregistrements.map((e) => e.length);
|
||||||
|
|
||||||
|
// Le même nombre, une fois vérifié qu'aucun guillemet ne reste ouvert.
|
||||||
|
const largeurs = (texte, separateur) => enregistrementsDe(texte, separateur).map((e) => e.length);
|
||||||
|
|
||||||
|
describe('decouper : enregistrements et champs (§ 10.1)', () => {
|
||||||
|
test('des champs séparés, une dernière ligne sans fin de ligne', () => {
|
||||||
|
assert.deepEqual(decouper('nom;prenom\nBenoît;Exemple', ';'), {
|
||||||
|
enregistrements: [
|
||||||
|
['nom', 'prenom'],
|
||||||
|
['Benoît', 'Exemple'],
|
||||||
|
],
|
||||||
|
guillemetOuvert: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un champ cité garde le séparateur ; deux guillemets y font un guillemet', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"a ; b ""c"" d"\r\n'), [
|
||||||
|
['nom', 'notes'],
|
||||||
|
['Benoît Exemple', 'a ; b "c" d'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un champ cité sur deux lignes est un seul enregistrement, ses fins de ligne gardées telles quelles', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"ligne 1\r\nligne 2"\nSuivant;x\r\n'), [
|
||||||
|
['nom', 'notes'],
|
||||||
|
['Benoît Exemple', 'ligne 1\r\nligne 2'],
|
||||||
|
['Suivant', 'x'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(enregistrementsDe('a;"x\ny"'), [['a', 'x\ny']]);
|
||||||
|
assert.deepEqual(enregistrementsDe('a;"x\ry"'), [['a', 'x\ry']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule fin', () => {
|
||||||
|
const attendu = [
|
||||||
|
['a', '1'],
|
||||||
|
['b', '2'],
|
||||||
|
['c', '3'],
|
||||||
|
];
|
||||||
|
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\r\nc;3'), attendu);
|
||||||
|
assert.deepEqual(enregistrementsDe('a;1\nb;2\nc;3'), attendu);
|
||||||
|
assert.deepEqual(enregistrementsDe('a;1\rb;2\rc;3'), attendu);
|
||||||
|
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\nc;3\r'), attendu);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la dernière ligne, avec ou sans fin de ligne, donne les mêmes enregistrements', () => {
|
||||||
|
for (const fin of ['', '\n', '\r\n', '\r']) {
|
||||||
|
assert.deepEqual(enregistrementsDe(`a;b${fin}`), [['a', 'b']], JSON.stringify(fin));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une ligne vide au milieu est un enregistrement d'un champ vide, qui garde son rang", () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('nom;prenom\r\n\r\nBenoît;Exemple\r\n'), [
|
||||||
|
['nom', 'prenom'],
|
||||||
|
[''],
|
||||||
|
['Benoît', 'Exemple'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un enregistrement se désigne par son rang à partir de 1, l'en-tête compris : celui d'une ligne de tableur", () => {
|
||||||
|
const texte = [
|
||||||
|
'nom;notes', // rang 1
|
||||||
|
'Benoît Exemple;1', // rang 2
|
||||||
|
'', // rang 3 : ligne vide
|
||||||
|
'Odile Fictive;"deux\nlignes"', // rang 4 : une cellule, une seule ligne de tableur
|
||||||
|
'Rémi Témoin;3', // rang 5
|
||||||
|
].join('\r\n');
|
||||||
|
const enregistrements = enregistrementsDe(texte);
|
||||||
|
const rang = (n) => enregistrements[n - 1];
|
||||||
|
assert.equal(enregistrements.length, 5);
|
||||||
|
assert.deepEqual(rang(1), ['nom', 'notes']);
|
||||||
|
assert.deepEqual(rang(3), ['']);
|
||||||
|
assert.deepEqual(rang(4), ['Odile Fictive', 'deux\nlignes']);
|
||||||
|
assert.deepEqual(rang(5), ['Rémi Témoin', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seule la ligne vide qui suit la dernière fin de ligne n'est pas un enregistrement", () => {
|
||||||
|
assert.deepEqual(enregistrementsDe(''), []);
|
||||||
|
assert.deepEqual(enregistrementsDe('\n'), [['']]);
|
||||||
|
assert.deepEqual(enregistrementsDe('a\n\n'), [['a'], ['']]);
|
||||||
|
assert.deepEqual(enregistrementsDe('a\r\n\r\n'), [['a'], ['']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un dernier enregistrement réduit à un champ cité vide est un enregistrement, avec ou sans fin de ligne', () => {
|
||||||
|
assert.deepEqual(decouper('a\n""', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
|
||||||
|
assert.deepEqual(decouper('a\n""\n', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
|
||||||
|
assert.deepEqual(decouper('""', ';'), { enregistrements: [['']], guillemetOuvert: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un guillemet resté ouvert est signalé, et le texte lu jusque-là est rendu', () => {
|
||||||
|
assert.deepEqual(decouper('nom;notes\nBenoît;"jamais fermé\nsuite', ';'), {
|
||||||
|
enregistrements: [
|
||||||
|
['nom', 'notes'],
|
||||||
|
['Benoît', 'jamais fermé\nsuite'],
|
||||||
|
],
|
||||||
|
guillemetOuvert: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un champ cité refermé en toute fin ne laisse rien ouvert, un guillemet doublé en toute fin le laisse ouvert', () => {
|
||||||
|
assert.equal(decouper('a;"b"', ';').guillemetOuvert, false);
|
||||||
|
assert.equal(decouper('a;"b', ';').guillemetOuvert, true);
|
||||||
|
assert.equal(decouper('a;"b""', ';').guillemetOuvert, true);
|
||||||
|
assert.equal(decouper('a;"', ';').guillemetOuvert, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux guillemets font un champ cité vide ; quatre, un champ réduit à un guillemet', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('a;"";c'), [['a', '', 'c']]);
|
||||||
|
assert.deepEqual(enregistrementsDe('"""";x'), [['"', 'x']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un guillemet qui n'ouvre pas un champ est un caractère comme un autre", () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('Benoît 5" Exemple;x\nsuite;y'), [
|
||||||
|
['Benoît 5" Exemple', 'x'],
|
||||||
|
['suite', 'y'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]);
|
||||||
|
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe(' Benoît ; Exemple ;" Odile "'), [[' Benoît ', ' Exemple ', ' Odile ']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les champs vides, aux deux bouts comme au milieu', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe(';a;;b;'), [['', 'a', '', 'b', '']]);
|
||||||
|
assert.deepEqual(enregistrementsDe(';;'), [['', '', '']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la tabulation sépare, et se cite comme tout séparateur', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('nom\tprenom\nExemple\t"a\tb"', '\t'), [
|
||||||
|
['nom', 'prenom'],
|
||||||
|
['Exemple', 'a\tb'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans séparateur (null), chaque enregistrement est un seul champ, virgules et points-virgules compris', () => {
|
||||||
|
assert.deepEqual(enregistrementsDe('nom\nBenoît Exemple, Odile\n"Rémi; ""R"""', null), [
|
||||||
|
['nom'],
|
||||||
|
['Benoît Exemple, Odile'],
|
||||||
|
['Rémi; "R"'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enregistrementVide', () => {
|
||||||
|
test('vrai quand chaque champ est vide ou réduit à des blancs', () => {
|
||||||
|
assert.equal(enregistrementVide(['']), true);
|
||||||
|
assert.equal(enregistrementVide(['', '', '']), true);
|
||||||
|
assert.equal(enregistrementVide([' ', '\t']), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("faux dès qu'un champ porte un caractère", () => {
|
||||||
|
assert.equal(enregistrementVide(['', 'a']), false);
|
||||||
|
assert.equal(enregistrementVide(['0']), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('choisirSeparateur : le candidat retenu (§ 10.1)', () => {
|
||||||
|
test("un fichier séparé par des virgules : « ; » est écarté parce qu'il donne un champ, non faute de constance", () => {
|
||||||
|
// « ; » donne un champ à chaque enregistrement : la constance seule le
|
||||||
|
// retiendrait, et, sans en-tête reconnu pour départager, l'ordre des
|
||||||
|
// candidats le placerait devant « , ».
|
||||||
|
const texte = 'a,b,c\n1,2,3\n';
|
||||||
|
assert.deepEqual(largeurs(texte, ';'), [1, 1]);
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: ',' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fichier séparé par des virgules, ses en-têtes reconnus', () => {
|
||||||
|
const texte = 'nom,prenom,appartenance\nBenoît,Exemple,Groupe Azur\nOdile,Fictive,Groupe Ambre\n';
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ',' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un collage de tableur est séparé par des tabulations, même quand un champ porte une virgule', () => {
|
||||||
|
const texte = 'nom\tprenom\tappartenance\nBenoît\tExemple\tGroupe Azur, nord\nOdile\tFictive\tGroupe Ambre\n';
|
||||||
|
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: '\t' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// « ; » donne deux champs, « , » trois, l'un et l'autre constants : seule la
|
||||||
|
// reconnaissance des en-têtes les départage.
|
||||||
|
const TEXTE_A_EGALITE = 'nom,prenom,appartenance;x\nBenoît,Exemple,Groupe Azur;y\n';
|
||||||
|
|
||||||
|
for (const [libelle, reconnaissance] of RECONNAISSANCES) {
|
||||||
|
test(`égalité de constance : le candidat aux plus d'en-têtes reconnus, fût-il le dernier essayé (reconnaissance ${libelle})`, () => {
|
||||||
|
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ';'), [2, 2]);
|
||||||
|
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ','), [3, 3]);
|
||||||
|
// « , » reconnaît deux en-têtes (nom, prenom), « ; » aucun.
|
||||||
|
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, reconnaissance), { separateur: ',' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("la même égalité, une reconnaissance qui favorise l'autre lecture : le choix suit le verdict", () => {
|
||||||
|
const lectureParPointVirgule = (entete) => entete === 'x' || entete === 'nom,prenom,appartenance';
|
||||||
|
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, lectureParPointVirgule), { separateur: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("égalité complète : l'ordre « ; », « , », tabulation départage", () => {
|
||||||
|
const aucune = () => false;
|
||||||
|
assert.deepEqual(choisirSeparateur('a,b;c\nd,e;f\n', aucune), { separateur: ';' });
|
||||||
|
assert.deepEqual(choisirSeparateur('a,b\tc\nd,e\tf\n', aucune), { separateur: ',' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un en-tête non reconnu n'écarte pas le candidat : la reconnaissance ne fait que départager", () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('a;b\nc;d\n', () => false), { separateur: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reconnaitre reçoit les champs du premier enregistrement non vide, tels qu'écrits", () => {
|
||||||
|
const appels = [];
|
||||||
|
choisirSeparateur('\n Nom ;prenom\nA;B\n', (entete) => {
|
||||||
|
appels.push(entete);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assert.deepEqual(appels, [' Nom ', 'prenom']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une colonne d'en-tête vide compte comme un champ", () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('nom;;appartenance\nBenoît;;Groupe Azur\n', reconnaitre), { separateur: ';' });
|
||||||
|
assert.deepEqual(choisirSeparateur('nom;prenom;\nBenoît;Exemple;\n', reconnaitre), { separateur: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fichier réduit à son en-tête donne son séparateur', () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('nom;prenom', reconnaitre), { separateur: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les enregistrements vides — ligne vide, champs vides, blancs seuls — ne rompent pas la constance', () => {
|
||||||
|
const texte = 'nom;prenom\r\n\r\nBenoît;Exemple\r\n;\r\n \r\nOdile;Fictive\r\n';
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('la fenêtre des vingt premiers enregistrements', () => {
|
||||||
|
// Vingt-cinq enregistrements de trois champs, dont celui du rang donné,
|
||||||
|
// à partir de 1, est remplacé.
|
||||||
|
const texteAvec = (rang, remplacement) => {
|
||||||
|
const lignes = [
|
||||||
|
'nom;prenom;appartenance',
|
||||||
|
...Array.from({ length: 24 }, (_, i) => `n${i};p${i};a${i}`),
|
||||||
|
];
|
||||||
|
lignes[rang - 1] = remplacement;
|
||||||
|
return lignes.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
test('un enregistrement plus long au rang 20 écarte « ; », au rang 21 non', () => {
|
||||||
|
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y;z;t'), reconnaitre));
|
||||||
|
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));
|
||||||
|
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);
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
|
||||||
|
// Le découpage du fichier entier, lui, voit le guillemet resté ouvert.
|
||||||
|
assert.equal(decouper(texte, ';').guillemetOuvert, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('choisirSeparateur : un fichier à une seule colonne (§ 10.1)', () => {
|
||||||
|
test('une colonne « nom » dont une ligne porte une virgule : une colonne, la virgule gardée dans le nom', () => {
|
||||||
|
const texte = 'nom\nBenoît Exemple, Odile\nRémi Témoin\n';
|
||||||
|
// La virgule donne un champ, puis deux, puis un : elle n'est pas constante.
|
||||||
|
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: null });
|
||||||
|
assert.deepEqual(enregistrementsDe(texte, null), [['nom'], ['Benoît Exemple, Odile'], ['Rémi Témoin']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la première ligne entière, ses blancs de bord retirés, est celle qu'on reconnaît", () => {
|
||||||
|
assert.deepEqual(choisirSeparateur(' nom \nBenoît Exemple\n', reconnaitre), { separateur: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une ligne vide avant l'en-tête n'empêche pas de le reconnaître", () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('\r\n nom\r\nBenoît Exemple\r\n', reconnaitre), { separateur: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un en-tête cité se reconnaît comme le même sans guillemets', () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('"nom"\nBenoît Exemple\n', reconnaitre), { separateur: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une colonne unique n'est retenue que faute de candidat", () => {
|
||||||
|
assert.deepEqual(choisirSeparateur('nom;prenom\nBenoît;Exemple\n', reconnaitre), { separateur: ';' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => {
|
||||||
|
test("une seule colonne dont la première ligne n'est pas un en-tête reconnu", () => {
|
||||||
|
assertSeparateurIntrouvable(() => choisirSeparateur('Benoît Exemple\nOdile Fictive\n', reconnaitre));
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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\nBenoît;Exemple\nune ligne sans séparateur\n', reconnaitre),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
// Deux champs partout, mais le second de la dernière ligne avale la fin du
|
||||||
|
// fichier : seul le guillemet resté ouvert écarte « ; ».
|
||||||
|
const texte = 'nom;prenom\nBenoît;"Exemple\nOdile;Fictive\n';
|
||||||
|
const lu = decouper(texte, ';');
|
||||||
|
assert.deepEqual(lu.enregistrements.map((e) => e.length), [2, 2]);
|
||||||
|
assert.equal(lu.guillemetOuvert, true);
|
||||||
|
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte vide, ou fait de lignes vides', () => {
|
||||||
|
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
|
||||||
|
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("de l'octet à l'enregistrement : un CSV tel qu'un tableur l'écrit", () => {
|
||||||
|
test('marque UTF-8 et CRLF, champ cité à guillemets doublés, ligne vide, blancs, colonne sans en-tête, dernière ligne sans fin', () => {
|
||||||
|
const texteTableur = [
|
||||||
|
'nom;prenom;appartenance;;notes',
|
||||||
|
'Exemple;Benoît;Groupe Azur;;"dit ""bonjour"" ; puis part"',
|
||||||
|
'',
|
||||||
|
' Fictif ; Odile ;;;',
|
||||||
|
'Témoin;Rémi;Groupe Ambre;;fin',
|
||||||
|
].join('\r\n');
|
||||||
|
const octets = Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(texteTableur)]);
|
||||||
|
|
||||||
|
const { texte, encodage } = decoder(octets);
|
||||||
|
assert.equal(encodage, 'utf-8-bom');
|
||||||
|
const { separateur } = choisirSeparateur(texte, reconnaitre);
|
||||||
|
assert.equal(separateur, ';');
|
||||||
|
assert.deepEqual(decouper(texte, separateur), {
|
||||||
|
enregistrements: [
|
||||||
|
['nom', 'prenom', 'appartenance', '', 'notes'],
|
||||||
|
['Exemple', 'Benoît', 'Groupe Azur', '', 'dit "bonjour" ; puis part'],
|
||||||
|
[''],
|
||||||
|
[' Fictif ', ' Odile ', '', '', ''],
|
||||||
|
['Témoin', 'Rémi', 'Groupe Ambre', '', 'fin'],
|
||||||
|
],
|
||||||
|
guillemetOuvert: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('choisirSeparateur et decouper : propriété par construction (§ 14.12)', () => {
|
||||||
|
// Graine fixée, écrite ici : un contre-exemple se rejoue.
|
||||||
|
const GRAINE = 161_803;
|
||||||
|
const TIRAGES = 300;
|
||||||
|
|
||||||
|
// Unités des champs engendrés : trois fois sur quatre une lettre, un accent
|
||||||
|
// ou un blanc, sinon un guillemet, une fin de ligne ou le séparateur connu,
|
||||||
|
// qui forcent la citation. Les deux autres candidats en sont absents : un
|
||||||
|
// tableau qui les porte se lit de plusieurs façons, et le départage par les
|
||||||
|
// en-têtes s'éprouve à part.
|
||||||
|
const unite = (separateur) =>
|
||||||
|
fc.oneof(
|
||||||
|
{ arbitrary: fc.constantFrom('a', 'Z', 'é', ' '), weight: 3 },
|
||||||
|
{ arbitrary: fc.constantFrom('"', '\n', '\r', separateur), weight: 1 },
|
||||||
|
);
|
||||||
|
const champ = (separateur) => fc.string({ unit: unite(separateur), maxLength: 6 });
|
||||||
|
|
||||||
|
// Un champ qui commence par une lettre n'est jamais vide, même une fois ses
|
||||||
|
// blancs retirés : aucun enregistrement du tableau n'est un enregistrement
|
||||||
|
// vide, que le choix du séparateur ignore.
|
||||||
|
const premierChamp = (separateur) =>
|
||||||
|
fc
|
||||||
|
.tuple(fc.constantFrom('a', 'Z', 'é'), champ(separateur))
|
||||||
|
.map(([tete, suite]) => tete + suite);
|
||||||
|
|
||||||
|
// Un tableau de 1 à 25 enregistrements, de 2 à 5 champs chacun, et la
|
||||||
|
// manière de l'écrire : séparateur, fin de ligne, fin de ligne finale ou non.
|
||||||
|
const cas = fc.constantFrom(...SEPARATEURS).chain((separateur) =>
|
||||||
|
fc.record({
|
||||||
|
separateur: fc.constant(separateur),
|
||||||
|
tableau: fc.integer({ min: 2, max: 5 }).chain((largeur) =>
|
||||||
|
fc.array(
|
||||||
|
fc
|
||||||
|
.tuple(premierChamp(separateur), fc.array(champ(separateur), { minLength: largeur - 1, maxLength: largeur - 1 }))
|
||||||
|
.map(([premier, autres]) => [premier, ...autres]),
|
||||||
|
{ minLength: 1, maxLength: 25, size: 'max' },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fin: fc.constantFrom('\r\n', '\n', '\r'),
|
||||||
|
finale: fc.boolean(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Écrit le tableau comme un tableur : un champ qui porte le séparateur, un
|
||||||
|
// guillemet ou une fin de ligne se cite, ses guillemets doublés.
|
||||||
|
function ecrire({ separateur, tableau, fin, finale }) {
|
||||||
|
const ecrireChamp = (valeur) =>
|
||||||
|
valeur.includes(separateur) || /["\r\n]/.test(valeur) ? `"${valeur.replaceAll('"', '""')}"` : valeur;
|
||||||
|
const lignes = tableau.map((ligne) => ligne.map(ecrireChamp).join(separateur));
|
||||||
|
return lignes.join(fin) + (finale ? fin : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('le séparateur est retrouvé, et le découpage rend le tableau', () => {
|
||||||
|
const bilan = { tirages: 0, separateurCite: 0, champSurPlusieursLignes: 0, auDelaDeLaFenetre: 0 };
|
||||||
|
fc.assert(
|
||||||
|
fc.property(cas, (tirage) => {
|
||||||
|
const texte = ecrire(tirage);
|
||||||
|
bilan.tirages += 1;
|
||||||
|
const champs = tirage.tableau.flat();
|
||||||
|
if (champs.some((valeur) => valeur.includes(tirage.separateur))) bilan.separateurCite += 1;
|
||||||
|
if (champs.some((valeur) => /[\r\n]/.test(valeur))) bilan.champSurPlusieursLignes += 1;
|
||||||
|
if (tirage.tableau.length > 20) bilan.auDelaDeLaFenetre += 1;
|
||||||
|
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: tirage.separateur });
|
||||||
|
assert.deepEqual(decouper(texte, tirage.separateur), {
|
||||||
|
enregistrements: tirage.tableau,
|
||||||
|
guillemetOuvert: false,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{ seed: GRAINE, numRuns: TIRAGES },
|
||||||
|
);
|
||||||
|
// Un générateur qui n'engendre pas les cas difficiles rend la propriété
|
||||||
|
// vraie sans rien éprouver : chaque famille de cas a son plancher.
|
||||||
|
assert.equal(bilan.tirages, TIRAGES);
|
||||||
|
assert.ok(bilan.separateurCite >= 100, `séparateur cité : ${bilan.separateurCite}`);
|
||||||
|
assert.ok(bilan.champSurPlusieursLignes >= 100, `champ sur plusieurs lignes : ${bilan.champSurPlusieursLignes}`);
|
||||||
|
assert.ok(bilan.auDelaDeLaFenetre >= 20, `au-delà de la fenêtre : ${bilan.auDelaDeLaFenetre}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue