-
{$seance.evenement?.nom}
+{#snippet tete()}
+
-
+{/snippet}
+
+
+
+ {#if $seance.supportRompu !== null}
+
+ {/if}
+
+
+
diff --git a/src/interface/evenement/modele.js b/src/interface/evenement/modele.js
new file mode 100644
index 0000000..44176d8
--- /dev/null
+++ b/src/interface/evenement/modele.js
@@ -0,0 +1,134 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le modèle de l'événement ouvert, pur : ce que ses composants montrent de
+// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre
+// de mode et sa forme, la commande de la place fixe, les états du plan et
+// leurs clés, la reprise de verrou que l'écran offre, et les avertissements
+// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé
+// que la séance ne décide déjà : le refus courant, le mode et l'état du plan
+// se lisent dans EtatSeance, jamais se recalculent.
+import { cleDeMode } from '../mode.js';
+
+// La forme du cadre pour chaque clé du libellé de mode : un trait différent
+// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5).
+const FORMES = new Map([
+ ['coquille.mode.lecture', 'tirets'],
+ ['coquille.mode.ecriture', 'plein'],
+ ['coquille.mode.bloque', 'double'],
+]);
+
+/**
+ * Libellé et forme du cadre de mode : cle, celle de cleDeMode (mode.js) — le
+ * libellé du bandeau — ; forme : lecture (tirets), écriture (trait plein),
+ * bloqué (trait double), quel que soit le mode d'un plan bloqué. null sans
+ * événement.
+ *
+ * @param {Object} etatSeance EtatSeance
+ * @returns {null|{cle: string, forme: 'tirets'|'plein'|'double'}}
+ */
+export function cadreDeMode(etatSeance) {
+ const cle = cleDeMode(etatSeance);
+ if (cle === null) return null;
+ return { cle, forme: FORMES.get(cle) };
+}
+
+/**
+ * La commande de la place fixe (§ 8.4, § 8.5, § 8.8) : 'format' quand le
+ * refus courant est celui d'un format plus récent — l'explication remplace
+ * alors toute commande, plan bloqué compris ; 'debloquer' sur un plan
+ * bloqué ; 'lecture' — « Revenir en lecture » — en écriture ; 'modifier'
+ * sinon. null sans événement.
+ *
+ * @param {Object} etatSeance EtatSeance
+ * @returns {null|'format'|'debloquer'|'lecture'|'modifier'}
+ */
+export function commandeFixe(etatSeance) {
+ if (etatSeance.evenement === null) return null;
+ const { refus } = etatSeance;
+ if (refus?.code === 'LECTURE_SEULE' && refus.details?.raison === 'FORMAT') return 'format';
+ if (etatSeance.evenement.etat === 'bloque') return 'debloquer';
+ return etatSeance.mode === 'ecriture' ? 'lecture' : 'modifier';
+}
+
+/** Les états d'un plan (§ 9), dans l'ordre du spec. */
+export const ETATS_PLAN = Object.freeze(['brouillon', 'propose', 'retenu', 'bloque']);
+
+/**
+ * La clé du libellé d'un état du plan, evenement.etat.
.
+ *
+ * @param {string} etat
+ * @returns {string}
+ * @throws {RangeError} hors d'ETATS_PLAN
+ */
+export function cleEtat(etat) {
+ if (!ETATS_PLAN.includes(etat)) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
+ return `evenement.etat.${etat}`;
+}
+
+/**
+ * Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a
+ * refusé le dernier passage en écriture quand sa vie ne se prouve pas
+ * (vivant null) — un autre poste, un verrou illisible. Un verrou vivant ne
+ * se reprend jamais, et celui d'une séance morte, la séance le reprend
+ * seule. null sinon.
+ *
+ * @param {Object} etatSeance EtatSeance
+ * @returns {null|{seance: string|null, depuis: string|null, vivant: null}}
+ */
+export function repriseOfferte(etatSeance) {
+ const { evenement, mode, verrouAutre } = etatSeance;
+ if (evenement === null || mode !== 'lecture' || verrouAutre === null || verrouAutre.vivant !== null) return null;
+ return verrouAutre;
+}
+
+// Le remède que l'écran joint à un avertissement : rouvrir la version
+// précédente d'un retenu fautif ; ouvrir les propositions devant une dérive,
+// et les tables devant des places manquantes. Les autres n'en ont pas.
+const VERS_PROPOSITIONS = Object.freeze({ geste: 'ouvrirSection', section: 'propositions' });
+const REMEDES = new Map([
+ ['RETENU_FAUTIF', Object.freeze({ geste: 'ouvrirSecours', source: 'precedent' })],
+ ['DERIVE', VERS_PROPOSITIONS],
+ ['DERIVE_NOUVELLE', VERS_PROPOSITIONS],
+ ['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
+]);
+
+// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
+// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
+// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
+// que l'examen ne porte pas n'apparaît pas.
+function derivesNommees({ ids = [], retenu = false }, examen) {
+ if (examen === null || examen === undefined) return [];
+ const nommees = [];
+ if (retenu && examen.retenu.raisons.length > 0) nommees.push({ id: 'retenu', raisons: examen.retenu.raisons });
+ for (const id of ids) {
+ const derive = examen.derives.find((candidate) => candidate.id === id);
+ if (derive !== undefined) nommees.push({ id, raisons: derive.raisons });
+ }
+ return nommees;
+}
+
+/**
+ * Les avertissements à montrer, dans l'ordre d'EtatSeance.avertissements —
+ * celui de la séance : ceux de l'Ouvert courant, INVARIANT_VIOLE, ceux du
+ * dernier geste, RETOUR_AUTOMATIQUE —, sans en retirer ni en ajouter. Chacun
+ * garde son code et ses détails, et reçoit son remède : RETENU_FAUTIF →
+ * { geste: 'ouvrirSecours', source: 'precedent' } ; DERIVE et
+ * DERIVE_NOUVELLE → { geste: 'ouvrirSection', section: 'propositions' } ;
+ * PLACES_MANQUANTES → { geste: 'ouvrirSection', section: 'tables' } ; null
+ * pour les autres. PROPOSITIONS_FAUTIVES porte en plus fautives, celles
+ * d'EtatSeance.fautives ; DERIVE, derives, ce que l'examen dit de chaque
+ * placement qu'elle nomme ({id: number|'retenu', raisons}). Le texte vient
+ * de la traduction, sur le code.
+ *
+ * @param {Object} etatSeance EtatSeance
+ * @returns {Array<{code: string, details: Object, remede: Object|null, fautives?: Object[], derives?: Object[]}>}
+ */
+export function avertissementsOrdonnes(etatSeance) {
+ return etatSeance.avertissements.map(({ code, details }) => {
+ const rendu = { code, details, remede: REMEDES.get(code) ?? null };
+ if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
+ if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
+ return rendu;
+ });
+}
diff --git a/src/interface/evenement/modele.test.js b/src/interface/evenement/modele.test.js
new file mode 100644
index 0000000..23618ea
--- /dev/null
+++ b/src/interface/evenement/modele.test.js
@@ -0,0 +1,218 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le modèle de l'événement ouvert (§ 8.4, § 8.5, § 8.8, § 8.9, § 9) : le cadre
+// de mode et sa forme, la commande de la place fixe, les états du plan et
+// leurs clés, la reprise de verrou offerte, et les avertissements ordonnés,
+// chacun avec son remède. Chaque épreuve part d'un EtatSeance réduit à ce que
+// la fonction lit ; les noms et identifiants sont inventés.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../../test/lanceur.js';
+import { TABLES_APPLICATION } from '../../application/libelles.js';
+import { creerTraduction } from '../../application/traduction.js';
+import { TABLES_INTERFACE } from '../libelles.js';
+import { ETATS_PLAN, avertissementsOrdonnes, cadreDeMode, cleEtat, commandeFixe, repriseOfferte } from './modele.js';
+
+const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
+
+const FORMAT_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'FORMAT', remede: null } };
+const BLOQUE_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
+
+// Un EtatSeance réduit : mode, événement (null, ou son état du plan), refus,
+// avertissements, fautives, examen, verrouAutre.
+function etat({
+ mode = 'lecture',
+ plan = 'brouillon',
+ refus = null,
+ avertissements = [],
+ fautives = [],
+ examen = null,
+ verrouAutre = null,
+} = {}) {
+ return {
+ mode,
+ evenement:
+ plan === null ? null : { base: 'merles', id: 'evt-merles', nom: 'Club des Merles', etat: plan, revision: 3, annonceFormat: null },
+ refus,
+ avertissements,
+ fautives,
+ examen,
+ verrouAutre,
+ };
+}
+
+describe('cadreDeMode (§ 8.4, § 14.5)', () => {
+ test('null sans événement', () => {
+ assert.equal(cadreDeMode(etat({ plan: null })), null);
+ });
+
+ test('lecture : tirets ; écriture : trait plein ; bloqué : trait double, quel que soit le mode', () => {
+ assert.deepEqual(cadreDeMode(etat({ mode: 'lecture' })), { cle: 'coquille.mode.lecture', forme: 'tirets' });
+ assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture' })), { cle: 'coquille.mode.ecriture', forme: 'plein' });
+ assert.deepEqual(cadreDeMode(etat({ mode: 'lecture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
+ assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
+ });
+
+ test('trois formes distinctes pour trois libellés distincts, chaque libellé traduit', () => {
+ const cadres = [etat(), etat({ mode: 'ecriture' }), etat({ plan: 'bloque' })].map(cadreDeMode);
+ assert.equal(new Set(cadres.map(({ forme }) => forme)).size, 3);
+ assert.equal(new Set(cadres.map(({ cle }) => t(cle))).size, 3);
+ });
+});
+
+describe('commandeFixe (§ 8.4, § 8.5, § 8.8)', () => {
+ test('null sans événement ; modifier en lecture ; lecture en écriture ; debloquer sur un plan bloqué', () => {
+ assert.equal(commandeFixe(etat({ plan: null })), null);
+ assert.equal(commandeFixe(etat()), 'modifier');
+ assert.equal(commandeFixe(etat({ mode: 'ecriture', plan: 'retenu' })), 'lecture');
+ assert.equal(commandeFixe(etat({ plan: 'bloque', refus: BLOQUE_REFUS })), 'debloquer');
+ });
+
+ test("un format plus récent remplace la commande par l'explication, avant le plan bloqué", () => {
+ assert.equal(commandeFixe(etat({ refus: FORMAT_REFUS })), 'format');
+ assert.equal(commandeFixe(etat({ plan: 'bloque', refus: FORMAT_REFUS })), 'format');
+ });
+});
+
+describe('les états du plan (§ 9)', () => {
+ test("les quatre états, dans l'ordre du § 9, figés, chacun sous sa clé evenement.etat., traduite", () => {
+ assert.deepEqual(ETATS_PLAN, ['brouillon', 'propose', 'retenu', 'bloque']);
+ assert.ok(Object.isFrozen(ETATS_PLAN));
+ assert.ok(ETATS_PLAN.length > 0);
+ for (const nom of ETATS_PLAN) {
+ assert.equal(cleEtat(nom), `evenement.etat.${nom}`);
+ assert.notEqual(t(cleEtat(nom)).trim(), '');
+ }
+ assert.equal(new Set(ETATS_PLAN.map((nom) => t(cleEtat(nom)))).size, 4);
+ });
+
+ test('un état inconnu lève RangeError', () => {
+ assert.throws(() => cleEtat('archive'), RangeError);
+ assert.throws(() => cleEtat('toString'), RangeError);
+ });
+});
+
+describe('repriseOfferte (§ 8.8)', () => {
+ const lointain = { seance: 'lointaine-1', depuis: '2031-05-17T09:00:00-04:00', vivant: null };
+
+ test('en lecture, un verrou refusé dont la vie ne se prouve pas : le verrou, à reprendre', () => {
+ assert.deepEqual(repriseOfferte(etat({ verrouAutre: lointain })), lointain);
+ const illisible = { seance: null, depuis: null, vivant: null };
+ assert.deepEqual(repriseOfferte(etat({ verrouAutre: illisible })), illisible);
+ });
+
+ test('ni sans verrou refusé, ni en écriture, ni sans événement, ni pour un verrou vivant ou mort', () => {
+ assert.equal(repriseOfferte(etat()), null);
+ assert.equal(repriseOfferte(etat({ mode: 'ecriture', verrouAutre: lointain })), null);
+ assert.equal(repriseOfferte(etat({ plan: null, verrouAutre: lointain })), null);
+ assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: true } })), null);
+ assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: false } })), null);
+ });
+});
+
+describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
+ // Un avertissement de chaque code du contrat, dans l'ordre où la séance
+ // les rend, avec des détails de forme quelconque : la fonction n'en lit
+ // aucun, hors de ceux qu'elle complète.
+ const CODES = [
+ 'RETENU_FAUTIF',
+ 'JOURNAL_REPRIS',
+ 'JOURNAL_EN_AVANCE_DE',
+ 'JOURNAL_INCOMPLET',
+ 'JOURNAL_ECARTEES',
+ 'JOURNAL_RECREE',
+ 'JOURNAL_FORMAT_PLUS_RECENT',
+ 'JOURNAL_ILLISIBLE',
+ 'JOURNAL_ETRANGER',
+ 'PROPOSITIONS_FAUTIVES',
+ 'PROPOSITIONS_MANQUANTES',
+ 'DERIVE',
+ 'INVARIANT_VIOLE',
+ 'VERROU_REPRIS',
+ 'EVENEMENT_CHANGE',
+ 'VERROU_NON_RENDU',
+ 'JALON_NON_ECRIT',
+ 'SUPPORT_AMOVIBLE',
+ 'RESERVATIONS_SUSPENDUES',
+ 'IMPORT_EN_RETENU',
+ 'PLACES_MANQUANTES',
+ 'DERIVE_NOUVELLE',
+ 'RESERVATION_NON_HONOREE',
+ 'CONFIGURATION_INCOHERENTE',
+ 'RETOUR_AUTOMATIQUE',
+ ];
+ const REMEDES = new Map([
+ ['RETENU_FAUTIF', { geste: 'ouvrirSecours', source: 'precedent' }],
+ ['DERIVE', { geste: 'ouvrirSection', section: 'propositions' }],
+ ['DERIVE_NOUVELLE', { geste: 'ouvrirSection', section: 'propositions' }],
+ ['PLACES_MANQUANTES', { geste: 'ouvrirSection', section: 'tables' }],
+ ]);
+ const avertissement = (code, rang) => ({ code, details: { rang, ids: [], retenu: false } });
+
+ test('[] sans avertissement', () => {
+ assert.deepEqual(avertissementsOrdonnes(etat()), []);
+ assert.deepEqual(avertissementsOrdonnes(etat({ plan: null })), []);
+ });
+
+ test("l'ordre de la séance, codes et détails gardés, chacun avec son remède, null hors des quatre", () => {
+ const recus = CODES.map(avertissement);
+ const rendus = avertissementsOrdonnes(etat({ avertissements: recus }));
+ assert.equal(rendus.length, CODES.length);
+ assert.ok(rendus.length > 0);
+ rendus.forEach((rendu, rang) => {
+ assert.equal(rendu.code, CODES[rang]);
+ assert.deepEqual(rendu.details, recus[rang].details);
+ assert.deepEqual(rendu.remede, REMEDES.get(rendu.code) ?? null, rendu.code);
+ });
+ });
+
+ test("l'ordre est celui reçu, jamais un ordre propre : la liste renversée revient renversée", () => {
+ const renverses = [...CODES].reverse().map(avertissement);
+ assert.deepEqual(
+ avertissementsOrdonnes(etat({ avertissements: renverses })).map(({ code }) => code),
+ [...CODES].reverse(),
+ );
+ });
+
+ test('PROPOSITIONS_FAUTIVES porte les fautives de la séance, et lui seul', () => {
+ const fautives = [{ rang: 1, id: 4, fautes: [{ code: 'LONGUEUR', chemin: 'tours' }] }];
+ const rendus = avertissementsOrdonnes(
+ etat({ fautives, avertissements: [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [4] } }, avertissement('DERIVE_NOUVELLE', 0)] }),
+ );
+ assert.deepEqual(rendus[0].fautives, fautives);
+ assert.equal(Object.hasOwn(rendus[1], 'fautives'), false);
+ });
+
+ test("DERIVE porte ce que l'examen dit de chaque placement nommé : le retenu d'abord, puis chaque proposition de ids", () => {
+ const raisonRetenu = { code: 'PARTICIPANT_EXCLU', participant: 2 };
+ const raisonCinq = { code: 'TOURS_CHANGES', avant: 3, maintenant: 2 };
+ const examen = {
+ derives: [
+ { id: 5, raisons: [raisonCinq] },
+ { id: 9, raisons: [raisonCinq] },
+ ],
+ retenu: { fautes: [], raisons: [raisonRetenu] },
+ };
+ const [derive] = avertissementsOrdonnes(
+ etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [5], retenu: true } }] }),
+ );
+ assert.deepEqual(derive.derives, [
+ { id: 'retenu', raisons: [raisonRetenu] },
+ { id: 5, raisons: [raisonCinq] },
+ ]);
+ const [seule] = avertissementsOrdonnes(etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
+ assert.deepEqual(seule.derives, [{ id: 9, raisons: [raisonCinq] }]);
+ const [sansExamen] = avertissementsOrdonnes(etat({ avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
+ assert.deepEqual(sansExamen.derives, []);
+ });
+
+ test('chaque remède rendu se traduit : sa clé existe', () => {
+ const rendus = avertissementsOrdonnes(etat({ avertissements: CODES.map(avertissement) }));
+ const avecRemede = rendus.filter(({ remede }) => remede !== null);
+ assert.equal(avecRemede.length, 4);
+ for (const { remede } of avecRemede) {
+ const cle = remede.geste === 'ouvrirSecours' ? `communs.remede.ouvrirSecours.${remede.source}` : `communs.remede.ouvrirSection.${remede.section}`;
+ assert.notEqual(t(cle).trim(), '');
+ }
+ });
+});
diff --git a/src/interface/import/Apercu.svelte b/src/interface/import/Apercu.svelte
new file mode 100644
index 0000000..46d4220
--- /dev/null
+++ b/src/interface/import/Apercu.svelte
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+ {#if source.origine === 'fichier'}
+ - {t('import.apercu.fichier')}
+ - {source.nom}
+ {:else}
+ - {t('import.apercu.collage')}
+
+ {/if}
+ {#if modele.encodage !== null}
+ - {t('import.apercu.encodage')}
+ - {t(modele.encodage)}
+ {/if}
+ {#if modele.separateur !== null}
+ - {t('import.apercu.separateur')}
+ - {t(modele.separateur)}
+ {/if}
+
+
+ {#if global !== null}
+
+
{t(global.cle, global.details)}
+ {#if !global.remede.dansLaCause}
+
{t(global.remede.cle, global.remede.details)}
+ {/if}
+
+ {/if}
+
+ {#if modele.colonnes.length > 0}
+
+ {t('import.apercu.colonnes')}
+
+
+ | {t('import.apercu.entete')} |
+ {t('import.apercu.devient')} |
+
+
+
+ {#each modele.colonnes as colonne (colonne.rang)}
+
+ | {colonne.entete} |
+ {t(colonne.cle)} |
+
+ {/each}
+
+
+ {/if}
+
+ {#if choisir !== null}
+ {#each modele.ambigus as ambigu (ambigu.champ)}
+
+ {/each}
+ {/if}
+
+ {#if global === null || modele.valides > 0}
+
+ {format.nombre(modele.valides)}
+ {t('import.apercu.valides', { n: modele.valides })}
+
+ {/if}
+
+ {#if modele.lignes.length > 0}
+
+
+
+
+ | {t('import.apercu.ligne')} |
+ {#each COLONNES_DES_LIGNES as { champ, cle } (champ)}
+ {t(cle)} |
+ {/each}
+
+
+
+ {#each modele.lignes as { ligne, cellules } (ligne)}
+
+ | {format.nombre(ligne)} |
+ {#each cellules as { champ, texte, cle } (champ)}
+ {cle === null ? (texte ?? '') : t(cle)} |
+ {/each}
+
+ {/each}
+
+
+
+ {/if}
+
+ {#if refuseesMontrees.length > 0}
+
+ {t('import.apercu.refusees')}
+
+
+ | {t('import.apercu.ligne')} |
+ {t('import.apercu.motif')} |
+
+
+
+ {#each refuseesMontrees as { ligne, code, details } (ligne)}
+
+ | {format.nombre(ligne)} |
+
+ {motif(code, details)}
+ {#if details.participants !== undefined}
+ {t('import.rapport.designees')}
+ {#each nommer(participants, details.participants) as personne (personne.id)}
+
+ {#if personne.prenom !== null}{personne.prenom}{/if}
+ {personne.nom}
+
+ {/each}
+ {/if}
+ |
+
+ {/each}
+
+
+ {/if}
+
+ {#if modele.fusions.length > 0}
+
+ {t('import.apercu.fusions')}
+
+ {#each modele.fusions as fusion, rang (rang)}
+ -
+ {t('import.apercu.affichee')}
+ {fusion.affichee}
+
+ {#each fusion.orthographes as orthographe (orthographe.texte)}
+ -
+ {orthographe.texte}
+ {format.nombre(orthographe.lignes)}
+ {t('import.apercu.orthographeLignes', { n: orthographe.lignes })}
+
+ {/each}
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if modele.doublons.length > 0}
+
+ {t('import.apercu.doublons')}
+
+ {#each modele.doublons as doublon, rang (rang)}
+ -
+ {t('import.apercu.doublonLignes')}
+ {#each doublon.lignes as ligne (ligne)}
+ {format.nombre(ligne)}
+ {/each}
+ {#if doublon.existants.length > 0}
+ {t('import.apercu.doublonExistants')}
+ {#each nommer(participants, doublon.existants) as personne (personne.id)}
+
+ {#if personne.prenom !== null}{personne.prenom}{/if}
+ {personne.nom}
+
+ {/each}
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+ {#if modele.homonymes.length > 0}
+
+ {t('import.apercu.homonymes')}
+
+ {#each modele.homonymes as homonyme (homonyme.ligne)}
+ -
+ {t('import.apercu.ligne')}
+ {format.nombre(homonyme.ligne)}
+ {#each nommer(participants, homonyme.participants) as personne (personne.id)}
+
+ {#if personne.prenom !== null}{personne.prenom}{/if}
+ {personne.nom}
+
+ {/each}
+
+ {/each}
+
+
+ {/if}
+
+ {#if modele.multilignes.length > 0}
+
+ {t('import.apercu.multilignes')}
+
+ {#each modele.multilignes as multiligne (multiligne.ligne)}
+ -
+ {t('import.apercu.ligne')}
+ {format.nombre(multiligne.ligne)}
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js
new file mode 100644
index 0000000..227b41a
--- /dev/null
+++ b/src/interface/import/Import.navigateur.test.js
@@ -0,0 +1,824 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2,
+// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le
+// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
+// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois
+// modes, l'import selon l'état, le collage. 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
+// reçoit (contrat de données, « Les dialogues de fichiers »).
+//
+// Les octets de chaque cas se construisent ici ; seuls les CSV livrés se
+// lisent, par leur adresse (?url) et fetch : ?raw les lirait en UTF-8 et
+// détruirait la variante windows-1252. Les valeurs attendues viennent des
+// mêmes implémentations que l'écran sollicite — decoder, apercevoir,
+// exporterRefus et libelle, bilanRemplacement —, appelées ici une seconde
+// fois (§ 19.1). Les noms d'épreuve sont inventés.
+//
+// Le projet navigateur emploie expect, et test et describe de Vitest
+// (§ 14.8).
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { flushSync } from 'svelte';
+import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
+import { creerFormat } from '../../application/format.js';
+import { creerTraduction } from '../../application/traduction.js';
+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 { serialiser } from '../../stockage/canonique.js';
+import { creerCharge } from '../../stockage/document.js';
+import { lireJournal } from '../../stockage/journal.js';
+import { SUFFIXES } from '../../stockage/noms.js';
+import { VERSION } from '../../version.genere.js';
+import { monterApp } from '../../../test/navigateur/montage.js';
+import { visible } from '../../../test/navigateur/rendu.js';
+import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
+import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
+import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
+import { TABLES_INTERFACE } from '../libelles.js';
+import { ENCODAGES, SEPARATEURS } from './modele.js';
+
+const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
+const format = creerFormat(t);
+
+const BASE = 'soiree';
+const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
+const OUVERTURE_TEMOIN = '\u{2045}';
+const FERMETURE_TEMOIN = '\u{2046}';
+
+// Les octets d'un CSV livré, tels que le fichier les porte.
+const lireLivre = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
+
+// Un texte en UTF-8 avec marque, en UTF-16 LE ou BE avec marque.
+const enUtf8 = (texte) => Uint8Array.from([...MARQUE_UTF8, ...new TextEncoder().encode(texte)]);
+function enUtf16(texte, ordre) {
+ const octets = [ordre === 'le' ? 0xff : 0xfe, ordre === 'le' ? 0xfe : 0xff];
+ for (let i = 0; i < texte.length; i += 1) {
+ const unite = texte.charCodeAt(i);
+ const fort = unite >> 8;
+ const faible = unite & 0xff;
+ octets.push(...(ordre === 'le' ? [faible, fort] : [fort, faible]));
+ }
+ return Uint8Array.from(octets);
+}
+
+// Un participant de la charge d'épreuve.
+const personne = (id, nom, prenom = null, autres = {}) => ({
+ id, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres,
+});
+
+// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
+// n'a rien reçu pendant l'épreuve.
+let montes = [];
+let erreursAvant = 0;
+beforeEach(() => {
+ erreursAvant = globalThis.__gttErreurs?.length ?? 0;
+});
+afterEach(() => {
+ for (const m of montes.reverse()) m.demonter();
+ montes = [];
+ vi.restoreAllMocks();
+ expect(globalThis.__gttErreurs?.slice(erreursAvant) ?? [], 'erreurs reçues par le collecteur').toEqual([]);
+});
+
+/**
+ * Un montage dont l'événement « Soirée des Merles » est ouvert sur sa
+ * section d'import : les participants donnés, en écriture sauf demande,
+ * préparé par prepare(seance, generation) avant que l'espion se pose. Le
+ * dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
+ * enregistres et rend le nom proposé.
+ */
+async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
+ const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
+ charge.participants = participants;
+ charge.prochainsIds.participant = participants.length + 1;
+ const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
+ const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
+ montes.push(m);
+ await m.seance.ouvrir(BASE);
+ await m.seance.passerEnEcriture();
+ if (prepare !== null) await prepare(m.seance, m.app.generation());
+ if (!ecriture && m.seance.etat().mode === 'ecriture') await m.seance.passerEnLecture();
+ m.ui.allerA('import');
+ flushSync();
+ const aServir = [];
+ const enregistres = [];
+ m.fs.choisirFichierAImporter = async () => aServir.shift() ?? null;
+ m.fs.enregistrerSous = async (nomPropose, octets) => {
+ enregistres.push({ nomPropose, octets });
+ return nomPropose;
+ };
+ const espion = vi.spyOn(m.seance, 'executer');
+ return { m, aServir, enregistres, espion };
+}
+
+const section = ({ m }) => m.racine.querySelector('[data-section="import"]');
+const dans = (e, selecteur) => section(e).querySelector(selecteur);
+const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)];
+const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
+const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
+const ecritures = ({ m }) => m.trace.length;
+const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants');
+
+// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
+async function choisir(e, nom, octets) {
+ e.aServir.push({ nom, octets });
+ commande(e, 'choisirFichier').click();
+ await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe(nom));
+}
+
+// Remet un texte comme le collage de la grille (I5) : ui.ouvrirImport,
+// puis ui.allerA('import').
+async function coller(e, texte) {
+ e.m.ui.ouvrirImport({ texte, origine: 'collage' });
+ e.m.ui.allerA('import');
+ await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-collage]')).not.toBeNull());
+}
+
+// Choisit un mode d'import.
+function choisirMode(e, mode) {
+ dans(e, `[data-champ="mode"] input[value="${mode}"]`).click();
+ flushSync();
+}
+
+// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait :
+// rend ce que rend seance.executer, ou le refus.
+async function importer(e) {
+ const avant = imports(e).length;
+ commande(e, 'importer').click();
+ await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1));
+ const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1);
+ try {
+ const rendu = await resultat.value;
+ await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
+ return { rendu, refus: null };
+ } catch (erreur) {
+ await vi.waitFor(() => expect(dans(e, `[data-refus="${erreur.code}"]`)).not.toBeNull());
+ return { rendu: null, refus: erreur };
+ }
+}
+
+// Les lignes valides montrées sous un conteneur : le texte de chaque
+// cellule, dans l'ordre.
+const lignesMontrees = (conteneur) =>
+ [...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) =>
+ [...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent));
+const cellule = (conteneur, ligne, champ) =>
+ conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent;
+const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
+const association = (conteneur) =>
+ [...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
+ colonne.querySelector('[data-entete]').textContent,
+ colonne.querySelector('[data-devient]').textContent,
+ ]);
+const participantsDe = ({ m }) => m.seance.etat().charge.participants;
+
+// Les entrées du journal de l'événement, 0 avant le premier geste.
+function entrees({ m }) {
+ const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`);
+ return texte === null ? 0 : lireJournal(texte).entrees.length;
+}
+
+// Les comptes du rapport, chacun lu à son [data-compte] ; et ceux qu'il
+// doit afficher : le résumé que rend seance.executer, par format.nombre.
+const COMPTES_DU_RAPPORT = ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees'];
+const comptesAffiches = (e) =>
+ COMPTES_DU_RAPPORT.map((nom) => [nom, dans(e, `[data-rapport] [data-compte="${nom}"] [data-nombre]`).textContent]);
+const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom])]);
+
+// Un glissement sur la fenêtre, portant un fichier ou un texte seul : rend
+// l'événement distribué, dont defaultPrevented dit s'il a été accepté.
+function glisser({ fichier = null, texte = null }) {
+ const transfert = new DataTransfer();
+ if (fichier !== null) transfert.items.add(fichier);
+ if (texte !== null) transfert.setData('text/plain', texte);
+ const evenement = new DragEvent('dragover', { dataTransfer: transfert, bubbles: true, cancelable: true });
+ document.body.dispatchEvent(evenement);
+ return evenement;
+}
+
+// Amène un élément dans la fenêtre, puis dit s'il y est rendu visible
+// (test/navigateur/rendu.js).
+function vu(element) {
+ element.scrollIntoView({ block: 'center' });
+ return visible(element);
+}
+
+describe('l’aperçu est obligatoire (§ 10.1)', () => {
+ test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
+ const e = await ouvrir();
+ const avant = ecritures(e);
+ await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
+ expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
+ expect(ecritures(e)).toBe(avant);
+
+ const octets = enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n');
+ const deposee = new File([octets], 'deposee.csv', { type: 'text/csv' });
+ // Le survol qui porte un fichier est accepté, sans quoi le navigateur ne
+ // remet pas le dépôt et ouvre le fichier à la place de l'application ;
+ // un survol sans fichier ne l'est pas.
+ expect(glisser({ fichier: deposee }).defaultPrevented).toBe(true);
+ expect(glisser({ texte: 'Ombrelle' }).defaultPrevented).toBe(false);
+ const transfert = new DataTransfer();
+ transfert.items.add(deposee);
+ document.body.dispatchEvent(new DragEvent('drop', { dataTransfer: transfert, bubbles: true, cancelable: true }));
+ await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe('deposee.csv'));
+
+ const controles = tous(e, '[data-apercu] button, [data-apercu] input').filter(
+ (element) => element.dataset.commande !== 'importer' && element.dataset.commande !== 'fermerApercu',
+ );
+ expect(controles.length).toBeGreaterThan(0);
+ for (const controle of controles) {
+ controle.click();
+ flushSync();
+ }
+ await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
+ expect(imports(e)).toEqual([]);
+ expect(ecritures(e)).toBe(avant);
+ });
+});
+
+describe('encodage et séparateur nommés (§ 10.1, § 19.3)', () => {
+ test('les cas limites, puis leur variante windows-1252 : encodage et séparateur de decoder et de l’aperçu, « Héloïse » intacte, mêmes lignes', async () => {
+ const e = await ouvrir();
+ const vus = [];
+ for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
+ const octets = await lireLivre(url);
+ await choisir(e, nom, octets);
+ const { texte, encodage } = decoder(octets);
+ expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(encodage)));
+ expect(dans(e, '[data-separateur]').textContent).toBe(t(SEPARATEURS.get(apercevoir(texte).separateur)));
+ const apercu = dans(e, '[data-apercu]');
+ expect(apercu.textContent).toContain('Héloïse');
+ expect(apercu.textContent).not.toContain('Héloïse');
+ vus.push({ encodage, lignes: lignesMontrees(apercu) });
+ }
+ expect(vus.map(({ encodage }) => encodage)).toEqual(['utf-8-bom', 'windows-1252']);
+ expect(dans(e, '[data-separateur]').textContent).toBe(t('import.separateur.virgule'));
+ expect(vus[0].lignes.length).toBe(13);
+ expect(vus[1].lignes).toEqual(vus[0].lignes);
+ });
+
+ test('un même texte en UTF-16 LE puis BE, à marque : chacun nommé, mêmes lignes', async () => {
+ const e = await ouvrir();
+ const texte = 'nom;prenom;appartenance\r\nOmbrelle;Iris;Club des Merles\r\nPervenche;Benoît;Grisaille\r\n';
+ const vus = [];
+ for (const ordre of ['le', 'be']) {
+ await choisir(e, `liste-${ordre}.csv`, enUtf16(texte, ordre));
+ expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(`utf-16${ordre}`)));
+ vus.push(lignesMontrees(dans(e, '[data-apercu]')));
+ }
+ expect(vus[0].length).toBe(2);
+ expect(vus[1]).toEqual(vus[0]);
+ expect(vus[0][1].slice(0, 3)).toEqual(['Pervenche', 'Benoît', 'Grisaille']);
+ });
+
+ test('un fichier d’une seule colonne, en-tête « nom » : « une seule colonne », « Ombrelle, Iris » entière', async () => {
+ const e = await ouvrir();
+ await choisir(e, 'noms.csv', enUtf8('nom\r\nPervenche\r\nOmbrelle, Iris\r\nLacasse\r\n'));
+ expect(dans(e, '[data-separateur]').textContent).toBe('une seule colonne');
+ expect(cellule(dans(e, '[data-apercu]'), 3, 'nom')).toBe('Ombrelle, Iris');
+ });
+});
+
+describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => {
+ test('deux fichiers aux colonnes permutées, « NOM », « Équipe », « Rôle » : même association, mêmes participants ; « Allergies » non reconnue', async () => {
+ const e = await ouvrir();
+ const premier = 'NOM;Équipe;Rôle;Allergies\r\nOmbrelle;Club des Merles;animation;noix\r\nPervenche;Grisaille;;\r\n';
+ const second = 'Allergies;Rôle;Équipe;NOM\r\nnoix;animation;Club des Merles;Ombrelle\r\n;;Grisaille;Pervenche\r\n';
+ const vus = [];
+ for (const [nom, texte] of [['premier.csv', premier], ['second.csv', second]]) {
+ await choisir(e, nom, enUtf8(texte));
+ const apercu = dans(e, '[data-apercu]');
+ vus.push({ association: new Map(association(apercu)), lignes: lignesMontrees(apercu) });
+ }
+ expect(vus[0].association.size).toBe(4);
+ expect(vus[1].association).toEqual(vus[0].association);
+ expect(vus[0].association.get('NOM')).toBe(t('import.champ.nom'));
+ expect(vus[0].association.get('Équipe')).toBe(t('import.champ.appartenance'));
+ expect(vus[0].association.get('Rôle')).toBe(t('import.champ.titrePressenti'));
+ expect(vus[0].association.get('Allergies')).toBe(t('import.colonne.nonReconnue'));
+ expect(vus[1].lignes).toEqual(vus[0].lignes);
+ expect(vus[0].lignes.length).toBe(2);
+ });
+
+ test('« organisation » et « entreprise » : l’aperçu demande laquelle vaut appartenance ; importer est refusé à côté de la commande tant qu’elle demeure ; le choix l’associe, et l’import passe', async () => {
+ const e = await ouvrir();
+ await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
+ const question = dans(e, '[data-champ="choix-appartenance"]');
+ expect(vu(question)).toBe(true);
+ expect(texteDe(question)).toContain('organisation');
+ expect(texteDe(question)).toContain('entreprise');
+ const avant = ecritures(e);
+ const { refus } = await importer(e);
+ expect(refus.code).toBe('AMBIGUITE');
+ const posee = dans(e, '[data-refus="AMBIGUITE"]');
+ expect(commande(e, 'importer').parentElement.contains(posee)).toBe(true);
+ expect(ecritures(e)).toBe(avant);
+
+ question.querySelector('input[value="2"]').click();
+ flushSync();
+ expect(new Map(association(dans(e, '[data-apercu]'))).get('entreprise')).toBe(t('import.champ.appartenance'));
+ expect(dans(e, '[data-champ="choix-appartenance"]')).toBeNull();
+ const { rendu } = await importer(e);
+ expect(rendu.resume.ajoutes).toBe(1);
+ expect(participantsDe(e).map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Ombrelle', 'Grisaille']]);
+ });
+
+ test('sous « Remplacer », l’ambiguïté refuse l’import à côté de la commande, sans ouvrir la fenêtre ; le choix fait, la fenêtre s’ouvre', async () => {
+ const e = await ouvrir({ participants: QUATRE() });
+ await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
+ choisirMode(e, 'remplacer');
+ const avant = ecritures(e);
+ commande(e, 'importer').click();
+ await vi.waitFor(() => expect(dans(e, '[data-refus="AMBIGUITE"]')).not.toBeNull());
+ expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="AMBIGUITE"]'))).toBe(true);
+ expect(dans(e, 'dialog')).toBeNull();
+ expect(imports(e)).toEqual([]);
+ expect(ecritures(e)).toBe(avant);
+
+ dans(e, '[data-champ="choix-appartenance"] input[value="1"]').click();
+ flushSync();
+ commande(e, 'importer').click();
+ await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
+ dans(e, 'dialog [data-commande="renoncer"]').click();
+ await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
+ expect(imports(e)).toEqual([]);
+ expect(ecritures(e)).toBe(avant);
+ });
+});
+
+describe('exclu : une liste fermée (§ 10.1)', () => {
+ test('« Oui », « X », « 0 » et vide acceptés ; « peut-être » refuse sa ligne, le motif citant la valeur ; les autres s’importent', async () => {
+ const e = await ouvrir();
+ await choisir(
+ e,
+ 'exclus.csv',
+ enUtf8('nom;exclu\r\nOmbrelle;Oui\r\nPervenche;X\r\nLacasse;0\r\nFauvelle;\r\nMorillon;peut-être\r\n'),
+ );
+ const apercu = dans(e, '[data-apercu]');
+ expect(lignesMontrees(apercu).length).toBe(4);
+ expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').length).toBe(1);
+ expect(texteDe(refusee(apercu, 6))).toContain('« peut-être »');
+ await importer(e);
+ expect(participantsDe(e).map(({ nom, exclu }) => [nom, exclu])).toEqual([
+ ['Ombrelle', true], ['Pervenche', true], ['Lacasse', false], ['Fauvelle', false],
+ ]);
+ });
+});
+
+// Un fichier par refus global, ses octets, et la clé du remède attendue.
+const REFUS_GLOBAUX = [
+ ['AUCUN_ENTETE', enUtf8('alpha;beta\r\nOmbrelle;Iris\r\n'), 'import.remede.aucunEntete'],
+ ['NOM_NON_ASSOCIE', enUtf8('prenom;notes\r\nIris;x\r\n'), 'import.remede.nomNonAssocie'],
+ ['AUCUNE_LIGNE_VALIDE', enUtf8('nom;prenom\r\n;Iris\r\n'), 'import.remede.aucuneLigneValide'],
+ ['UTF16_SANS_MARQUE', Uint8Array.from([0x6e, 0x00, 0x6f, 0x00, 0x6d, 0x00]), 'ENREGISTRER_EN_UTF8'],
+ ['UTF16_INVALIDE', Uint8Array.from([0xff, 0xfe, 0x6e, 0x00, 0x6f]), 'ENREGISTRER_EN_UTF8'],
+ ['UTF8_INVALIDE', Uint8Array.from([...MARQUE_UTF8, 0x6e, 0x6f, 0xe9, 0x6d]), 'ENREGISTRER_EN_UTF8'],
+ ['CARACTERE_NUL', enUtf8('nom\r\nOmbrelle\u{0}\r\n'), 'ENREGISTRER_EN_UTF8'],
+ ['GUILLEMET_OUVERT', enUtf8('nom;prenom\r\n"Ombrelle;Iris\r\n'), 'import.remede.guillemetOuvert'],
+ ['SEPARATEUR_INTROUVABLE', enUtf8('nom;prenom\r\nOmbrelle;Iris;x\r\nPervenche\r\n'), 'import.remede.separateurIntrouvable'],
+];
+
+describe('les refus globaux (§ 10.1)', () => {
+ test('un fichier par cas : la cause et le remède de la table affichés, aucune commande d’import, rien d’écrit', async () => {
+ const e = await ouvrir();
+ expect(REFUS_GLOBAUX.length).toBe(9);
+ const avant = ecritures(e);
+ for (const [code, octets, remede] of REFUS_GLOBAUX) {
+ await choisir(e, `${code}.csv`, octets);
+ const pose = dans(e, `[data-apercu] [data-refus="${code}"]`);
+ expect(pose, code).not.toBeNull();
+ expect(vu(pose), code).toBe(true);
+ const texte = pose.textContent;
+ expect(texte, code).toContain(t(remede, { ligne: 2 }));
+ expect(texte.length, code).toBeGreaterThan(t(remede, { ligne: 2 }).length);
+ expect(commande(e, 'importer'), code).toBeNull();
+ }
+ expect(ecritures(e)).toBe(avant);
+ });
+
+ test('un collage qui porte le caractère nul : la cause et le remède du collage', async () => {
+ const e = await ouvrir();
+ const avant = ecritures(e);
+ await coller(e, 'nom\nOmbrelle\u{0}\n');
+ const pose = dans(e, '[data-apercu] [data-refus="CARACTERE_NUL"]');
+ expect(pose.textContent).toContain(t('CARACTERE_NUL'));
+ expect(pose.textContent).toContain(t('import.remede.caractereNulCollage'));
+ expect(commande(e, 'importer')).toBeNull();
+ expect(dans(e, '[data-encodage]')).toBeNull();
+ expect(ecritures(e)).toBe(avant);
+ });
+});
+
+describe('une ligne fausse n’arrête pas le reste (§ 10.1, § 2.1)', () => {
+ test('la grande démonstration, « peut-être » à l’enregistrement 213 : 259 lignes valides, la ligne 213 et sa valeur ; l’import en ajoute 259', async () => {
+ const e = await ouvrir();
+ const { texte } = decoder(await lireLivre(urlGrande));
+ const enregistrements = texte.split('\r\n');
+ expect(enregistrements[212]).toMatch(/;non;$/);
+ enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;');
+ await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n')));
+ const apercu = dans(e, '[data-apercu]');
+ expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259');
+ expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['213']);
+ expect(texteDe(refusee(apercu, 213))).toContain('213');
+ expect(texteDe(refusee(apercu, 213))).toContain(t('EXCLU_INCONNU', { valeur: 'peut-être' }));
+ const { rendu } = await importer(e);
+ expect(rendu.resume.ajoutes).toBe(259);
+ expect(participantsDe(e).length).toBe(259);
+ });
+
+ test('le rang : la ligne refusée des cas limites, sans nom, s’affiche 13, son enregistrement, et non 14, sa ligne de texte', async () => {
+ const e = await ouvrir();
+ await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
+ const apercu = dans(e, '[data-apercu]');
+ expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['13']);
+ expect(texteDe(refusee(apercu, 13))).toContain('13');
+ expect(texteDe(refusee(apercu, 13))).toContain(t('NOM_ABSENT'));
+ expect(refusee(apercu, 14)).toBeNull();
+ });
+});
+
+describe('le rapport (§ 10.1, § 19.3)', () => {
+ test('« Coopérative Bleue » existante, « cooperative bleue » et « COOPÉRATIVE BLEUE » lues : un groupe affiché, ses trois orthographes et leurs comptes, dans l’aperçu puis le rapport', async () => {
+ const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' })] });
+ await choisir(
+ e,
+ 'bleue.csv',
+ enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;cooperative bleue\r\nLacasse;Ondine;COOPÉRATIVE BLEUE\r\nFauvelle;;\r\n'),
+ );
+ const attendu = [['Coopérative Bleue', '0'], ['cooperative bleue', '1'], ['COOPÉRATIVE BLEUE', '1']];
+ const groupes = (conteneur) =>
+ [...conteneur.querySelectorAll('[data-fusion]')].map((fusion) => ({
+ affichee: fusion.querySelector('[data-affichee]').textContent,
+ orthographes: [...fusion.querySelectorAll('[data-orthographe]')].map((o) => [
+ o.querySelector('[data-texte]').textContent,
+ o.querySelector('[data-nombre]').textContent,
+ ]),
+ }));
+ expect(groupes(dans(e, '[data-apercu]'))).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
+ await importer(e);
+ const rapport = dans(e, '[data-rapport]');
+ expect(vu(rapport.querySelector('h3'))).toBe(true);
+ expect(groupes(rapport)).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
+ for (const texte of tous(e, '[data-orthographe] [data-texte]')) expect(texte.textContent.trim()).not.toBe('');
+ });
+
+ test('les cas limites : au moins un groupe et une ligne refusée, dans l’aperçu puis le rapport ; aucune appartenance vide listée', async () => {
+ const e = await ouvrir();
+ await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
+ for (const conteneur of ['[data-apercu]', null]) {
+ if (conteneur === null) await importer(e);
+ const ici = dans(e, conteneur ?? '[data-rapport]');
+ expect(ici.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1);
+ expect(ici.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1);
+ const orthographes = [...ici.querySelectorAll('[data-orthographe] [data-texte]')];
+ expect(orthographes.length).toBeGreaterThan(0);
+ for (const texte of orthographes) expect(texte.textContent.trim()).not.toBe('');
+ }
+ });
+});
+
+describe('le CSV des lignes refusées (§ 10.1)', () => {
+ test('« Enregistrer les lignes refusées » remet les octets d’exporterRefus de src/csv, motif de libelle ; corrigé et réimporté, la ligne entre, ligne et motif non reconnues', async () => {
+ const e = await ouvrir();
+ const octets = await lireLivre(urlCasLimites);
+ await choisir(e, 'participants_cas_limites.csv', octets);
+ commande(e, 'enregistrerRefus').click();
+ await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
+ const apercu = apercevoir(decoder(octets).texte, { participants: [], mode: 'ajouter' });
+ expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`);
+ expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]);
+
+ const corrige = decoder(e.enregistres[0].octets).texte.replace('\r\n;Clotilde;', '\r\nRavenelle;Clotilde;');
+ expect(corrige).toContain('Ravenelle;Clotilde;');
+ await choisir(e, 'corrige.csv', enUtf8(corrige));
+ const vu = new Map(association(dans(e, '[data-apercu]')));
+ expect(vu.get('ligne')).toBe(t('import.colonne.nonReconnue'));
+ expect(vu.get('motif')).toBe(t('import.colonne.nonReconnue'));
+ await importer(e);
+ expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ravenelle', 'Clotilde']]);
+ });
+
+ test('après une mise à jour qui refuse des lignes, le rapport remet exporterRefus(apercu, libelle, resume.refusees), refus de la mise à jour compris', async () => {
+ const e = await ouvrir({
+ participants: [personne(1, 'Lacasse', 'Ondine'), personne(2, 'Lacasse', 'Odile'), personne(3, 'Ombrelle', 'Iris')],
+ });
+ await choisir(e, 'maj.csv', enUtf8('nom;prenom\r\nLacasse;\r\nOmbrelle;Irène\r\n;Théo\r\n'));
+ const { rendu } = await importer(e);
+ const { apercu } = imports(e).at(-1)[1];
+ expect(rendu.resume.refusees.map(({ code }) => code)).toEqual(['PLUSIEURS_CORRESPONDENT', 'NOM_ABSENT']);
+ commande(e, 'enregistrerRefus').click();
+ await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
+ expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle, rendu.resume.refusees)]);
+ });
+
+ test('en langue témoin, le motif affiché porte les marques, la colonne motif du CSV aucune', async () => {
+ const e = await ouvrir({ langue: 'temoin' });
+ await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
+ const motif = refusee(dans(e, '[data-apercu]'), 13).querySelector('[data-motif]').textContent;
+ expect(motif.startsWith(OUVERTURE_TEMOIN) && motif.endsWith(FERMETURE_TEMOIN)).toBe(true);
+ commande(e, 'enregistrerRefus').click();
+ await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
+ const { texte } = decoder(e.enregistres[0].octets);
+ const [entete, ...refus] = decouper(texte, ';').enregistrements;
+ const rang = entete.indexOf('motif');
+ expect(rang).toBeGreaterThan(0);
+ expect(refus.length).toBe(1);
+ expect(refus[0][rang]).toBe(libelle('NOM_ABSENT', { ligne: 13, valeur: null }));
+ expect(texte).not.toContain(OUVERTURE_TEMOIN);
+ expect(texte).not.toContain(FERMETURE_TEMOIN);
+ });
+});
+
+describe('une seule entrée, des doublons signalés, une mise à jour qui n’efface pas (§ 10.1)', () => {
+ test('les 260 lignes de la grande démonstration : une entrée du journal, un état écrit', async () => {
+ // Un premier geste pose le journal, et l'entrée de reprise qui l'ouvre.
+ const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
+ expect(entrees(e)).toBeGreaterThan(0);
+ await choisir(e, 'participants_demo_grande.csv', await lireLivre(urlGrande));
+ const avant = ecritures(e);
+ const entreesAvant = entrees(e);
+ const { rendu } = await importer(e);
+ expect(rendu.resume.ajoutes).toBe(260);
+ expect(entrees(e)).toBe(entreesAvant + 1);
+ const etats = e.m.trace.slice(avant).filter(([primitive, , chemin]) => primitive === 'ecrireAtomique' && chemin === `${BASE}${SUFFIXES.etat}`);
+ expect(etats.length).toBe(1);
+ });
+
+ test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => {
+ const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
+ await choisir(
+ e,
+ 'doublons.csv',
+ enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;Club des Merles\r\npervenche;THÉO;club des merles\r\nOmbrelle;Iris;Grisaille\r\n'),
+ );
+ expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
+ choisirMode(e, 'ajouter');
+ expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(2);
+ const { rendu } = await importer(e);
+ expect(rendu.resume.ajoutes).toBe(3);
+ const ids = participantsDe(e).map(({ id }) => id);
+ expect(ids.length).toBe(4);
+ expect(new Set(ids).size).toBe(4);
+ });
+
+ test('mettre à jour : aucun courriel vidé ; une ligne qui désigne deux personnes, refusée, les deux nommées ; une seconde ligne pour la même personne, refusée, la première nommée', async () => {
+ const e = await ouvrir({
+ participants: [
+ personne(1, 'Ombrelle', 'Iris', { courriel: 'iris@exemple.test' }),
+ personne(2, 'Lacasse', 'Ondine', { courriel: 'ondine@exemple.test' }),
+ personne(3, 'Lacasse', 'Odile'),
+ personne(4, 'Pervenche', 'Théo', { courriel: 'theo@exemple.test' }),
+ ],
+ });
+ await choisir(
+ e,
+ 'noms_prenoms.csv',
+ enUtf8('nom;prenom\r\nOmbrelle;Iris\r\nLacasse;\r\nPervenche;Théo\r\nPervenche;\r\nMorillon;Aurèle\r\n'),
+ );
+ expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
+ const { rendu } = await importer(e);
+ expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 0, inchanges: 2, retires: 0, reservationsRetirees: 0 });
+ expect(comptesAffiches(e)).toEqual(comptesDu(rendu.resume));
+ const courriels = new Map(participantsDe(e).map(({ id, courriel }) => [id, courriel]));
+ expect([courriels.get(1), courriels.get(2), courriels.get(4)]).toEqual(['iris@exemple.test', 'ondine@exemple.test', 'theo@exemple.test']);
+ const rapport = dans(e, '[data-rapport]');
+ const plusieurs = refusee(rapport, 3);
+ expect(texteDe(plusieurs)).toContain(t('PLUSIEURS_CORRESPONDENT', { participants: [2, 3] }));
+ expect([...plusieurs.querySelectorAll('[data-participant]')].map((p) => [p.dataset.participant, texteDe(p)])).toEqual([
+ ['2', 'Ondine Lacasse'],
+ ['3', 'Odile Lacasse'],
+ ]);
+ const deja = refusee(rapport, 5);
+ expect(texteDe(deja)).toContain(t('DEJA_DESIGNE', { premiereLigne: 4 }));
+ });
+});
+
+// Deux tables de trois places, puis une génération enregistrée jusqu'à
+// compter nombre propositions.
+async function avecPropositions(seance, generation, nombre) {
+ await seance.executer('creerTable', { forme: 'ronde' });
+ await seance.executer('creerTable', { forme: 'ronde' });
+ for (let essai = 0; essai < 5 && seance.etat().charge.propositions.length < nombre; essai += 1) {
+ await generation.generer({ ...generation.reglagesProposes(), nombre, arret: 500 });
+ }
+ expect(seance.etat().charge.propositions.length).toBeGreaterThanOrEqual(nombre);
+}
+
+const QUATRE = () => [
+ personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' }),
+ personne(2, 'Pervenche', 'Théo', { appartenance: 'Grisaille' }),
+ personne(3, 'Lacasse', 'Ondine', { appartenance: 'Club des Merles' }),
+ personne(4, 'Fauvelle', 'Aurèle'),
+];
+
+describe('remplacer annonce ce qu’il détruit (§ 10.1)', () => {
+ test('l’aperçu se recalcule sous remplacer, la fenêtre porte les quatre comptes ; Annuler n’écrit rien ; Confirmer, une entrée, des identifiants neufs', async () => {
+ const e = await ouvrir({
+ participants: QUATRE(),
+ siegesParDefaut: 3,
+ prepare: async (seance, generation) => {
+ await avecPropositions(seance, generation, 2);
+ const [table] = seance.etat().charge.tables;
+ await seance.executer('reserver', { participant: 1, table: table.id, siege: null, portee: 'tous', tour: null });
+ await seance.executer('designer', { participant: 2, table: table.id, siege: null, libelle: 'Animation' });
+ },
+ });
+ await choisir(e, 'remplace.csv', enUtf8('nom;prenom;appartenance\r\nTisserin;Basilie;cooperative bleue\r\nMorillon;Aurèle;\r\n'));
+ expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(1);
+ choisirMode(e, 'remplacer');
+ expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(0);
+ expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(0);
+ expect(cellule(dans(e, '[data-apercu]'), 2, 'appartenance')).toBe('cooperative bleue');
+
+ const charge = e.m.seance.etat().charge;
+ const bilan = bilanRemplacement(charge);
+ expect(bilan.reservations).toBeGreaterThan(0);
+ expect(bilan.titresPourvus).toBeGreaterThan(0);
+ expect(bilan.propositions).toBeGreaterThanOrEqual(2);
+ const attendus = [
+ t('import.remplacer.participants', { n: bilan.participants }),
+ t('import.remplacer.reservations', { n: bilan.reservations }),
+ t('import.remplacer.titres', { n: bilan.titresPourvus }),
+ t('import.remplacer.propositions', { n: bilan.propositions }),
+ ];
+ const avant = ecritures(e);
+ commande(e, 'importer').click();
+ await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
+ expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus);
+ dans(e, 'dialog [data-commande="renoncer"]').click();
+ await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
+ expect(imports(e)).toEqual([]);
+ expect(ecritures(e)).toBe(avant);
+
+ const prochain = charge.prochainsIds.participant;
+ const entreesAvant = entrees(e);
+ commande(e, 'importer').click();
+ await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
+ dans(e, 'dialog [data-commande="confirmer"]').click();
+ await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
+ expect(imports(e).length).toBe(1);
+ expect(imports(e)[0][1].mode).toBe('remplacer');
+ expect(entrees(e)).toBe(entreesAvant + 1);
+ const { resume } = await e.espion.mock.results.at(-1).value;
+ expect(resume).toMatchObject({ ajoutes: 2, misAJour: 0, inchanges: 0, retires: 4, reservationsRetirees: 2 });
+ expect(comptesAffiches(e)).toEqual(comptesDu(resume));
+ const apres = participantsDe(e);
+ expect(apres.map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Tisserin', 'cooperative bleue'], ['Morillon', null]]);
+ for (const { id } of apres) expect(id).toBeGreaterThanOrEqual(prochain);
+ });
+});
+
+describe('importer selon l’état (§ 10.1, § 9)', () => {
+ const FICHIER = 'nom;prenom\r\nMorillon;Aurèle\r\n';
+
+ test('brouillon et proposé sans avertissement ; retenu avec IMPORT_EN_RETENU dans le rendu de seance.executer', async () => {
+ const preparations = [
+ ['brouillon', null],
+ ['propose', (seance, generation) => avecPropositions(seance, generation, 1)],
+ [
+ 'retenu',
+ async (seance, generation) => {
+ await avecPropositions(seance, generation, 1);
+ await seance.executer('retenirProposition', { id: seance.etat().charge.propositions[0].id });
+ },
+ ],
+ ];
+ for (const [etat, prepare] of preparations) {
+ const e = await ouvrir({ participants: QUATRE(), siegesParDefaut: 3, prepare });
+ expect(e.m.seance.etat().charge.evenement.etat).toBe(etat);
+ await choisir(e, 'tardif.csv', enUtf8(FICHIER));
+ const { rendu } = await importer(e);
+ const codes = rendu.avertissements.map(({ code }) => code);
+ if (etat === 'retenu') expect(codes, etat).toContain('IMPORT_EN_RETENU');
+ else expect(codes, etat).not.toContain('IMPORT_EN_RETENU');
+ expect(participantsDe(e).length, etat).toBe(5);
+ }
+ });
+
+ test('bloqué : l’aperçu s’ouvre, l’import est refusé à côté de la commande, le message portant « Débloquer », rien d’écrit', async () => {
+ const e = await ouvrir({
+ participants: QUATRE(),
+ prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }),
+ });
+ const avant = ecritures(e);
+ await choisir(e, 'tardif.csv', enUtf8(FICHIER));
+ expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
+ const { refus } = await importer(e);
+ expect(refus.code).toBe('LECTURE_SEULE');
+ const pose = dans(e, '[data-refus="LECTURE_SEULE"]');
+ expect(commande(e, 'importer').parentElement.contains(pose)).toBe(true);
+ expect(texteDe(pose)).toContain('Débloquer');
+ expect(ecritures(e)).toBe(avant);
+ expect(participantsDe(e).length).toBe(4);
+ });
+
+ test('en lecture : l’aperçu s’ouvre, l’import est refusé avec « Modifier », rien d’écrit', async () => {
+ const e = await ouvrir({ ecriture: false });
+ expect(e.m.seance.etat().mode).toBe('lecture');
+ const avant = ecritures(e);
+ await choisir(e, 'tardif.csv', enUtf8(FICHIER));
+ expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
+ const { refus } = await importer(e);
+ expect(refus.code).toBe('MODE_LECTURE');
+ const pose = dans(e, '[data-refus="MODE_LECTURE"]');
+ expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
+ expect(ecritures(e)).toBe(avant);
+ });
+});
+
+describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => {
+ const TEXTE = [
+ ['nom', 'prenom', 'appartenance'],
+ ['Ombrelle', 'Iris', '"Club\tdes ""Merles"""'],
+ ['Pervenche', 'Benoît', 'Grisaille'],
+ ].map((champs) => champs.join('\t')).join('\n');
+
+ test('un texte à tabulations collé : un aperçu sans encodage ; l’import rend les mêmes participants, champ par champ, que le même texte servi en fichier', async () => {
+ const collage = await ouvrir();
+ await coller(collage, TEXTE);
+ expect(dans(collage, '[data-encodage]')).toBeNull();
+ expect(dans(collage, '[data-separateur]').textContent).toBe(t('import.separateur.tabulation'));
+ await importer(collage);
+ const fichier = await ouvrir();
+ await choisir(fichier, 'colle.csv', enUtf8(TEXTE));
+ await importer(fichier);
+ expect(participantsDe(collage).length).toBe(2);
+ expect(participantsDe(collage)[0].appartenance).toBe('Club\tdes "Merles"');
+ expect(participantsDe(collage)[1].prenom).toBe('Benoît');
+ expect(participantsDe(collage)).toEqual(participantsDe(fichier));
+ });
+
+ test('sans sa ligne d’en-tête : AUCUN_ENTETE, puis « Lire les colonnes dans l’ordre de la grille » en fait nom, prénom et appartenance', async () => {
+ const e = await ouvrir();
+ await coller(e, TEXTE.split('\n').slice(1).join('\n'));
+ const pose = dans(e, '[data-refus="AUCUN_ENTETE"]');
+ expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage'));
+ commande(e, 'ordreDeLaGrille').click();
+ await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
+ expect(association(dans(e, '[data-apercu]')).map(([, devient]) => devient)).toEqual([
+ t('import.champ.nom'), t('import.champ.prenom'), t('import.champ.appartenance'),
+ ]);
+ expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['2', '3']);
+ await importer(e);
+ expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ombrelle', 'Iris'], ['Pervenche', 'Benoît']]);
+ });
+
+ test('une liste de noms d’une colonne : SEPARATEUR_INTROUVABLE, la même offre, chaque ligne entière en nom', async () => {
+ const e = await ouvrir();
+ await coller(e, 'Ombrelle\nLacasse, Ondine\nPervenche');
+ const pose = dans(e, '[data-refus="SEPARATEUR_INTROUVABLE"]');
+ expect(pose.textContent).toContain(t('import.remede.separateurIntrouvableCollage'));
+ commande(e, 'ordreDeLaGrille').click();
+ await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
+ expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Lacasse, Ondine', 'Pervenche']);
+ });
+});
+
+describe('les cas du § 10.3', () => {
+ // Chacun des neuf cas, lu dans un conteneur : l'aperçu, puis le rapport.
+ function neufCas(conteneur) {
+ const colonnes = new Map(association(conteneur));
+ expect(colonnes.get('Téléphone'), 'colonne non reconnue').toBe(t('import.colonne.nonReconnue'));
+ expect(cellule(conteneur, 6, 'appartenance'), 'sans appartenance').toBe('');
+ expect(cellule(conteneur, 7, 'exclu'), 'exclusion').toBe(t('import.exclu.oui'));
+ expect(cellule(conteneur, 8, 'notes'), 'champ cité avec le séparateur').toBe('végétarien; sans noix, ni arachides');
+ expect(cellule(conteneur, 9, 'notes'), 'champ cité sur deux lignes').toMatch(/premier tour\r?\nrepart/);
+ const doublons = [...conteneur.querySelectorAll('[data-doublon]')].map((d) =>
+ [...d.querySelectorAll('[data-nombre]')].map((n) => n.textContent));
+ expect(doublons, 'doublon').toEqual([['2', '10']]);
+ expect(cellule(conteneur, 11, 'prenom'), 'sans prénom').toBe('');
+ const fusions = [...conteneur.querySelectorAll('[data-fusion] [data-orthographe] [data-texte]')].map((o) => o.textContent);
+ expect(fusions, 'paire d’orthographes').toEqual(['Société des Hérons', 'Societe des herons']);
+ expect(refusee(conteneur, 13), 'ligne refusée').not.toBeNull();
+ }
+
+ test('l’aperçu et le rapport des cas limites montrent chacun des neuf cas', async () => {
+ const e = await ouvrir();
+ await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
+ neufCas(dans(e, '[data-apercu]'));
+ await importer(e);
+ neufCas(dans(e, '[data-rapport]'));
+ });
+
+ test('la variante windows-1252 nomme windows-1252 et la virgule, et rend les mêmes participants', async () => {
+ const parVariante = [];
+ for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
+ const e = await ouvrir();
+ await choisir(e, nom, await lireLivre(url));
+ parVariante.push({ encodage: dans(e, '[data-encodage]').textContent, separateur: dans(e, '[data-separateur]').textContent });
+ await importer(e);
+ parVariante.at(-1).participants = participantsDe(e);
+ }
+ expect(parVariante[1].encodage).toBe(t('import.encodage.windows1252'));
+ expect(parVariante[1].separateur).toBe(t('import.separateur.virgule'));
+ expect(parVariante[0].participants.length).toBe(13);
+ expect(parVariante[1].participants).toEqual(parVariante[0].participants);
+ });
+});
diff --git a/src/interface/import/Rapport.svelte b/src/interface/import/Rapport.svelte
new file mode 100644
index 0000000..9c16e88
--- /dev/null
+++ b/src/interface/import/Rapport.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+ {t('import.rapport.titre')}
+ {#if rapport.libelle === null}
+ {t('import.rapport.rienEcrit')}
+ {/if}
+
+ {#each COMPTES as [nom, cle] (nom)}
+ -
+ {format.nombre(rapport.resume[nom])}
+ {t(cle, { n: rapport.resume[nom] })}
+
+ {/each}
+
+
+
+
+
diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte
index 8307797..6a2172f 100644
--- a/src/interface/import/SectionImport.svelte
+++ b/src/interface/import/SectionImport.svelte
@@ -2,12 +2,292 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
+
+
{t('coquille.section.import')}
+ {t('import.consigne')}
+
+
+
+
+ {#if source !== null}
+ {#key source}
+
+ {/key}
+ {/if}
+
+ {#if rapport !== null}
+
+
+ {#if rapport.resume.refusees.length > 0}
+ enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
+ />
+ {/if}
+ (rapport = null)} />
+
+ {#if enregistre !== null}
+ {t('import.refusEnregistres')} {enregistre}
+ {/if}
+ {/if}
+
+ {#if confirmation !== null}
+ (confirmation = null)}
+ />
+ {/if}
+
+
diff --git a/src/interface/import/modele.js b/src/interface/import/modele.js
new file mode 100644
index 0000000..72c2c18
--- /dev/null
+++ b/src/interface/import/modele.js
@@ -0,0 +1,309 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre,
+// en clés de libellés et en données, d'un fichier décodé, d'un fichier que
+// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce
+// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
+// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
+// quels.
+import { CHAMPS } from '../../application/csv.js';
+
+/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
+ * quatre temps. */
+export const ENCODAGES = new Map([
+ ['utf-8-bom', 'import.encodage.utf8Marque'],
+ ['utf-16le', 'import.encodage.utf16le'],
+ ['utf-16be', 'import.encodage.utf16be'],
+ ['utf-8', 'import.encodage.utf8'],
+ ['windows-1252', 'import.encodage.windows1252'],
+]);
+
+/** La clé du nom de chaque séparateur de l'aperçu ; null : le fichier n'a
+ * qu'une colonne. */
+export const SEPARATEURS = new Map([
+ [';', 'import.separateur.pointVirgule'],
+ [',', 'import.separateur.virgule'],
+ ['\t', 'import.separateur.tabulation'],
+ [null, 'import.separateur.uneColonne'],
+]);
+
+// La clé du nom de chaque champ, dans l'ordre de CHAMPS.
+const CLES_DES_CHAMPS = new Map([
+ ['nom', 'import.champ.nom'],
+ ['prenom', 'import.champ.prenom'],
+ ['appartenance', 'import.champ.appartenance'],
+ ['courriel', 'import.champ.courriel'],
+ ['titre_pressenti', 'import.champ.titrePressenti'],
+ ['exclu', 'import.champ.exclu'],
+ ['notes', 'import.champ.notes'],
+]);
+
+// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
+const CLES_D_EXCLU = new Map([
+ [true, 'import.exclu.oui'],
+ [false, 'import.exclu.non'],
+]);
+
+// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
+const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
+
+/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les
+ * offre, et la clé de leur nom. */
+export const MODES_D_IMPORT = Object.freeze(
+ [
+ ['ajouter', 'import.mode.ajouter'],
+ ['mettreAJour', 'import.mode.mettreAJour'],
+ ['remplacer', 'import.mode.remplacer'],
+ ].map(([mode, cle]) => Object.freeze({ mode, cle })),
+);
+
+/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
+ * CHAMPS, et la clé de son nom. */
+export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
+
+// Le remède d'un refus de decoder : le code que ses détails portent, que le
+// texte du refus compose déjà (contrat « Refus et avertissements »).
+const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']);
+
+// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y
+// arrive que d'un texte collé : celui d'un fichier, decoder le refuse.
+const REMEDES_DE_L_APERCU = new Map([
+ ['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
+ ['AUCUN_ENTETE', 'import.remede.aucunEntete'],
+ ['NOM_NON_ASSOCIE', 'import.remede.nomNonAssocie'],
+ ['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'],
+ ['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'],
+ ['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
+]);
+
+// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
+const REMEDES_DE_LA_GRILLE = new Map([
+ ['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'],
+ ['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvableCollage'],
+]);
+
+// La valeur d'une table de clés, ou RangeError qui nomme ce qui manque :
+// jamais une clé devinée.
+function dans(table, valeur, quoi) {
+ if (!table.has(valeur)) throw new RangeError(`import : ${quoi} inconnu ${JSON.stringify(valeur)}`);
+ return table.get(valeur);
+}
+
+// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte
+// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur.
+function offreLaGrille(apercu) {
+ if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
+ return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null;
+}
+
+// Le refus global d'un refus de decoder : sa cause, le code ; son remède,
+// le code de details.remede, déjà dans la cause.
+function globalDeDecoder({ code, details }) {
+ if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`);
+ return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } };
+}
+
+// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu
+// offre l'ordre de la grille prend le remède qui le nomme.
+function globalDeLApercu(apercu, collage) {
+ const code = apercu.refusGlobal;
+ if (code === null) return null;
+ const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
+ const rang = apercu.refusGlobalLigne;
+ const details = rang === null ? {} : { ligne: rang };
+ return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } };
+}
+
+// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère
+// nul, un séparateur introuvable ou un texte blanc, où l'aperçu porte null
+// sans que le texte ait une seule colonne.
+function separateurDe(apercu) {
+ const nonEtabli =
+ apercu.refusGlobal === 'CARACTERE_NUL' ||
+ apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' ||
+ apercu.entetes.length === 0;
+ return nonEtabli ? null : dans(SEPARATEURS, apercu.separateur, 'séparateur');
+}
+
+// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la
+// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne
+// écartée par le choix d'une autre, une colonne non reconnue.
+function colonnesDe({ entetes, association }) {
+ const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
+ const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
+ const nonReconnues = new Set(association.nonReconnues);
+ return entetes.map((entete, rang) => {
+ let cle = 'import.colonne.ecartee';
+ if (champDuRang.has(rang)) cle = CLES_DES_CHAMPS.get(champDuRang.get(rang));
+ else if (aTrancher.has(rang)) cle = 'import.colonne.aTrancher';
+ else if (nonReconnues.has(rang)) cle = 'import.colonne.nonReconnue';
+ return { rang, entete, cle };
+ });
+}
+
+// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
+// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu ».
+function lignesDe(lignes) {
+ return lignes.map(({ ligne, champs }) => ({
+ ligne,
+ cellules: CHAMPS.map((champ) => {
+ const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
+ if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
+ return { champ, texte: valeur, cle: null };
+ }),
+ }));
+}
+
+/**
+ * Les lignes refusées, par l'aperçu ou par la mise à jour, telles que
+ * l'aperçu et le rapport les montrent : le rang, le code, et les détails que
+ * reçoit le motif — la ligne refusée sans son code ni ses champs découpés,
+ * comme exporterRefus les passe au motif du CSV.
+ *
+ * @param {Array<{ligne: number, code: string, brut: string[]}>} refusees
+ * @returns {Array<{ligne: number, code: string, details: Object}>}
+ */
+export function refuseesDe(refusees) {
+ return refusees.map(({ code, brut, ...details }) => ({ ligne: details.ligne, code, details }));
+}
+
+/**
+ * Les personnes que désigne une liste d'identifiants, dans son ordre ; un
+ * identifiant que la liste des participants ne porte plus est omis.
+ *
+ * @param {Array<{id: number, nom: string, prenom: string|null}>} participants
+ * @param {number[]} ids
+ * @returns {Array<{id: number, nom: string, prenom: string|null}>}
+ */
+export function nommer(participants, ids) {
+ const parId = new Map(participants.map((participant) => [participant.id, participant]));
+ return ids.filter((id) => parId.has(id)).map((id) => {
+ const { nom, prenom } = parId.get(id);
+ return { id, nom, prenom };
+ });
+}
+
+/**
+ * Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans
+ * participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se
+ * choisit.
+ *
+ * @param {Array