[ADD] csv: apply an import three ways, re-export refusals, export list

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
This commit is contained in:
Mathieu Benoit 2026-10-06 14:47:25 -04:00
parent 3b00566560
commit 201259331d
4 changed files with 1504 additions and 0 deletions

111
src/csv/export.js Normal file
View file

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

193
src/csv/export.test.js Normal file
View file

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

342
src/csv/import.js Normal file
View file

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

858
src/csv/import.test.js Normal file
View file

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