gestion_table_tournante_libre/src/csv/colonnes.test.js
Mathieu Benoit 35cc5f5f75 [ADD] csv: columns by header, closed exclu values, mandatory preview
Columns associate by header, never by position, insensitive to case,
accents and spaces, with the § 10.1 synonyms; two columns for one field
associate neither and ask. The preview imports nothing: it lists valid
rows, refused rows with their reason, each group of merged spellings
with its counts, duplicates never merged, and global refusals, an open
quote or a NUL included. A paste goes through the same parser, and the
separator rule lives once, in lecture.js, whose refusal names its line.

Checked: 185 CSV tests, eight spreadsheet-shaped fixtures; two reviews.

--- FR ---

[ADD] csv : colonnes par en-tête, valeurs d'exclu fermées, aperçu

Les colonnes s'associent par en-tête, jamais par position, sans égard à
la casse, aux accents ni aux espaces, avec les synonymes du § 10.1 ;
deux colonnes d'un même champ ne s'associent pas, et le logiciel
demande. L'aperçu n'importe rien : lignes valides, lignes refusées et
leur motif, chaque groupe d'orthographes fondues et ses comptes,
doublons jamais fondus, refus globaux, guillemet ouvert et nul compris.
Le collage passe par le même analyseur, et la règle du séparateur ne
s'écrit qu'une fois, dans lecture.js, dont le refus nomme sa ligne.

Vérifié : 185 épreuves CSV, huit données de tableur ; deux revues.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:28 -04:00

180 lines
7.1 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'association des colonnes (§ 10.1) : les champs du CSV, la
// reconnaissance d'un en-tête et de ses synonymes, et l'association par
// en-tête, jamais par position, qui laisse sans colonne un champ que deux
// colonnes revendiquent tant qu'un choix ne tranche pas.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CHAMPS, associer, reconnaitre } from './colonnes.js';
describe('CHAMPS', () => {
test('les sept champs du § 10.1, dans leur ordre, figés', () => {
assert.deepEqual(CHAMPS, ['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
assert.ok(Object.isFrozen(CHAMPS));
});
});
describe('reconnaitre (§ 10.1)', () => {
test('chaque champ par son propre nom', () => {
assert.ok(CHAMPS.length > 0, 'aucun champ');
for (const champ of CHAMPS) assert.equal(reconnaitre(champ), champ);
});
test('les synonymes : organisation, entreprise et équipe ; titre et rôle', () => {
assert.deepEqual(
['organisation', 'entreprise', 'équipe', 'titre', 'rôle'].map((entete) => reconnaitre(entete)),
['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti'],
);
});
test('sans égard à la casse, aux accents ni aux espaces', () => {
const cas = [
['NOM', 'nom'],
[' Prénom ', 'prenom'],
['Équipe', 'appartenance'],
['titre', 'titre_pressenti'],
['Rôle', 'titre_pressenti'],
['E X C L U', 'exclu'],
['Titre_Pressenti', 'titre_pressenti'],
['titre pressenti', 'titre_pressenti'],
['COURRIEL', 'courriel'],
['Notes ', 'notes'],
['ORGANISATION', 'appartenance'],
['Entre-prise', 'appartenance'],
['Equipe', 'appartenance'],
];
assert.deepEqual(
cas.map(([entete]) => reconnaitre(entete)),
cas.map(([, champ]) => champ),
);
});
test('un en-tête inconnu rend null, dont « ligne » et « motif » du CSV des refus', () => {
const inconnus = [
'ligne', 'motif', '', ' ', 'nom de famille', 'société', 'prenoms', 'courriels',
'organisations', 'titres', 'appartenances', 'exclus', 'note', 'titre.pressenti',
];
assert.deepEqual(
inconnus.map((entete) => reconnaitre(entete)),
inconnus.map(() => null),
);
});
});
describe('associer : par en-tête, jamais par position (§ 10.1)', () => {
test('chaque champ reconnu reçoit le rang de sa colonne, à partir de 0', () => {
assert.deepEqual(associer(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']), {
colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6 },
ambigus: [],
nonReconnues: [],
});
});
test('des colonnes déplacées donnent à chaque champ la même valeur', () => {
const entetes = ['nom', 'prenom', 'appartenance', 'courriel'];
const enregistrement = ['Ombrelle', 'Iris', 'Club des Merles', 'iris@exemple.test'];
// La colonne i du fichier déplacé est la colonne deplacement[i] de l'autre.
const deplacement = [3, 2, 0, 1];
const entetesDeplaces = deplacement.map((rang) => entetes[rang]);
const enregistrementDeplace = deplacement.map((rang) => enregistrement[rang]);
// [champ, valeur] de chaque champ associé, dans l'ordre de CHAMPS.
const lire = ({ colonnes }, champs) =>
CHAMPS.filter((champ) => colonnes[champ] !== undefined).map((champ) => [champ, champs[colonnes[champ]]]);
const association = associer(entetes);
const associationDeplacee = associer(entetesDeplaces);
assert.deepEqual(associationDeplacee.colonnes, { nom: 2, prenom: 3, appartenance: 1, courriel: 0 });
assert.deepEqual(lire(associationDeplacee, enregistrementDeplace), lire(association, enregistrement));
assert.deepEqual(lire(association, enregistrement), [
['nom', 'Ombrelle'],
['prenom', 'Iris'],
['appartenance', 'Club des Merles'],
['courriel', 'iris@exemple.test'],
]);
});
test("colonnes suit l'ordre de CHAMPS, quel que soit celui du fichier", () => {
assert.equal(
JSON.stringify(associer(['notes', 'exclu', 'courriel', 'nom']).colonnes),
'{"nom":3,"courriel":2,"exclu":1,"notes":0}',
);
});
test("organisation et entreprise ensemble : ni l'une ni l'autre, une ambiguïté sur appartenance", () => {
assert.deepEqual(associer(['nom', 'organisation', 'entreprise']), {
colonnes: { nom: 0 },
ambigus: [{ champ: 'appartenance', rangs: [1, 2] }],
nonReconnues: [],
});
});
test("le choix tranche l'ambiguïté, pour l'une comme pour l'autre colonne", () => {
const entetes = ['nom', 'organisation', 'entreprise'];
assert.deepEqual(associer(entetes, { appartenance: 2 }), {
colonnes: { nom: 0, appartenance: 2 },
ambigus: [],
nonReconnues: [],
});
assert.deepEqual(associer(entetes, { appartenance: 1 }).colonnes, { nom: 0, appartenance: 1 });
});
test('des colonnes déplacées donnent la même ambiguïté, aux rangs déplacés', () => {
const entetes = ['entreprise', 'nom', 'organisation'];
assert.deepEqual(associer(entetes), {
colonnes: { nom: 1 },
ambigus: [{ champ: 'appartenance', rangs: [0, 2] }],
nonReconnues: [],
});
assert.deepEqual(associer(entetes, { appartenance: 0 }).colonnes, { nom: 1, appartenance: 0 });
});
test("un choix qui ne désigne pas l'une des colonnes en conflit est sans effet", () => {
const entetes = ['nom', 'organisation', 'entreprise', 'inconnue'];
const sansChoix = associer(entetes);
assert.deepEqual(sansChoix.ambigus, [{ champ: 'appartenance', rangs: [1, 2] }]);
const choix = [
{ appartenance: 0 },
{ appartenance: 3 },
{ appartenance: 7 },
{ appartenance: '2' },
{ appartenance: null },
{ nom: 1 },
{ prenom: 3 },
{ inconnu: 1 },
];
for (const un of choix) assert.deepEqual(associer(entetes, un), sansChoix, JSON.stringify(un));
});
test("deux colonnes nom : ni l'une ni l'autre", () => {
assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), {
colonnes: { prenom: 1 },
ambigus: [{ champ: 'nom', rangs: [0, 2] }],
nonReconnues: [],
});
});
test("plusieurs conflits : les ambiguïtés dans l'ordre de CHAMPS, les rangs croissants", () => {
assert.deepEqual(associer(['titre', 'équipe', 'nom', 'rôle', 'organisation', 'entreprise']), {
colonnes: { nom: 2 },
ambigus: [
{ champ: 'appartenance', rangs: [1, 4, 5] },
{ champ: 'titre_pressenti', rangs: [0, 3] },
],
nonReconnues: [],
});
});
test('les colonnes non reconnues, en-tête vide compris, par rang croissant', () => {
assert.deepEqual(associer(['x', 'nom', '', 'ligne', ' ']), {
colonnes: { nom: 1 },
ambigus: [],
nonReconnues: [0, 2, 3, 4],
});
});
test('aucune colonne : rien à associer', () => {
assert.deepEqual(associer([]), { colonnes: {}, ambigus: [], nonReconnues: [] });
});
});