[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:
parent
3b00566560
commit
201259331d
4 changed files with 1504 additions and 0 deletions
111
src/csv/export.js
Normal file
111
src/csv/export.js
Normal 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
193
src/csv/export.test.js
Normal 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
342
src/csv/import.js
Normal 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
858
src/csv/import.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue