[ADD] import: downloadable CSV templates for participants and goals
An operator had to guess the expected columns before a first import. The Import section now saves a template for the chosen kind, in the export format, and it imports unchanged with no refused line. The goals template follows the event: one row per table, per round where goals are per round, two examples on free rows, and only blank rows for a table whose number is shared, since the importer refuses that number. A goals row with neither goal nor rule is now a row to fill, skipped instead of refused. Checked: node 5173, browser 1160, node-long 129; four Import captures republished. --- FR --- [ADD] import : modèles CSV à télécharger, participants et objectifs Avant un premier import, l'opérateur devait deviner les colonnes attendues. La section Import enregistre un modèle de la nature choisie, au format de l'export, qui s'importe tel quel sans ligne refusée. Celui des objectifs suit l'événement : une ligne par table, par tour quand les objectifs le sont, deux exemples sur des lignes libres, et des lignes vides seulement pour une table au numéro partagé, que l'import refuse. Une ligne sans objectif ni règle est désormais à remplir, passée plutôt que refusée. Vérifié : node 5173, navigateur 1160, node-long 129 ; quatre captures. Assisted-by: Claude Opus 5.5
14
CHANGELOG.md
|
|
@ -179,6 +179,20 @@
|
|||
fenêtre dit d'abord combien de propositions partent, et « Défaire » les rend.
|
||||
Une longue liste allégée garde le logiciel rapide ; le nombre choisi ne
|
||||
s'enregistre pas dans l'événement.
|
||||
- La section « Import » offre « Télécharger un modèle » pour le contenu choisi.
|
||||
Le modèle des participants montre chaque colonne sur quatre personnes
|
||||
inventées ; celui des objectifs de table porte une ligne par table de
|
||||
l'événement, et par tour pour une table qui a des objectifs par tour, avec
|
||||
deux objectifs d'exemple, « au moins une » et « toutes », sur des lignes
|
||||
sans objectif — il en ajoute au besoin une par tour ; un événement dont
|
||||
chaque table a déjà ses objectifs n'en reçoit aucun. Une table dont le
|
||||
numéro est porté par une autre n'a que des lignes à remplir, et le modèle se
|
||||
refuse quand chaque numéro est ainsi partagé, en menant à la section des
|
||||
tables. Les deux s'importent tels quels sans aucun refus. Le modèle n'écrit
|
||||
rien dans l'événement et reste disponible en lecture.
|
||||
- Dans un fichier d'objectifs, une ligne sans objectif ni règle est une ligne à
|
||||
remplir : l'import la passe sans la refuser, et l'objectif de sa table reste
|
||||
en mise à jour.
|
||||
|
||||
**Version de format des fichiers** : 2. Les étiquettes, les objectifs, leurs
|
||||
règles, les cases nouvelles, le mode des objectifs et l'ordre des priorités
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
56
spec.md
|
|
@ -3018,6 +3018,17 @@ courriels, d'un geste qui s'annonce comme une mise à jour.
|
|||
l'avertissement de dérive en retenu ; **refusé** en bloqué, le message nommant
|
||||
la commande qui déverrouille.
|
||||
|
||||
**Le modèle à télécharger.** La section d'import offre « Télécharger un
|
||||
modèle » pour la nature cochée (§ 10.4) ; la commande passe par « Enregistrer
|
||||
sous… » et n'écrit rien dans l'événement : elle est permise en lecture et sur
|
||||
un plan bloqué. Son nom accessible nomme la nature, et le chemin enregistré
|
||||
s'annonce sous elle. Le modèle a la forme de l'export — UTF-8 avec marque, `;`,
|
||||
CRLF, les en-têtes de l'import — et **s'importe tel quel sans refuser aucune
|
||||
ligne**. Celui des participants, `modele-participants.csv`, est fixe : quatre
|
||||
personnes inventées, dont les cellules remplissent chaque colonne du tableau
|
||||
ci-dessus, `exclu` sous ses deux valeurs et une cellule à deux étiquettes.
|
||||
Celui des objectifs suit l'événement (§ 10.4).
|
||||
|
||||
### 10.2 La saisie
|
||||
|
||||
Seul `nom` est obligatoire : refuser une personne faute de courriel pousse
|
||||
|
|
@ -3139,11 +3150,20 @@ associée à `table` (`TABLE_NON_ASSOCIEE`) ou à `objectif`
|
|||
dont le remède mène à la section des tables. **Refus de ligne**, un motif par
|
||||
ligne, dans cet ordre : champs en trop ; numéro de table absent, non entier,
|
||||
inconnu, ou porté par deux tables ; tour non entier ou hors de la soirée ;
|
||||
objectif vide ; étiquette mal formée ou vingt-et-unième étiquette ; règle hors
|
||||
de la liste fermée — l'inconnu ne se lit jamais comme le défaut ; deuxième ligne
|
||||
pour un même couple (table, tour), qui nomme la première. **Avis** : après
|
||||
l'import, un objectif écrit par le fichier auquel aucune personne présente ne
|
||||
correspond ; l'aperçu ne le calcule pas.
|
||||
objectif vide sous une règle écrite ; étiquette mal formée ou vingt-et-unième
|
||||
étiquette ; règle hors de la liste fermée — l'inconnu ne se lit jamais comme le
|
||||
défaut ; deuxième ligne pour un même couple (table, tour), qui nomme la
|
||||
première. **Avis** : après l'import, un objectif écrit par le fichier auquel
|
||||
aucune personne présente ne correspond ; l'aperçu ne le calcule pas.
|
||||
|
||||
**La ligne à remplir.** Une ligne dont la cellule `objectif` ne porte aucune
|
||||
étiquette — vide, ou faite de blancs et de séparateurs — et dont la cellule
|
||||
`regle` est vide ne demande rien : l'import la passe, ni importée ni refusée,
|
||||
quels que soient sa table et son tour ; seul un champ en trop la refuse. En
|
||||
mise à jour, l'objectif de son couple reste ; en remplacement, son couple est
|
||||
de ceux que le fichier ne nomme pas. Sous une règle écrite, la même cellule est
|
||||
un objectif vide, refusé (`OBJECTIF_VIDE`). Un fichier fait des seules lignes à
|
||||
remplir n'a aucune ligne valide.
|
||||
|
||||
**L'export des objectifs** écrit `table;tour;objectif;regle`, une ligne par
|
||||
objectif, les tables par numéro affiché puis identifiant croissant, et pour
|
||||
|
|
@ -3153,6 +3173,32 @@ sauvegarde, et l'aller-retour s'éprouve comme celui des participants. La
|
|||
commande « Exporter les objectifs » se trouve dans la section des tables ; elle
|
||||
est permise en lecture et sur un plan bloqué : sortir n'est pas modifier.
|
||||
|
||||
**Le modèle des objectifs**, `‹base›-modele-objectifs.csv` (§ 10.1), écrit
|
||||
l'en-tête de l'export puis une ligne par couple (table, tour) de l'événement,
|
||||
les tables par numéro affiché puis identifiant : la soirée d'une table qui ne
|
||||
porte aucun objectif de tour ; la soirée puis chaque tour, de 1 à R, d'une table
|
||||
qui en porte au moins un — le mode se lit dans les données (§ 4.5). Sur une
|
||||
table dont aucune autre ne partage le numéro, un couple qui porte un objectif
|
||||
l'écrit, comme l'export, et les deux premiers couples libres reçoivent un
|
||||
exemple, `finance, ressources humaines` sous `ou`, puis `finance, Montréal`
|
||||
sous `et` ; les autres couples sont des lignes à remplir. Une ligne nomme sa
|
||||
table par son numéro : l'import refuse l'objectif d'une table dont le numéro est
|
||||
partagé (`PLUSIEURS_TABLES`), et chacun de ses couples, objectif existant
|
||||
compris, est au modèle une ligne à remplir. Quand les tables à numéro unique
|
||||
offrent moins de deux couples libres — chacune garnie à sa soirée, ou une seule
|
||||
table sans objectif —, les premières d'entre elles sans objectif de tour
|
||||
prennent aussi leurs lignes de tour 1 à R, une table après l'autre, jusqu'à en
|
||||
offrir deux. Le modèle montre ainsi les deux formes, sauf quand ces tables
|
||||
n'offrent qu'un couple libre et aucune table à étendre — il n'écrit alors que
|
||||
l'exemple `ou` — ou aucun — il n'en écrit aucun. Importé tel quel, sous l'un ou
|
||||
l'autre mode, il ne refuse aucune ligne, rend inchangés les objectifs existants
|
||||
des tables à numéro unique et ajoute les exemples ; *Remplacer* retire ceux
|
||||
d'une table à numéro partagé, comme son bilan l'annonce avant d'agir. Un
|
||||
événement sans table refuse le modèle (`OBJECTIFS_SANS_TABLE`), et un événement
|
||||
dont chaque numéro de table est partagé aussi (`OBJECTIFS_NUMEROS_PARTAGES`) —
|
||||
aucune ligne de son modèle ne s'importerait —, l'un et l'autre avec le remède
|
||||
qui ouvre la section des tables.
|
||||
|
||||
---
|
||||
|
||||
## 11. Les sorties
|
||||
|
|
|
|||
|
|
@ -6,17 +6,21 @@
|
|||
// les constantes de src/csv dont un écran a besoin, telles quelles — la
|
||||
// jointure des étiquettes comprise, qu'un écran écrit comme l'export, et le
|
||||
// CSV des objectifs de table, son aperçu, son export et ce que remplacer y
|
||||
// touche —, et
|
||||
// touche, et le modèle des participants à télécharger —, et
|
||||
// lie le motif du CSV des refus au libellé français : l'interface ne
|
||||
// choisit pas ce motif, qui est une donnée du fichier écrit, jamais un texte
|
||||
// de la langue témoin.
|
||||
// de la langue témoin. Le modèle des objectifs se lit sur la charge de
|
||||
// l'événement, et se refuse, avec son remède, quand il n'a aucune table ou
|
||||
// que chacun de ses numéros de table est partagé.
|
||||
import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
|
||||
import { modeleObjectifs as modeleObjectifsCsv } from '../csv/objectifs.js';
|
||||
import { refus } from './erreurs.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
export { apercevoir } from '../csv/apercu.js';
|
||||
export { CHAMPS } from '../csv/colonnes.js';
|
||||
export { decoder } from '../csv/encodage.js';
|
||||
export { JOINTURE_ETIQUETTES, exporterParticipants } from '../csv/export.js';
|
||||
export { JOINTURE_ETIQUETTES, exporterParticipants, modeleParticipants } from '../csv/export.js';
|
||||
export { bilanRemplacement } from '../csv/import.js';
|
||||
export { cleNormalisee } from '../csv/normalisation.js';
|
||||
export { CHAMPS_OBJECTIFS, apercevoirObjectifs, bilanRemplacementObjectifs, exporterObjectifs } from '../csv/objectifs.js';
|
||||
|
|
@ -35,3 +39,25 @@ export { CHAMPS_OBJECTIFS, apercevoirObjectifs, bilanRemplacementObjectifs, expo
|
|||
export function exporterRefus(apercu, refusees = apercu.refusees) {
|
||||
return exporterRefusCsv(apercu, libelle, refusees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le modèle du CSV des objectifs de l'événement (modeleObjectifs de
|
||||
* src/csv), sur ses tables et son nombre de tours. Il n'écrit rien dans la
|
||||
* charge.
|
||||
*
|
||||
* @param {{evenement: {tours: number}, tables: Array<Object>}} charge
|
||||
* @returns {Uint8Array}
|
||||
* @throws {import('./erreurs.js').ErreurCommande} OBJECTIFS_SANS_TABLE pour
|
||||
* un événement sans table, OBJECTIFS_NUMEROS_PARTAGES pour un événement
|
||||
* dont chaque numéro de table est porté par au moins deux tables : dans
|
||||
* l'un et l'autre, aucune ligne du modèle ne s'importerait. Le remède
|
||||
* ouvre la section des tables, comme celui de l'import
|
||||
*/
|
||||
export function modeleObjectifs({ evenement, tables }) {
|
||||
const remede = { geste: 'ouvrirSection', section: 'tables' };
|
||||
if (tables.length === 0) throw refus('OBJECTIFS_SANS_TABLE', {}, remede);
|
||||
const portees = new Map();
|
||||
for (const { numero } of tables) portees.set(numero, (portees.get(numero) ?? 0) + 1);
|
||||
if (![...portees.values()].includes(1)) throw refus('OBJECTIFS_NUMEROS_PARTAGES', {}, remede);
|
||||
return modeleObjectifsCsv(tables, evenement.tours);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// atteint src/csv par ce module, qui réexporte ses fonctions telles quelles
|
||||
// et lie le motif du CSV des refus au libellé français ; les textes des
|
||||
// deux refus d'une ligne pour ses étiquettes ; le CSV des objectifs de table,
|
||||
// son CSV des refus et les textes de ses refus (§ 10.4). Les noms sont
|
||||
// inventés.
|
||||
// son CSV des refus et les textes de ses refus (§ 10.4) ; le modèle des
|
||||
// objectifs de l'événement, refusé sans table. Les noms sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import * as apercu from '../csv/apercu.js';
|
||||
|
|
@ -27,13 +27,14 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
|
|||
['decoder', encodage.decoder],
|
||||
['apercevoir', apercu.apercevoir],
|
||||
['exporterParticipants', exportCsv.exporterParticipants],
|
||||
['modeleParticipants', exportCsv.modeleParticipants],
|
||||
['bilanRemplacement', importCsv.bilanRemplacement],
|
||||
['cleNormalisee', normalisation.cleNormalisee],
|
||||
['apercevoirObjectifs', objectifs.apercevoirObjectifs],
|
||||
['exporterObjectifs', objectifs.exporterObjectifs],
|
||||
['bilanRemplacementObjectifs', objectifs.bilanRemplacementObjectifs],
|
||||
];
|
||||
assert.equal(attendus.length, 8);
|
||||
assert.equal(attendus.length, 9);
|
||||
for (const [nom, fonction] of attendus) {
|
||||
assert.equal(typeof fonction, 'function', nom);
|
||||
assert.equal(relais[nom], fonction, nom);
|
||||
|
|
@ -45,6 +46,7 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
|
|||
assert.deepEqual(Object.keys(relais).sort(), [
|
||||
'CHAMPS', 'CHAMPS_OBJECTIFS', 'JOINTURE_ETIQUETTES', 'apercevoir', 'apercevoirObjectifs', 'bilanRemplacement',
|
||||
'bilanRemplacementObjectifs', 'cleNormalisee', 'decoder', 'exporterObjectifs', 'exporterParticipants', 'exporterRefus',
|
||||
'modeleObjectifs', 'modeleParticipants',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -124,6 +126,52 @@ describe('csv : le CSV des objectifs de table par le relais (§ 10.4)', () => {
|
|||
assert.equal(lignes[1][5], "La soirée compte 4 tours : le tour 6 n'en est pas un.");
|
||||
});
|
||||
|
||||
test('modeleObjectifs lit les tables et les tours de la charge, comme le modèle de src/csv', () => {
|
||||
const charge = creerCharge({ id: 'evt-modele', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 3 });
|
||||
charge.tables = [
|
||||
{ id: 1, numero: 2, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||
{ id: 2, numero: 1, sieges: null, forme: 'ronde', position: { x: 200, y: 0 }, objectifs: [
|
||||
{ portee: 'tour', tour: 2, regle: null, etiquettes: ['bénévole'] },
|
||||
] },
|
||||
];
|
||||
assert.deepEqual(relais.modeleObjectifs(charge), objectifs.modeleObjectifs(charge.tables, 3));
|
||||
assert.match(relais.decoder(relais.modeleObjectifs(charge)).texte, /^1;3;;\r$/m);
|
||||
});
|
||||
|
||||
test('modeleObjectifs refuse un événement sans table, OBJECTIFS_SANS_TABLE, et son remède ouvre la section des tables', () => {
|
||||
const charge = creerCharge({ id: 'evt-modele', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 3 });
|
||||
assert.throws(
|
||||
() => relais.modeleObjectifs(charge),
|
||||
(erreur) => {
|
||||
assert.equal(erreur.name, 'ErreurCommande');
|
||||
assert.equal(erreur.code, 'OBJECTIFS_SANS_TABLE');
|
||||
assert.deepEqual(erreur.details, { remede: { geste: 'ouvrirSection', section: 'tables' } });
|
||||
assert.match(libelle(erreur.code, erreur.details), /créez les tables/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('modeleObjectifs refuse un événement dont chaque numéro de table est partagé, OBJECTIFS_NUMEROS_PARTAGES, remède vers la section des tables ; un seul numéro propre suffit', () => {
|
||||
const charge = creerCharge({ id: 'evt-modele', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 3 });
|
||||
charge.tables = [
|
||||
{ id: 1, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||
{ id: 2, numero: 4, sieges: null, forme: 'ronde', position: { x: 200, y: 0 } },
|
||||
];
|
||||
assert.throws(
|
||||
() => relais.modeleObjectifs(charge),
|
||||
(erreur) => {
|
||||
assert.equal(erreur.name, 'ErreurCommande');
|
||||
assert.equal(erreur.code, 'OBJECTIFS_NUMEROS_PARTAGES');
|
||||
assert.deepEqual(erreur.details, { remede: { geste: 'ouvrirSection', section: 'tables' } });
|
||||
assert.match(libelle(erreur.code, erreur.details), /numéros distincts/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
charge.tables.push({ id: 3, numero: 6, sieges: null, forme: 'ronde', position: { x: 400, y: 0 } });
|
||||
assert.deepEqual(relais.modeleObjectifs(charge), objectifs.modeleObjectifs(charge.tables, 3));
|
||||
});
|
||||
|
||||
test('exporterObjectifs écrit les objectifs des tables, que apercevoirObjectifs relit', () => {
|
||||
const charge = evenementAuxTables();
|
||||
charge.tables[1].objectifs = [{ portee: 'tour', tour: 3, regle: 'et', etiquettes: ['Montréal', 'finance'] }];
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ const EXEMPLES = new Map([
|
|||
['TABLE_NON_ASSOCIEE', {}],
|
||||
['OBJECTIF_NON_ASSOCIE', {}],
|
||||
['OBJECTIFS_SANS_TABLE', { ligne: null, remede: { geste: 'ouvrirSection', section: 'tables' } }],
|
||||
['OBJECTIFS_NUMEROS_PARTAGES', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
|
||||
['TABLE_ABSENTE', { ligne: 3, valeur: null }],
|
||||
['TABLE_NON_ENTIERE', { ligne: 3, valeur: '3b' }],
|
||||
['NUMERO_TABLE_INCONNU', { ligne: 4, valeur: '9' }],
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ export const TABLE = new Map([
|
|||
['TABLE_NON_ASSOCIEE', () => "Aucune colonne ne porte le numéro de table : l'en-tête doit compter une colonne « table »."],
|
||||
['OBJECTIF_NON_ASSOCIE', () => "Aucune colonne ne porte l'objectif : l'en-tête doit compter une colonne « objectif »."],
|
||||
['OBJECTIFS_SANS_TABLE', () => "L'événement n'a aucune table : créez les tables avant d'importer leurs objectifs."],
|
||||
[
|
||||
'OBJECTIFS_NUMEROS_PARTAGES',
|
||||
() =>
|
||||
'Chaque numéro de table est porté par plusieurs tables : aucune ligne du modèle ne dirait laquelle elle vise. Donnez aux tables des numéros distincts.',
|
||||
],
|
||||
['TABLE_ABSENTE', () => 'Le numéro de table manque.'],
|
||||
[
|
||||
'TABLE_NON_ENTIERE',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
// 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.
|
||||
//
|
||||
// Le modèle des participants (§ 10.1) est l'export d'une liste fixe de
|
||||
// personnes inventées : il a la forme de l'export, et s'importe tel quel.
|
||||
|
||||
import { CHAMPS } from './colonnes.js';
|
||||
|
||||
|
|
@ -127,3 +130,37 @@ export function exporterParticipants(participants) {
|
|||
const lignes = participants.map((participant, rang) => CHAMPS.map((champ) => cellule(participant, champ, rang)));
|
||||
return ecrireCsv([[...CHAMPS], ...lignes], SEPARATEUR);
|
||||
}
|
||||
|
||||
// Une personne inventée du modèle, ses champs omis vides et non exclue.
|
||||
const exemple = (nom, prenom, autres) => ({
|
||||
nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, etiquettes: [], ...autres,
|
||||
});
|
||||
|
||||
// Les personnes du modèle : à elles quatre, elles remplissent chaque colonne
|
||||
// de CHAMPS, exclu sous ses deux valeurs, une appartenance partagée, une
|
||||
// absente, et une cellule à deux étiquettes.
|
||||
const EXEMPLES_DU_MODELE = [
|
||||
exemple('Ombrelle', 'Iris', {
|
||||
appartenance: 'Coopérative Aubier', courriel: 'iris.ombrelle@exemple.test', titrePressenti: 'animation',
|
||||
etiquettes: ['finance', 'Montréal'],
|
||||
}),
|
||||
exemple('Pervenche', 'Théo', {
|
||||
appartenance: 'Coopérative Aubier', notes: 'arrive au deuxième tour', etiquettes: ['ressources humaines'],
|
||||
}),
|
||||
exemple('Lacasse', 'Ondine', {
|
||||
appartenance: 'Atelier Ressac', courriel: 'ondine.lacasse@exemple.test', notes: 'désistement annoncé', exclu: true,
|
||||
}),
|
||||
exemple('Tourteau', 'Basile', { etiquettes: ['bénévole'] }),
|
||||
];
|
||||
|
||||
/**
|
||||
* Le modèle du CSV des participants, à télécharger (§ 10.1) : l'export
|
||||
* (exporterParticipants) de quatre personnes inventées, toujours les mêmes,
|
||||
* dont les cellules remplissent chaque colonne que l'import lit. Importé
|
||||
* tel quel, il ne refuse aucune ligne. Chaque appel rend un tableau neuf.
|
||||
*
|
||||
* @returns {Uint8Array} UTF-8 avec marque, « ; », CRLF
|
||||
*/
|
||||
export function modeleParticipants() {
|
||||
return exporterParticipants(EXEMPLES_DU_MODELE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
// test/fixtures/csv/, sur les fichiers d'exemple 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.
|
||||
// découpe qui la relit. Le modèle à télécharger (§ 10.1) s'éprouve par son
|
||||
// texte exact, par chaque colonne qu'il remplit et par son import tel quel.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
|
|
@ -18,7 +19,7 @@ import { SEPARATEURS_ETIQUETTES, lireEtiquettes } from '../stockage/etiquettes.j
|
|||
import { VALEURS_EXCLU, apercevoir } from './apercu.js';
|
||||
import { CHAMPS } from './colonnes.js';
|
||||
import { decoder } from './encodage.js';
|
||||
import { JOINTURE_ETIQUETTES, SEPARATEUR, ecrireCsv, exporterParticipants } from './export.js';
|
||||
import { JOINTURE_ETIQUETTES, SEPARATEUR, ecrireCsv, exporterParticipants, modeleParticipants } from './export.js';
|
||||
import { appliquerImport } from './import.js';
|
||||
import { decouper } from './lecture.js';
|
||||
|
||||
|
|
@ -248,3 +249,58 @@ describe("ecrireCsv : l'écriture d'un CSV, qu'une découpe relit telle quelle",
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("modeleParticipants : le modèle à télécharger, dans la forme de l'export (§ 10.1)", () => {
|
||||
const ATTENDU = csv(
|
||||
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes',
|
||||
'Ombrelle;Iris;Coopérative Aubier;iris.ombrelle@exemple.test;animation;non;;finance, Montréal',
|
||||
'Pervenche;Théo;Coopérative Aubier;;;non;arrive au deuxième tour;ressources humaines',
|
||||
'Lacasse;Ondine;Atelier Ressac;ondine.lacasse@exemple.test;;oui;désistement annoncé;',
|
||||
'Tourteau;Basile;;;;non;;bénévole',
|
||||
);
|
||||
|
||||
test("marque UTF-8, « ; », CRLF, l'en-tête de l'export puis quatre personnes inventées", () => {
|
||||
const octets = modeleParticipants();
|
||||
assert.ok(octets instanceof Uint8Array);
|
||||
assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
|
||||
assert.equal(texteDe(octets), MARQUE + ATTENDU);
|
||||
});
|
||||
|
||||
test("chaque colonne que l'import lit porte au moins une valeur ; exclu ses deux valeurs, une cellule plusieurs étiquettes", () => {
|
||||
const { enregistrements } = decouper(decoder(modeleParticipants()).texte, SEPARATEUR);
|
||||
const [entete, ...lignes] = enregistrements;
|
||||
assert.deepEqual(entete, [...CHAMPS]);
|
||||
CHAMPS.forEach((champ, rang) => assert.ok(lignes.some((ligne) => ligne[rang] !== ''), champ));
|
||||
const exclus = new Set(lignes.map((ligne) => ligne[CHAMPS.indexOf('exclu')]));
|
||||
assert.deepEqual([...exclus].sort(), ['non', 'oui']);
|
||||
assert.ok(lignes.some((ligne) => ligne[CHAMPS.indexOf('etiquettes')].includes(JOINTURE_ETIQUETTES)));
|
||||
});
|
||||
|
||||
test("importé tel quel : aucun refus, les quatre personnes, champ par champ", () => {
|
||||
memesParticipants(importer(modeleParticipants(), 'modèle'), [
|
||||
personne(1, 'Ombrelle', {
|
||||
prenom: 'Iris', appartenance: 'Coopérative Aubier', courriel: 'iris.ombrelle@exemple.test', titrePressenti: 'animation',
|
||||
etiquettes: ['Montréal', 'finance'],
|
||||
}),
|
||||
personne(2, 'Pervenche', {
|
||||
prenom: 'Théo', appartenance: 'Coopérative Aubier', notes: 'arrive au deuxième tour', etiquettes: ['ressources humaines'],
|
||||
}),
|
||||
personne(3, 'Lacasse', {
|
||||
prenom: 'Ondine', appartenance: 'Atelier Ressac', courriel: 'ondine.lacasse@exemple.test', notes: 'désistement annoncé',
|
||||
exclu: true,
|
||||
}),
|
||||
personne(4, 'Tourteau', { prenom: 'Basile', etiquettes: ['bénévole'] }),
|
||||
], 'modèle');
|
||||
const apercu = apercevoir(decoder(modeleParticipants()).texte);
|
||||
assert.deepEqual([apercu.doublons, apercu.multilignes], [[], []]);
|
||||
});
|
||||
|
||||
test('deux appels rendent les mêmes octets, dans deux tableaux distincts', () => {
|
||||
const premier = modeleParticipants();
|
||||
const second = modeleParticipants();
|
||||
assert.notEqual(premier, second);
|
||||
assert.deepEqual(premier, second);
|
||||
premier.fill(0);
|
||||
assert.equal(texteDe(modeleParticipants()), MARQUE + ATTENDU);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
// des participants. Il garde son séparateur, son en-tête et son CSV des
|
||||
// refus ; tout le § 10.1 vaut, sauf ce qui nomme les participants. Ce module
|
||||
// en porte l'aperçu, l'import appliqué selon le mode, ce que remplacer
|
||||
// touche et l'export, qui est le gabarit de l'import et sa sauvegarde.
|
||||
// touche, l'export, qui est le gabarit de l'import et sa sauvegarde, et le
|
||||
// modèle à télécharger, qui donne une ligne à remplir par couple.
|
||||
//
|
||||
// La lecture reprend, dans l'ordre, celle de l'aperçu des participants :
|
||||
// 1. un caractère nul refuse le texte ;
|
||||
|
|
@ -19,8 +20,9 @@
|
|||
// (TABLE_NON_ASSOCIEE), puis à objectif (OBJECTIF_NON_ASSOCIE), puis un
|
||||
// événement sans table (OBJECTIFS_SANS_TABLE) refusent ;
|
||||
// 5. chaque enregistrement non vide qui suit l'en-tête est une ligne,
|
||||
// valide ou refusée pour un seul motif (lireLigne) ; zéro ligne valide
|
||||
// refuse (AUCUNE_LIGNE_VALIDE) ;
|
||||
// valide, refusée pour un seul motif, ou à remplir — sans objectif ni
|
||||
// règle —, que l'import passe (lireLigne) ; zéro ligne valide refuse
|
||||
// (AUCUNE_LIGNE_VALIDE) ;
|
||||
// 6. les étiquettes des lignes valides se réconcilient avec celles de
|
||||
// l'événement : celles des personnes, exclues comprises, et celles des
|
||||
// objectifs de table, sauf sous remplacer, qui retire ces objectifs.
|
||||
|
|
@ -259,19 +261,24 @@ function etiquettesDe(cellule) {
|
|||
}
|
||||
|
||||
// Lit un enregistrement de données selon les colonnes associées, largeur
|
||||
// étant le nombre d'en-têtes. Rend { champs } ou le motif du refus, un seul,
|
||||
// dans l'ordre du § 10.4 : un champ non vide au-delà de l'en-tête ; le
|
||||
// numéro de table ; le tour ; l'objectif ; la règle hors de sa liste fermée.
|
||||
// Le doublon d'un couple (table, tour) se juge après, contre les lignes
|
||||
// valides qui précèdent.
|
||||
// étant le nombre d'en-têtes. Rend { champs }, { aRemplir: true }, ou le
|
||||
// motif du refus, un seul, dans l'ordre du § 10.4 : un champ non vide
|
||||
// au-delà de l'en-tête ; le numéro de table ; le tour ; l'objectif ; la
|
||||
// règle hors de sa liste fermée. Après le champ en trop, une ligne dont
|
||||
// l'objectif n'a aucune étiquette — cellule absente, vide, ou faite de
|
||||
// blancs et de séparateurs — et dont la règle est vide est à remplir : elle
|
||||
// ne demande rien, et sa table et son tour ne se lisent pas. Sous une règle
|
||||
// écrite, le même objectif est vide, OBJECTIF_VIDE. Le doublon d'un couple
|
||||
// (table, tour) se juge après, contre les lignes valides qui précèdent.
|
||||
function lireLigne(brut, largeur, colonnes, { tables, tours }) {
|
||||
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
|
||||
const lire = (nom) => valeurDe(brut, colonnes[nom]);
|
||||
const objectif = etiquettesDe(colonnes.objectif < brut.length ? brut[colonnes.objectif] : undefined);
|
||||
if (objectif.refus?.code === 'OBJECTIF_VIDE' && lire('regle') === null) return { aRemplir: true };
|
||||
const visee = tableDe(lire('table'), tables);
|
||||
if (visee.refus !== undefined) return visee.refus;
|
||||
const lu = tourDe(lire('tour'), tours);
|
||||
if (lu.refus !== undefined) return lu.refus;
|
||||
const objectif = etiquettesDe(colonnes.objectif < brut.length ? brut[colonnes.objectif] : undefined);
|
||||
if (objectif.refus !== undefined) return objectif.refus;
|
||||
const valeurRegle = lire('regle');
|
||||
const regle = VALEURS_REGLE.get(cleNormalisee(valeurRegle ?? ''));
|
||||
|
|
@ -314,7 +321,8 @@ const cleDuCouple = (table, tour) => JSON.stringify([table, tour]);
|
|||
/**
|
||||
* Aperçu d'un texte décodé du CSV des objectifs (§ 10.4), contre la charge,
|
||||
* pour un mode de l'import : rien n'est importé. Les lignes se lisent dans
|
||||
* l'ordre du texte ; une ligne refusée ne refuse que soi. Le doublon d'un
|
||||
* l'ordre du texte ; une ligne refusée ne refuse que soi ; une ligne à
|
||||
* remplir, sans objectif ni règle, n'est ni valide ni refusée. Le doublon d'un
|
||||
* couple (table, tour) se juge contre les lignes valides qui précèdent : une
|
||||
* ligne refusée n'est la première d'aucun couple.
|
||||
*
|
||||
|
|
@ -372,7 +380,11 @@ export function apercevoirObjectifs(texte, charge, { choix = {}, mode = 'mettreA
|
|||
const brut = enregistrements[indice];
|
||||
if (enregistrementVide(brut)) continue;
|
||||
const ligne = indice + 1;
|
||||
const { champs, ...refus } = lireLigne(brut, entetes.length, association.colonnes, { tables: charge.tables, tours });
|
||||
const { champs, aRemplir = false, ...refus } = lireLigne(brut, entetes.length, association.colonnes, {
|
||||
tables: charge.tables,
|
||||
tours,
|
||||
});
|
||||
if (aRemplir) continue;
|
||||
if (champs === undefined) {
|
||||
refusees.push({ ligne, ...refus, brut });
|
||||
continue;
|
||||
|
|
@ -569,3 +581,89 @@ export function exporterObjectifs(tables) {
|
|||
);
|
||||
return ecrireCsv([[...CHAMPS_OBJECTIFS], ...lignes], SEPARATEUR);
|
||||
}
|
||||
|
||||
// Les objectifs d'exemple du modèle, dans l'ordre où ils prennent les
|
||||
// premiers couples libres : la règle « ou », puis la règle « et ».
|
||||
const EXEMPLES_D_OBJECTIFS = [
|
||||
{ regle: 'ou', etiquettes: ['finance', 'ressources humaines'] },
|
||||
{ regle: 'et', etiquettes: ['finance', 'Montréal'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Le modèle du CSV des objectifs d'un événement, à télécharger (§ 10.4) :
|
||||
* la forme de l'export — UTF-8 avec marque, « ; », CRLF, l'en-tête
|
||||
* table;tour;objectif;regle —, puis une ligne par couple (table, tour) à
|
||||
* remplir. Les tables se rangent par numéro affiché puis identifiant. Une
|
||||
* table qui ne porte aucun objectif de tour a la ligne de sa soirée ; une
|
||||
* table qui en porte au moins un, celle de sa soirée puis une par tour, de
|
||||
* 1 à tours. Sur une table dont le numéro n'est porté par aucune autre, un
|
||||
* couple qui porte un objectif l'écrit, comme l'export, et les deux premiers
|
||||
* couples libres reçoivent les exemples, « ou » puis « et ». Les autres
|
||||
* couples restent à remplir, objectif et règle vides — tous ceux d'une table
|
||||
* à numéro partagé, objectif existant compris, puisque l'import refuse ce
|
||||
* numéro (PLUSIEURS_TABLES). Quand ces tables offrent moins de deux couples
|
||||
* libres, les premières d'entre elles sans objectif de tour prennent aussi
|
||||
* leurs lignes de tour, une à une, jusqu'à en offrir deux ; sans table à
|
||||
* étendre, un seul couple libre ne reçoit que l'exemple « ou », et aucun
|
||||
* exemple ne s'écrit sans couple libre. Importé tel quel, sous l'un ou
|
||||
* l'autre mode, il ne refuse aucune ligne : les lignes à remplir passent,
|
||||
* les objectifs existants des tables à numéro unique reviennent inchangés et
|
||||
* les exemples s'ajoutent ; en remplacement, ceux d'une table à numéro
|
||||
* partagé partent. Quand chaque numéro est partagé, le fichier n'a aucune
|
||||
* ligne valide : l'application refuse ce modèle (src/application/csv.js).
|
||||
* Sans table, l'en-tête seul. Les tables reçues ne sont pas modifiées.
|
||||
*
|
||||
* @param {Array<{id: number, numero: number, objectifs?: Array<{tour: number|null, regle: 'ou'|'et'|null, etiquettes: string[]}>}>} tables
|
||||
* @param {number} tours le nombre de tours de l'événement
|
||||
* @returns {Uint8Array}
|
||||
* @throws {TypeError} pour un nombre de tours qui n'est pas un entier
|
||||
* positif, et pour un objectif existant de forme fausse sur une table à
|
||||
* numéro unique, comme l'export ; ceux d'une table à numéro partagé ne se
|
||||
* lisent pas
|
||||
*/
|
||||
export function modeleObjectifs(tables, tours) {
|
||||
if (!Number.isSafeInteger(tours) || tours < 1) throw new TypeError(`tours : entier positif attendu, reçu ${String(tours)}`);
|
||||
const portees = new Map();
|
||||
for (const { numero } of tables) portees.set(numero, (portees.get(numero) ?? 0) + 1);
|
||||
const triees = [...tables].sort((a, b) => a.numero - b.numero || a.id - b.id);
|
||||
// Les tables écrites par tour : celles qui portent un objectif de tour,
|
||||
// puis, tant que les tables à numéro unique offrent moins de couples
|
||||
// libres que d'exemples, la suivante d'entre elles qui n'en porte pas —
|
||||
// ses tours sont tous libres.
|
||||
const parTour = new Set(triees.filter(({ objectifs = [] }) => objectifs.some(({ tour }) => tour !== null)));
|
||||
const unique = ({ numero }) => portees.get(numero) === 1;
|
||||
const libresDe = (table) => {
|
||||
const pris = new Set((table.objectifs ?? []).map(({ tour }) => tour));
|
||||
const couples = [null, ...(parTour.has(table) ? Array.from({ length: tours }, (_, rang) => rang + 1) : [])];
|
||||
return couples.filter((tour) => !pris.has(tour)).length;
|
||||
};
|
||||
let libres = triees.filter(unique).reduce((somme, table) => somme + libresDe(table), 0);
|
||||
for (const table of triees) {
|
||||
if (libres >= EXEMPLES_D_OBJECTIFS.length) break;
|
||||
if (!unique(table) || parTour.has(table)) continue;
|
||||
parTour.add(table);
|
||||
libres += tours;
|
||||
}
|
||||
const lignes = [];
|
||||
let exemples = 0;
|
||||
for (const table of triees) {
|
||||
const { numero, objectifs = [] } = table;
|
||||
const couples = [null, ...(parTour.has(table) ? Array.from({ length: tours }, (_, rang) => rang + 1) : [])];
|
||||
for (const tour of couples) {
|
||||
// Un numéro partagé ne s'importe pas (PLUSIEURS_TABLES) : chacun de
|
||||
// ses couples est une ligne à remplir, son objectif existant tu.
|
||||
let objectif = unique(table) ? (objectifs.find((existant) => existant.tour === tour) ?? null) : null;
|
||||
if (objectif === null && unique(table) && exemples < EXEMPLES_D_OBJECTIFS.length) {
|
||||
objectif = EXEMPLES_D_OBJECTIFS[exemples];
|
||||
exemples += 1;
|
||||
}
|
||||
lignes.push([
|
||||
String(numero),
|
||||
tour === null ? '' : String(tour),
|
||||
objectif === null ? '' : celluleObjectif(objectif.etiquettes, numero),
|
||||
objectif === null ? '' : celluleRegle(objectif.regle, numero),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return ecrireCsv([[...CHAMPS_OBJECTIFS], ...lignes], SEPARATEUR);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
// des en-têtes et son association, l'aperçu et ses refus globaux, les refus
|
||||
// d'une ligne dans l'ordre de A20, la règle lue dans sa liste fermée, la
|
||||
// réconciliation des étiquettes, l'import appliqué selon le mode, l'export
|
||||
// et l'aller-retour, le fichier d'exemple et la boucle de son CSV des refus.
|
||||
// et l'aller-retour, le fichier d'exemple et la boucle de son CSV des refus,
|
||||
// la ligne à remplir, que l'import passe, et le modèle à télécharger.
|
||||
// Les noms, les étiquettes et les événements sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
associerObjectifs,
|
||||
bilanRemplacementObjectifs,
|
||||
exporterObjectifs,
|
||||
modeleObjectifs,
|
||||
reconnaitreObjectif,
|
||||
} from './objectifs.js';
|
||||
|
||||
|
|
@ -250,9 +252,9 @@ describe('objectifs : les refus d’une ligne, un motif, dans l’ordre de A20 (
|
|||
}
|
||||
});
|
||||
|
||||
test("l'objectif : vide ou fait de séparateurs, puis une étiquette mal formée, puis une vingt-et-unième étiquette", () => {
|
||||
assert.deepEqual(refusDe('1;;;'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null });
|
||||
assert.deepEqual(refusDe('1;;" , | ";'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null });
|
||||
test("l'objectif : vide ou fait de séparateurs sous une règle écrite, puis une étiquette mal formée, puis une vingt-et-unième étiquette", () => {
|
||||
assert.deepEqual(refusDe('1;;;et'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null });
|
||||
assert.deepEqual(refusDe('1;;" , | ";ou'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null });
|
||||
assert.deepEqual(refusDe(`1;;finance, ressources${TAB}humaines;`), {
|
||||
ligne: 2, code: 'ETIQUETTE_MAL_FORMEE', valeur: `ressources${TAB}humaines`, raison: 'controle',
|
||||
});
|
||||
|
|
@ -511,6 +513,248 @@ describe('objectifs : l’export, gabarit de l’import et sauvegarde (§ 10.4)'
|
|||
});
|
||||
});
|
||||
|
||||
describe('objectifs : la ligne à remplir, sans objectif ni règle, que l’import passe (§ 10.4)', () => {
|
||||
test('ni valide ni refusée, quels que soient sa table et son tour ; seule, aucune ligne valide', () => {
|
||||
for (const ligne of ['1;;;', '1;2;;', '1;;" , | ";', '9;;;', 'un;cinq;;', ';3; ;']) {
|
||||
const apercu = apercevoirObjectifs(csv('table;tour;objectif;regle', ligne), evenement());
|
||||
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE', ligne);
|
||||
assert.deepEqual(apercu.refusees, [], ligne);
|
||||
}
|
||||
const sansRegle = apercevoirObjectifs(csv('table;objectif', '1;', '2;finance'), evenement());
|
||||
assert.deepEqual(sansRegle.refusees, []);
|
||||
assert.deepEqual(sansRegle.lignes.map(({ ligne }) => ligne), [3]);
|
||||
});
|
||||
|
||||
test('un champ en trop la refuse encore, et une règle écrite en fait un objectif vide', () => {
|
||||
// Vingt lignes à remplir précèdent le champ en trop, que l'analyse du
|
||||
// séparateur ne lit qu'au-delà de ses vingt premiers enregistrements.
|
||||
const apercu = apercevoirObjectifs(
|
||||
csv('table;tour;objectif;regle', '1;;finance;', ...Array(20).fill('3;;;'), '2;;;;x', '2;1;;toutes'),
|
||||
evenement(),
|
||||
);
|
||||
assert.deepEqual(refus(apercu), [[23, 'CHAMPS_EN_TROP'], [24, 'OBJECTIF_VIDE']]);
|
||||
assert.deepEqual(apercu.lignes.map(({ ligne }) => ligne), [2]);
|
||||
});
|
||||
|
||||
test('elle ne touche pas l’objectif de son couple en mise à jour ; en remplacement, son couple est de ceux que le fichier ne nomme pas', () => {
|
||||
const charge = avecObjectifs();
|
||||
const texte = csv('table;tour;objectif;regle', '1;;;', '1;2;;', '2;;finance;');
|
||||
const misAJour = appliquerImportObjectifs(charge, apercevoirObjectifs(texte, charge), 'mettreAJour');
|
||||
assert.deepEqual(misAJour.charge.tables[0].objectifs, charge.tables[0].objectifs);
|
||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.inchanges, misAJour.resume.retires], [1, 0, 0]);
|
||||
const remplace = appliquerImportObjectifs(charge, apercevoirObjectifs(texte, charge, { mode: 'remplacer' }), 'remplacer');
|
||||
assert.equal(remplace.charge.tables[0].objectifs, undefined);
|
||||
assert.deepEqual([remplace.resume.ajoutes, remplace.resume.retires], [1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : le modèle à télécharger, adapté à l’événement (§ 10.4)', () => {
|
||||
test('sans objectif : une ligne de soirée par table, les deux premières portent l’exemple « ou » puis l’exemple « et »', () => {
|
||||
const charge = evenement();
|
||||
const octets = modeleObjectifs(charge.tables, charge.evenement.tours);
|
||||
assert.ok(octets instanceof Uint8Array);
|
||||
assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
|
||||
assert.equal(
|
||||
texteDe(octets),
|
||||
MARQUE + csv('table;tour;objectif;regle', '1;;finance, ressources humaines;ou', '2;;finance, Montréal;et', '3;;;'),
|
||||
);
|
||||
});
|
||||
|
||||
test('une table qui porte un objectif de tour : sa soirée puis chaque tour ; un objectif existant s’écrit à son couple, les exemples aux premiers couples libres', () => {
|
||||
const charge = avecObjectifs();
|
||||
// Une quatrième table numérotée 0 vient en tête par son numéro.
|
||||
charge.tables.push(table(4, 0));
|
||||
charge.tables.reverse();
|
||||
const avant = structuredClone(charge.tables);
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(charge.tables, charge.evenement.tours)),
|
||||
MARQUE +
|
||||
csv(
|
||||
'table;tour;objectif;regle',
|
||||
'0;;finance, ressources humaines;ou',
|
||||
'1;;finance;',
|
||||
'1;1;finance, Montréal;et',
|
||||
'1;2;Montréal, bénévole;et',
|
||||
'1;3;;',
|
||||
'1;4;;',
|
||||
'2;;;',
|
||||
'3;;;',
|
||||
'3;1;;',
|
||||
'3;2;;',
|
||||
'3;3;Vélo;ou',
|
||||
'3;4;;',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(charge.tables, avant);
|
||||
});
|
||||
|
||||
test('deux tables d’un même numéro ont leurs lignes, à remplir, leurs objectifs existants tus : aucun exemple n’y va, l’import refuserait ce numéro', () => {
|
||||
const charge = evenement();
|
||||
charge.tables = [table(1, 3), table(2, 3), table(3, 5)];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(charge.tables, 2)),
|
||||
MARQUE + csv('table;tour;objectif;regle', '3;;;', '3;;;', '5;;finance, ressources humaines;ou', '5;1;finance, Montréal;et', '5;2;;'),
|
||||
);
|
||||
const garnie = evenement();
|
||||
garnie.tables = [
|
||||
table(1, 3, { objectifs: [
|
||||
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'bénévole'] },
|
||||
] }),
|
||||
table(2, 3),
|
||||
table(3, 5),
|
||||
];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(garnie.tables, 2)),
|
||||
MARQUE + csv('table;tour;objectif;regle', '3;;;', '3;1;;', '3;2;;', '3;;;', '5;;finance, ressources humaines;ou', '5;1;finance, Montréal;et', '5;2;;'),
|
||||
);
|
||||
assert.equal(texteDe(modeleObjectifs([], 2)), `${MARQUE}table;tour;objectif;regle\r\n`);
|
||||
});
|
||||
|
||||
test('moins de deux couples libres : la première table à numéro unique sans objectif de tour prend ses lignes de tour, et les deux formes s’y montrent', () => {
|
||||
const garnies = evenement();
|
||||
garnies.tables = [
|
||||
table(1, 1, { objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }] }),
|
||||
table(2, 2, { objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['bénévole'] }] }),
|
||||
];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(garnies.tables, 3)),
|
||||
MARQUE +
|
||||
csv(
|
||||
'table;tour;objectif;regle',
|
||||
'1;;finance;',
|
||||
'1;1;finance, ressources humaines;ou',
|
||||
'1;2;finance, Montréal;et',
|
||||
'1;3;;',
|
||||
'2;;bénévole;',
|
||||
),
|
||||
);
|
||||
const seule = evenement();
|
||||
seule.tables = [table(1, 4)];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(seule.tables, 3)),
|
||||
MARQUE + csv('table;tour;objectif;regle', '4;;finance, ressources humaines;ou', '4;1;finance, Montréal;et', '4;2;;', '4;3;;'),
|
||||
);
|
||||
// Un seul tour : la première table n'offre qu'un couple de tour, la
|
||||
// suivante prend aussi sa ligne de tour pour le second exemple.
|
||||
const tourUnique = evenement();
|
||||
tourUnique.tables = [
|
||||
table(1, 1, { objectifs: [{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['finance'] }] }),
|
||||
table(2, 2, { objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['Montréal'] }] }),
|
||||
];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(tourUnique.tables, 1)),
|
||||
MARQUE + csv('table;tour;objectif;regle', '1;;finance;et', '1;1;finance, ressources humaines;ou', '2;;Montréal;', '2;1;finance, Montréal;et'),
|
||||
);
|
||||
// Une table déjà écrite par tour, avant une table garnie à sa soirée :
|
||||
// son couple libre ne suffit pas, ses tours ne se recomptent pas, et la
|
||||
// table suivante prend ses lignes de tour pour l'exemple « et ».
|
||||
const parTourDabord = evenement();
|
||||
parTourDabord.tables = [
|
||||
table(1, 1, { objectifs: [
|
||||
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 1, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 2, regle: null, etiquettes: ['finance'] },
|
||||
] }),
|
||||
table(2, 2, { objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['bénévole'] }] }),
|
||||
];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(parTourDabord.tables, 3)),
|
||||
MARQUE +
|
||||
csv(
|
||||
'table;tour;objectif;regle',
|
||||
'1;;finance;',
|
||||
'1;1;finance;',
|
||||
'1;2;finance;',
|
||||
'1;3;finance, ressources humaines;ou',
|
||||
'2;;bénévole;',
|
||||
'2;1;finance, Montréal;et',
|
||||
'2;2;;',
|
||||
'2;3;;',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sans couple libre sur une table à numéro unique, aucun exemple : un événement entièrement garni, ou des numéros tous partagés, dont le fichier n’a aucune ligne valide', () => {
|
||||
const garni = evenement();
|
||||
garni.tables = [
|
||||
table(1, 1, { objectifs: [
|
||||
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 1, regle: 'ou', etiquettes: ['Montréal'] },
|
||||
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['bénévole'] },
|
||||
] }),
|
||||
];
|
||||
assert.equal(
|
||||
texteDe(modeleObjectifs(garni.tables, 2)),
|
||||
MARQUE + csv('table;tour;objectif;regle', '1;;finance;', '1;1;Montréal;ou', '1;2;bénévole;et'),
|
||||
);
|
||||
const partages = evenement();
|
||||
partages.tables = [table(1, 3), table(2, 3)];
|
||||
assert.equal(texteDe(modeleObjectifs(partages.tables, 2)), MARQUE + csv('table;tour;objectif;regle', '3;;;', '3;;;'));
|
||||
// Ce fichier ne s'importe pas : l'application refuse ce modèle
|
||||
// (OBJECTIFS_NUMEROS_PARTAGES, src/application/csv.js).
|
||||
const texte = decoder(modeleObjectifs(partages.tables, 2)).texte;
|
||||
for (const mode of MODES_IMPORT_OBJECTIFS) {
|
||||
const apercu = apercevoirObjectifs(texte, partages, { mode });
|
||||
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE', mode);
|
||||
assert.deepEqual(apercu.refusees, [], mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('importé tel quel, sous les deux modes : aucun refus, les objectifs existants inchangés, les exemples ajoutés', () => {
|
||||
const garnies = evenement();
|
||||
garnies.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
garnies.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['bénévole'] }];
|
||||
garnies.tables[2].objectifs = [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['Montréal'] }];
|
||||
for (const depart of [evenement(), avecObjectifs(), garnies]) {
|
||||
const texte = decoder(modeleObjectifs(depart.tables, depart.evenement.tours)).texte;
|
||||
const parMode = MODES_IMPORT_OBJECTIFS.map((mode) => {
|
||||
const apercu = apercevoirObjectifs(texte, depart, { mode });
|
||||
assert.equal(apercu.refusGlobal, null, mode);
|
||||
assert.deepEqual(apercu.refusees, [], mode);
|
||||
assert.deepEqual(apercu.multilignes, [], mode);
|
||||
return appliquerImportObjectifs(depart, apercu, mode);
|
||||
});
|
||||
const [misAJour, remplace] = parMode;
|
||||
assert.deepEqual(remplace.charge, misAJour.charge);
|
||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.remplaces, misAJour.resume.retires], [2, 0, 0]);
|
||||
for (const avant of depart.tables) {
|
||||
const apres = misAJour.charge.tables.find(({ id }) => id === avant.id);
|
||||
for (const objectif of avant.objectifs ?? []) assert.ok(apres.objectifs.some((o) => JSON.stringify(o) === JSON.stringify(objectif)));
|
||||
}
|
||||
}
|
||||
// Un numéro partagé, dont une table porte un objectif : ses lignes sont à
|
||||
// remplir, aucune n'est refusée ; la mise à jour garde son objectif, le
|
||||
// remplacement le retire, comme son bilan l'annonce.
|
||||
const partagee = evenement();
|
||||
partagee.tables = [
|
||||
table(1, 3, { objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }] }),
|
||||
table(2, 3),
|
||||
table(3, 5),
|
||||
];
|
||||
const texte = decoder(modeleObjectifs(partagee.tables, partagee.evenement.tours)).texte;
|
||||
const [misAJour, remplace] = MODES_IMPORT_OBJECTIFS.map((mode) => {
|
||||
const apercu = apercevoirObjectifs(texte, partagee, { mode });
|
||||
assert.equal(apercu.refusGlobal, null, mode);
|
||||
assert.deepEqual(apercu.refusees, [], mode);
|
||||
const applique = appliquerImportObjectifs(partagee, apercu, mode);
|
||||
assert.deepEqual(applique.resume.refusees, [], mode);
|
||||
return applique;
|
||||
});
|
||||
assert.deepEqual(misAJour.charge.tables[0].objectifs, partagee.tables[0].objectifs);
|
||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.remplaces, misAJour.resume.retires], [2, 0, 0]);
|
||||
assert.equal(remplace.charge.tables[0].objectifs, undefined);
|
||||
assert.deepEqual([remplace.resume.ajoutes, remplace.resume.remplaces, remplace.resume.retires], [2, 0, 1]);
|
||||
});
|
||||
|
||||
test('un nombre de tours qui n’est pas un entier positif lève TypeError ; une forme fausse d’objectif aussi, qui nomme la table', () => {
|
||||
const charge = avecObjectifs();
|
||||
for (const tours of [0, -1, 1.5, '4', null]) assert.throws(() => modeleObjectifs(charge.tables, tours), TypeError, String(tours));
|
||||
charge.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: 'toutes', etiquettes: ['finance'] }];
|
||||
assert.throws(() => modeleObjectifs(charge.tables, 4), (erreur) => erreur instanceof TypeError && /table 2/.test(erreur.message));
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : le fichier d’exemple et la boucle de son CSV des refus (A19, § 10.3, § 14.10)', () => {
|
||||
test('objectifs_cas_limites.csv : UTF-8 avec marque, « ; », CRLF, l’en-tête Table;Tour;Objectif;Règle, et le contenu fixé', () => {
|
||||
const octets = readFileSync(new URL('objectifs_cas_limites.csv', EXEMPLES));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
|
||||
// d'une ligne et les refus globaux, les étiquettes en pastilles et leurs
|
||||
// fusions à part, le rapport, le CSV des refus, les modes, l'import selon
|
||||
// l'état, le collage. Chaque épreuve monte App par
|
||||
// l'état, le collage, le modèle à télécharger. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé, ouvre un événement et sa
|
||||
// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers
|
||||
// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il
|
||||
|
|
@ -31,7 +31,8 @@ import { apercevoir } from '../../csv/apercu.js';
|
|||
import { decoder } from '../../csv/encodage.js';
|
||||
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
|
||||
import { decouper } from '../../csv/lecture.js';
|
||||
import { apercevoirObjectifs, bilanRemplacementObjectifs } from '../../csv/objectifs.js';
|
||||
import { modeleParticipants } from '../../csv/export.js';
|
||||
import { apercevoirObjectifs, bilanRemplacementObjectifs, modeleObjectifs } from '../../csv/objectifs.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
|
|
@ -1051,7 +1052,7 @@ const TEXTE_DES_MOTIFS = [
|
|||
'3;;finance;',
|
||||
'2;2.0;finance;',
|
||||
'2;5;finance;',
|
||||
'2;1;;',
|
||||
'2;1;;ou',
|
||||
`2;1;${'x'.repeat(41)};`,
|
||||
`2;1;${VINGT_ET_UNE};`,
|
||||
'2;1;finance;peut-être',
|
||||
|
|
@ -1387,3 +1388,100 @@ describe('les étiquettes des participants dans l’aperçu (§ 10.1, A17)', ()
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('le modèle à télécharger, de la nature cochée (§ 10.1, § 10.4)', () => {
|
||||
// Le chemin annoncé après l'enregistrement du modèle, quand il paraît.
|
||||
const noteDuModele = (e) => dans(e, '[data-modele-enregistre]');
|
||||
|
||||
test('« Télécharger un modèle » remet à « Enregistrer sous… » le modèle de la nature cochée, sous son nom ; le chemin s’annonce ; rien ne s’écrit', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
const avant = ecritures(e);
|
||||
const bouton = commande(e, 'telechargerModele');
|
||||
expect(bouton.textContent.trim()).toBe(t('import.commande.telechargerModele'));
|
||||
expect(bouton.getAttribute('aria-label')).toBe(t('import.modele.participants'));
|
||||
expect(noteDuModele(e)).toBeNull();
|
||||
bouton.click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect(e.enregistres[0].nomPropose).toBe('modele-participants.csv');
|
||||
expect([...e.enregistres[0].octets]).toEqual([...modeleParticipants()]);
|
||||
await vi.waitFor(() => expect(noteDuModele(e)).not.toBeNull());
|
||||
expect(noteDuModele(e).getAttribute('role')).toBe('status');
|
||||
expect(texteDe(noteDuModele(e))).toBe(`${t('import.modele.enregistre')} modele-participants.csv`);
|
||||
|
||||
choisirNature(e, 'objectifs');
|
||||
expect(noteDuModele(e)).toBeNull();
|
||||
const objectifs = commande(e, 'telechargerModele');
|
||||
expect(objectifs.getAttribute('aria-label')).toBe(t('import.modele.objectifs'));
|
||||
objectifs.click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(2));
|
||||
const { charge } = e.m.seance.etat();
|
||||
expect(e.enregistres[1].nomPropose).toBe(`${BASE}-modele-objectifs.csv`);
|
||||
expect([...e.enregistres[1].octets]).toEqual([...modeleObjectifs(charge.tables, charge.evenement.tours)]);
|
||||
await vi.waitFor(() => expect(texteDe(noteDuModele(e))).toBe(`${t('import.modele.enregistre')} ${BASE}-modele-objectifs.csv`));
|
||||
expect(dans(e, '[data-apercu]')).toBeNull();
|
||||
expect(e.espion).not.toHaveBeenCalled();
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
|
||||
test('en lecture aussi : le modèle s’enregistre, le mode reste la lecture', async () => {
|
||||
const e = await ouvrir({ ecriture: false, tables: NEUF_TABLES() });
|
||||
expect(e.m.seance.etat().mode).toBe('lecture');
|
||||
choisirNature(e, 'objectifs');
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect(e.enregistres[0].nomPropose).toBe(`${BASE}-modele-objectifs.csv`);
|
||||
expect(e.m.seance.etat().mode).toBe('lecture');
|
||||
});
|
||||
|
||||
test('un enregistrement annulé n’annonce rien, et retire la note du précédent', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(noteDuModele(e)).not.toBeNull());
|
||||
let annules = 0;
|
||||
e.m.fs.enregistrerSous = async () => {
|
||||
annules += 1;
|
||||
return null;
|
||||
};
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(annules).toBe(1));
|
||||
await vi.waitFor(() => expect(noteDuModele(e)).toBeNull());
|
||||
expect(texteDe(section(e))).not.toContain('null');
|
||||
});
|
||||
|
||||
test('le modèle des objectifs, rechoisi tel quel, deux tables du même numéro comprises : un aperçu sans refus, et l’import n’en refuse aucune ligne', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
choisirNature(e, 'objectifs');
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
await choisir(e, e.enregistres[0].nomPropose, e.enregistres[0].octets);
|
||||
expect(dans(e, '[data-apercu] [data-refus]')).toBeNull();
|
||||
const { rendu } = await importer(e, 'importerObjectifs');
|
||||
expect(rendu.resume).toMatchObject({ ajoutes: 2, remplaces: 0, retires: 0, refusees: [] });
|
||||
});
|
||||
|
||||
test('sans table, le modèle des objectifs est refusé à côté de la commande, et son remède ouvre la section des tables', async () => {
|
||||
const e = await ouvrir();
|
||||
choisirNature(e, 'objectifs');
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-refus="OBJECTIFS_SANS_TABLE"]')).not.toBeNull());
|
||||
expect(e.enregistres).toEqual([]);
|
||||
const remede = dans(e, '[data-refus="OBJECTIFS_SANS_TABLE"] [data-commande="ouvrirSection"]');
|
||||
expect(remede.textContent.trim()).toBe(t('communs.remede.ouvrirSection.tables'));
|
||||
choisirNature(e, 'participants');
|
||||
expect(dans(e, '[data-refus="OBJECTIFS_SANS_TABLE"]')).toBeNull();
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect(e.enregistres[0].nomPropose).toBe('modele-participants.csv');
|
||||
});
|
||||
|
||||
test('chaque numéro de table partagé : le modèle des objectifs est refusé à côté de la commande, remède vers la section des tables', async () => {
|
||||
const e = await ouvrir({ tables: [table(1, 4), table(2, 4)] });
|
||||
choisirNature(e, 'objectifs');
|
||||
commande(e, 'telechargerModele').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-refus="OBJECTIFS_NUMEROS_PARTAGES"]')).not.toBeNull());
|
||||
expect(texteDe(dans(e, '[data-refus="OBJECTIFS_NUMEROS_PARTAGES"]'))).toContain(t('OBJECTIFS_NUMEROS_PARTAGES'));
|
||||
expect(e.enregistres).toEqual([]);
|
||||
const remede = dans(e, '[data-refus="OBJECTIFS_NUMEROS_PARTAGES"] [data-commande="ouvrirSection"]');
|
||||
expect(remede.textContent.trim()).toBe(t('communs.remede.ouvrirSection.tables'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@
|
|||
// libelle ; la façade l'enregistre sous ‹base›-refus.csv. La détection
|
||||
// d'encodage ou de séparateur ne se force pas : l'opérateur ferme l'aperçu
|
||||
// et corrige le fichier.
|
||||
//
|
||||
// « Télécharger un modèle », sous le choix de la nature, remet à
|
||||
// facade.enregistrerSous le modèle de la nature cochée
|
||||
// (modeleATelecharger) : elle n'écrit rien dans l'événement, et part en
|
||||
// lecture comme sur un plan bloqué. Son nom accessible nomme la nature ;
|
||||
// le chemin enregistré s'annonce sous elle, sous la seule nature de son
|
||||
// modèle ; un enregistrement annulé n'annonce rien. Le modèle des
|
||||
// objectifs se refuse à côté d'elle sans table, ou quand chaque numéro de
|
||||
// table est partagé, avec le remède qui ouvre la section des tables ; la
|
||||
// commande se recrée à chaque nature, son refus avec elle.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { bilanRemplacement, bilanRemplacementObjectifs, decoder, exporterRefus } from '../../application/csv.js';
|
||||
|
|
@ -48,6 +58,7 @@
|
|||
apercevoirSelon,
|
||||
avecEntetesDeLaGrille,
|
||||
modeleApercu,
|
||||
modeleATelecharger,
|
||||
modePropose,
|
||||
modesDImport,
|
||||
} from './modele.js';
|
||||
|
|
@ -78,8 +89,14 @@
|
|||
let confirmation = $state.raw(null);
|
||||
// Le chemin où le CSV des refus vient d'être enregistré.
|
||||
let enregistre = $state(null);
|
||||
// Le dernier modèle enregistré : { nature, chemin } ; il ne s'annonce que
|
||||
// sous sa nature.
|
||||
let modeleEnregistre = $state.raw(null);
|
||||
|
||||
const charge = $derived($seance.charge ?? null);
|
||||
// Le nom accessible de « Télécharger un modèle » sous la nature cochée.
|
||||
const nomDuModele = $derived(t(NATURES_D_IMPORT.find((offerte) => offerte.nature === nature).modele));
|
||||
const cheminDuModele = $derived(modeleEnregistre?.nature === nature ? modeleEnregistre.chemin : null);
|
||||
const participants = $derived(charge?.participants ?? []);
|
||||
const apercu = $derived(
|
||||
source === null || source.refusDecodage !== null || charge === null
|
||||
|
|
@ -116,6 +133,15 @@
|
|||
repartir();
|
||||
}
|
||||
|
||||
// Le modèle de la nature cochée, remis à la façade sous son nom.
|
||||
async function telechargerModele() {
|
||||
const { evenement, charge: courante } = seance.etat();
|
||||
const saNature = nature;
|
||||
const { nom, octets } = modeleATelecharger(saNature, { base: evenement.base, charge: courante });
|
||||
const chemin = await facade.enregistrerSous(nom, octets);
|
||||
modeleEnregistre = chemin === null ? null : { nature: saNature, chemin };
|
||||
}
|
||||
|
||||
// Les octets d'un fichier : leur texte décodé, ou le refus de decoder,
|
||||
// qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
|
||||
function lireFichier({ nom, octets }) {
|
||||
|
|
@ -269,6 +295,21 @@
|
|||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<div class="modele">
|
||||
{#key nature}
|
||||
<Commande
|
||||
cle="import.commande.telechargerModele"
|
||||
nom="telechargerModele"
|
||||
nomAccessible={nomDuModele}
|
||||
executer={telechargerModele}
|
||||
/>
|
||||
{/key}
|
||||
{#if cheminDuModele !== null}
|
||||
<p role="status" data-modele-enregistre>
|
||||
<span>{t('import.modele.enregistre')}</span> <span class="chemin">{cheminDuModele}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
|
||||
{#if source !== null}
|
||||
|
|
@ -362,6 +403,19 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.modele {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin-top: var(--espace-3);
|
||||
}
|
||||
|
||||
/* Un chemin long se coupe où il faut, plutôt que de déborder. */
|
||||
.chemin {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -13,15 +13,26 @@
|
|||
// participants porte souvent une colonne « Table » d'une soirée passée, et
|
||||
// un en-tête d'objectifs lu sous Participants reste refusé, jamais lu comme
|
||||
// des objectifs.
|
||||
import { CHAMPS, CHAMPS_OBJECTIFS, apercevoir, apercevoirObjectifs } from '../../application/csv.js';
|
||||
//
|
||||
// Le modèle à télécharger suit la nature cochée : le modèle fixe des
|
||||
// participants, ou celui des objectifs de l'événement ouvert.
|
||||
import {
|
||||
CHAMPS,
|
||||
CHAMPS_OBJECTIFS,
|
||||
apercevoir,
|
||||
apercevoirObjectifs,
|
||||
modeleObjectifs,
|
||||
modeleParticipants,
|
||||
} from '../../application/csv.js';
|
||||
|
||||
/** Les deux natures d'un fichier importé (§ 10.4), dans l'ordre où l'écran
|
||||
* les offre, et la clé de leur nom. */
|
||||
* les offre, la clé de leur nom, et celle du nom accessible de
|
||||
* « Télécharger un modèle » sous cette nature. */
|
||||
export const NATURES_D_IMPORT = Object.freeze(
|
||||
[
|
||||
['participants', 'import.nature.participants'],
|
||||
['objectifs', 'import.nature.objectifs'],
|
||||
].map(([nature, cle]) => Object.freeze({ nature, cle })),
|
||||
['participants', 'import.nature.participants', 'import.modele.participants'],
|
||||
['objectifs', 'import.nature.objectifs', 'import.modele.objectifs'],
|
||||
].map(([nature, cle, modele]) => Object.freeze({ nature, cle, modele })),
|
||||
);
|
||||
|
||||
/** La nature cochée à l'ouverture de la section : les participants. */
|
||||
|
|
@ -117,6 +128,28 @@ export function modesDImport(nature) {
|
|||
return exigerNature(nature) === 'participants' ? MODES_D_IMPORT : MODES_D_IMPORT_OBJECTIFS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le modèle à télécharger sous une nature (§ 10.1, § 10.4), et le nom que
|
||||
* l'enregistrement propose : modele-participants.csv pour le modèle fixe des
|
||||
* participants, qui ne dépend d'aucun événement ; ‹base›-modele-objectifs.csv
|
||||
* pour celui des objectifs, fait des tables et des tours de l'événement, sur
|
||||
* le patron de ‹base›-objectifs.csv. Rien ne s'écrit dans la charge.
|
||||
*
|
||||
* @param {'participants'|'objectifs'} nature
|
||||
* @param {{base: string, charge: Object}} evenement la base de l'événement
|
||||
* ouvert et sa charge
|
||||
* @returns {{nom: string, octets: Uint8Array}}
|
||||
* @throws {RangeError} une nature inconnue
|
||||
* @throws {import('../../application/erreurs.js').ErreurCommande}
|
||||
* sous Objectifs, OBJECTIFS_SANS_TABLE pour un événement sans table et
|
||||
* OBJECTIFS_NUMEROS_PARTAGES pour un événement dont chaque numéro de table
|
||||
* est partagé
|
||||
*/
|
||||
export function modeleATelecharger(nature, { base, charge }) {
|
||||
if (exigerNature(nature) === 'participants') return { nom: 'modele-participants.csv', octets: modeleParticipants() };
|
||||
return { nom: `${base}-modele-objectifs.csv`, octets: modeleObjectifs(charge) };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'aperçu d'un texte décodé sous la nature choisie, contre la charge : celui
|
||||
* des participants (apercevoir, contre ses personnes et ses tables, dont les
|
||||
|
|
|
|||
|
|
@ -6,12 +6,22 @@
|
|||
// celle du remède de chaque refus global, l'encodage et le séparateur
|
||||
// nommés, les titres que « Remplacer » annonce, comptés par la règle unique
|
||||
// des titres pourvus, la nature du fichier reçue et jamais devinée, les
|
||||
// objectifs de table et les étiquettes fondues, à part des appartenances.
|
||||
// objectifs de table et les étiquettes fondues, à part des appartenances ;
|
||||
// le modèle à télécharger de chaque nature, son nom et son nom accessible.
|
||||
// Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { apercevoir, apercevoirObjectifs, bilanRemplacement, CHAMPS, CHAMPS_OBJECTIFS, decoder } from '../../application/csv.js';
|
||||
import {
|
||||
apercevoir,
|
||||
apercevoirObjectifs,
|
||||
bilanRemplacement,
|
||||
CHAMPS,
|
||||
CHAMPS_OBJECTIFS,
|
||||
decoder,
|
||||
modeleObjectifs,
|
||||
modeleParticipants,
|
||||
} from '../../application/csv.js';
|
||||
import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
|
|
@ -31,6 +41,7 @@ import {
|
|||
messageDuBloc,
|
||||
modesDImport,
|
||||
modeleApercu,
|
||||
modeleATelecharger,
|
||||
modePropose,
|
||||
motifLigne,
|
||||
nommer,
|
||||
|
|
@ -491,7 +502,7 @@ const TEXTE_DES_MOTIFS = lignes(
|
|||
'3;;finance;',
|
||||
'2;2.0;finance;',
|
||||
'2;5;finance;',
|
||||
'2;1;;',
|
||||
'2;1;;ou',
|
||||
`2;1;${'x'.repeat(41)};`,
|
||||
`2;1;${VINGT_ET_UNE};`,
|
||||
'2;1;finance;peut-être',
|
||||
|
|
@ -747,3 +758,39 @@ describe('le rapport et la fenêtre de « Remplacer » selon la nature', () => {
|
|||
assert.ok(t('import.remplacer.titreObjectifs').length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le modèle à télécharger de chaque nature (§ 10.1, § 10.4)', () => {
|
||||
test('chaque nature porte la clé du nom accessible de sa commande, qui nomme la nature après le libellé visible', () => {
|
||||
const visible = t('import.commande.telechargerModele');
|
||||
assert.equal(visible, 'Télécharger un modèle');
|
||||
const noms = NATURES_D_IMPORT.map(({ modele }) => t(modele));
|
||||
assert.deepEqual(noms, ['Télécharger un modèle de liste de participants', "Télécharger un modèle d'objectifs de table"]);
|
||||
for (const nom of noms) assert.ok(nom.startsWith(visible), nom);
|
||||
assert.ok(t('import.modele.enregistre').length > 0);
|
||||
});
|
||||
|
||||
test('Participants : le modèle fixe, sous modele-participants.csv, quel que soit l’événement', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const { nom, octets } = modeleATelecharger('participants', { base: 'soiree', charge });
|
||||
assert.equal(nom, 'modele-participants.csv');
|
||||
assert.deepEqual(octets, modeleParticipants());
|
||||
});
|
||||
|
||||
test('Objectifs de table : le modèle de l’événement ouvert, sous ‹base›-modele-objectifs.csv', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const { nom, octets } = modeleATelecharger('objectifs', { base: 'soiree', charge });
|
||||
assert.equal(nom, 'soiree-modele-objectifs.csv');
|
||||
assert.deepEqual(octets, modeleObjectifs(charge));
|
||||
});
|
||||
|
||||
test('un événement sans table, ou dont chaque numéro est partagé, refuse le modèle des objectifs, jamais celui des participants ; une nature inconnue lève', () => {
|
||||
const charge = chargeDesObjectifs({ tables: [] });
|
||||
assert.throws(() => modeleATelecharger('objectifs', { base: 'soiree', charge }), { code: 'OBJECTIFS_SANS_TABLE' });
|
||||
const partages = chargeDesObjectifs();
|
||||
for (const table of partages.tables) table.numero = 7;
|
||||
assert.throws(() => modeleATelecharger('objectifs', { base: 'soiree', charge: partages }), { code: 'OBJECTIFS_NUMEROS_PARTAGES' });
|
||||
assert.equal(modeleATelecharger('participants', { base: 'soiree', charge: partages }).nom, 'modele-participants.csv');
|
||||
assert.equal(modeleATelecharger('participants', { base: 'soiree', charge }).nom, 'modele-participants.csv');
|
||||
assert.throws(() => modeleATelecharger('tables', { base: 'soiree', charge }), RangeError);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export const TABLE = new Map([
|
|||
['import.commande.fermerRapport', 'Fermer le rapport'],
|
||||
['import.refusEnregistres', 'Lignes refusées enregistrées :'],
|
||||
|
||||
// --- Le modèle à télécharger, de la nature cochée (§ 10.1, § 10.4) -----------
|
||||
// Le nom accessible de la commande commence par son libellé visible, puis
|
||||
// nomme la nature : le même texte ne désigne pas deux fichiers.
|
||||
['import.commande.telechargerModele', 'Télécharger un modèle'],
|
||||
['import.modele.participants', 'Télécharger un modèle de liste de participants'],
|
||||
['import.modele.objectifs', "Télécharger un modèle d'objectifs de table"],
|
||||
['import.modele.enregistre', 'Modèle enregistré :'],
|
||||
|
||||
// --- La sorte d'un message de l'aperçu et du rapport (messageDuBloc) ------
|
||||
['import.sorte.refus', 'Refus'],
|
||||
['import.sorte.avis', 'Avis'],
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ const ENTREES = [
|
|||
['data-mode', 'événement : la barre, la tête et la surface de l’événement, par son mode', null],
|
||||
['data-mode-libelle', 'le libellé de mode', ['bandeau', 'barre']],
|
||||
['data-mode-objectifs', 'génération : le groupe du choix du mode des objectifs', ['']],
|
||||
['data-modele-enregistre', 'import : l’annonce du chemin où le modèle à télécharger est enregistré', ['']],
|
||||
['data-motif', 'import : le motif d’une ligne refusée', ['']],
|
||||
['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null],
|
||||
['data-nature-import', 'import : le choix de la nature du fichier', ['']],
|
||||
|
|
|
|||