From 201259331d904c1f81f3ea3fa1f80b424fac7914 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 14:47:25 -0400 Subject: [PATCH] [ADD] csv: apply an import three ways, re-export refusals, export list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An import applies its preview by adding, updating or replacing. Updating designates one participant by name, first name and, when given, affiliation; an empty field keeps the stored value, and a row that designates several is refused. Replacing first counts what it destroys, reservations and filled titles included. A blocked plan refuses; a retained one warns of drift. Refused rows come back as a CSV of the original columns plus line and reason, which reimports as is; the list exports in the exact form the import reads. Checked: 59 tests, the round trip field by field and the refusal loop. --- FR --- [ADD] csv : import de trois façons, refus réexportés, liste exportée Un import applique son aperçu en ajoutant, mettant à jour ou remplaçant. La mise à jour désigne un participant par nom, prénom et, s'il est donné, appartenance ; un champ vide garde la valeur, et une ligne qui en désigne plusieurs est refusée. Remplacer compte d'abord ce qu'il détruit, réservations et titres pourvus compris. Un plan bloqué refuse ; un plan retenu avertit de la dérive. Les lignes refusées reviennent en CSV des colonnes d'origine, plus ligne et motif, qui se réimporte tel quel ; la liste s'exporte dans la forme que l'import lit. Vérifié : 59 épreuves, l'aller-retour champ par champ et la boucle. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- src/csv/export.js | 111 ++++++ src/csv/export.test.js | 193 +++++++++ src/csv/import.js | 342 ++++++++++++++++ src/csv/import.test.js | 858 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1504 insertions(+) create mode 100644 src/csv/export.js create mode 100644 src/csv/export.test.js create mode 100644 src/csv/import.js create mode 100644 src/csv/import.test.js diff --git a/src/csv/export.js b/src/csv/export.js new file mode 100644 index 0000000..93367ce --- /dev/null +++ b/src/csv/export.js @@ -0,0 +1,111 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'export de la liste des participants (§ 10.2, § 10.3), dans la forme exacte +// que l'import lit : UTF-8 avec marque d'ordre d'octets, « ; », CRLF, les +// champs de l'import pour en-têtes. Exporter puis réimporter rend les mêmes +// participants, champ par champ : le fichier sert de sauvegarde, et de gabarit +// que personne n'a besoin de documenter. +// +// L'écriture d'un CSV est unique : le CSV des lignes refusées d'un import passe +// par ecrireCsv, avec son propre séparateur. Une valeur s'écrit telle quelle, +// même quand elle commence par « = », « + », « - » ou « @ », qu'un tableur +// lirait comme une formule : la neutraliser romprait l'aller-retour. Une +// valeur se relit sans ses blancs de bord, que l'aperçu retire de chaque +// champ ; une valeur importée n'en porte pas. + +import { CHAMPS } from './colonnes.js'; + +/** Séparateur de l'export : celui qu'un tableur francophone rouvre sans manipulation (§ 10.3). */ +export const SEPARATEUR = ';'; + +const MARQUE_ORDRE_OCTETS = '\u{FEFF}'; +const FIN_DE_LIGNE = '\r\n'; +const GUILLEMET = '"'; + +// Les deux valeurs écrites d'« exclu », que la liste fermée de l'aperçu +// relit : oui pour vrai, non pour faux. +const OUI = 'oui'; +const NON = 'non'; + +// Propriété du participant que porte chaque champ de CHAMPS. +const PROPRIETE_DU_CHAMP = new Map([ + ['nom', 'nom'], + ['prenom', 'prenom'], + ['appartenance', 'appartenance'], + ['courriel', 'courriel'], + ['titre_pressenti', 'titrePressenti'], + ['exclu', 'exclu'], + ['notes', 'notes'], +]); + +// Un champ s'écrit cité (RFC 4180) quand il porte le séparateur, un retour +// chariot ou un saut de ligne, que la découpe lirait sinon comme des +// frontières, ou un guillemet, qui en tête ouvrirait un champ cité. +const SIGNES_A_CITER = /["\r\n]/; +const aCiter = (champ, separateur) => champ.includes(separateur) || SIGNES_A_CITER.test(champ); + +// Champ cité : entre guillemets, chaque guillemet doublé. +const citer = (champ) => GUILLEMET + champ.replaceAll(GUILLEMET, GUILLEMET + GUILLEMET) + GUILLEMET; + +/** + * Octets d'un CSV : la marque d'ordre d'octets UTF-8, puis chaque + * enregistrement, ses champs joints par le séparateur, terminé par CRLF, + * dernier compris. Un champ qui porte le séparateur, un guillemet, un retour + * chariot ou un saut de ligne s'écrit entre guillemets, les siens doublés ; + * tout autre champ s'écrit tel quel, blancs compris. decouper relit chaque + * enregistrement tel qu'il est donné, sous le même séparateur. + * + * @param {string[][]} enregistrements + * @param {string} separateur un caractère + * @returns {Uint8Array} UTF-8 avec marque + */ +export function ecrireCsv(enregistrements, separateur) { + const lignes = enregistrements.map( + (enregistrement) => + enregistrement.map((champ) => (aCiter(champ, separateur) ? citer(champ) : champ)).join(separateur) + FIN_DE_LIGNE, + ); + return new TextEncoder().encode(MARQUE_ORDRE_OCTETS + lignes.join('')); +} + +// Cellule d'un champ de CHAMPS pour le participant de rang donné, à partir +// de 0 : exclu en oui ou non, un texte tel quel, null vide. Lèvent TypeError +// un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un +// autre texte ni chaîne ni null. Écrit sans bruit « non » ou vide, un exclu +// de travers réinviterait au réimport une personne qui s'est désistée ; un +// texte d'un autre genre lèverait plus loin, dans l'écriture, sans nommer ni +// la personne ni le champ. +function cellule(participant, champ, rang) { + const valeur = participant[PROPRIETE_DU_CHAMP.get(champ)]; + const refuser = (attendu) => { + const recu = JSON.stringify(valeur) ?? String(valeur); + throw new TypeError(`participant ${rang + 1} de la liste, ${champ} : ${attendu} attendu, reçu ${recu}`); + }; + if (champ === 'exclu') { + if (typeof valeur !== 'boolean') refuser('booléen'); + return valeur ? OUI : NON; + } + if (valeur === null && champ !== 'nom') return ''; + if (typeof valeur !== 'string') refuser(champ === 'nom' ? 'texte' : 'texte ou null'); + return valeur; +} + +/** + * La liste des participants en CSV, dans la forme exacte que l'import lit + * (§ 10.2) : UTF-8 avec marque, « ; », CRLF ; l'en-tête + * nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes, les champs + * de CHAMPS dans leur ordre ; puis un participant par ligne, dans l'ordre de + * la liste reçue, que la charge range par identifiant. exclu s'écrit oui ou + * non, un champ null vide, un champ qui porte « ; », un guillemet ou une fin + * de ligne entre guillemets. L'identifiant ne s'écrit pas : le réimport en + * attribue de neufs. La liste reçue n'est pas modifiée. + * + * @param {import('../stockage/types.js').Participant[]} participants + * @returns {Uint8Array} + * @throws {TypeError} pour un exclu qui n'est pas un booléen, un nom qui + * n'est pas une chaîne, un autre texte ni chaîne ni null + */ +export function exporterParticipants(participants) { + const lignes = participants.map((participant, rang) => CHAMPS.map((champ) => cellule(participant, champ, rang))); + return ecrireCsv([[...CHAMPS], ...lignes], SEPARATEUR); +} diff --git a/src/csv/export.test.js b/src/csv/export.test.js new file mode 100644 index 0000000..552b1c8 --- /dev/null +++ b/src/csv/export.test.js @@ -0,0 +1,193 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'export de la liste (§ 10.2, § 10.3) : la forme exacte que +// l'import lit — marque UTF-8, « ; », CRLF, les sept en-têtes de l'import —, +// les champs cités au besoin, et l'aller-retour qui importe, exporte, +// réimporte et compare champ par champ, sur chaque donnée d'épreuve de +// test/fixtures/csv/ et sur une liste aux valeurs difficiles. L'écriture d'un +// CSV, que l'export partage avec le CSV des refus, s'éprouve contre la +// découpe qui la relit. +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { describe, test } from '../../test/lanceur.js'; +import { creerCharge } from '../stockage/document.js'; +import { VALEURS_EXCLU, apercevoir } from './apercu.js'; +import { CHAMPS } from './colonnes.js'; +import { decoder } from './encodage.js'; +import { SEPARATEUR, ecrireCsv, exporterParticipants } from './export.js'; +import { appliquerImport } from './import.js'; +import { decouper } from './lecture.js'; + +const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url); + +const MARQUE = '\u{FEFF}'; + +// Texte des octets, la marque d'ordre d'octets gardée en tête. +const texteDe = (octets) => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(octets); + +// Texte d'un CSV : chaque ligne donnée, terminée par CRLF. +const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join(''); + +// Participant, ses champs omis vides et non exclu. +const personne = (id, nom, autres = {}) => ({ + id, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres, +}); + +// Champs d'un participant, dans l'ordre du fichier d'état. +const CHAMPS_DU_PARTICIPANT = ['id', 'nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu']; + +// Participants qu'un import crée, en ajoutant, sur une charge neuve, à +// partir des octets d'un fichier ; l'aperçu ne doit refuser ni le texte ni +// aucune ligne. +function importer(octets, nom) { + const apercu = apercevoir(decoder(octets).texte); + assert.equal(apercu.refusGlobal, null, nom); + assert.deepEqual(apercu.refusees, [], nom); + const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 }); + return appliquerImport(charge, apercu, 'ajouter').charge.participants; +} + +// Compare deux listes de participants champ par champ, puis en entier. +function memesParticipants(obtenus, attendus, nom) { + assert.equal(obtenus.length, attendus.length, nom); + attendus.forEach((attendu, rang) => { + for (const champ of CHAMPS_DU_PARTICIPANT) { + assert.deepEqual(obtenus[rang][champ], attendu[champ], `${nom}, participant ${rang + 1}, ${champ}`); + } + }); + assert.deepEqual(obtenus, attendus, nom); +} + +describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", () => { + test("marque UTF-8, « ; », CRLF, les sept en-têtes de l'import dans leur ordre, un participant par ligne", () => { + const octets = exporterParticipants([ + personne(1, 'Ombrelle', { + prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test', titrePressenti: 'animation', + }), + personne(2, 'Pervenche', { prenom: 'Théo', notes: 'arrive tard', exclu: true }), + ]); + assert.ok(octets instanceof Uint8Array); + assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]); + assert.equal( + texteDe(octets), + MARQUE + + csv( + 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes', + 'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;', + 'Pervenche;Théo;;;;oui;arrive tard', + ), + ); + }); + + test("les en-têtes sont les champs de l'import, CHAMPS, et le séparateur « ; »", () => { + assert.equal(SEPARATEUR, ';'); + assert.equal(CHAMPS.join(';'), 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes'); + assert.equal(texteDe(exporterParticipants([])), `${MARQUE}${CHAMPS.join(SEPARATEUR)}\r\n`); + }); + + test("exclu s'écrit oui ou non, deux valeurs que la liste fermée de l'import relit", () => { + assert.equal(VALEURS_EXCLU.get('oui'), true); + assert.equal(VALEURS_EXCLU.get('non'), false); + const texte = texteDe(exporterParticipants([personne(1, 'Ombrelle', { exclu: true }), personne(2, 'Pervenche')])); + assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;oui;', 'Pervenche;;;;;non;')); + }); + + test('un champ qui porte « ; », un guillemet ou une fin de ligne s\'écrit cité, ses guillemets doublés', () => { + const octets = exporterParticipants([ + personne(1, 'Ombrelle', { appartenance: 'Club "Les Merles" ; nord', notes: 'premier\r\nsecond' }), + personne(2, '"Pervenche"', { courriel: 'theo\r@exemple.test', notes: 'a\nb' }), + personne(3, 'Sarcelle', { notes: 'virgule, tabulation\tseules' }), + ]); + assert.equal( + texteDe(octets), + MARQUE + + csv( + CHAMPS.join(';'), + 'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond"', + '"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb"', + 'Sarcelle;;;;;non;virgule, tabulation\tseules', + ), + ); + }); + + test("les lignes suivent l'ordre de la liste reçue", () => { + const texte = texteDe(exporterParticipants([personne(7, 'Sarcelle'), personne(2, 'Ombrelle')])); + assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Sarcelle;;;;;non;', 'Ombrelle;;;;;non;')); + }); + + test("exporterParticipants ne modifie pas la liste reçue", () => { + const participants = [personne(1, 'Ombrelle', { prenom: 'Iris', notes: 'a;b', exclu: true })]; + const avant = structuredClone(participants); + exporterParticipants(participants); + assert.deepEqual(participants, avant); + }); + + test("un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un texte ni chaîne ni null : TypeError qui nomme la personne et le champ, rien d'écrit en silence", () => { + const refuse = (participant, message) => + assert.throws(() => exporterParticipants([personne(1, 'Sarcelle'), participant]), { name: 'TypeError', message }); + refuse(personne(2, 'Ombrelle', { exclu: null }), 'participant 2 de la liste, exclu : booléen attendu, reçu null'); + refuse(personne(2, 'Ombrelle', { exclu: 'oui' }), 'participant 2 de la liste, exclu : booléen attendu, reçu "oui"'); + refuse(personne(2, 'Ombrelle', { courriel: 5 }), 'participant 2 de la liste, courriel : texte ou null attendu, reçu 5'); + refuse(personne(2, undefined), 'participant 2 de la liste, nom : texte attendu, reçu undefined'); + refuse(personne(2, null), 'participant 2 de la liste, nom : texte attendu, reçu null'); + }); +}); + +describe("exporterParticipants : l'aller-retour, champ par champ (§ 10.2)", () => { + test("chaque donnée d'épreuve de test/fixtures/csv/ : importée, exportée, réimportée, les mêmes participants", () => { + const donnees = readdirSync(FIXTURES).sort(); + assert.ok(donnees.length > 0, "aucune donnée d'épreuve"); + for (const nom of donnees) { + const importes = importer(readFileSync(new URL(nom, FIXTURES)), nom); + assert.ok(importes.length > 0, nom); + const octets = exporterParticipants(importes); + assert.equal(apercevoir(decoder(octets).texte).separateur, ';', nom); + memesParticipants(importer(octets, nom), importes, nom); + } + }); + + test('une liste aux valeurs difficiles : guillemets, séparateurs, fins de ligne, formules, accents, exclusions', () => { + const difficiles = [ + personne(1, 'Ombrelle', { + prenom: 'Iris', appartenance: 'Club "Les Merles" ; nord', courriel: 'iris@exemple.test', + titrePressenti: 'animation', notes: 'premier\r\nsecond', exclu: true, + }), + personne(2, '"Pervenche"', { prenom: 'Théo', notes: 'virgule, tabulation\tet ; point-virgule' }), + personne(3, '=SOMME(A1:A3)', { prenom: '+33', appartenance: '-moins', courriel: '@arobase' }), + personne(4, 'Sarcelle', { prenom: 'Ondine', appartenance: 'Chœur de l\u{2019}Anse', notes: 'saut\nseul\rretour seul' }), + personne(5, 'Bruyère', { titrePressenti: 'fin"', notes: '"' }), + ]; + const octets = exporterParticipants(difficiles); + assert.equal(apercevoir(decoder(octets).texte).separateur, ';'); + memesParticipants(importer(octets, 'difficiles'), difficiles, 'difficiles'); + }); +}); + +describe("ecrireCsv : l'écriture d'un CSV, qu'une découpe relit telle quelle", () => { + test('cite un champ qui porte le séparateur, un guillemet, un retour chariot ou un saut de ligne ; tout autre champ tel quel', () => { + assert.equal( + texteDe(ecrireCsv([['a;b', 'c"d', 'e\rf', 'g\nh', ' i ', '', 'j,k\tl']], ';')), + `${MARQUE}"a;b";"c""d";"e\rf";"g\nh"; i ;;j,k\tl\r\n`, + ); + }); + + test('le séparateur reçu décide : sous la tabulation, « ; » et « , » restent nus', () => { + assert.equal(texteDe(ecrireCsv([['a;b', 'c\td', 'e,f']], '\t')), `${MARQUE}a;b\t"c\td"\te,f\r\n`); + assert.equal(texteDe(ecrireCsv([['a;b', 'c\td', 'e,f']], ',')), `${MARQUE}a;b,c\td,"e,f"\r\n`); + }); + + test('decouper relit chaque enregistrement tel qu\'écrit, sous chacun des trois séparateurs', () => { + const enregistrements = [ + ['nom', 'notes', 'courriel'], + ['"Ombrelle"', 'a;b,c\td', 'x'], + ['x\r\ny', ' ', '""'], + ['', 'fin"', 'z\r'], + ]; + for (const separateur of [';', ',', '\t']) { + const { texte, encodage } = decoder(ecrireCsv(enregistrements, separateur)); + assert.equal(encodage, 'utf-8-bom'); + assert.deepEqual(decouper(texte, separateur), { enregistrements, guillemetOuvert: false }, separateur); + } + }); +}); diff --git a/src/csv/import.js b/src/csv/import.js new file mode 100644 index 0000000..aca6bef --- /dev/null +++ b/src/csv/import.js @@ -0,0 +1,342 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'import appliqué (§ 10.1) : ce que remplacer détruirait, à annoncer avant +// d'agir ; l'aperçu appliqué à une charge, selon le mode ; le CSV des lignes +// refusées, qui se corrige et se réimporte tel quel. +// +// appliquerImport n'agit que sur un plan qui n'est pas bloqué, et sur un +// aperçu sans refus global ni ambiguïté. Il rend une charge neuve, qui ne +// partage aucun objet avec celle qu'il reçoit : l'import entier tient dans +// une seule entrée d'historique, que la commande d'import écrit (§ 8.2). Les +// personnes ajoutées prennent leurs identifiants à partir de +// prochainsIds.participant, dans l'ordre du fichier, et le compteur ne recule +// jamais : un identifiant retiré ne revient pas (§ 4). +// +// Le CSV des refus reprend chaque ligne refusée telle que découpée, sous +// l'en-tête d'origine, et ajoute deux colonnes, ligne et motif, qu'aucun +// en-tête ne reconnaît : corrigé, il se réimporte sans qu'on en retire rien. +// Réimporté sans correction, il ne fait entrer aucune ligne que l'aperçu a +// refusée : chacune l'est de nouveau pour le même motif, sauf une ligne aux +// champs en trop parmi les vingt premiers enregistrements, qui fait refuser +// le texte entier, à son rang, par le choix du séparateur. + +import { ErreurCsv } from './erreurs.js'; +import { SEPARATEUR, ecrireCsv } from './export.js'; +import { enregistrementVide } from './lecture.js'; +import { cleNormalisee } from './normalisation.js'; + +const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']); + +// Champs d'une ligne de l'aperçu, dans l'ordre du participant : ceux que +// porte une personne créée, et que pose une mise à jour quand la cellule +// n'est pas vide. +const CHAMPS_LUS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu']); + +// En-têtes des deux colonnes qu'ajoute le CSV des refus, inconnues de +// l'association des colonnes. +const ENTETE_LIGNE = 'ligne'; +const ENTETE_MOTIF = 'motif'; + +/** + * Une ligne refusée, par l'aperçu ou par la mise à jour (§ 10.1). Le CSV des + * refus en écrit une par ligne. + * + * @typedef {Object} LigneRefusee + * @property {number} ligne rang de l'enregistrement, en-tête compris + * @property {string} code CHAMPS_EN_TROP, NOM_ABSENT ou + * EXCLU_INCONNU, de l'aperçu ; PLUSIEURS_CORRESPONDENT ou + * DEJA_DESIGNE, de la mise à jour + * @property {string|null} valeur la valeur d'« exclu » refusée sous + * EXCLU_INCONNU, null sous tout autre code + * @property {number[]} [participants] refus de la mise à jour : les + * identifiants croissants que la ligne désigne + * @property {number} [premiereLigne] DEJA_DESIGNE : la ligne qui désigne + * ce participant la première + * @property {string[]} brut les champs tels que découpés + * + * @typedef {Object} ResumeImport + * @property {number} ajoutes participants créés + * @property {number} misAJour participants dont une valeur au + * moins a changé + * @property {number} inchanges participants désignés par une + * ligne qui ne change rien + * @property {number} retires participants retirés + * @property {number} reservationsRetirees leurs réservations + * @property {LigneRefusee[]} refusees rangées par ligne + * @property {boolean} derive vrai en état retenu + */ + +const croissant = (a, b) => a - b; +const parLigne = (a, b) => a.ligne - b.ligne; + +// Clé qui apparie une ligne aux participants existants : le nom et le +// prénom, chacun par sa clé normalisée, un prénom absent comptant comme +// vide, écrits en JSON pour qu'aucun texte ne se confonde avec une frontière. +const cleDeNom = (nom, prenom) => JSON.stringify([cleNormalisee(nom), cleNormalisee(prenom ?? '')]); + +// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation +// d'une personne non exclue, sur sa place, à un tour au moins : la +// réservation d'une personne exclue est suspendue. La place se lit selon le +// réglage attribuerSieges (§ 2.1, étape 6). Vrai, c'est un siège : une +// réservation sans siège n'y pourvoit aucun titre. Faux, c'est la table, dont +// les titres ne se distinguent pas : à un tour donné, une table de k titres +// en a autant de pourvus que de personnes non exclues qui y sont réservées à +// ce tour, k au plus, et ses titres pourvus sont ceux du tour qui en pourvoit +// le plus. +function titresPourvus({ evenement, reglages, participants, reservations, titres }) { + const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id)); + const actives = reservations.filter(({ participant }) => presents.has(participant)); + if (reglages.attribuerSieges) { + // Un titre porte toujours un siège : la place d'une réservation sans + // siège, [table, null], n'est celle d'aucun titre. + const place = (table, siege) => JSON.stringify([table, siege]); + const occupees = new Set(actives.map(({ table, siege }) => place(table, siege))); + return titres.filter(({ table, siege }) => occupees.has(place(table, siege))).length; + } + const titresParTable = new Map(); + for (const { table } of titres) titresParTable.set(table, (titresParTable.get(table) ?? 0) + 1); + let pourvus = 0; + for (const [table, nombre] of titresParTable) { + let auMieux = 0; + for (let tour = 1; tour <= evenement.tours; tour += 1) { + const reserves = new Set( + actives + .filter((r) => r.table === table && (r.portee === 'tous' || r.tour === tour)) + .map(({ participant }) => participant), + ); + auMieux = Math.max(auMieux, Math.min(nombre, reserves.size)); + } + pourvus += auMieux; + } + return pourvus; +} + +/** + * Ce que remplacer détruirait, à annoncer avant d'agir (§ 10.1) : « 260 + * personnes seront supprimées » laisse croire qu'on ne perd que des noms. + * Remplacer retire chaque participant et ses réservations ; il garde les + * titres, qui appartiennent aux places, et les propositions, qui deviennent + * périmées (§ 8.9). Le bilan compte : + * - participants : les participants retirés, exclus compris ; + * - reservations : leurs réservations, suspendues comprises, soit toutes : + * chaque réservation d'une charge que l'analyse admet est celle d'un + * participant ; + * - titresPourvus : les titres qu'une réservation pourvoit, et que le + * remplacement laisse non pourvus — par la réservation d'une personne non + * exclue, sur son siège quand les sièges sont attribués, sur sa table + * sinon, à un tour au moins ; + * - propositions : les propositions qui placent au moins une personne + * retirée. Le retenu n'y entre pas : l'import en état retenu porte + * l'avertissement de dérive. + * La charge n'est pas modifiée. + * + * @param {import('../stockage/types.js').Charge} charge + * @returns {{participants: number, reservations: number, titresPourvus: number, propositions: number}} + */ +export function bilanRemplacement(charge) { + const retires = new Set(charge.participants.map(({ id }) => id)); + return { + participants: charge.participants.length, + reservations: charge.reservations.length, + titresPourvus: titresPourvus(charge), + propositions: charge.propositions.filter(({ participants }) => participants.some((id) => retires.has(id))).length, + }; +} + +// Participant créé d'une ligne de l'aperçu, ses clés dans l'ordre du +// fichier d'état : un exclu vide vaut non. +const creer = (id, { nom, prenom, appartenance, courriel, titrePressenti, notes, exclu }) => ({ + id, nom, prenom, appartenance, courriel, titrePressenti, notes, exclu: exclu ?? false, +}); + +// Pose sur le participant chaque champ non vide de la ligne : un champ vide +// garde la valeur (§ 10.1). Rend vrai quand une valeur a changé. +function poser(participant, champs) { + let change = false; + for (const champ of CHAMPS_LUS) { + if (champs[champ] !== null && champs[champ] !== participant[champ]) { + participant[champ] = champs[champ]; + change = true; + } + } + return change; +} + +// Met à jour, en place, les participants que désignent les lignes, et rend +// les lignes à ajouter, les refus et les comptes. Une ligne désigne les +// participants d'avant l'import dont le nom et le prénom ont la même clé +// normalisée que les siens, et l'appartenance aussi quand la ligne en porte +// une : un participant ajouté par une ligne n'est désigné par aucune autre. +// Un prénom absent compte comme vide, sur la ligne comme chez le +// participant : seule une ligne sans prénom désigne un participant sans +// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne +// filtre même un homonyme unique, et un participant sans appartenance ne +// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter, +// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est +// refusée PLUSIEURS_CORRESPONDENT. Le participant qu'une ligne désigne seule +// est mis à jour par la première de ces lignes, dans l'ordre du fichier ; +// chaque suivante est refusée DEJA_DESIGNE : deux lignes ne se fondent pas +// en silence dans une même personne. +function mettreAJour(participants, lignes) { + const parNom = new Map(); + for (const participant of participants) { + const cle = cleDeNom(participant.nom, participant.prenom); + if (!parNom.has(cle)) parNom.set(cle, []); + parNom.get(cle).push(participant); + } + const designes = new Map(); + const nouvelles = []; + const refusees = []; + let misAJour = 0; + let inchanges = 0; + for (const { ligne, champs, brut } of lignes) { + const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance); + const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter( + (participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance, + ); + if (candidats.length === 0) { + nouvelles.push({ champs }); + continue; + } + if (candidats.length > 1) { + const ids = candidats.map(({ id }) => id).sort(croissant); + refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] }); + continue; + } + const [designe] = candidats; + if (designes.has(designe.id)) { + refusees.push({ + ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [designe.id], premiereLigne: designes.get(designe.id), + brut: [...brut], + }); + continue; + } + designes.set(designe.id, ligne); + if (poser(designe, champs)) misAJour += 1; + else inchanges += 1; + } + return { nouvelles, refusees, misAJour, inchanges }; +} + +/** + * Applique un aperçu à une charge (§ 10.1), selon le mode : + * - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se + * fusionne jamais ; + * - 'mettreAJour' : une ligne désigne les participants d'avant l'import de + * même nom et même prénom normalisés, et de même appartenance quand la + * ligne en porte une : un prénom absent compte comme vide, sur la ligne + * comme chez le participant, et un participant sans appartenance n'est + * désigné que par une ligne qui n'en porte pas. Désigné seul, le + * participant prend chaque champ non vide de la ligne, nom et prénom + * compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une + * ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs, + * refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une + * ligne précédente désigne déjà, refusée DEJA_DESIGNE ; + * - 'remplacer' : les participants et leurs réservations sont retirés, + * puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu + * restent (bilanRemplacement). + * Une personne créée prend les champs de sa ligne, exclu vide valant non ; + * les identifiants partent de prochainsIds.participant, dans l'ordre du + * fichier, et le compteur avance d'autant. La charge et l'aperçu reçus ne + * sont pas modifiés ; la charge rendue n'en partage aucun objet. + * + * Le résumé (ResumeImport) compte les participants ajoutés, mis à jour, + * inchangés et retirés, et les réservations retirées. refusees porte les + * lignes refusées par l'aperçu, puis par la mise à jour, rangées par ligne : + * c'est la liste que reçoit exporterRefus. Chaque ligne valide est comptée + * une fois : ajoutée, mise à jour, inchangée ou refusée. Une mise à jour dont + * toutes les lignes sont refusées rend une charge égale à celle reçue. + * derive est vrai en état retenu : l'import y avertit sans bloquer (§ 9). + * + * @param {import('../stockage/types.js').Charge} charge + * @param {import('./apercu.js').Apercu} apercu calculé contre les + * participants de la charge : l'appartenance d'une ligne y prend + * l'orthographe d'un existant de même clé, que remplacer garde même quand + * il retire cet existant + * @param {'ajouter'|'mettreAJour'|'remplacer'} mode + * @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}} + * @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état + * bloqué ; le refus global de l'aperçu, son code, détails { ligne }, le rang + * que nomme l'aperçu ou null ; AMBIGUITE, détails { champs }, les champs + * que l'en-tête laisse ambigus, tant que le choix ne les tranche pas + * @throws {TypeError} pour un mode inconnu + */ +export function appliquerImport(charge, apercu, mode) { + if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`); + if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE'); + if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne }); + const { ambigus } = apercu.association; + if (ambigus.length > 0) throw new ErreurCsv('AMBIGUITE', { champs: ambigus.map(({ champ }) => champ) }); + + const resultat = structuredClone(charge); + let retires = 0; + let reservationsRetirees = 0; + let aAjouter = apercu.lignes; + let suite = { refusees: [], misAJour: 0, inchanges: 0 }; + if (mode === 'remplacer') { + retires = resultat.participants.length; + reservationsRetirees = resultat.reservations.length; + resultat.participants = []; + resultat.reservations = []; + } else if (mode === 'mettreAJour') { + suite = mettreAJour(resultat.participants, apercu.lignes); + aAjouter = suite.nouvelles; + } + for (const { champs } of aAjouter) { + resultat.participants.push(creer(resultat.prochainsIds.participant, champs)); + resultat.prochainsIds.participant += 1; + } + return { + charge: resultat, + resume: { + ajoutes: aAjouter.length, + misAJour: suite.misAJour, + inchanges: suite.inchanges, + retires, + reservationsRetirees, + refusees: [...structuredClone(apercu.refusees), ...suite.refusees].sort(parLigne), + derive: charge.evenement.etat === 'retenu', + }, + }; +} + +/** + * CSV des lignes refusées (§ 10.1, § 14.10), qui se corrige et se réimporte + * tel quel. L'en-tête est celui d'origine, tel qu'écrit, suivi de ligne et + * motif, deux en-têtes qu'aucun champ ne reconnaît. Chaque refus, rangé par + * ligne, écrit ses cellules d'origine telles que découpées, complétées de + * cellules vides jusqu'à la largeur de l'en-tête, puis son rang et son + * motif : ligne et motif restent sous leur en-tête. Des cellules au-delà de + * l'en-tête ne s'écrivent, après le motif, que lorsque l'une n'est pas + * blanche — la ligne refusée CHAMPS_EN_TROP — : réimporté sans correction, + * le fichier ne fait entrer aucune ligne décalée. UTF-8 avec marque, CRLF, + * séparateur d'origine, « ; » pour un fichier à une colonne, qui n'en a pas ; + * sans refus, l'en-tête seul. + * + * @param {import('./apercu.js').Apercu} apercu ses en-têtes et son séparateur + * @param {(code: string, details: Object) => string} motif le texte d'un + * refus, de son code et de ses détails : la LigneRefusee sans code ni + * brut — ligne et valeur, plus participants et premiereLigne pour un refus + * de la mise à jour + * @param {LigneRefusee[]} [refusees] les refus à écrire : par défaut ceux + * de l'aperçu, sous un refus global comme sans ; après l'application, + * resume.refusees, qui porte aussi ceux de la mise à jour + * @returns {Uint8Array} + * @throws {TypeError} quand motif ne rend pas une chaîne + */ +export function exporterRefus(apercu, motif, refusees = apercu.refusees) { + const largeur = apercu.entetes.length; + const enregistrements = [[...apercu.entetes, ENTETE_LIGNE, ENTETE_MOTIF]]; + for (const { code, brut, ...details } of [...refusees].sort(parLigne)) { + const texte = motif(code, details); + if (typeof texte !== 'string') { + throw new TypeError(`motif de ${code} : chaîne attendue, reçu ${JSON.stringify(texte) ?? String(texte)}`); + } + const cellules = brut.slice(0, largeur); + while (cellules.length < largeur) cellules.push(''); + const auDela = brut.slice(largeur); + enregistrements.push([...cellules, String(details.ligne), texte, ...(enregistrementVide(auDela) ? [] : auDela)]); + } + return ecrireCsv(enregistrements, apercu.separateur ?? SEPARATEUR); +} diff --git a/src/csv/import.test.js b/src/csv/import.test.js new file mode 100644 index 0000000..c5c717e --- /dev/null +++ b/src/csv/import.test.js @@ -0,0 +1,858 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'import appliqué (§ 10.1, § 14.10) : ajouter, mettre à jour, +// remplacer, et ce que remplacer annonce avant d'agir ; l'import selon l'état +// du plan ; les refus globaux ; les identifiants jamais réutilisés ; le CSV +// des lignes refusées, sa forme, et la boucle qui le corrige et le réimporte +// tel quel. Les marques combinantes s'écrivent par leur point de code. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { serialiser } from '../stockage/canonique.js'; +import { analyser, creerCharge } from '../stockage/document.js'; +import { VERSION } from '../version.genere.js'; +import { apercevoir } from './apercu.js'; +import { decoder } from './encodage.js'; +import { ErreurCsv } from './erreurs.js'; +import { appliquerImport, bilanRemplacement, exporterRefus } from './import.js'; +import { decouper } from './lecture.js'; + +const MARQUE = '\u{FEFF}'; + +// Texte d'un CSV : chaque ligne donnée, terminée par CRLF. +const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join(''); + +// Octets d'un fichier UTF-8 avec marque, de texte donné. +const enUtf8 = (texte) => new TextEncoder().encode(MARQUE + texte); + +// Octets windows-1252 d'un texte dont chaque caractère est de Latin-1 hors +// des commandes C1 : windows-1252 y donne à chacun l'octet de son point de +// code. +const enWindows1252 = (texte) => Uint8Array.from(texte, (caractere) => caractere.charCodeAt(0)); + +// Octets UTF-16 LE avec marque d'un texte. +function enUtf16le(texte) { + const octets = new Uint8Array(2 + 2 * texte.length); + octets.set([0xff, 0xfe]); + for (let i = 0; i < texte.length; i += 1) { + const unite = texte.charCodeAt(i); + octets[2 + 2 * i] = unite & 0xff; + octets[3 + 2 * i] = unite >> 8; + } + return octets; +} + +// Dix-neuf patronymes inventés : les lignes 2 à 20 d'un texte, la fenêtre où +// se choisit le séparateur. Un enregistrement dont le nombre de champs +// diffère de celui de l'en-tête n'est une ligne refusée qu'au-delà, à partir +// du rang 21 ; dans la fenêtre, il refuse le texte entier. +const REMPLISSAGE = [ + 'Aubier', 'Bourgeon', 'Cormier', 'Duvet', 'Églantier', 'Frêne', 'Genêt', 'Houx', 'Ibéris', 'Jasmin', + 'Lierre', 'Mélèze', 'Noisetier', 'Orme', 'Pommier', 'Quenouille', 'Ronce', 'Saule', 'Tilleul', +]; + +// Participant, ses champs omis vides et non exclu. +const personne = (id, nom, prenom = null, appartenance = null, autres = {}) => ({ + id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres, +}); + +const table = (id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 250 * (id - 1), y: 0 } }); + +// Réservation de portée « tous les tours », ou du tour donné. +const reservation = (participant, tableReservee, siege, tour = null) => ({ + participant, table: tableReservee, siege, portee: tour === null ? 'tous' : 'tour', tour, +}); + +const titre = (tableTitree, siege, libelle = 'animation') => ({ table: tableTitree, siege, libelle }); + +// Proposition de deux tours sur les tables 1 et 2, qui place les +// participants donnés, triés, en alternance. +function proposition(id, participants) { + const tour = (decalage) => ({ + sieges: [0, 1].map((rang) => participants.filter((_, i) => (i + decalage) % 2 === rang)), + reserve: [], + }); + return { + id, graine: 48271 + id, arret: 200000, historique: 1000, produitVersion: VERSION.affichee, siegesAttribues: false, + tables: [1, 2], capacites: [4, 4], tours: 2, participants, placement: [tour(0), tour(1)], + }; +} + +// Charge d'un événement de deux tours, tables de quatre sièges par défaut, +// qui porte ce qu'on lui donne. Le compteur des identifiants de participant +// dépasse par défaut le plus grand donné ; ceux des tables et des +// propositions, toujours. +function chargeDe({ + participants = [], tables = [], reservations = [], titres = [], propositions = [], retenu = null, + etat = 'brouillon', attribuerSieges = false, prochain, +} = {}) { + const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 }); + const auDela = (ids) => ids.reduce((plus, id) => Math.max(plus, id), 0) + 1; + charge.evenement.etat = etat; + charge.reglages.attribuerSieges = attribuerSieges; + charge.prochainsIds = { + participant: prochain ?? auDela(participants.map(({ id }) => id)), + table: auDela(tables.map(({ id }) => id)), + proposition: auDela(propositions.map(({ id }) => id)), + }; + charge.participants = participants; + charge.tables = tables; + charge.reservations = reservations; + charge.titres = titres; + charge.propositions = propositions; + charge.retenu = retenu; + return charge; +} + +// Une charge garnie : quatre personnes dont une exclue, deux tables, des +// réservations, des titres, deux propositions et un retenu. +function chargeGarnie(autres = {}) { + return chargeDe({ + participants: [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' }), + personne(2, 'Grisaille', null, 'Club des Merles', { exclu: true }), + personne(3, 'Pervenche', 'Théo'), + personne(4, 'Lacasse', 'Ondine', 'Société Alpha'), + ], + tables: [table(1), table(2)], + reservations: [reservation(1, 1, 1), reservation(2, 1, 2), reservation(3, 2, 1, 2), reservation(4, 2, null)], + titres: [titre(1, 1), titre(1, 2, 'accueil'), titre(2, 1), titre(2, 2)], + propositions: [proposition(1, [1, 2, 3, 4]), proposition(2, [1, 3])], + retenu: retenuDe(proposition(1, [1, 2, 3, 4])), + prochain: 9, + ...autres, + }); +} + +// Retenu tiré d'une proposition : son identifiant d'origine, puis les +// champs du plan, sans ceux qui ne sont qu'à une proposition. +function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) { + return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }; +} + +// Aperçu d'un texte contre les participants de la charge, puis import. +function importer(charge, texte, mode, choix = {}) { + return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix }), mode); +} + +// Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et +// virgules, que le CSV des refus doit citer. +const MOTIF = (code, details) => `${code} ${JSON.stringify(details)}`; + +// Valide une ErreurCsv de code et de détails donnés. +const refus = (code, details) => (erreur) => { + assert.ok(erreur instanceof ErreurCsv, String(erreur)); + assert.equal(erreur.code, code); + assert.deepEqual(erreur.details, details); + return true; +}; + +// Objets et listes d'une donnée JSON : JSON.stringify visite chaque valeur, +// et le remplaçant relève au passage chaque objet et chaque liste. +function objetsDe(valeur) { + const vus = new Set(); + JSON.stringify(valeur, (_, v) => { + if (typeof v === 'object' && v !== null) vus.add(v); + return v; + }); + return vus; +} + +// Résumé d'un import, ses comptes omis à zéro. +const resumeDe = (comptes = {}) => ({ + ajoutes: 0, misAJour: 0, inchanges: 0, retires: 0, reservationsRetirees: 0, refusees: [], derive: false, ...comptes, +}); + +const MODES = ['ajouter', 'mettreAJour', 'remplacer']; + +describe('appliquerImport : ajouter (§ 10.1)', () => { + test("chaque ligne valide devient un participant, identifiants depuis prochainsIds dans l'ordre du fichier", () => { + const charge = chargeDe({ participants: [personne(3, 'Lacasse', 'Ondine')], prochain: 5 }); + const { charge: apres, resume } = importer( + charge, + csv( + 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes', + 'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;arrive tôt', + 'Pervenche;Théo;;;;oui;', + 'Sarcelle;;;;;;', + ), + 'ajouter', + ); + assert.deepEqual(apres.participants, [ + personne(3, 'Lacasse', 'Ondine'), + personne(5, 'Ombrelle', 'Iris', 'Club des Merles', { + courriel: 'iris@exemple.test', titrePressenti: 'animation', notes: 'arrive tôt', + }), + personne(6, 'Pervenche', 'Théo', null, { exclu: true }), + personne(7, 'Sarcelle'), + ]); + assert.equal(apres.prochainsIds.participant, 8); + assert.deepEqual(resume, resumeDe({ ajoutes: 3 })); + }); + + test("sans colonne exclu, ou sa cellule vide, la personne créée n'est pas exclue", () => { + const { charge: apres } = importer(chargeDe(), csv('nom', 'Ombrelle', 'Pervenche'), 'ajouter'); + assert.deepEqual(apres.participants.map(({ exclu }) => exclu), [false, false]); + }); + + test("ajouter ne fusionne jamais un doublon : la ligne d'un participant existant en crée un second", () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')] }); + const { charge: apres, resume } = importer( + charge, csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles', 'ombrelle;IRIS;club des merles'), 'ajouter', + ); + assert.deepEqual(apres.participants, [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(3, 'ombrelle', 'IRIS', 'Club des Merles'), + ]); + assert.equal(resume.ajoutes, 2); + }); + + test("l'appartenance prend l'orthographe que l'aperçu affiche ; une appartenance de clé vide arrive à null", () => { + const charge = chargeDe({ participants: [personne(1, 'Lacasse', 'Ondine', 'Club des Merles')] }); + const { charge: apres } = importer( + charge, csv('nom;prenom;appartenance', 'Ombrelle;Iris;CLUB DES MERLES', 'Pervenche;Théo;\u{0301}\u{0301}'), 'ajouter', + ); + assert.deepEqual(apres.participants.map(({ appartenance }) => appartenance), ['Club des Merles', 'Club des Merles', null]); + }); + + test("les lignes refusées de l'aperçu passent au résumé, dans l'ordre des lignes, et ne créent rien", () => { + const { charge: apres, resume } = importer( + chargeDe(), csv('nom;prenom;exclu', 'Ombrelle;Iris;', ';Théo;non', 'Sarcelle;Ondine;peut-être'), 'ajouter', + ); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris')]); + assert.deepEqual(resume, resumeDe({ + ajoutes: 1, + refusees: [ + { ligne: 3, code: 'NOM_ABSENT', valeur: null, brut: ['', 'Théo', 'non'] }, + { ligne: 4, code: 'EXCLU_INCONNU', valeur: 'peut-être', brut: ['Sarcelle', 'Ondine', 'peut-être'] }, + ], + })); + }); +}); + +describe('appliquerImport : mettre à jour (§ 10.1)', () => { + test('un fichier qui ne porte que des noms ne vide aucun courriel, ni aucun autre champ', () => { + const existants = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { + courriel: 'iris@exemple.test', titrePressenti: 'animation', notes: 'arrive tard', exclu: true, + }), + personne(2, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' }), + ]; + const charge = chargeDe({ participants: existants }); + const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Ombrelle;Iris', 'Pervenche;Théo'), 'mettreAJour'); + assert.deepEqual(apres.participants, existants); + assert.deepEqual(resume, resumeDe({ inchanges: 2 })); + }); + + test('un champ non vide remplace la valeur, un champ vide la garde ; la ligne désigne sans égard à la casse, aux accents ni aux blancs', () => { + const charge = chargeDe({ + participants: [personne(1, 'Pervenche', 'Théo', 'Club des Merles', { courriel: 'ancien@exemple.test', notes: 'garde' })], + }); + const { charge: apres, resume } = importer( + charge, + csv('nom;prenom;appartenance;courriel;notes', ' PERVENCHE ;theo;club des merles;nouveau@exemple.test;'), + 'mettreAJour', + ); + assert.deepEqual(apres.participants, [ + personne(1, 'PERVENCHE', 'theo', 'Club des Merles', { courriel: 'nouveau@exemple.test', notes: 'garde' }), + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 1 })); + }); + + test('exclu : une cellule vide garde la valeur, non réintègre, oui exclut', () => { + const charge = chargeDe({ + participants: [ + personne(1, 'Ombrelle', null, null, { exclu: true }), + personne(2, 'Pervenche', null, null, { exclu: true }), + personne(3, 'Sarcelle'), + ], + }); + const { charge: apres, resume } = importer(charge, csv('nom;exclu', 'Ombrelle;', 'Pervenche;non', 'Sarcelle;oui'), 'mettreAJour'); + assert.deepEqual(apres.participants.map(({ exclu }) => exclu), [true, false, true]); + assert.deepEqual(resume, resumeDe({ misAJour: 2, inchanges: 1 })); + }); + + test("une ligne qui désigne deux participants est refusée PLUSIEURS_CORRESPONDENT, leurs identifiants nommés ; l'appartenance de la ligne les départage", () => { + const existants = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), + ]; + const charge = chargeDe({ participants: existants }); + const { charge: apres, resume } = importer( + charge, + csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;;iris@exemple.test', 'Ombrelle;Iris;chorale du givre;givre@exemple.test'), + 'mettreAJour', + ); + assert.deepEqual(apres.participants, [existants[0], { ...existants[1], courriel: 'givre@exemple.test' }]); + assert.deepEqual(resume, resumeDe({ + misAJour: 1, + refusees: [{ + ligne: 2, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: [1, 2], + brut: ['Ombrelle', 'Iris', '', 'iris@exemple.test'], + }], + })); + }); + + test("une ligne qui ne désigne personne est ajoutée, son identifiant depuis prochainsIds", () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris')], prochain: 4 }); + const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Ombrelle;Iris', 'Ombrelle;Théo'), 'mettreAJour'); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris'), personne(4, 'Ombrelle', 'Théo')]); + assert.equal(apres.prochainsIds.participant, 5); + assert.deepEqual(resume, resumeDe({ ajoutes: 1, inchanges: 1 })); + }); + + test("l'appartenance que porte la ligne compte même devant un seul homonyme : d'une autre appartenance, la ligne est ajoutée, et l'homonyme garde ses valeurs", () => { + const existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), + csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'), + 'mettreAJour', + ); + assert.deepEqual(apres.participants, [ + existants[0], + personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }), + ]); + assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + }); + + test('une ligne sans prénom ne désigne que des participants sans prénom : devant le seul participant de ce nom, qui en porte un, elle est ajoutée, et lui reste tel quel', () => { + // La clé est (nom, prénom), un prénom absent y comptant comme vide : une + // liste de noms sans prénoms ajoute des personnes au lieu de mettre à + // jour les inscrits de même nom. + const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')]; + const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour'); + assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]); + assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + }); + + test('une ligne qui porte un prénom ne désigne pas un participant sans prénom : elle est ajoutée, et lui reste sans prénom', () => { + const existants = [personne(1, 'Grisaille', null, 'Club des Merles')]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour', + ); + assert.deepEqual(apres.participants, [...existants, personne(2, 'Grisaille', 'Silas', null, { exclu: true })]); + assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + }); + + test("un participant sans appartenance n'est désigné que par une ligne qui n'en porte pas : celle qui en porte une est ajoutée, et lui reste sans appartenance", () => { + const existants = [personne(1, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), csv('nom;prenom;appartenance', 'Pervenche;Théo;Club des Merles'), 'mettreAJour', + ); + assert.deepEqual(apres.participants, [...existants, personne(2, 'Pervenche', 'Théo', 'Club des Merles')]); + assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + }); + + test("l'appartenance se compare par sa clé normalisée, des deux côtés : la ligne désigne l'homonyme qui l'écrit sans accents ou en capitales, et lui pose l'orthographe affichée", () => { + // L'aperçu donne aux deux lignes l'orthographe du premier existant, + // « Société Alpha » ; les participants qu'elles désignent en écrivent + // chacun une autre. + const existants = [ + personne(1, 'Lacasse', 'Ondine', 'Société Alpha'), + personne(2, 'Ombrelle', 'Iris', 'Societe Alpha'), + personne(3, 'Pervenche', 'Théo', 'SOCIÉTÉ ALPHA'), + ]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), + csv( + 'nom;prenom;appartenance;courriel', + 'Ombrelle;Iris;société alpha;iris@exemple.test', + 'Pervenche;Théo;Societe alpha;theo@exemple.test', + ), + 'mettreAJour', + ); + assert.deepEqual(apres.participants, [ + existants[0], + personne(2, 'Ombrelle', 'Iris', 'Société Alpha', { courriel: 'iris@exemple.test' }), + personne(3, 'Pervenche', 'Théo', 'Société Alpha', { courriel: 'theo@exemple.test' }), + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 2 })); + }); + + test('deux lignes qui désignent le même participant : la première le met à jour, la seconde est refusée DEJA_DESIGNE', () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris')] }); + const { charge: apres, resume } = importer( + charge, csv('nom;prenom;courriel', 'Ombrelle;Iris;a@exemple.test', 'ombrelle;iris;b@exemple.test'), 'mettreAJour', + ); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris', null, { courriel: 'a@exemple.test' })]); + assert.deepEqual(resume, resumeDe({ + misAJour: 1, + refusees: [{ + ligne: 3, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 2, + brut: ['ombrelle', 'iris', 'b@exemple.test'], + }], + })); + }); + + test('une mise à jour dont toutes les lignes sont refusées rend une charge égale à celle reçue ; les identifiants refusés, croissants', () => { + const charge = chargeDe({ + participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], + }); + const { charge: apres, resume } = importer( + charge, csv('nom;prenom;courriel', 'Ombrelle;Iris;a@exemple.test', 'OMBRELLE;iris;'), 'mettreAJour', + ); + assert.deepEqual(apres, charge); + assert.deepEqual(resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [ + [2, 'PLUSIEURS_CORRESPONDENT', [1, 2]], + [3, 'PLUSIEURS_CORRESPONDENT', [1, 2]], + ]); + assert.deepEqual(resume, resumeDe({ refusees: resume.refusees })); + }); + + test("la correspondance porte sur les participants d'avant l'import : deux lignes nouvelles et identiques font deux participants", () => { + const { charge: apres, resume } = importer(chargeDe(), csv('nom;prenom', 'Ombrelle;Iris', 'Ombrelle;Iris'), 'mettreAJour'); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Ombrelle', 'Iris')]); + assert.deepEqual(resume, resumeDe({ ajoutes: 2 })); + }); +}); + +describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', () => { + test("bilanRemplacement compte, avant d'agir, les participants, les réservations, les titres pourvus et les propositions touchées", () => { + const charge = chargeDe({ + participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Sarcelle')], + tables: [table(1), table(2)], + reservations: [reservation(1, 1, 1), reservation(2, 2, 3)], + titres: [titre(1, 1), titre(2, 1)], + propositions: [proposition(1, [1, 2, 3])], + attribuerSieges: true, + }); + assert.deepEqual(bilanRemplacement(charge), { participants: 3, reservations: 2, titresPourvus: 1, propositions: 1 }); + }); + + test("le bilan annonce ce que remplacer retire : participants et réservations du résumé, titres et propositions gardés", () => { + const charge = chargeGarnie(); + const bilan = bilanRemplacement(charge); + const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Bruyère;Anouk', 'Sarcelle;Ondine'), 'remplacer'); + assert.equal(resume.retires, bilan.participants); + assert.equal(resume.reservationsRetirees, bilan.reservations); + assert.deepEqual(resume, resumeDe({ ajoutes: 2, retires: 4, reservationsRetirees: 4 })); + assert.deepEqual(apres.participants, [personne(9, 'Bruyère', 'Anouk'), personne(10, 'Sarcelle', 'Ondine')]); + assert.deepEqual(apres.reservations, []); + for (const cle of ['evenement', 'reglages', 'tables', 'titres', 'propositions', 'retenu']) { + assert.deepEqual(apres[cle], charge[cle], cle); + } + assert.deepEqual(apres.prochainsIds, { ...charge.prochainsIds, participant: 11 }); + }); + + test("lecture par siège : un titre est pourvu par la réservation d'une personne non exclue sur son siège, à un tour au moins", () => { + // Titre (1, 1) : réservé tous les tours. Titre (1, 2) : réservé par une + // personne exclue. Titre (2, 1) : réservé au tour 2 seulement. Titre + // (2, 2) : la réservation de la table 2 ne désigne aucun siège. + assert.deepEqual(bilanRemplacement(chargeGarnie({ attribuerSieges: true })), { + participants: 4, reservations: 4, titresPourvus: 2, propositions: 2, + }); + }); + + test('lecture par table : à chaque tour, autant de titres pourvus que de personnes non exclues réservées, les titres de la table au plus ; le tour le mieux pourvu compte', () => { + const participants = [1, 2, 3, 4, 5, 6, 7, 8].map((id) => personne(id, `Personne ${id}`, null, null, { exclu: id === 7 })); + const charge = chargeDe({ + participants, + tables: [table(1), table(2), table(3), table(4)], + reservations: [ + // Table 1, trois titres : deux personnes tous les tours, et une exclue. + reservation(1, 1, null), reservation(2, 1, null), reservation(7, 1, null), + // Table 2, un titre : deux personnes tous les tours. + reservation(3, 2, null), reservation(4, 2, null), + // Table 3, deux titres : une personne au tour 1, une autre au tour 2. + reservation(5, 3, null, 1), reservation(6, 3, null, 2), + // Table 4, un titre : une personne au tour 1 seulement. + reservation(8, 4, null, 1), + ], + titres: [titre(1, 1), titre(1, 2), titre(1, 3), titre(2, 1), titre(3, 1), titre(3, 2), titre(4, 1)], + }); + assert.deepEqual(bilanRemplacement(charge), { participants: 8, reservations: 8, titresPourvus: 5, propositions: 0 }); + assert.equal(bilanRemplacement(chargeGarnie()).titresPourvus, 3); + }); + + test("une proposition qui ne place aucune des personnes de la liste n'est pas touchée", () => { + const charge = chargeDe({ + participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')], + tables: [table(1), table(2)], + propositions: [proposition(1, [1, 2]), proposition(2, [7, 8]), proposition(3, [2, 8])], + prochain: 9, + }); + assert.equal(bilanRemplacement(charge).propositions, 2); + }); + + test("identifiants jamais réutilisés : retirer, puis importer, attribue au-delà de prochainsIds", () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')], prochain: 6 }); + const remplacee = importer(charge, csv('nom', 'Sarcelle', 'Bruyère'), 'remplacer').charge; + assert.deepEqual(remplacee.participants.map(({ id }) => id), [6, 7]); + const ajoutee = importer(remplacee, csv('nom', 'Lacasse'), 'ajouter').charge; + assert.deepEqual(ajoutee.participants.map(({ id }) => id), [6, 7, 8]); + assert.equal(ajoutee.prochainsIds.participant, 9); + }); + + test('bilanRemplacement ne modifie pas la charge', () => { + const charge = chargeGarnie(); + const avant = structuredClone(charge); + bilanRemplacement(charge); + assert.deepEqual(charge, avant); + }); +}); + +describe('appliquerImport : la charge rendue', () => { + test("la charge et l'aperçu reçus ne sont pas modifiés, et la charge rendue n'en partage aucun objet", () => { + for (const mode of MODES) { + const charge = chargeGarnie(); + const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), { + participants: charge.participants, + }); + const avant = structuredClone({ charge, apercu }); + const resultat = appliquerImport(charge, apercu, mode); + assert.deepEqual({ charge, apercu }, avant, mode); + const recus = objetsDe({ charge, apercu }); + for (const objet of objetsDe(resultat)) assert.ok(!recus.has(objet), `${mode} : objet partagé ${JSON.stringify(objet)}`); + } + }); + + test("la charge rendue s'écrit et se relit : l'analyse du stockage l'admet, comptes et références compris", () => { + for (const mode of MODES) { + const { charge: apres } = importer(chargeGarnie(), csv('nom;prenom;exclu', 'Ombrelle;Iris;oui', 'Bruyère;Anouk;'), mode); + const relue = analyser(serialiser(apres, { revision: 2, produitVersion: VERSION.affichee })).charge; + assert.deepEqual(relue.participants, apres.participants, mode); + assert.equal(relue.prochainsIds.participant, apres.prochainsIds.participant, mode); + } + }); +}); + +describe("appliquerImport : selon l'état du plan (§ 9, § 10.1)", () => { + test('brouillon et proposé : appliqué, sans avertissement de dérive', () => { + for (const etat of ['brouillon', 'propose']) { + for (const mode of MODES) { + const { charge: apres, resume } = importer(chargeGarnie({ etat }), csv('nom', 'Bruyère'), mode); + assert.equal(resume.derive, false, `${etat} ${mode}`); + assert.equal(apres.participants.at(-1).nom, 'Bruyère', `${etat} ${mode}`); + } + } + }); + + test("retenu : appliqué, avec l'avertissement de dérive", () => { + for (const mode of MODES) { + const { charge: apres, resume } = importer(chargeGarnie({ etat: 'retenu' }), csv('nom', 'Bruyère'), mode); + assert.equal(resume.derive, true, mode); + assert.equal(apres.participants.at(-1).nom, 'Bruyère', mode); + assert.equal(apres.evenement.etat, 'retenu', mode); + } + }); + + test("bloqué : refusé PLAN_BLOQUE dans chaque mode, avant tout autre refus, et rien n'est rendu", () => { + for (const mode of MODES) { + const charge = chargeGarnie({ etat: 'bloque' }); + const avant = structuredClone(charge); + assert.throws(() => importer(charge, csv('nom', 'Bruyère'), mode), refus('PLAN_BLOQUE', {}), mode); + assert.throws(() => importer(charge, csv('nom;exclu', 'Bruyère;peut-être'), mode), refus('PLAN_BLOQUE', {}), mode); + assert.deepEqual(charge, avant, mode); + } + }); +}); + +describe('appliquerImport : les refus globaux (§ 10.1)', () => { + test("le refus global de l'aperçu, son rang dans les détails", () => { + const charge = chargeDe(); + assert.throws(() => importer(charge, csv('nom;exclu', 'Ombrelle;peut-être'), 'ajouter'), refus('AUCUNE_LIGNE_VALIDE', { ligne: null })); + assert.throws(() => importer(charge, csv('prenom;courriel', 'Iris;'), 'ajouter'), refus('NOM_NON_ASSOCIE', { ligne: null })); + assert.throws( + () => importer(charge, csv('nom;notes', 'Ombrelle;"arrive tard', 'Pervenche;'), 'ajouter'), + refus('GUILLEMET_OUVERT', { ligne: 2 }), + ); + assert.throws( + () => importer(charge, csv('nom;notes', 'Ombrelle;arrive tard;vers 20 h', 'Pervenche;'), 'ajouter'), + refus('SEPARATEUR_INTROUVABLE', { ligne: 2 }), + ); + }); + + test('une ambiguïté qui reste refuse : AMBIGUITE, ses champs nommés ; le choix la lève', () => { + const texte = csv('nom;organisation;entreprise', 'Ombrelle;Club des Merles;Société Alpha'); + for (const mode of MODES) { + assert.throws(() => importer(chargeDe(), texte, mode), refus('AMBIGUITE', { champs: ['appartenance'] }), mode); + } + const { charge: apres } = importer(chargeDe(), texte, 'ajouter', { appartenance: 2 }); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', null, 'Société Alpha')]); + }); + + test("le refus global de l'aperçu passe avant une ambiguïté : deux colonnes nom refusent NOM_NON_ASSOCIE", () => { + assert.throws( + () => importer(chargeDe(), csv('nom;nom;organisation;entreprise', 'Ombrelle;Iris;Club des Merles;Alpha'), 'ajouter'), + refus('NOM_NON_ASSOCIE', { ligne: null }), + ); + assert.throws( + () => importer(chargeDe(), csv('nom;organisation;entreprise', 'Ombrelle;"Club;', 'Pervenche;;'), 'ajouter'), + refus('GUILLEMET_OUVERT', { ligne: 2 }), + ); + }); + + test('un mode inconnu lève TypeError', () => { + const apercu = apercevoir(csv('nom', 'Ombrelle')); + for (const mode of ['fusionner', 'Ajouter', undefined, null]) { + assert.throws(() => appliquerImport(chargeDe(), apercu, mode), TypeError, String(mode)); + } + }); +}); + +describe('exporterRefus : la boucle des refus (§ 10.1, § 14.10)', () => { + // Un exclu inconnu à la ligne 7, au milieu de lignes valides. + const FICHIER = csv( + 'nom;prenom;appartenance;exclu', + 'Ombrelle;Iris;Club des Merles;non', + 'Pervenche;Théo;;', + 'Sarcelle;Ondine;Chorale du Givre;oui', + 'Bruyère;Anouk;;', + 'Grisaille;Silas;Club des Merles;', + 'Lacasse;Perrine;Société Alpha;peut-être', + 'Ardillon;Maëlle;;non', + ); + + // Le premier import et son CSV des refus. + function premierImport() { + const charge = chargeDe(); + const apercu = apercevoir(decoder(enUtf8(FICHIER)).texte, { participants: charge.participants }); + const { charge: apres, resume } = appliquerImport(charge, apercu, 'ajouter'); + return { apres, resume, refusees: exporterRefus(apercu, MOTIF, resume.refusees) }; + } + + test("un exclu inconnu à la ligne 7 revient dans le CSV des refus avec ligne 7 et son motif ; corrigé, le fichier se réimporte tel quel et la ligne entre", () => { + const { apres, resume, refusees } = premierImport(); + assert.equal(resume.ajoutes, 6); + assert.deepEqual(resume.refusees.map(({ ligne, code, valeur }) => [ligne, code, valeur]), [[7, 'EXCLU_INCONNU', 'peut-être']]); + + const { texte, encodage } = decoder(refusees); + assert.equal(encodage, 'utf-8-bom'); + assert.equal( + texte, + csv( + 'nom;prenom;appartenance;exclu;ligne;motif', + 'Lacasse;Perrine;Société Alpha;peut-être;7;"EXCLU_INCONNU {""ligne"":7,""valeur"":""peut-être""}"', + ), + ); + + // L'opérateur corrige le champ dans ce fichier, et le réimporte tel quel. + const corrige = enUtf8(texte.replace(';peut-être;7;', ';oui;7;')); + const apercu = apercevoir(decoder(corrige).texte, { participants: apres.participants }); + assert.deepEqual(apercu.association.nonReconnues, [4, 5]); + assert.deepEqual(apercu.refusees, []); + const fin = appliquerImport(apres, apercu, 'ajouter'); + assert.deepEqual(fin.resume, resumeDe({ ajoutes: 1 })); + assert.deepEqual(fin.charge.participants.at(-1), personne(7, 'Lacasse', 'Perrine', 'Société Alpha', { exclu: true })); + }); + + test('réimporté sans correction, le CSV des refus refuse la même ligne pour le même motif, et son propre CSV des refus garde les deux rangs', () => { + const { apres, refusees } = premierImport(); + const apercu = apercevoir(decoder(refusees).texte, { participants: apres.participants }); + assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE'); + assert.deepEqual(apercu.refusees.map(({ ligne, code, valeur }) => [ligne, code, valeur]), [[2, 'EXCLU_INCONNU', 'peut-être']]); + const { texte } = decoder(exporterRefus(apercu, MOTIF)); + assert.deepEqual(decouper(texte, ';').enregistrements.map((enregistrement) => enregistrement.slice(4)), [ + ['ligne', 'motif', 'ligne', 'motif'], + ['7', 'EXCLU_INCONNU {"ligne":7,"valeur":"peut-être"}', '2', 'EXCLU_INCONNU {"ligne":2,"valeur":"peut-être"}'], + ]); + }); +}); + +describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => { + test("UTF-8 avec marque, CRLF, les en-têtes d'origine tels qu'écrits, puis ligne et motif", () => { + const apercu = apercevoir(csv(' Nom ;Prénom;Équipe;Exclu', 'Ombrelle;Iris;Club des Merles;peut-être', 'Pervenche;Théo;;non')); + const octets = exporterRefus(apercu, MOTIF); + assert.ok(octets instanceof Uint8Array); + assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]); + assert.equal( + new TextDecoder('utf-8', { ignoreBOM: true }).decode(octets), + MARQUE + + csv( + ' Nom ;Prénom;Équipe;Exclu;ligne;motif', + 'Ombrelle;Iris;Club des Merles;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"', + ), + ); + }); + + test("le séparateur d'origine : la virgule, la tabulation", () => { + const virgule = apercevoir(csv('nom,prenom,exclu', 'Ombrelle,Iris,peut-être', 'Pervenche,Théo,non')); + assert.equal( + decoder(exporterRefus(virgule, MOTIF)).texte, + csv('nom,prenom,exclu,ligne,motif', 'Ombrelle,Iris,peut-être,2,"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"'), + ); + const tabulation = apercevoir(csv('nom\tprenom\texclu', 'Ombrelle\tIris\tpeut-être', 'Pervenche\tThéo\tnon')); + assert.equal( + decoder(exporterRefus(tabulation, MOTIF)).texte, + csv('nom\tprenom\texclu\tligne\tmotif', 'Ombrelle\tIris\tpeut-être\t2\t"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"'), + ); + }); + + test("un fichier à une colonne, sans séparateur établi : le CSV des refus prend « ; », et se relit à trois colonnes", () => { + const charge = chargeDe({ + participants: [ + personne(1, 'Ombrelle', null, 'Club des Merles'), + personne(2, 'Ombrelle', null, 'Chorale du Givre'), + personne(3, 'Pervenche'), + ], + }); + const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants }); + assert.equal(apercu.separateur, null); + const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); + const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees)); + assert.equal( + texte, + csv('nom;ligne;motif', 'Ombrelle;2;"PLUSIEURS_CORRESPONDENT {""ligne"":2,""valeur"":null,""participants"":[1,2]}"'), + ); + const relu = apercevoir(texte); + assert.equal(relu.separateur, ';'); + assert.deepEqual(relu.lignes.map(({ champs }) => champs.nom), ['Ombrelle']); + }); + + test('un champ qui porte le séparateur, un guillemet ou une fin de ligne s\'écrit cité : chaque cellule se relit telle que découpée', () => { + const apercu = apercevoir(csv( + 'nom;prenom;notes;exclu', + '"Ombrelle ""dite Iris""";Iris;"premier ; second', + 'troisième";peut-être', + 'Pervenche;Théo;;non', + )); + const { brut } = apercu.refusees[0]; + assert.deepEqual(brut, ['Ombrelle "dite Iris"', 'Iris', 'premier ; second\r\ntroisième', 'peut-être']); + const { enregistrements } = decouper(decoder(exporterRefus(apercu, MOTIF)).texte, ';'); + assert.deepEqual(enregistrements[1], [...brut, '2', MOTIF('EXCLU_INCONNU', { ligne: 2, valeur: 'peut-être' })]); + }); + + test("une ligne trop courte se complète de cellules vides, des cellules blanches au-delà de l'en-tête ne s'écrivent pas : ligne et motif restent sous leur en-tête", () => { + const apercu = apercevoir(csv( + 'nom;prenom;notes', + ...REMPLISSAGE.map((nom) => `${nom};;`), + ';Théo', + ' ;Anouk;;; ', + )); + assert.deepEqual(apercu.refusees.map(({ ligne, code, brut }) => [ligne, code, brut]), [ + [21, 'NOM_ABSENT', ['', 'Théo']], + [22, 'NOM_ABSENT', [' ', 'Anouk', '', '', ' ']], + ]); + assert.equal( + decoder(exporterRefus(apercu, MOTIF)).texte, + csv( + 'nom;prenom;notes;ligne;motif', + ';Théo;;21;"NOM_ABSENT {""ligne"":21,""valeur"":null}"', + ' ;Anouk;;22;"NOM_ABSENT {""ligne"":22,""valeur"":null}"', + ), + ); + }); + + test("une ligne aux champs en trop garde ses cellules au-delà de ligne et motif : réimporté sans correction, le CSV ne fait entrer aucune ligne décalée ; corrigé, il se réimporte", () => { + const apercu = apercevoir(csv('nom;prenom;notes', ...REMPLISSAGE.map((nom) => `${nom};;`), 'Ombrelle;Iris;arrive tard;vers 20 h')); + assert.deepEqual(apercu.refusees.map(({ ligne, code }) => [ligne, code]), [[21, 'CHAMPS_EN_TROP']]); + const { texte } = decoder(exporterRefus(apercu, MOTIF)); + assert.equal( + texte, + csv('nom;prenom;notes;ligne;motif', 'Ombrelle;Iris;arrive tard;21;"CHAMPS_EN_TROP {""ligne"":21,""valeur"":null}";vers 20 h'), + ); + + const charge = chargeDe(); + assert.throws(() => importer(charge, texte, 'ajouter'), refus('SEPARATEUR_INTROUVABLE', { ligne: 2 })); + + const corrige = texte.replace('arrive tard;21;', 'arrive tard, vers 20 h;21;').replace(';vers 20 h\r\n', '\r\n'); + const { charge: apres } = importer(charge, corrige, 'ajouter'); + assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris', null, { notes: 'arrive tard, vers 20 h' })]); + }); + + test("réimporté sans correction, le CSV des refus ne fait entrer aucune ligne : chacune est refusée de nouveau pour le même motif, champs en trop compris au-delà de la fenêtre", () => { + // Dix-neuf noms absents, puis des champs en trop et un exclu inconnu : + // dans le CSV des refus, la ligne aux champs en trop tombe au rang 21, + // hors de la fenêtre du séparateur. + const apercu = apercevoir(csv( + 'nom;prenom;exclu', + ...REMPLISSAGE.map((nom) => `${nom};;`), + ...REMPLISSAGE.map((prenom) => `;${prenom};`), + 'Ombrelle;Iris;non;en trop', + 'Pervenche;Théo;peut-être', + )); + const codes = (refusees) => refusees.map(({ code, valeur }) => [code, valeur]); + assert.deepEqual(codes(apercu.refusees), [ + ...REMPLISSAGE.map(() => ['NOM_ABSENT', null]), ['CHAMPS_EN_TROP', null], ['EXCLU_INCONNU', 'peut-être'], + ]); + const relu = apercevoir(decoder(exporterRefus(apercu, MOTIF)).texte); + assert.equal(relu.separateur, ';'); + assert.equal(relu.refusGlobal, 'AUCUNE_LIGNE_VALIDE'); + assert.deepEqual(codes(relu.refusees), codes(apercu.refusees)); + assert.deepEqual(relu.refusees.map(({ ligne }) => ligne).slice(-2), [21, 22]); + }); + + test("les refus de l'application, passés en troisième argument, entrent dans le CSV avec ceux de l'aperçu, rangés par ligne", () => { + const charge = chargeDe({ + participants: [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), + personne(3, 'Pervenche', 'Théo'), + ], + }); + const apercu = apercevoir( + csv( + 'nom;prenom;courriel;exclu', + 'Ombrelle;Iris;iris@exemple.test;', + 'Pervenche;Théo;theo@exemple.test;', + ';Anouk;;', + 'pervenche;theo;autre@exemple.test;', + 'Sarcelle;Ondine;;peut-être', + ), + { participants: charge.participants }, + ); + const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); + assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [ + [2, 'PLUSIEURS_CORRESPONDENT'], [4, 'NOM_ABSENT'], [5, 'DEJA_DESIGNE'], [6, 'EXCLU_INCONNU'], + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 1, refusees: resume.refusees })); + assert.equal( + decoder(exporterRefus(apercu, MOTIF, resume.refusees)).texte, + csv( + 'nom;prenom;courriel;exclu;ligne;motif', + 'Ombrelle;Iris;iris@exemple.test;;2;"PLUSIEURS_CORRESPONDENT {""ligne"":2,""valeur"":null,""participants"":[1,2]}"', + ';Anouk;;;4;"NOM_ABSENT {""ligne"":4,""valeur"":null}"', + 'pervenche;theo;autre@exemple.test;;5;"DEJA_DESIGNE {""ligne"":5,""valeur"":null,""participants"":[3],""premiereLigne"":3}"', + 'Sarcelle;Ondine;;peut-être;6;"EXCLU_INCONNU {""ligne"":6,""valeur"":""peut-être""}"', + ), + ); + assert.deepEqual( + exporterRefus(apercu, MOTIF, [...resume.refusees].reverse()), + exporterRefus(apercu, MOTIF, resume.refusees), + ); + }); + + test("les lignes refusées d'un aperçu sans ligne valide s'exportent depuis l'aperçu seul", () => { + const apercu = apercevoir(csv('nom;exclu', 'Ombrelle;peut-être', ';non')); + assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE'); + assert.equal( + decoder(exporterRefus(apercu, MOTIF)).texte, + csv( + 'nom;exclu;ligne;motif', + 'Ombrelle;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"', + ';non;3;"NOM_ABSENT {""ligne"":3,""valeur"":null}"', + ), + ); + }); + + test("motif reçoit le code et les détails de chaque refus ; un motif qui ne rend pas une chaîne lève TypeError", () => { + const apercu = apercevoir(csv('nom;exclu', 'Ombrelle;peut-être', ';non', 'Pervenche;')); + const appels = []; + exporterRefus(apercu, (code, details) => { + appels.push([code, details]); + return code; + }); + assert.deepEqual(appels, [ + ['EXCLU_INCONNU', { ligne: 2, valeur: 'peut-être' }], + ['NOM_ABSENT', { ligne: 3, valeur: null }], + ]); + for (const [rendu, recu] of [[undefined, 'undefined'], [7, '7'], [['a'], '["a"]']]) { + assert.throws(() => exporterRefus(apercu, () => rendu), { + name: 'TypeError', + message: `motif de EXCLU_INCONNU : chaîne attendue, reçu ${recu}`, + }); + } + }); + + test("un fichier en windows-1252 ou en UTF-16 : le CSV des refus s'écrit en UTF-8 avec marque, ses accents intacts", () => { + const texte = csv('nom;prenom;exclu', 'Pervenche;Théo;peut-être', 'Ombrelle;Iris;non'); + const attendu = csv('nom;prenom;exclu;ligne;motif', 'Pervenche;Théo;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"'); + for (const [octets, encodageLu] of [[enWindows1252(texte), 'windows-1252'], [enUtf16le(texte), 'utf-16le']]) { + const lu = decoder(octets); + assert.equal(lu.encodage, encodageLu); + const refusees = decoder(exporterRefus(apercevoir(lu.texte), MOTIF)); + assert.deepEqual(refusees, { texte: attendu, encodage: 'utf-8-bom' }, encodageLu); + } + }); +});