[FIX] csv: update matching, stray quotes, preview by import mode

An update file without a prénom or appartenance column matched nobody who
had one, and added everyone again. Prénom and appartenance now narrow the
match only when both the line and the participant carry them. A quote that
opened a field and closed anywhere later swallowed records and could merge
two people; a closing quote not followed by a separator or a record end now
refuses the file with GUILLEMET_OUVERT. The preview takes the import mode,
so in replace mode no line is shown as a duplicate of someone deleted.
Checked: 1519 node tests, the new cases red on the previous code.

--- FR ---

[FIX] csv : rapprochement, guillemet égaré, aperçu selon le mode

Un fichier de mise à jour sans colonne prénom ou appartenance ne retrouvait
personne qui en avait une, et ajoutait tout le monde de nouveau. Prénom et
appartenance ne restreignent désormais le rapprochement que s'ils sont des
deux côtés. Un guillemet qui ouvrait un champ et se fermait plus loin avalait
des lignes et pouvait fondre deux personnes ; un guillemet fermant que ne
suit ni séparateur ni fin d'enregistrement refuse le fichier (GUILLEMET_OUVERT).
L'aperçu prend le mode : en remplacement, aucune ligne n'est un doublon d'une
personne supprimée.
Vérifié : 1519 épreuves node ; les nouveaux cas, rouges sur l'ancien code.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 01:20:50 -04:00
parent 0bc48fa1ae
commit c72215a2da
6 changed files with 561 additions and 105 deletions

View file

@ -22,7 +22,11 @@
// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne, // 6. chaque enregistrement non vide qui suit l'en-tête est une ligne,
// valide ou refusée pour un seul motif ; zéro ligne valide refuse ; // valide ou refusée pour un seul motif ; zéro ligne valide refuse ;
// 7. les appartenances des lignes valides se réconcilient avec celles des // 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent. // existants, et les doublons se relèvent ; en mode remplacer, qui
// retire chaque existant, avec ceux du fichier seuls ;
// 8. en mode mettreAJour, les lignes ajoutées à côté d'un existant de même
// nom se nomment ; dans chaque mode, les cellules qui s'étendent sur
// plusieurs lignes du texte et portent le séparateur.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne // Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1). // revient dans le CSV des refus (§ 10.1).
@ -32,6 +36,13 @@ import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js'; import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js'; import { cleNormalisee } from './normalisation.js';
/**
* Modes de l'import (§ 10.1), que l'aperçu et l'import reçoivent tous deux.
*
* @type {readonly string[]}
*/
export const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']);
/** /**
* Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste * Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste
* fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce * fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce
@ -69,6 +80,9 @@ export const VALEURS_EXCLU = new Map([
* la valeur d'une personne existante qu'un import met à jour * la valeur d'une personne existante qu'un import met à jour
* *
* @typedef {Object} Apercu * @typedef {Object} Apercu
* @property {'ajouter'|'mettreAJour'|'remplacer'} mode le mode pour lequel
* l'aperçu est calculé : appliquerImport n'applique l'aperçu que
* dans ce mode
* @property {string|null} separateur null pour un fichier à une colonne, * @property {string|null} separateur null pour un fichier à une colonne,
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous * et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
* SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc * SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc
@ -84,8 +98,9 @@ export const VALEURS_EXCLU = new Map([
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null * valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
* pour les deux autres codes * pour les deux autres codes
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions * @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises, * chaque groupe d'au moins deux orthographes, existantes comprises
* qui porte une ligne valide, rangé par sa première ligne ; * hors du mode remplacer, qui porte une ligne valide, rangé par sa
* première ligne ;
* orthographes dans l'ordre de rencontre, la première affichée ; * orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour * lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent * une orthographe que seuls des participants existants portent
@ -93,10 +108,30 @@ export const VALEURS_EXCLU = new Map([
* chaque triplet (nom, prénom, appartenance) normalisé que portent au * chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première * moins deux personnes, dont une ligne valide, rangé par sa première
* ligne ; cle : le triplet normalisé, en JSON ; existants : * ligne ; cle : le triplet normalisé, en JSON ; existants :
* identifiants croissants * identifiants croissants, toujours vide en mode remplacer
* @property {Array<{ligne: number, participants: number[]}>} homonymes
* en mode mettreAJour, chaque ligne valide qui ne désigne personne
* (designer) alors que des existants portent son nom normalisé :
* l'import l'ajoute à côté d'eux, une seconde personne de ce nom,
* parce que le prénom ou l'appartenance, présents des deux côtés,
* diffèrent ; participants : ces existants, identifiants
* croissants ; rangée par ligne. Toujours vide dans les autres
* modes : ajouter crée chaque ligne, remplacer retire chaque
* existant
* @property {Array<{ligne: number, rangs: number[]}>} multilignes
* chaque ligne valide dont une cellule au moins s'étend sur
* plusieurs lignes du texte et porte le séparateur, rangée par
* ligne ; rangs : les rangs croissants de ces cellules dans brut.
* Sans séparateur, la fin de ligne seule suffit. C'est la forme
* que laisse un guillemet égaré qu'un autre guillemet referme plus
* loin, devant le séparateur ou une fin de ligne : les
* enregistrements d'entre les deux tiennent dans la cellule, et
* deux personnes se fondent en une. Une note écrite sur plusieurs
* lignes a la même forme, et l'export l'écrit ainsi (§ 10.2) :
* l'aperçu la nomme sans refuser la ligne
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal * @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusions et doublons sont vides, et * sous un refus global, lignes, fusions, doublons, homonymes et
* refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes * multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause * refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de * @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous * l'enregistrement où le guillemet s'ouvre ; sous
@ -105,14 +140,16 @@ export const VALEURS_EXCLU = new Map([
* null sinon * null sinon
*/ */
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le // Aperçu d'un texte refusé en entier : son mode, ce que la lecture a établi
// refus, aucune ligne. // avant le refus, aucune ligne.
function refuser( function refuserEn(
mode,
code, code,
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {}, { separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null, ligne = null,
) { ) {
return { return {
mode,
separateur, separateur,
entetes, entetes,
association, association,
@ -120,6 +157,8 @@ function refuser(
refusees: [], refusees: [],
fusions: [], fusions: [],
doublons: [], doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: code, refusGlobal: code,
refusGlobalLigne: ligne, refusGlobalLigne: ligne,
}; };
@ -237,9 +276,98 @@ function doublonsDe(existants, valides) {
.sort((a, b) => a.lignes[0] - b.lignes[0]); .sort((a, b) => a.lignes[0] - b.lignes[0]);
} }
// Vrai quand la valeur d'une ligne et celle d'un participant laissent ce
// participant parmi les candidats de la ligne : l'une ou l'autre est absente
// — null, ou de clé normalisée vide —, ou leurs clés normalisées sont égales.
function compatibles(deLaLigne, duParticipant) {
const cleLigne = cleNormalisee(deLaLigne ?? '');
const cleParticipant = cleNormalisee(duParticipant ?? '');
return cleLigne === '' || cleParticipant === '' || cleLigne === cleParticipant;
}
/**
* Index des participants par nom normalisé, que lit designer. Chaque entrée
* copie l'identifiant, le prénom et l'appartenance du participant : ce
* qu'une mise à jour pose ensuite sur lui ne change pas l'index, qui garde
* les valeurs d'avant l'import.
*
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} participants
* @returns {Map<string, Array<{id: number, prenom: string|null, appartenance: string|null}>>}
*/
export function indexerParNom(participants) {
const index = new Map();
for (const { id, nom, prenom, appartenance } of participants) {
const cle = cleNormalisee(nom);
if (!index.has(cle)) index.set(cle, []);
index.get(cle).push({ id, prenom, appartenance });
}
return index;
}
/**
* Participants qu'une ligne désigne en mise à jour (§ 10.1), seule règle de
* la désignation, que l'aperçu et l'import lisent tous deux. memeNom : les
* participants de l'index de même nom normalisé que la ligne. candidats :
* ceux d'entre eux que ni le prénom ni l'appartenance n'écartent ; l'un ou
* l'autre n'écarte un participant que lorsque la ligne et lui en portent
* tous deux un, de clés normalisées différentes : une colonne absente ou une
* cellule vide n'écarte personne, comme elle ne vide aucune valeur.
*
* @param {{nom: string, prenom: string|null, appartenance: string|null}} champs
* @param {ReturnType<typeof indexerParNom>} index
* @returns {{memeNom: number[], candidats: number[]}} identifiants croissants
*/
export function designer(champs, index) {
const memeNom = index.get(cleNormalisee(champs.nom)) ?? [];
const croissants = (entrees) => entrees.map(({ id }) => id).sort((a, b) => a - b);
return {
memeNom: croissants(memeNom),
candidats: croissants(
memeNom.filter(
({ prenom, appartenance }) =>
compatibles(champs.prenom, prenom) && compatibles(champs.appartenance, appartenance),
),
),
};
}
// Homonymes d'une mise à jour : chaque ligne valide que designer ne fait
// désigner à personne, alors que des participants portent son nom, avec
// leurs identifiants. L'index est celui des participants d'avant l'import :
// une ligne n'y change rien pour la suivante.
function homonymesDe(participants, lignes) {
const index = indexerParNom(participants);
const homonymes = [];
for (const { ligne, champs } of lignes) {
const { memeNom, candidats } = designer(champs, index);
if (memeNom.length > 0 && candidats.length === 0) homonymes.push({ ligne, participants: memeNom });
}
return homonymes;
}
// Cellules d'une ligne sur plusieurs lignes du texte qui portent le
// séparateur, ou toute fin de ligne sans séparateur : les lignes valides qui
// en ont, avec leurs rangs.
function multilignesDe(lignes, separateur) {
const multilignes = [];
for (const { ligne, brut } of lignes) {
const rangs = [];
brut.forEach((cellule, rang) => {
const surPlusieursLignes = cellule.includes('\n') || cellule.includes('\r');
if (surPlusieursLignes && (separateur === null || cellule.includes(separateur))) rangs.push(rang);
});
if (rangs.length > 0) multilignes.push({ ligne, rangs });
}
return multilignes;
}
/** /**
* Aperçu d'un texte décodé, import ou collage, contre les participants * Aperçu d'un texte décodé, import ou collage, contre les participants
* existants : rien n'est importé. * existants, pour un mode de l'import : rien n'est importé. En mode
* remplacer, chaque existant est retiré avant que les lignes s'ajoutent :
* aucune ligne n'en est le doublon, et aucune orthographe existante ne
* s'affiche ni ne fond avec celles du fichier. Ajouter et mettre à jour
* lisent le fichier contre les existants.
* *
* @param {string} texte le texte que rend decoder, ou celui d'un collage * @param {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options] * @param {Object} [options]
@ -248,12 +376,17 @@ function doublonsDe(existants, valides) {
* ordonne leurs orthographes * ordonne leurs orthographes
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche * @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer) * une ambiguïté de l'en-tête (voir associer)
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par
* défaut
* @returns {Apercu} * @returns {Apercu}
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent * @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder * d'abord par decoder ; pour un mode inconnu
*/ */
export function apercevoir(texte, { participants = [], choix = {} } = {}) { export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajouter' } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue'); if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
// Chaque refus global porte le mode de l'aperçu.
const refuser = (...refus) => refuserEn(mode, ...refus);
if (contientNul(texte)) return refuser('CARACTERE_NUL'); if (contientNul(texte)) return refuser('CARACTERE_NUL');
let separateur; let separateur;
@ -295,7 +428,7 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
} }
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees }; if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = [...participants].sort((a, b) => a.id - b.id); const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides); const { afficheeDe, fusions } = reconcilier(existants, valides);
const lignes = valides.map(({ ligne, champs, brut }) => ({ const lignes = valides.map(({ ligne, champs, brut }) => ({
ligne, ligne,
@ -303,11 +436,14 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
brut, brut,
})); }));
return { return {
mode,
...lu, ...lu,
lignes, lignes,
refusees, refusees,
fusions, fusions,
doublons: doublonsDe(existants, lignes), doublons: doublonsDe(existants, lignes),
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
multilignes: multilignesDe(lignes, separateur),
refusGlobal: null, refusGlobal: null,
refusGlobalLigne: null, refusGlobalLigne: null,
}; };

View file

@ -53,6 +53,7 @@ const refusGlobal = (
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {}, { separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null, ligne = null,
) => ({ ) => ({
mode: 'ajouter',
separateur, separateur,
entetes, entetes,
association, association,
@ -60,6 +61,8 @@ const refusGlobal = (
refusees: [], refusees: [],
fusions: [], fusions: [],
doublons: [], doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: code, refusGlobal: code,
refusGlobalLigne: ligne, refusGlobalLigne: ligne,
}); });
@ -94,6 +97,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
'Pervenche;Théo;;;;oui;', 'Pervenche;Théo;;;;oui;',
); );
assert.deepEqual(apercevoir(texte), { assert.deepEqual(apercevoir(texte), {
mode: 'ajouter',
separateur: ';', separateur: ';',
entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'], entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'],
association: { association: {
@ -119,6 +123,8 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
refusees: [], refusees: [],
fusions: [], fusions: [],
doublons: [], doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: null, refusGlobal: null,
refusGlobalLigne: null, refusGlobalLigne: null,
}); });
@ -565,6 +571,89 @@ describe('apercevoir : les doublons, signalés et jamais fusionnés (§ 10.1)',
}); });
}); });
describe("apercevoir : le mode de l'import (§ 10.1)", () => {
// Deux participants existants, dont l'appartenance de l'un s'écrit
// autrement dans le fichier.
const participants = [existant(1, 'Ombrelle', 'Iris', 'Club des Merles'), existant(2, 'Lacasse', 'Ondine', null)];
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles', 'Lacasse;Ondine;', 'Lacasse;Ondine;');
test("ajouter, par défaut, et mettre à jour lisent le fichier contre les existants : doublons et fusions les comptent", () => {
for (const mode of [undefined, 'ajouter', 'mettreAJour']) {
const apercu = apercevoir(texte, { participants, mode });
assert.equal(apercu.mode, mode ?? 'ajouter');
assert.deepEqual(apercu.doublons, [
{ cle: '["ombrelle","iris","club des merles"]', lignes: [2], existants: [1] },
{ cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [2] },
], String(mode));
assert.deepEqual(apercu.fusions, [
{ affichee: 'Club des Merles', orthographes: [{ texte: 'Club des Merles', lignes: [] }, { texte: 'club des merles', lignes: [2] }] },
], String(mode));
assert.equal(apercu.lignes[0].champs.appartenance, 'Club des Merles', String(mode));
}
});
test("remplacer retire chaque existant : aucune ligne n'en est le doublon, aucune orthographe existante ne s'affiche ni ne fond", () => {
const apercu = apercevoir(texte, { participants, mode: 'remplacer' });
assert.equal(apercu.mode, 'remplacer');
assert.deepEqual(apercu.doublons, [{ cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [] }]);
assert.deepEqual(apercu.fusions, []);
assert.equal(apercu.lignes[0].champs.appartenance, 'club des merles');
assert.deepEqual(apercu, apercevoir(texte, { participants: [], mode: 'remplacer' }));
});
test("mettre à jour nomme chaque ligne qu'il ajoute à côté d'un existant de même nom, que le prénom ou l'appartenance écarte des deux côtés", () => {
// Iris passe du Club des Merles à la Société Alpha : la ligne ne désigne
// personne, et l'import ajouterait une seconde Iris Ombrelle.
const existants = [
existant(1, 'Ombrelle', 'Iris', 'Club des Merles'),
existant(2, 'Ombrelle', 'Théo', null),
existant(3, 'Lacasse', 'Ondine', null),
];
const changeDeClub = csv(
'nom;prenom;appartenance',
// Iris écarte 2, la Société Alpha écarte 1 : un homonyme de 1 et 2.
'OMBRELLE;Iris;Société Alpha',
// Désigne 3, qui n'a pas d'appartenance : aucun homonyme.
'Lacasse;Ondine;Club des Merles',
// Aucun existant de ce nom : un ajout, pas un homonyme.
'Pervenche;Théo;',
// Sans prénom ni appartenance : les deux Ombrelle, un refus de
// l'import, pas un ajout.
'Ombrelle;;',
// Désigne 2, sans prénom ni appartenance de part et d'autre.
'Ombrelle;Théo;Chorale du Givre',
);
assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode: 'mettreAJour' }).homonymes, [
{ ligne: 2, participants: [1, 2] },
]);
// Ajouter et remplacer n'en nomment aucun : ajouter crée chaque ligne,
// remplacer retire chaque existant.
for (const mode of ['ajouter', 'remplacer']) {
assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode }).homonymes, [], mode);
}
// Les lignes qui désignent un existant ne retirent rien aux candidats
// des suivantes ; les identifiants sont croissants, quel que soit
// l'ordre des existants.
const inverse = csv('nom;prenom', 'Ombrelle;Théo', 'Ombrelle;Iris', 'Ombrelle;Anouk');
assert.deepEqual(
apercevoir(inverse, { participants: [existants[1], existants[0]], mode: 'mettreAJour' }).homonymes,
[{ ligne: 4, participants: [1, 2] }],
);
});
test('un aperçu refusé porte son mode', () => {
assert.equal(apercevoir('', { mode: 'remplacer' }).mode, 'remplacer');
assert.equal(apercevoir(csv('prenom', 'Iris'), { mode: 'mettreAJour' }).mode, 'mettreAJour');
});
test('un mode inconnu : TypeError, qui nomme les trois modes', () => {
assert.throws(() => apercevoir(texte, { mode: 'fusionner' }), {
name: 'TypeError',
message: 'mode : ajouter, mettreAJour, remplacer attendu, reçu fusionner',
});
});
});
describe('apercevoir : la réconciliation et la clé de doublon, une seule forme normalisée (§ 10.1)', () => { describe('apercevoir : la réconciliation et la clé de doublon, une seule forme normalisée (§ 10.1)', () => {
test('deux personnes du même nom et du même prénom forment un doublon si et seulement si leurs appartenances se fondent', () => { test('deux personnes du même nom et du même prénom forment un doublon si et seulement si leurs appartenances se fondent', () => {
// Chaque orthographe éprouve une dimension de la clé : casse, accent // Chaque orthographe éprouve une dimension de la clé : casse, accent
@ -764,6 +853,72 @@ describe('apercevoir : les refus globaux (§ 10.1)', () => {
} }
}); });
test("un guillemet égaré au-delà de la fenêtre, refermé plus loin sur une lettre : GUILLEMET_OUVERT au rang où il s'ouvre, aucune personne fondue dans une autre", () => {
// Vingt-cinq enregistrements : la note du rang 23 ouvre un guillemet que
// rien ne referme à sa place ; celui de « "Bob" », au rang 25, le
// referme, suivi d'une lettre. Les rangs 24 et 25 tiendraient sinon dans
// la note du rang 23, et l'aperçu compterait 22 lignes sans un refus.
const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`);
const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;surnommé "Bob" par tous');
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['nom', 'prenom', 'notes'],
association: { colonnes: { nom: 0, prenom: 1, notes: 2 }, ambigus: [], nonReconnues: [] },
},
23,
),
);
// Le même texte dont la note se referme à sa place s'importe en entier.
const referme = texte.replace('"VIP au fond', '"VIP au fond"');
assert.equal(apercevoir(referme).refusGlobal, null);
assert.equal(apercevoir(referme).lignes.length, 24);
});
test("un guillemet égaré refermé plus loin par un pouce, au-delà de la fenêtre : la cellule fondue est nommée, à son rang", () => {
// Le guillemet de « écran 27" » referme la note ouverte au rang 23 :
// les rangs 24 et 25 tiennent dans cette note, que rien ne refuse.
// L'aperçu nomme la cellule, qui s'étend sur plusieurs lignes du texte
// et porte le séparateur.
const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`);
const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;écran 27"', 'Nom25;Pre25;');
const apercu = apercevoir(texte);
assert.equal(apercu.refusGlobal, null);
assert.equal(apercu.lignes.length, 23);
assert.equal(apercu.lignes[21].champs.notes, 'VIP au fond\r\nNom23;Pre23;\r\nNom24;Pre24;écran 27');
assert.deepEqual(apercu.multilignes, [{ ligne: 23, rangs: [2] }]);
});
test("une cellule nommée : sur plusieurs lignes du texte et portant le séparateur, dans la fenêtre comme au-delà ; un nom sur plusieurs lignes d'un fichier à une colonne", () => {
// Dans la fenêtre, le guillemet égaré du rang 3 se referme au rang 4 :
// l'enregistrement fondu a la largeur de l'en-tête, et le séparateur
// est retenu.
const fondu = csv('nom;prenom;notes', 'Ombrelle;Iris;', 'Lacasse;Ondine;"à l\'entrée', 'Pervenche;Théo;écran 27"', 'Givre;Silas;');
assert.deepEqual(apercevoir(fondu).multilignes, [{ ligne: 3, rangs: [2] }]);
// Une note sur plusieurs lignes sans le séparateur, ou le séparateur
// sur une seule ligne, n'est pas nommée ; une cellule qui porte les
// deux l'est, une fois par cellule, quel que soit le séparateur.
const notes = csv(
'nom,prenom,appartenance,notes',
'Ombrelle,Iris,"Club, nord","arrive tôt\nrepart tard"',
'Lacasse,Ondine,"Chorale\ndu Givre","noix, arachides\r\nsans gluten"',
'Pervenche,Théo,,"au fond\rprès de la scène, côté jardin"',
);
assert.deepEqual(apercevoir(notes).multilignes, [{ ligne: 3, rangs: [3] }, { ligne: 4, rangs: [3] }]);
// Fichier à une colonne : un nom ne s'étend jamais sur deux lignes, et
// aucun séparateur n'est à chercher.
assert.deepEqual(apercevoir(csv('nom', 'Ombrelle', '"Lacasse', 'Pervenche"', 'Givre')).multilignes, [
{ ligne: 3, rangs: [0] },
]);
// Une ligne refusée revient dans le CSV des refus : elle n'est pas
// nommée ici.
const refusee = csv('nom;notes;exclu', 'Ombrelle;"a\nb;c";peut-être', 'Lacasse;;');
assert.deepEqual(apercevoir(refusee).multilignes, []);
});
test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => { test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => {
for (const rang of [2, 21]) { for (const rang of [2, 21]) {
const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x'); const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x');

View file

@ -21,12 +21,10 @@
// champs en trop parmi les vingt premiers enregistrements, qui fait refuser // champs en trop parmi les vingt premiers enregistrements, qui fait refuser
// le texte entier, à son rang, par le choix du séparateur. // le texte entier, à son rang, par le choix du séparateur.
import { MODES, designer, indexerParNom } from './apercu.js';
import { ErreurCsv } from './erreurs.js'; import { ErreurCsv } from './erreurs.js';
import { SEPARATEUR, ecrireCsv } from './export.js'; import { SEPARATEUR, ecrireCsv } from './export.js';
import { enregistrementVide } from './lecture.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 // 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 // porte une personne créée, et que pose une mise à jour quand la cellule
@ -67,14 +65,8 @@ const ENTETE_MOTIF = 'motif';
* @property {boolean} derive vrai en état retenu * @property {boolean} derive vrai en état retenu
*/ */
const croissant = (a, b) => a - b;
const parLigne = (a, b) => a.ligne - b.ligne; 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 // 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 // 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éservation d'une personne exclue est suspendue. La place se lit selon le
@ -164,56 +156,44 @@ function poser(participant, champs) {
} }
// Met à jour, en place, les participants que désignent les lignes, et rend // 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 // les lignes à ajouter, les refus et les comptes. Chaque ligne désigne par
// participants d'avant l'import dont le nom et le prénom ont la même clé // designer, contre l'index des participants d'avant l'import : un
// normalisée que les siens, et l'appartenance aussi quand la ligne en porte // participant ajouté par une ligne n'est désigné par aucune autre, et le
// une : un participant ajouté par une ligne n'est désigné par aucune autre. // prénom ou l'appartenance qu'une ligne pose n'écarte pas ce participant des
// Un prénom absent compte comme vide, sur la ligne comme chez le // candidats d'une suivante. Une ligne qui ne désigne personne est à ajouter.
// participant : seule une ligne sans prénom désigne un participant sans // Une ligne qui en désigne plusieurs est refusée PLUSIEURS_CORRESPONDENT,
// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne // jamais ajoutée. Le participant qu'une ligne désigne seule est mis à jour
// filtre même un homonyme unique, et un participant sans appartenance ne // par la première de ces lignes, dans l'ordre du fichier ; chaque suivante
// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter, // est refusée DEJA_DESIGNE : deux lignes ne se fondent pas en silence dans
// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est // une même personne.
// 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) { function mettreAJour(participants, lignes) {
const parNom = new Map(); const index = indexerParNom(participants);
for (const participant of participants) { const parId = new Map(participants.map((participant) => [participant.id, participant]));
const cle = cleDeNom(participant.nom, participant.prenom);
if (!parNom.has(cle)) parNom.set(cle, []);
parNom.get(cle).push(participant);
}
const designes = new Map(); const designes = new Map();
const nouvelles = []; const nouvelles = [];
const refusees = []; const refusees = [];
let misAJour = 0; let misAJour = 0;
let inchanges = 0; let inchanges = 0;
for (const { ligne, champs, brut } of lignes) { for (const { ligne, champs, brut } of lignes) {
const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance); const { candidats } = designer(champs, index);
const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter(
(participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance,
);
if (candidats.length === 0) { if (candidats.length === 0) {
nouvelles.push({ champs }); nouvelles.push({ champs });
continue; continue;
} }
if (candidats.length > 1) { if (candidats.length > 1) {
const ids = candidats.map(({ id }) => id).sort(croissant); refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: candidats, brut: [...brut] });
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] });
continue; continue;
} }
const [designe] = candidats; const [id] = candidats;
if (designes.has(designe.id)) { if (designes.has(id)) {
refusees.push({ refusees.push({
ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [designe.id], premiereLigne: designes.get(designe.id), ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [id], premiereLigne: designes.get(id),
brut: [...brut], brut: [...brut],
}); });
continue; continue;
} }
designes.set(designe.id, ligne); designes.set(id, ligne);
if (poser(designe, champs)) misAJour += 1; if (poser(parId.get(id), champs)) misAJour += 1;
else inchanges += 1; else inchanges += 1;
} }
return { nouvelles, refusees, misAJour, inchanges }; return { nouvelles, refusees, misAJour, inchanges };
@ -223,16 +203,17 @@ function mettreAJour(participants, lignes) {
* Applique un aperçu à une charge (§ 10.1), selon le mode : * Applique un aperçu à une charge (§ 10.1), selon le mode :
* - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se * - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se
* fusionne jamais ; * fusionne jamais ;
* - 'mettreAJour' : une ligne désigne les participants d'avant l'import de * - 'mettreAJour' : une ligne désigne, par designer, les participants
* même nom et même prénom normalisés, et de même appartenance quand la * d'avant l'import de même nom normalisé ; le prénom et l'appartenance
* ligne en porte une : un prénom absent compte comme vide, sur la ligne * n'en écartent un que lorsque la ligne et lui en portent tous deux un,
* comme chez le participant, et un participant sans appartenance n'est * de clés normalisées différentes, selon leurs valeurs d'avant l'import.
* désigné que par une ligne qui n'en porte pas. Désigné seul, le * Une ligne ajoutée à côté d'un participant de même nom est celle que
* participant prend chaque champ non vide de la ligne, nom et prénom * l'aperçu nomme dans homonymes. Désigné seul, le participant prend chaque champ non vide de
* compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une * la ligne, nom, prénom et appartenance compris, et garde la valeur d'un
* ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs, * champ vide ; exclu vide le garde. Une ligne qui ne désigne personne est
* refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une * ajoutée ; qui en désigne plusieurs, refusée PLUSIEURS_CORRESPONDENT ; qui
* ligne précédente désigne déjà, refusée DEJA_DESIGNE ; * 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, * - 'remplacer' : les participants et leurs réservations sont retirés,
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu * puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
* restent (bilanRemplacement). * restent (bilanRemplacement).
@ -251,19 +232,22 @@ function mettreAJour(participants, lignes) {
* *
* @param {import('../stockage/types.js').Charge} charge * @param {import('../stockage/types.js').Charge} charge
* @param {import('./apercu.js').Apercu} apercu calculé contre les * @param {import('./apercu.js').Apercu} apercu calculé contre les
* participants de la charge : l'appartenance d'une ligne y prend * participants de la charge, pour ce mode : hors de remplacer,
* l'orthographe d'un existant de même clé, que remplacer garde même quand * l'appartenance d'une ligne y prend l'orthographe d'un existant de même
* il retire cet existant * clé ; en remplacer, celle du fichier
* @param {'ajouter'|'mettreAJour'|'remplacer'} mode * @param {'ajouter'|'mettreAJour'|'remplacer'} mode
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}} * @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}}
* @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état * @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 * 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 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 * que l'en-tête laisse ambigus, tant que le choix ne les tranche pas
* @throws {TypeError} pour un mode inconnu * @throws {TypeError} pour un mode inconnu, et pour un aperçu calculé pour un
* autre mode : l'aperçu montré à l'opérateur est celui que l'import
* applique
*/ */
export function appliquerImport(charge, apercu, mode) { export function appliquerImport(charge, apercu, mode) {
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`); if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
if (apercu.mode !== mode) throw new TypeError(`aperçu calculé pour ${String(apercu.mode)}, import en ${mode}`);
if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE'); if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE');
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne }); if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
const { ambigus } = apercu.association; const { ambigus } = apercu.association;

View file

@ -130,9 +130,10 @@ function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants,
return { proposition: 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. // Aperçu d'un texte contre les participants de la charge, dans le mode
// donné, puis import dans ce même mode.
function importer(charge, texte, mode, choix = {}) { function importer(charge, texte, mode, choix = {}) {
return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix }), mode); return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix, mode }), mode);
} }
// Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et // Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et
@ -304,11 +305,12 @@ describe('appliquerImport : mettre à jour (§ 10.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", () => { 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 existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })];
const { charge: apres, resume } = importer( const texte = csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test');
chargeDe({ participants: existants }), // L'aperçu nomme la ligne qui s'ajoute à côté de l'homonyme.
csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'), assert.deepEqual(apercevoir(texte, { participants: existants, mode: 'mettreAJour' }).homonymes, [
'mettreAJour', { ligne: 2, participants: [1] },
); ]);
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), texte, 'mettreAJour');
assert.deepEqual(apres.participants, [ assert.deepEqual(apres.participants, [
existants[0], existants[0],
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }), personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }),
@ -316,32 +318,91 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); 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', () => { test('une ligne sans prénom, ou un fichier sans colonne prénom, désigne le seul participant de ce nom, qui en porte un : il est mis à jour, rien ajouté', () => {
// La clé est (nom, prénom), un prénom absent y comptant comme vide : une // Un fichier qui ne porte que les noms et les courriels complète les
// liste de noms sans prénoms ajoute des personnes au lieu de mettre à // inscrits ; il ne les double pas.
// jour les inscrits de même nom. const existants = [
const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')]; personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour'); personne(2, 'Lacasse', 'Ondine', 'Société Alpha'),
assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]); personne(3, 'Pervenche', 'Théo'),
assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); ];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv('nom;courriel', 'Ombrelle;iris@exemple.test', 'Lacasse;ondine@exemple.test'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [
{ ...existants[0], courriel: 'iris@exemple.test' },
{ ...existants[1], courriel: 'ondine@exemple.test' },
existants[2],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2 }));
const vide = importer(chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Ombrelle;;oui'), 'mettreAJour');
assert.deepEqual(vide.charge.participants, [{ ...existants[0], exclu: true }, existants[1], existants[2]]);
assert.deepEqual(vide.resume, resumeDe({ misAJour: 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', () => { test('une ligne qui porte un prénom désigne le seul participant de ce nom sans prénom : il prend le prénom', () => {
const existants = [personne(1, 'Grisaille', null, 'Club des Merles')]; const existants = [personne(1, 'Grisaille', null, 'Club des Merles')];
const { charge: apres, resume } = importer( const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour', 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(apres.participants, [personne(1, 'Grisaille', 'Silas', 'Club des Merles', { exclu: true })]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); assert.deepEqual(resume, resumeDe({ misAJour: 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", () => { test("une ligne qui porte une appartenance désigne le participant de ce nom et de ce prénom qui n'en a pas : il la prend, rien n'est ajouté", () => {
const existants = [personne(1, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })]; const existants = [
personne(1, 'Ombrelle', 'Iris'),
personne(2, 'Lacasse', 'Ondine', null, { courriel: 'ondine@exemple.test' }),
];
const { charge: apres, resume } = importer( const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;appartenance', 'Pervenche;Théo;Club des Merles'), 'mettreAJour', chargeDe({ participants: existants }),
csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles', 'Lacasse;Ondine;Société Alpha'),
'mettreAJour',
); );
assert.deepEqual(apres.participants, [...existants, personne(2, 'Pervenche', 'Théo', 'Club des Merles')]); assert.deepEqual(apres.participants, [
assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Lacasse', 'Ondine', 'Société Alpha', { courriel: 'ondine@exemple.test' }),
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2 }));
});
test("le prénom et l'appartenance ne départagent que lorsque la ligne et le participant en portent tous deux un : plusieurs homonymes restants sont refusés, jamais un ajout", () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Ombrelle', 'Ondine', null),
personne(3, 'Ombrelle', null, 'Chorale du Givre'),
personne(4, 'Givre', 'Silas', 'Club des Merles'),
personne(5, 'Givre', 'Anouk', 'Club des Merles'),
];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv(
'nom;prenom;appartenance;courriel',
// Iris : 1 par le prénom, 3 sans prénom ; Chorale du Givre écarte 1.
'Ombrelle;Iris;Chorale du Givre;a@exemple.test',
// Sans prénom ni appartenance : les trois Ombrelle.
'OMBRELLE;;;b@exemple.test',
// Club des Merles écarte 3 ; Iris écarte 2 : reste 1.
'Ombrelle;Iris;Club des Merles;c@exemple.test',
// Sans prénom : deux Givre du même club.
'Givre;;club des merles;d@exemple.test',
),
'mettreAJour',
);
assert.deepEqual(resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [
[3, 'PLUSIEURS_CORRESPONDENT', [1, 2, 3]],
[5, 'PLUSIEURS_CORRESPONDENT', [4, 5]],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2, refusees: resume.refusees }));
assert.deepEqual(apres.participants, [
{ ...existants[0], courriel: 'c@exemple.test' },
existants[1],
{ ...existants[2], prenom: 'Iris', courriel: 'a@exemple.test' },
existants[3],
existants[4],
]);
}); });
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", () => { 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", () => {
@ -385,6 +446,33 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
})); }));
}); });
test("une ligne désigne parmi les valeurs d'avant l'import : ce qu'une ligne précédente pose ne l'écarte pas, et elle est refusée DEJA_DESIGNE, dans les deux ordres", () => {
// Ombrelle n'a ni prénom ni appartenance : chaque ligne la désigne, quel
// que soit le prénom ou l'appartenance que la précédente lui a donné.
const existants = [personne(1, 'Ombrelle')];
const cas = [
['nom;prenom', ['Ombrelle;Iris', 'Ombrelle;Théo']],
['nom;prenom', ['Ombrelle;Théo', 'Ombrelle;Iris']],
['nom;appartenance', ['Ombrelle;Club des Merles', 'Ombrelle;Chorale du Givre']],
];
for (const [entete, [premiere, seconde]] of cas) {
const texte = csv(entete, premiere, seconde);
const apercu = apercevoir(texte, { participants: existants, mode: 'mettreAJour' });
const { charge: apres, resume } = appliquerImport(chargeDe({ participants: existants }), apercu, 'mettreAJour');
const [champ, valeur] = [entete.split(';')[1], premiere.split(';')[1]];
assert.deepEqual(apres.participants, [{ ...existants[0], [champ]: valeur }], premiere);
assert.deepEqual(resume, resumeDe({
misAJour: 1,
refusees: [{
ligne: 3, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 2, brut: seconde.split(';'),
}],
}), premiere);
// L'aperçu, qui lit lui aussi les valeurs d'avant l'import, ne nomme
// aucun homonyme.
assert.deepEqual(apercu.homonymes, [], premiere);
}
});
test('une mise à jour dont toutes les lignes sont refusées rend une charge égale à celle reçue ; les identifiants refusés, croissants', () => { 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({ const charge = chargeDe({
participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')],
@ -498,6 +586,7 @@ describe('appliquerImport : la charge rendue', () => {
const charge = chargeGarnie(); const charge = chargeGarnie();
const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), { const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), {
participants: charge.participants, participants: charge.participants,
mode,
}); });
const avant = structuredClone({ charge, apercu }); const avant = structuredClone({ charge, apercu });
const resultat = appliquerImport(charge, apercu, mode); const resultat = appliquerImport(charge, apercu, mode);
@ -583,6 +672,29 @@ describe('appliquerImport : les refus globaux (§ 10.1)', () => {
); );
}); });
test("un aperçu calculé pour un autre mode lève TypeError : l'aperçu montré est celui que l'import applique", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')] });
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles');
for (const vu of MODES) {
const apercu = apercevoir(texte, { participants: charge.participants, mode: vu });
for (const mode of MODES.filter((autre) => autre !== vu)) {
assert.throws(() => appliquerImport(charge, apercu, mode), {
name: 'TypeError',
message: `aperçu calculé pour ${vu}, import en ${mode}`,
});
}
}
});
test("remplacer pose l'orthographe du fichier, non celle d'un participant retiré : l'aperçu et l'import s'accordent", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], prochain: 2 });
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles');
const apercu = apercevoir(texte, { participants: charge.participants, mode: 'remplacer' });
assert.deepEqual(apercu.doublons, []);
const { charge: apres } = appliquerImport(charge, apercu, 'remplacer');
assert.deepEqual(apres.participants, [personne(2, 'Ombrelle', 'Iris', 'club des merles')]);
});
test('un mode inconnu lève TypeError', () => { test('un mode inconnu lève TypeError', () => {
const apercu = apercevoir(csv('nom', 'Ombrelle')); const apercu = apercevoir(csv('nom', 'Ombrelle'));
for (const mode of ['fusionner', 'Ajouter', undefined, null]) { for (const mode of ['fusionner', 'Ajouter', undefined, null]) {
@ -687,7 +799,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => {
personne(3, 'Pervenche'), personne(3, 'Pervenche'),
], ],
}); });
const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants }); const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants, mode: 'mettreAJour' });
assert.equal(apercu.separateur, null); assert.equal(apercu.separateur, null);
const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees)); const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees));
@ -790,7 +902,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => {
'pervenche;theo;autre@exemple.test;', 'pervenche;theo;autre@exemple.test;',
'Sarcelle;Ondine;;peut-être', 'Sarcelle;Ondine;;peut-être',
), ),
{ participants: charge.participants }, { participants: charge.participants, mode: 'mettreAJour' },
); );
const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [ assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [

View file

@ -18,6 +18,11 @@ const CANDIDATS = [';', ',', '\t'];
// Nombre d'enregistrements que le choix du séparateur lit. // Nombre d'enregistrements que le choix du séparateur lit.
const FENETRE = 20; const FENETRE = 20;
// Vrai quand le caractère qui suit un guillemet fermant le laisse refermer
// le champ cité : le séparateur, une fin de ligne, ou la fin du texte.
const fermeLeChamp = (suivant, separateur) =>
suivant === undefined || suivant === separateur || suivant === '\r' || suivant === '\n';
// Parcourt texte en un seul passage et rend ses enregistrements, avec l'état // Parcourt texte en un seul passage et rend ses enregistrements, avec l'état
// des guillemets à la fin de la lecture. La lecture s'arrête après limite // des guillemets à la fin de la lecture. La lecture s'arrête après limite
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture // enregistrements terminés ; guillemetOuvert est alors faux, la lecture
@ -27,8 +32,14 @@ const FENETRE = 20;
// - un guillemet ouvre un champ cité quand il en est le premier caractère ; // - un guillemet ouvre un champ cité quand il en est le premier caractère ;
// ailleurs, c'est un caractère comme un autre ; // ailleurs, c'est un caractère comme un autre ;
// - dans un champ cité, le séparateur et les fins de ligne font partie du // - dans un champ cité, le séparateur et les fins de ligne font partie du
// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui // texte, deux guillemets font un guillemet, un seul referme le champ quand
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ; // le séparateur, une fin de ligne ou la fin du texte le suit ;
// - un guillemet seul suivi de tout autre caractère ne referme pas le champ :
// le guillemet qui l'a ouvert est égaré, et tout ce qu'il a lu depuis
// tiendrait dans un seul champ, jusqu'à fondre plusieurs enregistrements
// en un. La lecture s'arrête là, guillemetOuvert vrai : le dernier
// enregistrement rendu est celui où le champ s'ouvre, et porte le texte lu
// jusqu'à ce guillemet ;
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule // - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
// fin ; // fin ;
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide // - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
@ -50,8 +61,12 @@ function analyser(texte, separateur, limite) {
} else if (texte[i + 1] === GUILLEMET) { } else if (texte[i + 1] === GUILLEMET) {
champ += GUILLEMET; champ += GUILLEMET;
i += 1; i += 1;
} else { } else if (fermeLeChamp(texte[i + 1], separateur)) {
cite = false; cite = false;
} else {
enregistrement.push(champ);
enregistrements.push(enregistrement);
return { enregistrements, guillemetOuvert: true };
} }
continue; continue;
} }
@ -88,9 +103,10 @@ function analyser(texte, separateur, limite) {
/** /**
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités, * Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
* guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de * guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de
* ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ * ligne CRLF, LF ou CR. Un champ cité se referme sur un guillemet que suivent
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas * le séparateur, une fin de ligne ou la fin du texte. Une ligne vide est un
* d'enregistrement. Aucun blanc n'est retiré des champs. * enregistrement d'un seul champ vide ; la ligne vide qui suit la dernière fin
* de ligne ne fait pas d'enregistrement. Aucun blanc n'est retiré des champs.
* *
* Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris : * Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris :
* le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs * le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs
@ -101,8 +117,10 @@ function analyser(texte, separateur, limite) {
* @param {string|null} separateur un caractère, ou null pour un fichier à une * @param {string|null} separateur un caractère, ou null pour un fichier à une
* colonne, dont chaque ligne est un seul champ * colonne, dont chaque ligne est un seul champ
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }} * @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le * guillemetOuvert est vrai quand le texte s'achève dans un champ cité, ou
* dernier enregistrement porte alors le texte lu jusque-là. * qu'un guillemet seul y est suivi d'un autre caractère que le séparateur
* ou une fin de ligne : la découpe s'arrête alors, et le dernier
* enregistrement, celui où le champ s'ouvre, porte le texte lu jusque-là.
*/ */
export function decouper(texte, separateur) { export function decouper(texte, separateur) {
return analyser(texte, separateur, Infinity); return analyser(texte, separateur, Infinity);
@ -122,8 +140,9 @@ export function enregistrementVide(enregistrement) {
// Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est // Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est
// le premier enregistrement non vide parmi ceux que la fenêtre termine : un // le premier enregistrement non vide parmi ceux que la fenêtre termine : un
// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement // guillemet resté ouvert, ou refermé sur un autre caractère que le
// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête // séparateur ou une fin de ligne, laisse inachevé l'enregistrement où il
// s'ouvre, le dernier lu. Rend null quand l'en-tête
// manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat // manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat
// n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le // n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le
// premier défaut qui écarte le candidat, ou null : un enregistrement non vide // premier défaut qui écarte le candidat, ou null : un enregistrement non vide

View file

@ -183,9 +183,46 @@ describe('decouper : enregistrements et champs (§ 10.1)', () => {
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]); assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
}); });
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => { test("un guillemet fermant suivi d'autre chose qu'un séparateur, une fin d'enregistrement ou un second guillemet : la lecture s'arrête, guillemetOuvert", () => {
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]); // Le champ cité est refusé là où son guillemet fermant est suivi d'un
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]); // autre caractère ; le dernier enregistrement rendu est celui où le champ
// s'ouvre, et porte le texte lu jusqu'à ce guillemet.
assert.deepEqual(decouper('"Benoît" ;x', ';'), { enregistrements: [['Benoît']], guillemetOuvert: true });
assert.deepEqual(decouper('"a"b;c', ';'), { enregistrements: [['a']], guillemetOuvert: true });
assert.deepEqual(decouper('nom;notes\nA;"a ""b"" c"x\nB;y\n', ';'), {
enregistrements: [['nom', 'notes'], ['A', 'a "b" c']],
guillemetOuvert: true,
});
// Sous un séparateur null, seule une fin d'enregistrement suit le
// guillemet fermant.
assert.deepEqual(decouper('nom\n"Roy, Camille";x\n', null), {
enregistrements: [['nom'], ['Roy, Camille']],
guillemetOuvert: true,
});
});
test("un guillemet égaré qui avale des enregistrements : le champ se referme plus loin sur un autre caractère, la lecture s'arrête au rang où il s'ouvre", () => {
// Le guillemet qui ouvre la note de A n'est jamais refermé à sa place :
// celui de « "Bob" » le referme, suivi d'une lettre. Les enregistrements
// de B et de C ne se fondent pas dans la note de A.
const texte = 'nom;prenom;notes\r\nA;Pa;"VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé "Bob" par tous\r\nD;Pd;\r\n';
assert.deepEqual(decouper(texte, ';'), {
enregistrements: [
['nom', 'prenom', 'notes'],
['A', 'Pa', 'VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé '],
],
guillemetOuvert: true,
});
});
test('un guillemet fermant suivi du séparateur, de CR, de LF, de CRLF ou de la fin du texte referme le champ', () => {
assert.deepEqual(enregistrementsDe('"a";b'), [['a', 'b']]);
assert.deepEqual(enregistrementsDe('"a"\rb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('"a"\nb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('"a"\r\nb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('x;"a"'), [['x', 'a']]);
assert.deepEqual(enregistrementsDe('x\t"a"\ty', '\t'), [['x', 'a', 'y']]);
assert.deepEqual(enregistrementsDe('"a"\nb', null), [['a'], ['b']]);
}); });
test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => { test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => {
@ -397,6 +434,19 @@ describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2)); assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2));
}); });
test("un guillemet fermant suivi d'un autre caractère dans la fenêtre écarte le candidat, au rang où le champ s'ouvre", () => {
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;Exemple\nOdile;"Fictive"x\nRémi;Témoin\n', reconnaitre),
ouvert(';', 3),
);
// Le champ s'ouvre au rang 2 et avale les rangs 3 et 4 avant de se
// refermer sur une lettre : le rang nommé est celui où il s'ouvre.
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;"Exemple\nOdile;Fictive\nRémi;dit "R" ici\n', reconnaitre),
ouvert(';', 2),
);
});
test('un texte vide, ou fait de lignes vides', () => { test('un texte vide, ou fait de lignes vides', () => {
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre)); assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre)); assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));