From dd088f2dd96c59ca6eb92dc689d553441c3c252f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 07:05:05 -0400 Subject: [PATCH] [ADD] csv: decode in four steps, choose the separator, cut records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/csv/encodage.js | 130 ++++++++++ src/csv/encodage.test.js | 353 +++++++++++++++++++++++++++ src/csv/erreurs.js | 21 ++ src/csv/lecture.js | 165 +++++++++++++ src/csv/lecture.test.js | 500 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1169 insertions(+) create mode 100644 src/csv/encodage.js create mode 100644 src/csv/encodage.test.js create mode 100644 src/csv/erreurs.js create mode 100644 src/csv/lecture.js create mode 100644 src/csv/lecture.test.js diff --git a/src/csv/encodage.js b/src/csv/encodage.js new file mode 100644 index 0000000..a08233c --- /dev/null +++ b/src/csv/encodage.js @@ -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; +} diff --git a/src/csv/encodage.test.js b/src/csv/encodage.test.js new file mode 100644 index 0000000..4d0bdef --- /dev/null +++ b/src/csv/encodage.test.js @@ -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, {}); + }); +}); diff --git a/src/csv/erreurs.js b/src/csv/erreurs.js new file mode 100644 index 0000000..da1bc0b --- /dev/null +++ b/src/csv/erreurs.js @@ -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'; diff --git a/src/csv/lecture.js b/src/csv/lecture.js new file mode 100644 index 0000000..2cff81e --- /dev/null +++ b/src/csv/lecture.js @@ -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'); +} diff --git a/src/csv/lecture.test.js b/src/csv/lecture.test.js new file mode 100644 index 0000000..afe172b --- /dev/null +++ b/src/csv/lecture.test.js @@ -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}`); + }); +});