[ADD] interface: event list, open event, participants, import, plan

The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.

--- FR ---

[ADD] interface : liste, événement, participants, import, tables, plan

Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 08:12:27 -04:00
parent 5fbbab6477
commit 134e7850b7
67 changed files with 14131 additions and 110 deletions

View file

@ -10,6 +10,7 @@ import { exporterRefus as exporterRefusCsv } from '../csv/import.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 { exporterParticipants } from '../csv/export.js';
export { bilanRemplacement } from '../csv/import.js';

View file

@ -8,6 +8,7 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import * as apercu from '../csv/apercu.js';
import * as colonnes from '../csv/colonnes.js';
import * as encodage from '../csv/encodage.js';
import * as exportCsv from '../csv/export.js';
import * as importCsv from '../csv/import.js';
@ -17,7 +18,7 @@ import * as relais from './csv.js';
import { libelle } from './libelles.js';
describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => {
test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee et CHAMPS sont ceux de src/csv', () => {
const attendus = [
['decoder', encodage.decoder],
['apercevoir', apercu.apercevoir],
@ -30,8 +31,9 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
assert.equal(typeof fonction, 'function', nom);
assert.equal(relais[nom], fonction, nom);
}
assert.equal(relais.CHAMPS, colonnes.CHAMPS);
assert.deepEqual(Object.keys(relais).sort(), [
'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
'CHAMPS', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
]);
});

View file

@ -92,7 +92,7 @@ export const TABLE = new Map([
],
[
'VERROU_REPRIS',
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
({ depuis }, texte) => `Le verrou qu'une autre séance tenait${depuisQuand(depuis, texte)} est repris.`,
],
[
'RETOUR_AUTOMATIQUE',

View file

@ -88,6 +88,7 @@ import {
import { FORMAT, analyser } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
import { avertissementsApres, invariantViole } from './avertissements.js';
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
@ -429,21 +430,35 @@ export function creerSeance({
// null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis,
// vivant}, remède null, que verrouAutre garde. Un système sans verrou le
// rend toujours pris. Un support qui refuse d'écrire le verrou, ou
// d'effacer celui du mort, rompt le support sous l'échec
// d'effacer celui qu'il reprend, rompt le support sous l'échec
// VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les
// avertissements.
async function prendreVerrou(ouvert) {
//
// reprise : le verrou que l'opérateur a vu refusé et confirmé reprendre,
// {seance, depuis} de verrouAutre (reprendreVerrou), null sinon. Un verrou
// dont la vie ne se prouve pas (vivant null) s'efface alors, puis se prend,
// sous VERROU_REPRIS {seance, depuis} — mais seulement le verrou même que
// la reprise nomme, de même séance et de même instant : un verrou posé
// depuis, que l'opérateur n'a pas vu, se refuse VERROU_PRIS comme sans
// reprise. Un verrou vivant ne se reprend jamais.
async function prendreVerrou(ouvert, reprise = null) {
let verrou;
const avertissements = [];
try {
verrou = await verrouiller(ouvert, id);
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
if (!verrou.pris && verrou.vivant === false) {
const mort = verrou;
await deverrouiller(ouvert, mort.seance);
const confirme =
!verrou.pris &&
reprise !== null &&
verrou.vivant === null &&
verrou.seance === reprise.seance &&
verrou.depuis === reprise.depuis;
if (!verrou.pris && (verrou.vivant === false || confirme)) {
const ancien = verrou;
await effacerVerrou(ouvert, ancien.seance);
verrou = await verrouiller(ouvert, id);
if (verrou.pris) {
avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis }));
avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis }));
}
}
} catch (erreur) {
@ -460,6 +475,18 @@ export function creerSeance({
return avertissements;
}
// Efface le verrou d'une autre séance, que prendreVerrou reprend : celui
// de la séance nommée, par deverrouiller, qui n'efface que lui ; un verrou
// illisible (seance null) n'a pas de séance qui le rende, et son fichier
// s'efface.
async function effacerVerrou(ouvert, seance) {
if (seance !== null) {
await deverrouiller(ouvert, seance);
return;
}
await ouvert.fs.supprimer(ouvert.racine, ouvert.base + SUFFIXES.verrou);
}
// Rend le verrou que la séance tient sur l'événement de l'Ouvert. Un refus
// du système de fichiers ne retient pas la séance : VERROU_NON_RENDU le
// dit, et le verrou tombe avec ce processus.
@ -911,7 +938,33 @@ export function creerSeance({
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
const avertissements = await prendreVerrou(ouvert);
return entrerSousVerrou(ouvert, null);
}
// Reprendre le verrou (§ 8.8) : le passage en écriture qui reprend le
// verrou refusé que verrouAutre garde, une fois que l'opérateur l'a
// confirmé en voyant sa séance et depuis quand (prendreVerrou). Sans
// verrou refusé, c'est un passage en écriture. Le refus d'un débloquer
// admet la reprise : sur un plan bloqué, ou relu bloqué, le verrou repris
// se rend aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit
// parmi les avertissements du dernier geste ; débloquer passe ensuite.
async function reprendreVerrou() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw refusFormat();
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
const reprise = etat.verrouAutre;
return entrerSousVerrou(ouvert, reprise === null ? null : { seance: reprise.seance, depuis: reprise.depuis }, {
bloqueAdmis: true,
});
}
// Prend le verrou — en reprenant celui que reprise nomme —, relit
// l'événement, et passe en écriture. Relu d'un format plus récent, il rend
// le verrou et lève le refus ; relu bloqué, il rend le verrou, et lève le
// refus sauf bloqueAdmis, qui rend les avertissements.
async function entrerSousVerrou(ouvert, reprise, { bloqueAdmis = false } = {}) {
const avertissements = await prendreVerrou(ouvert, reprise);
avertissements.push(...(await relireSousVerrou(ouvert)));
const relu = etat.ouvert;
let refus = null;
@ -919,6 +972,7 @@ export function creerSeance({
else if (estBloque(relu)) refus = refusBloque();
if (refus !== null) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
if (bloqueAdmis && !relu.formatPlusRecent) return { avertissements: [...etat.avertissementsGeste] };
throw refus;
}
entrerEnEcriture();
@ -1170,6 +1224,14 @@ export function creerSeance({
return { avertissements };
}),
passerEnEcriture: () => enFile(passerEnEcriture),
/**
* Reprend le verrou que verrouAutre garde, après la confirmation de
* l'opérateur qui a vu sa séance et depuis quand (§ 8.8) : l'efface,
* prend celui de la séance et passe en écriture, sous VERROU_REPRIS
* {seance, depuis}. Un verrou qui a changé depuis le refus se refuse
* VERROU_PRIS ; un verrou vivant ne se reprend jamais.
*/
reprendreVerrou: () => enFile(reprendreVerrou),
passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
debloquer: () => enFile(() => suivi(() => geste('debloquer', {}))),

View file

@ -420,6 +420,165 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => {
});
});
describe('séance : reprendre le verrou que la séance ne reprend pas seule (§ 8.8)', () => {
// Un verrou posé sur le disque partagé par une séance d'un autre poste, à
// l'instant depuis.
const verrouLointain = (seance, depuis) => `${JSON.stringify({ seance, pid: 41, hote: 'poste-lointain', depuis })}\n`;
// Une séance d'épreuve qui ouvre en lecture l'événement d'essai, créé par
// une première séance, déjà fermée, sur le même disque.
async function secondeSeance(disque, hote = 'autre-poste') {
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'createur' });
await premier.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
await premier.seance.fermer();
const second = monter({ fs: creerFichiersSimules({ disque, hote }), prefixe: 'reprise' });
await ouvrirSansEcrire(second);
return second;
}
test("le verrou d'un autre poste, refusé puis repris : il s'efface, la séance prend le sien et passe en écriture, VERROU_REPRIS le dit", async () => {
const disque = new Map();
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
await enEcriture(premier);
const { depuis } = verrouDe(premier.fs);
const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' });
await ouvrirSansEcrire(lointain);
await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null, remede: null });
assert.deepEqual(lointain.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null });
const journal = lointain.fs.contenu('documents', JOURNAL);
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }];
assert.deepEqual(await lointain.seance.reprendreVerrou(), { avertissements: repris });
assert.equal(lointain.seance.mode, 'ecriture');
assert.equal(verrouDe(lointain.fs).seance, 'lointaine-1');
assert.equal(lointain.fs.contenu('documents', JOURNAL), journal, 'reprendre n\u{2019}écrit aucune entrée');
const etat = lointain.seance.etat();
assert.equal(etat.verrouAutre, null);
assert.deepEqual(etat.avertissements, repris);
assert.equal(etat.refus, null);
await lointain.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
assert.deepEqual(noms(etatDe(lointain.fs).charge), ['Ombrelle']);
});
test('un verrou illisible se reprend de même, sous VERROU_REPRIS {seance: null, depuis: null}', async () => {
const disque = new Map();
const second = await secondeSeance(disque, 'poste-epreuve');
second.fs.deposer('documents', VERROU, 'pas un verrou\n');
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null });
const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }];
assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris });
assert.equal(second.seance.mode, 'ecriture');
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
});
test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => {
const disque = new Map();
const second = await secondeSeance(disque);
const vu = '2031-05-17T09:00:00-04:00';
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu));
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null });
const pose = '2031-05-17T09:30:00-04:00';
const nouveau = verrouLointain('lointaine-2', pose);
second.fs.deposer('documents', VERROU, nouveau);
const details = { seance: 'lointaine-2', depuis: pose, vivant: null };
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
assert.equal(second.fs.contenu('documents', VERROU), nouveau);
assert.equal(second.seance.mode, 'lecture');
assert.deepEqual(second.seance.etat().verrouAutre, details);
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-2', depuis: pose } }];
assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris });
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
});
// Le verrou vu, puis remplacé par celui que décrit change : la reprise
// le refuse VERROU_PRIS avec les détails du nouveau, ne touche pas au
// fichier, et verrouAutre garde le nouveau.
async function repriseRefusee(seance, depuis) {
const second = await secondeSeance(new Map());
const vu = '2031-05-17T09:00:00-04:00';
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu));
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null });
const nouveau = verrouLointain(seance, depuis);
second.fs.deposer('documents', VERROU, nouveau);
const details = { seance, depuis, vivant: null };
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
assert.equal(second.fs.contenu('documents', VERROU), nouveau);
assert.equal(second.seance.mode, 'lecture');
assert.deepEqual(second.seance.etat().verrouAutre, details);
}
test("la même séance reprise à un instant plus tard : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => {
await repriseRefusee('lointaine-1', '2031-05-17T09:45:00-04:00');
});
test("une autre séance au même instant : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => {
await repriseRefusee('lointaine-5', '2031-05-17T09:00:00-04:00');
});
test("le verrou d'une séance vivante de ce poste ne se reprend jamais", async () => {
const disque = new Map();
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
await enEcriture(premier);
const { depuis } = verrouDe(premier.fs);
const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' });
await ouvrirSansEcrire(voisin);
const details = { seance: 'premiere-1', depuis, vivant: true };
await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { ...details, remede: null });
await refus(voisin.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
assert.equal(verrouDe(voisin.fs).seance, 'premiere-1');
assert.equal(voisin.seance.mode, 'lecture');
});
test("sans verrou refusé, reprendre est passer en écriture : un verrou posé depuis n'est pas repris", async () => {
const second = await secondeSeance(new Map());
const pose = '2031-05-17T10:00:00-04:00';
second.fs.deposer('documents', VERROU, verrouLointain('lointaine-3', pose));
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { seance: 'lointaine-3', depuis: pose, vivant: null, remede: null });
assert.equal(verrouDe(second.fs).seance, 'lointaine-3');
const libre = await secondeSeance(new Map());
assert.deepEqual(await libre.seance.reprendreVerrou(), { avertissements: [] });
assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']);
});
test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => {
const montage = monter({ prefixe: 'bloqueuse' });
const { fs, seance } = montage;
await enEcriture(montage);
await seance.executer('changerEtat', { etat: 'bloque' });
const depuis = '2031-05-17T11:00:00-04:00';
fs.deposer('documents', VERROU, verrouLointain('lointaine-4', depuis));
await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-4', depuis, vivant: null, remede: null });
const journal = fs.contenu('documents', JOURNAL);
const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-4', depuis } }];
assert.deepEqual(await seance.reprendreVerrou(), { avertissements: repris });
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']);
assert.equal(verrouDe(fs), null);
assert.equal(fs.contenu('documents', JOURNAL), journal);
assert.deepEqual(seance.etat().avertissements, repris);
assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: BLOQUE_SEUL });
await seance.debloquer();
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']);
});
test("en écriture, reprendre ne fait rien ; un format plus récent et un support rompu le refusent sans rien écrire", async () => {
const montage = monter();
await enEcriture(montage);
const avant = montage.fs.compteurs.ecritures;
assert.deepEqual(await montage.seance.reprendreVerrou(), { avertissements: [] });
assert.equal(montage.fs.compteurs.ecritures, avant);
montage.fs.pannes.ecriture('documents');
await refus(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), 'ECRITURE');
await montage.seance.passerEnLecture();
const rompu = montage.fs.compteurs.ecritures;
await refus(montage.seance.reprendreVerrou(), 'SUPPORT_ROMPU');
assert.equal(montage.fs.compteurs.ecritures, rompu);
const fsRecent = creerFichiersSimules();
deposerAuFormat(fsRecent, FORMAT + 1);
const recent = monter({ fs: fsRecent });
await ouvrirSansEcrire(recent);
await refus(recent.seance.reprendreVerrou(), 'LECTURE_SEULE', FORMAT_SEUL);
});
});
describe("séance : l'inactivité (§ 8.4)", () => {
test('au terme du délai, la séance revient seule en lecture et rend son verrou', async () => {
const montage = monter();
@ -2350,7 +2509,7 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () =
assert.deepEqual(second.seance.etat().avertissements, repris);
});
test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste de la séance ne le reprend", async () => {
test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste ne le reprend seul : seul reprendreVerrou, que l'opérateur confirme, le reprend", async () => {
const disque = new Map();
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
await enEcriture(premier);
@ -2365,7 +2524,6 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () =
assert.equal(premier.fs.contenu('documents', VERROU), texte);
assert.deepEqual(seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null });
assert.equal(seance.etat().mode, 'lecture');
assert.equal(seance.reprendreVerrou, undefined);
});
test("un verrou illisible n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé", async () => {
@ -2641,7 +2799,7 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)
for (const motif of [/identifiant 3\b/, /tour 2\b/, /\b9 personnes\b/, /\b8 places\b/]) assert.match(texte, motif);
});
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou le dit d'une séance interrompue", () => {
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => {
const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]);
assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive);
assert.match(derive, /n° 2/);
@ -2649,7 +2807,11 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)
const viole = libelle('INVARIANT_VIOLE', EXEMPLES_A2[1][1]);
assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole);
assert.match(viole, /n° 3/);
assert.match(libelle('VERROU_REPRIS', { seance: 's', depuis: null }), /interrompue/);
for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) {
const repris = libelle('VERROU_REPRIS', details);
assert.match(repris, /une autre séance/);
assert.doesNotMatch(repris, /interrompue|ce poste/);
}
assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/);
});
});

View file

@ -83,10 +83,12 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'LARGEUR_BLOC_NOMS_CM',
valeur: 180,
valeur: 500,
unite: 'cm',
mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège",
raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)',
raison:
"la ligne entière d'une place réservée et titrée (numéro, état, nom, titre) tient sur une ligne " +
'du plan au corps des listes, la liste faisant foi (§ 7.4, § 7.6)',
configuration: 'grande démonstration au zoom par défaut',
proprietaire: 'manuel',
statut: 'defautDeTravail',

View file

@ -14,7 +14,7 @@ const CONTRAT = [
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'],
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'],
['K_LISTES', 0.3, 'px/cm', 'banc'],
['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'],
['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel'],
['HAUTEUR_LIGNE', null, 'cm', 'execution'],
];
@ -42,7 +42,7 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
assert.equal(constante('SEUIL_GLISSER_PX'), 4);
assert.equal(constante('K_LISTES'), 0.3);
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180);
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
assert.equal(constante('HAUTEUR_LIGNE'), null);
assert.throws(() => constante('INCONNUE'), RangeError);
assert.throws(() => constante('nom'), RangeError);

View file

@ -15,7 +15,9 @@
// gestes à appeler ; mode.js choisit les clés du libellé de mode et de
// l'annonce. Le refus de « Liste des événements » passe par la primitive
// Refus, qui pose son texte et son remède en Commande — écrire ailleurs
// sous un support rompu.
// sous un support rompu. Ce refus se pose sous les commandes, hors du
// flux : le bandeau garde sa hauteur, et l'écran de l'événement, sa place
// fixe comprise, ne bouge pas (§ 8.4).
import { getContext } from 'svelte';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
import Refus from './primitives/Refus.svelte';
@ -93,8 +95,18 @@
/* Le refus garde les couleurs de la page, sur lesquelles sa couleur et
celles de sa commande tiennent leur contraste : le fond du bandeau ne
passe pas sous lui. */
passe pas sous lui. Posé sous les commandes, aligné à leur droite, il
recouvre l'écran au lieu de le pousser. Sa largeur bornée le tient à
droite, loin de la place fixe, qui ouvre l'écran à gauche, et dans la
fenêtre. */
.refus-liste {
position: absolute;
top: 100%;
right: 0;
width: max-content;
max-width: min(32rem, calc(100vw - 2 * var(--espace-4)));
margin-top: var(--espace-1);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
@ -109,6 +121,7 @@
}
.commandes {
position: relative;
margin-left: auto;
}

File diff suppressed because it is too large Load diff

View file

@ -2,79 +2,339 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le premier écran sous sa forme minimale (§ 8.1, § 18.6) : son titre, la
// version — ou la marque d'une construction de documentation —, puis une
// commande par événement du dossier de travail, qui l'ouvre en lecture, et
// une par démonstration, qui la charge. Les lignes viennent d'App, qui lit
// la liste par la façade une fois la détermination réglée : l'accueil n'en
// lit rien lui-même, et une question en attente les laisse vides jusqu'à
// la réponse. Un geste qui change le dossier de travail — importer,
// copier, renommer, supprimer — demande ensuite à App de relire la liste,
// par la fonction relireListe du contexte. refusLecture est le refus qu'App a
// reçu en lisant la liste ; le refus d'un geste de l'accueil le précède,
// jusqu'au geste suivant. Chacun s'affiche par son code traduit.
import { getContext } from 'svelte';
// Le premier écran (§ 8.1, § 8.6, § 18.6) : la liste des événements du
// dossier de travail, avec ses trois issues toujours offertes — la liste,
// la création, les démonstrations —, l'import d'un événement, la corbeille
// et le dossier de travail, ses avis et sa question. Il ouvre un événement
// en lecture par la séance ; ses autres gestes passent par la façade (A3),
// et il ne lit ni n'écrit rien lui-même.
//
// Les lignes viennent d'App, qui lit la liste par la façade une fois la
// détermination du dossier de travail réglée, et refusLecture est le refus
// qu'App a reçu en la lisant. L'accueil prend la détermination par
// facade.demarrer — la même pour toute la séance, sans nouvelle sonde — et
// en montre la question, puis l'autre emplacement, les avis et le dossier.
// Les avis ne se disent qu'au premier accueil de la séance (premiereAnnonce)
// : un retour à la liste ne les répète pas. Toute opération qui change le
// dossier de travail demande ensuite à App de relire la liste, par
// relireListe du contexte, et relit la corbeille.
//
// Un état qui ne s'ouvre pas (ETAT_ILLISIBLE) mène à la récupération, qui
// tient lieu de liste jusqu'à son retour. Chaque refus s'affiche, traduit,
// là où son geste a eu lieu, sans remède : sur la liste, aucun événement
// n'est ouvert pour l'exécuter (refusDeLaListe).
import { getContext, onDestroy, untrack } from 'svelte';
import { versionVisible } from '../apropos.js';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import Corbeille from './Corbeille.svelte';
import Demonstrations from './Demonstrations.svelte';
import DossierTravail from './DossierTravail.svelte';
import ListeEvenements from './ListeEvenements.svelte';
import Recuperation from './Recuperation.svelte';
import { champDuRefus, premiereAnnonce, refusDeLaListe, surLaListe } from './modele.js';
const t = getContext('t');
const facade = getContext('facade');
const seance = getContext('seance');
const relireListe = getContext('relireListe');
let { provenance, version, demonstrations, lignes, refusLecture } = $props();
let refusGeste = $state.raw(null);
const refus = $derived(refusGeste ?? refusLecture);
// Vrai au premier accueil de la séance : ses avis s'y disent.
const annoncer = premiereAnnonce(facade);
const uid = $props.id();
const estRefus = (erreur) => typeof erreur?.code === 'string';
// La détermination du dossier de travail, { dossier, avis }, null tant
// qu'elle n'est pas connue ; le dossier de travail de la séance.
let issue = $state.raw(null);
let racine = $state.raw(seance.etat().racine);
let corbeille = $state.raw([]);
let importAutre = $state.raw(null);
// La récupération d'un état illisible : { base, chemin, refus }.
let recuperation = $state.raw(null);
let refusOuverture = $state.raw(null);
let refusDemonstration = $state.raw(null);
// Le formulaire de création, et son refus.
let nom = $state('');
let date = $state('');
let refusCreation = $state.raw(null);
const champRefuse = $derived(champDuRefus(refusCreation));
// Exécute un geste ; un refus s'affiche, toute autre erreur remonte.
async function tenter(geste) {
refusGeste = null;
const question = $derived(issue?.dossier.question ?? null);
const avis = $derived(annoncer ? (issue?.avis ?? []) : []);
let detruit = false;
const desabonner = seance.subscribe((etat) => {
racine = etat.racine;
});
onDestroy(() => {
detruit = true;
desabonner();
});
// La corbeille du dossier de travail, quand aucune question n'attend ; un
// refus de la lire la laisse vide.
async function lireCorbeille() {
if (question !== null) return;
try {
await geste();
const lue = await facade.corbeille();
if (!detruit) corbeille = lue;
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusGeste = { code: erreur.code, details: erreur.details };
if (refusDeLaListe(erreur) === null) throw erreur;
}
}
// Une détermination rendue par demarrer ou repondre devient celle de
// l'accueil ; refusée, elle laisse le refus qu'App affiche.
async function prendreIssue(promesse) {
let rendue;
try {
rendue = await promesse;
} catch (erreur) {
if (refusDeLaListe(erreur) === null) throw erreur;
return;
}
if (detruit) return;
issue = rendue;
await lireCorbeille();
}
// La détermination se demande au montage, et de nouveau quand le refus de
// la lecture se lève sans qu'elle soit connue : une détermination refusée
// puis refaite se montre alors.
let demande = null;
function demander() {
if (demande !== null) return;
demande = prendreIssue(facade.demarrer()).finally(() => {
demande = null;
});
}
$effect(() => {
if (refusLecture === null && issue === null) untrack(demander);
});
// Après une opération qui change le dossier de travail.
function apresOperation() {
relireListe();
lireCorbeille();
}
// Les comptes d'un import depuis l'autre emplacement, ou de la copie que
// fait la réponse « Documents ».
const comptesImport = ({ importes, refuses }) => ({ importes: importes.length, refuses: refuses.length });
// Une réponse à la question : la détermination qu'elle rend devient celle
// de l'accueil, et la façade d'App relit la liste quand plus rien
// n'attend. Un refus se pose contre la réponse. Tant qu'une réponse
// s'exécute — « Réessayer » refait la détermination —, la question ne
// s'offre plus : la façade n'en a aucune à recevoir.
let reponseEnCours = $state(false);
async function repondre(choix) {
reponseEnCours = true;
let rendue;
try {
rendue = await surLaListe(() => facade.repondre(choix));
} finally {
reponseEnCours = false;
}
if (detruit) return;
if (rendue.importe !== undefined) importAutre = comptesImport(rendue.importe);
issue = rendue;
await lireCorbeille();
}
// L'import de l'autre emplacement ; la détermination relue ne le propose
// plus.
async function importerDepuisAutre() {
importAutre = comptesImport(await surLaListe(() => facade.importerDepuisAutre()));
await prendreIssue(facade.demarrer());
}
async function importerFichier() {
const importe = await surLaListe(() => facade.importerFichier());
if (importe !== null) apresOperation();
}
// Ouvre une ligne en lecture ; un état illisible mène à la récupération.
async function ouvrir(ligne) {
refusOuverture = null;
try {
await seance.ouvrir(ligne.base);
} catch (erreur) {
const refus = refusDeLaListe(erreur);
if (refus === null) throw erreur;
if (refus.code === 'ETAT_ILLISIBLE') recuperation = { base: ligne.base, chemin: ligne.chemin, refus };
else refusOuverture = { base: ligne.base, refus };
}
}
async function charger(cle) {
refusDemonstration = null;
try {
await surLaListe(() => facade.chargerDemonstration(cle));
} catch (refus) {
if (typeof refus?.code !== 'string') throw refus;
refusDemonstration = { cle, refus };
}
}
async function creer(evenement) {
evenement.preventDefault();
refusCreation = null;
try {
await surLaListe(() => facade.creer({ nom, date }));
} catch (refus) {
if (typeof refus?.code !== 'string') throw refus;
refusCreation = refus;
return;
}
apresOperation();
}
</script>
<section class="accueil" data-ecran="accueil">
<h1>{t('coquille.liste')}</h1>
<p class="version">
<span>{t('coquille.version.libelle')}</span>
<span>{versionVisible(provenance, version, t)}</span>
</p>
{#if refus !== null}
<p class="refus" data-refus="accueil">{t(refus.code, refus.details)}</p>
{/if}
<ul class="entrees">
{#each lignes as ligne (ligne.base)}
<li>
<button type="button" data-evenement={ligne.base} onclick={() => tenter(() => seance.ouvrir(ligne.base))}
>{ligne.nom ?? ligne.base}</button
>
</li>
{/each}
{#each demonstrations as demonstration (demonstration.cle)}
<li>
<button
type="button"
data-demonstration={demonstration.cle}
onclick={() => tenter(() => facade.chargerDemonstration(demonstration.cle))}>{demonstration.nom}</button
>
</li>
{/each}
</ul>
</section>
{#if recuperation !== null}
<Recuperation {...recuperation} retour={() => (recuperation = null)} />
{:else}
<section class="accueil" data-ecran="accueil">
<h1>{t('coquille.liste')}</h1>
<p class="version" data-version>
<span>{t('coquille.version.libelle')}</span>
<span>{versionVisible(provenance, version, t)}</span>
</p>
{#if refusLecture !== null}
<p class="refus" data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
{/if}
{#if issue !== null}
<DossierTravail
{issue}
{racine}
{avis}
explorateur={facade.explorateur()}
{repondre}
{reponseEnCours}
{importerDepuisAutre}
{importAutre}
lignesPresentes={lignes.length > 0}
/>
{/if}
<ListeEvenements {lignes} {ouvrir} {refusOuverture} {apresOperation} />
<section class="creation" aria-labelledby={`${uid}-creation`}>
<h2 id={`${uid}-creation`}>{t('accueil.creation.titre')}</h2>
<form novalidate onsubmit={creer}>
<div class="champ">
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
<input
id={`${uid}-nom`}
type="text"
data-champ="nom"
bind:value={nom}
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'nom'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="champ">
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
<input
id={`${uid}-date`}
type="text"
data-champ="date"
bind:value={date}
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'date'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="commandes">
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button>
{#if refusCreation !== null && champRefuse === null}
<Refus refus={refusCreation} />
{/if}
</div>
</form>
<Commande cle="accueil.operation.importer" executer={importerFichier} />
</section>
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
<Corbeille contenu={corbeille} />
</section>
{/if}
<style>
.entrees {
.accueil {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h1,
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
.version {
display: flex;
gap: var(--espace-1);
}
.refus {
color: var(--couleur-refus);
}
.creation,
form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: 0;
list-style: none;
}
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
.commandes {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
input:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
button {
@ -84,16 +344,7 @@
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
text-align: start;
font-size: var(--taille-texte);
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.refus {
color: var(--couleur-refus);
}
</style>

View file

@ -0,0 +1,71 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La corbeille du dossier de travail (§ 8.7) : un événement supprimé y est
// déplacé avec tous ses fichiers, dans un dossier daté, et le logiciel ne la
// vide jamais. Chaque dossier daté montre son chemin en entier et le nom de
// chacun de ses fichiers, des données dans leur propre nœud. Une corbeille
// vide ne montre rien.
//
// contenu : [{ dossier, chemin, fichiers }] de facade.corbeille().
import { getContext } from 'svelte';
let { contenu } = $props();
const t = getContext('t');
const uid = $props.id();
</script>
{#if contenu.length > 0}
<section class="corbeille" data-corbeille aria-labelledby={`${uid}-titre`}>
<h2 id={`${uid}-titre`}>{t('accueil.corbeille.titre')}</h2>
<p>{t('accueil.corbeille.explication')}</p>
<ul class="dossiers">
{#each contenu as { dossier, chemin, fichiers } (dossier)}
<li data-corbeille-dossier={dossier}>
<p class="chemin">{chemin}</p>
<ul class="fichiers">
{#each fichiers as nom (nom)}
<li>{nom}</li>
{/each}
</ul>
</li>
{/each}
</ul>
</section>
{/if}
<style>
.corbeille,
.dossiers {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
.dossiers {
margin: 0;
padding: 0;
list-style: none;
}
.chemin {
overflow-wrap: anywhere;
}
.fichiers {
margin: 0;
padding-left: var(--espace-4);
color: var(--couleur-page-texte-secondaire);
}
</style>

View file

@ -0,0 +1,84 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les démonstrations (§ 15), une issue toujours offerte du premier écran :
// une commande par démonstration reçue, dans leur ordre, qui porte son nom
// — une donnée, dans son propre nœud — et data-demonstration, sa clé.
// Charger crée un événement neuf, numéroté, ouvert en lecture ; aucun
// autre n'est touché. Le refus d'un chargement s'affiche sous sa commande.
//
// demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ;
// refus : { cle, refus } du dernier échec, ou null.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
let { demonstrations, charger, refus } = $props();
const t = getContext('t');
const uid = $props.id();
</script>
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
<h2 id={`${uid}-titre`}>{t('accueil.demonstrations.titre')}</h2>
<p>{t('accueil.demonstrations.explication')}</p>
<ul>
{#each demonstrations as { cle, nom } (cle)}
<li>
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button>
{#if refus !== null && refus.cle === cle}
<Refus refus={refus.refus} />
{/if}
</li>
{/each}
</ul>
</section>
<style>
.demonstrations,
ul {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
text-align: start;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,140 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le dossier de travail sur la liste (§ 8.6, § 8.8), dans cet ordre : la
// question qui attend sa réponse — elle vient avant toute liste, et nomme
// le dossier, la cause et ce qu'il porte ; la proposition d'importer
// l'autre emplacement, quand le dossier retenu est neuf et vide — son
// compte, puis son chemin dans son propre nœud (data-autre-chemin) —, avec
// la phrase sur les originaux ; les avis de la détermination, non modaux ;
// puis le chemin du dossier, en entier, et la commande qui l'ouvre dans
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque.
//
// issue : la détermination { dossier, avis } de la façade ; racine : le
// dossier de travail de la séance, null tant qu'une question attend ;
// avis : ceux à dire, vides hors du premier accueil de la séance ;
// explorateur : { disponible, raison } de la façade ; repondre(choix) et
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
// porte déjà des événements, et l'import de l'autre emplacement ne se
// propose plus.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { reponsesDe, surLaListe } from './modele.js';
let {
issue,
racine,
avis,
explorateur,
repondre,
reponseEnCours,
importerDepuisAutre,
importAutre,
lignesPresentes,
} = $props();
const t = getContext('t');
const facade = getContext('facade');
const uid = $props.id();
const question = $derived(issue.dossier.question);
const autre = $derived(issue.dossier.autre);
const reponses = $derived(question === null ? [] : reponsesDe(question));
</script>
<section class="dossier" data-dossier>
{#if question !== null}
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
<p id={`${uid}-question`}>{t(question.code, question)}</p>
{#if !reponseEnCours}
<div class="reponses">
{#each reponses as { choix, cle } (choix)}
<Commande {cle} executer={() => repondre(choix)} />
{/each}
</div>
{/if}
</div>
{/if}
{#if autre !== null && !lignesPresentes}
<div class="autre" data-autre>
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
<p>{t('accueil.autre.originaux')}</p>
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
</div>
{/if}
{#if importAutre !== null}
<p class="avis" role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
{/if}
{#each avis as { code, details }, rang (rang)}
<p class="avis" role="status" data-avertissement={code}>{t(code, details)}</p>
{/each}
{#if question === null && racine !== null}
<div class="chemin">
<h2 id={`${uid}-titre`}>{t('accueil.dossier.titre')}</h2>
<p data-dossier-travail>{racine.chemin}</p>
{#if explorateur.disponible}
<Commande cle="accueil.dossier.explorateur" executer={() => surLaListe(() => facade.ouvrirExplorateur())} />
{:else}
<p class="avis" data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
{/if}
</div>
{/if}
</section>
<style>
.dossier {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
.question,
.autre {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-3);
border: 2px solid var(--couleur-avis);
border-radius: var(--rayon-2);
}
.reponses {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.avis {
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
}
.chemin {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2) var(--espace-3);
}
[data-dossier-travail],
.donnee {
overflow-wrap: anywhere;
}
</style>

View file

@ -0,0 +1,158 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La liste des événements (§ 8.1) : une ligne par événement du dossier de
// travail, dans l'ordre de la façade, que désigne data-evenement. Chaque
// ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq
// autres informations sous leur intitulé, chacune dans son propre nœud
// (data-colonne), ses signalements traduits — les bases qu'un doublon
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
// le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide :
// la création et les démonstrations de l'accueil en tiennent lieu.
//
// lignes : LigneListe[] de la façade ; ouvrir(ligne) : le geste de
// l'accueil ; refusOuverture : { base, refus } du dernier échec, ou null ;
// apresOperation() : à appeler quand une opération a changé le dossier.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
import Operations from './Operations.svelte';
import { ligneAffichee } from './modele.js';
let { lignes, ouvrir, refusOuverture, apresOperation } = $props();
const t = getContext('t');
const format = getContext('format');
</script>
{#if lignes.length > 0}
<ul class="liste">
{#each lignes as ligne (ligne.base)}
{@const affichee = ligneAffichee(ligne, format)}
<li class="ligne" data-evenement={ligne.base}>
<button type="button" class="ouvrir" data-commande="ouvrir" onclick={() => ouvrir(ligne)}
><span data-colonne="nom">{affichee.nom}</span></button
>
<dl class="informations">
<div>
<dt>{t('accueil.colonne.date')}</dt>
<dd data-colonne="date">{affichee.date}</dd>
</div>
<div>
<dt>{t('accueil.colonne.etat')}</dt>
<dd data-colonne="etat">{t(affichee.etat)}</dd>
</div>
<div>
<dt>{t('accueil.colonne.participants')}</dt>
<dd data-colonne="participants">{affichee.participants}</dd>
</div>
<div>
<dt>{t('accueil.colonne.modification')}</dt>
<dd data-colonne="modification">{affichee.modification}</dd>
</div>
<div class="chemin">
<dt>{t('accueil.colonne.chemin')}</dt>
<dd data-colonne="chemin">{affichee.chemin}</dd>
</div>
</dl>
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
<div class="signalement" data-signalement={cle}>
<p>{t(cle, details)}</p>
{#if noms.length > 0}
<ul class="noms">
{#each noms as nom (nom)}
<li data-doublon={nom}>{nom}</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
<Refus refus={refusOuverture.refus} />
{/if}
<Operations {ligne} {apresOperation} />
</li>
{/each}
</ul>
{/if}
<style>
.liste {
display: flex;
flex-direction: column;
gap: var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
}
.ligne {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
}
.ouvrir {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-titre);
font-weight: bold;
text-align: start;
cursor: pointer;
}
.ouvrir:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.informations {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-4);
margin: 0;
}
.informations div {
display: flex;
flex-direction: column;
}
dt {
color: var(--couleur-page-texte-secondaire);
}
dd {
margin: 0;
}
.chemin dd {
overflow-wrap: anywhere;
}
.signalement {
display: flex;
flex-direction: column;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
}
.signalement p {
margin: 0;
}
.noms {
margin: 0;
padding-left: var(--espace-4);
overflow-wrap: anywhere;
}
</style>

View file

@ -0,0 +1,152 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les opérations de fichiers d'une ligne de la liste (§ 8.7), leur seule
// place : copier, renommer, supprimer. Copier et renommer demandent d'abord
// un nom (saisieDe) : rien ne s'écrit avant la validation, et un nom vide
// se refuse contre le champ. Copier ouvre la copie en lecture ; renommer
// laisse la liste, que l'accueil relit. Supprimer déplace les fichiers dans
// la corbeille et se confirme en place, par un second clic : aucune
// fenêtre. Chaque refus s'affiche contre le champ ou à côté de sa
// commande, sans remède (surLaListe) ; apresOperation() suit chaque
// opération réussie.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { champDuRefus, saisieDe, surLaListe } from './modele.js';
let { ligne, apresOperation } = $props();
const t = getContext('t');
const facade = getContext('facade');
const uid = $props.id();
// La saisie en cours, { operation, champ, etiquette, commande, valider,
// texte }, ou null ; et son refus.
let saisie = $state(null);
let refus = $state.raw(null);
const surLeChamp = $derived(champDuRefus(refus) === 'nom');
function ouvrirSaisie(operation) {
refus = null;
saisie = { operation, ...saisieDe(operation, ligne.nom) };
}
function fermerSaisie() {
saisie = null;
refus = null;
}
// Le geste de chaque saisie, par la façade.
const GESTES = {
copier: (nom) => facade.copier(ligne.base, { nom }),
renommer: (nom) => facade.renommer(ligne.base, { nom }),
};
async function valider(evenement) {
evenement.preventDefault();
refus = null;
const { operation, texte } = saisie;
try {
await surLaListe(() => GESTES[operation](texte));
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refus = erreur;
return;
}
fermerSaisie();
apresOperation();
}
async function supprimer() {
await surLaListe(() => facade.supprimer(ligne.base));
apresOperation();
}
</script>
<div class="operations">
{#if saisie === null}
<Commande cle="accueil.operation.copier" executer={() => ouvrirSaisie('copier')} />
<Commande cle="accueil.operation.renommer" executer={() => ouvrirSaisie('renommer')} />
<Commande cle="accueil.operation.supprimer" confirmer="accueil.operation.confirmerSuppression" executer={supprimer} />
{:else}
<form novalidate onsubmit={valider}>
<div class="champ">
<label for={`${uid}-nom`}>{t(saisie.etiquette)}</label>
<input
id={`${uid}-nom`}
type="text"
data-champ={saisie.champ}
bind:value={saisie.texte}
aria-invalid={surLeChamp ? 'true' : undefined}
aria-describedby={surLeChamp ? `${uid}-refus` : undefined}
/>
{#if surLeChamp}
<Refus id={`${uid}-refus`} {refus} />
{/if}
</div>
<div class="commandes">
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button>
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
{#if refus !== null && !surLeChamp}
<Refus {refus} />
{/if}
</div>
</form>
{/if}
</div>
<style>
.operations,
.commandes {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
form,
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
.champ {
gap: var(--espace-1);
}
input {
min-width: 20em;
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
cursor: pointer;
}
input:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,81 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La récupération d'un état qui ne s'ouvre pas (§ 8.8) : elle ne repart
// jamais de zéro en silence. Elle nomme le fichier, en entier, et la raison
// — le texte de l'ETAT_ILLISIBLE qu'a levé l'ouverture —, puis offre les
// secours lisibles dans l'ordre du § 8.8, la version précédente puis le
// dernier instant du journal (secoursOfferts), chacun ouvert en lecture
// par seance.ouvrirSecours, sans rien écrire. Sans secours lisible, elle
// le dit, et rien ne s'ouvre ni ne se crée. Le retour rend la liste.
//
// base, chemin : ceux de la ligne ; refus : { code, details } de
// l'ouverture refusée ; retour() : rend la liste.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { secoursOfferts, surLaListe } from './modele.js';
let { base, chemin, refus, retour } = $props();
const t = getContext('t');
const seance = getContext('seance');
const uid = $props.id();
const offerts = $derived(secoursOfferts(refus.details.secours));
</script>
<section class="recuperation" data-ecran="recuperation" aria-labelledby={`${uid}-titre`}>
<h1 id={`${uid}-titre`}>{t('accueil.recuperation.titre')}</h1>
<p class="fichier">
<span>{t('accueil.recuperation.fichier')}</span>
<span class="chemin">{chemin}</span>
</p>
<Refus {refus} />
{#if offerts.length > 0}
<p>{t('accueil.recuperation.offerte')}</p>
<ul class="secours">
{#each offerts as { source, cle } (source)}
<li data-secours={source}>
<Commande {cle} executer={() => surLaListe(() => seance.ouvrirSecours(base, source))} />
</li>
{/each}
</ul>
{:else}
<p>{t('accueil.recuperation.aucunSecours')}</p>
{/if}
<Commande cle="accueil.recuperation.retour" executer={retour} />
</section>
<style>
.recuperation,
.secours {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-3);
}
h1,
p {
margin: 0;
}
.fichier {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.chemin {
overflow-wrap: anywhere;
}
.secours {
gap: var(--espace-2);
margin: 0;
padding: 0;
list-style: none;
}
</style>

View file

@ -0,0 +1,272 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
// de la liste des événements affichent, sans arithmétique ni texte. Une
// ligne de la liste (LigneListe de la façade, A3) devient ses six
// informations mises en forme et ses signalements ; une question du dossier
// de travail, ses réponses ; un état illisible, ses secours ; un refus de la
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en
// entier : aucun composant ne compose une clé.
// Les états du plan (§ 9) et la clé de leur nom, dans l'ordre du § 9.
const CLES_ETATS = new Map([
['brouillon', 'accueil.etat.brouillon'],
['propose', 'accueil.etat.propose'],
['retenu', 'accueil.etat.retenu'],
['bloque', 'accueil.etat.bloque'],
]);
/** Les états du plan, dans l'ordre du § 9. */
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
// La clé de la valeur absente, celle de la mise en forme.
const CLE_VIDE = 'format.vide';
// Les réponses de chaque question du dossier de travail, dans l'ordre où
// l'accueil les offre, et la clé de leur libellé : celles qu'admet
// facade.repondre (A3).
const REPONSES = new Map([
[
'PORTABLE_NON_INSCRIPTIBLE',
[
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
{ choix: 'documents', cle: 'accueil.question.documents' },
],
],
[
'DOCUMENTS_NON_INSCRIPTIBLE',
[
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
{ choix: 'continuer', cle: 'accueil.question.continuer' },
],
],
]);
// Les secours d'un état illisible, dans l'ordre du § 8.8 — la version
// précédente, puis le dernier instant du journal —, et la clé de la commande
// qui l'ouvre.
const SECOURS = [
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
];
/**
* La clé du nom d'un état du plan ; null, celui d'un état illisible, rend
* la clé de la valeur absente.
*
* @param {string|null} etat
* @returns {string}
* @throws {RangeError} un état hors de ETATS
*/
export function cleDEtat(etat) {
if (etat === null) return CLE_VIDE;
const cle = CLES_ETATS.get(etat);
if (cle === undefined) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
return cle;
}
/**
* Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details, noms }
* : la traduction compose cle et details, et noms sont les données qu'il
* nomme hors de son texte, que le composant rend chacune dans son propre
* nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un journal
* sans état compris, de raison ABSENT —, avec la base, la raison et les
* secours lus par la façade, et aucun nom à part ;
* accueil.signalement.doublon pour un état dont d'autres bases portent
* l'identifiant, avec leur nombre, et ces bases pour noms, dans l'ordre de la
* façade.
* Un verrou présent ne se signale pas : seule la sonde d'une opération dit si
* sa séance vit. Les détails et les noms sont des copies.
*
* @param {Object} ligne LigneListe (A3)
* @returns {Array<{cle: string, details: Object, noms: string[]}>}
*/
export function signalementsDe(ligne) {
const signalements = [];
if (ligne.illisible !== null) {
signalements.push({ cle: 'ETAT_ILLISIBLE', details: { base: ligne.base, ...ligne.illisible }, noms: [] });
}
if (ligne.doublonAvec.length > 0) {
const noms = [...ligne.doublonAvec];
signalements.push({ cle: 'accueil.signalement.doublon', details: { autres: noms.length }, noms });
}
return signalements;
}
/**
* Les six informations d'une ligne (§ 8.1), mises en forme, et ses
* signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
* ne se lit pas ; date, participants et dernière modification par format,
* la valeur absente quand ils manquent — la dernière modification est
* l'horodatage de la dernière entrée du journal, sur l'horloge de
* l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la
* clé de son nom, que le composant traduit ; chemin : tel que la façade
* l'affiche, en entier.
*
* @param {Object} ligne LigneListe (A3)
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @returns {{nom: string, date: string, etat: string, participants: string,
* modification: string, chemin: string,
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
*/
export function ligneAffichee(ligne, format) {
return {
nom: ligne.nom ?? ligne.base,
date: ligne.date === null ? format.nombre(null) : format.date(ligne.date),
etat: cleDEtat(ligne.etat),
participants: format.nombre(ligne.participants),
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
chemin: ligne.chemin,
signalements: signalementsDe(ligne),
};
}
/**
* Les réponses qu'offre une question du dossier de travail (§ 8.6), dans
* l'ordre : [{ choix, cle }], choix tel que facade.repondre le reçoit.
*
* @param {{code: string}} question
* @returns {Array<{choix: string, cle: string}>}
* @throws {RangeError} une question inconnue
*/
export function reponsesDe(question) {
const reponses = REPONSES.get(question.code);
if (reponses === undefined) throw new RangeError(`question inconnue : ${JSON.stringify(question.code)}`);
return reponses.map((reponse) => ({ ...reponse }));
}
/**
* Les secours lisibles d'un état illisible (§ 8.8), dans l'ordre : la
* version précédente, puis le dernier instant du journal ; chacun { source,
* cle }, source telle que seance.ouvrirSecours la reçoit. Aucun secours, ou
* des secours non lus, rendent [].
*
* @param {{precedent: number|null, journal: number|null}|null|undefined} secours
* @returns {Array<{source: 'precedent'|'journal', cle: string}>}
*/
export function secoursOfferts(secours) {
if (secours === null || secours === undefined) return [];
return SECOURS.filter(({ source }) => secours[source] !== null && secours[source] !== undefined).map((offert) => ({
...offert,
}));
}
/**
* Le refus d'un geste de la liste, tel que l'accueil l'affiche : le code et
* les détails de l'erreur, son remède retiré. Sur la liste, aucun événement
* n'est ouvert : un remède de la séance — débloquer, modifier, ouvrir un
* secours — n'y aurait rien sur quoi agir, et le texte du refus dit déjà ce
* qui le lève. Une erreur sans code n'est pas un refus : null.
*
* @param {unknown} erreur
* @returns {{code: string, details: Object}|null}
*/
export function refusDeLaListe(erreur) {
if (typeof erreur?.code !== 'string') return null;
return { code: erreur.code, details: { ...(erreur.details ?? {}), remede: null } };
}
/**
* Exécute une opération de la liste et rend ce qu'elle rend. Un refus
* remonte tel que la liste l'affiche (refusDeLaListe) : { code, details },
* sans remède. Toute autre erreur est une faute ou une panne, et remonte
* telle quelle.
*
* @template T
* @param {() => T|Promise<T>} operation
* @returns {Promise<T>}
*/
export async function surLaListe(operation) {
try {
return await operation();
} catch (erreur) {
const refus = refusDeLaListe(erreur);
if (refus === null) throw erreur;
throw refus;
}
}
// Le champ que désigne chaque refus d'un nom ou d'une date saisis.
const CHAMPS_DES_REFUS = new Map([
['NOM_REQUIS', 'nom'],
['DATE_INVALIDE', 'date'],
]);
/**
* Le champ contre lequel un refus se pose : 'nom' pour NOM_REQUIS, 'date'
* pour DATE_INVALIDE ; null pour tout autre refus, qui se pose contre la
* commande, et sans refus.
*
* @param {{code: string}|null} refus
* @returns {'nom'|'date'|null}
*/
export function champDuRefus(refus) {
if (refus === null) return null;
return CHAMPS_DES_REFUS.get(refus.code) ?? null;
}
// La saisie d'un nom de chaque opération qui en demande un (§ 8.7) : le
// data-champ de l'entrée, la clé de son étiquette, le data-commande et la
// clé du libellé de la commande qui valide.
const CLES_SAISIES = new Map([
[
'copier',
{
champ: 'nomCopie',
etiquette: 'accueil.operation.nomCopie',
commande: 'validerCopie',
valider: 'accueil.operation.validerCopie',
},
],
[
'renommer',
{
champ: 'nouveauNom',
etiquette: 'accueil.operation.nouveauNom',
commande: 'validerRenommage',
valider: 'accueil.operation.validerRenommage',
},
],
]);
/** Les opérations de la liste qui demandent un nom avant d'écrire. */
export const SAISIES = Object.freeze([...CLES_SAISIES.keys()]);
/**
* La saisie d'un nom pour une opération (§ 8.7) : son champ, ses clés, et le
* texte de départ — vide pour copier, qui demande un nom neuf plutôt que d'en
* empiler un « (copie) » ; le nom inscrit pour renommer, vide quand l'état
* ne se lit pas.
*
* @param {'copier'|'renommer'} operation
* @param {string|null} nom le nom inscrit dans le fichier
* @returns {{champ: string, etiquette: string, commande: string, valider: string, texte: string}}
* @throws {RangeError} une opération hors de SAISIES
*/
export function saisieDe(operation, nom) {
const cles = CLES_SAISIES.get(operation);
if (cles === undefined) throw new RangeError(`opération sans saisie : ${JSON.stringify(operation)}`);
return { ...cles, texte: operation === 'renommer' ? (nom ?? '') : '' };
}
// Les séances dont le dossier de travail a déjà été annoncé.
const ANNONCEES = new WeakSet();
/**
* Vrai la première fois qu'on le demande pour une séance, faux ensuite
* (§ 8.6) : l'accueil annonce le dossier de travail — les avis de la
* détermination — une fois par séance, et non à chaque retour à la liste.
* La séance est un objet qui vit autant qu'elle, la façade d'App ; rien ne
* survit à elle.
*
* @param {object} seance
* @returns {boolean}
* @throws {TypeError} une séance qui n'est pas un objet
*/
export function premiereAnnonce(seance) {
if (seance === null || typeof seance !== 'object') throw new TypeError('premiereAnnonce : la séance est un objet');
if (ANNONCEES.has(seance)) return false;
ANNONCEES.add(seance);
return true;
}

View file

@ -0,0 +1,303 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'accueil (§ 8.1, § 8.6, § 8.8) : les six informations d'une
// ligne de la liste, mises en forme, et ses signalements ; les réponses
// d'une question du dossier de travail ; les secours d'un état illisible ;
// le refus d'un geste de la liste, sans remède ; l'annonce du dossier de
// travail, une fois par séance. Chaque clé que le modèle rend existe dans la
// traduction. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
ETATS,
SAISIES,
champDuRefus,
cleDEtat,
ligneAffichee,
premiereAnnonce,
refusDeLaListe,
reponsesDe,
saisieDe,
secoursOfferts,
signalementsDe,
surLaListe,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const VIDE = t('format.vide');
const RACINE = 'C:\\Epreuve\\Tablees';
// Une ligne de la liste telle que la façade la rend (LigneListe, A3), lisible.
function ligne(champs = {}) {
return {
base: 'Bal des Grisailles',
id: 'evt-grisailles',
nom: 'Bal des Grisailles',
date: '2031-05-17',
etat: 'propose',
participants: 1234,
derniereModification: '2031-05-17T08:05:00-04:00',
chemin: `${RACINE}\\Bal des Grisailles.gtt.json`,
illisible: null,
journalSeul: false,
doublonAvec: [],
verrou: null,
...champs,
};
}
describe('ligneAffichee : les six informations (§ 8.1)', () => {
test('une ligne lisible : nom, date, état, participants, dernière modification et chemin, mis en forme', () => {
const affichee = ligneAffichee(ligne(), format);
assert.deepEqual(Object.keys(affichee).sort(), [
'chemin',
'date',
'etat',
'modification',
'nom',
'participants',
'signalements',
]);
assert.equal(affichee.nom, 'Bal des Grisailles');
assert.equal(affichee.date, format.date('2031-05-17'));
assert.equal(affichee.etat, 'accueil.etat.propose');
assert.equal(affichee.participants, format.nombre(1234));
assert.equal(affichee.modification, format.horodatage('2031-05-17T08:05:00-04:00'));
assert.equal(affichee.chemin, `${RACINE}\\Bal des Grisailles.gtt.json`);
assert.deepEqual(affichee.signalements, []);
});
test('sans journal, la dernière modification est la valeur absente ; sans date, de même — jamais une date du système', () => {
const affichee = ligneAffichee(ligne({ derniereModification: null, date: null }), format);
assert.equal(affichee.modification, VIDE);
assert.equal(affichee.date, VIDE);
});
test('une ligne illisible : son nom est sa base, ses informations absentes, son état la valeur absente', () => {
const affichee = ligneAffichee(
ligne({
nom: null,
date: null,
etat: null,
participants: null,
derniereModification: null,
illisible: { base: 'Bal des Grisailles', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } },
}),
format,
);
assert.equal(affichee.nom, 'Bal des Grisailles');
assert.equal(affichee.date, VIDE);
assert.equal(affichee.etat, 'format.vide');
assert.equal(affichee.participants, VIDE);
assert.equal(affichee.modification, VIDE);
});
test('chaque état du plan a sa clé, qui existe ; un état inconnu lève', () => {
assert.deepEqual([...ETATS], ['brouillon', 'propose', 'retenu', 'bloque']);
for (const etat of ETATS) {
const cle = cleDEtat(etat);
assert.equal(cle, `accueil.etat.${etat}`);
assert.equal(typeof t(cle), 'string');
}
assert.equal(cleDEtat(null), 'format.vide');
assert.throws(() => cleDEtat('archive'), RangeError);
});
test('ligneAffichee ne lit pas la ligne au-delà de ses champs : deux appels rendent la même chose', () => {
assert.deepEqual(ligneAffichee(ligne(), format), ligneAffichee(ligne(), format));
});
});
describe('signalementsDe (§ 8.1, § 8.8)', () => {
test('un état vide : ETAT_ILLISIBLE, sa base et sa raison', () => {
const illisible = { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } };
const signalements = signalementsDe(ligne({ base: 'Soirée vide', nom: null, illisible }));
assert.deepEqual(signalements, [{ cle: 'ETAT_ILLISIBLE', details: illisible, noms: [] }]);
assert.match(t(signalements[0].cle, signalements[0].details), /vide/);
});
test('un journal sans état : ETAT_ILLISIBLE de raison ABSENT, qui nomme le secours du journal', () => {
const illisible = { base: 'Soirée des Hiboux', raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: 1 } };
const [signalement, ...reste] = signalementsDe(ligne({ base: 'Soirée des Hiboux', journalSeul: true, illisible }));
assert.deepEqual(reste, []);
assert.equal(signalement.cle, 'ETAT_ILLISIBLE');
assert.equal(signalement.details.raison, 'ABSENT');
assert.match(t(signalement.cle, signalement.details), /historique/);
});
test('un doublon nomme chaque autre base à part, hors de son texte, et sa clé existe', () => {
const signalements = signalementsDe(ligne({ base: 'Merles A', doublonAvec: ['Merles B'] }));
assert.deepEqual(signalements, [{ cle: 'accueil.signalement.doublon', details: { autres: 1 }, noms: ['Merles B'] }]);
// Une base est une donnée : elle occupe son propre nœud, jamais le texte.
assert.ok(!t(signalements[0].cle, signalements[0].details).includes('Merles'));
const deux = signalementsDe(ligne({ doublonAvec: ['Merles B', 'Merles C'] }));
assert.deepEqual(deux[0], { cle: 'accueil.signalement.doublon', details: { autres: 2 }, noms: ['Merles B', 'Merles C'] });
assert.ok(!t(deux[0].cle, deux[0].details).includes('Merles'));
});
test('une ligne lisible sans doublon ne porte rien ; un verrou présent ne se signale pas', () => {
assert.deepEqual(signalementsDe(ligne()), []);
assert.deepEqual(signalementsDe(ligne({ verrou: { seance: 'seance-autre', depuis: null } })), []);
});
test('les détails rendus sont des copies : la ligne reçue ne change pas', () => {
const recue = ligne({ doublonAvec: ['Merles B'] });
const [signalement] = signalementsDe(recue);
signalement.noms.push('Merles Z');
assert.deepEqual(recue.doublonAvec, ['Merles B']);
});
});
describe('reponsesDe : les réponses d’une question du dossier de travail (§ 8.6)', () => {
test('PORTABLE_NON_INSCRIPTIBLE : réessayer, puis travailler dans les Documents', () => {
const reponses = reponsesDe({ code: 'PORTABLE_NON_INSCRIPTIBLE' });
assert.deepEqual(reponses, [
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
{ choix: 'documents', cle: 'accueil.question.documents' },
]);
});
test('DOCUMENTS_NON_INSCRIPTIBLE : réessayer, puis continuer', () => {
const reponses = reponsesDe({ code: 'DOCUMENTS_NON_INSCRIPTIBLE' });
assert.deepEqual(reponses, [
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
{ choix: 'continuer', cle: 'accueil.question.continuer' },
]);
});
test('chaque clé de réponse existe ; une question inconnue lève', () => {
let comptees = 0;
for (const code of ['PORTABLE_NON_INSCRIPTIBLE', 'DOCUMENTS_NON_INSCRIPTIBLE']) {
for (const { cle } of reponsesDe({ code })) {
comptees += 1;
assert.equal(typeof t(cle), 'string');
}
}
assert.equal(comptees, 4);
assert.throws(() => reponsesDe({ code: 'AUTRE' }), RangeError);
});
});
describe('secoursOfferts : les secours d’un état illisible (§ 8.8)', () => {
test('la version précédente, puis le dernier instant du journal, dans cet ordre', () => {
assert.deepEqual(secoursOfferts({ precedent: 4, journal: 6 }), [
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
]);
});
test('seuls les secours lisibles ; aucun, ou des secours inconnus, ne rendent rien', () => {
assert.deepEqual(secoursOfferts({ precedent: null, journal: 1 }), [
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
]);
assert.deepEqual(secoursOfferts({ precedent: 2, journal: null }), [
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
]);
assert.deepEqual(secoursOfferts({ precedent: null, journal: null }), []);
assert.deepEqual(secoursOfferts(null), []);
assert.deepEqual(secoursOfferts(undefined), []);
});
});
describe('refusDeLaListe : le refus d’un geste de la liste', () => {
test('code et détails gardés, le remède retiré : aucun événement n’est ouvert pour l’exécuter', () => {
const erreur = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
const refus = refusDeLaListe(erreur);
assert.deepEqual(refus, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } });
assert.deepEqual(erreur.details.remede, { geste: 'debloquer' });
assert.equal(t(refus.code, refus.details), t(erreur.code, erreur.details));
});
test('un refus sans détails en reçoit de vides ; une erreur sans code n’est pas un refus', () => {
assert.deepEqual(refusDeLaListe({ code: 'NOM_REQUIS' }), { code: 'NOM_REQUIS', details: { remede: null } });
assert.equal(refusDeLaListe(new TypeError('faute')), null);
assert.equal(refusDeLaListe(null), null);
});
});
describe('premiereAnnonce : le dossier de travail s’annonce une fois par séance (§ 8.6)', () => {
test('vrai la première fois pour une séance, faux ensuite ; une autre séance a sa première fois', () => {
const seance = {};
const autre = {};
assert.equal(premiereAnnonce(seance), true);
assert.equal(premiereAnnonce(seance), false);
assert.equal(premiereAnnonce(seance), false);
assert.equal(premiereAnnonce(autre), true);
assert.equal(premiereAnnonce(autre), false);
});
test('une séance absente lève', () => {
assert.throws(() => premiereAnnonce(null), TypeError);
});
});
describe('surLaListe : une opération de la liste et son refus', () => {
test('rend ce que rend l’opération', async () => {
assert.deepEqual(await surLaListe(async () => ({ base: 'Bal des Grisailles' })), { base: 'Bal des Grisailles' });
assert.equal(await surLaListe(() => null), null);
});
test('un refus remonte sans remède ; toute autre erreur remonte telle quelle', async () => {
const refus = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
await assert.rejects(
surLaListe(async () => {
throw refus;
}),
(recu) => {
assert.deepEqual(recu, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } });
return true;
},
);
const faute = new TypeError('faute');
await assert.rejects(
surLaListe(() => {
throw faute;
}),
(recu) => recu === faute,
);
});
});
describe('champDuRefus : le champ contre lequel un refus se pose', () => {
test('le nom pour NOM_REQUIS, la date pour DATE_INVALIDE, aucun sinon', () => {
assert.equal(champDuRefus({ code: 'NOM_REQUIS', details: {} }), 'nom');
assert.equal(champDuRefus({ code: 'DATE_INVALIDE', details: { date: '2031-02-30' } }), 'date');
assert.equal(champDuRefus({ code: 'CHEMIN_TROP_LONG', details: {} }), null);
assert.equal(champDuRefus(null), null);
});
});
describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', () => {
test('copier part d’un champ vide, renommer du nom inscrit ; chaque clé existe', () => {
assert.deepEqual([...SAISIES], ['copier', 'renommer']);
const copier = saisieDe('copier', 'Bal des Grisailles');
assert.deepEqual(copier, {
champ: 'nomCopie',
etiquette: 'accueil.operation.nomCopie',
commande: 'validerCopie',
valider: 'accueil.operation.validerCopie',
texte: '',
});
const renommer = saisieDe('renommer', 'Bal des Grisailles');
assert.deepEqual(renommer, {
champ: 'nouveauNom',
etiquette: 'accueil.operation.nouveauNom',
commande: 'validerRenommage',
valider: 'accueil.operation.validerRenommage',
texte: 'Bal des Grisailles',
});
assert.equal(saisieDe('renommer', null).texte, '');
for (const saisie of [copier, renommer]) {
assert.equal(typeof t(saisie.etiquette), 'string');
assert.equal(typeof t(saisie.valider), 'string');
}
assert.throws(() => saisieDe('supprimer', 'x'), RangeError);
});
});

View file

@ -4,7 +4,7 @@
// L'état de navigation de l'interface : la section ouverte, le panneau « À
// propos », le tour montré, la personne mise en évidence, la proposition
// demandée au plan, le texte d'un import en attente, les listes de noms, le
// plein écran, la place sélectionnée. Regarder n'écrit jamais (§ 8.4) : cet
// plein écran, la place sélectionnée, la fiche ouverte. Regarder n'écrit jamais (§ 8.4) : cet
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
// rien de ce qu'il tient ne quitte la mémoire.
//
@ -25,6 +25,7 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* @property {boolean} listes les listes de noms sont montrées
* @property {boolean} pleinEcran
* @property {Object|null} selection la place sélectionnée
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@ -38,6 +39,7 @@ const DEPART = Object.freeze({
listes: true,
pleinEcran: false,
selection: null,
fiche: null,
});
/**
@ -49,7 +51,7 @@ const DEPART = Object.freeze({
* montrerProposition: Function, ouvrirImport: Function,
* prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function}}
* selectionner: Function, ouvrirFiche: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
@ -118,5 +120,16 @@ export function creerEtatUi() {
selectionner(place) {
changer({ selection: place });
},
/** Ouvre la fiche de la personne id, entier positif, dans la section des
* participants ; null referme la fiche, la section reste. RangeError
* sinon. */
ouvrirFiche(id) {
if (id === null) {
changer({ fiche: null });
return;
}
if (!Number.isInteger(id) || id < 1) throw new RangeError(`fiche : entier positif ou null attendu, reçu ${String(id)}`);
changer({ fiche: id, section: 'participants' });
},
};
}

View file

@ -20,6 +20,7 @@ const DEPART = {
listes: true,
pleinEcran: false,
selection: null,
fiche: null,
};
// Un état et le relevé de ce que reçoit son abonné.
@ -87,6 +88,7 @@ describe('état de navigation : chaque transition', () => {
ui.basculerListes();
ui.basculerPleinEcran();
ui.selectionner({ table: 1, siege: 2 });
ui.ouvrirFiche(4);
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
@ -143,11 +145,32 @@ describe('état de navigation : chaque transition', () => {
assert.deepEqual(ui.etat(), DEPART);
});
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
const ui = creerEtatUi();
ui.allerA('plan');
ui.ouvrirFiche(7);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'participants', fiche: 7 });
ui.allerA('tables');
ui.ouvrirFiche(null);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'tables', fiche: null });
});
test('ouvrirFiche prend un identifiant entier positif ou null ; sinon RangeError sans rien changer', () => {
const ui = creerEtatUi();
ui.ouvrirFiche(3);
const avant = ui.etat();
for (const faute of [0, -2, 1.5, Number.NaN, '3', undefined]) {
assert.throws(() => ui.ouvrirFiche(faute), RangeError, String(faute));
}
assert.equal(ui.etat(), avant);
});
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
const { ui, recus } = suivi();
ui.allerA(SECTION_OUVERTURE);
ui.fermerApropos();
ui.mettreEnEvidence(null);
ui.ouvrirFiche(null);
assert.equal(recus.length, 1);
});
});

View file

@ -0,0 +1,108 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les avertissements de l'événement ouvert (§ 8.6, § 8.8, § 8.9, § 9), dans
// l'ordre de la séance (avertissementsOrdonnes, modele.js) : chacun par le
// texte de son code, puis ce qu'il nomme — les propositions écartées, leur
// numéro et leurs fautes ; les placements en dérive et leurs raisons —,
// puis son remède en Commande, par actionRemede (primitives/commande.js).
// Un avertissement n'empêche rien.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { actionRemede } from '../primitives/commande.js';
import { avertissementsOrdonnes } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const liste = $derived(avertissementsOrdonnes($seance));
</script>
{#if liste.length > 0}
<section class="avertissements" aria-label={t('evenement.avertissements')}>
{#each liste as avertissement, rang (rang)}
{@const action = actionRemede(avertissement.remede, { seance, ui })}
<div class="avertissement" role="status" data-avertissement={avertissement.code}>
<p>{t(avertissement.code, avertissement.details)}</p>
{#if avertissement.fautives !== undefined && avertissement.fautives.length > 0}
<ul>
{#each avertissement.fautives as fautive, n (n)}
<li data-fautive={fautive.id ?? ''}>
{#if fautive.id !== null}
<span>{t('evenement.fautive.numero')}</span>
<span class="donnee">{fautive.id}</span>
{:else}
<span>{t('evenement.fautive.rang')}</span>
<span class="donnee">{fautive.rang}</span>
{/if}
{#each fautive.fautes as faute, f (f)}
<span>{t(faute.code, faute)}</span>
{/each}
</li>
{/each}
</ul>
{/if}
{#if avertissement.derives !== undefined && avertissement.derives.length > 0}
<ul>
{#each avertissement.derives as derive (derive.id)}
<li data-derive={derive.id}>
{#if derive.id === 'retenu'}
<span>{t('evenement.derive.retenu')}</span>
{:else}
<span>{t('evenement.derive.proposition')}</span>
<span class="donnee">{derive.id}</span>
{/if}
{#each derive.raisons as raison, r (r)}
<span>{t(raison.code, raison)}</span>
{/each}
</li>
{/each}
</ul>
{/if}
{#if action !== null}
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} />
{/if}
</div>
{/each}
</section>
{/if}
<style>
.avertissements {
display: flex;
flex-direction: column;
gap: var(--espace-2);
margin-bottom: var(--espace-3);
}
.avertissement {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
font-size: var(--taille-texte);
}
p,
ul {
margin: 0;
}
ul {
padding-left: var(--espace-4);
}
li {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-2);
}
.donnee {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,210 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le cadre de mode (§ 8.4, § 8.5, § 14.5) : un trait autour de la surface
// de travail, dont la forme dit le mode — tirets en lecture, plein en
// écriture, double sur un plan bloqué —, et une barre qui porte, de gauche
// à droite, la place fixe, le libellé de mode, puis « Défaire » et
// « Refaire » (§ 8.3). Sous la barre vient la tête, tete, quand elle est
// donnée — l'en-tête de l'événement et la navigation des sections —, puis
// la surface, children, qui défile seule. La barre ouvre le cadre : rien
// ne la précède dans l'écran de l'événement, si bien qu'un refus posé dans
// la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête
// sans déplacer la place fixe (§ 8.4).
//
// La place fixe porte la commande que rend commandeFixe (modele.js) :
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
// « Revenir en lecture », ou, pour un format plus récent, l'explication du
// refus courant à la place de toute commande. Le verrou d'une autre séance
// qui a refusé le passage en écriture s'y nomme à côté du refus — sa
// séance et l'instant de sa prise, chacun dans son nœud — et, quand sa vie
// ne se prouve pas, « Reprendre le verrou » s'y offre, confirmé en place à
// côté de ce qui le nomme (§ 8.8). « Refaire » n'est là que lorsqu'un
// retour se refait ; « Défaire » est toujours là, et la séance refuse à
// côté de lui ce qui ne se défait pas.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { CONFIRMATION_DEBLOCAGE } from '../primitives/commande.js';
import Refus from '../primitives/Refus.svelte';
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
let { children, tete = null } = $props();
const t = getContext('t');
const seance = getContext('seance');
// Sans événement — l'instant où l'écran se retire —, le cadre garde la
// forme de la lecture, et n'a pas de libellé.
const cadre = $derived(cadreDeMode($seance));
const forme = $derived(cadre?.forme ?? 'tirets');
const commande = $derived(commandeFixe($seance));
const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null);
const reprise = $derived(repriseOfferte($seance));
</script>
<div class="cadre" data-mode={forme} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
<div class="barre" role="group" aria-label={t('evenement.cadre.commandes')}>
<div class="ligne">
<div class="fixe" data-place-fixe>
{#if commande === 'format'}
<Refus refus={$seance.refus} />
{:else if commande === 'modifier'}
<Commande cle="communs.commande.modifier" nom="modifier" executer={() => seance.passerEnEcriture()} />
{:else if commande === 'debloquer'}
<Commande
cle="communs.commande.debloquer"
nom="debloquer"
confirmer={CONFIRMATION_DEBLOCAGE}
executer={() => seance.debloquer()}
/>
{:else if commande === 'lecture'}
<Commande cle="communs.commande.lecture" nom="lecture" executer={() => seance.passerEnLecture()} />
{/if}
</div>
{#if cadre !== null}
<span class="libelle" data-mode-libelle="cadre">{t(cadre.cle)}</span>
{/if}
{#if commande !== 'format'}
<div class="historique" data-historique>
<Commande cle="evenement.commande.defaire" nom="defaire" executer={() => seance.defaire()} />
{#if $seance.retours.refaire}
<Commande cle="evenement.commande.refaire" nom="refaire" executer={() => seance.refaire()} />
{/if}
</div>
{/if}
</div>
{#if verrou !== null}
<div class="verrou" data-verrou>
<p>{t('evenement.verrou.titre')}</p>
<p>
<span>{t('evenement.verrou.seance')}</span>
<span class="donnee" data-verrou-seance>{verrou.seance ?? t('evenement.verrou.inconnu')}</span>
<span>{t('evenement.verrou.depuis')}</span>
<span class="donnee" data-verrou-depuis>{verrou.depuis ?? t('evenement.verrou.inconnu')}</span>
</p>
{#if reprise !== null}
<p>{t('evenement.verrou.avis')}</p>
<Commande
cle="communs.remede.reprendreVerrou"
nom="reprendreVerrou"
confirmer="evenement.verrou.confirmerReprise"
executer={() => seance.reprendreVerrou()}
/>
{/if}
</div>
{/if}
</div>
{#if tete !== null}
<div class="tete">
{@render tete()}
</div>
{/if}
<div class="surface">
{@render children()}
</div>
</div>
<style>
/* Le trait du cadre : sa forme dit le mode, sa couleur le redit. */
/* Une même épaisseur dans les trois formes : la place fixe ne bouge pas
d'un mode à l'autre. */
.cadre {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
border: 6px solid var(--couleur-cadre-ecriture);
border-radius: var(--rayon-2);
}
.cadre.tirets {
border-style: dashed;
border-color: var(--couleur-cadre-lecture);
}
.cadre.double {
border-style: double;
border-color: var(--couleur-cadre-bloque);
}
/* La barre ne prend jamais toute la hauteur du cadre : au-delà, ses
messages défilent, la place fixe en tête. */
.barre {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--espace-2);
max-height: 50%;
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
border-bottom: 1px solid var(--couleur-page-filet);
}
.ligne {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-3);
}
.fixe {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
.libelle {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
font-weight: bold;
}
.historique {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
.verrou {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
font-size: var(--taille-texte);
}
.verrou p {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-2);
margin: 0;
}
.donnee {
font-weight: bold;
}
/* La tête ne prend jamais plus du tiers du cadre : au-delà, elle défile,
et la surface garde sa place. */
.tete {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--espace-2);
max-height: 35%;
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
border-bottom: 1px solid var(--couleur-page-filet);
}
.surface {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--espace-3);
}
</style>

View file

@ -0,0 +1,67 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'en-tête de l'événement ouvert (§ 9) : son nom, puis ses quatre états,
// dans l'ordre du § 9. Un état se pose en le cliquant, par la commande
// changerEtat de la séance — « Bloqué » est la commande « Bloquer », après
// laquelle la séance revient en lecture et rend le verrou. Ce que la séance
// refuse — la lecture, un plan bloqué, « Retenu » sans placement retenu,
// « Proposé » sans proposition — s'affiche à côté de l'état cliqué, avec
// son remède. L'état courant se distingue par sa forme : gras, et un trait
// sous lui. Le nom se coupe où il le faut, même sans espace : un nom long
// de glyphes larges passe à la ligne au lieu d'élargir la page.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { ETATS_PLAN, cleEtat } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const courant = $derived($seance.evenement?.etat ?? null);
</script>
<header class="entete">
<h1>{$seance.evenement?.nom}</h1>
<div class="etats" role="group" aria-label={t('evenement.etat.titre')}>
{#each ETATS_PLAN as etat (etat)}
<span class="etat" class:courant={courant === etat} data-etat={etat}>
<Commande cle={cleEtat(etat)} nom="etat" executer={() => seance.executer('changerEtat', { etat })} />
</span>
{/each}
</div>
</header>
<style>
.entete {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-4);
}
h1 {
min-width: 0;
margin: 0;
font-size: var(--taille-titre);
overflow-wrap: anywhere;
}
.etats {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
.etat {
padding-bottom: var(--espace-1);
border-bottom: 3px solid var(--couleur-page-fond);
}
.etat.courant {
border-bottom-color: var(--couleur-page-texte);
font-weight: bold;
}
</style>

View file

@ -0,0 +1,869 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'événement ouvert dans un vrai moteur de rendu (§ 2.1, § 8.3 à § 8.9,
// § 9, § 14.5) : aucune section n'en exige une autre ; la place fixe de
// « Modifier », de « Débloquer » et de « Revenir en lecture », plein écran
// compris ; le cadre de mode et son libellé ; l'inactivité ; l'état bloqué
// et son déblocage confirmé en place ; les états du plan ; défaire et
// refaire ; les annonces d'ouverture, du journal et de la dérive ; le verrou
// d'une autre séance et sa reprise ; le support rompu ; le format plus
// récent. Chaque épreuve monte App par monterApp, sur le système de fichiers
// simulé, au cadre de 1 366 × 700, et lit le rendu : la boîte, l'élément
// qu'atteint un point, le style calculé, le texte — jamais une classe.
//
// Les éléments s'atteignent par leurs attributs data-… : data-place-fixe, la
// place fixe du cadre ; data-etat, un état du plan de l'en-tête ;
// data-verrou, le verrou d'une autre séance ; data-support-rompu, le message
// du support rompu. Le texte attendu vient de la même traduction que celle
// de l'application ; les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { INACTIVITE_MS } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { apercevoir } from '../../csv/apercu.js';
import { FLUX, creerPcg32 } from '../../demo/prng.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { CONFIRMATION_DEBLOCAGE, DELAI_CONFIRMATION_MS } from '../primitives/commande.js';
import { SECTIONS } from './sections.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Merlette', 'Fauvelle'];
const EVENEMENT = { nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 };
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
// n'a rien reçu pendant l'épreuve.
let montes = [];
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// --- Les lectures du rendu ----------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
const cadre = (m) => dans(m, '[data-mode]');
const fixe = (m, nom) => dans(m, `[data-place-fixe] [data-commande="${nom}"]`);
const historique = (m, nom) => dans(m, `[data-mode] [data-historique] [data-commande="${nom}"]`);
const boutonEtat = (m, etat) => dans(m, `[data-etat="${etat}"] [data-commande="etat"]`);
const libelleCadre = (m) => dans(m, '[data-mode-libelle="cadre"]');
const libelleBandeau = (m) => dans(m, '[data-mode-libelle="bandeau"]');
const avertissement = (m, code) => dans(m, `[data-avertissement="${code}"]`);
const ecran = (m) => dans(m, '[data-ecran]');
// Le coin haut gauche de la boîte d'un élément.
const coin = (element) => {
const { left, top } = element.getBoundingClientRect();
return [left, top];
};
// L'élément qu'atteint le centre de la boîte d'un élément.
const auCentre = (element) => {
const { left, top, width, height } = element.getBoundingClientRect();
return document.elementFromPoint(left + width / 2, top + height / 2);
};
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// --- Les événements d'épreuve -----------------------------------------------------
const baseDe = (m) => m.seance.etat().evenement.base;
const fichier = (m, suffixe, racine = 'documents') => m.fs.contenu(racine, `${baseDe(m)}${suffixe}`);
const journalDe = (m, racine = 'documents') => lireJournal(fichier(m, SUFFIXES.journal, racine));
// Un montage où l'événement neuf « Club des Merles », zéro participant et
// zéro table, est ouvert en lecture.
async function neuf(options = {}) {
const m = await monter(options);
await m.seance.creer(EVENEMENT);
flushSync();
return m;
}
// L'événement ouvert en écriture, garni par la séance des six participants
// et de trois tables de trois places.
async function garnir(m) {
await m.seance.passerEnEcriture();
for (const nom of NOMS) await m.seance.executer('ajouterParticipant', { champs: { nom } });
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
flushSync();
}
// Enregistre deux propositions du moteur, puis, quand retenir le veut, retient
// la première.
async function generer(m, { retenir = true } = {}) {
const charge = m.seance.etat().charge;
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 7, arret: 40, nombre: 2 });
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
if (retenir) await m.seance.executer('retenirProposition', { id: m.seance.etat().charge.propositions[0].id });
flushSync();
}
// Réécrit l'état d'un événement fermé : change(charge) le modifie en place,
// et l'état se sérialise à sa révision.
function reecrireEtat(m, base, change) {
const chemin = `${base}${SUFFIXES.etat}`;
const { entete, charge } = analyser(m.fs.contenu('documents', chemin));
change(charge);
m.fs.deposer('documents', chemin, serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion }));
}
// --- Les épreuves -----------------------------------------------------------------
describe('aucun assistant (§ 2.1)', () => {
test('un événement neuf : chaque section s’ouvre depuis la navigation, dans un ordre tiré puis renversé, et rend son titre ; aucune n’est grisée, aucun « Suivant »', async () => {
const m = await neuf();
const { charge } = m.seance.etat();
expect([charge.participants.length, charge.tables.length]).toEqual([0, 0]);
const ordre = creerPcg32(20261006, FLUX.DEMO).melanger([...SECTIONS]);
expect(ordre.length).toBe(7);
for (const nom of [...ordre, ...[...ordre].reverse()]) {
const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`);
expect(visible(bouton), nom).toBe(true);
bouton.click();
flushSync();
expect(m.ui.etat().section).toBe(nom);
const section = dans(m, `[data-section="${nom}"]`);
expect(section, nom).not.toBeNull();
expect(section.textContent).toContain(t(`coquille.section.${nom}`));
}
const boutons = tous(m, '[data-ecran="evenement"] button');
expect(boutons.length).toBeGreaterThan(0);
for (const bouton of boutons) {
expect(bouton.disabled).toBe(false);
expect(bouton.getAttribute('aria-disabled')).toBeNull();
}
expect(ecran(m).textContent).not.toMatch(/suivant/i);
});
});
describe('la place fixe (§ 8.4, § 8.5)', () => {
test('« Modifier » aux mêmes coordonnées sur les sept sections ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => {
const m = await neuf();
const coins = [];
for (const nom of SECTIONS) {
m.ui.allerA(nom);
flushSync();
expect(visible(fixe(m, 'modifier')), nom).toBe(true);
coins.push(coin(fixe(m, 'modifier')));
}
expect(coins.length).toBe(7);
for (const lu of coins) expect(lu).toEqual(coins[0]);
// Un refus posé à côté d'un état, au-dessus de la surface, ne déplace
// pas la place fixe, et ne la couvre pas.
boutonEtat(m, 'bloque').click();
await attendre(() => expect(dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]')).not.toBeNull());
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
expect(visible(fixe(m, 'modifier'))).toBe(true);
m.ui.allerA('plan');
m.ui.basculerPleinEcran();
flushSync();
expect(visible(fixe(m, 'modifier'))).toBe(true);
expect(fixe(m, 'modifier').contains(auCentre(fixe(m, 'modifier')))).toBe(true);
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
expect(dans(m, '[data-commande="section"]')).toBeNull();
m.ui.basculerPleinEcran();
flushSync();
await m.seance.passerEnEcriture();
flushSync();
expect(fixe(m, 'modifier')).toBeNull();
expect(fixe(m, 'lecture').textContent).toBe(t('communs.commande.lecture'));
expect(coin(fixe(m, 'lecture'))).toEqual(coins[0]);
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
expect(visible(fixe(m, 'debloquer'))).toBe(true);
});
});
describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
test('deux cents caractères sans espace : la page ne s’élargit pas, et « Modifier » garde sa place', async () => {
const m = await neuf();
const place = coin(fixe(m, 'modifier'));
await m.seance.fermer();
for (const motif of ['W', 'WW W', '\u{C6}\u{C6}\u{C6}', '\u{D4}jgq']) {
const nom = motif.repeat(Math.ceil(200 / motif.length)).slice(0, 200);
expect(nom.length).toBe(200);
await m.seance.creer({ ...EVENEMENT, nom });
flushSync();
const titre = dans(m, '[data-ecran="evenement"] h1');
expect(titre.textContent).toBe(nom);
expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth);
expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth);
expect(coin(fixe(m, 'modifier')), motif).toEqual(place);
expect(visible(fixe(m, 'modifier')), motif).toBe(true);
await m.seance.fermer();
}
});
});
describe('passer en écriture (§ 8.4, § 19.3 01b)', () => {
test('avant « Modifier », « Bloqué » est refusé à côté de l’état avec « Modifier », sans rien écrire ; après, il écrit une entrée, un jalon, et la séance revient en lecture', async () => {
const m = await neuf();
const avant = m.trace.length;
boutonEtat(m, 'bloque').click();
const refus = await attendre(() => {
const element = dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]');
expect(element).not.toBeNull();
return element;
});
expect(refus.textContent).toContain(t('MODE_LECTURE', {}));
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(visible(refus)).toBe(true);
expect(m.trace.length).toBe(avant);
fixe(m, 'modifier').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
// Sur un brouillon neuf, seul « Bloqué » pose un état : les autres sont
// refusés à côté d'eux, sans rien écrire.
const ecrites = m.trace.length;
for (const [etat, code] of [
['brouillon', 'SANS_EFFET'],
['propose', 'AUCUNE_PROPOSITION'],
['retenu', 'AUCUN_RETENU'],
]) {
boutonEtat(m, etat).click();
await attendre(() => expect(refusPres(boutonEtat(m, etat), code)).not.toBeNull());
}
expect(m.trace.length).toBe(ecrites);
const { entrees, jalons } = journalDe(m);
boutonEtat(m, 'bloque').click();
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('bloque'));
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
const apres = journalDe(m);
expect(apres.entrees.length).toBe(entrees.length + 1);
expect(apres.jalons.length).toBe(jalons.length + 1);
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
expect(fixe(m, 'debloquer')).not.toBeNull();
});
});
describe('le cadre de mode (§ 8.4, § 14.5)', () => {
test('il contient la surface, sa bordure n’est pas nulle, son libellé se voit ; texte et trait diffèrent en lecture, en écriture et bloqué ; même boîte après défilement', async () => {
const m = await neuf();
const releves = [];
const relever = () => {
flushSync();
const boite = cadre(m).getBoundingClientRect();
const surface = dans(m, '[data-section]').getBoundingClientRect();
expect(boite.left).toBeLessThanOrEqual(surface.left);
expect(boite.top).toBeLessThanOrEqual(surface.top);
expect(boite.right).toBeGreaterThanOrEqual(surface.right);
expect(boite.bottom).toBeGreaterThanOrEqual(surface.bottom);
const style = getComputedStyle(cadre(m));
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0);
expect(style.borderTopStyle).not.toBe('none');
const libelle = libelleCadre(m);
expect(libelle.checkVisibility()).toBe(true);
expect(visible(libelle)).toBe(true);
expect(libelle.contains(auCentre(libelle))).toBe(true);
expect(libelle.textContent).toBe(libelleBandeau(m).textContent);
releves.push({ texte: libelle.textContent, trait: style.borderTopStyle });
};
relever();
await m.seance.passerEnEcriture();
relever();
await m.seance.executer('changerEtat', { etat: 'bloque' });
relever();
expect(releves.map(({ texte }) => texte)).toEqual([
t('coquille.mode.lecture'),
t('coquille.mode.ecriture'),
t('coquille.mode.bloque'),
]);
expect(new Set(releves.map(({ trait }) => trait)).size).toBe(3);
// Une surface plus haute que la fenêtre : la page et la surface
// défilées jusqu'en bas, le libellé garde sa boîte.
const avant = libelleCadre(m).getBoundingClientRect().toJSON();
const haut = document.createElement('div');
haut.style.height = '3000px';
dans(m, '[data-section]').append(haut);
window.scrollTo(0, document.documentElement.scrollHeight);
for (let porteur = dans(m, '[data-section]'); porteur !== null; porteur = porteur.parentElement) {
porteur.scrollTop = porteur.scrollHeight;
}
expect(libelleCadre(m).getBoundingClientRect().toJSON()).toEqual(avant);
expect(visible(libelleCadre(m))).toBe(true);
expect(visible(fixe(m, 'debloquer'))).toBe(true);
haut.remove();
});
});
describe('l’inactivité (§ 8.4, § 19.4)', () => {
for (const [ouvrir, fermer] of [
['debuterGeste', 'finirGeste'],
['debuterCalcul', 'finirCalcul'],
]) {
test(`${ouvrir} ouvert, onze minutes ne ramènent pas en lecture ; ${fermer}, dix minutes y ramènent : verrou rendu, cadre, bandeau et avertissement le disent`, async () => {
const m = await neuf();
fixe(m, 'modifier').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
m.seance[ouvrir]();
m.horloge.avancer(11 * 60 * 1000);
flushSync();
expect(m.seance.etat().mode).toBe('ecriture');
m.seance[fermer]();
m.horloge.avancer(INACTIVITE_MS);
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
expect(libelleCadre(m).textContent).toBe(t('coquille.mode.lecture'));
expect(libelleBandeau(m).textContent).toBe(t('coquille.mode.lecture'));
const annonce = avertissement(m, 'RETOUR_AUTOMATIQUE');
expect(annonce.textContent).toContain(t('RETOUR_AUTOMATIQUE', {}));
expect(visible(annonce)).toBe(true);
expect(fixe(m, 'modifier')).not.toBeNull();
});
}
});
describe('bloqué (§ 8.5, § 9)', () => {
test('« Bloquer » rend le verrou ; une autre séance le lit bloqué ; ni « Modifier », ni « Retenu », ni « Défaire » ne débloquent ; « Débloquer » se confirme en place, puis écrit', async () => {
const m = await neuf();
await garnir(m);
boutonEtat(m, 'bloque').click();
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
expect(m.seance.etat().evenement.etat).toBe('bloque');
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
expect(fixe(m, 'debloquer')).not.toBeNull();
const base = baseDe(m);
const autre = await monter({ systeme: { disque: m.disque } });
await autre.seance.ouvrir(base);
flushSync();
expect(autre.seance.etat().evenement.etat).toBe('bloque');
expect(fixe(autre, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
expect(fixe(autre, 'modifier')).toBeNull();
const avant = m.trace.length;
expect(fixe(m, 'modifier')).toBeNull();
boutonEtat(m, 'retenu').click();
await attendre(() => expect(refusPres(boutonEtat(m, 'retenu'), 'LECTURE_SEULE')).not.toBeNull());
historique(m, 'defaire').click();
const refusDefaire = await attendre(() => {
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
expect(element).not.toBeNull();
return element;
});
expect(refusDefaire.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(m.seance.etat().evenement.etat).toBe('bloque');
expect(m.trace.length).toBe(avant);
// Un clic arme, sans rien écrire ; l'horloge le désarme au terme du délai.
fixe(m, 'debloquer').click();
flushSync();
expect(fixe(m, 'debloquer').textContent).toBe(t(CONFIRMATION_DEBLOCAGE));
m.horloge.avancer(DELAI_CONFIRMATION_MS);
flushSync();
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
fixe(m, 'debloquer').click();
flushSync();
expect(m.trace.length).toBe(avant);
expect(m.seance.etat().evenement.etat).toBe('bloque');
const { entrees, jalons } = journalDe(m);
fixe(m, 'debloquer').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
const { charge } = m.seance.etat();
expect(charge.evenement.etat).toBe(etatDeduit(charge));
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
expect(journalDe(m).jalons.length).toBe(jalons.length + 1);
expect(fixe(m, 'lecture')).not.toBeNull();
});
});
describe('les états du plan (§ 9)', () => {
test('avec des propositions et un retenu, « Brouillon » puis « Retenu » : rien d’autre ne change, deux entrées, deux jalons, libellés exacts', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
const avant = m.seance.etat().charge;
expect(avant.evenement.etat).toBe('retenu');
const { entrees, jalons } = journalDe(m);
boutonEtat(m, 'brouillon').click();
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('brouillon'));
boutonEtat(m, 'retenu').click();
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('retenu'));
const apres = m.seance.etat().charge;
expect(apres).toEqual(avant);
const journal = journalDe(m);
expect(journal.entrees.slice(entrees.length).map((entree) => entree.libelle)).toEqual([
libelle('ENTREE_CHANGER_ETAT', { etat: 'brouillon' }),
libelle('ENTREE_CHANGER_ETAT', { etat: 'retenu' }),
]);
expect(journal.jalons.length).toBe(jalons.length + 2);
expect(journal.jalons.slice(-2).map(({ nom }) => nom)).toEqual([
libelle('JALON_ETAT', { etat: 'brouillon' }),
libelle('JALON_ETAT', { etat: 'retenu' }),
]);
});
test('« Retenu » sans retenu : refusé à côté de l’état, avec la section des propositions pour remède', async () => {
const m = await neuf();
fixe(m, 'modifier').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
const avant = m.trace.length;
boutonEtat(m, 'retenu').click();
const refus = await attendre(() => {
const element = refusPres(boutonEtat(m, 'retenu'), 'AUCUN_RETENU');
expect(element).not.toBeNull();
return element;
});
expect(visible(refus)).toBe(true);
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.propositions'));
remede.click();
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
expect(m.trace.length).toBe(avant);
});
});
describe('défaire, refaire (§ 8.3)', () => {
const nombre = (m) => m.seance.etat().charge.participants.length;
test('trois gestes, « Défaire » rend le deuxième et offre « Refaire » ; « Refaire » rend le troisième ; un geste après « Défaire » retire « Refaire »', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
flushSync();
expect(historique(m, 'refaire')).toBeNull();
historique(m, 'defaire').click();
await attendre(() => expect(nombre(m)).toBe(2));
expect(historique(m, 'refaire').textContent).toBe(t('evenement.commande.refaire'));
historique(m, 'refaire').click();
await attendre(() => expect(nombre(m)).toBe(3));
historique(m, 'defaire').click();
await attendre(() => expect(nombre(m)).toBe(2));
expect(historique(m, 'refaire')).not.toBeNull();
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
await attendre(() => expect(historique(m, 'refaire')).toBeNull());
});
test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
for (const nom of NOMS.slice(0, 4)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
expect(m.seance.etat().evenement.revision).toBe(5);
await m.seance.revenir(2);
flushSync();
expect(nombre(m)).toBe(1);
historique(m, 'defaire').click();
await attendre(() => expect(nombre(m)).toBe(0));
});
test('en lecture, « Défaire » est refusé à côté de lui avec « Modifier » ; bloqué, avec « Débloquer » ; rien ne s’écrit', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
await m.seance.passerEnLecture();
flushSync();
const avant = m.trace.length;
historique(m, 'defaire').click();
const enLecture = await attendre(() => {
const element = refusPres(historique(m, 'defaire'), 'MODE_LECTURE');
expect(element).not.toBeNull();
return element;
});
expect(enLecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(m.trace.length).toBe(avant);
expect(nombre(m)).toBe(1);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const bloque = m.trace.length;
historique(m, 'defaire').click();
const refus = await attendre(() => {
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
expect(element).not.toBeNull();
return element;
});
expect(refus.querySelector('[data-commande="debloquer"]')).not.toBeNull();
expect(m.trace.length).toBe(bloque);
});
});
describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
test('une proposition de longueur fausse et une périmée : l’écartée nomme son numéro et sa faute ; la dérive nomme la périmée et sa raison, et la charge la garde', async () => {
const m = await neuf();
await garnir(m);
await generer(m, { retenir: false });
const base = baseDe(m);
const [premiere, seconde] = m.seance.etat().charge.propositions.map(({ id }) => id);
await m.seance.fermer();
reecrireEtat(m, base, (charge) => {
charge.propositions[0].placement.pop();
charge.participants[0].exclu = true;
});
await m.seance.ouvrir(base);
flushSync();
const etat = m.seance.etat();
expect(etat.fautives.length).toBe(1);
const [fautive] = etat.fautives;
expect(fautive.id).toBe(premiere);
const ecartee = avertissement(m, 'PROPOSITIONS_FAUTIVES');
expect(visible(ecartee)).toBe(true);
expect(ecartee.textContent).toContain(t('PROPOSITIONS_FAUTIVES', { ids: [fautive.id] }));
expect(fautive.fautes.length).toBeGreaterThan(0);
for (const faute of fautive.fautes) expect(ecartee.textContent).toContain(t(faute.code, faute));
expect(ecartee.querySelector('[data-fautive]').textContent).toContain(String(fautive.id));
const derive = avertissement(m, 'DERIVE');
expect(derive.textContent).toContain(t('DERIVE', { ids: [seconde], retenu: false }));
const [{ raisons }] = etat.examen.derives;
expect(raisons.length).toBeGreaterThan(0);
for (const raison of raisons) expect(derive.textContent).toContain(t(raison.code, raison));
expect(etat.charge.propositions.map(({ id }) => id)).toEqual([seconde]);
derive.querySelector('[data-commande="ouvrirSection"]').click();
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
});
test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
const base = baseDe(m);
await m.seance.fermer();
reecrireEtat(m, base, (charge) => {
charge.retenu.placement.pop();
});
await m.seance.ouvrir(base);
flushSync();
const premier = dans(m, '[data-avertissement]');
expect(premier.dataset.avertissement).toBe('RETENU_FAUTIF');
expect(premier.textContent).toContain(t('RETENU_FAUTIF', {}));
const remede = premier.querySelector('[data-commande="ouvrirSecours"]');
expect(remede.textContent).toBe(t('communs.remede.ouvrirSecours.precedent'));
const avant = m.trace.length;
remede.click();
await attendre(() => expect(m.seance.etat().examen.retenu.fautes).toEqual([]));
expect(m.seance.etat().mode).toBe('lecture');
expect(m.seance.etat().charge.retenu).toBeNull();
expect(avertissement(m, 'RETENU_FAUTIF')).toBeNull();
expect(m.trace.length).toBe(avant);
});
});
describe('le journal (§ 8.6, § 8.2)', () => {
test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
for (let n = 2; n <= 100; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `Ombrelle ${n}` } });
const base = baseDe(m);
const journal = journalDe(m);
expect(journal.entrees.length).toBe(100);
expect(journal.jalons.length).toBe(1);
await m.seance.fermer();
const chemin = `${base}${SUFFIXES.journal}`;
const lignes = m.fs.contenu('documents', chemin).split('\n');
const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 37);
expect(rang).toBeGreaterThan(0);
lignes[rang] = '{"type":"entree", abîmée';
m.fs.deposer('documents', chemin, lignes.join('\n'));
const avant = m.trace.length;
await m.seance.ouvrir(base);
flushSync();
const ecartees = avertissement(m, 'JOURNAL_ECARTEES');
expect(ecartees.textContent).toContain(t('JOURNAL_ECARTEES', { n: 64 }));
expect(ecartees.textContent).toContain('64');
expect(visible(ecartees)).toBe(true);
expect(m.trace.length).toBe(avant);
});
test('un journal en avance d’une entrée : la charge du geste est celle qui s’ouvre, annoncée, sans rien écrire', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } });
const base = baseDe(m);
await m.seance.fermer();
m.fs.deposer('documents', `${base}${SUFFIXES.etat}`, m.fs.contenu('documents', `${base}${SUFFIXES.precedent}`));
const avant = m.trace.length;
await m.seance.ouvrir(base);
flushSync();
expect(m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(NOMS.slice(0, 2));
expect(m.seance.etat().evenement.revision).toBe(3);
expect(avertissement(m, 'JOURNAL_REPRIS').textContent).toContain(t('JOURNAL_REPRIS', {}));
expect(m.trace.length).toBe(avant);
});
});
describe('la dérive (§ 9)', () => {
test('retenu, puis exclure une personne : l’avertissement nomme le retenu et la raison, et le geste suivant reste permis', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
const exclue = m.seance.etat().charge.participants[0].id;
await m.seance.executer('exclureParticipant', { id: exclue });
flushSync();
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
const element = avertissement(m, 'DERIVE_NOUVELLE');
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue }));
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
});
test('retenu : supprimer une table, réduire une capacité, ajouter une personne, chacun accepté, l’avertissement nommant les propositions touchées ; leur nombre inchangé ; un import, IMPORT_EN_RETENU', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
const propositions = m.seance.etat().charge.propositions.length;
const [premiere, seconde] = m.seance.etat().charge.tables;
const gestes = [
['supprimerTable', { id: premiere.id }],
['changerPlacesTable', { id: seconde.id, sieges: 2, origine: 'champ' }],
['ajouterParticipant', { champs: { nom: 'Escarbille' } }],
];
for (const [nom, args] of gestes) {
await m.seance.executer(nom, args);
flushSync();
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
expect(nouvelle, nom).toBeDefined();
expect(nouvelle.details.touchees.length, nom).toBeGreaterThan(0);
expect(avertissement(m, 'DERIVE_NOUVELLE').textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
expect(m.seance.etat().charge.propositions.length).toBe(propositions);
}
const apercu = apercevoir('nom\nTilleraie\n', { participants: m.seance.etat().charge.participants });
await m.seance.executer('importerParticipants', { apercu, mode: 'ajouter' });
flushSync();
expect(avertissement(m, 'IMPORT_EN_RETENU').textContent).toContain(t('IMPORT_EN_RETENU', {}));
});
});
describe('le verrou d’une autre séance (§ 8.8)', () => {
test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => {
const premier = await neuf();
await premier.seance.passerEnEcriture();
const base = baseDe(premier);
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
const second = await monter({ systeme: { disque: premier.disque } });
// Les deux montages s'empilent dans la page : le premier se cache, et
// le second occupe la fenêtre.
premier.racine.hidden = true;
await second.seance.ouvrir(base);
flushSync();
expect(second.seance.etat().mode).toBe('lecture');
expect(dans(second, '[data-refus]')).toBeNull();
fixe(second, 'modifier').click();
const refus = await attendre(() => {
const element = refusPres(fixe(second, 'modifier'), 'VERROU_PRIS');
expect(element).not.toBeNull();
return element;
});
const pris = second.seance.etat().verrouAutre;
expect(pris).toEqual({ seance: tenu.seance, depuis: tenu.depuis, vivant: true });
expect(refus.textContent).toContain(t('VERROU_PRIS', { ...pris, remede: null }));
const verrou = dans(second, '[data-verrou]');
expect(visible(verrou)).toBe(true);
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
expect(verrou.querySelector('[data-commande="reprendreVerrou"]')).toBeNull();
premier.fs.pannes.couperApres(0);
fixe(second, 'modifier').click();
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
const repris = avertissement(second, 'VERROU_REPRIS');
expect(repris.textContent).toContain(t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }));
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
expect(dans(second, '[data-verrou]')).toBeNull();
});
test('le verrou d’un autre poste : la reprise s’offre à côté du refus, se confirme en place en nommant la séance et l’instant, puis le reprend', async () => {
const premier = await neuf();
await premier.seance.passerEnEcriture();
const base = baseDe(premier);
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
const second = await monter({ systeme: { disque: premier.disque, hote: 'poste-voisin' } });
premier.racine.hidden = true;
await second.seance.ouvrir(base);
flushSync();
fixe(second, 'modifier').click();
await attendre(() => expect(refusPres(fixe(second, 'modifier'), 'VERROU_PRIS')).not.toBeNull());
expect(second.seance.etat().verrouAutre.vivant).toBeNull();
const verrou = dans(second, '[data-verrou]');
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
const reprendre = () => dans(second, '[data-verrou] [data-commande="reprendreVerrou"]');
expect(reprendre().textContent).toBe(t('communs.remede.reprendreVerrou'));
expect(visible(reprendre())).toBe(true);
const avant = second.trace.length;
reprendre().click();
flushSync();
expect(reprendre().textContent).toBe(t('evenement.verrou.confirmerReprise'));
expect(verrou.contains(reprendre())).toBe(true);
expect(second.trace.length).toBe(avant);
expect(second.seance.etat().mode).toBe('lecture');
reprendre().click();
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
expect(avertissement(second, 'VERROU_REPRIS').textContent).toContain(
t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }),
);
expect(dans(second, '[data-verrou]')).toBeNull();
});
});
describe('le support rompu (§ 8.6, § 8.2)', () => {
const message = (m) => dans(m, '[data-support-rompu]');
const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]');
// L'événement ouvert en écriture, puis le support de racine refuse
// l'écriture d'un geste.
async function rompre(m, racine) {
fixe(m, 'modifier').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
m.fs.pannes.ecriture(racine);
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
flushSync();
}
test('publié : le message nomme le fichier, le dossier et la rupture ; « Liste » est refusée ; « Écrire dans les Documents » y écrit, et la séance s’y poursuit', async () => {
const m = await neuf({ systeme: { executable: 'E:\\soirees' } });
expect(m.seance.etat().racine.id).toBe('portable');
const base = baseDe(m);
await rompre(m, 'portable');
const rompu = m.seance.etat().supportRompu;
expect(rompu.ailleurs).toEqual({ id: 'documents', chemin: DOCUMENTS });
expect(visible(message(m))).toBe(true);
const { ailleurs, ...details } = rompu;
expect(message(m).textContent).toContain(t('SUPPORT_ROMPU', details));
expect(message(m).textContent).toContain(rompu.chemin);
expect(message(m).textContent).toContain(rompu.dossier);
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } })).rejects.toMatchObject({
code: 'SUPPORT_ROMPU',
});
// Le refus posé dans le bandeau ne déplace pas la place fixe, et ne la
// couvre pas.
const place = coin(fixe(m, 'lecture'));
commandeListe(m).click();
await attendre(() => expect(dans(m, '[data-refus="liste"] [data-refus="SUPPORT_ROMPU_NON_SAUVE"]')).not.toBeNull());
expect(coin(fixe(m, 'lecture'))).toEqual(place);
expect(visible(fixe(m, 'lecture'))).toBe(true);
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
expect(m.seance.etat().evenement.base).toBe(base);
expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull();
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
const nouvelle = baseDe(m);
expect(m.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).not.toBeNull();
const entrees = journalDe(m, 'documents').entrees.length;
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[2] } });
expect(journalDe(m, 'documents').entrees.length).toBe(entrees + 1);
expect(message(m)).toBeNull();
commandeListe(m).click();
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
expect(m.seance.etat().racine.id).toBe('documents');
await attendre(() => expect(dans(m, `[data-evenement="${nouvelle}"]`)).not.toBeNull());
const dossier = dans(m, '[data-dossier-travail]');
expect(dossier.textContent).toBe(DOCUMENTS);
expect(visible(dossier)).toBe(true);
});
test('non publié : le message n’offre que « Choisir un dossier… » ; le dialogue annulé ne change rien', async () => {
const m = await neuf();
await rompre(m, 'documents');
expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull();
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
choisir.click();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
expect(visible(message(m))).toBe(true);
});
test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => {
const m = await neuf();
await rompre(m, 'documents');
const avant = m.trace.length;
const abandonner = () => message(m).querySelector('[data-commande="abandonner"]');
expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner'));
abandonner().click();
flushSync();
expect(abandonner().textContent).toBe(t('evenement.supportRompu.confirmerAbandon'));
expect(m.seance.etat().evenement).not.toBeNull();
abandonner().click();
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
expect(m.seance.etat().evenement).toBeNull();
expect(m.trace.slice(avant).filter(([primitive]) => primitive !== 'deverrouiller')).toEqual([]);
});
test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => {
const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } });
await rompre(m, 'documents');
message(m).querySelector('[data-commande="choisirDossier"]').click();
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' });
const base = baseDe(m);
expect(m.fs.contenu('choisi-1', `${base}${SUFFIXES.etat}`)).not.toBeNull();
const entrees = journalDe(m, 'choisi-1').entrees.length;
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
expect(journalDe(m, 'choisi-1').entrees.length).toBe(entrees + 1);
commandeListe(m).click();
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
await attendre(() => expect(dans(m, `[data-evenement="${base}"]`)).not.toBeNull());
});
});
describe('le format plus récent (§ 8.8)', () => {
test('« Modifier » est remplacé par l’explication, et aucune commande de l’événement ne passe en écriture', async () => {
const m = await neuf();
const base = baseDe(m);
await m.seance.fermer();
const chemin = `${base}${SUFFIXES.etat}`;
const texte = m.fs.contenu('documents', chemin);
m.fs.deposer('documents', chemin, texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`));
await m.seance.ouvrir(base);
flushSync();
expect(fixe(m, 'modifier')).toBeNull();
const explication = dans(m, '[data-place-fixe] [data-refus="LECTURE_SEULE"]');
expect(explication.textContent).toContain(t('LECTURE_SEULE', { raison: 'FORMAT', remede: null }));
expect(visible(explication)).toBe(true);
const avant = m.trace.length;
const boutons = tous(m, '[data-ecran="evenement"] button').filter((bouton) => bouton.dataset.commande !== 'section');
expect(boutons.length).toBeGreaterThan(0);
for (const bouton of boutons) {
if (!bouton.isConnected) continue;
bouton.click();
bouton.click();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
expect(m.trace.length).toBe(avant);
});
});

View file

@ -2,9 +2,22 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'événement ouvert sous sa forme minimale : son nom, une commande par
// section, dans l'ordre du § 2.1, et la section ouverte de l'état de
// navigation. Aucune section n'en exige une autre.
// L'événement ouvert (§ 2.1, § 8.4) : le cadre de mode, qui entoure la
// surface de travail de toute section, et porte sous sa barre — place
// fixe, libellé, Défaire et Refaire — la tête de l'événement : son en-tête
// — nom et états du plan — et la navigation des sections, dans l'ordre du
// § 2.1. Rien ne précède le cadre dans l'écran : ce que la tête montre ne
// déplace pas la place fixe. Aucune section n'en exige une autre : la
// navigation les offre toutes, et la séance refuse elle-même ce qui ne
// peut pas se faire. Le plein écran du plan efface la tête, jamais le
// cadre ni sa place fixe.
//
// L'écran de l'événement tient dans la fenêtre, sous le bandeau : la page
// ne défile pas, la surface de travail défile dans le cadre, et le cadre,
// son libellé et sa place fixe restent où ils sont (§ 8.4). La règle
// globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran
// — le conteneur de l'application et son <main> — tant qu'il porte cet
// écran, et à lui seul.
import { getContext } from 'svelte';
import Diagnostic from '../diagnostic/Diagnostic.svelte';
import SectionGeneration from '../generation/SectionGeneration.svelte';
@ -13,6 +26,10 @@
import SectionPlan from '../plan/SectionPlan.svelte';
import SectionPropositions from '../propositions/SectionPropositions.svelte';
import SectionTables from '../tables/SectionTables.svelte';
import Avertissements from './Avertissements.svelte';
import CadreMode from './CadreMode.svelte';
import EnteteEvenement from './EnteteEvenement.svelte';
import SupportRompu from './SupportRompu.svelte';
import { SECTIONS, cleDeSection } from './sections.js';
const t = getContext('t');
@ -33,8 +50,8 @@
const Section = $derived(COMPOSANTS.get($ui.section));
</script>
<div class="evenement" data-ecran="evenement">
<h1>{$seance.evenement?.nom}</h1>
{#snippet tete()}
<EnteteEvenement />
<nav aria-label={t('coquille.navigation')}>
{#each SECTIONS as nom (nom)}
<button
@ -46,10 +63,39 @@
>
{/each}
</nav>
<Section />
{/snippet}
<div class="evenement" data-ecran="evenement">
<CadreMode tete={$ui.pleinEcran ? null : tete}>
{#if $seance.supportRompu !== null}
<SupportRompu />
{/if}
<Avertissements />
<Section />
</CadreMode>
</div>
<style>
/* L'ascendance de l'écran de l'événement prend la hauteur de la fenêtre :
le bandeau en haut, l'écran dessous, qui ne déborde pas. */
:global(div:has(> main > [data-ecran='evenement'])) {
height: 100vh;
}
:global(main:has(> [data-ecran='evenement'])) {
display: flex;
flex-direction: column;
min-height: 0;
}
.evenement {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
nav {
display: flex;
flex-wrap: wrap;

View file

@ -0,0 +1,72 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le support rompu (§ 8.6) : le texte du refus SUPPORT_ROMPU, qui nomme le
// fichier, le dossier et la cause, et dit que l'historique et l'état ne se
// correspondent plus sur ce support ; puis les issues. « Écrire dans les
// Documents » quand la séance propose la racine documents (ailleurs) ;
// toujours « Choisir un dossier… », seance.ecrireAilleurs(null), qui
// demande le dossier à l'opérateur — un refus, NON_DISPONIBLE sous web,
// s'affiche à côté de la commande ; et « Fermer sans écrire ailleurs »,
// confirmé en place, qui abandonne l'événement à ce support.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
const t = getContext('t');
const seance = getContext('seance');
const rompu = $derived($seance.supportRompu);
const refus = $derived(
rompu === null
? null
: { echec: rompu.echec, chemin: rompu.chemin, dossier: rompu.dossier, cause: rompu.cause },
);
</script>
{#if rompu !== null}
<section class="rompu" role="status" data-support-rompu>
<p>{t('SUPPORT_ROMPU', refus)}</p>
<div class="issues">
{#if rompu.ailleurs !== null}
<Commande
cle="communs.commande.ecrireDocuments"
nom="ecrireDocuments"
executer={() => seance.ecrireAilleurs(rompu.ailleurs)}
/>
{/if}
<Commande cle="communs.commande.choisirDossier" nom="choisirDossier" executer={() => seance.ecrireAilleurs(null)} />
<Commande
cle="evenement.supportRompu.abandonner"
nom="abandonner"
confirmer="evenement.supportRompu.confirmerAbandon"
executer={() => seance.fermer({ abandonner: true })}
/>
</div>
</section>
{/if}
<style>
.rompu {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
margin-bottom: var(--espace-3);
padding: var(--espace-2) var(--espace-3);
border-left: 3px solid var(--couleur-refus);
color: var(--couleur-refus);
font-size: var(--taille-texte);
}
p {
margin: 0;
}
.issues {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
</style>

View file

@ -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.<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;
});
}

View file

@ -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.<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(), '');
}
});
});

View file

@ -0,0 +1,322 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le contenu d'un aperçu d'import (§ 10.1, § 10.2), celui que rend
// modeleApercu (modele.js) : la source — le nom du fichier, ou le texte
// collé —, l'encodage et le séparateur nommés, le refus global avec sa
// cause et son remède, l'association des colonnes et ses ambiguïtés, les
// lignes valides, les lignes refusées et leur motif, les orthographes
// fondues, les doublons, les homonymes d'une mise à jour, les cellules sur
// plusieurs lignes. Le rapport le reprend pour l'aperçu appliqué, ses
// refus remplacés par ceux du résumé.
//
// modele : le modèle ; source : { origine: 'fichier'|'collage', nom } ;
// participants : ceux de la charge, qui nomment les personnes désignées ;
// refusees : les lignes refusées à montrer, celles du modèle par défaut ;
// choisir(champ, rang) : tranche une ambiguïté, null pour un aperçu figé.
// Chaque donnée — nom de fichier, en-tête, cellule, rang, compte — occupe
// son propre nœud de texte.
import { getContext } from 'svelte';
import { COLONNES_DES_LIGNES, motifLigne, nommer } from './modele.js';
let { modele, source, participants = [], refusees = null, choisir = null } = $props();
const t = getContext('t');
const format = getContext('format');
const motif = motifLigne(t);
const refuseesMontrees = $derived(refusees ?? modele.refusees);
const global = $derived(modele.global);
</script>
<div class="apercu">
<dl class="source">
{#if source.origine === 'fichier'}
<dt>{t('import.apercu.fichier')}</dt>
<dd data-fichier>{source.nom}</dd>
{:else}
<dt data-collage>{t('import.apercu.collage')}</dt>
<dd></dd>
{/if}
{#if modele.encodage !== null}
<dt>{t('import.apercu.encodage')}</dt>
<dd data-encodage>{t(modele.encodage)}</dd>
{/if}
{#if modele.separateur !== null}
<dt>{t('import.apercu.separateur')}</dt>
<dd data-separateur>{t(modele.separateur)}</dd>
{/if}
</dl>
{#if global !== null}
<div class="refus" role="status" data-refus={global.cle}>
<p>{t(global.cle, global.details)}</p>
{#if !global.remede.dansLaCause}
<p>{t(global.remede.cle, global.remede.details)}</p>
{/if}
</div>
{/if}
{#if modele.colonnes.length > 0}
<table class="colonnes" data-colonnes>
<caption>{t('import.apercu.colonnes')}</caption>
<thead>
<tr>
<th scope="col">{t('import.apercu.entete')}</th>
<th scope="col">{t('import.apercu.devient')}</th>
</tr>
</thead>
<tbody>
{#each modele.colonnes as colonne (colonne.rang)}
<tr data-colonne={colonne.rang}>
<td data-entete>{colonne.entete}</td>
<td data-devient>{t(colonne.cle)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if choisir !== null}
{#each modele.ambigus as ambigu (ambigu.champ)}
<fieldset class="ambigu" data-champ={`choix-${ambigu.champ}`}>
<legend><span>{t('import.ambiguite.question')}</span> <span>{t(ambigu.cle)}</span></legend>
{#each ambigu.rangs as { rang, entete } (rang)}
<label>
<input type="radio" name={`choix-${ambigu.champ}`} value={rang} onchange={() => choisir(ambigu.champ, rang)} />
<span>{entete}</span>
</label>
{/each}
</fieldset>
{/each}
{/if}
{#if global === null || modele.valides > 0}
<p data-valides>
<span data-nombre>{format.nombre(modele.valides)}</span>
<span>{t('import.apercu.valides', { n: modele.valides })}</span>
</p>
{/if}
{#if modele.lignes.length > 0}
<div class="defile">
<table class="lignes" data-lignes>
<thead>
<tr>
<th scope="col">{t('import.apercu.ligne')}</th>
{#each COLONNES_DES_LIGNES as { champ, cle } (champ)}
<th scope="col">{t(cle)}</th>
{/each}
</tr>
</thead>
<tbody>
{#each modele.lignes as { ligne, cellules } (ligne)}
<tr data-ligne={ligne}>
<th scope="row">{format.nombre(ligne)}</th>
{#each cellules as { champ, texte, cle } (champ)}
<td data-cellule={champ}>{cle === null ? (texte ?? '') : t(cle)}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if refuseesMontrees.length > 0}
<table class="refusees" data-refusees>
<caption>{t('import.apercu.refusees')}</caption>
<thead>
<tr>
<th scope="col">{t('import.apercu.ligne')}</th>
<th scope="col">{t('import.apercu.motif')}</th>
</tr>
</thead>
<tbody>
{#each refuseesMontrees as { ligne, code, details } (ligne)}
<tr data-ligne={ligne}>
<th scope="row">{format.nombre(ligne)}</th>
<td>
<span data-motif>{motif(code, details)}</span>
{#if details.participants !== undefined}
<span>{t('import.rapport.designees')}</span>
{#each nommer(participants, details.participants) as personne (personne.id)}
<span class="personne" data-participant={personne.id}>
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
<span>{personne.nom}</span>
</span>
{/each}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if modele.fusions.length > 0}
<section class="bloc">
<h4>{t('import.apercu.fusions')}</h4>
<ul data-fusions>
{#each modele.fusions as fusion, rang (rang)}
<li data-fusion>
<span>{t('import.apercu.affichee')}</span>
<strong data-affichee>{fusion.affichee}</strong>
<ul>
{#each fusion.orthographes as orthographe (orthographe.texte)}
<li data-orthographe>
<span data-texte>{orthographe.texte}</span>
<span data-nombre>{format.nombre(orthographe.lignes)}</span>
<span>{t('import.apercu.orthographeLignes', { n: orthographe.lignes })}</span>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</section>
{/if}
{#if modele.doublons.length > 0}
<section class="bloc">
<h4>{t('import.apercu.doublons')}</h4>
<ul data-doublons>
{#each modele.doublons as doublon, rang (rang)}
<li data-doublon>
<span>{t('import.apercu.doublonLignes')}</span>
{#each doublon.lignes as ligne (ligne)}
<span data-nombre>{format.nombre(ligne)}</span>
{/each}
{#if doublon.existants.length > 0}
<span>{t('import.apercu.doublonExistants')}</span>
{#each nommer(participants, doublon.existants) as personne (personne.id)}
<span class="personne" data-participant={personne.id}>
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
<span>{personne.nom}</span>
</span>
{/each}
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if modele.homonymes.length > 0}
<section class="bloc">
<h4>{t('import.apercu.homonymes')}</h4>
<ul>
{#each modele.homonymes as homonyme (homonyme.ligne)}
<li data-homonyme={homonyme.ligne}>
<span>{t('import.apercu.ligne')}</span>
<span>{format.nombre(homonyme.ligne)}</span>
{#each nommer(participants, homonyme.participants) as personne (personne.id)}
<span class="personne" data-participant={personne.id}>
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
<span>{personne.nom}</span>
</span>
{/each}
</li>
{/each}
</ul>
</section>
{/if}
{#if modele.multilignes.length > 0}
<section class="bloc">
<h4>{t('import.apercu.multilignes')}</h4>
<ul>
{#each modele.multilignes as multiligne (multiligne.ligne)}
<li data-multiligne={multiligne.ligne}>
<span>{t('import.apercu.ligne')}</span>
<span>{format.nombre(multiligne.ligne)}</span>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style>
.apercu {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
.source {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--espace-1) var(--espace-3);
margin: 0;
}
dt {
font-weight: bold;
}
dd {
margin: 0;
}
.refus {
display: flex;
flex-direction: column;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-refus);
color: var(--couleur-refus);
}
.refus p {
margin: 0;
}
table {
border-collapse: collapse;
}
caption {
text-align: left;
font-weight: bold;
padding-bottom: var(--espace-1);
}
th,
td {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
text-align: left;
vertical-align: top;
white-space: pre-wrap;
}
.defile {
max-height: 24rem;
overflow: auto;
}
.ambigu {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
.bloc h4 {
margin: 0 0 var(--espace-1);
}
.bloc ul {
margin: 0;
padding-left: var(--espace-5);
}
.personne {
display: inline-flex;
gap: var(--espace-1);
font-weight: bold;
}
</style>

View file

@ -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);
});
});

View file

@ -0,0 +1,73 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le rapport d'un import appliqué (§ 10.1) : les comptes du résumé que
// rend seance.executer — ajoutés, mis à jour, inchangés, retirés,
// réservations retirées —, puis l'aperçu appliqué, ses lignes refusées
// remplacées par resume.refusees : celles de l'aperçu, puis celles de la
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un
// import qui n'a rien écrit le dit.
//
// rapport : { modele, source, resume, libelle } ; participants : ceux de
// la charge écrite. Les commandes du rapport sont celles de la section.
import { getContext } from 'svelte';
import Apercu from './Apercu.svelte';
import { refuseesDe } from './modele.js';
let { rapport, participants } = $props();
const t = getContext('t');
const format = getContext('format');
// Chaque compte du résumé et la clé de son libellé, dans l'ordre du résumé.
const COMPTES = [
['ajoutes', 'import.rapport.ajoutes'],
['misAJour', 'import.rapport.misAJour'],
['inchanges', 'import.rapport.inchanges'],
['retires', 'import.rapport.retires'],
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
];
const refusees = $derived(refuseesDe(rapport.resume.refusees));
</script>
<section class="rapport" data-rapport>
<h3>{t('import.rapport.titre')}</h3>
{#if rapport.libelle === null}
<p>{t('import.rapport.rienEcrit')}</p>
{/if}
<ul class="comptes">
{#each COMPTES as [nom, cle] (nom)}
<li data-compte={nom}>
<span data-nombre>{format.nombre(rapport.resume[nom])}</span>
<span>{t(cle, { n: rapport.resume[nom] })}</span>
</li>
{/each}
</ul>
<Apercu modele={rapport.modele} source={rapport.source} {participants} {refusees} />
</section>
<style>
.rapport {
display: flex;
flex-direction: column;
gap: var(--espace-3);
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-page-filet);
}
h3 {
margin: 0;
}
.comptes {
margin: 0;
padding-left: var(--espace-5);
}
.comptes li {
display: flex;
gap: var(--espace-1);
}
</style>

View file

@ -2,12 +2,292 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de l'import sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
// La section de l'import des participants (§ 10.1, § 10.2, § 2.1 étape
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé
// sur la fenêtre, ou un texte que le collage de la grille remet par
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui.
// L'aperçu se calcule par apercevoir contre les participants de la
// charge, sous le mode choisi et le choix des colonnes ambiguës : il
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Seule
// « Importer » écrit, par seance.executer('importerParticipants', …), et
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
// et ne l'ouvre que sur un import que la commande accepterait.
// Le rapport lit le résumé que la séance rend.
//
// Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient
// d'exporterRefus de src/application/csv.js, dont le motif est celui de
// 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.
import { getContext, untrack } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Apercu from './Apercu.svelte';
import Rapport from './Rapport.svelte';
import { MODES_D_IMPORT, avecEntetesDeLaGrille, modeleApercu, modePropose } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const facade = getContext('facade');
const ui = getContext('ui');
const uid = $props.id();
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
// texte, refusDecodage } ; texte null sous un refus de decoder.
let source = $state.raw(null);
// { champ: rang } qui tranche une ambiguïté de l'en-tête.
let choix = $state.raw({});
let mode = $state('ajouter');
// Le dernier import appliqué : { modele, source, apercu, resume, libelle }.
let rapport = $state.raw(null);
// La fenêtre de « Remplacer » ouverte : { apercu, elements }.
let confirmation = $state.raw(null);
// Le chemin où le CSV des refus vient d'être enregistré.
let enregistre = $state(null);
const participants = $derived($seance.charge?.participants ?? []);
const apercu = $derived(
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, choix, mode }),
);
const modele = $derived(
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
);
// Le texte relu dans l'ordre de la grille, offert à un collage seul.
const grille = $derived(source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu) : null);
// Une source neuve : le choix des colonnes repart de rien, le mode
// proposé suit la liste, le rapport précédent se retire.
function ouvrirSource(nouvelle) {
source = nouvelle;
choix = {};
mode = modePropose(untrack(() => participants));
rapport = null;
confirmation = null;
enregistre = null;
}
// 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 }) {
try {
const { texte, encodage } = decoder(octets);
ouvrirSource({ origine: 'fichier', nom, encodage, texte, refusDecodage: null });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
ouvrirSource({ origine: 'fichier', nom, encodage: null, texte: null, refusDecodage: { code: erreur.code, details: erreur.details } });
}
}
async function choisirFichier() {
const choisi = await facade.choisirCsv();
if (choisi !== null) lireFichier(choisi);
}
// Un fichier déposé sur la fenêtre ouvre son aperçu, comme un fichier
// choisi ; un glissement sans fichier n'est pas retenu.
function survoler(evenement) {
if (evenement.dataTransfer?.types?.includes('Files')) evenement.preventDefault();
}
async function deposer(evenement) {
const fichier = evenement.dataTransfer?.files?.[0];
if (fichier === undefined) return;
evenement.preventDefault();
lireFichier({ nom: fichier.name, octets: new Uint8Array(await fichier.arrayBuffer()) });
}
// Le texte que le collage de la grille remet : pris une fois, il ouvre
// l'aperçu d'un collage, sans encodage.
$effect(() => {
if ($ui.importEnAttente === null) return;
untrack(() => {
const pris = ui.prendreImport();
if (pris !== null) ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
});
});
function choisirColonne(champ, rang) {
choix = { ...choix, [champ]: rang };
}
function lireDansLOrdreDeLaGrille() {
source = { ...source, texte: grille };
choix = {};
}
function fermerApercu() {
source = null;
enregistre = null;
}
// L'aperçu appliqué par la séance, dans son mode ; le rapport lit le
// résumé rendu, et l'aperçu se ferme.
async function appliquer(applique, sonMode, depuis) {
const { libelle, resume } = await seance.executer('importerParticipants', { apercu: applique, mode: sonMode });
rapport = {
modele: modeleApercu({ encodage: depuis.encodage, apercu: applique }),
source: { origine: depuis.origine, nom: depuis.nom },
apercu: applique,
resume,
libelle,
};
source = null;
enregistre = null;
}
// « Importer » : l'aperçu montré, dans le mode choisi. « Remplacer »
// ouvre d'abord sa fenêtre, sauf quand le geste serait refusé : le refus
// de la séance, celui de son état, puis celui de la commande essayée sur
// la charge sans rien écrire (appliquerCommande, le calcul même que
// seance.executer applique) — une ambiguïté non tranchée, AMBIGUITE —
// se posent à côté de la commande, et la fenêtre n'annonce jamais une
// destruction qui n'aura pas lieu.
async function importer() {
const applique = apercu;
const sonMode = mode;
const depuis = source;
if (sonMode !== 'remplacer') {
await appliquer(applique, sonMode, depuis);
return;
}
const { refus, charge } = seance.etat();
if (refus !== null) throw refus;
appliquerCommande('importerParticipants', charge, { apercu: applique, mode: sonMode });
const bilan = bilanRemplacement(charge);
confirmation = {
apercu: applique,
depuis,
elements: [
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 }),
],
};
}
async function confirmerRemplacement() {
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis);
confirmation = null;
}
// Le CSV des refus, remis à la façade sous ‹base›-refus.csv.
async function enregistrerRefus(octets) {
enregistre = await facade.enregistrerSous(`${seance.etat().evenement.base}-refus.csv`, octets);
}
</script>
<section data-section="import">
<svelte:window ondragover={survoler} ondrop={deposer} />
<section class="import" data-section="import">
<h2>{t('coquille.section.import')}</h2>
<p>{t('import.consigne')}</p>
<div class="commandes">
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" executer={choisirFichier} />
</div>
{#if source !== null}
{#key source}
<section class="apercu" data-apercu>
<h3>{t('import.apercu.titre')}</h3>
{#if modele.global === null}
<fieldset class="modes" data-champ="mode">
<legend>{t('import.mode.titre')}</legend>
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
<label>
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
<span>{t(cle)}</span>
</label>
{/each}
</fieldset>
{/if}
<Apercu {modele} source={{ origine: source.origine, nom: source.nom }} {participants} choisir={choisirColonne} />
<div class="commandes">
{#if grille !== null}
<Commande cle="import.commande.ordreDeLaGrille" nom="ordreDeLaGrille" executer={lireDansLOrdreDeLaGrille} />
{/if}
{#if modele.global === null}
<Commande cle="import.commande.importer" nom="importer" executer={importer} />
{/if}
{#if modele.refusees.length > 0}
<Commande
cle="import.commande.enregistrerRefus"
nom="enregistrerRefus"
executer={() => enregistrerRefus(exporterRefus(apercu))}
/>
{/if}
<Commande cle="import.commande.fermerApercu" nom="fermerApercu" executer={fermerApercu} />
</div>
{#if enregistre !== null}
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
{/if}
</section>
{/key}
{/if}
{#if rapport !== null}
<Rapport {rapport} {participants} />
<div class="commandes">
{#if rapport.resume.refusees.length > 0}
<Commande
cle="import.commande.enregistrerRefus"
nom="enregistrerRefus"
executer={() => enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
/>
{/if}
<Commande cle="import.commande.fermerRapport" nom="fermerRapport" executer={() => (rapport = null)} />
</div>
{#if enregistre !== null}
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
{/if}
{/if}
{#if confirmation !== null}
<Confirmation
titre="import.remplacer.titre"
elements={confirmation.elements}
surConfirmer={confirmerRemplacement}
surAnnuler={() => (confirmation = null)}
/>
{/if}
</section>
<style>
.import {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
h2,
h3,
p {
margin: 0;
}
.apercu {
display: flex;
flex-direction: column;
gap: var(--espace-3);
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-page-filet);
}
.commandes {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
.modes {
display: flex;
flex-wrap: wrap;
gap: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
</style>

View file

@ -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<Object>} participants
* @returns {'ajouter'|'mettreAJour'}
*/
export function modePropose(participants) {
return participants.length === 0 ? 'ajouter' : 'mettreAJour';
}
// Un modèle vide : rien de lu.
const RIEN = Object.freeze({
encodage: null,
separateur: null,
colonnes: [],
ambigus: [],
valides: 0,
lignes: [],
refusees: [],
fusions: [],
doublons: [],
homonymes: [],
multilignes: [],
global: null,
});
/**
* Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour
* un collage) et séparateur nommés — « une seule colonne » pour un
* séparateur null —, association par colonne, ambiguïtés, lignes valides et
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage,
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause
* et celle de son remède — en clés de libellés et en données.
*
* La cause est le code du refus, sa propre clé ; ses détails sont ceux de
* decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus
* de decoder est le code de details.remede, que le texte de la cause compose
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas.
*
* @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree
* encodage : celui que rend decoder, null pour un collage
* @returns {{
* encodage: string|null, separateur: string|null,
* colonnes: Array<{rang: number, entete: string, cle: string}>,
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
* valides: number,
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>,
* refusees: Array<{ligne: number, code: string, details: Object}>,
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
* doublons: Array<{lignes: number[], existants: number[]}>,
* homonymes: Array<{ligne: number, participants: number[]}>,
* multilignes: Array<{ligne: number, rangs: number[]}>,
* global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}},
* }}
* encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de
* traduction, comme lignes[].cellules[].cle, celle de la valeur
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de
* l'orthographe, 0 pour celle que seuls des participants existants portent
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé
*/
export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) {
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) };
if (apercu === null) return { ...RIEN };
const { entetes, association } = apercu;
return {
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
separateur: separateurDe(apercu),
colonnes: colonnesDe(apercu),
ambigus: association.ambigus.map(({ champ, rangs }) => ({
champ,
cle: CLES_DES_CHAMPS.get(champ),
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
})),
valides: apercu.lignes.length,
lignes: lignesDe(apercu.lignes),
refusees: refuseesDe(apercu.refusees),
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({
affichee,
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
})),
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })),
homonymes: apercu.homonymes,
multilignes: apercu.multilignes,
global: globalDeLApercu(apercu, encodage === null),
};
}
/**
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de sept —,
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un
* collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et
* les rangs de ce second aperçu comptent la ligne ajoutée.
*
* @param {string} texte le texte de l'aperçu
* @param {Object} apercu l'aperçu de ce texte
* @returns {string|null}
*/
export function avecEntetesDeLaGrille(texte, apercu) {
if (!offreLaGrille(apercu)) return null;
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;
}
/**
* Le motif d'une ligne refusée, tel que l'aperçu et le rapport l'affichent :
* (code, details) → t(code, details). Le CSV des refus ne le reçoit pas :
* son motif est celui de libelle, que lie exporterRefus de
* src/application/csv.js.
*
* @param {(cle: string, details?: Object) => string} t
* @returns {(code: string, details: Object) => string}
*/
export function motifLigne(t) {
return (code, details) => t(code, details);
}

View file

@ -0,0 +1,408 @@
// © 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) : la lecture d'un collage
// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du
// remède de chaque refus global, l'encodage et le séparateur nommés, et les
// titres que « Remplacer » annonce, comptés par la règle unique des titres
// pourvus. 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, bilanRemplacement, CHAMPS, decoder } from '../../application/csv.js';
import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
COLONNES_DES_LIGNES,
ENCODAGES,
MODES_D_IMPORT,
SEPARATEURS,
avecEntetesDeLaGrille,
modeleApercu,
modePropose,
motifLigne,
nommer,
refuseesDe,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TAB = '\t';
const lignes = (...enregistrements) => `${enregistrements.join('\n')}\n`;
// Les noms lus des lignes valides d'un aperçu, dans l'ordre du texte.
const nomsLus = (apercu) => apercu.lignes.map(({ champs }) => champs.nom);
// Le refus de decoder sur ces octets, sous la forme { code, details }.
function refusDeDecoder(octets) {
try {
decoder(octets);
} catch (erreur) {
return { code: erreur.code, details: erreur.details };
}
throw new Error('decoder a accepté les octets');
}
const octets = (...valeurs) => Uint8Array.from(valeurs);
const utf8 = (texte) => new TextEncoder().encode(texte);
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
describe('avecEntetesDeLaGrille : un collage sans en-tête lu dans l’ordre de la grille (§ 10.2)', () => {
test('trois colonnes à tabulations, sans en-tête : AUCUN_ENTETE, puis nom, prénom et appartenance, les rangs comptant la ligne ajoutée', () => {
const texte = lignes(
['Ombrelle', 'Iris', 'Club des Merles'].join(TAB),
['Pervenche', 'Théo', 'Grisaille'].join(TAB),
);
const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
const avec = avecEntetesDeLaGrille(texte, sans);
assert.equal(avec, `${['nom', 'prenom', 'appartenance'].join(TAB)}\n${texte}`);
const lu = apercevoir(avec);
assert.equal(lu.refusGlobal, null);
assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1, appartenance: 2 });
assert.equal(lu.lignes.length, 2);
assert.deepEqual(lu.lignes.map(({ ligne }) => ligne), [2, 3]);
assert.deepEqual(lu.lignes[0].champs.appartenance, 'Club des Merles');
});
test('deux colonnes : nom et prénom', () => {
const texte = lignes(['Ombrelle', 'Iris'].join(TAB), ['Pervenche', 'Théo'].join(TAB));
const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
const lu = apercevoir(avecEntetesDeLaGrille(texte, sans));
assert.equal(lu.refusGlobal, null);
assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1 });
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']);
});
test('neuf colonnes : les sept champs de la grille, puis deux en-têtes vides, listés non reconnus', () => {
const rangee = (nom) => [nom, 'Iris', 'Grisaille', 'iris@exemple.test', 'animation', 'non', 'note', 'x1', 'x2'].join(TAB);
const texte = lignes(rangee('Ombrelle'), rangee('Lacasse'));
const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
assert.equal(sans.entetes.length, 9);
const avec = avecEntetesDeLaGrille(texte, sans);
assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB));
const lu = apercevoir(avec);
assert.equal(lu.refusGlobal, null);
assert.equal(CHAMPS.length, 7);
assert.deepEqual(lu.association.colonnes, Object.fromEntries(CHAMPS.map((champ, rang) => [champ, rang])));
assert.deepEqual(lu.association.nonReconnues, [7, 8]);
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse']);
});
test('une liste de noms d’une colonne, « Lacasse, Ondine » compris : SEPARATEUR_INTROUVABLE sans rang, puis une colonne, la ligne entière en nom', () => {
const texte = lignes('Ombrelle', 'Lacasse, Ondine', 'Pervenche');
const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'SEPARATEUR_INTROUVABLE');
assert.equal(sans.refusGlobalLigne, null);
const avec = avecEntetesDeLaGrille(texte, sans);
assert.equal(avec, `nom\n${texte}`);
const lu = apercevoir(avec);
assert.equal(lu.refusGlobal, null);
assert.equal(lu.separateur, null);
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse, Ondine', 'Pervenche']);
});
test('un texte blanc, un guillemet ouvert, un séparateur écarté à un rang nommé, un aperçu sans refus : null', () => {
const blanc = ' \n\t\n';
assert.equal(apercevoir(blanc).refusGlobal, 'AUCUN_ENTETE');
assert.equal(avecEntetesDeLaGrille(blanc, apercevoir(blanc)), null);
const guillemet = lignes('nom;prenom', '"Ombrelle;Iris', 'Pervenche;Théo');
assert.equal(apercevoir(guillemet).refusGlobal, 'GUILLEMET_OUVERT');
assert.equal(avecEntetesDeLaGrille(guillemet, apercevoir(guillemet)), null);
const ecarte = lignes('Lacasse, Ondine', 'Ombrelle', 'Pervenche');
const refus = apercevoir(ecarte);
assert.equal(refus.refusGlobal, 'SEPARATEUR_INTROUVABLE');
assert.equal(refus.refusGlobalLigne, 2);
assert.equal(avecEntetesDeLaGrille(ecarte, refus), null);
const valide = lignes('nom;prenom', 'Ombrelle;Iris');
assert.equal(avecEntetesDeLaGrille(valide, apercevoir(valide)), null);
});
});
// Chaque refus global de la table des décisions d'I6, la façon de le
// produire, et la clé du remède attendue. Les quatre de decoder passent par
// refusDecodage ; les autres, par l'aperçu d'un fichier (encodage donné) ou
// d'un collage (encodage null).
const REFUS = [
['UTF16_SANS_MARQUE', { refusDecodage: refusDeDecoder(octets(0x6e, 0x00, 0x6f, 0x00)) }, 'ENREGISTRER_EN_UTF8'],
['UTF16_INVALIDE', { refusDecodage: refusDeDecoder(octets(0xff, 0xfe, 0x6e, 0x00, 0x6f)) }, 'ENREGISTRER_EN_UTF8'],
['UTF8_INVALIDE', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, 0x6e, 0xff, 0x6f)) }, 'ENREGISTRER_EN_UTF8'],
['CARACTERE_NUL', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, ...utf8('nom\nOmbrelle\u{0}\n'))) }, 'ENREGISTRER_EN_UTF8'],
['CARACTERE_NUL', { encodage: null, apercu: apercevoir('nom\nOmbrelle\u{0}\n') }, 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', { encodage: 'utf-8', apercu: apercevoir(lignes('a;b', 'Ombrelle;Iris')) }, 'import.remede.aucunEntete'],
['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(lignes('Ombrelle;Iris', 'Pervenche;Théo')) }, 'import.remede.aucunEnteteCollage'],
['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(' \n') }, 'import.remede.aucunEntete'],
['NOM_NON_ASSOCIE', { encodage: 'utf-8', apercu: apercevoir(lignes('prenom;notes', 'Iris;x')) }, 'import.remede.nomNonAssocie'],
['AUCUNE_LIGNE_VALIDE', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', ';Iris')) }, 'import.remede.aucuneLigneValide'],
['GUILLEMET_OUVERT', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', '"Ombrelle;Iris')) }, 'import.remede.guillemetOuvert'],
[
'SEPARATEUR_INTROUVABLE',
{ encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', 'Ombrelle;Iris;x', 'Pervenche')) },
'import.remede.separateurIntrouvable',
],
[
'SEPARATEUR_INTROUVABLE',
{ encodage: null, apercu: apercevoir(lignes('Ombrelle', 'Lacasse, Ondine')) },
'import.remede.separateurIntrouvableCollage',
],
];
describe('modeleApercu : la cause et le remède de chaque refus global (§ 10.1)', () => {
test('chaque refus de la table des décisions rend la clé de sa cause, le code, et celle de son remède, toutes deux traduites', () => {
const codes = new Set(REFUS.map(([code]) => code));
assert.equal(codes.size, 9);
assert.equal(REFUS.length, 13);
for (const [code, entree, remede] of REFUS) {
if (entree.apercu !== undefined) assert.equal(entree.apercu.refusGlobal, code, code);
if (entree.refusDecodage !== undefined) assert.equal(entree.refusDecodage.code, code, code);
const { global } = modeleApercu(entree);
assert.notEqual(global, null, code);
assert.equal(global.cle, code);
assert.equal(global.remede.cle, remede, code);
assert.equal(typeof t(global.cle, global.details), 'string');
assert.ok(t(global.cle, global.details).length > 0, code);
assert.ok(t(global.remede.cle, global.remede.details).length > 0, code);
}
});
test('un refus de decoder : son texte compose déjà le remède, que le modèle dit et ne répète pas ; un refus de l’aperçu le porte à part', () => {
const [, entree] = REFUS[0];
const { global } = modeleApercu(entree);
assert.equal(global.remede.dansLaCause, true);
assert.ok(t(global.cle, global.details).includes(t(global.remede.cle, global.remede.details)));
for (const [code, apercuDonne] of REFUS.slice(4)) {
assert.equal(modeleApercu(apercuDonne).global.remede.dansLaCause, false, code);
}
});
test('le rang d’un guillemet ouvert et d’un séparateur écarté passe à la cause et au remède', () => {
const guillemet = modeleApercu(REFUS[10][1]).global;
assert.deepEqual(guillemet.details, { ligne: 2 });
assert.match(t(guillemet.cle, guillemet.details), /ligne 2/);
assert.match(t(guillemet.remede.cle, guillemet.remede.details), /2/);
const separateur = modeleApercu(REFUS[11][1]).global;
assert.deepEqual(separateur.details, { ligne: 2 });
assert.match(t(separateur.cle, separateur.details), /ligne 2/);
});
test('un code de refus que la table ne connaît pas lève, jamais un remède muet', () => {
const apercu = { ...apercevoir(lignes('nom', 'Ombrelle')), refusGlobal: 'REFUS_INVENTE' };
assert.throws(() => modeleApercu({ encodage: 'utf-8', apercu }), RangeError);
assert.throws(
() => modeleApercu({ refusDecodage: { code: 'REFUS_INVENTE', details: { remede: 'ENREGISTRER_EN_UTF8' } } }),
RangeError,
);
});
test('sous un refus de decoder : ni encodage, ni séparateur, ni colonne, ni ligne', () => {
const modele = modeleApercu(REFUS[0][1]);
assert.equal(modele.encodage, null);
assert.equal(modele.separateur, null);
for (const liste of ['colonnes', 'ambigus', 'lignes', 'refusees', 'fusions', 'doublons', 'homonymes', 'multilignes']) {
assert.deepEqual(modele[liste], [], liste);
}
assert.equal(modele.valides, 0);
});
});
describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
test('chaque encodage de decoder et chaque séparateur ont leur clé, qui se traduit, et un collage n’a pas d’encodage', () => {
const encodages = ['utf-8-bom', 'utf-16le', 'utf-16be', 'utf-8', 'windows-1252'];
assert.deepEqual([...ENCODAGES.keys()], encodages);
for (const encodage of encodages) assert.ok(t(ENCODAGES.get(encodage)).length > 0, encodage);
assert.deepEqual([...SEPARATEURS.keys()], [';', ',', '\t', null]);
for (const cle of SEPARATEURS.values()) assert.ok(t(cle).length > 0, cle);
assert.equal(t(SEPARATEURS.get(null)), 'une seule colonne');
const apercu = apercevoir(lignes('nom,prenom', 'Ombrelle,Iris'));
const fichier = modeleApercu({ encodage: 'windows-1252', apercu });
assert.equal(fichier.encodage, ENCODAGES.get('windows-1252'));
assert.equal(fichier.separateur, SEPARATEURS.get(','));
const collage = modeleApercu({ apercu });
assert.equal(collage.encodage, null);
assert.equal(collage.separateur, SEPARATEURS.get(','));
const uneColonne = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom', 'Lacasse, Ondine')) });
assert.equal(uneColonne.separateur, SEPARATEURS.get(null));
});
test('un séparateur que la lecture n’a pas établi n’est pas nommé « une seule colonne »', () => {
for (const [code, entree] of REFUS.slice(4)) {
const { separateur } = modeleApercu(entree);
const etabli = ['AUCUN_ENTETE', 'NOM_NON_ASSOCIE', 'AUCUNE_LIGNE_VALIDE', 'GUILLEMET_OUVERT'].includes(code) &&
entree.apercu.entetes.length > 0;
if (etabli) assert.equal(separateur, SEPARATEURS.get(entree.apercu.separateur), code);
else assert.equal(separateur, null, code);
}
});
test('colonnes associées, non reconnues, à trancher puis écartée ; ambiguïtés nommées par leurs en-têtes', () => {
const texte = lignes('Nom;Organisation;Entreprise;Allergies', 'Ombrelle;Club des Merles;Grisaille;aucune');
const ambigu = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.deepEqual(
ambigu.colonnes.map(({ rang, entete, cle }) => [rang, entete, cle]),
[
[0, 'Nom', 'import.champ.nom'],
[1, 'Organisation', 'import.colonne.aTrancher'],
[2, 'Entreprise', 'import.colonne.aTrancher'],
[3, 'Allergies', 'import.colonne.nonReconnue'],
],
);
assert.deepEqual(ambigu.ambigus, [
{ champ: 'appartenance', cle: 'import.champ.appartenance', rangs: [{ rang: 1, entete: 'Organisation' }, { rang: 2, entete: 'Entreprise' }] },
]);
const tranche = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { choix: { appartenance: 2 } }) });
assert.deepEqual(tranche.colonnes.map(({ cle }) => cle), [
'import.champ.nom', 'import.colonne.ecartee', 'import.champ.appartenance', 'import.colonne.nonReconnue',
]);
assert.deepEqual(tranche.ambigus, []);
for (const { cle } of [...ambigu.colonnes, ...tranche.colonnes]) assert.ok(t(cle).length > 0, cle);
});
test('chaque champ de CHAMPS a sa clé, qui se traduit', () => {
const texte = lignes(CHAMPS.join(';'), ['Ombrelle', 'Iris', 'Grisaille', 'i@exemple.test', 'x', 'non', 'n'].join(';'));
const { colonnes } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(colonnes.length, 7);
for (const { cle } of colonnes) {
assert.match(cle, /^import\.champ\./);
assert.ok(t(cle).length > 0, cle);
}
});
test('lignes refusées, fusions et doublons, en données ; le motif affiché est celui de t', () => {
const existants = [{ id: 4, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Coopérative Bleue' }];
const texte = lignes(
'nom;prenom;appartenance;exclu',
'Ombrelle;Iris;cooperative bleue;',
';Théo;Grisaille;',
'Pervenche;Théo;COOPÉRATIVE BLEUE;peut-être',
'Pervenche;Théo;COOPÉRATIVE BLEUE;x',
'Lacasse;Ondine;;',
);
const modele = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { participants: existants }) });
assert.equal(modele.valides, 3);
assert.deepEqual(modele.refusees, [
{ ligne: 3, code: 'NOM_ABSENT', details: { ligne: 3, valeur: null } },
{ ligne: 4, code: 'EXCLU_INCONNU', details: { ligne: 4, valeur: 'peut-être' } },
]);
assert.deepEqual(modele.fusions, [
{
affichee: 'Coopérative Bleue',
orthographes: [
{ texte: 'Coopérative Bleue', lignes: 0 },
{ texte: 'cooperative bleue', lignes: 1 },
{ texte: 'COOPÉRATIVE BLEUE', lignes: 1 },
],
},
]);
assert.deepEqual(modele.doublons, [{ lignes: [2], existants: [4] }]);
const motif = motifLigne(t);
const [, refusee] = modele.refusees;
assert.equal(motif(refusee.code, refusee.details), t('EXCLU_INCONNU', { ligne: 4, valeur: 'peut-être' }));
assert.match(motif(refusee.code, refusee.details), /peut-être/);
});
});
describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => {
test('chaque ligne valide montre ses sept champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur', () => {
const texte = lignes(
'notes;exclu;nom;prenom',
'arrive tard;Oui;Ombrelle;Iris',
';;Pervenche;',
';0;Lacasse;Ondine',
);
const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(lues.length, 3);
assert.deepEqual(lues.map(({ ligne }) => ligne), [2, 3, 4]);
for (const { cellules } of lues) assert.deepEqual(cellules.map(({ champ }) => champ), [...CHAMPS]);
assert.deepEqual(COLONNES_DES_LIGNES.map(({ champ }) => champ), [...CHAMPS]);
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
const [premiere, deuxieme, troisieme] = lues;
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard']);
assert.equal(premiere.cellules[5].cle, 'import.exclu.oui');
assert.equal(deuxieme.cellules[5].cle, null);
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');
for (const cle of ['import.exclu.oui', 'import.exclu.non']) assert.ok(t(cle).length > 0);
});
test('refuseesDe : les refus de la mise à jour gardent les personnes désignées et la première ligne, sans les champs découpés', () => {
const refusees = [
{ ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: [2, 5], brut: ['Lacasse', ''] },
{ ligne: 5, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 4, brut: ['Ombrelle', ''] },
];
assert.deepEqual(refuseesDe(refusees), [
{ ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', details: { ligne: 3, valeur: null, participants: [2, 5] } },
{ ligne: 5, code: 'DEJA_DESIGNE', details: { ligne: 5, valeur: null, participants: [1], premiereLigne: 4 } },
]);
const motif = motifLigne(t);
assert.match(motif('DEJA_DESIGNE', refuseesDe(refusees)[1].details), /ligne 4/);
});
test('nommer : les personnes d’une liste d’identifiants, dans son ordre ; une personne que la liste n’a plus est omise', () => {
const participants = [
{ id: 2, nom: 'Lacasse', prenom: 'Ondine' },
{ id: 5, nom: 'Lacasse', prenom: null },
];
assert.deepEqual(nommer(participants, [5, 2, 9]), [
{ id: 5, nom: 'Lacasse', prenom: null },
{ id: 2, nom: 'Lacasse', prenom: 'Ondine' },
]);
});
test('les trois modes offerts, chacun nommé par sa clé, ceux de l’aperçu de src/csv', () => {
assert.deepEqual(MODES_D_IMPORT.map(({ mode }) => mode), ['ajouter', 'mettreAJour', 'remplacer']);
for (const { mode, cle } of MODES_D_IMPORT) {
assert.ok(t(cle).length > 0, cle);
assert.equal(apercevoir(lignes('nom', 'Ombrelle'), { mode }).mode, mode);
}
});
test('le mode proposé : Ajouter pour une liste vide, Mettre à jour sinon ; jamais Remplacer', () => {
assert.equal(modePropose([]), 'ajouter');
assert.equal(modePropose([{ id: 1, nom: 'Ombrelle' }]), 'mettreAJour');
});
});
// Une charge de deux tours, une table de six places, trois titres à cette
// table, et trois personnes : la première réservée « tous » au siège du
// premier titre, la deuxième réservée au tour 1 seul au siège du deuxième,
// la troisième sans réservation. Sans attribution, les réservations ne
// portent pas de siège.
function chargeAuxTitres({ attribuerSieges }) {
const charge = creerCharge({ id: 'epreuve', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = ['Ombrelle', 'Pervenche', 'Lacasse'].map((nom, rang) => ({
id: rang + 1, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false,
}));
charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }];
charge.titres = [1, 2, 3].map((siege) => ({ table: 1, siege, libelle: `Animation ${siege}` }));
charge.reservations = [
{ participant: 1, table: 1, siege: attribuerSieges ? 1 : null, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: attribuerSieges ? 2 : null, portee: 'tour', tour: 1 },
];
charge.prochainsIds = { participant: 4, table: 2, proposition: 1 };
return charge;
}
describe('les titres que « Remplacer » annonce (§ 10.1, § 4.1)', () => {
for (const attribuerSieges of [true, false]) {
test(`sièges ${attribuerSieges ? 'attribués' : 'non attribués, réservations sans siège'} : deux titres perdent leur réservation, un « tous » et un « tour »`, () => {
const charge = chargeAuxTitres({ attribuerSieges });
const bilan = bilanRemplacement(charge);
assert.equal(bilan.titresPourvus, 2);
assert.equal(charge.tables.length, 1);
const pourSoiree = charge.tables.reduce((somme, { id }) => somme + comptesTitres(charge, id).pourvus, 0);
assert.equal(pourSoiree, 1);
const aUnTour = titresNonPourvus(charge).filter(({ pourvuAuxTours }) => pourvuAuxTours.length > 0).length;
assert.equal(aUnTour, 1);
assert.equal(bilan.titresPourvus, pourSoiree + aUnTour);
assert.deepEqual(bilan, { participants: 3, reservations: 2, titresPourvus: 2, propositions: 0 });
});
}
});

View file

@ -3,5 +3,115 @@
// Les libellés de l'accueil : la liste des événements, les démonstrations, les
// opérations de fichiers, la corbeille, le dossier de travail et la
// récupération (§ 14.6). Chaque clé commence par « accueil. ».
export const TABLE = new Map();
// récupération (§ 14.6). Chaque clé commence par « accueil. ». Les codes que
// l'accueil affiche — avis et questions du dossier de travail, refus, état
// illisible — gardent leur texte dans les tables de l'application.
// Une donnée — un chemin, une base — n'entre dans aucun libellé d'écran :
// le composant la rend dans son propre nœud, à côté du texte traduit
// (§ 14.6). Un compte s'accorde dans la phrase qui le porte.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
export const TABLE = new Map([
// --- La liste (§ 8.1) ----------------------------------------------------
// Les intitulés des informations d'une ligne ; le nom est la commande qui
// ouvre l'événement.
['accueil.colonne.date', 'Date'],
['accueil.colonne.etat', 'État'],
['accueil.colonne.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'],
// Le nom d'un état du plan (§ 9), que rend cleDEtat (modele.js).
['accueil.etat.brouillon', 'Brouillon'],
['accueil.etat.propose', 'Proposé'],
['accueil.etat.retenu', 'Retenu'],
['accueil.etat.bloque', 'Bloqué'],
// autres : le nombre des autres bases qui portent le même identifiant
// (§ 8.7, § 8.8) ; elles suivent le texte, chacune dans son propre nœud.
[
'accueil.signalement.doublon',
({ autres }) =>
autres <= 1
? 'Le même événement se trouve aussi dans le fichier nommé ci-dessous : deux fichiers portent son ' +
'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez l\u{2019}autre.'
: `Le même événement se trouve aussi dans les ${autres} fichiers nommés ci-dessous : chacun porte son ` +
'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez les autres.',
],
// --- La création (§ 2.1, étape 1) ----------------------------------------
['accueil.creation.titre', 'Créer un événement'],
['accueil.creation.nom', 'Nom de l\u{2019}événement'],
['accueil.creation.date', 'Date, facultative (AAAA-MM-JJ)'],
['accueil.creation.creer', 'Créer l\u{2019}événement'],
// --- Les opérations de fichiers (§ 8.7) ----------------------------------
['accueil.operation.importer', 'Importer un événement depuis un fichier\u{2026}'],
['accueil.operation.copier', 'Copier'],
['accueil.operation.renommer', 'Renommer'],
['accueil.operation.supprimer', 'Supprimer'],
['accueil.operation.confirmerSuppression', 'Confirmer : déplacer dans la corbeille'],
['accueil.operation.nomCopie', 'Nom de la copie'],
['accueil.operation.nouveauNom', 'Nouveau nom'],
['accueil.operation.validerCopie', 'Créer la copie'],
['accueil.operation.validerRenommage', 'Renommer l\u{2019}événement'],
['accueil.operation.annuler', 'Annuler'],
// --- Les démonstrations (§ 15) ---------------------------------------------
['accueil.demonstrations.titre', 'Démonstrations'],
[
'accueil.demonstrations.explication',
'Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; ' +
'aucun autre événement n\u{2019}est touché.',
],
// --- La corbeille (§ 8.7) --------------------------------------------------
['accueil.corbeille.titre', 'Corbeille'],
[
'accueil.corbeille.explication',
'Un événement supprimé y est déplacé avec tous ses fichiers, dans un dossier daté. ' +
'Le logiciel ne la vide jamais.',
],
// --- Le dossier de travail (§ 8.6) -----------------------------------------
['accueil.dossier.titre', 'Dossier de travail'],
['accueil.dossier.explorateur', 'Ouvrir dans l\u{2019}explorateur'],
['accueil.question.reessayer', 'Réessayer'],
['accueil.question.documents', 'Travailler dans les Documents, en y copiant ces événements'],
['accueil.question.continuer', 'Continuer : ouvrir la liste, aucun geste ne s\u{2019}y enregistrera'],
// evenements : ce que porte l'autre emplacement, dont le chemin suit le
// texte, dans son propre nœud.
[
'accueil.autre.proposition',
({ evenements }) =>
`Le dossier de travail est neuf, et cet autre dossier porte ${compte(evenements, 'événement', 'événements')} :`,
],
[
'accueil.autre.originaux',
'L\u{2019}import les copie : les originaux restent où ils sont, et chaque copie vit ensuite sa propre vie.',
],
['accueil.autre.importer', 'Importer ces événements'],
// importes, refuses : les comptes de l'import de l'autre emplacement.
[
'accueil.autre.resultat',
({ importes, refuses }) =>
refuses === 0
? `${compte(importes, 'événement importé', 'événements importés')}.`
: `${compte(importes, 'événement importé', 'événements importés')} ; ` +
`${compte(refuses, 'ne s\u{2019}est pas importé', 'ne se sont pas importés')}.`,
],
// --- La récupération (§ 8.8) -----------------------------------------------
['accueil.recuperation.titre', 'Récupération d\u{2019}un événement'],
['accueil.recuperation.fichier', 'Fichier'],
[
'accueil.recuperation.offerte',
'La version choisie s\u{2019}ouvre en lecture : rien ne s\u{2019}écrit avant un geste en écriture.',
],
[
'accueil.recuperation.aucunSecours',
'Aucune version de secours ne se lit : l\u{2019}événement ne s\u{2019}ouvre pas, et rien n\u{2019}est créé à sa place.',
],
['accueil.recuperation.retour', 'Revenir à la liste'],
]);

View file

@ -1,7 +1,43 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'événement ouvert : son en-tête, son mode, ses
// avertissements, le support rompu (§ 14.6). Chaque clé commence par «
// evenement. ».
export const TABLE = new Map();
// Les libellés de l'événement ouvert : son en-tête, ses états, la place fixe
// de son cadre de mode, la reprise d'un verrou, ses avertissements, le
// support rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par «
// evenement. ». Une donnée — un numéro, une séance, un instant — n'entre
// dans aucun de ces textes : l'écran la pose dans son propre nœud, à côté.
export const TABLE = new Map([
// --- L'en-tête et les états du plan (§ 9) -----------------------------------
['evenement.etat.titre', 'État du plan'],
['evenement.etat.brouillon', 'Brouillon'],
['evenement.etat.propose', 'Proposé'],
['evenement.etat.retenu', 'Retenu'],
['evenement.etat.bloque', 'Bloqué'],
// --- Le cadre de mode et sa place fixe (§ 8.3, § 8.4, § 8.5) -------------
['evenement.cadre.commandes', 'Mode et historique'],
['evenement.commande.defaire', 'Défaire'],
['evenement.commande.refaire', 'Refaire'],
// --- La reprise d'un verrou dont la vie ne se prouve pas (§ 8.8) ----------
['evenement.verrou.titre', 'Le verrou qui tient cet événement'],
['evenement.verrou.seance', 'Séance'],
['evenement.verrou.depuis', 'Pris le'],
['evenement.verrou.inconnu', 'illisible'],
[
'evenement.verrou.avis',
"Reprendre ce verrou n'est sûr que si cette séance est arrêtée : deux séances qui écrivent le même événement se contrediraient.",
],
['evenement.verrou.confirmerReprise', 'Confirmer la reprise de ce verrou'],
// --- Les avertissements (§ 8.6, § 8.9, § 9) -------------------------------
['evenement.avertissements', 'Avertissements'],
['evenement.fautive.numero', 'Proposition n°'],
['evenement.fautive.rang', 'Proposition illisible, au rang'],
['evenement.derive.retenu', 'Placement retenu'],
['evenement.derive.proposition', 'Proposition n°'],
// --- Le support rompu (§ 8.6) ----------------------------------------------
['evenement.supportRompu.abandonner', 'Fermer sans écrire ailleurs'],
['evenement.supportRompu.confirmerAbandon', "Confirmer : fermer sans l'écrire ailleurs"],
]);

View file

@ -2,5 +2,133 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
// clé commence par « import. ».
export const TABLE = new Map();
// clé commence par « import. ». Un nombre, un nom ou un rang s'affiche dans
// son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce
// qui accorde le nom au compte.
// Le singulier jusqu'à un, le pluriel au-delà, comme le veut le français.
const accorde = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
export const TABLE = new Map([
// --- La section et ses commandes (§ 10.1, § 10.2) ----------------------------
[
'import.consigne',
'Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. ' +
"Rien ne s'importe avant l'aperçu.",
],
['import.commande.choisirFichier', 'Choisir un fichier CSV\u{2026}'],
['import.commande.importer', 'Importer'],
['import.commande.fermerApercu', "Fermer l'aperçu"],
['import.commande.ordreDeLaGrille', "Lire les colonnes dans l'ordre de la grille"],
['import.commande.enregistrerRefus', 'Enregistrer les lignes refusées'],
['import.commande.fermerRapport', 'Fermer le rapport'],
['import.refusEnregistres', 'Lignes refusées enregistrées :'],
// --- Le mode de l'import (§ 10.1) -------------------------------------------
['import.mode.titre', "Mode de l'import"],
['import.mode.ajouter', 'Ajouter'],
['import.mode.mettreAJour', 'Mettre à jour'],
['import.mode.remplacer', 'Remplacer'],
// --- L'aperçu : source, encodage, séparateur ---------------------------------
['import.apercu.titre', 'Aperçu'],
['import.apercu.fichier', 'Fichier'],
['import.apercu.collage', 'Texte collé'],
['import.apercu.encodage', 'Encodage'],
['import.apercu.separateur', 'Séparateur'],
['import.encodage.utf8Marque', 'UTF-8 avec marque'],
['import.encodage.utf16le', 'UTF-16 LE'],
['import.encodage.utf16be', 'UTF-16 BE'],
['import.encodage.utf8', 'UTF-8'],
['import.encodage.windows1252', 'windows-1252'],
['import.separateur.pointVirgule', 'point-virgule'],
['import.separateur.virgule', 'virgule'],
['import.separateur.tabulation', 'tabulation'],
['import.separateur.uneColonne', 'une seule colonne'],
// --- L'aperçu : les colonnes et leur association (§ 10.1) --------------------
['import.apercu.colonnes', 'Colonnes'],
['import.apercu.entete', 'En-tête'],
['import.apercu.devient', 'Devient'],
['import.champ.nom', 'nom'],
['import.champ.prenom', 'prénom'],
['import.champ.appartenance', 'appartenance'],
['import.champ.courriel', 'courriel'],
['import.champ.titrePressenti', 'titre pressenti'],
['import.champ.exclu', 'exclu'],
['import.champ.notes', 'notes'],
['import.exclu.oui', 'oui'],
['import.exclu.non', 'non'],
['import.colonne.nonReconnue', 'non reconnue : ignorée'],
['import.colonne.aTrancher', 'à trancher'],
['import.colonne.ecartee', 'écartée par le choix'],
['import.ambiguite.question', 'Plusieurs colonnes répondent au même champ. Laquelle vaut :'],
// --- L'aperçu : les lignes ---------------------------------------------------
['import.apercu.valides', ({ n }) => accorde(n, 'ligne valide', 'lignes valides')],
['import.apercu.refusees', 'Lignes refusées'],
['import.apercu.ligne', 'Ligne'],
['import.apercu.motif', 'Motif'],
['import.apercu.fusions', 'Orthographes fondues en une appartenance'],
['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')],
['import.apercu.affichee', 'affichée :'],
['import.apercu.doublons', 'Doublons signalés, jamais fusionnés'],
['import.apercu.doublonLignes', 'lignes'],
['import.apercu.doublonExistants', 'déjà dans la liste :'],
['import.apercu.homonymes', "Ajoutées à côté d'une personne de même nom"],
['import.apercu.multilignes', 'Cellules sur plusieurs lignes qui portent le séparateur : à vérifier'],
// --- Les refus globaux : le remède (§ 10.1) ---------------------------------
['import.remede.caractereNulCollage', 'Retirez le caractère nul du texte collé, ou importez le fichier réenregistré en UTF-8.'],
['import.remede.aucunEntete', "Ajoutez une ligne d'en-tête qui nomme les colonnes, « nom » au moins."],
[
'import.remede.aucunEnteteCollage',
"Ajoutez une ligne d'en-tête qui nomme les colonnes, ou lisez les colonnes dans l'ordre de la grille.",
],
['import.remede.nomNonAssocie', 'Nommez « nom » la colonne des noms.'],
['import.remede.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'],
[
'import.remede.guillemetOuvert',
({ ligne }) => `Fermez le guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''}.`,
],
[
'import.remede.separateurIntrouvable',
'Séparez les colonnes par « ; », « , » ou une tabulation, en même nombre à chaque ligne.',
],
[
'import.remede.separateurIntrouvableCollage',
"Séparez les colonnes par « ; », « , » ou une tabulation, ou lisez la colonne dans l'ordre de la grille : " +
'chaque ligne devient un nom.',
],
// --- La fenêtre de « Remplacer » (§ 10.1) ------------------------------------
['import.remplacer.titre', 'Remplacer la liste des participants'],
[
'import.remplacer.participants',
({ n }) => `${n} ${accorde(n, 'personne sera supprimée', 'personnes seront supprimées')}`,
],
[
'import.remplacer.reservations',
({ n }) => `${n} ${accorde(n, 'réservation sera supprimée', 'réservations seront supprimées')}`,
],
[
'import.remplacer.titres',
({ n }) =>
`${n} ${accorde(n, 'titre perdra la réservation qui le pourvoit', 'titres perdront la réservation qui les pourvoit')}, ` +
'à un tour au moins',
],
[
'import.remplacer.propositions',
({ n }) => `${n} ${accorde(n, 'proposition deviendra périmée', 'propositions deviendront périmées')}`,
],
// --- Le rapport (§ 10.1) ---------------------------------------------------
['import.rapport.titre', "Rapport d'import"],
['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')],
['import.rapport.misAJour', ({ n }) => accorde(n, 'personne mise à jour', 'personnes mises à jour')],
['import.rapport.inchanges', ({ n }) => accorde(n, 'personne inchangée', 'personnes inchangées')],
['import.rapport.retires', ({ n }) => accorde(n, 'personne retirée', 'personnes retirées')],
['import.rapport.reservationsRetirees', ({ n }) => accorde(n, 'réservation retirée', 'réservations retirées')],
['import.rapport.rienEcrit', "Rien n'a changé dans la liste : seules des lignes refusées restent à lire."],
['import.rapport.designees', 'désignées :'],
]);

View file

@ -3,4 +3,89 @@
// Les libellés de la liste des participants : grille, formulaire, fiche (§
// 14.6). Chaque clé commence par « participants. ».
export const TABLE = new Map();
//
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
// d'une valeur de la fiche ; participants.statut.* nomme chaque statut
// calculé (§ 4.2), par cleDeStatut.
// Un nombre suivi de son nom, au singulier pour 0 et 1.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
// La portée d'une réservation : tous les tours, ou le tour désigné.
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'tous les tours' : `tour ${tour}`);
export const TABLE = new Map([
// --- Les colonnes, que la grille, le formulaire et la fiche partagent ------
['participants.colonne.nom', 'Nom'],
['participants.colonne.prenom', 'Prénom'],
['participants.colonne.appartenance', 'Appartenance'],
['participants.colonne.courriel', 'Courriel'],
['participants.colonne.titrePressenti', 'Titre pressenti'],
['participants.colonne.exclu', 'Exclusion'],
['participants.colonne.notes', 'Notes'],
// --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
['participants.formulaire.titre', 'Ajouter une personne'],
['participants.formulaire.ajouter', 'Ajouter'],
['participants.formulaire.orthographe', 'Cette appartenance existe déjà sous l\u{2019}orthographe'],
['participants.formulaire.reprendre', 'Reprendre cette orthographe'],
// --- La grille (§ 10.2) ---------------------------------------------------
['participants.grille.titre', 'Liste des participants'],
['participants.grille.filtre', 'Filtrer'],
[
'participants.grille.compte',
({ total, affiches }) =>
affiches === total ? compte(total, 'participant', 'participants') : `${affiches} sur ${compte(total, 'participant', 'participants')}`,
],
['participants.grille.croissant', '(croissant)'],
['participants.grille.decroissant', '(décroissant)'],
['participants.grille.actions', 'Actions'],
['participants.grille.exclue', 'exclue'],
['participants.grille.presente', 'présente'],
['participants.grille.vide', 'Aucun participant. Ajoutez une personne, ou collez une liste dans la grille.'],
['participants.grille.aucunResultat', 'Aucune personne ne correspond au filtre.'],
// --- Les commandes de la section ------------------------------------------
['participants.commande.fiche', 'Fiche'],
['participants.commande.exclure', 'Exclure'],
['participants.commande.reintegrer', 'Réintégrer'],
['participants.commande.supprimer', 'Supprimer'],
['participants.commande.exporter', 'Exporter la liste'],
['participants.commande.fermerFiche', 'Fermer la fiche'],
// --- La suppression d'une personne qui porte des réservations (§ 2.1) -----
['participants.suppression.titre', ({ personne }) => `Supprimer ${personne} et ses réservations ?`],
[
'participants.suppression.reservation',
(details) =>
`Réservation à la table ${details.numero}${details.siege === null ? '' : `, siège ${details.siege}`}, ${portee(details)}`,
],
// --- La fiche (§ 4, § 4.2, § 7.4) -----------------------------------------
['participants.fiche.titre', 'Fiche de la personne'],
['participants.fiche.vide', '\u{2014}'],
['participants.fiche.statut', 'Statut'],
['participants.fiche.statutExclue', 'exclue : ni placée, ni comptée'],
['participants.fiche.statutInconnu', 'non calculé : la configuration est incohérente'],
['participants.fiche.reservations', 'Réservations'],
['participants.fiche.aucuneReservation', 'Aucune réservation.'],
['participants.fiche.suspendues', 'Suspendues tant que la personne est exclue.'],
['participants.fiche.table', 'Table'],
['participants.fiche.siege', 'Siège'],
['participants.fiche.portee', 'Portée'],
['participants.fiche.sansSiege', 'sans siège'],
['participants.fiche.tousLesTours', 'tous les tours'],
['participants.fiche.auTour', 'tour'],
['participants.fiche.itineraire', 'Placement retenu, tour par tour'],
['participants.fiche.tour', 'Tour'],
['participants.fiche.enReserve', 'en réserve'],
['participants.fiche.siegeNonAttribue', 'non attribué'],
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],
// --- Les statuts calculés (§ 4.2) -----------------------------------------
['participants.statut.ancre', 'ancré'],
['participants.statut.partiel', 'partiellement fixé'],
['participants.statut.mobile', 'mobile'],
]);

View file

@ -3,4 +3,73 @@
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
// travail (§ 14.6). Chaque clé commence par « plan. ».
export const TABLE = new Map();
//
// Une ligne de la liste de noms (§ 7.6) se compose de parties, chacune son
// tspan : la tête — numéro, puis état de la place —, le nom, puis le titre
// entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre
// sont des données : aucun texte de cette table ne les porte. La tête finit
// par une espace quand un nom la suit (avecNom).
import { compte } from '../../application/libelles/aides.js';
export const TABLE = new Map([
// --- Le dessin d'une table (§ 7.5, § 7.6) ----------------------------------
// Le nom accessible d'une table : numéro, places, conflits du tour quand
// ils s'évaluent (conflits null sinon), puis gelée et surchargée.
[
'plan.table.nom',
({ numero, places, conflits, gelee, surchargee }) =>
[
`Table ${numero}`,
compte(places, 'place', 'places'),
...(conflits === null ? [] : [conflits === 0 ? 'aucun conflit' : compte(conflits, 'conflit', 'conflits')]),
...(gelee ? ['gelée'] : []),
...(surchargee ? ['surchargée'] : []),
].join(', '),
],
// --- La liste de noms (§ 7.4, § 7.6) ---------------------------------------
['plan.liste.gelee', 'table gelée'],
['plan.liste.abreviation', '\u{2026}'],
[
'plan.ligne.tete',
({ numero, etat, avecNom }, texte) =>
`${numero}.${etat === null ? '' : ` ${texte(etat.cle, etat.details)}`}${avecNom ? ' ' : ''}`,
],
['plan.ligne.reserveeTous', '(réservée, tous les tours)'],
['plan.ligne.reserveeTour', ({ tour }) => `(réservée, tour ${tour})`],
['plan.ligne.libre', '(libre)'],
// Une place réservée à une autre personne que l'occupant : l'état
// s'écrit autour du nom de la titulaire, qui occupe son propre nœud,
// entre teteReserveeA et reserveeA.
['plan.ligne.teteReserveeA', ({ numero }) => `${numero}. (réservée à `],
['plan.ligne.reserveeA', ({ portee, tour }) => (portee === 'tous' ? ', tous les tours)' : `, tour ${tour})`)],
['plan.ligne.avantTitrePourvu', ' \u{2014} '],
['plan.ligne.avantTitreNonPourvu', ' \u{2014} titre '],
['plan.ligne.apresTitreNonPourvu', ' non pourvu'],
// --- Le bandeau du plan (§ 7.5) --------------------------------------------
// L'appartenance d'un plancher de groupe est une donnée : l'écran la pose
// dans son propre nœud, avant ce texte.
['plan.bandeau.toutesEnConflit', 'toutes les tables sont en conflit à ce tour'],
[
'plan.bandeau.collisionPartout',
'tout placement porte une collision à chaque table : ajouter des tables ou désactiver la séparation',
],
[
'plan.bandeau.uneSeuleAppartenance',
'une seule appartenance sous séparation active : chaque table réunit des personnes de la même appartenance',
],
[
'plan.bandeau.plancherGroupe',
({ effectif, plancherCumulees }) =>
`${compte(effectif, 'personne', 'personnes')}, au moins ${compte(plancherCumulees, 'collision', 'collisions')} sur la soirée`,
],
['plan.bandeau.sansPlancher', 'le diagnostic ne prouve aucun plancher de collisions'],
// --- L'échelle graphique et la mention (§ 7.7) -----------------------------
['plan.echelle.mention', "disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié"],
['plan.echelle.nom', 'Échelle graphique'],
['plan.echelle.centimetres', 'cm'],
['plan.echelle.metres', 'm'],
['plan.echelle.kilometres', 'km'],
]);

View file

@ -1,6 +1,131 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par «
// tables. ».
export const TABLE = new Map();
// Les libellés des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5,
// § 14.6). Chaque clé commence par « tables. ».
//
// L'énoncé d'un changement de défaut se compose de phrases, que rend
// enonceDefaut (tables/modele.js) et que l'écran sépare d'une espace : la
// phrase d'application, puis celle du total — le § 6.1 les écrit ainsi. Une
// baisse refusée n'a qu'une phrase d'en-tête, puis le refus de chaque table,
// composé par texte depuis son code. Les noms de personnes et les numéros de
// table viennent des détails : une fenêtre à confirmer nomme chaque élément
// détruit dans son propre libellé.
import { cite, compte } from '../../application/libelles/aides.js';
// Un accord au nombre : le singulier jusqu'à un, comme compte.
const accord = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
// La portée d'une réservation en mots : tous les tours, ou le tour désigné.
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'pour tous les tours' : `au tour ${tour}`);
export const TABLE = new Map([
// --- Le défaut (§ 6.1) ------------------------------------------------------
['tables.defaut.etiquette', 'Places par défaut'],
[
'tables.defaut.application',
({ suivent, avant, apres, surchargees }) => {
const suivi =
suivent === 0
? 'Aucune table ne suit le défaut'
: `${compte(suivent, 'table suit', 'tables suivent')} le défaut et ` +
`${accord(suivent, 'passe', 'passent')} de ${avant} à ${compte(apres, 'place', 'places')}`;
if (surchargees.length === 0) return `${suivi}.`;
return (
`${suivi} ; ${compte(surchargees.length, 'table surchargée', 'tables surchargées')} ` +
`(${surchargees.join(', ')}) ${accord(surchargees.length, 'ne change', 'ne changent')} pas.`
);
},
],
[
'tables.defaut.total',
({ totalAvant, totalApres, participants }) =>
`Total : ${totalAvant} \u{2192} ${compte(totalApres, 'siège', 'sièges')} ` +
`pour ${compte(participants, 'participant', 'participants')}.`,
],
[
'tables.defaut.refusee',
({ avant, apres }) =>
`Le défaut ne passe pas de ${avant} à ${compte(apres, 'place', 'places')} : ` +
'une table au moins refuse, et rien ne change.',
],
[
'tables.defaut.realignementRefuse',
({ avant, apres }) =>
`Le réalignement sur ${compte(apres, 'place', 'places')} est refusé : une table au moins refuse, ` +
`et rien ne change ; le défaut peut passer de ${avant} à ${apres} sans réaligner.`,
],
// Une table qui refuse le changement : son numéro, puis son refus ; numero
// null pour un refus de l'événement entier, qui ne nomme aucune table.
[
'tables.defaut.refusTable',
({ numero, refus }, texte) => {
const motif = texte(refus.code, refus.details);
return numero === null ? motif : `Table ${numero} \u{2014} ${motif}`;
},
],
['tables.defaut.appliquer', 'Appliquer'],
[
'tables.defaut.appliquerRealigner',
({ n }) => `Appliquer et réaligner ${n <= 1 ? 'la surchargée' : `les ${n} surchargées`}`,
],
['tables.defaut.garder', 'Garder le défaut actuel'],
[
'tables.defaut.confirmationRealigner',
({ n }) =>
`Réaligner ${n <= 1 ? 'la table surchargée' : `les ${n} tables surchargées`} ? ` +
`${accord(n, 'Sa surcharge est perdue', 'Leurs surcharges sont perdues')} :`,
],
['tables.defaut.surchargePerdue', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`],
// --- La liste des tables (§ 4, § 6.1, § 6.2) ----------------------------------
['tables.liste.titre', 'Tables'],
['tables.liste.aucune', 'Aucune table : ajoutez-en une, ronde ou carrée.'],
['tables.liste.table', 'Table'],
['tables.liste.places', 'Places'],
['tables.liste.realigner', 'Réaligner'],
['tables.liste.supprimer', 'Supprimer'],
['tables.forme.ronde', 'Ronde'],
['tables.forme.carree', 'Carrée'],
['tables.etat.suitDefaut', 'Suit le défaut'],
['tables.etat.surchargee', 'Surchargée'],
['tables.creer.ronde', 'Ajouter une table ronde'],
['tables.creer.carree', 'Ajouter une table carrée'],
// Ce qu'un refus SIEGES_RETIRES retire, siège par siège : le nom de la
// personne réservée suit, dans son propre nœud.
['tables.siegeRetire.titre', ({ siege, libelle }) => `Siège ${siege} : titre ${cite(libelle)}`],
[
'tables.siegeRetire.reserve',
({ siege, suspendue }) => `Siège ${siege}${suspendue ? ', réservation suspendue' : ''} :`,
],
// --- La suppression d'une table (§ 6.2) -----------------------------------
[
'tables.suppression.confirmation',
({ numero }) => `Supprimer la table ${numero} ? Ces saisies partent avec elle :`,
],
[
'tables.suppression.reservation',
(reservation) =>
`${reservation.nom} : réservation ${portee(reservation)}` +
`${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`,
],
['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`],
// --- Les tours (§ 2.1 étape 5, § 4.3) ----------------------------------------
['tables.tours.etiquette', 'Nombre de tours'],
[
'tables.tours.confirmation',
({ avant, apres }) => `Passer de ${avant} à ${compte(apres, 'tour', 'tours')} ? Ce changement emporte :`,
],
[
'tables.tours.reservation',
({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`,
],
// Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne
// correspond plus à la configuration.
['tables.touchee.proposition', ({ id }) => `Proposition n° ${id}, qui ne correspondra plus à la configuration`],
['tables.touchee.retenu', 'Placement retenu, qui ne correspondra plus à la configuration'],
]);

View file

@ -0,0 +1,214 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
// réservations, et sa table et son siège à chaque tour du retenu. La fiche
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
// propre nœud de texte, et rien n'y est interprété comme du balisage.
//
// Le statut est celui de statuts (src/application/places.js), lu dans
// l'instance du moteur ; l'itinéraire, celui d'itineraire (grille.js), lu
// tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni
// l'autre, et ne porte aucun contrôle de saisie.
//
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
// grille.
import { getContext } from 'svelte';
import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces, statuts } from '../../application/places.js';
import { cleDeColonne, cleDeStatut, itineraire, reservationsDe } from './grille.js';
let { participant, charge } = $props();
const t = getContext('t');
const ui = getContext('ui');
// Les valeurs de la fiche, sous l'étiquette de leur colonne.
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
const statut = $derived.by(() => {
if (participant.exclu) return t('participants.fiche.statutExclue');
const { parId, erreur } = statuts(charge);
if (erreur !== null || !parId.has(participant.id)) return t('participants.fiche.statutInconnu');
return t(cleDeStatut(parId.get(participant.id)));
});
const reservations = $derived(reservationsDe(charge, participant.id));
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
let element;
$effect(() => {
if (participant.id !== null) element.scrollIntoView({ block: 'nearest' });
});
</script>
<aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}>
<div class="entete">
<h3>{nomAffiche(participant)}</h3>
<button type="button" data-commande="fermerFiche" onclick={() => ui.ouvrirFiche(null)}>
{t('participants.commande.fermerFiche')}
</button>
</div>
<dl>
{#each CHAMPS as cle (cle)}
<dt>{t(cleDeColonne(cle))}</dt>
<dd>{participant[cle] ?? t('participants.fiche.vide')}</dd>
{/each}
<dt>{t('participants.fiche.statut')}</dt>
<dd>{statut}</dd>
</dl>
<table>
<caption>{t('participants.fiche.reservations')}</caption>
{#if reservations.length > 0}
<thead>
<tr>
<th scope="col">{t('participants.fiche.table')}</th>
<th scope="col">{t('participants.fiche.siege')}</th>
<th scope="col">{t('participants.fiche.portee')}</th>
</tr>
</thead>
{/if}
<tbody>
{#each reservations as reservation, rang (rang)}
<tr>
<td>{reservation.numero}</td>
<td>
{#if reservation.siege === null}{t('participants.fiche.sansSiege')}{:else}{reservation.siege}{/if}
</td>
<td>
{#if reservation.portee === 'tous'}
{t('participants.fiche.tousLesTours')}
{:else}
<span>{t('participants.fiche.auTour')}</span> <span>{reservation.tour}</span>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="3">{t('participants.fiche.aucuneReservation')}</td></tr>
{/each}
</tbody>
</table>
{#if participant.exclu && reservations.length > 0}
<p>{t('participants.fiche.suspendues')}</p>
{/if}
{#if etapes === null}
<p>{t('participants.fiche.aucunRetenu')}</p>
{:else}
<table>
<caption>{t('participants.fiche.itineraire')}</caption>
<thead>
<tr>
<th scope="col">{t('participants.fiche.tour')}</th>
<th scope="col">{t('participants.fiche.table')}</th>
<th scope="col">{t('participants.fiche.siege')}</th>
</tr>
</thead>
<tbody>
{#each etapes as etape (etape.tour)}
<tr>
<td>{etape.tour}</td>
{#if etape.reserve}
<td colspan="2">{t('participants.fiche.enReserve')}</td>
{:else}
<td>{etape.numero}</td>
<td>
{#if etape.siege === null}{t('participants.fiche.siegeNonAttribue')}{:else}{etape.siege}{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</aside>
<style>
.fiche {
box-sizing: border-box;
width: 24rem;
max-width: 100%;
padding: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
line-height: 1.4;
overflow-wrap: anywhere;
}
.entete {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--espace-2);
}
h3 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--taille-titre);
}
dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: var(--espace-1) var(--espace-3);
margin: var(--espace-3) 0;
}
dt {
font-weight: bold;
}
dd {
min-width: 0;
margin: 0;
white-space: pre-wrap;
}
table {
width: 100%;
margin: var(--espace-2) 0;
border-collapse: collapse;
table-layout: fixed;
}
caption {
font-weight: bold;
text-align: left;
}
th,
td {
padding: var(--espace-1);
border-bottom: 1px solid var(--couleur-page-filet);
text-align: left;
}
p {
margin: var(--espace-2) 0;
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,221 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le formulaire d'ajout d'une personne (§ 4, § 10.2) : un champ par
// colonne de texte, seul le nom requis. Entrée, ou « Ajouter », exécute
// ajouterParticipant par la séance ; la commande lit chaque saisie — NFC,
// blancs de bord retirés, vide valant null — et refuse un nom vide,
// NOM_REQUIS, que le formulaire pose contre le champ du nom. Un autre
// refus — le mode, l'état du plan — se pose contre « Ajouter ».
//
// L'appartenance est collante : après un ajout, chaque champ se vide sauf
// elle. Une appartenance qui ne diffère d'une existante que par la casse,
// les accents ou les blancs (orthographeExistante, grille.js) est signalée
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
// rien ne fusionne seul.
//
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
import { getContext, onDestroy, tick } from 'svelte';
import { cleNormalisee } from '../../application/csv.js';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { cleDeColonne, orthographeExistante } from './grille.js';
let { participants } = $props();
const t = getContext('t');
const seance = getContext('seance');
const uid = $props.id();
const geste = creerGeste(seance);
// Les champs du formulaire, dans l'ordre des colonnes de la grille.
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
let valeurs = $state({ nom: '', prenom: '', appartenance: '', courriel: '', titrePressenti: '', notes: '' });
let refusNom = $state.raw(null);
let refusAjout = $state.raw(null);
let entreeNom = $state(null);
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
const idRefusNom = `${uid}-refus-nom`;
const idOrthographe = `${uid}-orthographe`;
$effect(() => {
geste.tenir(EPHEMERES.some((cle) => valeurs[cle] !== ''));
});
// Une frappe dans le nom retire le refus qu'il portait.
function surSaisieNom() {
refusNom = null;
}
async function ajouter(evenement) {
evenement.preventDefault();
refusNom = null;
refusAjout = null;
const champs = {
nom: valeurs.nom,
prenom: valeurs.prenom,
appartenance: valeurs.appartenance,
courriel: valeurs.courriel,
titrePressenti: valeurs.titrePressenti,
notes: valeurs.notes,
};
try {
await seance.executer('ajouterParticipant', { champs });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
const refus = { code: erreur.code, details: erreur.details ?? {} };
if (refus.code === 'NOM_REQUIS') refusNom = refus;
else refusAjout = refus;
return;
}
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' };
await tick();
entreeNom?.focus();
}
function reprendre() {
valeurs.appartenance = orthographe;
}
onDestroy(() => geste.liberer());
</script>
<form aria-labelledby={`${uid}-titre`} onsubmit={ajouter}>
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
<div class="champs">
{#each CHAMPS as cle (cle)}
<div class="champ">
<label for={`${uid}-${cle}`}>{t(cleDeColonne(cle))}</label>
{#if cle === 'nom'}
<input
id={`${uid}-${cle}`}
type="text"
data-champ={cle}
bind:value={valeurs.nom}
bind:this={entreeNom}
aria-invalid={refusNom === null ? undefined : 'true'}
aria-describedby={refusNom === null ? undefined : idRefusNom}
oninput={surSaisieNom}
/>
{#if refusNom !== null}
<Refus id={idRefusNom} refus={refusNom} lever={() => (refusNom = null)} />
{/if}
{:else if cle === 'appartenance'}
<input
id={`${uid}-${cle}`}
type="text"
data-champ={cle}
bind:value={valeurs.appartenance}
aria-describedby={orthographe === null ? undefined : idOrthographe}
/>
{#if orthographe !== null}
<div class="signal" id={idOrthographe} role="status" data-avertissement="orthographe">
<p><span>{t('participants.formulaire.orthographe')}</span> <span class="donnee">{orthographe}</span></p>
<button type="button" data-commande="reprendreOrthographe" onclick={reprendre}>
{t('participants.formulaire.reprendre')}
</button>
</div>
{/if}
{:else}
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
{/if}
</div>
{/each}
</div>
<div class="issue">
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button>
{#if refusAjout !== null}
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
{/if}
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
.champs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-3);
}
.champ {
display: flex;
flex-direction: column;
gap: var(--espace-1);
max-width: 18rem;
}
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
input:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
.signal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
font-size: var(--taille-texte);
}
.signal p {
margin: 0;
overflow-wrap: anywhere;
}
.donnee {
font-weight: bold;
}
.issue {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,443 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La grille éditable des participants (§ 10.2) : une ligne par personne
// de lignes, dans l'ordre que lui donne SectionParticipants, une cellule
// par colonne de COLONNES (grille.js), puis les commandes de la ligne.
//
// Au clavier, une seule cellule est dans l'ordre de tabulation, la cellule
// active ; flèches, Tab et Maj+Tab la déplacent (celluleSuivante), et Tab
// après la dernière, Maj+Tab avant la première, rendent le focus au
// navigateur. Entrée ou F2 ouvre l'édition d'une cellule de texte, que la
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident,
// Échap rend la valeur enregistrée. Les notes s'éditent dans une zone de
// texte, qui garde leurs sauts de ligne ; un champ d'une ligne les
// retirerait de toute note retouchée. Une validation qui change la valeur
// exécute modifierParticipant par la séance, sur ce seul champ ; une
// saisie qui se lit comme la valeur enregistrée — en NFC, sans blancs de
// bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
// réintègre la personne. Un refus se pose dans la cellule où le geste a eu
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
//
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
// 10.2) : le texte part à l'état de navigation, intact, et rien ne
// s'écrit ici.
import { getContext, onDestroy, tick } from 'svelte';
import { creerMachineChamp } from '../primitives/champ.js';
import Commande from '../primitives/Commande.svelte';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js';
let { lignes, tri = $bindable(null), supprimer } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const uid = $props.id();
const geste = creerGeste(seance);
// La cellule active : l'identifiant de sa ligne et le rang de sa colonne.
let actif = $state({ id: null, colonne: 0 });
// La cellule en édition, {id, colonne}, et le texte saisi.
let edition = $state.raw(null);
let saisie = $state('');
// Le refus posé sur une cellule : {id, colonne, refus}.
let refusCellule = $state.raw(null);
let machine = null;
let tableau;
let entree = $state(null);
// Le rang de la ligne active ; la première quand l'active n'est plus affichée.
const rangActif = $derived.by(() => {
const rang = lignes.findIndex(({ id }) => id === actif.id);
return rang < 0 ? 0 : rang;
});
const idRefus = (id, colonne) => `${uid}-refus-${id}-${colonne}`;
const enEdition = (id, colonne) => edition !== null && edition.id === id && edition.colonne === colonne;
const refusDe = (id, colonne) =>
refusCellule !== null && refusCellule.id === id && refusCellule.colonne === colonne ? refusCellule.refus : null;
// Le texte d'une valeur enregistrée, vide pour null ; et la valeur que
// l'enregistrement en lirait, pour reconnaître une saisie sans changement.
const texteDe = (valeur) => (valeur === null ? '' : valeur);
const lire = (texte) => ({ valeur: texte.normalize('NFC').trim() });
function trier(colonne) {
tri = tri !== null && tri.colonne === colonne && tri.sens === 'croissant' ? { colonne, sens: 'decroissant' } : { colonne, sens: 'croissant' };
}
const ariaSort = (colonne) => {
if (tri === null || tri.colonne !== colonne) return 'none';
return tri.sens === 'croissant' ? 'ascending' : 'descending';
};
function cellule(id, colonne) {
return tableau?.querySelector(`[data-participant="${id}"] [data-champ="${colonne}"]`) ?? null;
}
async function activer(rang, colonne) {
const ligne = lignes[rang];
actif = { id: ligne.id, colonne };
await tick();
cellule(ligne.id, COLONNES[colonne])?.focus();
}
function refuser(id, colonne, erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusCellule = { id, colonne, refus: { code: erreur.code, details: erreur.details ?? {} } };
}
async function ouvrirEdition(personne, colonne) {
const courant = seance.etat().refus;
if (courant !== null) {
refusCellule = { id: personne.id, colonne, refus: courant };
return;
}
refusCellule = null;
machine = creerMachineChamp({ stockee: texteDe(personne[colonne]), lire });
saisie = machine.saisie;
edition = { id: personne.id, colonne };
await tick();
entree?.focus();
entree?.setSelectionRange(saisie.length, saisie.length);
}
// Ferme l'édition et rend le focus à sa cellule quand il était dans le
// champ.
async function fermerEdition() {
const fermee = edition;
const avaitLeFocus = entree !== null && document.activeElement === entree;
edition = null;
machine = null;
geste.tenir(false);
if (fermee === null || !avaitLeFocus) return;
await tick();
cellule(fermee.id, fermee.colonne)?.focus();
}
// Valide la saisie de la cellule en édition : rien quand elle se lit comme
// la valeur enregistrée, la commande sinon. Refusée, l'édition reste
// ouverte sur la saisie, le refus contre elle.
function valider() {
if (edition === null) return null;
const { id, colonne } = edition;
const rendu = machine.valider();
saisie = machine.saisie;
if (rendu.action !== 'executer') return fermerEdition();
const enCours = machine;
let promesse;
try {
promesse = seance.executer('modifierParticipant', { id, champs: { [colonne]: rendu.valeur } });
} catch (erreur) {
return echouer(enCours, id, colonne, erreur);
}
fermerEdition();
return Promise.resolve(promesse).catch((erreur) => echouer(enCours, id, colonne, erreur));
}
async function echouer(enCours, id, colonne, erreur) {
refuser(id, colonne, erreur);
enCours.echec(refusCellule.refus);
machine = enCours;
saisie = enCours.saisie;
edition = { id, colonne };
geste.tenir(enCours.enCours);
await tick();
entree?.focus();
}
function abandonner() {
machine?.abandonner();
refusCellule = null;
return fermerEdition();
}
function surSaisie(evenement) {
machine.saisir(evenement.currentTarget.value);
saisie = machine.saisie;
refusCellule = null;
geste.tenir(machine.enCours);
}
async function surToucheEntree(evenement, rang) {
const touche = evenement.key;
if (touche === 'Escape') {
evenement.preventDefault();
abandonner();
} else if (touche === 'Enter') {
evenement.preventDefault();
valider();
} else if (touche === 'Tab') {
evenement.preventDefault();
const { id } = edition;
const colonne = COLONNES.indexOf(edition.colonne);
const sens = evenement.shiftKey ? 'Maj+Tab' : 'Tab';
await valider();
await tick();
tabulerDepuis(id, rang, colonne, sens);
}
}
// Tab ou Maj+Tab depuis la cellule validée de la personne id, au rang
// rangAvant avant la validation. La ligne se relit par son identifiant :
// la valeur validée peut l'avoir déplacée au tri, et le rang d'avant
// désignerait une autre personne. Une ligne que le filtre ne montre plus a
// laissé son rang à celle qui la suivait, les autres gardant leur ordre :
// Tab mène à la première cellule de celle-ci, Maj+Tab à la dernière de la
// ligne d'avant ; hors de la grille, le focus reste où il est.
function tabulerDepuis(id, rangAvant, colonne, touche) {
const rang = lignes.findIndex((personne) => personne.id === id);
if (rang >= 0) return deplacer(rang, colonne, touche);
const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 };
if (suivante.ligne < 0 || suivante.ligne >= lignes.length) return false;
activer(suivante.ligne, suivante.colonne);
return true;
}
// La sortie du champ valide, sauf quand l'édition s'est déjà close.
function surSortie() {
if (edition !== null) valider();
}
function deplacer(rang, colonne, touche) {
const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: lignes.length, colonnes: COLONNES.length });
if (suivante === null) return false;
activer(suivante.ligne, suivante.colonne);
return true;
}
function basculerExclusion(personne) {
const nom = personne.exclu ? 'reintegrerParticipant' : 'exclureParticipant';
refusCellule = null;
try {
Promise.resolve(seance.executer(nom, { id: personne.id })).catch((erreur) => refuser(personne.id, 'exclu', erreur));
} catch (erreur) {
refuser(personne.id, 'exclu', erreur);
}
}
function surToucheCellule(evenement, personne, rang, colonne) {
if (evenement.target !== evenement.currentTarget) return;
const touche = evenement.key === 'Tab' && evenement.shiftKey ? 'Maj+Tab' : evenement.key;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Maj+Tab'].includes(touche)) {
if (deplacer(rang, colonne, touche)) evenement.preventDefault();
} else if (touche === 'Enter' || touche === 'F2') {
evenement.preventDefault();
if (COLONNES[colonne] === 'exclu') basculerExclusion(personne);
else ouvrirEdition(personne, COLONNES[colonne]);
} else if (touche === 'Escape') {
refusCellule = null;
}
}
function surCollage(evenement) {
if (evenement.target instanceof HTMLInputElement || evenement.target instanceof HTMLTextAreaElement) return;
const texte = evenement.clipboardData?.getData('text/plain') ?? '';
if (texte === '') return;
evenement.preventDefault();
ui.ouvrirImport({ texte, origine: 'collage' });
ui.allerA('import');
}
onDestroy(() => geste.liberer());
</script>
<div class="defilement">
<table
role="grid"
aria-label={t('participants.grille.titre')}
tabindex={lignes.length === 0 ? 0 : undefined}
bind:this={tableau}
onpaste={surCollage}
>
<thead>
<tr>
{#each COLONNES as colonne (colonne)}
<th scope="col" aria-sort={ariaSort(colonne)}>
<button type="button" data-commande="trier" data-cible={colonne} onclick={() => trier(colonne)}>
<span>{t(cleDeColonne(colonne))}</span>
{#if tri !== null && tri.colonne === colonne}
<span class="sens">{t(tri.sens === 'croissant' ? 'participants.grille.croissant' : 'participants.grille.decroissant')}</span>
{/if}
</button>
</th>
{/each}
<th scope="col">{t('participants.grille.actions')}</th>
</tr>
</thead>
<tbody>
{#each lignes as personne, rang (personne.id)}
<tr data-participant={personne.id}>
{#each COLONNES as colonne, rangColonne (colonne)}
{@const refus = refusDe(personne.id, colonne)}
<td
role="gridcell"
data-champ={colonne}
tabindex={rang === rangActif && rangColonne === actif.colonne ? 0 : -1}
aria-describedby={refus === null || enEdition(personne.id, colonne) ? undefined : idRefus(personne.id, colonne)}
onfocus={(evenement) => {
if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne };
}}
onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)}
>
{#if enEdition(personne.id, colonne) && colonne === 'notes'}
<textarea
rows="3"
aria-label={t(cleDeColonne(colonne))}
value={saisie}
bind:this={entree}
aria-invalid={refus === null ? undefined : 'true'}
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
oninput={surSaisie}
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
onblur={surSortie}
></textarea>
{:else if enEdition(personne.id, colonne)}
<input
type="text"
aria-label={t(cleDeColonne(colonne))}
value={saisie}
bind:this={entree}
aria-invalid={refus === null ? undefined : 'true'}
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
oninput={surSaisie}
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
onblur={surSortie}
/>
{:else if colonne === 'exclu'}
<span class:exclue={personne.exclu}>
{t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')}
</span>
{:else}
<span class="valeur">{personne[colonne] ?? ''}</span>
{/if}
{#if refus !== null}
<Refus id={idRefus(personne.id, colonne)} {refus} lever={() => (refusCellule = null)} />
{/if}
</td>
{/each}
<td role="gridcell">
<div class="commandes">
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
{t('participants.commande.fiche')}
</button>
{#if personne.exclu}
<Commande
cle="participants.commande.reintegrer"
nom="reintegrerParticipant"
executer={() => seance.executer('reintegrerParticipant', { id: personne.id })}
/>
{:else}
<Commande
cle="participants.commande.exclure"
nom="exclureParticipant"
executer={() => seance.executer('exclureParticipant', { id: personne.id })}
/>
{/if}
<Commande cle="participants.commande.supprimer" nom="supprimerParticipant" executer={() => supprimer(personne)} />
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.defilement {
max-width: 100%;
overflow-x: auto;
}
table {
border-collapse: collapse;
font-size: var(--taille-texte);
}
th,
td {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
text-align: left;
vertical-align: top;
}
td {
max-width: 18rem;
overflow-wrap: anywhere;
}
.valeur {
white-space: pre-wrap;
}
/* L'exclusion se lit en texte ; l'italique la double, sans couleur. */
.exclue {
font-style: italic;
}
th button {
display: inline-flex;
gap: var(--espace-1);
padding: 0;
border: none;
background: none;
color: var(--couleur-page-texte);
font: inherit;
font-weight: bold;
cursor: pointer;
}
.sens {
font-weight: normal;
}
.commandes {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-1);
}
.commandes > button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
padding: var(--espace-1);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
}
input[aria-invalid='true'],
textarea[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
td:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
table:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: -2px;
}
</style>

View file

@ -0,0 +1,742 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La liste des participants dans un vrai moteur de rendu (§ 4, § 4.2, § 4.4,
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
// collante, la grille au clavier, le tri, le filtre et le compte,
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
// et l'itinéraire, l'export et le collage. Chaque épreuve monte App par
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
// gestes passent par des entrées que Chromium traite comme réelles
// (test/navigateur/entrees.js). 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 { cdp } from 'vitest/browser';
import { flushSync, tick } from 'svelte';
import { bilanChangementDefaut } from '../../application/bilans.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { apercevoir, decoder, exporterParticipants } from '../../application/csv.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { formeNommee } from '../../stockage/placements.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { visible } from '../../../test/navigateur/rendu.js';
import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url';
import { TABLES_INTERFACE } from '../libelles.js';
import { COLONNES, cleDeStatut } from './grille.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const EVENEMENT = 'Soirée des Merles';
const CLUB = 'Club des Merles';
// Des noms inventés, hors du réservoir du générateur.
const NOMS = [
'Quenouillet',
'Brindelle',
'Calvaille',
'Doucemer',
'Esquirol',
'Faverolle',
'Glanduc',
'Houblonnet',
'Isardin',
'Joncaille',
'Kerbouet',
'Lisonnet',
'Moucheron',
'Nivelette',
'Orbelin',
'Pistachon',
'Rouvrelle',
];
// 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();
const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? [];
expect(recues, 'erreurs reçues par le collecteur').toEqual([]);
});
async function rendu() {
flushSync();
await tick();
}
// Attend que la file de la séance ait passé ce que le geste y a posé, par
// une lecture admise dans tout mode qui n'écrit rien, puis le rendu.
async function attendreSeance(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await rendu();
await rendu();
}
// Un CSV d'épreuve, séparé par « ; » : nom, prénom, appartenance, exclu,
// titre pressenti, notes.
const csv = (personnes) =>
[
'nom;prenom;appartenance;exclu;titre_pressenti;notes',
...personnes.map((p) => [p.nom, p.prenom ?? '', p.appartenance ?? '', p.exclu ? 'oui' : '', p.titre ?? '', p.notes ?? ''].join(';')),
].join('\n');
// Importe un texte par la commande, comme le ferait l'aperçu (I6).
const importer = (m, texte) => m.seance.executer('importerParticipants', { apercu: apercevoir(texte), mode: 'ajouter' });
// Les octets d'un CSV livré, lus par son adresse.
async function octetsLivres(url) {
const reponse = await fetch(url);
return new Uint8Array(await reponse.arrayBuffer());
}
/**
* Un montage, un événement créé — sieges places par défaut, tours tours,
* tables tables rondes, personnes importées —, en écriture, la section des
* participants ouverte.
*/
async function evenementOuvert({ sieges = 8, tours = 2, tables = 0, personnes = [] } = {}) {
const m = await monterApp();
montes.push(m);
await m.seance.creer({ nom: EVENEMENT, date: null, siegesParDefaut: sieges, tours });
await m.seance.passerEnEcriture();
for (let i = 0; i < tables; i += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
if (personnes.length > 0) await importer(m, csv(personnes));
m.ui.allerA('participants');
await rendu();
return m;
}
const base = (m) => m.seance.etat().evenement.base;
const texteJournal = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.journal}`);
const entrees = (m) => lireJournal(texteJournal(m)).entrees;
const texteEtat = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.etat}`);
const participants = (m) => m.seance.etat().charge.participants;
const idDe = (m, nom) => participants(m).find((p) => p.nom === nom).id;
const section = (m) => m.racine.querySelector('[data-section="participants"]');
const formulaire = (m) => section(m).querySelector('form');
const champ = (m, cle) => formulaire(m).querySelector(`[data-champ="${cle}"]`);
const grille = (m) => section(m).querySelector('[role="grid"]');
const lignes = (m) => [...grille(m).querySelectorAll('[data-participant]')];
const idsAffiches = (m) => lignes(m).map((ligne) => Number(ligne.dataset.participant));
const ligne = (m, id) => grille(m).querySelector(`[data-participant="${id}"]`);
const cellule = (m, id, colonne) => ligne(m, id).querySelector(`[data-champ="${colonne}"]`);
const commandeDeLigne = (m, id, nom) => ligne(m, id).querySelector(`[data-commande="${nom}"]`);
const fiche = (m) => section(m).querySelector('[data-fiche]');
const centre = (element) => {
const b = element.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// Un clic réel au centre de l'élément, amené d'abord dans la fenêtre.
async function cliquer(element) {
element.scrollIntoView({ block: 'center' });
const { x, y } = centre(element);
await pointer('presser', x, y);
await pointer('relacher', x, y);
await rendu();
}
// Remplace le texte d'un champ par une saisie réelle : le champ prend le
// focus, son texte est sélectionné, puis remplacé par l'insertion du texte,
// ou effacé.
async function saisir(entree, texte) {
entree.focus();
entree.select();
if (texte === '') await touche('Backspace');
else await cdp().send('Input.insertText', { text: texte });
await rendu();
}
// Ajoute une personne par le formulaire : chaque champ donné saisi, puis
// Entrée dans le champ du nom.
async function ajouter(m, champs) {
for (const [cle, valeur] of Object.entries(champs)) await saisir(champ(m, cle), valeur);
champ(m, 'nom').focus();
await touche('Enter');
await attendreSeance(m);
}
// Maj+Tab réel : touche() ne porte pas de modificateur ; 8 est le masque de
// Maj dans Input.dispatchKeyEvent.
async function majTab() {
const commun = { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9, modifiers: 8 };
await cdp().send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...commun });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
}
// La valeur de la fiche que nomme l'étiquette de clé donnée.
function valeurDeFiche(m, cle) {
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
expect(etiquette, cle).toBeDefined();
return etiquette.nextElementSibling;
}
// Le tableau de la fiche que titre la légende de clé donnée.
const tableauDeFiche = (m, cle) => [...fiche(m).querySelectorAll('table')].find((tableau) => tableau.caption?.textContent === t(cle));
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
function ecart(a, b) {
const ba = a.getBoundingClientRect();
const bb = b.getBoundingClientRect();
const dx = Math.max(0, ba.left - bb.right, bb.left - ba.right);
const dy = Math.max(0, ba.top - bb.bottom, bb.top - ba.bottom);
return Math.max(dx, dy);
}
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
describe('la saisie (§ 4, § 10.2)', () => {
test('seul le nom : « Pervenche » enregistre une personne aux autres champs null, en une entrée ; un nom tapé en NFD est enregistré en NFC', async () => {
const m = await evenementOuvert();
const avant = entrees(m).length;
await ajouter(m, { nom: 'Pervenche' });
expect(entrees(m)).toHaveLength(avant + 1);
const [{ id, ...champs }] = participants(m);
expect(Number.isInteger(id)).toBe(true);
expect(champs).toEqual({
nom: 'Pervenche',
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
expect(champ(m, 'nom').value).toBe('');
const decompose = 'Ombre\u{0301}lle';
expect(decompose.length).toBe(9);
await ajouter(m, { nom: decompose });
expect(entrees(m)).toHaveLength(avant + 2);
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
});
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => {
const m = await evenementOuvert();
const trace = m.trace.length;
const etat = texteEtat(m);
await ajouter(m, { nom: ' ' });
const entree = champ(m, 'nom');
expect(entree.getAttribute('aria-invalid')).toBe('true');
const message = document.getElementById(entree.getAttribute('aria-describedby'));
expect(message).not.toBeNull();
expect(message.textContent).toContain(t('NOM_REQUIS', {}));
expect(visible(message)).toBe(true);
expect(ecart(entree, message)).toBeLessThanOrEqual(16);
aucuneFenetre();
expect(m.trace.length).toBe(trace);
expect(texteEtat(m)).toBe(etat);
expect(participants(m)).toHaveLength(0);
});
test('appartenance collante : trois personnes portent « Club des Merles » ; le champ vidé, la quatrième n’en porte aucune', async () => {
const m = await evenementOuvert();
await ajouter(m, { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB });
expect(champ(m, 'appartenance').value).toBe(CLUB);
expect(champ(m, 'prenom').value).toBe('');
await ajouter(m, { nom: 'Pervenche' });
await ajouter(m, { nom: 'Lacasse' });
expect(participants(m).map((p) => [p.nom, p.prenom, p.appartenance])).toEqual([
['Ombrelle', 'Iris', CLUB],
['Pervenche', null, CLUB],
['Lacasse', null, CLUB],
]);
await saisir(champ(m, 'appartenance'), '');
await ajouter(m, { nom: 'Grisaille' });
expect(participants(m).at(-1).appartenance).toBeNull();
});
test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
const trace = m.trace.length;
await saisir(champ(m, 'appartenance'), 'club des merles');
const signal = formulaire(m).querySelector('[data-avertissement]');
expect(signal).not.toBeNull();
expect(visible(signal)).toBe(true);
expect(signal.textContent).toContain(CLUB);
expect(champ(m, 'appartenance').getAttribute('aria-describedby')).toContain(signal.id);
expect(champ(m, 'appartenance').value).toBe('club des merles');
await cliquer(signal.querySelector('[data-commande="reprendreOrthographe"]'));
expect(champ(m, 'appartenance').value).toBe(CLUB);
expect(formulaire(m).querySelector('[data-avertissement]')).toBeNull();
expect(m.trace.length).toBe(trace);
});
});
describe('la grille (§ 10.2, § 19.3)', () => {
const TROIS = [
{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB },
{ nom: 'Pervenche', prenom: 'Théo', appartenance: 'CLUB DES MERLES' },
{ nom: 'Lacasse', prenom: 'Ondine' },
{ nom: 'Grisaille' },
];
test('au clavier seul : le courriel d’Ondine Lacasse changé en une entrée ; Échap rend la valeur, valider sans changement n’écrit rien', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const ondine = idDe(m, 'Lacasse');
const rang = idsAffiches(m).indexOf(ondine);
const colonne = COLONNES.indexOf('courriel');
expect(rang).toBeGreaterThan(0);
expect(colonne).toBeGreaterThan(0);
const premiere = grille(m).querySelector('[tabindex="0"]');
expect(premiere).toBe(cellule(m, idsAffiches(m)[0], COLONNES[0]));
premiere.focus();
for (let i = 0; i < rang; i += 1) await touche('ArrowDown');
for (let i = 0; i < colonne; i += 1) await touche('ArrowRight');
await rendu();
const cible = cellule(m, ondine, 'courriel');
expect(document.activeElement).toBe(cible);
const avant = entrees(m).length;
await touche('Enter');
await rendu();
for (const caractere of 'ondine@exemple.test') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(entrees(m).at(-1).libelle).toBe('Modifié Ondine Lacasse : courriel');
expect(participants(m).find((p) => p.id === ondine).courriel).toBe('ondine@exemple.test');
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
// Échap en cours d'édition : rien ne s'écrit, la valeur d'origine revient.
const trace = m.trace.length;
cellule(m, ondine, 'courriel').focus();
await touche('Enter');
await rendu();
for (const caractere of 'zz') await touche(caractere);
await touche('Escape');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
// Valider sans changement : aucune commande ne part, donc aucun refus
// SANS_EFFET ne se pose sur la cellule, et rien ne s'écrit.
const executer = vi.spyOn(m.seance, 'executer');
cellule(m, ondine, 'courriel').focus();
await touche('Enter');
await rendu();
await touche('Enter');
await attendreSeance(m);
expect(executer).not.toHaveBeenCalled();
expect(cellule(m, ondine, 'courriel').querySelector('[data-refus]')).toBeNull();
expect(cellule(m, ondine, 'courriel').querySelector('[aria-invalid]')).toBeNull();
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
expect(entrees(m)).toHaveLength(avant + 1);
expect(m.trace.length).toBe(trace);
});
test('Tab après une saisie qui déplace la ligne au tri mène à la cellule suivante de la même personne', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
const premier = idsAffiches(m)[0];
expect(participants(m).find((p) => p.id === premier).nom).toBe('Grisaille');
cellule(m, premier, 'nom').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, premier, 'nom').querySelector('input'), 'Zed');
await touche('Tab');
await attendreSeance(m);
expect(participants(m).find((p) => p.id === premier).nom).toBe('Zed');
expect(idsAffiches(m).at(-1)).toBe(premier);
expect(document.activeElement).toBe(cellule(m, premier, COLONNES[1]));
});
test('Tab après une saisie qui retire la ligne du filtre mène à la première cellule de la personne qui la suivait', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
const [premier, second] = idsAffiches(m);
expect(idsAffiches(m)).toHaveLength(2);
cellule(m, premier, 'appartenance').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, premier, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
await touche('Tab');
await attendreSeance(m);
expect(idsAffiches(m)).toEqual([second]);
expect(document.activeElement).toBe(cellule(m, second, COLONNES[0]));
});
test('Maj+Tab après une saisie qui retire la ligne du filtre mène à la dernière cellule de la personne qui la précédait', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
const [premier, second] = idsAffiches(m);
expect(idsAffiches(m)).toHaveLength(2);
cellule(m, second, 'appartenance').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, second, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
await majTab();
await attendreSeance(m);
expect(idsAffiches(m)).toEqual([premier]);
expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1)));
});
test('une note de deux lignes, retouchée dans la grille, garde son saut de ligne', async () => {
const m = await evenementOuvert();
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine', notes: 'arrive tard\nrepart tôt' } });
await rendu();
const id = idDe(m, 'Lacasse');
const avant = entrees(m).length;
cellule(m, id, 'notes').focus();
await touche('Enter');
await rendu();
for (const caractere of ' x') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
});
test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const parNom = (sens) =>
[...participants(m)]
.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : a.id - b.id) * (sens === 'croissant' ? 1 : -1))
.map((p) => p.id);
const merles = participants(m)
.filter((p) => p.appartenance !== null)
.map((p) => p.id);
expect(merles).toHaveLength(2);
for (const mode of ['ecriture', 'lecture']) {
if (mode === 'lecture') {
await m.seance.passerEnLecture();
await rendu();
}
const journal = texteJournal(m);
const etat = texteEtat(m);
const trace = m.trace.length;
const trier = section(m).querySelector('[data-commande="trier"][data-cible="nom"]');
await cliquer(trier);
expect(idsAffiches(m), mode).toEqual(parNom('croissant'));
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
expect(idsAffiches(m), mode).toEqual(parNom('decroissant'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
expect([...idsAffiches(m)].sort((a, b) => a - b), mode).toEqual(merles);
expect(section(m).querySelector('output').textContent).toBe(t('participants.grille.compte', { total: 4, affiches: 2 }));
await saisir(section(m).querySelector('[data-champ="filtre"]'), '');
expect(idsAffiches(m)).toHaveLength(4);
await attendreSeance(m);
expect(texteJournal(m), mode).toBe(journal);
expect(texteEtat(m), mode).toBe(etat);
expect(m.trace.length, mode).toBe(trace);
}
});
test('après l’import des cas limites, le compte affiché égale le nombre de lignes importées ; une personne exclue reste visible', async () => {
const m = await evenementOuvert();
const octets = await octetsLivres(CAS_LIMITES);
const { resume } = await m.seance.executer('importerParticipants', {
apercu: apercevoir(decoder(octets).texte),
mode: 'ajouter',
});
await rendu();
expect(resume.ajoutes).toBeGreaterThan(0);
expect(participants(m)).toHaveLength(resume.ajoutes);
expect(lignes(m)).toHaveLength(resume.ajoutes);
expect(section(m).querySelector('output').textContent).toBe(
t('participants.grille.compte', { total: resume.ajoutes, affiches: resume.ajoutes }),
);
const exclue = participants(m).find((p) => p.exclu);
expect(exclue).toBeDefined();
expect(cellule(m, exclue.id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
});
test('exclusion : douze personnes, en exclure une → douze lignes, la sienne porte « exclue » en texte ; le bilan compte onze participants', async () => {
const m = await evenementOuvert({ personnes: NOMS.slice(0, 12).map((nom) => ({ nom })) });
const id = idDe(m, NOMS[3]);
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, id, 'exclureParticipant'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(lignes(m)).toHaveLength(12);
expect(cellule(m, id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
const autres = participants(m).filter((p) => p.id !== id);
expect(autres).toHaveLength(11);
// La marque est un texte, distinct de celui d'une personne présente.
for (const p of autres) expect(cellule(m, p.id, 'exclu').textContent.trim()).toBe(t('participants.grille.presente'));
expect(t('participants.grille.presente')).not.toBe(t('participants.grille.exclue'));
expect(bilanChangementDefaut(m.seance.etat().charge, 8).participants).toBe(11);
});
test('notes jamais interprétées : « <b>x</b> & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément <b>', async () => {
const m = await evenementOuvert();
const notes = '<b>x</b> & =1+1';
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', notes } });
await rendu();
const id = idDe(m, 'Ombrelle');
expect(cellule(m, id, 'notes').textContent.trim()).toBe(notes);
m.ui.ouvrirFiche(id);
await rendu();
expect(valeurDeFiche(m, 'participants.colonne.notes').textContent).toBe(notes);
expect(section(m).querySelectorAll('b')).toHaveLength(0);
});
test('titre pressenti : visible dans la grille et sur la fiche', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Pervenche', prenom: 'Théo', titre: 'hôtesse' }] });
const id = idDe(m, 'Pervenche');
expect(cellule(m, id, 'titrePressenti').textContent.trim()).toBe('hôtesse');
cellule(m, id, 'titrePressenti').scrollIntoView({ block: 'center', inline: 'center' });
expect(visible(cellule(m, id, 'titrePressenti'))).toBe(true);
m.ui.ouvrirFiche(id);
await rendu();
const valeur = valeurDeFiche(m, 'participants.colonne.titrePressenti');
expect(valeur.textContent).toBe('hôtesse');
expect(visible(valeur)).toBe(true);
});
test('supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien', async () => {
const m = await evenementOuvert({ tables: 1, personnes: [{ nom: 'Ombrelle' }, { nom: 'Pervenche' }] });
const iris = idDe(m, 'Ombrelle');
const theo = idDe(m, 'Pervenche');
const table = m.seance.etat().charge.tables[0];
await m.seance.executer('reserver', { participant: theo, table: table.id, siege: 2, portee: 'tous', tour: null });
await rendu();
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, iris, 'supprimerParticipant'));
await attendreSeance(m);
aucuneFenetre();
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).map((p) => p.id)).toEqual([theo]);
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
const fenetre = document.querySelector('dialog[open]');
expect(fenetre).not.toBeNull();
const elements = [...fenetre.querySelectorAll('li')].map((li) => li.textContent);
expect(elements).toEqual([
t('participants.suppression.reservation', { numero: table.numero, siege: 2, portee: 'tous', tour: null }),
]);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).map((p) => p.id)).toEqual([theo]);
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
await cliquer(document.querySelector('dialog[open] [data-commande="confirmer"]'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 2);
expect(participants(m)).toEqual([]);
});
});
describe('le manque de places (§ 5.9, § 4.4)', () => {
test('deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
const m = await evenementOuvert({ tables: 2, personnes: NOMS.slice(0, 16).map((nom) => ({ nom })) });
const executer = vi.spyOn(m.seance, 'executer');
const avant = entrees(m).length;
await ajouter(m, { nom: NOMS[16] });
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m)).toHaveLength(17);
expect(executer).toHaveBeenCalledTimes(1);
expect(executer.mock.calls[0][0]).toBe('ajouterParticipant');
const rendus = await executer.mock.results[0].value;
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
expect(attendu).toBe(1);
const manque = rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
expect(manque?.details.placesManquantes).toBe(attendu);
const etat = m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
expect(etat?.details.placesManquantes).toBe(attendu);
});
test('deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
const personnes = NOMS.slice(0, 17).map((nom, rang) => ({ nom, exclu: rang === 5 }));
const m = await evenementOuvert({ tables: 2, personnes });
const id = idDe(m, NOMS[5]);
const executer = vi.spyOn(m.seance, 'executer');
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, id, 'reintegrerParticipant'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).find((p) => p.id === id).exclu).toBe(false);
expect(executer).toHaveBeenCalledTimes(1);
expect(executer.mock.calls[0]).toEqual(['reintegrerParticipant', { id }]);
const rendus = await executer.mock.results[0].value;
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
expect(attendu).toBe(1);
expect(rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(attendu);
expect(m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(
attendu,
);
});
});
describe('la fiche (§ 4.2, § 7.4)', () => {
test('statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut', async () => {
const m = await evenementOuvert({
sieges: 4,
tours: 3,
tables: 2,
personnes: [
{ nom: 'Ombrelle', prenom: 'Iris' },
{ nom: 'Pervenche', prenom: 'Théo' },
{ nom: 'Lacasse', prenom: 'Ondine' },
{ nom: 'Grisaille' },
],
});
const [t1, t2] = m.seance.etat().charge.tables.map(({ id }) => id);
const [a, b, c, d] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille'].map((nom) => idDe(m, nom));
const reserver = (participant, table, portee, tour = null) =>
m.seance.executer('reserver', { participant, table, siege: null, portee, tour });
await reserver(a, t1, 'tous');
for (const tour of [1, 2, 3]) await reserver(b, t1, 'tour', tour);
await reserver(c, t1, 'tour', 1);
await reserver(c, t2, 'tour', 2);
await rendu();
const instance = normaliser(configurationDepuisCharge(m.seance.etat().charge));
const NOMS_STATUTS = new Map([
[STATUT.ANCRE, 'ancre'],
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
[STATUT.MOBILE, 'mobile'],
]);
const attendus = [
[a, 'ancre'],
[b, 'ancre'],
[c, 'partiel'],
[d, 'mobile'],
];
for (const [id, statut] of attendus) {
expect(NOMS_STATUTS.get(instance.statut[instance.indexDe.get(id)])).toBe(statut);
m.ui.ouvrirFiche(id);
await rendu();
expect(fiche(m).dataset.fiche).toBe(String(id));
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
}
});
test('ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée', async () => {
const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: NOMS.slice(0, 7).map((nom) => ({ nom })) });
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
const [proposition] = m.seance.etat().charge.propositions;
await m.seance.executer('retenirProposition', { id: proposition.id });
// Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire.
await m.seance.executer('retirerDuTour', { tour: 2, participant: idDe(m, NOMS[2]) });
await rendu();
const charge = m.seance.etat().charge;
const forme = formeNommee(charge.retenu, charge);
expect(forme.some((ligneForme) => ligneForme.reserve)).toBe(true);
const id = idDe(m, NOMS[2]);
await cliquer(commandeDeLigne(m, id, 'fiche'));
expect(m.ui.etat().fiche).toBe(id);
expect(fiche(m).dataset.fiche).toBe(String(id));
expect(visible(fiche(m))).toBe(true);
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
expect(fiche(m)).toBeNull();
expect(m.ui.etat().section).toBe('participants');
// Chaque personne : l'itinéraire de la fiche, ligne par ligne.
expect(charge.participants).toHaveLength(7);
for (const { id: personne } of charge.participants) {
m.ui.ouvrirFiche(personne);
await rendu();
expect(visible(fiche(m))).toBe(true);
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
expect(tableau).toBeDefined();
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
const attendus = forme
.filter((l) => l.participant === personne)
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
expect(attendus).toHaveLength(3);
expect(lus).toEqual(attendus);
}
});
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => {
const long = 'Ombrelle'.repeat(25);
expect(long).toHaveLength(200);
const m = await evenementOuvert();
await m.seance.executer('ajouterParticipant', { champs: { nom: long } });
const id = idDe(m, long);
m.ui.ouvrirFiche(id);
await rendu();
const porteur = fiche(m);
const valeur = valeurDeFiche(m, 'participants.colonne.nom');
expect(valeur.textContent).toBe(long);
expect(porteur.textContent).not.toContain('\u{2026}');
expect(porteur.scrollWidth).toBeLessThanOrEqual(porteur.clientWidth);
expect(valeur.scrollWidth).toBeLessThanOrEqual(valeur.clientWidth);
const interligne = Number.parseFloat(getComputedStyle(valeur).lineHeight);
expect(interligne).toBeGreaterThan(0);
expect(valeur.getBoundingClientRect().height).toBeGreaterThanOrEqual(2 * interligne);
});
});
describe('export et collage (§ 10.2)', () => {
test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
const m = await evenementOuvert();
const octets = await octetsLivres(CAS_LIMITES);
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(octets).texte), mode: 'ajouter' });
await rendu();
const releves = [];
m.fs.enregistrerSous = async (nomPropose, contenu) => {
releves.push({ nomPropose, octets: contenu });
return nomPropose;
};
const avant = participants(m);
expect(avant.some((p) => p.exclu)).toBe(true);
const trace = m.trace.length;
await cliquer(section(m).querySelector('[data-commande="exporter"]'));
await attendreSeance(m);
expect(releves).toHaveLength(1);
expect(releves[0].nomPropose).toBe(`${base(m)}-participants.csv`);
expect([...releves[0].octets]).toEqual([...exporterParticipants(avant)]);
expect(m.trace.length).toBe(trace);
await m.seance.fermer();
await m.seance.creer({ nom: 'Soirée des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
await m.seance.passerEnEcriture();
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(releves[0].octets).texte), mode: 'ajouter' });
const sansId = (liste) => liste.map(({ id, ...champs }) => champs);
expect(avant.length).toBeGreaterThan(0);
expect(sansId(participants(m))).toEqual(sansId(avant));
});
test('un collage sur la grille ouvre l’aperçu d’import : importEnAttente porte le texte intact, origine collage, et la section import ; zéro écriture', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle' }] });
const etats = [];
const desabonner = m.ui.subscribe((etat) => etats.push(etat));
const trace = m.trace.length;
const texte = 'nom;prénom\r\nPervenche;Théo\n"Lacasse; Ondine";\u{00A0}x\t';
grille(m).querySelector('[tabindex="0"]').focus();
const presse = new DataTransfer();
presse.setData('text/plain', texte);
grille(m).querySelector('[tabindex="0"]').dispatchEvent(
new ClipboardEvent('paste', { clipboardData: presse, bubbles: true, cancelable: true }),
);
await rendu();
desabonner();
expect(etats.some((etat) => etat.importEnAttente?.texte === texte && etat.importEnAttente.origine === 'collage' && etat.section === 'import')).toBe(
true,
);
expect(m.ui.etat().section).toBe('import');
await attendreSeance(m);
expect(m.trace.length).toBe(trace);
});
});

View file

@ -2,12 +2,160 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des participants sous sa forme minimale : son titre, tiré des libellés de la coquille.
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : le
// formulaire d'ajout, le filtre, le compte, l'export, la grille et la
// fiche ouverte. Les lignes de la grille sont celles de lignesGrille
// (grille.js) sur la liste de la séance, le tri et le filtre de la
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
// en lecture. La fiche ouverte est celle de l'état de navigation, ui.fiche.
//
// Supprimer une personne qui porte des réservations ouvre la fenêtre à
// confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
// déjà tout geste modifiant, la commande part et la séance répond. L'export
// remet les octets d'exporterParticipants à facade.enregistrerSous, sous le
// nom ‹base›-participants.csv (nomExport).
import { getContext } from 'svelte';
import { cleNormalisee, exporterParticipants } from '../../application/csv.js';
import { nomAffiche } from '../../application/personnes.js';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Fiche from './Fiche.svelte';
import Formulaire from './Formulaire.svelte';
import Grille from './Grille.svelte';
import { lignesGrille, nomExport, reservationsDe } from './grille.js';
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const facade = getContext('facade');
const uid = $props.id();
let tri = $state.raw(null);
let filtre = $state('');
// La suppression qui attend sa confirmation : {participant, reservations}.
let aSupprimer = $state.raw(null);
const charge = $derived($seance.charge);
const participants = $derived(charge?.participants ?? []);
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
const personneFiche = $derived($ui.fiche === null ? null : (participants.find(({ id }) => id === $ui.fiche) ?? null));
async function exporter() {
const { evenement, charge: courante } = seance.etat();
await facade.enregistrerSous(nomExport(evenement.base), exporterParticipants(courante.participants));
}
function supprimer(participant) {
const reservations = reservationsDe(seance.etat().charge, participant.id);
if (seance.etat().refus === null && reservations.length > 0) {
aSupprimer = { participant, reservations };
return null;
}
return seance.executer('supprimerParticipant', { id: participant.id });
}
async function confirmerSuppression() {
await seance.executer('supprimerParticipant', { id: aSupprimer.participant.id });
aSupprimer = null;
}
</script>
<section data-section="participants">
<h2>{t('coquille.section.participants')}</h2>
<Formulaire {participants} />
<div class="outils">
<div class="filtre">
<label for={`${uid}-filtre`}>{t('participants.grille.filtre')}</label>
<input id={`${uid}-filtre`} type="search" data-champ="filtre" bind:value={filtre} />
</div>
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
<Commande cle="participants.commande.exporter" nom="exporter" executer={exporter} />
</div>
<div class="corps">
<div class="liste">
<Grille {lignes} bind:tri {supprimer} />
{#if participants.length === 0}
<p>{t('participants.grille.vide')}</p>
{:else if lignes.length === 0}
<p>{t('participants.grille.aucunResultat')}</p>
{/if}
</div>
{#if personneFiche !== null}
<Fiche participant={personneFiche} {charge} />
{/if}
</div>
{#if aSupprimer !== null}
<Confirmation
titre="participants.suppression.titre"
details={{ personne: nomAffiche(aSupprimer.participant) }}
elements={aSupprimer.reservations.map((reservation) => t('participants.suppression.reservation', reservation))}
surConfirmer={confirmerSuppression}
surAnnuler={() => (aSupprimer = null)}
/>
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
.outils {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--espace-3);
}
.filtre {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
output {
font-size: var(--taille-texte);
}
.corps {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-4);
}
.liste {
flex: 1 1 32rem;
min-width: 0;
}
p {
margin: var(--espace-2) 0;
}
</style>

View file

@ -0,0 +1,234 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2), que
// câblent SectionParticipants, Grille, Formulaire et Fiche : les colonnes de
// la grille, ses lignes triées et filtrées, la cellule qu'atteint une
// touche, l'orthographe d'appartenance à reprendre, l'itinéraire d'une
// personne dans le retenu et ses réservations. Aucune fonction ne modifie ce
// qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de
// src/application/csv.js.
/** Les colonnes de la grille, propriétés d'un participant, dans l'ordre des
* colonnes de l'export : « l'ordre de la grille » que relit l'import. */
export const COLONNES = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'exclu', 'notes']);
// Les colonnes de texte : toutes, exclu excepté.
const TEXTES = COLONNES.filter((colonne) => colonne !== 'exclu');
// La clé du libellé de chaque colonne, et celle de chaque statut calculé.
const CLES_COLONNES = new Map([
['nom', 'participants.colonne.nom'],
['prenom', 'participants.colonne.prenom'],
['appartenance', 'participants.colonne.appartenance'],
['courriel', 'participants.colonne.courriel'],
['titrePressenti', 'participants.colonne.titrePressenti'],
['exclu', 'participants.colonne.exclu'],
['notes', 'participants.colonne.notes'],
]);
const CLES_STATUTS = new Map([
['ancre', 'participants.statut.ancre'],
['partiel', 'participants.statut.partiel'],
['mobile', 'participants.statut.mobile'],
]);
/**
* La clé du libellé d'une colonne de COLONNES.
*
* @param {string} colonne
* @returns {string}
* @throws {RangeError} hors de COLONNES
*/
export function cleDeColonne(colonne) {
const cle = CLES_COLONNES.get(colonne);
if (cle === undefined) throw new RangeError(`colonne inconnue : ${JSON.stringify(colonne)}`);
return cle;
}
/**
* La clé du libellé d'un statut calculé (statuts, src/application/places.js) :
* 'ancre', 'partiel' ou 'mobile'.
*
* @param {string} statut
* @returns {string}
* @throws {RangeError} un autre statut
*/
export function cleDeStatut(statut) {
const cle = CLES_STATUTS.get(statut);
if (cle === undefined) throw new RangeError(`statut inconnu : ${JSON.stringify(statut)}`);
return cle;
}
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const SENS = new Map([
['croissant', 1],
['decroissant', -1],
]);
/**
* Les lignes affichées : une ligne reste quand la clé normalisée d'un de ses
* champs texte non null contient celle du filtre — un champ null n'est
* jamais passé à cleNormalisee, qui lève sur lui —, puis tri par colonne,
* croissant ou décroissant, sur la clé normalisée (exclu : faux avant vrai),
* un champ null après toute valeur dans les deux sens, départagé par
* identifiant croissant. Sans tri, l'ordre est celui des identifiants
* croissants ; un filtre dont la clé est vide garde tout.
*
* @param {Array<Object>} participants
* @param {{tri: null|{colonne: string, sens: 'croissant'|'decroissant'}, filtre: string}} vue
* @param {(texte: string) => string} cleNormalisee
* @returns {Array<Object>} les participants reçus, dans l'ordre de la grille
* @throws {RangeError} une colonne hors de COLONNES, un autre sens
*/
export function lignesGrille(participants, { tri, filtre }, cleNormalisee) {
const signe = tri === null ? 1 : SENS.get(tri.sens);
if (tri !== null && (!COLONNES.includes(tri.colonne) || signe === undefined)) {
throw new RangeError(`tri inconnu : ${JSON.stringify(tri)}`);
}
const cherche = cleNormalisee(filtre ?? '');
const retenues =
cherche === ''
? [...participants]
: participants.filter((p) => TEXTES.some((colonne) => p[colonne] !== null && cleNormalisee(p[colonne]).includes(cherche)));
// La valeur de tri de chaque ligne, calculée une fois : null reste null.
const colonne = tri?.colonne ?? null;
const valeurDe = (p) => {
if (colonne === null) return null;
if (colonne === 'exclu') return p.exclu ? 1 : 0;
return p[colonne] === null ? null : cleNormalisee(p[colonne]);
};
const valeurs = new Map(retenues.map((p) => [p, valeurDe(p)]));
const comparer = (a, b) => {
const va = valeurs.get(a);
const vb = valeurs.get(b);
if (va !== vb) {
if (va === null) return 1;
if (vb === null) return -1;
const ecart = typeof va === 'number' ? va - vb : comparerTextes(va, vb);
if (ecart !== 0) return ecart * signe;
}
return a.id - b.id;
};
return retenues.sort(comparer);
}
/**
* La cellule qu'atteint une touche depuis la cellule position, dans une
* grille de lignes × colonnes. Les flèches bougent d'une cellule et restent
* sur place au bord ; Tab avance dans la ligne puis passe au début de la
* suivante, Maj+Tab recule de même. Tab après la dernière cellule, Maj+Tab
* avant la première, rendent null : le focus quitte la grille, et le
* navigateur le mène à l'élément suivant. Une autre touche ne bouge rien.
* Une grille sans ligne n'a pas de cellule : null.
*
* @param {{ligne: number, colonne: number}} position
* @param {string} touche 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
* 'Tab' ou 'Maj+Tab'
* @param {{lignes: number, colonnes: number}} taille
* @returns {{ligne: number, colonne: number}|null}
*/
export function celluleSuivante({ ligne, colonne }, touche, { lignes, colonnes }) {
if (lignes === 0 || colonnes === 0) return null;
const borner = (valeur, max) => (valeur < 0 ? 0 : valeur > max ? max : valeur);
const rang = ligne * colonnes + colonne;
switch (touche) {
case 'ArrowUp':
return { ligne: borner(ligne - 1, lignes - 1), colonne };
case 'ArrowDown':
return { ligne: borner(ligne + 1, lignes - 1), colonne };
case 'ArrowLeft':
return { ligne, colonne: borner(colonne - 1, colonnes - 1) };
case 'ArrowRight':
return { ligne, colonne: borner(colonne + 1, colonnes - 1) };
case 'Tab':
return rang + 1 >= lignes * colonnes ? null : { ligne: Math.floor((rang + 1) / colonnes), colonne: (rang + 1) % colonnes };
case 'Maj+Tab':
return rang === 0 ? null : { ligne: Math.floor((rang - 1) / colonnes), colonne: (rang - 1) % colonnes };
default:
return { ligne, colonne };
}
}
/**
* L'orthographe déjà portée par un autre participant pour une appartenance
* saisie, à proposer sans fusionner : la première, dans l'ordre reçu, dont
* la clé normalisée égale celle de la saisie. null quand la saisie est
* nouvelle, vide, ou identique à une appartenance portée — lue comme la
* saisie l'enregistre, en NFC, sans ses blancs de bord. Une appartenance
* null ne se compare pas.
*
* @param {string|null} saisie
* @param {Array<{appartenance: string|null}>} participants
* @param {(texte: string) => string} cleNormalisee
* @returns {string|null}
*/
export function orthographeExistante(saisie, participants, cleNormalisee) {
if (saisie === null) return null;
const texte = saisie.normalize('NFC').trim();
if (texte === '') return null;
const portees = participants.map(({ appartenance }) => appartenance).filter((a) => a !== null);
if (portees.includes(texte)) return null;
const cle = cleNormalisee(texte);
return portees.find((appartenance) => cleNormalisee(appartenance) === cle) ?? null;
}
/**
* L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque
* tour, de 1 à tours, la table (numéro affiché) et le siège — null quand le
* retenu n'attribue pas les sièges —, ou la réserve. Lu dans etatDuTour(r)
* = etatPlaces(charge, {tour: r}), jamais recalculé ; null quand la source
* n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni
* assise ni en réserve — une exclue, absente de tout tour.
*
* @param {number} id
* @param {number} tours
* @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null}
*/
export function itineraire(id, tours, etatDuTour) {
const etapes = [];
for (let tour = 1; tour <= tours; tour += 1) {
const etat = etatDuTour(tour);
if (etat.source !== 'retenu') return null;
let etape = null;
for (const table of etat.tables) {
const chaise = table.chaises.find(({ occupant }) => occupant === id);
if (chaise !== undefined) {
etape = { tour, numero: table.numero, siege: etat.siegesAttribues ? chaise.siege : null };
break;
}
}
if (etape === null && etat.reserve.includes(id)) etape = { tour, reserve: true };
if (etape === null) return null;
etapes.push(etape);
}
return etapes;
}
/**
* Les réservations d'une personne, pour la fiche et la fenêtre de
* suppression : dans l'ordre de la charge, chacune avec le numéro affiché
* de sa table.
*
* @param {{tables: Array<{id: number, numero: number}>, reservations: Array<Object>}} charge
* @param {number} id
* @returns {Array<{numero: number, siege: number|null, portee: 'tous'|'tour', tour: number|null}>}
*/
export function reservationsDe(charge, id) {
const numeroDe = new Map(charge.tables.map((table) => [table.id, table.numero]));
return charge.reservations
.filter(({ participant }) => participant === id)
.map(({ table, siege, portee, tour }) => ({ numero: numeroDe.get(table), siege, portee, tour }));
}
/**
* Le nom proposé à l'enregistrement de l'export de la liste (§ 10.2) :
* ‹base›-participants.csv, base étant celle de l'événement ouvert.
*
* @param {string} base
* @returns {string}
*/
export const nomExport = (base) => `${base}-participants.csv`;

View file

@ -0,0 +1,305 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2) : les
// lignes de la grille, triées et filtrées, la navigation au clavier,
// l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne dans
// le retenu, ses réservations, et l'ordre des colonnes, celui de l'export.
// Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js';
import {
COLONNES,
celluleSuivante,
cleDeColonne,
cleDeStatut,
itineraire,
lignesGrille,
nomExport,
orthographeExistante,
reservationsDe,
} from './grille.js';
// Un participant ; les champs absents valent null, exclu faux.
const personne = (id, nom, champs = {}) => ({
id,
nom,
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...champs,
});
const ids = (lignes) => lignes.map(({ id }) => id);
describe('lignesGrille : tri et filtre (§ 10.2)', () => {
const participants = [
personne(4, 'Pervenche', { prenom: 'Théo', appartenance: 'CLUB DES MERLES' }),
personne(1, 'Ombrelle', { prenom: 'Iris', appartenance: 'Club des Merles' }),
personne(3, 'Lacasse', { prenom: 'Ondine' }),
personne(2, 'Ombrelle', { prenom: 'Anselme' }),
personne(5, 'Grisaille', { exclu: true }),
];
test('sans tri ni filtre, par identifiant croissant', () => {
assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre: '' }, cleNormalisee)), [1, 2, 3, 4, 5]);
});
test('par nom, croissant puis décroissant ; deux noms égaux se départagent par identifiant croissant', () => {
const croissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(croissant), [5, 3, 1, 2, 4]);
const decroissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(decroissant), [4, 1, 2, 3, 5]);
});
test('la clé normalisée trie : casse et accents ignorés', () => {
const liste = [personne(1, 'Émeraude'), personne(2, 'elan'), personne(3, 'Ezra')];
const lignes = lignesGrille(liste, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(lignes), [2, 1, 3]);
});
test('exclu : faux avant vrai en croissant, vrai avant faux en décroissant', () => {
const croissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'croissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(croissant), [1, 2, 3, 4, 5]);
const decroissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(decroissant), [5, 1, 2, 3, 4]);
});
test('filtre « merles » : « Club des Merles » et « CLUB DES MERLES » restent, la clé normalisée du filtre comprise', () => {
for (const filtre of ['merles', 'MERLES', ' Mérles ']) {
assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre }, cleNormalisee)), [1, 4], filtre);
}
});
test('le filtre lit chaque champ texte : prénom, courriel, titre pressenti, notes', () => {
const liste = [
personne(1, 'Ombrelle', { courriel: 'iris@exemple.test' }),
personne(2, 'Pervenche', { titrePressenti: 'hôtesse' }),
personne(3, 'Lacasse', { notes: 'arrive tard' }),
personne(4, 'Grisaille', { prenom: 'Hôtesse' }),
];
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'exemple' }, cleNormalisee)), [1]);
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'hotesse' }, cleNormalisee)), [2, 4]);
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'tard' }, cleNormalisee)), [3]);
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'absent' }, cleNormalisee)), []);
});
test('des champs null : le filtre ne lève pas, et le tri les range après toute valeur, croissant comme décroissant', () => {
const vides = ['prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
// 1 et 3 portent chaque champ, 2 et 4 aucun : les null viennent après,
// par identifiant croissant, dans les deux sens.
const liste = [
personne(4, 'Grisaille'),
personne(3, 'Lacasse', Object.fromEntries(vides.map((champ) => [champ, 'zinnia']))),
personne(2, 'Pervenche'),
personne(1, 'Ombrelle', Object.fromEntries(vides.map((champ) => [champ, 'acacia']))),
];
assert.doesNotThrow(() => lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee));
assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee)), [1]);
assert.ok(vides.length > 0);
for (const colonne of vides) {
const croissant = lignesGrille(liste, { tri: { colonne, sens: 'croissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(croissant), [1, 3, 2, 4], `${colonne} croissant`);
const decroissant = lignesGrille(liste, { tri: { colonne, sens: 'decroissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(ids(decroissant), [3, 1, 2, 4], `${colonne} décroissant`);
}
});
test('cleNormalisee ne reçoit jamais null', () => {
const recus = [];
const espion = (texte) => {
recus.push(texte);
return cleNormalisee(texte);
};
const liste = [personne(1, 'Ombrelle'), personne(2, 'Pervenche', { notes: 'x' })];
lignesGrille(liste, { tri: { colonne: 'notes', sens: 'croissant' }, filtre: 'o' }, espion);
assert.ok(recus.length > 0);
assert.ok(recus.every((texte) => typeof texte === 'string'));
});
test('ne modifie pas la liste reçue', () => {
const copie = structuredClone(participants);
lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee);
assert.deepEqual(participants, copie);
});
test('une colonne de tri inconnue, un sens inconnu : RangeError', () => {
assert.throws(() => lignesGrille(participants, { tri: { colonne: 'id', sens: 'croissant' }, filtre: '' }, cleNormalisee), RangeError);
assert.throws(() => lignesGrille(participants, { tri: { colonne: 'nom', sens: 'haut' }, filtre: '' }, cleNormalisee), RangeError);
});
});
describe('celluleSuivante : flèches, Tab, Maj+Tab (§ 10.2)', () => {
const taille = { lignes: 3, colonnes: 4 };
test('les flèches bougent d’une cellule et restent sur place au bord', () => {
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowDown', taille), { ligne: 2, colonne: 1 });
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowUp', taille), { ligne: 0, colonne: 1 });
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowRight', taille), { ligne: 1, colonne: 2 });
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowLeft', taille), { ligne: 1, colonne: 0 });
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowUp', taille), { ligne: 0, colonne: 0 });
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowLeft', taille), { ligne: 0, colonne: 0 });
assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowDown', taille), { ligne: 2, colonne: 3 });
assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowRight', taille), { ligne: 2, colonne: 3 });
});
test('Tab avance dans la ligne puis passe au début de la suivante ; Maj+Tab recule de même', () => {
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 2 }, 'Tab', taille), { ligne: 0, colonne: 3 });
assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 3 }, 'Tab', taille), { ligne: 1, colonne: 0 });
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 0 }, 'Maj+Tab', taille), { ligne: 0, colonne: 3 });
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 2 }, 'Maj+Tab', taille), { ligne: 1, colonne: 1 });
});
test('Tab après la dernière cellule, Maj+Tab avant la première : null, le focus quitte la grille', () => {
assert.equal(celluleSuivante({ ligne: 2, colonne: 3 }, 'Tab', taille), null);
assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'Maj+Tab', taille), null);
});
test('une autre touche ne bouge rien ; une grille vide n’a pas de cellule', () => {
assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'Enter', taille), { ligne: 1, colonne: 1 });
assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowDown', { lignes: 0, colonnes: 4 }), null);
});
});
describe('orthographeExistante (§ 10.1, § 10.2)', () => {
const participants = [
personne(1, 'Ombrelle', { appartenance: 'Club des Merles' }),
personne(2, 'Pervenche'),
personne(3, 'Lacasse', { appartenance: 'Atelier Grisaille' }),
];
test('« club des merles » → « Club des Merles », l’orthographe déjà portée', () => {
assert.equal(orthographeExistante('club des merles', participants, cleNormalisee), 'Club des Merles');
assert.equal(orthographeExistante('CLUB DÉS MERLES', participants, cleNormalisee), 'Club des Merles');
});
test('null pour une saisie identique, nouvelle, vide ou null ; une appartenance null ne se compare pas', () => {
assert.equal(orthographeExistante('Club des Merles', participants, cleNormalisee), null);
assert.equal(orthographeExistante(' Club des Merles ', participants, cleNormalisee), null);
assert.equal(orthographeExistante('Cercle des Saules', participants, cleNormalisee), null);
assert.equal(orthographeExistante('', participants, cleNormalisee), null);
assert.equal(orthographeExistante(' ', participants, cleNormalisee), null);
assert.equal(orthographeExistante(null, participants, cleNormalisee), null);
});
test('une saisie déjà portée telle quelle par une personne ne propose rien, même quand une autre l’écrit autrement', () => {
const deux = [...participants, personne(4, 'Grisaille', { appartenance: 'club des merles' })];
assert.equal(orthographeExistante('club des merles', deux, cleNormalisee), null);
assert.equal(orthographeExistante('Club Des Merles', deux, cleNormalisee), 'Club des Merles');
});
});
describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () => {
// Un état de places écrit à la main, réduit à ce que lit itineraire.
const etat = (source, siegesAttribues, tables, reserve) => ({ source, siegesAttribues, tables, reserve });
const tableDe = (numero, occupants) => ({
id: numero * 10,
numero,
chaises: occupants.map((occupant, rang) => ({ siege: rang + 1, occupant })),
});
test('à la table 4, siège 2, au tour 1, en réserve au tour 2', () => {
const etats = new Map([
[1, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, 7])], [])],
[2, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, null])], [7])],
]);
assert.deepEqual(itineraire(7, 2, (r) => etats.get(r)), [
{ tour: 1, numero: 4, siege: 2 },
{ tour: 2, reserve: true },
]);
});
test('sièges non attribués : siège null', () => {
const etats = new Map([[1, etat('retenu', false, [tableDe(4, [5, 7])], [])]]);
assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]);
});
test('source reservations ou proposition, à un tour : null', () => {
const retenu = etat('retenu', true, [tableDe(4, [7])], []);
for (const source of ['reservations', 'proposition']) {
const etats = new Map([
[1, retenu],
[2, etat(source, true, [tableDe(4, [7])], [])],
]);
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source);
}
});
test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => {
const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]);
assert.equal(itineraire(7, 1, (r) => etats.get(r)), null);
});
test('lit chaque tour de 1 à tours, une fois', () => {
const lus = [];
itineraire(7, 3, (r) => {
lus.push(r);
return etat('retenu', true, [tableDe(1, [7])], []);
});
assert.deepEqual(lus, [1, 2, 3]);
});
});
describe('reservationsDe : les réservations d’une personne, pour la fiche (§ 4)', () => {
test('les siennes, dans l’ordre de la charge, la table par son numéro affiché', () => {
const charge = {
tables: [
{ id: 1, numero: 3 },
{ id: 2, numero: 1 },
],
reservations: [
{ participant: 7, table: 2, siege: null, portee: 'tous', tour: null },
{ participant: 8, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 7, table: 1, siege: 4, portee: 'tour', tour: 2 },
],
};
assert.deepEqual(reservationsDe(charge, 7), [
{ numero: 1, siege: null, portee: 'tous', tour: null },
{ numero: 3, siege: 4, portee: 'tour', tour: 2 },
]);
assert.deepEqual(reservationsDe(charge, 9), []);
});
});
describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
test('la cellule de rang i de la ligne exportée égale p[COLONNES[i]], exclu écrit « non »', () => {
const p = {
id: 1,
nom: 'Ombrelle',
prenom: 'Iris',
appartenance: 'Club des Merles',
courriel: 'iris@exemple.test',
titrePressenti: 'hôtesse',
notes: 'arrive à neuf heures',
exclu: false,
};
const { texte } = decoder(exporterParticipants([p]));
const [, ligne] = texte.split('\r\n');
const cellules = ligne.split(';');
assert.equal(COLONNES.length, 7);
assert.equal(cellules.length, COLONNES.length);
COLONNES.forEach((colonne, i) => {
assert.equal(cellules[i], colonne === 'exclu' ? 'non' : p[colonne], colonne);
});
});
test('le nom proposé à l’enregistrement : ‹base›-participants.csv', () => {
assert.equal(nomExport('soiree-des-merles'), 'soiree-des-merles-participants.csv');
});
test('chaque colonne et chaque statut ont leur clé de libellé', () => {
assert.deepEqual(
COLONNES.map(cleDeColonne),
COLONNES.map((colonne) => `participants.colonne.${colonne}`),
);
assert.equal(cleDeStatut('ancre'), 'participants.statut.ancre');
assert.equal(cleDeStatut('partiel'), 'participants.statut.partiel');
assert.equal(cleDeStatut('mobile'), 'participants.statut.mobile');
assert.throws(() => cleDeColonne('id'), RangeError);
assert.throws(() => cleDeStatut('exclu'), RangeError);
});
});

View file

@ -0,0 +1,60 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le bandeau du plan (§ 7.5), au-dessus du <svg data-plan> et dans le
// cadre de mode : vide tant qu'une table au moins est saine ; quand toutes
// sont en conflit, la phrase, puis ce que le diagnostic prouve — chaque
// entrée {cle, details} du modèle (modele.js), traduite. L'appartenance
// d'un plancher de groupe est une donnée : elle occupe son propre nœud,
// avant le texte. Le bandeau d'application garde la version, la
// provenance et le mode.
import { getContext } from 'svelte';
let { bandeau } = $props();
const t = getContext('t');
</script>
{#if bandeau.length > 0}
<div class="bandeau-plan" data-bandeau-plan role="status">
<ul>
{#each bandeau as entree, rang (rang)}
<li>
{#if entree.details.groupe !== undefined}<span class="donnee">{entree.details.groupe}</span>{/if}<span
>{t(entree.cle, entree.details)}</span
>
</li>
{/each}
</ul>
</div>
{/if}
<style>
.bandeau-plan {
padding: var(--espace-2) var(--espace-3);
border-left: 4px solid var(--couleur-avis);
background: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
}
ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
li {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: 700;
}
</style>

View file

@ -0,0 +1,776 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du dessin du plan dans un vrai moteur de rendu (§ 7.1, § 7.3 à
// § 7.7, § 13.3, § 13.5, § 17 point 6). PlanDessin se monte dans un <svg
// data-plan> d'épreuve, sans viewBox, dont le groupe [data-vue] porte
// translate(tx,ty) scale(k) ; BandeauPlan et EchellePlan dans la page. Les
// épreuves lisent le style calculé, la boîte et l'élément qu'atteint un
// point, jamais la présence d'une classe, et atteignent les éléments par
// leurs attributs data-…. Une démonstration livrée se lit, et ses variantes
// se dérivent, en mémoire depuis texteLivre ; les noms d'épreuve sont
// inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import '../jetons.css';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { designer } from '../../geometrie/designation.js';
import { echelleGraphique } from '../../geometrie/echelle.js';
import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js';
import { dessinDepuisEcran, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
import { rechercher } from '../../moteur/recherche.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { versFichier } from '../../stockage/placements.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
import { TABLES_INTERFACE } from '../libelles.js';
import BandeauPlan from './BandeauPlan.svelte';
import EchellePlan from './EchellePlan.svelte';
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const CONTEXTE = new Map([
['t', t],
['format', creerFormat(t)],
]);
const SVG = 'http://www.w3.org/2000/svg';
const K_LISTES = constante('K_LISTES');
const LARGEUR = constante('LARGEUR_BLOC_NOMS_CM');
const MARQUE = t('plan.liste.abreviation');
const MARQUES_DE_CHAISE = ['cadenasFerme', 'cadenasOuvert', 'anneau'];
// Les montages de l'épreuve courante : composant et conteneur.
const montes = [];
beforeAll(async () => {
await fixerCadre(1366, 700);
});
// Démonte tout ce que l'épreuve courante a monté.
function demonterTout() {
for (const { composant, conteneur } of montes.splice(0)) {
unmount(composant);
conteneur.remove();
}
}
afterEach(demonterTout);
// --- Montage ---------------------------------------------------------------
/** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la
* fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est
* en display: none, et conteneur.style.display = '' le montre. */
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) {
const conteneur = document.createElement('div');
conteneur.dataset.surface = '';
if (cache) conteneur.style.display = 'none';
document.body.prepend(conteneur);
const svg = document.createElementNS(SVG, 'svg');
svg.setAttribute('data-plan', '');
svg.setAttribute('width', String(innerWidth));
svg.setAttribute('height', String(innerHeight));
svg.style.display = 'block';
conteneur.append(svg);
const groupe = document.createElementNS(SVG, 'g');
groupe.setAttribute('data-vue', '');
groupe.setAttribute('transform', transformDe(vue));
svg.append(groupe);
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE });
flushSync();
montes.push({ composant, conteneur });
const boite = svg.getBoundingClientRect();
const cadre = { gauche: boite.left, haut: boite.top, largeur: boite.width, hauteur: boite.height };
return { svg, groupe, conteneur, vue, cadre, ecran: (x, y) => ecranDepuisDessin(vue, cadre, x, y) };
}
// Attend deux images : les observations de taille de la première sont
// livrées, et les mises à jour qu'elles déclenchent appliquées.
const deuxImages = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
/** Monte un composant HTML dans un conteneur neuf, au début du corps. */
function monterHtml(Composant, props) {
const conteneur = document.createElement('div');
document.body.prepend(conteneur);
const composant = mount(Composant, { target: conteneur, props, context: CONTEXTE });
flushSync();
montes.push({ composant, conteneur });
return conteneur;
}
// --- Configurations ----------------------------------------------------------
const NOMS = [
['Iris', 'Ombrelle'],
['Théo', 'Pervenche'],
['Ondine', 'Lacasse'],
[null, 'Grisaille'],
['Basile', 'Tourmaline'],
['Capucine', 'Arrosoir'],
['Aurèle', 'Cerfeuil'],
['Mélisande', 'Bruyère'],
['Firmin', 'Galet'],
['Clélia', 'Mistral'],
['Anatole', 'Houblon'],
['Solveig', 'Sarcelle'],
];
const personne = (id, nom, prenom, appartenance = null) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
const nomsDe = (charge) => {
const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`]));
return (id) => noms.get(id);
};
/** Le modèle d'un tour de la charge, sous la source demandée. */
function modeleDe(charge, { tour = 1, proposition = null, ...options } = {}) {
return modeleDessin(etatPlaces(charge, { tour, proposition }), {
k: 1,
listes: true,
miseEnEvidence: null,
nomDe: nomsDe(charge),
...options,
});
}
/** Une charge de tables de capacités données — rondes, ou de la forme
* donnée —, centres espacés de pas cm sur une ligne, et douze personnes
* inventées. */
function chargeDeTables(capacites, { tours = 1, pas = 400, formes = [] } = {}) {
const charge = creerCharge({ id: 'epreuve:dessin', nom: 'Dessin', siegesParDefaut: 8, tours });
charge.tables = capacites.map((sieges, i) => ({
id: i + 1,
numero: i + 1,
sieges,
forme: formes[i] ?? 'ronde',
position: { x: pas * i, y: 0 },
}));
charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom));
charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 };
return charge;
}
// Pose une proposition de rechercher, ou la proposition donnée.
function avecProposition(charge, proposition = null) {
const configuration = configurationDepuisCharge(charge);
const fichier =
proposition ??
versFichier(rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 })[0], configuration, {
produitVersion: 'epreuve',
attribuerSieges: false,
decalage: 0,
});
charge.propositions = [fichier];
charge.prochainsIds.proposition = fichier.id + 1;
charge.evenement.etat = 'propose';
return charge;
}
const toutesEnConflit = () => analyser(TEXTE_TOUTES_EN_CONFLIT).charge;
// La petite démonstration, la seule règle « nouveaux voisins » active, et une
// proposition dont le tour 2 réunit de nouveau la table 1 du tour 1 : au tour
// 2, la table 1 seule est en rencontre répétée.
function rencontreRepetee() {
const charge = analyser(texteLivre('petite')).charge;
Object.assign(charge.reglages, { separerAppartenances: false, nouvelleTable: false, varierAppartenances: false });
const tours = [
PLAN_PARFAIT_PETITE.tours[0],
[
[1, 2, 3],
[4, 8, 12],
[5, 9, 10],
[6, 7, 11],
],
PLAN_PARFAIT_PETITE.tours[2],
PLAN_PARFAIT_PETITE.tours[3],
];
return avecProposition(charge, {
id: 1,
graine: 1,
arret: 1,
historique: 1000,
produitVersion: 'epreuve',
siegesAttribues: false,
tables: [1, 2, 3, 4],
capacites: [3, 3, 3, 3],
tours: 4,
participants: charge.participants.map(({ id }) => id),
placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: [] })),
});
}
// --- Lectures --------------------------------------------------------------------
const tous = (racine, selecteur) => [...racine.querySelectorAll(selecteur)];
const plateauDe = (svg, id) => svg.querySelector(`[data-table="${id}"] :is([data-marque=contourTable], [data-marque=contourConflit])`);
const centre = (element) => {
const b = element.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// La valeur d'un jeton de couleur, en composantes [r, g, b, a].
function jeton(nom) {
const sonde = document.createElement('div');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const lu = composantes(getComputedStyle(sonde).color);
sonde.remove();
return lu;
}
const memeCouleur = (a, b) => a.every((c, i) => Math.abs(c - b[i]) < 0.01);
// Les lignes rendues d'une liste : chaque <tspan> enfant du <text data-liste>.
const lignesDe = (svg, id) => tous(svg, `[data-liste="${id}"] > tspan`);
// La hauteur d'encre du gabarit sur la police calculée d'un élément.
function encreDuGabarit(element, gabarit = GABARIT_HAUTEUR_LIGNE) {
const style = getComputedStyle(element);
const contexte = new OffscreenCanvas(1, 1).getContext('2d');
contexte.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const m = contexte.measureText(gabarit);
return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent;
}
// L'anneau se voit quand il est rendu et qu'un point de son trait l'atteint,
// pointer-events ignoré : le centre de sa boîte, au milieu de la chaise, ne
// le touche pas.
function anneauVisible(anneau) {
if (!anneau.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return 'checkVisibility faux';
const style = getComputedStyle(anneau);
if (style.stroke === 'none' || !(parseFloat(style.strokeWidth) > 0)) return `trait ${style.stroke} ${style.strokeWidth}`;
const b = anneau.getBoundingClientRect();
const feuille = document.createElement('style');
feuille.textContent = '* { pointer-events: auto !important; }';
document.head.append(feuille);
try {
const atteints = document.elementsFromPoint(b.left + b.width / 2, b.top + 1);
return atteints.includes(anneau) ? true : `le haut de son trait n'atteint pas l'anneau`;
} finally {
feuille.remove();
}
}
// =============================================================================
describe('le mobilier à l\u{2019}échelle (§ 7.1, § 7.7)', () => {
test('à k = 2, le disque d\u{2019}une table de 8, de 3 et de 10 a la largeur de son diamètre, à moins d\u{2019}un pixel', () => {
const charge = chargeDeTables([8, 3, 10]);
const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 350 });
const diametres = [(8 * 60) / Math.PI, 70, (10 * 60) / Math.PI];
expect(diametres[0]).toBeCloseTo(2 * 76.39, 1);
diametres.forEach((diametre, i) => {
expect(Math.abs(plateauDe(svg, i + 1).getBoundingClientRect().width - diametre * 2)).toBeLessThan(1);
});
});
});
describe('les marques des places (§ 7.6, § 19.3 08)', () => {
// Siège 1 : réservé pour tous les tours et titré ; 2 : titre non pourvu ;
// 3 : réservé au seul tour 1 ; les autres, nus.
function marques() {
const charge = chargeDeTables([8], { tours: 2 });
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 },
];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
return charge;
}
const ATTENDUES = new Map([
[1, ['cadenasFerme', 'anneau']],
[2, ['cadenasOuvert']],
[3, ['cadenasFerme']],
]);
const REMPLISSAGES = new Map([
[1, 'remplissagePlein'],
[2, 'hachure'],
[3, 'remplissagePlein'],
]);
test('chaque marque attendue se voit, aucune autre ; deux marques de chaise au plus ; aucun pictogramme', () => {
const charge = marques();
const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 });
const sieges = tous(svg, '[data-siege]');
expect(sieges.length).toBe(8);
for (const siege of sieges) {
const numero = Number(siege.dataset.siege.split(':')[1]);
const portees = MARQUES_DE_CHAISE.flatMap((nom) => tous(siege, `[data-marque=${nom}]`));
expect(portees.length).toBeLessThanOrEqual(2);
expect(portees.map((m) => m.dataset.marque).sort()).toEqual([...(ATTENDUES.get(numero) ?? [])].sort());
for (const marque of portees) {
expect(marque).toBeInstanceOf(SVGElement);
expect(marque.dataset.marque === 'anneau' ? anneauVisible(marque) : visible(marque)).toBe(true);
}
}
// Les nœuds de texte du plan : au moins un par ligne de liste, huit.
const textes = tous(svg, '*').flatMap((e) => [...e.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE));
expect(tous(svg, '[data-liste] > tspan').length).toBe(8);
expect(textes.length).toBeGreaterThanOrEqual(8);
for (const noeud of textes) {
expect(/\p{Extended_Pictographic}/u.test(noeud.textContent)).toBe(false);
}
});
test('le remplissage se lit au style calculé : plein, hachuré, ou le fond de la chaise', () => {
const charge = marques();
const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 });
const chaises = tous(svg, '[data-siege] > :is([data-marque=contourChaise], [data-marque=remplissagePlein], [data-marque=hachure])');
expect(chaises.length).toBe(8);
for (const chaise of chaises) {
const numero = Number(chaise.closest('[data-siege]').dataset.siege.split(':')[1]);
const attendu = REMPLISSAGES.get(numero) ?? 'contourChaise';
expect(chaise.dataset.marque).toBe(attendu);
const { fill } = couleursPeintes(chaise);
if (attendu === 'remplissagePlein') expect(fill).toEqual([jeton('--couleur-chaise-pleine')]);
if (attendu === 'contourChaise') expect(fill).toEqual([jeton('--couleur-chaise-fond')]);
if (attendu === 'hachure') {
expect(getComputedStyle(chaise).fill).toMatch(/^url\(/);
expect(fill.some((c) => memeCouleur(c, jeton('--couleur-hachure')))).toBe(true);
}
// Un cadenas couvre le centre de sa chaise : seule une chaise nue se
// voit en son centre ; toutes sont rendues.
expect(chaise.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
if (!ATTENDUES.has(numero)) expect(visible(chaise)).toBe(true);
}
});
});
describe('la liste fait foi (§ 7.6)', () => {
test('la ligne du siège 1 égale exactement sa forme, son titre en italique ; celle d\u{2019}un titre non pourvu l\u{2019}énonce', () => {
const charge = chargeDeTables([8]);
charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
// Au bloc du registre, LARGEUR_BLOC_NOMS_CM : la ligne entière y tient.
const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 400, ty: 350 });
const lignes = lignesDe(svg, 1);
expect(lignes.length).toBe(8);
expect(lignes[0].textContent).toBe('1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur');
const titre = lignes[0].querySelector('[data-partie=titre]');
expect(titre.textContent).toBe('animateur');
expect(getComputedStyle(titre).fontStyle).toBe('italic');
expect(getComputedStyle(lignes[0].querySelector('[data-partie=nom]')).fontStyle).toBe('normal');
expect(lignes[1].textContent).toBe('2. (libre) \u{2014} titre animateur non pourvu');
});
test('grande démonstration livrée, tour 1, modèle tel quel : chaque ligne tient, chaque place occupée garde son nom, chaque titre son libellé', () => {
const charge = analyser(texteLivre('grande')).charge;
const modele = modeleDe(charge);
const occupees = modele.tables.flatMap(({ lignes }) => lignes.filter(({ nom }) => nom !== null));
expect(occupees.length).toBe(33);
expect(occupees.every(({ etat, titre }) => etat?.cle === 'plan.ligne.reserveeTous' && titre !== null)).toBe(true);
const { svg } = monterPlan(modele, { k: 1, tx: 50, ty: 50 });
const lignes = tous(svg, '[data-liste] > tspan');
expect(lignes.length).toBe(260);
for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const noms = tous(svg, '[data-liste] [data-partie=nom]');
expect(noms.length).toBe(33);
for (const nom of noms) expect(nom.textContent.replace(MARQUE, '').length).toBeGreaterThan(0);
const titres = tous(svg, '[data-liste] [data-partie=titre]');
expect(titres.length).toBe(33);
for (const titre of titres) expect(titre.textContent).toBe('animation');
for (const nom of noms) expect(nom.previousElementSibling.textContent).toMatch(/^\d+\. \(réservée, tous les tours\) $/);
});
});
describe('le texte des listes (§ 7.4)', () => {
test('chaque ligne est un <tspan> à x et y explicites, sans textLength ; deux lignes sont au moins à la hauteur d\u{2019}encre du gabarit', () => {
const charge = chargeDeTables([8, 4]);
charge.participants.forEach((p, i) => {
p.nom = ['\u{D4}jgq', '\u{C6}jgq', '\u{D4}gq \u{C6}'][i % 3];
});
avecProposition(charge);
const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 400, ty: 350 });
const lignes = lignesDe(svg, 1);
expect(lignes.length).toBe(8);
for (const ligne of lignes) {
expect(ligne.hasAttribute('x')).toBe(true);
expect(ligne.hasAttribute('y') || ligne.hasAttribute('dy')).toBe(true);
}
expect(svg.querySelectorAll('[textLength], [lengthAdjust]').length).toBe(0);
const encre = encreDuGabarit(svg.querySelector('[data-liste="1"]'));
expect(encre).toBeGreaterThan(18);
expect(encre).toBeGreaterThan(encreDuGabarit(svg.querySelector('[data-liste="1"]'), 'abc'));
for (let i = 1; i < lignes.length; i += 1) {
const ecart = lignes[i].getStartPositionOfChar(0).y - lignes[i - 1].getStartPositionOfChar(0).y;
expect(ecart).toBeGreaterThanOrEqual(encre - 1e-3);
}
});
test('troncature : sur les 33 tables sans exception, chaque ligne tient, chaque nom s\u{2019}abrège sur la marque', () => {
const charge = analyser(texteLivre('grande-sans-exception')).charge;
const LONGS = ['L'.repeat(200), 'W'.repeat(60), '\u{C6}'.repeat(60)];
charge.participants.forEach((p, i) => {
p.prenom = null;
p.nom = LONGS[i % 3];
});
expect(charge.titres.filter(({ siege, libelle }) => siege === 1 && libelle === 'animation').length).toBe(33);
avecProposition(charge);
const modele = modeleDe(charge, { proposition: 1, k: 0.5 });
const places = modele.tables.reduce((somme, { lignes }) => somme + lignes.length, 0);
const occupees = modele.tables.reduce((somme, { lignes }) => somme + lignes.filter(({ nom }) => nom !== null).length, 0);
expect(places).toBe(264);
expect(occupees).toBe(260);
// Au bloc du registre : chaque nom s'abrège, aucun ne tombe, et le
// titre reste entier.
const { svg } = monterPlan(modele, { k: 0.5, tx: 50, ty: 50 });
const lignes = tous(svg, '[data-liste] > tspan');
expect(lignes.length).toBe(places);
for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const noms = tous(svg, '[data-liste] [data-partie=nom]');
expect(noms.length).toBe(occupees);
for (const nom of noms) expect(nom.textContent.endsWith(MARQUE) && nom.textContent.length > 1).toBe(true);
const titres = tous(svg, '[data-liste] [data-partie=titre]');
expect(titres.length).toBe(33);
for (const titre of titres) expect(titre.textContent).toBe('animation');
});
test('un titre de deux cents caractères : la ligne tient encore, le titre abrégé', () => {
const charge = chargeDeTables([4]);
charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
charge.titres = [{ table: 1, siege: 1, libelle: 't'.repeat(200) }];
const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 350 });
const [premiere] = lignesDe(svg, 1);
expect(premiere.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const titre = premiere.querySelector('[data-partie=titre]');
expect(titre.textContent.endsWith(MARQUE)).toBe(true);
expect(titre.textContent.length).toBeLessThan(200);
});
});
describe('le plan monté caché (point d\u{2019}attention 1)', () => {
test('monté en display: none puis montré : chaque ligne tient, le nom long s\u{2019}abrège sur la marque', async () => {
const charge = chargeDeTables([4, 8]);
for (const p of charge.participants) {
p.prenom = null;
p.nom = 'W'.repeat(80);
}
avecProposition(charge);
const { svg, conteneur } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 }, { cache: true });
conteneur.style.display = '';
await deuxImages();
flushSync();
const lignes = lignesDe(svg, 1);
expect(lignes.length).toBe(4);
for (const ligne of lignes) {
expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const nom = ligne.querySelector('[data-partie=nom]').textContent;
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
}
});
});
describe('le signal de conflit (§ 7.5, § 13.3)', () => {
for (const theme of ['clair', 'sombre']) {
test(`thème ${theme} : contour épaissi, trame peinte, pastille chiffrée ; la rencontre répétée et le siège réservé peignent autrement ; le tour change le signal`, async () => {
await sousTheme(theme, async () => {
const charge = rencontreRepetee();
const tour2 = modeleDe(charge, { tour: 2, proposition: 1, k: 1.5 });
expect(tour2.tables.map(({ conflit }) => conflit)).toEqual([true, false, false, false]);
const vue = { k: 1.5, tx: 150, ty: 250 };
const { svg } = monterPlan(tour2, vue);
const enConflit = plateauDe(svg, 1);
const saine = plateauDe(svg, 2);
expect(enConflit.dataset.marque).toBe('contourConflit');
expect(parseFloat(getComputedStyle(enConflit).strokeWidth)).toBeGreaterThan(parseFloat(getComputedStyle(saine).strokeWidth));
expect(couleursPeintes(enConflit).stroke).not.toEqual(couleursPeintes(saine).stroke);
const trames = tous(svg, '[data-marque=trameConflit]');
expect(trames.length).toBe(1);
expect(trames[0].closest('[data-table]').dataset.table).toBe('1');
expect(getComputedStyle(trames[0]).fill).toMatch(/^url\("?#/);
expect(couleursPeintes(trames[0]).fill.some((c) => memeCouleur(c, jeton('--couleur-conflit')))).toBe(true);
const pastilles = tous(svg, '[data-pastille]');
expect(pastilles.length).toBe(1);
expect(pastilles[0].dataset.pastille).toBe('1');
expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille));
expect(visible(pastilles[0].querySelector('text'))).toBe(true);
expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]);
expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none');
demonterTout();
const tour1 = monterPlan(modeleDe(charge, { tour: 1, proposition: 1, k: 1.5 }), vue).svg;
expect(plateauDe(tour1, 1).dataset.marque).toBe('contourTable');
expect(tous(tour1, '[data-marque=trameConflit], [data-pastille]').length).toBe(0);
expect(getComputedStyle(plateauDe(tour1, 1)).strokeWidth).toBe(getComputedStyle(plateauDe(tour1, 2)).strokeWidth);
const reserve = chargeDeTables([8]);
reserve.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
const places = monterPlan(modeleDe(reserve, { k: 1.5 }), vue).svg;
const [plein, nu] = ['1:1', '1:2'].map((s) => places.querySelector(`[data-siege="${s}"] > circle`));
expect(couleursPeintes(plein).fill).not.toEqual(couleursPeintes(nu).fill);
});
});
}
test('toutes en conflit, au rendu : ni contour ni trame, le style d\u{2019}une table saine ; chaque pastille garde son nombre ; le bandeau porte la phrase', () => {
const charge = toutesEnConflit();
const modele = modeleDe(charge, { proposition: 1, k: 1 });
expect(modele.signalerConflits).toBe(false);
const { svg } = monterPlan(modele, { k: 1, tx: 150, ty: 250 });
expect(tous(svg, '[data-marque=contourConflit], [data-marque=trameConflit]').length).toBe(0);
const saine = monterPlan(modeleDe(chargeDeTables([3]), { k: 1 }), { k: 1, tx: 150, ty: 600 }).svg;
const reference = getComputedStyle(plateauDe(saine, 1));
for (const id of [1, 2, 3, 4]) {
const style = getComputedStyle(plateauDe(svg, id));
for (const propriete of ['stroke', 'strokeWidth', 'fill']) expect(style[propriete]).toBe(reference[propriete]);
}
const pastilles = tous(svg, '[data-pastille]');
expect(pastilles.length).toBe(4);
for (const pastille of pastilles) {
const table = modele.tables.find(({ id }) => String(id) === pastille.dataset.pastille);
expect(table.pastille).toBeGreaterThan(0);
expect(pastille.textContent).toBe(String(table.pastille));
}
const bandeau = monterHtml(BandeauPlan, { bandeau: modele.bandeau });
const porteur = bandeau.querySelector('[data-bandeau-plan]');
expect(porteur.textContent).toBe(t('plan.bandeau.toutesEnConflit'));
expect(visible(porteur.querySelector('li span'))).toBe(true);
});
test('le bandeau pose l\u{2019}appartenance d\u{2019}un plancher dans son propre nœud', () => {
const bandeau = [
{ cle: 'plan.bandeau.toutesEnConflit', details: {} },
{ cle: 'plan.bandeau.plancherGroupe', details: { groupe: 'Club des Merles', effectif: 6, plancherCumulees: 8 } },
];
const conteneur = monterHtml(BandeauPlan, { bandeau });
const entrees = tous(conteneur, '[data-bandeau-plan] li');
expect(entrees.length).toBe(2);
const noeuds = [...entrees[1].querySelectorAll('*')].map((e) => e.textContent);
expect(noeuds).toContain('Club des Merles');
expect(noeuds).toContain(t('plan.bandeau.plancherGroupe', bandeau[1].details));
});
});
describe('l\u{2019}échelle graphique et la mention (§ 7.7, § 19.3 07)', () => {
test('la mention se voit, portée par EchellePlan, tirée des libellés', () => {
const conteneur = monterHtml(EchellePlan, { k: 1 });
const mention = [...conteneur.querySelectorAll('*')].find((e) => e.textContent === t('plan.echelle.mention'));
expect(mention).toBeDefined();
expect(mention.textContent).toBe("disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié");
expect(visible(mention)).toBe(true);
});
for (const k of [0.2, 1, 5]) {
test(`k = ${k} : la largeur rendue de la barre, divisée par k, égale sa longueur libellée à 1 % près`, () => {
const conteneur = monterHtml(EchellePlan, { k });
const barre = conteneur.querySelector('[data-marque=echelle]');
expect(visible(barre)).toBe(true);
const { longueurCm } = echelleGraphique(k, LARGEUR_ECHELLE_PX);
const libelle = barre.parentElement.textContent;
const lu = /^([\d,]+)\s*(cm|m|km)$/.exec(libelle.trim());
expect(lu).not.toBeNull();
const facteur = { cm: 1, m: 100, km: 100000 }[lu[2]];
const longueurLue = Number(lu[1].replace(',', '.')) * facteur;
expect(longueurLue).toBeCloseTo(longueurCm, 6);
expect(Math.abs(barre.getBoundingClientRect().width / k - longueurLue) / longueurLue).toBeLessThan(0.01);
expect(/\b1\s*:\s*\d/.test(conteneur.textContent)).toBe(false);
});
}
});
describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => {
test('à K_LISTES − 0,01, aucun tspan de liste ; à K_LISTES + 0,01, les listes sont là', () => {
const charge = chargeDeTables([8, 8]);
for (const [k, present] of [
[K_LISTES - 0.01, false],
[K_LISTES + 0.01, true],
]) {
const { svg } = monterPlan(modeleDe(charge, { k }), { k, tx: 200, ty: 200 });
expect(tous(svg, '[data-liste] tspan').length > 0).toBe(present);
expect(tous(svg, '[data-table]').length).toBe(2);
}
});
test('grande démonstration : à 0,39, aucune chaise, une seule cible par table ; à 0,41, 260 sièges', () => {
const charge = analyser(texteLivre('grande')).charge;
const vue = { k: 0.39, tx: 80, ty: 80 };
const bas = monterPlan(modeleDe(charge, { k: 0.39 }), vue);
expect(tous(bas.svg, '[data-siege]').length).toBe(0);
const modele = modeleDe(charge, { k: 0.39 });
let chaises = 0;
for (const table of modele.tables) {
const { centre: c, rayonEmprise } = table.geometrie;
const coin1 = bas.ecran(c.x - rayonEmprise, c.y - rayonEmprise);
const coin2 = bas.ecran(c.x + rayonEmprise, c.y + rayonEmprise);
if (coin1.x < 0 || coin1.y < 0 || coin2.x > innerWidth || coin2.y > innerHeight) continue;
const atteints = table.geometrie.chaises.map(({ x, y }) => {
const p = bas.ecran(x, y);
return document.elementFromPoint(p.x, p.y);
});
chaises += atteints.length;
expect(new Set(atteints).size).toBe(1);
expect(atteints[0].closest('[data-siege]')).toBeNull();
expect(atteints[0].closest('[data-table]').dataset.table).toBe(String(table.id));
}
expect(chaises).toBeGreaterThanOrEqual(10);
const haut = monterPlan(modeleDe(charge, { k: 0.41 }), { k: 0.41, tx: 80, ty: 80 });
const capacites = etatPlaces(charge, { tour: 1 }).tables.reduce((somme, { capacite }) => somme + capacite, 0);
expect(capacites).toBe(260);
expect(tous(haut.svg, '[data-siege]').length).toBe(260);
});
});
describe('dessin et désignation (§ 7.3, § 13.5)', () => {
test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => {
const charge = chargeDeTables([8, 8], { pas: 200 });
const vue = { k: 1.5, tx: 300, ty: 350 };
const modele = modeleDe(charge, { k: vue.k });
const { svg, cadre, ecran } = monterPlan(modele, vue);
const geometries = modele.tables.map(({ geometrie }) => geometrie);
const sieges = tous(svg, '[data-siege]');
expect(sieges.length).toBe(16);
for (const element of sieges) {
const [table, siege] = element.dataset.siege.split(':').map(Number);
const attendu = geometries[table - 1].chaises[siege - 1];
const c = centre(element);
const lu = dessinDepuisEcran(vue, cadre, c.x, c.y);
expect(Math.hypot(lu.x - attendu.x, lu.y - attendu.y) * vue.k).toBeLessThan(0.5);
}
const temoins = [];
for (const { centre: c, chaises } of geometries) {
temoins.push(c);
chaises.forEach((chaise, i) => {
const suivante = chaises[(i + 1) % chaises.length];
temoins.push(chaise, { x: (chaise.x + suivante.x) / 2, y: (chaise.y + suivante.y) / 2 });
});
}
temoins.push({ x: -300, y: 0 }, { x: 100, y: -200 });
expect(temoins.length).toBe(36);
for (const point of temoins) {
const p = ecran(point.x, point.y);
const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null;
const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, vue.k);
const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null;
expect(parDom, `point (${point.x}, ${point.y})`).toBe(parGeometrie);
}
const boite = svg.querySelector('[data-liste="1"]').getBoundingClientRect();
const couvertes = geometries[1].chaises
.map((chaise) => ({ chaise, p: ecran(chaise.x, chaise.y) }))
.filter(({ p }) => p.x >= boite.left && p.x <= boite.right && p.y >= boite.top && p.y <= boite.bottom);
expect(couvertes.length).toBeGreaterThanOrEqual(1);
for (const { chaise, p } of couvertes) {
expect(document.elementFromPoint(p.x, p.y).closest('[data-siege]').dataset.siege).toBe(`2:${chaise.siege}`);
}
});
});
describe('aucune rotation, aucun canvas (§ 7.1, § 13.3)', () => {
test('chaque table, ronde ou carrée, porte une seule poignée, centrée sur GeometrieTable.poignee', () => {
const charge = chargeDeTables([8, 12, 2], { formes: ['ronde', 'carree', 'carree'], pas: 450 });
for (const vue of [
{ k: 1, tx: 200, ty: 300 },
{ k: 0.3, tx: 200, ty: 300 },
]) {
const modele = modeleDe(charge, { k: vue.k });
const { svg, groupe, ecran } = monterPlan(modele, vue);
expect(tous(svg, '[data-poignee]').length).toBe(3);
for (const table of modele.tables) {
const poignees = tous(svg, `[data-poignee="${table.id}"]`);
expect(poignees.length).toBe(1);
const attendu = ecran(table.geometrie.poignee.x, table.geometrie.poignee.y);
const c = centre(poignees[0]);
expect(Math.hypot(c.x - attendu.x, c.y - attendu.y)).toBeLessThan(1);
expect(poignees[0].dataset.marque).toBe('poignee');
}
expect(groupe.closest('[data-surface]').querySelectorAll('canvas').length).toBe(0);
}
});
});
describe('la mise en évidence', () => {
test('la chaise de la personne, et elle seule, porte [data-marque=miseEnEvidence] au tour montré', () => {
const charge = avecProposition(chargeDeTables([4, 4, 4], { tours: 2 }));
const sieges = new Set();
for (const tour of [1, 2]) {
const { svg } = monterPlan(modeleDe(charge, { tour, proposition: 1, k: 1, miseEnEvidence: 5 }), { k: 1, tx: 150, ty: 300 });
const marquees = tous(svg, '[data-marque=miseEnEvidence]');
expect(marquees.length).toBe(1);
const siege = marquees[0].closest('[data-siege]').dataset.siege;
const etat = etatPlaces(charge, { tour, proposition: 1 });
const [table, numero] = siege.split(':').map(Number);
expect(etat.tables.find(({ id }) => id === table).chaises[numero - 1].occupant).toBe(5);
expect(couleursPeintes(marquees[0]).stroke).toEqual([jeton('--couleur-mise-en-evidence')]);
expect(anneauVisible(marquees[0])).toBe(true);
sieges.add(siege);
demonterTout();
}
expect(sieges.size).toBe(2);
});
});
describe('les bords (points d\u{2019}attention 3 et 4)', () => {
test('une table de 2 : deux chaises, un disque de 70 cm', () => {
const { svg } = monterPlan(modeleDe(chargeDeTables([2]), { k: 2 }), { k: 2, tx: 300, ty: 300 });
expect(tous(svg, '[data-siege]').length).toBe(2);
expect(Math.abs(plateauDe(svg, 1).getBoundingClientRect().width / 2 - 70)).toBeLessThan(0.5);
});
test('une table gelée : la marque gel se voit, « gelée » dans son aria-label et en tête de sa liste', () => {
const charge = chargeDeTables([2]);
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 2, portee: 'tous', tour: null },
];
const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 300 });
const gel = svg.querySelector('[data-table="1"] [data-marque=gel]');
expect(gel).not.toBeNull();
expect(visible(gel)).toBe(true);
expect(svg.querySelector('[data-table="1"]').getAttribute('aria-label')).toMatch(/gelée/);
expect(lignesDe(svg, 1)[0].textContent).toBe(t('plan.liste.gelee'));
});
test('une seule table ; aucune table : aucun [data-table], un bandeau vide, un montage qui ne lève pas', () => {
expect(tous(monterPlan(modeleDe(chargeDeTables([6]))).svg, '[data-table]').length).toBe(1);
const vide = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 });
const modele = modeleDe(vide);
const { svg } = monterPlan(modele);
expect(tous(svg, '[data-table]').length).toBe(0);
const conteneur = monterHtml(BandeauPlan, { bandeau: modele.bandeau });
expect(conteneur.querySelector('[data-bandeau-plan]')).toBeNull();
expect(conteneur.textContent.trim()).toBe('');
});
test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => {
const charge = chargeDeTables([4]);
charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 300 });
const lignes = lignesDe(svg, 1).map((ligne) => ligne.textContent);
expect(lignes.slice(0, 2)).toEqual([
'1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
'2. (libre) \u{2014} titre animateur non pourvu',
]);
expect(svg.querySelector('[data-siege="1:1"] [data-marque=anneau]')).not.toBeNull();
});
});

View file

@ -0,0 +1,62 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'échelle graphique et la mention du plan (§ 7.7), hors du <svg
// data-plan>, dans la surface qui le porte : elles gardent leur taille à
// l'écran, et un zoom ne réécrit que la transformation du dessin. La barre
// a la largeur de sa longueur ronde à l'échelle k (echelleGraphique, P1),
// jamais un rapport écrit en chiffres ; sa longueur se lit en cm, m ou km
// (libelleLongueur), le nombre dans son propre nœud. La mention dit que la
// disposition est relative : aucune dimension de salle n'est saisie.
import { getContext } from 'svelte';
import { echelleGraphique } from '../../geometrie/echelle.js';
import { LARGEUR_ECHELLE_PX, libelleLongueur } from './modele.js';
let { k } = $props();
const t = getContext('t');
const format = getContext('format');
const echelle = $derived(echelleGraphique(k, LARGEUR_ECHELLE_PX));
const longueur = $derived(libelleLongueur(echelle.longueurCm));
</script>
<div class="echelle">
<div class="graduation" role="img" aria-label={t('plan.echelle.nom')}>
<div class="barre" data-marque="echelle" style:width="{echelle.largeurPx}px"></div>
<span class="longueur"
><span>{format.nombre(longueur.valeur, { decimales: longueur.decimales })}</span>&#32;<span
>{t(longueur.cle)}</span
></span
>
</div>
<p class="mention">{t('plan.echelle.mention')}</p>
</div>
<style>
.echelle {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
color: var(--couleur-page-texte);
font-size: var(--taille-texte-petit);
}
.graduation {
display: flex;
align-items: center;
gap: var(--espace-2);
}
.barre {
box-sizing: border-box;
height: 0.5rem;
background: var(--couleur-echelle);
}
.mention {
margin: 0;
}
</style>

View file

@ -0,0 +1,62 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<svelte:options namespace="svg" />
<script>
// La liste de noms d'une table (§ 7.4, § 7.6) : un <text data-liste>, une
// ligne par siège, chacune un <tspan> à x et y explicites, précédée de
// l'en-tête d'une table gelée. Chaque partie d'une ligne — tête, nom,
// titre et ce qui l'entoure — est son propre tspan, nommé par
// data-partie ; le titre est en italique. La ligne entière tient dans
// largeur (ajusterLigne), mesurée par les mesureurs que PlanDessin crée
// sur la police rendue ; les lignes s'espacent de la hauteur d'encre du
// gabarit. La liste ne reçoit pas le pointeur : elle ne masque aucune
// chaise.
//
// Le texte est en white-space: pre, pour que les espaces des parties
// comptent comme elles ont été mesurées : le gabarit n'a aucun blanc entre
// les balises du <text>.
import { getContext } from 'svelte';
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
import { ajusterLigne, ordonneesLignes, partiesLigne, textesLigne } from './modele.js';
let { table, mesures, largeur } = $props();
const t = getContext('t');
const origine = $derived(table.geometrie.origineListe);
const reglage = $derived({ ...mesures, largeur, marque: t('plan.liste.abreviation') });
// Les rangées du bloc : l'en-tête, s'il y en a un, puis chaque ligne.
const rangees = $derived.by(() => {
const parties = table.lignes.map((ligne) => partiesLigne(ajusterLigne(textesLigne(ligne, t), reglage)));
if (table.enTete !== null) {
const enTete = t(table.enTete.cle, table.enTete.details);
const seule = { tete: enTete, teteSansNom: enTete, nom: null, avantTitre: null, titre: null, apresTitre: null };
parties.unshift(partiesLigne(ajusterLigne(seule, reglage)));
}
const ordonnees = ordonneesLignes(origine.y, parties.length, mesures.hauteur);
return parties.map((partiesDeLaLigne, rang) => ({ rang, y: ordonnees[rang], parties: partiesDeLaLigne }));
});
</script>
<text class="liste" data-liste={table.id} font-size={TAILLE_TEXTE_LISTE_CM}
>{#each rangees as rangee (rangee.rang)}<tspan x={origine.x} y={rangee.y}
>{#each rangee.parties as partie (partie.partie)}<tspan class:titre={partie.partie === 'titre'} data-partie={partie.partie}
>{partie.texte}</tspan
>{/each}</tspan
>{/each}</text
>
<style>
.liste {
fill: var(--couleur-liste-texte);
font-family: var(--police-interface);
white-space: pre;
pointer-events: none;
}
.titre {
font-style: italic;
}
</style>

View file

@ -0,0 +1,50 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<svelte:options namespace="svg" />
<script>
// Les motifs du plan (§ 7.5, § 7.6) : la hachure d'une place à pourvoir —
// des traits --couleur-hachure sur le fond de la chaise — et la trame d'une
// table en conflit — des traits --couleur-conflit sur le fond du plateau.
// Chaque motif peint son fond, opaque, puis ses traits : la couleur relue
// dans le style calculé est la couleur peinte. Les tailles sont en cm de
// dessin ; prefixe rend leurs identifiants uniques dans le document.
//
// Les cadenas et l'anneau ne sont pas des symboles référencés par <use> :
// chaque chaise les dessine elle-même (TablePlan), et les aides de rendu
// des épreuves lisent la peinture de chaque forme, jamais à travers une
// référence.
let { prefixe } = $props();
</script>
<defs>
<pattern id="{prefixe}-hachure" patternUnits="userSpaceOnUse" width="8" height="8" patternTransform="rotate(45)">
<rect class="fond-chaise" width="8" height="8" />
<line class="trait-hachure" x1="4" y1="0" x2="4" y2="8" />
</pattern>
<pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)">
<rect class="fond-table" width="12" height="12" />
<line class="trait-trame" x1="6" y1="0" x2="6" y2="12" />
</pattern>
</defs>
<style>
.fond-chaise {
fill: var(--couleur-chaise-fond);
}
.trait-hachure {
stroke: var(--couleur-hachure);
stroke-width: 3;
}
.fond-table {
fill: var(--couleur-table-fond);
}
.trait-trame {
stroke: var(--couleur-conflit);
stroke-width: 3;
}
</style>

View file

@ -0,0 +1,87 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<svelte:options namespace="svg" />
<script>
// Le dessin du plan (§ 7.1, § 7.3 à § 7.7), enfant du groupe <g data-vue>
// que Plan.svelte transforme : les motifs, les tables — plateaux, chaises,
// poignées, pastilles —, puis les listes de noms, peintes par-dessus et
// transparentes au pointeur. Le composant ne lit k qu'à travers le modèle
// (modele.js), qu'il reçoit entier ; aucun attribut qu'il écrit ne dépend
// de k hors du franchissement d'un seuil — régime des cibles, K_LISTES.
//
// Les mesures du texte se relèvent sur une sonde : un <text> caché au
// style des listes, et un tspan en italique pour les titres. La hauteur
// d'une ligne est la hauteur d'encre du gabarit (mesure_texte.js) ; les
// listes paraissent une fois les mesures prises, et la largeur de leur
// bloc est LARGEUR_BLOC_NOMS_CM du registre. Aucun <canvas>.
//
// Un plan monté caché — un ancêtre en display: none — n'a pas de mise en
// page : ses largeurs mesurées vaudraient 0. Les mesures ne se prennent
// donc que sur une sonde rendue, au montage, puis à chaque changement de
// taille du <svg> qui porte le dessin, qu'un ResizeObserver signale : un
// plan qui se cache perd ses mesures, et ses listes, et les reprend en se
// montrant.
import { untrack } from 'svelte';
import { constante } from '../../geometrie/constantes.js';
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
import ListeNoms from './ListeNoms.svelte';
import { creerMesureur, hauteurLigne, mesurable } from './mesure_texte.js';
import Motifs from './Motifs.svelte';
import TablePlan from './TablePlan.svelte';
let { modele } = $props();
const prefixe = $props.id();
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
let sonde = $state(null);
let sondeItalique = $state(null);
let mesures = $state(null);
// Prend les mesures quand la sonde est rendue et qu'il n'y en a pas ; les
// oublie quand elle ne l'est plus.
function actualiserMesures() {
if (!mesurable(sonde)) mesures = null;
else if (mesures === null) {
mesures = { hauteur: hauteurLigne(sonde), mesurer: creerMesureur(sonde), mesurerItalique: creerMesureur(sondeItalique) };
}
}
$effect(() => {
const cible = sonde.ownerSVGElement ?? sonde;
untrack(actualiserMesures);
const observateur = new ResizeObserver(actualiserMesures);
observateur.observe(cible);
return () => observateur.disconnect();
});
</script>
<Motifs {prefixe} />
{#each modele.tables as table (table.id)}
<TablePlan {table} signaler={modele.signalerConflits} {prefixe} />
{/each}
<text class="sonde" font-size={TAILLE_TEXTE_LISTE_CM} aria-hidden="true" bind:this={sonde}
><tspan class="titre" bind:this={sondeItalique}></tspan></text
>
{#if modele.listesRendues && mesures !== null}
{#each modele.tables as table (table.id)}
<ListeNoms {table} {mesures} {largeur} />
{/each}
{/if}
<style>
/* La sonde a le style des listes (ListeNoms.svelte) ; elle ne se voit pas
et ne porte de texte que le temps d'une mesure. */
.sonde {
font-family: var(--police-interface);
white-space: pre;
visibility: hidden;
pointer-events: none;
}
.titre {
font-style: italic;
}
</style>

View file

@ -0,0 +1,193 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<svelte:options namespace="svg" />
<script>
// Une table du plan (§ 7.1, § 7.3, § 7.5, § 7.6) : le groupe [data-table],
// qui ne porte que le plateau et les chaises, avec les marques posées sur
// eux ; puis, frères du groupe, la poignée [data-poignee] et la pastille
// [data-pastille]. Tout vient de la TableDessin du modèle (modele.js) ; le
// composant ne calcule rien.
//
// En régime table, les chaises ne se dessinent pas : l'emprise entière se
// peint au fond de la chaise et devient la seule cible, sous le plateau.
// En régime sièges, chaque chaise est un groupe [data-siege="table:siège"]
// dont seul le disque reçoit le pointeur : ses marques, comme la pastille,
// ont pointer-events: none, et la cible d'une chaise est le disque même que
// la désignation lit (designation.js).
//
// Une table en conflit, quand le plan signale, porte un contour épaissi
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
// pastille porte le nombre de violations, signalée ou non (§ 7.5).
import { getContext } from 'svelte';
import { RAYON_POIGNEE } from '../../geometrie/tables.js';
import {
CADENAS_FERME,
CADENAS_OUVERT,
ECART_ANNEAU,
ECART_MISE_EN_EVIDENCE,
RAYON_PASTILLE,
TAILLE_TEXTE_PASTILLE_CM,
TRACE_GEL,
formesTable,
} from './modele.js';
let { table, signaler, prefixe } = $props();
const t = getContext('t');
// La marque que porte le disque d'une chaise, selon son remplissage.
const MARQUE_DU_REMPLISSAGE = { normal: 'contourChaise', plein: 'remplissagePlein', hachure: 'hachure' };
const TRACE_DU_CADENAS = { cadenasFerme: CADENAS_FERME, cadenasOuvert: CADENAS_OUVERT };
const formes = $derived(formesTable(table.geometrie));
const poignee = $derived(table.geometrie.poignee);
const signal = $derived(signaler && table.conflit);
const hachure = $derived(`url(#${prefixe}-hachure)`);
const trame = $derived(`url(#${prefixe}-trame)`);
</script>
<g data-table={table.id} role="group" aria-label={t(table.nomAccessible.cle, table.nomAccessible.details)}>
{#if table.regime === 'table'}
{#if formes.ronde}
<circle class="emprise" data-marque="contourChaise" {...formes.emprise} />
{:else}
<rect class="emprise" data-marque="contourChaise" {...formes.emprise} />
{/if}
{/if}
{#if formes.ronde}
<circle class="plateau" class:conflit={signal} data-marque={signal ? 'contourConflit' : 'contourTable'} {...formes.plateau} />
{:else}
<rect class="plateau" class:conflit={signal} data-marque={signal ? 'contourConflit' : 'contourTable'} {...formes.plateau} />
{/if}
{#if signal}
{#if formes.ronde}
<circle class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
{:else}
<rect class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
{/if}
{/if}
{#if table.gelee}
<path class="gel" data-marque="gel" d={TRACE_GEL} transform="translate({formes.gel.x},{formes.gel.y})" />
{/if}
{#each table.chaises as chaise (chaise.siege)}
<g data-siege="{table.id}:{chaise.siege}">
<circle
class="chaise"
class:pleine={chaise.remplissage === 'plein'}
data-marque={MARQUE_DU_REMPLISSAGE[chaise.remplissage]}
style:fill={chaise.remplissage === 'hachure' ? hachure : null}
cx={chaise.x}
cy={chaise.y}
r={chaise.rayon}
/>
{#each chaise.marques as marque (marque)}
{#if marque === 'anneau'}
<circle class="anneau" data-marque="anneau" cx={chaise.x} cy={chaise.y} r={chaise.rayon + ECART_ANNEAU} />
{:else}
<path class="cadenas" data-marque={marque} d={TRACE_DU_CADENAS[marque]} transform="translate({chaise.x},{chaise.y})" />
{/if}
{/each}
{#if chaise.miseEnEvidence}
<circle class="evidence" data-marque="miseEnEvidence" cx={chaise.x} cy={chaise.y} r={chaise.rayon + ECART_MISE_EN_EVIDENCE} />
{/if}
</g>
{/each}
</g>
<circle class="poignee" data-poignee={table.id} data-marque="poignee" cx={poignee.x} cy={poignee.y} r={RAYON_POIGNEE} />
{#if table.pastille > 0}
<g class="pastille" data-pastille={table.id}>
<circle class="disque" data-marque="pastille" cx={formes.pastille.x} cy={formes.pastille.y} r={RAYON_PASTILLE} />
<text
class="nombre"
x={formes.pastille.x}
y={formes.pastille.y}
font-size={TAILLE_TEXTE_PASTILLE_CM}
text-anchor="middle"
dominant-baseline="central">{table.pastille}</text>
</g>
{/if}
<style>
/* Les traits gardent leur épaisseur à l'écran, à tout zoom (§ 7.2). */
.emprise,
.plateau,
.chaise,
.poignee {
vector-effect: non-scaling-stroke;
stroke-width: 1.5;
}
.emprise {
fill: var(--couleur-chaise-fond);
stroke: var(--couleur-chaise-trait);
}
.plateau {
fill: var(--couleur-table-fond);
stroke: var(--couleur-table-trait);
}
.plateau.conflit {
stroke: var(--couleur-conflit);
stroke-width: 4;
}
.trame {
stroke: none;
pointer-events: none;
}
.gel {
fill: var(--couleur-gel);
pointer-events: none;
}
.chaise {
fill: var(--couleur-chaise-fond);
stroke: var(--couleur-chaise-trait);
}
.chaise.pleine {
fill: var(--couleur-chaise-pleine);
}
.anneau {
fill: none;
stroke: var(--couleur-marque);
stroke-width: 4;
pointer-events: none;
}
.cadenas {
fill: var(--couleur-marque);
pointer-events: none;
}
.evidence {
fill: none;
stroke: var(--couleur-mise-en-evidence);
stroke-width: 4;
pointer-events: none;
}
.poignee {
fill: var(--couleur-poignee);
stroke: var(--couleur-poignee);
}
.pastille {
pointer-events: none;
}
.disque {
fill: var(--couleur-pastille-fond);
}
.nombre {
fill: var(--couleur-pastille-texte);
font-family: var(--police-interface);
font-weight: 700;
}
</style>

View file

@ -0,0 +1,68 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les mesures du texte du plan, au navigateur (§ 7.4, § 14.1) : la hauteur
// d'une ligne de liste et la largeur d'une chaîne, relevées sur la police
// rendue, jamais déduites de la taille de police. La hauteur se mesure dans
// un OffscreenCanvas, hors du document : le plan ne porte aucun <canvas>.
import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js';
const SVG = 'http://www.w3.org/2000/svg';
/**
* Hauteur d'encre du gabarit « Ôjgq Æ W » sur la police calculée de
* l'élément : actualBoundingBoxAscent + actualBoundingBoxDescent d'un
* OffscreenCanvas réglé sur elle — style, graisse, corps et famille. Le
* corps calculé d'un texte du dessin est en unités du dessin : la hauteur
* rendue est en cm, quel que soit le zoom.
*
* @param {Element} elementTexte
* @returns {number}
*/
export function hauteurLigne(elementTexte) {
const { fontStyle, fontWeight, fontSize, fontFamily } = getComputedStyle(elementTexte);
const contexte = new OffscreenCanvas(1, 1).getContext('2d');
contexte.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
const mesure = contexte.measureText(GABARIT_HAUTEUR_LIGNE);
return mesure.actualBoundingBoxAscent + mesure.actualBoundingBoxDescent;
}
/**
* La sonde est-elle rendue : un texte sous un ancêtre en display: none n'a
* pas de mise en page, et getComputedTextLength y rend 0 pour toute chaîne.
* La visibilité (visibility: hidden) n'y entre pas : la sonde du plan est
* cachée ainsi et se mesure.
*
* @param {Element} elementTexte
* @returns {boolean}
*/
export function mesurable(elementTexte) {
return elementTexte.isConnected && elementTexte.checkVisibility();
}
/**
* La mesure de largeur d'une chaîne au style de l'élément : un tspan sonde,
* ajouté à l'élément le temps de getComputedTextLength, puis retiré. Un
* élément en italique mesure en italique. Chaque chaîne se mesure une fois :
* la police calculée de l'élément est fixe pour la durée du mesureur. Une
* largeur nulle d'une chaîne non vide — l'élément n'est pas rendu — ne se
* retient pas : la chaîne se mesure de nouveau à la demande suivante.
*
* @param {Element} elementTexte un <text> ou un <tspan> du dessin
* @returns {(chaine: string) => number} largeur en unités du dessin
*/
export function creerMesureur(elementTexte) {
const deja = new Map();
return (chaine) => {
const connue = deja.get(chaine);
if (connue !== undefined) return connue;
const sonde = elementTexte.ownerDocument.createElementNS(SVG, 'tspan');
sonde.textContent = chaine;
elementTexte.append(sonde);
const largeur = sonde.getComputedTextLength();
sonde.remove();
if (largeur > 0 || chaine === '') deja.set(chaine, largeur);
return largeur;
};
}

View file

@ -0,0 +1,336 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de dessin du plan (§ 7.3 à § 7.7), sans DOM : ce que PlanDessin
// peint, tiré de l'état des places d'un tour (EtatPlaces, A5) et de la
// géométrie des tables (P2). Le modèle lit les conflits de l'état des
// places, jamais ne les recalcule (§ 7.5) ; il ne rend que des codes, des
// nombres et des données — noms, libellés de titre —, que les composants
// traduisent. Les mesures de texte sont injectées : getComputedTextLength
// au navigateur, une fonction quelconque sous node.
import { constante } from '../../geometrie/constantes.js';
import { regimeCibles } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js';
import { decalagesLignes, tronquer } from '../../geometrie/texte.js';
/** Largeur maximale de la barre de l'échelle graphique, en px d'écran. */
export const LARGEUR_ECHELLE_PX = 120;
/** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel
* d'une table de 2, la plus petite. */
export const RAYON_PASTILLE = 15;
/** Corps du chiffre de la pastille, en cm de dessin. */
export const TAILLE_TEXTE_PASTILLE_CM = 18;
/** Écart entre le bord d'une chaise et son anneau, ou sa mise en évidence, en cm. */
export const ECART_ANNEAU = 6;
export const ECART_MISE_EN_EVIDENCE = 12;
// Les cadenas d'une chaise (§ 7.6), en cm, centrés sur l'origine : un corps
// plein et une anse, fermée ou ouverte, remplis d'un seul tenant. Le dessin
// les translate au centre de la chaise. Le centre de la boîte de chacun
// tombe dans son corps.
const CORPS = 'M-8,-6 H8 V9 H-8 Z';
/** Le tracé du cadenas fermé, centré sur l'origine, en cm. */
export const CADENAS_FERME = `${CORPS} M-6,-6 V-10 A6,6 0 0 1 6,-10 V-6 H3.5 V-10 A3.5,3.5 0 0 0 -3.5,-10 V-6 Z`;
/** Le tracé du cadenas ouvert : la branche droite de l'anse ne rejoint pas le corps. */
export const CADENAS_OUVERT = `${CORPS} M-6,-6 V-13 A6,6 0 0 1 6,-13 V-10 H3.5 V-13 A3.5,3.5 0 0 0 -3.5,-13 V-6 Z`;
/** Distance entre le haut du plateau et le centre de la marque de gel, en cm. */
export const RETRAIT_GEL = 13;
/** Le tracé de la marque de gel, un hexagone plein centré sur l'origine, en cm. */
export const TRACE_GEL = 'M0,-8 L7,-4 L7,4 L0,8 L-7,4 L-7,-4 Z';
/**
* Les formes d'une table en attributs SVG, en cm : plateau et emprise —
* disques d'une ronde ({cx, cy, r}), carrés aux côtés parallèles aux axes
* d'une carrée ({x, y, width, height}) —, le centre de la pastille, celui du
* plateau, et celui de la marque de gel, dans le plateau, RETRAIT_GEL sous
* son haut.
*
* @param {import('../../geometrie/tables.js').GeometrieTable} geometrie
*/
export function formesTable({ forme, centre, demiTaille, rayonEmprise }) {
const ronde = forme === 'ronde';
const dessiner = (demi) =>
ronde ? { cx: centre.x, cy: centre.y, r: demi } : { x: centre.x - demi, y: centre.y - demi, width: 2 * demi, height: 2 * demi };
return {
ronde,
plateau: dessiner(demiTaille),
emprise: dessiner(rayonEmprise),
pastille: { x: centre.x, y: centre.y },
gel: { x: centre.x, y: centre.y - demiTaille + RETRAIT_GEL },
};
}
/**
* La ligne d'une chaise (§ 7.6) : son numéro, l'état de la place — réservée
* pour tous les tours, réservée au tour désigné, libre, ou rien pour un
* occupant sans réservation —, le nom de l'occupant, et le titre de la place.
* La réservation dit l'état de la place ; l'occupant, le nom : l'occupant
* d'une place titrée n'en devient pas titulaire (§ 4.1).
*
* Une réservation dont la titulaire n'occupe pas la chaise — une source
* qui assied une autre personne, ou personne, sur la place réservée — ne
* s'attribue pas à l'occupant : l'état devient plan.ligne.reserveeA, de
* mêmes portée et tour, et titulaire porte le nom de la personne réservée,
* que la ligne énonce dans sa propre partie.
*
* @param {Object} chaise EtatChaise
* @param {(id: number) => string} nomDe le nom affiché d'un participant
* @returns {{numero: number, etat: {cle: string, details: Object}|null, titulaire: string|null,
* nom: string|null, titre: {libelle: string, pourvu: boolean}|null}}
*/
export function ligneListe(chaise, nomDe) {
const { siege, occupant, reservation, titre } = chaise;
let etat = null;
let titulaire = null;
if (reservation !== null && reservation.participant !== occupant) {
etat = { cle: 'plan.ligne.reserveeA', details: { portee: reservation.portee, tour: reservation.tour } };
titulaire = nomDe(reservation.participant);
} else if (reservation !== null) {
etat =
reservation.portee === 'tous'
? { cle: 'plan.ligne.reserveeTous', details: {} }
: { cle: 'plan.ligne.reserveeTour', details: { tour: reservation.tour } };
} else if (occupant === null) {
etat = { cle: 'plan.ligne.libre', details: {} };
}
return {
numero: siege,
etat,
titulaire,
nom: occupant === null ? null : nomDe(occupant),
titre: titre === null ? null : { libelle: titre.libelle, pourvu: titre.pourvu },
};
}
/**
* Les textes d'une ligne, partie par partie : tete — numéro et état, suivis
* d'une espace quand un nom suit —, teteSansNom — la même sans l'espace,
* quand le nom tombe —, titulaire et suite, nom, avantTitre, titre — le
* libellé seul —, apresTitre ; null pour une partie absente. Leur
* concaténation, dans cet ordre et sans les formes « SansNom », est la
* ligne entière.
*
* Quand la ligne a une titulaire, l'état s'écrit autour de son nom : tete
* porte le numéro et le début de l'état, titulaire le nom de la personne
* réservée, suite la portée — suivie d'une espace quand un nom suit —, et
* suiteSansNom la même sans l'espace ; tete et teteSansNom sont alors
* égales. Sans titulaire, titulaire, suite et suiteSansNom valent null.
*
* @param {ReturnType<typeof ligneListe>} ligne
* @param {(cle: string, details?: Object) => string} t
*/
export function textesLigne({ numero, etat, titulaire = null, nom, titre }, t) {
const tete = (avecNom) => t('plan.ligne.tete', { numero, etat, avecNom });
const espace = nom === null ? '' : ' ';
const reservee = titulaire === null ? null : t(etat.cle, etat.details);
return {
tete: titulaire === null ? tete(nom !== null) : t('plan.ligne.teteReserveeA', { numero }),
teteSansNom: titulaire === null ? tete(false) : t('plan.ligne.teteReserveeA', { numero }),
titulaire,
suite: reservee === null ? null : `${reservee}${espace}`,
suiteSansNom: reservee,
nom,
avantTitre: titre === null ? null : t(titre.pourvu ? 'plan.ligne.avantTitrePourvu' : 'plan.ligne.avantTitreNonPourvu'),
titre: titre === null ? null : titre.libelle,
apresTitre: titre === null || titre.pourvu ? null : t('plan.ligne.apresTitreNonPourvu'),
};
}
/**
* La ligne qui tient dans largeur (§ 7.4) : le nom s'abrège, suivi de la
* marque, à la largeur que lui laissent les autres parties ; quand la
* marque n'y tient plus, le nom tombe — la tête, ou la suite, perd son
* espace — et le titre s'abrège à son tour ; quand il ne tient plus, il
* tombe avec ce qui l'entoure, puis le nom de la titulaire s'abrège dans sa
* partie ; enfin une tête encore trop large s'abrège seule. Le numéro, en
* tête, part le dernier.
*
* Les parties de même style se mesurent ensemble, comme le moteur de rendu
* les compose : tête, titulaire, suite, nom et avantTitre droits d'un
* tenant, le titre en italique, apresTitre droit ; un changement de style
* coupe la composition.
*
* @param {ReturnType<typeof textesLigne>} textes
* @param {{mesurer: (s: string) => number, mesurerItalique: (s: string) => number,
* largeur: number, marque: string}} mesures
* @returns {{tete: string, titulaire: string|null, suite: string|null, nom: string|null,
* avantTitre: string|null, titre: string|null, apresTitre: string|null}}
*/
export function ajusterLigne(textes, { mesurer, mesurerItalique, largeur, marque }) {
const { tete, teteSansNom, nom, avantTitre, titre, apresTitre } = textes;
const titulaire = textes.titulaire ?? null;
const suite = textes.suite ?? null;
const suiteSansNom = textes.suiteSansNom ?? null;
// Le début droit de la ligne, jusqu'au nom : tête, titulaire et suite.
const debutAvecNom = `${tete}${titulaire ?? ''}${suite ?? ''}`;
const debutSansNom = `${teteSansNom}${titulaire ?? ''}${suiteSansNom ?? ''}`;
// Largeur de ce qui suit le titre, puis du titre en italique.
const apres = apresTitre === null ? 0 : mesurer(apresTitre);
const italique = (texte) => (texte === null ? 0 : mesurerItalique(texte));
const vide = { nom: null, avantTitre: null, titre: null, apresTitre: null };
if (nom !== null) {
const reste = italique(titre) + apres;
const nomAjuste = tronquer(nom, largeur, (candidat) => mesurer(`${debutAvecNom}${candidat}${avantTitre ?? ''}`) + reste, marque);
if (nomAjuste !== '') return { tete, titulaire, suite, nom: nomAjuste, avantTitre, titre, apresTitre };
}
if (titre !== null) {
const debut = mesurer(`${debutSansNom}${avantTitre}`);
const titreAjuste = tronquer(titre, largeur, (candidat) => debut + italique(candidat) + apres, marque);
if (titreAjuste !== '') return { tete: teteSansNom, titulaire, suite: suiteSansNom, ...vide, avantTitre, titre: titreAjuste, apresTitre };
}
if (titulaire !== null) {
const titulaireAjuste = tronquer(titulaire, largeur, (candidat) => mesurer(`${teteSansNom}${candidat}${suiteSansNom}`), marque);
if (titulaireAjuste !== '') return { tete: teteSansNom, titulaire: titulaireAjuste, suite: suiteSansNom, ...vide };
}
return { tete: tronquer(teteSansNom, largeur, mesurer, marque), titulaire: null, suite: null, ...vide };
}
// L'ordre des parties d'une ligne, celui de la lecture.
const PARTIES = ['tete', 'titulaire', 'suite', 'nom', 'avantTitre', 'titre', 'apresTitre'];
/**
* Les parties présentes d'une ligne ajustée, dans l'ordre de la lecture :
* chacune son tspan, nommé par data-partie ; le titre seul en italique.
* @param {ReturnType<typeof ajusterLigne>} ligne
* @returns {Array<{partie: string, texte: string}>}
*/
export function partiesLigne(ligne) {
return PARTIES.filter((partie) => (ligne[partie] ?? null) !== null).map((partie) => ({ partie, texte: ligne[partie] }));
}
/**
* Les lignes de base des n lignes d'un bloc dont le haut est à hautY : la
* première une hauteur de ligne sous le haut, chacune une hauteur sous la
* précédente. hauteur est la hauteur d'encre du gabarit (§ 7.4) : deux
* lignes consécutives ne se chevauchent pas.
* @param {number} hautY @param {number} n @param {number} hauteur
* @returns {number[]}
*/
export function ordonneesLignes(hautY, n, hauteur) {
return decalagesLignes(n, hauteur).map((decalage) => hautY + hauteur + decalage);
}
/**
* La longueur d'une barre d'échelle en mots : sa valeur dans l'unité qui la
* rend lisible — cm sous le mètre, m sous le kilomètre, km au-delà —, le
* nombre de décimales qu'elle demande, et la clé de l'unité.
* @param {number} longueurCm 1, 2 ou 5 × 10ⁿ
* @returns {{valeur: number, decimales: number, cle: string}}
*/
export function libelleLongueur(longueurCm) {
let valeur = longueurCm;
let cle = 'plan.echelle.centimetres';
if (longueurCm >= 100000) {
valeur = longueurCm / 100000;
cle = 'plan.echelle.kilometres';
} else if (longueurCm >= 100) {
valeur = longueurCm / 100;
cle = 'plan.echelle.metres';
}
// Sous l'unité, une mantisse 1, 2 ou 5 × 10⁻ⁿ demande n décimales ; la
// marge absorbe l'arrondi du logarithme d'une puissance de dix.
const decimales = valeur >= 1 ? 0 : Math.ceil(-Math.log10(valeur) - 1e-9);
return { valeur, decimales, cle };
}
// Le bandeau du plan (§ 7.5) : vide tant qu'une table au moins est saine ;
// sinon la phrase, puis collisionPartout quand le diagnostic le prouve, puis
// les planchers — une seule appartenance, ou chaque groupe de collisions,
// ou l'absence de plancher prouvé. Les chiffres sont ceux du moteur, tels
// quels, sans somme ; sans diagnostic, la phrase seule.
function bandeauDe(toutesEnConflit, diagnostic) {
if (!toutesEnConflit) return [];
const entrees = [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }];
if (diagnostic === null) return entrees;
if (diagnostic.collisionPartout) entrees.push({ cle: 'plan.bandeau.collisionPartout', details: {} });
if (diagnostic.uneSeuleAppartenance) {
entrees.push({ cle: 'plan.bandeau.uneSeuleAppartenance', details: {} });
} else if (diagnostic.collisions.length > 0) {
for (const { groupe, effectif, plancherCumulees } of diagnostic.collisions) {
entrees.push({ cle: 'plan.bandeau.plancherGroupe', details: { groupe, effectif, plancherCumulees } });
}
} else {
entrees.push({ cle: 'plan.bandeau.sansPlancher', details: {} });
}
return entrees;
}
// Le dessin d'une table de l'état des places au zoom k.
function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) {
const { id, numero, capacite, gelee, surchargee, conflits } = etatTable;
const geometrie = geometrieTable(etatTable, capacite);
const regime = regimeCibles(k, geometrie);
const pastille = conflits === null ? 0 : conflits.nombre;
const chaises =
regime === 'table'
? []
: etatTable.chaises.map((chaise, i) => {
const { x, y, rayon } = geometrie.chaises[i];
return {
siege: chaise.siege,
x,
y,
rayon,
remplissage: chaise.remplissage,
marques: [...chaise.marques],
miseEnEvidence: miseEnEvidence !== null && chaise.occupant === miseEnEvidence,
};
});
return {
id,
geometrie,
regime,
conflit: pastille > 0,
pastille,
gelee,
surchargee,
nomAccessible: {
cle: 'plan.table.nom',
details: { numero, places: capacite, conflits: conflits === null ? null : conflits.nombre, gelee, surchargee },
},
chaises,
enTete: gelee ? { cle: 'plan.liste.gelee', details: {} } : null,
lignes: etatTable.chaises.map((chaise) => ligneListe(chaise, nomDe)),
};
}
/**
* Le modèle de dessin d'un tour (§ 7.3 à § 7.7).
*
* Chaque table, dans l'ordre de l'état des places, porte sa géométrie
* (geometrieTable, P2), son régime des cibles au zoom k (regimeCibles) —
* aucune chaise dessinée en régime table —, son conflit et sa pastille, lus
* dans conflits — null : ni conflit, ni pastille —, son nom accessible, et
* une ligne par siège, de 1 à la capacité, précédée de l'en-tête d'une
* table gelée. Quand toutes les tables sont en conflit, aucune n'est
* signalée — signalerConflits faux — et le bandeau énonce le diagnostic ;
* les pastilles gardent leur nombre. Les listes se rendent quand elles
* sont demandées et que k atteint K_LISTES (§ 17, point 6). La largeur du
* bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du
* registre, que le dessin lit.
*
* @param {Object} etatPlaces EtatPlaces (A5)
* @param {{k: number, listes: boolean, miseEnEvidence: number|null,
* nomDe: (id: number) => string, diagnostic?: Object|null}} options
* diagnostic : celui que rend diagnostiquer, compléments de M1 compris
* @returns {Object} ModeleDessin
*/
export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null }) {
const tables = etatPlaces.tables.map((etatTable) => tableDessin(etatTable, { k, miseEnEvidence, nomDe }));
const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit);
return {
tables,
signalerConflits: !toutesEnConflit,
bandeau: bandeauDe(toutesEnConflit, diagnostic),
listesRendues: listes && k >= constante('K_LISTES'),
};
}

View file

@ -0,0 +1,719 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de dessin du plan (§ 7.3, § 7.4, § 7.5, § 7.6, § 7.7),
// sans DOM. Le plan lit les conflits de l'état des places, jamais ne les
// recalcule ; l'épreuve, elle, importe le moteur et le stockage pour
// construire ses données et obtenir la valeur attendue (§ 19.1). Les noms
// d'épreuve sont inventés ; la petite démonstration livrée se lit, et sa
// variante se dérive, en mémoire depuis texteLivre.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces, instanceDe } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { regimeCibles } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js';
import { indexerPlan } from '../../moteur/configuration.js';
import { conflitsParTable } from '../../moteur/conflits.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
LARGEUR_ECHELLE_PX,
ajusterLigne,
formesTable,
libelleLongueur,
ligneListe,
modeleDessin,
ordonneesLignes,
partiesLigne,
textesLigne,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const K_LISTES = constante('K_LISTES');
// --- Les configurations d'épreuve -------------------------------------------
// Douze noms inventés : prénom (null pour un nom seul) et nom.
const NOMS = [
['Iris', 'Ombrelle'],
['Théo', 'Pervenche'],
['Ondine', 'Lacasse'],
[null, 'Grisaille'],
['Basile', 'Tourmaline'],
['Capucine', 'Arrosoir'],
['Aurèle', 'Cerfeuil'],
['Mélisande', 'Bruyère'],
['Firmin', 'Galet'],
['Clélia', 'Mistral'],
['Anatole', 'Houblon'],
['Solveig', 'Sarcelle'],
];
const personne = (id, nom, prenom, appartenance) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
// Les propositions de rechercher(configuration, {graine, nombre, arret:
// 4000}), passées par versFichier et posées dans la charge ; le compteur
// passe au-delà du dernier identifiant, et le plan est proposé.
function avecPropositions(charge, { graine = 7, nombre = 1 } = {}) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine, nombre, arret: 4000 });
charge.propositions = propositions.map((p) =>
versFichier(p, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 }),
);
charge.prochainsIds.proposition = nombre + 1;
charge.evenement.etat = 'propose';
return charge;
}
/** Douze personnes en deux appartenances de six — une seule avec uneSeule —,
* quatre tables rondes de 3, quatre tours, séparation active, et une
* proposition de rechercher(…, {graine: 7, nombre: 1, arret: 4000}). */
function toutesEnConflit({ uneSeule = false } = {}) {
const charge = creerCharge({ id: 'epreuve:toutes-en-conflit', nom: 'Toutes en conflit', siegesParDefaut: 3, tours: 4 });
charge.participants = NOMS.map(([prenom, nom], i) =>
personne(i + 1, nom, prenom, uneSeule || i % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons'),
);
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } }));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return avecPropositions(charge);
}
const FIXTURE = new URL('../../../test/fixtures/plan/toutes_en_conflit.gtt.json', import.meta.url);
// La petite démonstration livrée, relue en mémoire.
const petite = (cle = 'petite') => analyser(texteLivre(cle)).charge;
// nomDe sur les participants de la charge : prénom et nom, ou le nom seul.
const nomsDe = (charge) => {
const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`]));
return (id) => noms.get(id);
};
const options = (charge, autres = {}) => ({ k: 1, listes: true, miseEnEvidence: null, nomDe: nomsDe(charge), ...autres });
// Une copie de l'état des places où la table d'identifiant donné est saine.
function avecTableSaine(etat, id) {
return { ...etat, tables: etat.tables.map((table) => (table.id === id ? { ...table, conflits: { nombre: 0, regles: [] } } : table)) };
}
// --- Les conflits se lisent ---------------------------------------------------
describe('modeleDessin : les conflits se lisent, jamais ne se recalculent (§ 7.5)', () => {
test('petite démonstration, conflit inévitable : tables signalées et pastilles de chaque tour égalent conflitsParTable', () => {
const charge = avecPropositions(petite('petite-conflit'));
const instance = instanceDe(charge);
const attendus = conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(charge.propositions[0])));
let signalees = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
const modele = modeleDessin(etat, options(charge));
assert.equal(modele.tables.length, 4);
const toutes = attendus[tour - 1].every(({ nombre }) => nombre > 0);
assert.equal(modele.signalerConflits, !toutes, `tour ${tour}`);
const nombreDe = new Map(attendus[tour - 1].map(({ table, nombre }) => [table, nombre]));
for (const table of modele.tables) {
const lu = etat.tables.find(({ id }) => id === table.id).conflits;
assert.equal(table.pastille, lu.nombre);
assert.equal(table.pastille, nombreDe.get(table.id), `tour ${tour}, table ${table.id}`);
assert.equal(table.conflit, lu.nombre > 0);
if (table.conflit && modele.signalerConflits) signalees += 1;
}
}
assert.ok(signalees > 0, 'la variante conflit signale au moins une table, à un tour où une autre est saine');
});
test('conflits null — non évalués — : ni conflit, ni pastille', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const sansEvaluation = { ...etat, evalue: false, tables: etat.tables.map((table) => ({ ...table, conflits: null })) };
const modele = modeleDessin(sansEvaluation, options(charge));
assert.equal(modele.tables.length, 4);
for (const table of modele.tables) {
assert.equal(table.conflit, false);
assert.equal(table.pastille, 0);
assert.deepEqual(table.nomAccessible.details.conflits, null);
}
assert.equal(modele.signalerConflits, true);
assert.deepEqual(modele.bandeau, []);
});
test('toutes en conflit : le fichier d\u{2019}épreuve se relit, et le plan cesse de signaler', () => {
const texte = readFileSync(FIXTURE, 'utf8');
const attendu = toutesEnConflit();
assert.equal(texte, serialiser(attendu, { revision: 1, produitVersion: 'epreuve' }), 'le fichier est la configuration construite');
const { charge } = analyser(texte);
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
const modele = modeleDessin(etat, options(charge));
assert.equal(modele.tables.length, 4);
assert.equal(modele.signalerConflits, false);
for (const table of modele.tables) {
assert.ok(table.conflit);
assert.ok(table.pastille > 0);
assert.equal(table.pastille, etat.tables.find(({ id }) => id === table.id).conflits.nombre);
}
assert.deepEqual(modele.bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]);
const sain = modeleDessin(avecTableSaine(etat, 2), options(charge));
assert.equal(sain.signalerConflits, true, 'une table saine rétablit le signalement');
assert.deepEqual(sain.bandeau, []);
assert.deepEqual(
sain.tables.map(({ id, conflit }) => [id, conflit]),
[
[1, true],
[2, false],
[3, true],
[4, true],
],
);
}
});
test('aucune table : aucun dessin, un bandeau vide', () => {
const charge = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 });
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge));
assert.deepEqual(modele.tables, []);
assert.deepEqual(modele.bandeau, []);
assert.equal(modele.signalerConflits, true);
});
});
// --- Le bandeau énonce le diagnostic -------------------------------------------
describe('modeleDessin : le bandeau énonce le diagnostic (§ 7.5)', () => {
const lire = (charge) => etatPlaces(charge, { tour: 1, proposition: 1 });
test('deux appartenances de six : la phrase, collisionPartout, puis un plancher par groupe, chiffres du moteur', () => {
const charge = toutesEnConflit();
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
assert.equal(diagnostic.collisionPartout, true);
assert.equal(diagnostic.uneSeuleAppartenance, false);
assert.equal(diagnostic.collisions.length, 2);
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.deepEqual(bandeau, [
{ cle: 'plan.bandeau.toutesEnConflit', details: {} },
{ cle: 'plan.bandeau.collisionPartout', details: {} },
...diagnostic.collisions.map(({ groupe, effectif, plancherCumulees }) => ({
cle: 'plan.bandeau.plancherGroupe',
details: { groupe, effectif, plancherCumulees },
})),
]);
assert.deepEqual(
bandeau.slice(2).map(({ details }) => details.effectif),
[6, 6],
);
for (const { cle, details } of bandeau) assert.equal(typeof t(cle, details), 'string');
});
test('une seule appartenance : l\u{2019}entrée uneSeuleAppartenance, sans plancher par groupe', () => {
const charge = toutesEnConflit({ uneSeule: true });
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
assert.equal(diagnostic.uneSeuleAppartenance, true);
const { bandeau, signalerConflits } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.equal(signalerConflits, false);
assert.deepEqual(
bandeau.map(({ cle }) => cle),
[
'plan.bandeau.toutesEnConflit',
...(diagnostic.collisionPartout ? ['plan.bandeau.collisionPartout'] : []),
'plan.bandeau.uneSeuleAppartenance',
],
);
});
test('un diagnostic sans plancher : la phrase, puis l\u{2019}entrée qui le dit', () => {
const charge = toutesEnConflit();
const diagnostic = { collisions: [], uneSeuleAppartenance: false, collisionPartout: false };
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.deepEqual(bandeau, [
{ cle: 'plan.bandeau.toutesEnConflit', details: {} },
{ cle: 'plan.bandeau.sansPlancher', details: {} },
]);
});
test('diagnostic null : la phrase seule', () => {
const charge = toutesEnConflit();
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic: null }));
assert.deepEqual(bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]);
});
test('la petite démonstration : collisionPartout faux, son entrée absente', () => {
const diagnostic = diagnostiquer(configurationDepuisCharge(petite()));
assert.equal(diagnostic.collisionPartout, false);
const charge = toutesEnConflit();
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.equal(bandeau[0].cle, 'plan.bandeau.toutesEnConflit');
assert.ok(!bandeau.some(({ cle }) => cle === 'plan.bandeau.collisionPartout'));
});
test('une table saine : le bandeau reste vide, diagnostic donné ou non', () => {
const charge = toutesEnConflit();
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
const { bandeau } = modeleDessin(avecTableSaine(lire(charge), 3), options(charge, { diagnostic }));
assert.deepEqual(bandeau, []);
});
});
// --- La source --------------------------------------------------------------------
describe('modeleDessin : les lignes nomment les occupants de la source (contrat)', () => {
test('proposition 2 demandée → ses occupants ; sans demande → ceux du retenu, tiré de la proposition 1', () => {
const charge = avecPropositions(petite(), { graine: 11, nombre: 2 });
const [premiere] = charge.propositions;
const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(premiere);
charge.retenu = { proposition: 1, siegesAttribues, tables, capacites, tours, participants, placement };
charge.evenement.etat = 'retenu';
const nomDe = nomsDe(charge);
// Les noms des lignes de chaque table, dans l'ordre des tables.
const nomsDesLignes = (etat) => modeleDessin(etat, options(charge)).tables.map(({ lignes }) => lignes.map(({ nom }) => nom));
// Les noms qu'une proposition assied au tour 1, table par table.
const nomsDeLaSource = (p) => charge.tables.map(({ id }) => p.placement[0].sieges[p.tables.indexOf(id)].map(nomDe).sort());
const montree = etatPlaces(charge, { tour: 1, proposition: 2 });
assert.equal(montree.source, 'proposition');
const retenu = etatPlaces(charge, { tour: 1 });
assert.equal(retenu.source, 'retenu');
const deLaDeux = nomsDesLignes(montree).map((noms) => [...noms].sort());
const duRetenu = nomsDesLignes(retenu).map((noms) => [...noms].sort());
assert.deepEqual(deLaDeux, nomsDeLaSource(charge.propositions[1]));
assert.deepEqual(duRetenu, nomsDeLaSource(charge.retenu));
assert.notDeepEqual(deLaDeux, duRetenu, 'les deux sources assoient autrement au tour 1');
});
});
// --- Les lignes de la liste --------------------------------------------------------
// Une chaise de l'état des places, comme etatPlaces la rend.
const chaise = (siege, { occupant = null, reservation = null, titre = null } = {}) => ({
siege,
occupant,
reservation,
titre,
remplissage: 'normal',
marques: [],
});
const NOMS_LIGNES = new Map([
[1, 'Iris Ombrelle'],
[2, 'Théo Pervenche'],
[3, 'Ondine Lacasse'],
[4, 'Grisaille'],
]);
const nomDeLigne = (id) => NOMS_LIGNES.get(id);
const animateur = (pourvu) => ({ libelle: 'animateur', pourvu });
// Les six formes de la décision : chaise, ligne attendue, texte attendu.
const SIX_FORMES = [
[
chaise(1, { occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: animateur(true) }),
{ numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, titulaire: null, nom: 'Iris Ombrelle', titre: animateur(true) },
'1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
],
[
chaise(2, { occupant: 2, reservation: { participant: 2, portee: 'tour', tour: 2 } }),
{ numero: 2, etat: { cle: 'plan.ligne.reserveeTour', details: { tour: 2 } }, titulaire: null, nom: 'Théo Pervenche', titre: null },
'2. (réservée, tour 2) Théo Pervenche',
],
[chaise(3, { occupant: 3 }), { numero: 3, etat: null, titulaire: null, nom: 'Ondine Lacasse', titre: null }, '3. Ondine Lacasse'],
[
chaise(4, { occupant: 4, titre: animateur(false) }),
{ numero: 4, etat: null, titulaire: null, nom: 'Grisaille', titre: animateur(false) },
'4. Grisaille \u{2014} titre animateur non pourvu',
],
[chaise(5), { numero: 5, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: null }, '5. (libre)'],
[
chaise(6, { titre: animateur(false) }),
{ numero: 6, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: animateur(false) },
'6. (libre) \u{2014} titre animateur non pourvu',
],
];
// Le texte entier d'une ligne : ses parties dans l'ordre, les absentes omises.
const joindre = ({ tete, titulaire, suite, nom, avantTitre, titre, apresTitre }) =>
[tete, titulaire, suite, nom, avantTitre, titre, apresTitre].filter((partie) => partie !== null).join('');
describe('ligneListe et textesLigne : les six formes (§ 7.6)', () => {
test('chaque forme, exactement, titre valant {libelle, pourvu}', () => {
assert.equal(SIX_FORMES.length, 6);
for (const [entree, ligne, texte] of SIX_FORMES) {
assert.deepEqual(ligneListe(entree, nomDeLigne), ligne);
assert.equal(joindre(textesLigne(ligne, t)), texte);
}
});
test('le titre est sa propre partie, le libellé seul ; le nom aussi', () => {
const textes = textesLigne(SIX_FORMES[0][1], t);
assert.equal(textes.nom, 'Iris Ombrelle');
assert.equal(textes.titre, 'animateur');
assert.equal(textes.tete, '1. (réservée, tous les tours) ');
assert.equal(textes.teteSansNom, '1. (réservée, tous les tours)');
assert.equal(textesLigne(SIX_FORMES[2][1], t).titre, null);
});
});
// Mesures factices : une unité par unité de code, 1,5 en italique.
const droit = (chaine) => chaine.length;
const italique = (chaine) => 1.5 * chaine.length;
const MARQUE = '\u{2026}';
const largeurDe = (ligne) =>
droit(`${ligne.tete}${ligne.titulaire ?? ''}${ligne.suite ?? ''}${ligne.nom ?? ''}${ligne.avantTitre ?? ''}`) +
italique(ligne.titre ?? '') +
droit(ligne.apresTitre ?? '');
// --- Une place réservée qu'occupe une autre personne -----------------------------------
describe('ligneListe : la réservation d\u{2019}une autre personne que l\u{2019}occupant (§ 7.6)', () => {
test('petite démonstration, une réservation posée après la proposition : la ligne nomme sa titulaire, puis l\u{2019}occupant', () => {
const charge = petite();
const configuration = configurationDepuisCharge(charge);
const [proposition] = rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 });
charge.propositions = [versFichier(proposition, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 })];
charge.prochainsIds.proposition = 2;
charge.evenement.etat = 'propose';
charge.reservations.push({ participant: 1, table: 1, siege: 2, portee: 'tous', tour: null });
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
const chaise2 = etat.tables[0].chaises[1];
assert.equal(chaise2.reservation.participant, 1);
assert.equal(etat.tables[0].chaises.some(({ occupant }) => occupant === 1), false, 'la proposition n\u{2019}assied pas la titulaire à la table 1');
assert.notEqual(chaise2.occupant, null);
const nomDe = nomsDe(charge);
const titulaire = nomDe(1);
const occupant = nomDe(chaise2.occupant);
const ligne = ligneListe(chaise2, nomDe);
assert.deepEqual(ligne, {
numero: 2,
etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tous', tour: null } },
titulaire,
nom: occupant,
titre: null,
});
assert.deepEqual(modeleDessin(etat, options(charge)).tables[0].lignes[1], ligne);
const textes = textesLigne(ligne, t);
assert.equal(joindre(textes), `2. (réservée à ${titulaire}, tous les tours) ${occupant}`);
// La titulaire et l'occupant occupent chacun leur partie, donc leur tspan.
const parties = partiesLigne(ajusterLigne(textes, { mesurer: droit, mesurerItalique: italique, largeur: 1000, marque: MARQUE }));
assert.deepEqual(
parties.filter(({ partie }) => partie === 'titulaire' || partie === 'nom'),
[
{ partie: 'titulaire', texte: titulaire },
{ partie: 'nom', texte: occupant },
],
);
});
test('réservée au seul tour 3 pour une autre personne, la place vide : « 2. (réservée à …, tour 3) »', () => {
const ligne = ligneListe(chaise(2, { reservation: { participant: 1, portee: 'tour', tour: 3 } }), nomDeLigne);
assert.deepEqual(ligne, {
numero: 2,
etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tour', tour: 3 } },
titulaire: 'Iris Ombrelle',
nom: null,
titre: null,
});
assert.equal(joindre(textesLigne(ligne, t)), '2. (réservée à Iris Ombrelle, tour 3)');
});
test('trop étroite : le nom de l\u{2019}occupant s\u{2019}abrège, puis tombe ; la titulaire s\u{2019}abrège, dans sa partie', () => {
const entree = textesLigne(
ligneListe(chaise(2, { occupant: 2, reservation: { participant: 1, portee: 'tous', tour: null } }), nomDeLigne),
t,
);
const pleine = joindre(entree).length;
const abregee = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: pleine - 3, marque: MARQUE });
assert.equal(abregee.titulaire, 'Iris Ombrelle');
assert.ok(abregee.nom.endsWith(MARQUE));
assert.ok(largeurDe(abregee) <= pleine - 3);
const sansNom = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(sansNom.nom, null);
assert.ok(sansNom.titulaire.endsWith(MARQUE), sansNom.titulaire);
assert.equal(sansNom.suite, entree.suiteSansNom);
assert.ok(largeurDe(sansNom) <= 40, `${largeurDe(sansNom)}`);
});
});
// --- La ligne entière tient dans la largeur du bloc ------------------------------------
describe('ajusterLigne : la ligne entière tient (§ 7.4)', () => {
const textes = (nom, titre = null, pourvu = true) =>
textesLigne({ numero: 1, etat: null, nom, titre: titre === null ? null : { libelle: titre, pourvu } }, t);
test('une ligne qui tient reste entière', () => {
const entree = textes('Iris Ombrelle', 'animateur');
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 100, marque: MARQUE });
assert.deepEqual(ligne, {
tete: entree.tete,
titulaire: null,
suite: null,
nom: entree.nom,
avantTitre: entree.avantTitre,
titre: entree.titre,
apresTitre: null,
});
});
test('le nom s\u{2019}abrège à la largeur que lui laissent les autres parties, mesurées chacune dans son style', () => {
const entree = textes('W'.repeat(200), 'animateur');
// tête « 1. » + espace : 3 ; « — » entouré : 3 ; titre en italique : 13,5.
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(ligne.titre, 'animateur');
assert.ok(ligne.nom.endsWith(MARQUE));
assert.ok(largeurDe(ligne) <= 40);
assert.ok(largeurDe(ligne) > 39, `le nom prend toute la place restante : ${largeurDe(ligne)}`);
});
test('sans place pour la marque, le nom tombe et le titre s\u{2019}abrège', () => {
const entree = textes('Iris Ombrelle', 'a'.repeat(30), false);
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(ligne.nom, null);
assert.equal(ligne.tete, entree.teteSansNom);
assert.ok(ligne.titre.endsWith(MARQUE));
assert.equal(ligne.apresTitre, entree.apresTitre);
assert.ok(largeurDe(ligne) <= 40);
});
test('sans place pour le titre, il tombe avec ce qui l\u{2019}entoure ; une tête trop large s\u{2019}abrège', () => {
const entree = textesLigne(
{ numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } },
t,
);
const etroite = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 20, marque: MARQUE });
assert.deepEqual([etroite.nom, etroite.avantTitre, etroite.titre, etroite.apresTitre], [null, null, null, null]);
assert.ok(etroite.tete.startsWith('1. (r'));
assert.ok(etroite.tete.endsWith(MARQUE));
assert.ok(largeurDe(etroite) <= 20);
});
test('chaque ligne d\u{2019}un bloc ajusté tient, quelle que soit la largeur', () => {
const lignes = SIX_FORMES.map(([, ligne]) => textesLigne(ligne, t));
assert.equal(lignes.length, 6);
for (const largeur of [5, 12, 20, 40, 80, 200]) {
for (const entree of lignes) {
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur, marque: MARQUE });
assert.ok(largeurDe(ligne) <= largeur, `${joindre(entree)} à ${largeur} : ${largeurDe(ligne)}`);
}
}
});
});
describe('ordonneesLignes : une ligne sous la précédente', () => {
test('la première ligne sous le haut du bloc, d\u{2019}une hauteur de ligne ; puis une hauteur de plus chacune', () => {
assert.deepEqual(ordonneesLignes(-50, 3, 20), [-30, -10, 10]);
assert.deepEqual(ordonneesLignes(0, 0, 20), []);
});
});
// --- Régime, listes, marques, bords ------------------------------------------------------
// Une charge de tables rondes de capacités données, des personnes inventées.
function chargeDeTables(capacites, { tours = 1 } = {}) {
const charge = creerCharge({ id: 'epreuve:tables', nom: 'Tables', siegesParDefaut: 8, tours });
charge.tables = capacites.map((sieges, i) => ({
id: i + 1,
numero: i + 1,
sieges,
forme: i % 2 === 0 ? 'ronde' : 'carree',
position: { x: 400 * i, y: 0 },
}));
charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom, null));
charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 };
return charge;
}
describe('modeleDessin : le dessin d\u{2019}une table (§ 7.3, § 7.6, § 17 point 6)', () => {
test('géométrie de geometrieTable ; chaises dessinées en régime sièges, aucune en régime table', () => {
const charge = chargeDeTables([8, 12, 2]);
const etat = etatPlaces(charge, { tour: 1 });
for (const k of [0.39, 0.41, 2]) {
const modele = modeleDessin(etat, options(charge, { k }));
assert.equal(modele.tables.length, 3);
for (const table of modele.tables) {
const enregistrement = charge.tables.find(({ id }) => id === table.id);
const geometrie = geometrieTable(enregistrement, etat.tables.find(({ id }) => id === table.id).capacite);
assert.deepEqual(table.geometrie, geometrie);
assert.equal(table.regime, regimeCibles(k, geometrie));
if (table.regime === 'table') assert.deepEqual(table.chaises, []);
else {
assert.equal(table.chaises.length, geometrie.chaises.length);
table.chaises.forEach((dessin, i) => {
const { siege, x, y, rayon } = geometrie.chaises[i];
assert.deepEqual({ siege: dessin.siege, x: dessin.x, y: dessin.y, rayon: dessin.rayon }, { siege, x, y, rayon });
});
}
}
}
const regimes = (k) => modeleDessin(etat, options(charge, { k })).tables.map(({ regime }) => regime);
assert.deepEqual(regimes(0.39), ['table', 'table', 'sieges']);
assert.deepEqual(regimes(0.41), ['sieges', 'sieges', 'sieges']);
});
test('listes rendues : demandées et k ≥ K_LISTES', () => {
const charge = chargeDeTables([8]);
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES - 0.01 })).listesRendues, false);
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES })).listesRendues, true);
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES + 0.01 })).listesRendues, true);
assert.equal(modeleDessin(etat, options(charge, { k: 5, listes: false })).listesRendues, false);
assert.equal('largeurListe' in modeleDessin(etat, options(charge)), false, 'la largeur du bloc est celle du registre');
});
test('une ligne par siège, de 1 à la capacité, quel que soit le régime', () => {
const charge = chargeDeTables([8, 2]);
const etat = etatPlaces(charge, { tour: 1 });
const modele = modeleDessin(etat, options(charge, { k: 0.1 }));
assert.deepEqual(
modele.tables.map(({ lignes }) => lignes.map(({ numero }) => numero)),
[
[1, 2, 3, 4, 5, 6, 7, 8],
[1, 2],
],
);
});
test('remplissage, marques et mise en évidence reprennent l\u{2019}état des places, au tour montré', () => {
const charge = chargeDeTables([8], { tours: 2 });
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 },
];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
for (const tour of [1, 2]) {
const etat = etatPlaces(charge, { tour });
const modele = modeleDessin(etat, options(charge, { miseEnEvidence: 1 }));
const [table] = modele.tables;
assert.equal(table.chaises.length, 8);
table.chaises.forEach((dessin, i) => {
const lue = etat.tables[0].chaises[i];
assert.equal(dessin.remplissage, lue.remplissage);
assert.deepEqual(dessin.marques, lue.marques);
assert.equal(dessin.miseEnEvidence, lue.occupant === 1);
});
assert.equal(table.chaises.filter(({ miseEnEvidence }) => miseEnEvidence).length, 1);
}
const [tour1] = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge)).tables;
assert.deepEqual(tour1.chaises.slice(0, 3).map(({ marques }) => marques), [['cadenasFerme', 'anneau'], ['cadenasOuvert'], ['cadenasFerme']]);
assert.deepEqual(tour1.chaises.slice(0, 3).map(({ remplissage }) => remplissage), ['plein', 'hachure', 'plein']);
assert.ok(tour1.chaises.every(({ miseEnEvidence }) => miseEnEvidence === false), 'aucune mise en évidence demandée');
});
test('une table gelée le dit dans son nom accessible et en tête de sa liste ; une surchargée, dans son nom', () => {
const charge = chargeDeTables([2, 3]);
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 2, portee: 'tous', tour: null },
];
charge.evenement.siegesParDefaut = 3;
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.tables[0].gelee, true);
const [gelee, autre] = modeleDessin(etat, options(charge)).tables;
assert.equal(gelee.gelee, true);
assert.deepEqual(gelee.enTete, { cle: 'plan.liste.gelee', details: {} });
assert.equal(gelee.nomAccessible.cle, 'plan.table.nom');
assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /gelée/);
assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /surchargée/);
assert.equal(autre.gelee, false);
assert.equal(autre.enTete, null);
assert.doesNotMatch(t(autre.nomAccessible.cle, autre.nomAccessible.details), /gelée/);
assert.match(t(autre.nomAccessible.cle, autre.nomAccessible.details), /^Table 2, 3 places, .*surchargée$/);
});
test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => {
const charge = chargeDeTables([4]);
charge.participants = charge.participants.slice(0, 1);
charge.prochainsIds.participant = 2;
charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
const etat = etatPlaces(charge, { tour: 1 });
const [table] = modeleDessin(etat, options(charge)).tables;
const textes = table.lignes.map((ligne) => joindre(textesLigne(ligne, t)));
assert.deepEqual(textes, [
'1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
'2. (libre) \u{2014} titre animateur non pourvu',
'3. (libre)',
'4. (libre)',
]);
});
});
describe('libelleLongueur : la longueur de la barre en mots (§ 7.7)', () => {
test('cm sous le mètre, m sous le kilomètre, km au-delà ; décimales d\u{2019}une longueur sous l\u{2019}unité', () => {
assert.deepEqual(libelleLongueur(50), { valeur: 50, decimales: 0, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(200), { valeur: 2, decimales: 0, cle: 'plan.echelle.metres' });
assert.deepEqual(libelleLongueur(500000), { valeur: 5, decimales: 0, cle: 'plan.echelle.kilometres' });
assert.deepEqual(libelleLongueur(0.5), { valeur: 0.5, decimales: 1, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.02), { valeur: 0.02, decimales: 2, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.1), { valeur: 0.1, decimales: 1, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.01), { valeur: 0.01, decimales: 2, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(1), { valeur: 1, decimales: 0, cle: 'plan.echelle.centimetres' });
assert.ok(LARGEUR_ECHELLE_PX > 0);
});
});
describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1, § 7.7)', () => {
test('ronde : disques du plateau et de l\u{2019}emprise ; carrée : carrés aux côtés parallèles aux axes', () => {
const ronde = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 100, y: -40 } }, 8);
const formes = formesTable(ronde);
assert.equal(formes.ronde, true);
assert.deepEqual(formes.plateau, { cx: 100, cy: -40, r: ronde.demiTaille });
assert.deepEqual(formes.emprise, { cx: 100, cy: -40, r: ronde.rayonEmprise });
const carree = geometrieTable({ id: 2, numero: 2, forme: 'carree', position: { x: 0, y: 0 } }, 12);
const deCarree = formesTable(carree);
assert.equal(deCarree.ronde, false);
assert.deepEqual(deCarree.plateau, { x: -90, y: -90, width: 180, height: 180 });
assert.deepEqual(deCarree.emprise, {
x: -carree.rayonEmprise,
y: -carree.rayonEmprise,
width: 2 * carree.rayonEmprise,
height: 2 * carree.rayonEmprise,
});
});
test('la pastille au centre du plateau ; la marque de gel dans le plateau, au-dessus de la pastille', () => {
for (const places of [2, 8, 16]) {
const geometrie = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 10, y: 20 } }, places);
const { pastille, gel } = formesTable(geometrie);
assert.deepEqual(pastille, { x: 10, y: 20 });
assert.equal(gel.x, 10);
assert.ok(gel.y < 20 && gel.y > 20 - geometrie.demiTaille, `${places} places : ${gel.y}`);
}
});
});
describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => {
test('chaque partie présente, nommée ; les absentes omises', () => {
const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null };
assert.deepEqual(partiesLigne(ligne), [
{ partie: 'tete', texte: '1. ' },
{ partie: 'nom', texte: 'Iris Ombrelle' },
{ partie: 'avantTitre', texte: ' \u{2014} ' },
{ partie: 'titre', texte: 'animateur' },
]);
assert.deepEqual(partiesLigne({ tete: '5. (libre)', titulaire: null, suite: null, nom: null, avantTitre: null, titre: null, apresTitre: null }), [
{ partie: 'tete', texte: '5. (libre)' },
]);
});
});

View file

@ -0,0 +1,269 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le nombre de places par défaut (§ 6.1) : son champ, puis, avant toute
// application, l'énoncé chiffré de son changement. Une saisie validée ne
// s'applique pas : elle devient la demande, dont l'énoncé se calcule sur
// la charge courante (bilanChangementDefaut, enonceDefaut). Accepté, il
// offre « Appliquer » et, quand des tables sont surchargées, « Appliquer
// et réaligner », dont la fenêtre à confirmer nomme les surcharges
// perdues ; refusé, il nomme chaque table qui refuse et n'offre aucune
// application. Le réalignement a son propre bilan, avec realigner : une
// surchargée qui ne peut descendre au nouveau défaut le refuse. Refusé, le
// clic n'ouvre aucune fenêtre ; le refus se pose sous les commandes,
// chaque table nommée par son numéro, et « Appliquer » reste offert. « Garder le défaut actuel » retire la demande et remonte le
// champ, qui rend la valeur enregistrée. Un refus de l'événement entier —
// moins de 2 places — se pose sur le champ (refusDuChampDefaut).
//
// La demande tombe quand le défaut l'atteint ; appliquée, elle tombe, et
// les avertissements du geste s'affichent tant que la révision qui les a
// produits est la courante.
import { getContext } from 'svelte';
import { bilanChangementDefaut } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js';
import { ErreurCommande } from '../../application/erreurs.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import { actionRemede } from '../primitives/commande.js';
import { avertissementsNommes, enonceDefaut, refusDuChampDefaut, surchargesPerdues } from './modele.js';
let { charge, revision } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
// Le défaut demandé, pas encore appliqué ; le rang du champ, qui le
// remonte ; la fenêtre du réalignement ; le rendu du dernier geste.
let demande = $state(null);
let rangChamp = $state(0);
let fenetreRealigner = $state.raw(null);
let realignementTente = $state(false);
let dernier = $state.raw(null);
const enAttente = $derived(demande !== null && demande !== charge.evenement.siegesParDefaut ? demande : null);
const bilan = $derived(enAttente === null ? null : bilanChangementDefaut(charge, enAttente));
const entrees = $derived(bilan === null ? [] : enonceDefaut(bilan, charge.tables));
const refuse = $derived(bilan !== null && bilan.refus.length > 0);
const bilanRealigne = $derived(
bilan === null || refuse || bilan.surchargees.length === 0
? null
: bilanChangementDefaut(charge, enAttente, { realigner: true }),
);
const refusRealigne = $derived(
realignementTente && bilanRealigne !== null && bilanRealigne.refus.length > 0
? enonceDefaut(bilanRealigne, charge.tables, { realigner: true })
: [],
);
const avertissements = $derived(
dernier !== null && dernier.revision === revision ? avertissementsNommes(charge, dernier.avertissements) : [],
);
// Le refus courant de la séance, levé avant d'ouvrir une fenêtre : la
// séance l'aurait levé à l'exécution, et la fenêtre n'annonce rien qui ne
// s'appliquerait pas.
function exigerModifiable() {
const refus = seance.etat().refus;
if (refus !== null) throw new ErreurCommande(refus.code, refus.details);
}
// Une saisie lue : le bilan de ce défaut, son refus de l'événement entier
// posé sur le champ, sinon la demande.
function demander(sieges) {
const refus = refusDuChampDefaut(bilanChangementDefaut(charge, sieges));
if (refus !== null) throw new ErreurCommande(refus.code, refus.details);
demande = sieges;
realignementTente = false;
dernier = null;
}
async function appliquer(realigner) {
const sieges = enAttente;
const rendu = await seance.executer('changerDefaut', { sieges, realigner });
demande = null;
realignementTente = false;
dernier = { revision: seance.etat().evenement.revision, avertissements: rendu.avertissements };
}
// Le réalignement que son bilan refuse pose son refus et n'ouvre rien :
// la fenêtre n'annonce que ce qui s'appliquera.
function ouvrirRealignement() {
exigerModifiable();
realignementTente = bilanRealigne.refus.length > 0;
if (realignementTente) return;
fenetreRealigner = surchargesPerdues(charge).map(({ cle, details }) => t(cle, details));
}
async function confirmerRealignement() {
await appliquer(true);
fenetreRealigner = null;
}
function garder() {
demande = null;
realignementTente = false;
rangChamp += 1;
}
</script>
<!-- Le refus d'une table, nommée par son numéro, et le remède qu'il offre. -->
{#snippet refusDeTable(entree)}
{@const action = actionRemede(entree.details.refus.details.remede ?? null, { seance, ui })}
<div class="refus" data-refus={entree.details.refus.code}>
<p><span data-phrase>{t(entree.cle, entree.details)}</span></p>
{#if action !== null}
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
</div>
{/snippet}
<div class="defaut" data-defaut>
{#key rangChamp}
<Champ
cle="siegesParDefaut"
etiquette="tables.defaut.etiquette"
valeur={charge.evenement.siegesParDefaut}
lire={lireEntier}
executer={demander}
/>
{/key}
{#if enAttente !== null}
<div class="enonce" data-enonce="defaut" role="status">
{#if refuse}
{#each entrees as entree, rang (rang)}
{#if rang === 0}
<p><span data-phrase>{t(entree.cle, entree.details)}</span></p>
{:else}
{@render refusDeTable(entree)}
{/if}
{/each}
{:else}
<p>
{#each entrees as entree, rang (rang)}{#if rang > 0}{' '}{/if}<span data-phrase
>{t(entree.cle, entree.details)}</span
>{/each}
</p>
{/if}
<div class="issues">
{#if !refuse}
<Commande cle="tables.defaut.appliquer" nom="appliquer" executer={() => appliquer(false)} />
{#if bilan.surchargees.length > 0}
<Commande
cle="tables.defaut.appliquerRealigner"
details={{ n: bilan.surchargees.length }}
nom="appliquerRealigner"
executer={ouvrirRealignement}
/>
{/if}
{/if}
<Commande cle="tables.defaut.garder" nom="garder" executer={garder} />
</div>
{#if refusRealigne.length > 0}
<div class="realignement" data-refus-realigner>
{#each refusRealigne as entree, rang (rang)}
{#if rang === 0}
<p><span data-phrase>{t(entree.cle, entree.details)}</span></p>
{:else}
{@render refusDeTable(entree)}
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{#each avertissements as avertissement, rang (rang)}
<div class="avertissement" data-avertissement={avertissement.code} role="status">
{#if avertissement.numero !== null}
<p><span>{t('tables.liste.table')}</span> <span>{avertissement.numero}</span></p>
{/if}
<p>{t(avertissement.code, avertissement.details)}</p>
{#if avertissement.noms.length > 0}
<ul>
{#each avertissement.noms as nom, rangNom (rangNom)}
<li>{nom}</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if fenetreRealigner !== null}
<Confirmation
titre="tables.defaut.confirmationRealigner"
details={{ n: fenetreRealigner.length }}
elements={fenetreRealigner}
surConfirmer={confirmerRealignement}
surAnnuler={() => (fenetreRealigner = null)}
/>
{/if}
</div>
<style>
.defaut {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
max-width: 40rem;
}
.enonce {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
.enonce p {
margin: 0;
}
.refus {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
padding-left: var(--espace-2);
border-left: 3px solid var(--couleur-refus);
color: var(--couleur-refus);
}
.realignement {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
.realignement p {
margin: 0;
}
.issues {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.avertissement {
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
font-size: var(--taille-texte);
}
.avertissement p,
.avertissement ul {
margin: 0;
}
</style>

View file

@ -0,0 +1,266 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La liste des tables (§ 4, § 6.1, § 6.2) : la création, ronde ou carrée,
// puis une ligne par table, par numéro — son numéro, sa forme, son état
// (« suit le défaut » ou « surchargée », etatTable), le champ de ses
// places, « Réaligner » pour une surchargée, « Supprimer ». Le champ
// montre la capacité ; une saisie lue par lireEntier s'exécute par
// changerPlacesTable, au champ, et son refus se pose contre lui.
//
// Chaque ligne porte le complément de son dernier geste, tant que la
// révision qui l'a vu est la courante : les sièges qu'un refus
// SIEGES_RETIRES retire, nommés (siegesRetiresNommes), ou les
// avertissements d'un geste accepté, leurs personnes nommées
// (avertissementsNommes). Une frappe dans le champ, ou Échap, retire le
// complément d'un refus avec le refus que le champ retire.
//
// Supprimer une table qui porte une réservation ou un titre ouvre la
// fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ; une table
// nue part sans fenêtre.
import { getContext } from 'svelte';
import { on } from 'svelte/events';
import { bilanSuppressionTable } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js';
import { ErreurCommande } from '../../application/erreurs.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import {
annonceSuppression,
avertissementsNommes,
cleForme,
elementsSuppression,
etatTable,
siegesRetiresNommes,
tablesParNumero,
} from './modele.js';
let { charge, revision } = $props();
const t = getContext('t');
const seance = getContext('seance');
const tables = $derived(tablesParNumero(charge));
// Le complément de chaque ligne, par identifiant de table :
// { revision, retires, avertissements }. La fenêtre de suppression
// ouverte : la table et ce qu'elle nomme.
let complements = $state({});
let suppression = $state.raw(null);
const complementDe = (id) => {
const complement = complements[id];
return complement !== undefined && complement.revision === revision ? complement : null;
};
function oublier(id) {
delete complements[id];
}
// Le refus courant de la séance, levé avant d'ouvrir une fenêtre.
function exigerModifiable() {
const refus = seance.etat().refus;
if (refus !== null) throw new ErreurCommande(refus.code, refus.details);
}
// Exécute une commande sur la table id ; son complément suit le rendu ou
// le refus.
async function executerSurTable(id, nom, args) {
oublier(id);
try {
const rendu = await seance.executer(nom, args);
const etat = seance.etat();
complements[id] = {
revision: etat.evenement.revision,
retires: [],
avertissements: avertissementsNommes(etat.charge, rendu.avertissements),
};
} catch (erreur) {
if (erreur?.code === 'SIEGES_RETIRES') {
const etat = seance.etat();
complements[id] = { revision: etat.evenement.revision, retires: siegesRetiresNommes(etat.charge, erreur), avertissements: [] };
}
throw erreur;
}
}
function supprimer(table) {
exigerModifiable();
const bilan = bilanSuppressionTable(charge, table.id);
if (!annonceSuppression(bilan)) return seance.executer('supprimerTable', { id: table.id });
suppression = {
id: table.id,
numero: table.numero,
elements: elementsSuppression(charge, bilan).map(({ cle, details }) => t(cle, details)),
};
return undefined;
}
async function confirmerSuppression() {
await seance.executer('supprimerTable', { id: suppression.id });
suppression = null;
}
// Une frappe ou Échap dans le champ de la table id retire le complément
// de son refus : le champ retire le refus au même instant.
const suivreSaisie = (id) => (element) => {
const arrets = [
on(element, 'input', () => oublier(id)),
on(element, 'keydown', (evenement) => {
if (evenement.key === 'Escape') oublier(id);
}),
];
return () => {
for (const arreter of arrets) arreter();
};
};
</script>
<div class="liste">
<h3>{t('tables.liste.titre')}</h3>
<div class="creation">
<Commande cle="tables.creer.ronde" nom="creerTableRonde" executer={() => seance.executer('creerTable', { forme: 'ronde' })} />
<Commande cle="tables.creer.carree" nom="creerTableCarree" executer={() => seance.executer('creerTable', { forme: 'carree' })} />
</div>
{#if tables.length === 0}
<p>{t('tables.liste.aucune')}</p>
{:else}
<ul class="tables">
{#each tables as table (table.id)}
{@const etat = etatTable(table, charge.evenement.siegesParDefaut)}
{@const complement = complementDe(table.id)}
<li data-table={table.id}>
<div class="entete">
<span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span>
<span>{t(cleForme(table.forme))}</span>
<span class="etat" data-etat-table>{t(etat.cle)}</span>
</div>
<div class="saisie" {@attach suivreSaisie(table.id)}>
<Champ
cle={`sieges-${table.id}`}
etiquette="tables.liste.places"
valeur={etat.capacite}
lire={lireEntier}
executer={(sieges) => executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })}
/>
</div>
{#if complement !== null && complement.retires.length > 0}
<ul class="retires" data-sieges-retires>
{#each complement.retires as retire, rang (rang)}
<li>{t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}<span>{retire.nom}</span>{/if}</li>
{/each}
</ul>
{/if}
{#if complement !== null}
{#each complement.avertissements as avertissement, rang (rang)}
<div class="avertissement" data-avertissement={avertissement.code} role="status">
<p>{t(avertissement.code, avertissement.details)}</p>
{#if avertissement.noms.length > 0}
<ul>
{#each avertissement.noms as nom, rangNom (rangNom)}
<li>{nom}</li>
{/each}
</ul>
{/if}
</div>
{/each}
{/if}
<div class="commandes">
{#if etat.realignable}
<Commande
cle="tables.liste.realigner"
nom="realignerTable"
executer={() => executerSurTable(table.id, 'realignerTable', { id: table.id })}
/>
{/if}
<Commande cle="tables.liste.supprimer" nom="supprimerTable" executer={() => supprimer(table)} />
</div>
</li>
{/each}
</ul>
{/if}
{#if suppression !== null}
<Confirmation
titre="tables.suppression.confirmation"
details={{ numero: suppression.numero }}
elements={suppression.elements}
surConfirmer={confirmerSuppression}
surAnnuler={() => (suppression = null)}
/>
{/if}
</div>
<style>
.liste {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
.creation,
.commandes {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.tables {
display: flex;
flex-direction: column;
gap: var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
}
.tables > li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
.entete {
display: flex;
flex-wrap: wrap;
gap: var(--espace-3);
}
.numero {
font-weight: bold;
}
/* L'état se lit par son texte ; l'italique le détache de la forme. */
.etat {
font-style: italic;
}
.retires {
margin: 0;
padding-left: var(--espace-5);
color: var(--couleur-refus);
}
.avertissement {
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
font-size: var(--taille-texte);
}
.avertissement p,
.avertissement ul {
margin: 0;
}
</style>

View file

@ -2,12 +2,47 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des tables et des tours sous sa forme minimale : son titre, tiré des libellés de la coquille.
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : le
// nombre de places par défaut et l'énoncé de son changement, le nombre de
// tours, puis la liste des tables. Elle lit la charge et la révision dans
// l'état observable de la séance et les donne à ses parties ; chaque geste
// passe par la séance, et chaque refus se pose là où il a eu lieu.
import { getContext } from 'svelte';
import Defaut from './Defaut.svelte';
import ListeTables from './ListeTables.svelte';
import Tours from './Tours.svelte';
const t = getContext('t');
const seance = getContext('seance');
</script>
<section data-section="tables">
<h2>{t('coquille.section.tables')}</h2>
{#if $seance.charge !== null}
<div class="reglages">
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
<Tours charge={$seance.charge} />
</div>
<ListeTables charge={$seance.charge} revision={$seance.evenement.revision} />
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
.reglages {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-5);
}
</style>

View file

@ -0,0 +1,672 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la section des tables et des tours dans un vrai moteur de
// rendu (§ 4, § 4.3, § 6, § 2.1 étapes 4 et 5, § 19.3 captures 05 et 06).
// La section est montée par mount avec les contextes que pose App —
// 'seance', 't', 'horloge', 'ui' —, sur une séance qui ouvre un fichier
// d'état d'épreuve dans le système de fichiers simulé, à l'horloge pilotée :
// chaque écriture se compte, et chaque entrée se lit dans la révision. Les
// saisies et les clics passent par des entrées que Chromium traite comme
// réelles (test/navigateur/entrees.js). L'épreuve lit le texte rendu, les
// attributs data-… du contrat et la charge que la séance a écrite. Les noms
// d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, describe, expect, test, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { controlerCapacite } from '../../application/capacite.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
import { creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js';
import { nombrePlaces, normaliser } from '../../moteur/configuration.js';
import { serialiser } from '../../stockage/canonique.js';
import { capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { visible } from '../../../test/navigateur/rendu.js';
import { creerEtatUi } from '../etat_ui.js';
import { TABLES_INTERFACE } from '../libelles.js';
import SectionTables from './SectionTables.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const BASE = 'soiree';
const NOMS = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Grisaille', null],
['Bruyère', 'Anouk'],
['Sarcelle', 'Capucine'],
];
const personne = (id, exclu = false) => ({
id,
nom: NOMS[(id - 1) % NOMS.length][0],
prenom: NOMS[(id - 1) % NOMS.length][1],
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
// Une charge : les tables données ({id, numero, sieges, forme}), n
// personnes dont les exclus, R tours, un défaut.
function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) {
const charge = creerCharge({ id: 'evt-tables', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, exclus.includes(rang + 1)));
charge.tables = tables.map(({ id, numero = id, sieges = null, forme = 'ronde' }) => ({ id, numero, sieges, forme, position: { x: 300 * id, y: 0 } }));
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
return charge;
}
// Ajoute la proposition id, ses listes dans l'ordre de charge.tables, et la
// retient quand retenu est vrai.
function placer(charge, listes, { id, retenu = false }) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
const plan = {
siegesAttribues: charge.reglages.attribuerSieges,
tables: charge.tables.map(({ id: table }) => table),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: listes.length,
participants,
placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })),
};
charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: VERSION.affichee, ...plan });
charge.prochainsIds.proposition = id + 1;
if (retenu) {
charge.retenu = { proposition: id, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
} else if (charge.evenement.etat === 'brouillon') {
charge.evenement.etat = 'propose';
}
return charge;
}
// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées
// à 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues.
function treizeTables() {
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
return chargeDe({ tables, n: 120, exclus: [40, 80] });
}
// Les composants montés par l'épreuve courante, et leurs conteneurs.
const montes = [];
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
vi.restoreAllMocks();
});
// Le fichier d'état de la charge posé dans le système simulé, ouvert par une
// séance, en écriture sauf mode 'lecture' ; la section montée au début du
// corps, avec les contextes de l'application.
async function preparer(charge, { mode = 'ecriture' } = {}) {
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
const { documents } = await fs.racines();
const horloge = creerHorlogePilotee();
let tirages = 0;
const seance = creerSeance({
fs,
racine: documents,
horloge,
identifiant: () => {
tirages += 1;
return `epreuve-${tirages}`;
},
produitVersion: VERSION.affichee,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
await seance.ouvrir(BASE);
if (mode === 'ecriture') await seance.passerEnEcriture();
const ui = creerEtatUi();
const contexte = new Map([
['seance', seance],
['t', t],
['horloge', horloge],
['ui', ui],
]);
const cible = document.createElement('div');
document.body.prepend(cible);
const composant = mount(SectionTables, { target: cible, props: {}, context: contexte });
flushSync();
montes.push({ composant, cible });
return { fs, seance, ui, cible };
}
const section = (m) => m.cible.querySelector('[data-section="tables"]');
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
const ecritures = (m) => m.fs.compteurs.ecritures;
const ligne = (m, id) => section(m).querySelector(`[data-table="${id}"]`);
const champTable = (m, id) => ligne(m, id).querySelector(`input[data-champ="sieges-${id}"]`);
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`);
const etatDe = (m, id) => ligne(m, id).querySelector('[data-etat-table]').textContent;
const enonce = (m) => section(m).querySelector('[data-enonce="defaut"]');
const phrases = (m) => [...enonce(m).querySelectorAll('[data-phrase]')].map((p) => p.textContent).join(' ');
const fenetre = () => document.querySelector('dialog[open]');
const elementsFenetre = () => [...fenetre().querySelectorAll('li')].map((li) => li.textContent);
// Attend que la file de la séance ait passé tout ce qui s'y trouve, par une
// lecture admise dans tout mode qui n'écrit rien, puis le rendu.
async function attendre(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await tick();
flushSync();
}
// Un clic réel au centre de l'élément, puis la file de la séance passée.
async function cliquer(m, element) {
expect(element, 'élément à cliquer').not.toBeNull();
element.scrollIntoView({ block: 'center' });
const boite = element.getBoundingClientRect();
const x = boite.left + boite.width / 2;
const y = boite.top + boite.height / 2;
await pointer('presser', x, y);
await pointer('relacher', x, y);
await attendre(m);
}
// Saisit texte dans le champ, la valeur courante remplacée, puis valide par
// Entrée : le geste s'achève, et la file de la séance passe.
async function saisir(m, entree, texte) {
await cliquer(m, entree);
entree.select();
if (texte === '') await touche('Backspace');
for (const caractere of texte) await touche(caractere);
await touche('Enter');
await attendre(m);
}
describe('une table créée (§ 4, § 6.1)', () => {
test('défaut 8 : la table créée suit le défaut, affichée 8 ; le défaut à 10, elle affiche 10 ; changer de tour affiché ne change ni la valeur ni le contrôle', async () => {
const m = await preparer(chargeDe({ n: 2 }));
await cliquer(m, commande(section(m), 'creerTableRonde'));
expect(charge(m).tables).toHaveLength(1);
const { id, sieges } = charge(m).tables[0];
expect(sieges).toBeNull();
expect(champTable(m, id).value).toBe('8');
expect(etatDe(m, id)).toBe(t('tables.etat.suitDefaut'));
await saisir(m, champ(m, 'siegesParDefaut'), '10');
await cliquer(m, commande(enonce(m), 'appliquer'));
expect(charge(m).evenement.siegesParDefaut).toBe(10);
expect(charge(m).tables[0].sieges).toBeNull();
expect(champTable(m, id).value).toBe('10');
const avant = { valeur: champTable(m, id).value, etat: etatDe(m, id), champ: champTable(m, id) };
m.ui.montrerTour(2);
await attendre(m);
expect(champTable(m, id)).toBe(avant.champ);
expect(champTable(m, id).value).toBe(avant.valeur);
expect(etatDe(m, id)).toBe(avant.etat);
});
test('la forme : la création offre ronde et carrée ; carrée, puis ronde, une entrée chacune', async () => {
const m = await preparer(chargeDe({ n: 2 }));
expect(commande(section(m), 'creerTableRonde').textContent).toBe(t('tables.creer.ronde'));
expect(commande(section(m), 'creerTableCarree').textContent).toBe(t('tables.creer.carree'));
const r0 = revision(m);
await cliquer(m, commande(section(m), 'creerTableCarree'));
expect(charge(m).tables.map(({ forme }) => forme)).toEqual(['carree']);
expect(revision(m)).toBe(r0 + 1);
await cliquer(m, commande(section(m), 'creerTableRonde'));
expect(charge(m).tables.map(({ forme }) => forme)).toEqual(['carree', 'ronde']);
expect(revision(m)).toBe(r0 + 2);
expect(ligne(m, charge(m).tables[0].id).textContent).toContain(t('tables.forme.carree'));
expect(ligne(m, charge(m).tables[1].id).textContent).toContain(t('tables.forme.ronde'));
});
});
describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => {
test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 }));
expect(champTable(m, 1).value).toBe('8');
expect(champTable(m, 2).value).toBe('8');
expect(etatDe(m, 1)).not.toBe(etatDe(m, 2));
expect(visible(ligne(m, 1).querySelector('[data-etat-table]'))).toBe(true);
expect(commande(ligne(m, 1), 'realignerTable')).toBeNull();
expect(commande(ligne(m, 2), 'realignerTable').textContent).toBe(t('tables.liste.realigner'));
const r0 = revision(m);
await cliquer(m, commande(ligne(m, 2), 'realignerTable'));
expect(revision(m)).toBe(r0 + 1);
expect(charge(m).tables[1].sieges).toBeNull();
expect(etatDe(m, 2)).toBe(etatDe(m, 1));
expect(champTable(m, 2).value).toBe(champTable(m, 1).value);
expect(commande(ligne(m, 2), 'realignerTable')).toBeNull();
});
test('surcharge et réalignement : 6 sur une table qui suit 8, surchargée ; le défaut à 10 la laisse à 6 ; « Réaligner » la rend au défaut, une entrée', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }));
const r0 = revision(m);
await saisir(m, champTable(m, 1), '6');
expect(charge(m).tables[0].sieges).toBe(6);
expect(etatDe(m, 1)).toBe(t('tables.etat.surchargee'));
expect(revision(m)).toBe(r0 + 1);
await saisir(m, champ(m, 'siegesParDefaut'), '10');
await cliquer(m, commande(enonce(m), 'appliquer'));
expect(charge(m).evenement.siegesParDefaut).toBe(10);
expect(charge(m).tables[0].sieges).toBe(6);
expect(champTable(m, 1).value).toBe('6');
const r1 = revision(m);
await cliquer(m, commande(ligne(m, 1), 'realignerTable'));
expect(charge(m).tables[0].sieges).toBeNull();
expect(champTable(m, 1).value).toBe('10');
expect(etatDe(m, 1)).toBe(t('tables.etat.suitDefaut'));
expect(revision(m)).toBe(r1 + 1);
});
});
describe('changer le défaut (§ 6.1, § 6.2, § 19.3 06)', () => {
test('treize tables, 120 participants dont 2 exclus ; 10 saisi : l’énoncé exact, zéro écriture avant le clic ; « Appliquer » : le total annoncé est celui de l’instance écrite, et 118 son N', async () => {
const m = await preparer(treizeTables());
const avant = ecritures(m);
const r0 = revision(m);
await saisir(m, champ(m, 'siegesParDefaut'), '10');
expect(visible(enonce(m))).toBe(true);
expect(phrases(m)).toBe(
'9 tables suivent le défaut et passent de 8 à 10 places ; 4 tables surchargées (7, 7, 6, 12) ne changent pas. ' +
'Total : 104 \u{2192} 122 sièges pour 118 participants.',
);
expect(ecritures(m)).toBe(avant);
expect(commande(enonce(m), 'appliquer').textContent).toBe(t('tables.defaut.appliquer'));
expect(commande(enonce(m), 'appliquerRealigner').textContent).toBe('Appliquer et réaligner les 4 surchargées');
await cliquer(m, commande(enonce(m), 'appliquer'));
expect(revision(m)).toBe(r0 + 1);
const instance = normaliser(configurationDepuisCharge(charge(m)));
expect(nombrePlaces(instance)).toBe(122);
expect(instance.N).toBe(118);
expect(enonce(m)).toBeNull();
});
test('appliquer et réaligner : la fenêtre nomme les surcharges perdues ; Annuler n’écrit rien ; Confirmer : les treize tables suivent 10, une entrée', async () => {
const m = await preparer(treizeTables());
await saisir(m, champ(m, 'siegesParDefaut'), '10');
const avant = ecritures(m);
const r0 = revision(m);
await cliquer(m, commande(enonce(m), 'appliquerRealigner'));
expect(fenetre()).not.toBeNull();
expect(elementsFenetre()).toEqual(['Table 3 : 7 places', 'Table 6 : 7 places', 'Table 9 : 6 places', 'Table 12 : 12 places']);
await cliquer(m, commande(fenetre(), 'renoncer'));
expect(fenetre()).toBeNull();
expect(ecritures(m)).toBe(avant);
await cliquer(m, commande(enonce(m), 'appliquerRealigner'));
await cliquer(m, commande(fenetre(), 'confirmer'));
await attendre(m);
expect(fenetre()).toBeNull();
expect(revision(m)).toBe(r0 + 1);
expect(charge(m).evenement.siegesParDefaut).toBe(10);
expect(charge(m).tables).toHaveLength(13);
expect(charge(m).tables.every(({ sieges }) => sieges === null)).toBe(true);
});
test('baisse refusée en bloc : la table 2 porte 7 réservations ; 6 saisi : l’énoncé nomme la table 2 et ses 7 réservations, aucune commande d’application, rien n’est écrit', async () => {
const reservations = suite(1, 7).map((p) => resa(p, 2, null));
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10, reservations }));
const avant = ecritures(m);
await saisir(m, champ(m, 'siegesParDefaut'), '6');
const texte = enonce(m).textContent;
expect(texte).toContain('Table 2');
expect(texte).toContain('7 réservations');
expect(commande(enonce(m), 'appliquer')).toBeNull();
expect(commande(enonce(m), 'appliquerRealigner')).toBeNull();
expect(charge(m).evenement.siegesParDefaut).toBe(8);
expect([1, 2, 3].map((id) => champTable(m, id).value)).toEqual(['8', '8', '8']);
expect(ecritures(m)).toBe(avant);
});
test('réaligner refusé : la table 2 surchargée à 12 porte 11 réservations ; 10 saisi, « Appliquer et réaligner » : aucune fenêtre, le refus nomme la table 2, rien n’est écrit', async () => {
const reservations = suite(1, 11).map((p) => resa(p, 2, null));
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 12 }], n: 12, reservations }));
await saisir(m, champ(m, 'siegesParDefaut'), '10');
expect(enonce(m).textContent).not.toContain('Table 2');
const avant = ecritures(m);
await cliquer(m, commande(enonce(m), 'appliquerRealigner'));
expect(fenetre()).toBeNull();
const refus = enonce(m).querySelector('[data-refus-realigner]');
expect(refus).not.toBeNull();
expect(refus.textContent).toContain('Table 2');
expect(refus.textContent).toContain('11 réservations');
expect(ecritures(m)).toBe(avant);
expect(charge(m).evenement.siegesParDefaut).toBe(8);
expect(charge(m).tables.find(({ id }) => id === 2).sieges).toBe(12);
expect(commande(enonce(m), 'appliquer')).not.toBeNull();
});
test('« Garder le défaut actuel » : l’énoncé se retire, le champ rend la valeur enregistrée, rien n’est écrit', async () => {
const m = await preparer(treizeTables());
const avant = ecritures(m);
await saisir(m, champ(m, 'siegesParDefaut'), '10');
await cliquer(m, commande(enonce(m), 'garder'));
expect(enonce(m)).toBeNull();
expect(champ(m, 'siegesParDefaut').value).toBe('8');
expect(ecritures(m)).toBe(avant);
});
test('moins de 2 places par défaut : refusé sur le champ, sans énoncé ni écriture', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }));
const avant = ecritures(m);
await saisir(m, champ(m, 'siegesParDefaut'), '1');
const bloc = champ(m, 'siegesParDefaut').closest('[data-defaut]');
expect(bloc.querySelector('[data-refus="MOINS_DE_DEUX"]').textContent).toContain(t('MOINS_DE_DEUX'));
expect(enonce(m)).toBeNull();
expect(ecritures(m)).toBe(avant);
});
});
describe('la capacité d’une table, saisie au champ (§ 6.2)', () => {
test('1, 0, -3, 2.5, « 8abc », « huit » et vide : refusés sur le champ, leur cause, aucun remède, rien d’écrit ; 2 accepté', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }));
const avant = ecritures(m);
const saisies = [
['1', 'MOINS_DE_DEUX'],
['0', 'MOINS_DE_DEUX'],
['-3', 'NON_ENTIER'],
['2.5', 'NON_ENTIER'],
['8abc', 'NON_ENTIER'],
['huit', 'NON_ENTIER'],
['', 'NON_ENTIER'],
];
expect(saisies).toHaveLength(7);
for (const [saisie, code] of saisies) {
await saisir(m, champTable(m, 1), saisie);
const refus = ligne(m, 1).querySelector(`[data-refus="${code}"]`);
expect(refus, `refus de « ${saisie} »`).not.toBeNull();
const details = code === 'NON_ENTIER' ? { saisie } : {};
expect(refus.querySelector('p').textContent).toBe(t(code, details));
expect(refus.querySelector('[data-commande]')).toBeNull();
expect(ecritures(m)).toBe(avant);
expect(charge(m).tables[0].sieges).toBeNull();
}
await saisir(m, champTable(m, 1), '2');
expect(charge(m).tables[0].sieges).toBe(2);
expect(ligne(m, 1).querySelector('[data-refus]')).toBeNull();
});
test('sous les réservations : 5 réservations « tous » ; 4 refusé contre le champ, qui nomme 5, son remède offert ; 5 accepté, une entrée', async () => {
const reservations = suite(1, 5).map((p) => resa(p, 1, null));
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 6, reservations }));
const avant = ecritures(m);
await saisir(m, champTable(m, 1), '4');
const refus = ligne(m, 1).querySelector('[data-refus="SOUS_RESERVATIONS"]');
expect(refus.textContent).toContain('5 réservations');
expect(commande(refus, 'afficherTour').textContent).toBe(t('communs.remede.afficherTour', { tour: 1 }));
expect(ecritures(m)).toBe(avant);
const r0 = revision(m);
await saisir(m, champTable(m, 1), '5');
expect(charge(m).tables[0].sieges).toBe(5);
expect(revision(m)).toBe(r0 + 1);
});
test('sièges retirés : sièges attribués, Iris Ombrelle au siège 7, un titre « animateur » au siège 8 ; 6 liste le siège 8 puis le siège 7 et sa personne, rien d’écrit ; 7, le siège 8 seul', async () => {
const m = await preparer(
chargeDe({
tables: [{ id: 1 }],
n: 4,
attribuerSieges: true,
reservations: [resa(1, 1, 7)],
titres: [{ table: 1, siege: 8, libelle: 'animateur' }],
}),
);
const avant = ecritures(m);
await saisir(m, champTable(m, 1), '6');
expect(ligne(m, 1).querySelector('[data-refus="SIEGES_RETIRES"]')).not.toBeNull();
const retires = () => [...ligne(m, 1).querySelectorAll('[data-sieges-retires] li')].map((li) => li.textContent);
expect(retires()).toEqual(['Siège 8 : titre « animateur »', 'Siège 7 : Iris Ombrelle']);
const texte = ligne(m, 1).textContent;
expect(texte.indexOf('animateur')).toBeLessThan(texte.indexOf('Iris Ombrelle'));
expect(ecritures(m)).toBe(avant);
await saisir(m, champTable(m, 1), '7');
expect(retires()).toEqual(['Siège 8 : titre « animateur »']);
expect(ligne(m, 1).textContent).not.toContain('Iris Ombrelle');
expect(ecritures(m)).toBe(avant);
});
test('surnuméraires et lisibilité : un retenu assied huit personnes à la table 3 au tour 2 ; 6 accepté, l’avertissement nomme les deux personnes versées en réserve ; S + 1 avertit de S ; S, non', async () => {
const donnees = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
placer(
donnees,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const { avertissements } = controlerCapacite(donnees, 3, 6, { origine: 'champ' });
expect(avertissements).toHaveLength(1);
const noms = avertissements[0].details.personnes.map((id) => nomAffiche(donnees.participants[id - 1]));
expect(noms).toHaveLength(2);
const m = await preparer(donnees);
const r0 = revision(m);
await saisir(m, champTable(m, 3), '6');
expect(charge(m).tables[2].sieges).toBe(6);
expect(revision(m)).toBe(r0 + 1);
const surnumeraires = ligne(m, 3).querySelector('[data-avertissement="SURNUMERAIRES"]');
expect(surnumeraires.textContent).toContain(t('SURNUMERAIRES', avertissements[0].details));
expect([...surnumeraires.querySelectorAll('li')].map((li) => li.textContent)).toEqual(noms);
const seuil = constante('SEUIL_SIEGES_LISIBLES');
await saisir(m, champTable(m, 1), String(seuil + 1));
expect(charge(m).tables[0].sieges).toBe(seuil + 1);
const lisible = ligne(m, 1).querySelector('[data-avertissement="AU_DELA_LISIBLE"]');
expect(lisible.textContent).toContain(String(seuil));
expect(lisible.querySelector('p').textContent).toBe(t('AU_DELA_LISIBLE', { seuil }));
await saisir(m, champTable(m, 1), String(seuil));
expect(charge(m).tables[0].sieges).toBe(seuil);
expect(ligne(m, 1).querySelector('[data-avertissement="AU_DELA_LISIBLE"]')).toBeNull();
});
});
describe('les tours (§ 4, § 4.3, § 2.1 étape 5)', () => {
test('0, 2.5 et « x » refusés sur le champ, leur cause, sans fenêtre, rien d’écrit ; 1 accepté', async () => {
// La réservation du tour 2 : un changement qui la détruirait ouvre la
// fenêtre, que la saisie refusée n'ouvre pas.
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4, reservations: [resa(1, 1, null, 2)] }));
const avant = ecritures(m);
const saisies = [
['0', 'MOINS_D_UN_TOUR', { tours: 0 }],
['2.5', 'NON_ENTIER', { saisie: '2.5' }],
['x', 'NON_ENTIER', { saisie: 'x' }],
];
expect(saisies).toHaveLength(3);
for (const [saisie, code, details] of saisies) {
await saisir(m, champ(m, 'tours'), saisie);
const refus = champ(m, 'tours').closest('[data-tours]').querySelector(`[data-refus="${code}"]`);
expect(refus, `refus de « ${saisie} »`).not.toBeNull();
expect(refus.querySelector('p').textContent).toBe(t(code, details));
expect(refus.querySelector('[data-commande]')).toBeNull();
expect(fenetre()).toBeNull();
expect(ecritures(m)).toBe(avant);
}
const r0 = revision(m);
await saisir(m, champ(m, 'tours'), '1');
expect(ecritures(m)).toBe(avant);
await cliquer(m, commande(fenetre(), 'confirmer'));
await attendre(m);
expect(charge(m).evenement.tours).toBe(1);
expect(charge(m).reservations).toEqual([]);
expect(revision(m)).toBe(r0 + 1);
});
test('aucune perte : le nombre de tours change sans fenêtre, une entrée', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4, reservations: [resa(1, 1, null)] }));
const r0 = revision(m);
await saisir(m, champ(m, 'tours'), '5');
expect(fenetre()).toBeNull();
expect(charge(m).evenement.tours).toBe(5);
expect(revision(m)).toBe(r0 + 1);
});
test('R = 4, deux réservations au tour 4, une au tour 2, deux propositions ; 3 : la fenêtre les nomme, rien d’écrit avant ; Confirmer : les deux retirées, celle du tour 2 gardée, une entrée, le fichier se rouvre', async () => {
const donnees = chargeDe({
tables: [{ id: 1 }, { id: 2 }],
n: 8,
tours: 4,
reservations: [resa(1, 2, null, 4), resa(2, 1, null, 4), resa(3, 1, null, 2)],
});
// Chaque proposition honore les réservations : Iris Ombrelle à la table
// 2 et Théo Pervenche à la table 1 au tour 4, Ondine Lacasse à la table
// 1 au tour 2.
const listes = [[suite(1, 4), suite(5, 8)], [suite(1, 4), suite(5, 8)], [suite(1, 4), suite(5, 8)], [suite(2, 5), [1, 6, 7, 8]]];
placer(donnees, listes, { id: 1 });
placer(donnees, listes, { id: 2 });
const m = await preparer(donnees);
const avant = ecritures(m);
const r0 = revision(m);
await saisir(m, champ(m, 'tours'), '3');
expect(fenetre()).not.toBeNull();
expect(ecritures(m)).toBe(avant);
const elements = elementsFenetre();
expect(elements).toHaveLength(4);
expect(elements[0]).toContain('Iris Ombrelle');
expect(elements[0]).toContain('table 2');
expect(elements[1]).toContain('Théo Pervenche');
expect(elements[1]).toContain('table 1');
expect(elements.slice(2)).toEqual([t('tables.touchee.proposition', { id: 1 }), t('tables.touchee.proposition', { id: 2 })]);
await cliquer(m, commande(fenetre(), 'confirmer'));
await attendre(m);
expect(fenetre()).toBeNull();
expect(revision(m)).toBe(r0 + 1);
expect(charge(m).evenement.tours).toBe(3);
expect(charge(m).reservations).toEqual([resa(3, 1, null, 2)]);
await m.seance.fermer();
await m.seance.ouvrir(BASE);
expect(m.seance.etat().charge.evenement.tours).toBe(3);
expect(m.seance.etat().charge.reservations).toEqual([resa(3, 1, null, 2)]);
});
test('une hausse avec une proposition : sans fenêtre, une entrée, la dérive avertie par la séance', async () => {
const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 2 });
placer(donnees, [[suite(1, 4)], [suite(1, 4)]], { id: 1 });
const m = await preparer(donnees);
const r0 = revision(m);
await saisir(m, champ(m, 'tours'), '3');
expect(fenetre()).toBeNull();
expect(revision(m)).toBe(r0 + 1);
expect(charge(m).evenement.tours).toBe(3);
expect(charge(m).propositions).toHaveLength(1);
expect(m.seance.etat().avertissements.map(({ code }) => code)).toContain('DERIVE_NOUVELLE');
});
test('une baisse avec une proposition et sans réservation : la fenêtre nomme la proposition, rien d’écrit avant', async () => {
const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 3 });
placer(donnees, [[suite(1, 4)], [suite(1, 4)], [suite(1, 4)]], { id: 1 });
const m = await preparer(donnees);
const avant = ecritures(m);
await saisir(m, champ(m, 'tours'), '2');
expect(fenetre()).not.toBeNull();
expect(ecritures(m)).toBe(avant);
expect(elementsFenetre()).toEqual([t('tables.touchee.proposition', { id: 1 })]);
});
test('Annuler la fenêtre des tours : rien n’est écrit, et le champ rend la valeur enregistrée', async () => {
const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 4, reservations: [resa(1, 1, null, 4)] });
const m = await preparer(donnees);
const avant = ecritures(m);
await saisir(m, champ(m, 'tours'), '3');
await cliquer(m, commande(fenetre(), 'renoncer'));
expect(fenetre()).toBeNull();
expect(ecritures(m)).toBe(avant);
expect(champ(m, 'tours').value).toBe('4');
expect(charge(m).reservations).toHaveLength(1);
});
});
describe('supprimer une table (§ 6.2)', () => {
test('une table qui porte une réservation « tous » d’Iris Ombrelle et un titre « animateur » : la fenêtre les nomme ; une table nue : aucune fenêtre, une entrée', async () => {
const m = await preparer(
chargeDe({
tables: [{ id: 1 }, { id: 2 }],
n: 4,
reservations: [resa(1, 1, null)],
titres: [{ table: 1, siege: 1, libelle: 'animateur' }],
}),
);
const avant = ecritures(m);
const r0 = revision(m);
await cliquer(m, commande(ligne(m, 1), 'supprimerTable'));
expect(fenetre()).not.toBeNull();
expect(ecritures(m)).toBe(avant);
const elements = elementsFenetre();
expect(elements).toHaveLength(2);
expect(elements[0]).toContain('Iris Ombrelle');
expect(elements[1]).toContain('« animateur »');
await cliquer(m, commande(fenetre(), 'confirmer'));
await attendre(m);
expect(fenetre()).toBeNull();
expect(charge(m).tables.map(({ id }) => id)).toEqual([2]);
expect(charge(m).reservations).toEqual([]);
expect(charge(m).titres).toEqual([]);
expect(revision(m)).toBe(r0 + 1);
await cliquer(m, commande(ligne(m, 2), 'supprimerTable'));
expect(fenetre()).toBeNull();
expect(charge(m).tables).toEqual([]);
expect(revision(m)).toBe(r0 + 2);
expect(section(m).textContent).toContain(t('tables.liste.aucune'));
});
});
describe('en lecture (§ 8.4)', () => {
test('créer une table, surcharger, changer le défaut ou les tours : refusés contre la commande ou le champ, le message porte « Modifier », rien d’écrit', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }), { mode: 'lecture' });
const avant = ecritures(m);
const modifier = t('communs.remede.modifier');
await cliquer(m, commande(section(m), 'creerTableRonde'));
const pres = commande(section(m), 'creerTableRonde').parentElement;
expect(pres.querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
const porteurs = [
[champTable(m, 1), (e) => e.closest('[data-table]')],
[champ(m, 'siegesParDefaut'), (e) => e.closest('[data-defaut]')],
[champ(m, 'tours'), (e) => e.closest('[data-tours]')],
];
expect(porteurs).toHaveLength(3);
for (const [entree, porteur] of porteurs) {
await cliquer(m, entree);
await touche('5');
await attendre(m);
const refus = porteur(entree).querySelector('[data-refus="MODE_LECTURE"]');
expect(refus, entree.dataset.champ).not.toBeNull();
expect(refus.textContent).toContain(modifier);
expect(commande(refus, 'modifier').textContent).toBe(modifier);
}
expect(charge(m).tables).toHaveLength(1);
expect(ecritures(m)).toBe(avant);
});
});

View file

@ -0,0 +1,78 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le nombre de tours (§ 2.1 étape 5, § 4, § 4.3) : son champ. Une saisie
// lue s'essaie d'abord sur la charge sans rien écrire (refusDeCommande) :
// refusée, son refus se pose sur le champ. Acceptée, elle s'applique
// aussitôt quand rien ne se perd — une hausse, dont la dérive des
// placements s'avertit par la séance ; sinon la fenêtre à confirmer
// nomme, avant toute écriture, les réservations « tour désigné » qu'elle
// détruit — personne et table — et les placements qui perdent des tours
// (bilanTours, annonceTours). Annuler la ferme et remonte le champ, qui rend la valeur
// enregistrée ; Confirmer exécute le changement, une entrée.
import { getContext } from 'svelte';
import { bilanTours } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js';
import { ErreurCommande } from '../../application/erreurs.js';
import Champ from '../primitives/Champ.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import { annonceTours, elementsTours, refusDeCommande } from './modele.js';
let { charge } = $props();
const t = getContext('t');
const seance = getContext('seance');
// Le rang du champ, qui le remonte ; la fenêtre ouverte : les tours
// demandés, ceux d'avant et ce qu'elle nomme.
let rangChamp = $state(0);
let fenetre = $state.raw(null);
function demander(tours) {
const refus = refusDeCommande(charge, 'changerTours', { tours });
if (refus !== null) throw new ErreurCommande(refus.code, refus.details);
const bilan = bilanTours(charge, tours);
if (!annonceTours(bilan, charge.evenement.tours, tours)) return seance.executer('changerTours', { tours });
fenetre = {
tours,
avant: charge.evenement.tours,
elements: elementsTours(charge, bilan).map(({ cle, details }) => t(cle, details)),
};
return undefined;
}
async function confirmer() {
await seance.executer('changerTours', { tours: fenetre.tours });
fenetre = null;
}
function annuler() {
fenetre = null;
rangChamp += 1;
}
</script>
<div class="tours" data-tours>
{#key rangChamp}
<Champ cle="tours" etiquette="tables.tours.etiquette" valeur={charge.evenement.tours} lire={lireEntier} executer={demander} />
{/key}
{#if fenetre !== null}
<Confirmation
titre="tables.tours.confirmation"
details={{ avant: fenetre.avant, apres: fenetre.tours }}
elements={fenetre.elements}
surConfirmer={confirmer}
surAnnuler={annuler}
/>
{/if}
</div>
<style>
.tours {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
</style>

View file

@ -0,0 +1,267 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de la section des tables et des tours (§ 4, § 6, § 2.1 étapes 4
// et 5), pur : les composants de tables/ le câblent. Il lit la charge et les
// bilans d'A4 — bilanChangementDefaut, bilanTours, bilanSuppressionTable —
// et rend des clés de libellé et leurs détails, jamais un texte : la
// traduction les compose. Il ne refait aucun contrôle : un refus vient d'un
// bilan ou de la commande elle-même, essayée sur la charge sans rien écrire.
// Aucune fonction ne modifie ce qu'elle reçoit.
import { appliquerCommande } from '../../application/commandes.js';
import { nomAffiche } from '../../application/personnes.js';
const parNumero = (a, b) => a.numero - b.numero || a.id - b.id;
// Le numéro affiché de la table d'identifiant id parmi tables ; une table
// inconnue est une faute de l'appelant.
function numeroDe(tables, id) {
const table = tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table.numero;
}
// Le nom affiché du participant d'identifiant id ; une personne inconnue
// est une faute de l'appelant.
function nomDe(charge, id) {
const personne = charge.participants.find((candidate) => candidate.id === id);
if (personne === undefined) throw new TypeError(`participant inconnu : ${JSON.stringify(id)}`);
return nomAffiche(personne);
}
// La clé du nom de chaque forme de table (§ 4).
const FORMES = new Map([
['ronde', 'tables.forme.ronde'],
['carree', 'tables.forme.carree'],
]);
/** La clé du nom d'une forme de table ; RangeError pour une forme inconnue. */
export function cleForme(forme) {
if (!FORMES.has(forme)) throw new RangeError(`forme inconnue : ${JSON.stringify(forme)}`);
return FORMES.get(forme);
}
/** Les tables de la charge par numéro affiché croissant, à identifiant égal du plus petit. */
export function tablesParNumero(charge) {
return [...charge.tables].sort(parNumero);
}
/**
* L'état d'une table en texte : « suit le défaut » ou « surchargée », et sa
* capacité ; realignable, vrai pour une table surchargée, offre
* « Réaligner » (§ 6.1). Une table surchargée à la valeur du défaut a la
* même capacité qu'une table qui le suit, et un autre état.
*
* @param {{sieges: number|null}} table
* @param {number} siegesParDefaut
* @returns {{cle: string, capacite: number, realignable: boolean}}
*/
export function etatTable(table, siegesParDefaut) {
const surchargee = table.sieges !== null;
return {
cle: surchargee ? 'tables.etat.surchargee' : 'tables.etat.suitDefaut',
capacite: surchargee ? table.sieges : siegesParDefaut,
realignable: surchargee,
};
}
/**
* L'énoncé du § 6.1, en clés et détails, depuis bilanChangementDefaut ; ses
* phrases se lisent séparées d'une espace. Accepté : la phrase
* d'application — les tables qui suivent le défaut et ce qu'elles
* deviennent, puis les surchargées, qui ne changent pas — et celle du total.
* Refusé : une phrase d'en-tête, puis chaque refus de bilan.refus, sa table
* nommée par son numéro (numero null pour un refus de l'événement entier),
* et aucune forme d'application. Avec realigner, le bilan est celui de
* bilanChangementDefaut(…, {realigner: true}) : l'en-tête d'un refus dit
* que le réalignement est refusé, le changement seul restant possible.
*
* @param {ReturnType<import('../../application/bilans.js').bilanChangementDefaut>} bilan
* @param {Array<{id: number, numero: number}>} [tables] celles de la charge,
* qui donnent son numéro à une table qui refuse
* @param {{realigner?: boolean}} [options]
* @returns {Array<{cle: string, details: Object}>}
* @throws {TypeError} une table de bilan.refus absente de tables
*/
export function enonceDefaut(bilan, tables = [], { realigner = false } = {}) {
const { avant, apres } = bilan;
if (bilan.refus.length > 0) {
return [
{ cle: realigner ? 'tables.defaut.realignementRefuse' : 'tables.defaut.refusee', details: { avant, apres } },
...bilan.refus.map(({ table, code, details }) => ({
cle: 'tables.defaut.refusTable',
details: { numero: table === null ? null : numeroDe(tables, table), refus: { code, details } },
})),
];
}
return [
{ cle: 'tables.defaut.application', details: { suivent: bilan.suivent, avant, apres, surchargees: bilan.surchargees } },
{
cle: 'tables.defaut.total',
details: { totalAvant: bilan.totalAvant, totalApres: bilan.totalApres, participants: bilan.participants },
},
];
}
/**
* Le refus qu'un nouveau défaut reçoit sur son champ : celui du bilan qui
* ne nomme aucune table — moins de 2 places refusent pour l'événement
* entier —, ou null. Les refus d'une table s'énoncent (enonceDefaut).
*
* @param {{refus: Array<{table: number|null, code: string, details: Object}>}} bilan
* @returns {{code: string, details: Object}|null}
*/
export function refusDuChampDefaut(bilan) {
const refus = bilan.refus.find(({ table }) => table === null);
return refus === undefined ? null : { code: refus.code, details: refus.details };
}
/**
* Ce qu'un réalignement de toutes les tables efface : la surcharge de
* chaque table surchargée, par numéro de table (§ 6.1).
*
* @param {Object} charge
* @returns {Array<{cle: string, details: {numero: number, sieges: number}}>}
*/
export function surchargesPerdues(charge) {
return tablesParNumero(charge)
.filter(({ sieges }) => sieges !== null)
.map(({ numero, sieges }) => ({ cle: 'tables.defaut.surchargePerdue', details: { numero, sieges } }));
}
/**
* Vrai quand le changement de avant à apres tours détruit de la saisie :
* une réservation « tour désigné » au-delà, ou, pour une baisse, les tours
* retirés d'un placement engendré (§ 2.1 étape 5). Une hausse ne détruit
* rien : les placements qu'elle laisse en dérive restent entiers, et la
* séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
*
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @param {number} avant le nombre de tours enregistré
* @param {number} apres le nombre de tours demandé
* @returns {boolean}
*/
export const annonceTours = (bilan, avant, apres) =>
bilan.reservationsDetruites.length > 0 ||
(apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche));
// Les placements qu'un changement laisse en dérive : chaque proposition,
// puis le retenu.
const touches = (bilan) => [
...bilan.propositionsTouchees.map((id) => ({ cle: 'tables.touchee.proposition', details: { id } })),
...(bilan.retenuTouche ? [{ cle: 'tables.touchee.retenu', details: {} }] : []),
];
/**
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5) : chaque
* réservation détruite, sa personne et le numéro de sa table, dans l'ordre
* du bilan, puis les placements touchés.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @returns {Array<{cle: string, details: Object}>}
*/
export function elementsTours(charge, bilan) {
return [
...bilan.reservationsDetruites.map(({ participant, table, tour }) => ({
cle: 'tables.tours.reservation',
details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour },
})),
...touches(bilan),
];
}
/** Vrai quand supprimer la table détruit une réservation ou un titre. */
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0;
/**
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2) : ses
* réservations, leur personne, leur portée et leur siège, puis ses titres,
* dans l'ordre du bilan ; puis les placements touchés. Rien pour une table
* nue, que la suppression ne demande pas de confirmer.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanSuppressionTable>} bilan
* @returns {Array<{cle: string, details: Object}>}
*/
export function elementsSuppression(charge, bilan) {
if (!annonceSuppression(bilan)) return [];
return [
...bilan.reservations.map(({ participant, portee, tour, siege }) => ({
cle: 'tables.suppression.reservation',
details: { nom: nomDe(charge, participant), portee, tour, siege },
})),
...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })),
...touches(bilan),
];
}
/**
* Les sièges qu'un refus SIEGES_RETIRES retire, nommés : chaque siège dans
* l'ordre du refus, son titre, ou le nom de la personne qui le réserve —
* le texte du refus désigne les personnes par identifiant. Sous des sièges
* non attribués, le refus compte ses titres sans les lister : ils ne le
* sont pas ici non plus. Rien pour un autre refus.
*
* @param {Object} charge
* @param {{code: string, details: Object}} refus
* @returns {Array<{cle: string, details: Object, nom: string|null}>}
*/
export function siegesRetiresNommes(charge, refus) {
if (refus.code !== 'SIEGES_RETIRES') return [];
const comptes = refus.details.titres !== undefined;
return refus.details.sieges
.filter(({ cause }) => !(comptes && cause === 'SIEGE_TITRE'))
.map((retire) =>
retire.cause === 'SIEGE_TITRE'
? { cle: 'tables.siegeRetire.titre', details: { siege: retire.siege, libelle: retire.libelle }, nom: null }
: {
cle: 'tables.siegeRetire.reserve',
details: { siege: retire.siege, suspendue: retire.suspendue },
nom: nomDe(charge, retire.participant),
},
);
}
/**
* Les avertissements d'un geste, nommés : numero, celui de la table qu'ils
* désignent — details.table, ou table pour un avertissement d'un bilan —,
* null sinon ; noms, les personnes de details.personnes dans leur ordre.
*
* @param {Object} charge
* @param {Array<{code: string, details: Object, table?: number}>} avertissements
* @returns {Array<{code: string, details: Object, numero: number|null, noms: string[]}>}
*/
export function avertissementsNommes(charge, avertissements) {
return avertissements.map(({ code, details, table }) => {
const id = details.table ?? table ?? null;
return {
code,
details,
numero: id === null ? null : numeroDe(charge.tables, id),
noms: (details.personnes ?? []).map((personne) => nomDe(charge, personne)),
};
});
}
/**
* Le refus que la commande nom lèverait sur la charge, essayée sans rien
* écrire, ou null quand elle passe. Les fenêtres l'appellent avant de
* s'ouvrir : une saisie que la commande refuse reçoit son refus sur le
* champ, et aucune fenêtre n'annonce ce qui ne s'appliquera pas.
*
* @param {Object} charge
* @param {string} nom
* @param {Object} args
* @returns {{code: string, details: Object}|null}
* @throws {TypeError} une commande inconnue, un argument de forme fausse
*/
export function refusDeCommande(charge, nom, args) {
try {
appliquerCommande(nom, charge, args);
return null;
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
return { code: erreur.code, details: erreur.details };
}
}

View file

@ -0,0 +1,332 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de la section des tables (§ 4, § 6.1, § 6.2, § 2.1
// étape 5) : l'état d'une table, l'énoncé d'un changement de défaut, et ce
// que les fenêtres et les compléments d'un refus ou d'un avertissement
// nomment. Les bilans viennent des fonctions d'A4 sur des charges
// d'épreuve ; les textes, de la traduction réelle. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from '../../application/bilans.js';
import { controlerCapacite } from '../../application/capacite.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
annonceSuppression,
annonceTours,
avertissementsNommes,
cleForme,
elementsSuppression,
elementsTours,
enonceDefaut,
etatTable,
refusDeCommande,
refusDuChampDefaut,
siegesRetiresNommes,
surchargesPerdues,
tablesParNumero,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const NOMS = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Grisaille', null],
];
const personne = (id, exclu = false) => ({
id,
nom: NOMS[(id - 1) % NOMS.length][0],
prenom: NOMS[(id - 1) % NOMS.length][1],
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
// Une charge : les tables données ({id, numero, sieges}), n personnes dont
// les exclus, R tours, un défaut.
function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) {
const charge = creerCharge({ id: 'evt-tables', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, exclus.includes(rang + 1)));
charge.tables = tables.map(({ id, numero = id, sieges = null }) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
return charge;
}
// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées
// à 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues.
function treizeTables() {
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
return chargeDe({ tables, n: 120, exclus: [40, 80] });
}
// L'énoncé traduit, ses phrases séparées d'une espace, comme l'écran les pose.
const traduire = (entrees) => entrees.map(({ cle, details }) => t(cle, details)).join(' ');
describe('etatTable : l’état, pas seulement la valeur (§ 6.1)', () => {
test('une table qui suit le défaut : sa capacité est le défaut, et rien à réaligner', () => {
assert.deepEqual(etatTable({ id: 1, numero: 1, sieges: null }, 8), { cle: 'tables.etat.suitDefaut', capacite: 8, realignable: false });
assert.equal(t('tables.etat.suitDefaut'), 'Suit le défaut');
});
test('une table surchargée à la valeur du défaut : même capacité, autre état, réalignable', () => {
const suit = etatTable({ id: 1, numero: 1, sieges: null }, 8);
const surchargee = etatTable({ id: 2, numero: 2, sieges: 8 }, 8);
assert.equal(surchargee.capacite, suit.capacite);
assert.notEqual(t(surchargee.cle), t(suit.cle));
assert.deepEqual(surchargee, { cle: 'tables.etat.surchargee', capacite: 8, realignable: true });
});
test('une surcharge garde sa valeur quand le défaut change', () => {
assert.equal(etatTable({ id: 1, numero: 1, sieges: 6 }, 10).capacite, 6);
assert.equal(etatTable({ id: 1, numero: 1, sieges: null }, 10).capacite, 10);
});
test('cleForme : une clé par forme, traduite ; une forme inconnue lève', () => {
assert.deepEqual(['ronde', 'carree'].map((forme) => t(cleForme(forme))), ['Ronde', 'Carrée']);
assert.throws(() => cleForme('ovale'), RangeError);
});
test('tablesParNumero : par numéro croissant, sans toucher la charge', () => {
const charge = chargeDe({ tables: [{ id: 1, numero: 3 }, { id: 2, numero: 1 }, { id: 3, numero: 2 }] });
const avant = structuredClone(charge.tables);
const ordre = tablesParNumero(charge).map(({ numero }) => numero);
assert.equal(ordre.length, 3);
assert.deepEqual(ordre, [1, 2, 3]);
assert.deepEqual(charge.tables, avant);
});
});
describe('enonceDefaut : la conséquence chiffrée avant l’application (§ 6.1)', () => {
test('le bilan du § 6.1, traduit, est exactement l’énoncé du spec', () => {
const charge = treizeTables();
const entrees = enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables);
assert.equal(entrees.length, 2);
assert.equal(
traduire(entrees),
'9 tables suivent le défaut et passent de 8 à 10 places ; 4 tables surchargées (7, 7, 6, 12) ne changent pas. ' +
'Total : 104 \u{2192} 122 sièges pour 118 participants.',
);
});
test('une table seule, aucune surchargée : le singulier', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 1 });
assert.equal(
traduire(enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables)),
'1 table suit le défaut et passe de 8 à 10 places. Total : 8 \u{2192} 10 sièges pour 1 participant.',
);
});
test('une surchargée seule, aucune table qui suit : leur forme', () => {
const charge = chargeDe({ tables: [{ id: 1, sieges: 6 }], n: 4 });
assert.equal(
traduire(enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables)),
'Aucune table ne suit le défaut ; 1 table surchargée (6) ne change pas. Total : 6 \u{2192} 6 sièges pour 4 participants.',
);
});
test('une baisse de 8 à 6, sans réservation : sa forme', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10 });
assert.equal(
traduire(enonceDefaut(bilanChangementDefaut(charge, 6), charge.tables)),
'3 tables suivent le défaut et passent de 8 à 6 places. Total : 24 \u{2192} 18 sièges pour 10 participants.',
);
});
test('une baisse refusée : la table 2 nommée par son numéro, avec son refus, et aucune forme d’application', () => {
const reservations = [1, 2, 3, 4, 5, 6, 7].map((p) => resa(p, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10, reservations });
const bilan = bilanChangementDefaut(charge, 6);
assert.equal(bilan.refus.length, 1);
const entrees = enonceDefaut(bilan, charge.tables);
assert.equal(entrees.length, 2);
assert.deepEqual(entrees[1], {
cle: 'tables.defaut.refusTable',
details: { numero: 2, refus: { code: 'SOUS_RESERVATIONS', details: bilan.refus[0].details } },
});
assert.equal(bilan.refus[0].details.n, 7);
const texte = traduire(entrees);
assert.ok(texte.includes(`Table 2 \u{2014} ${t('SOUS_RESERVATIONS', bilan.refus[0].details)}`), texte);
assert.ok(texte.includes('7 réservations'), texte);
for (const cle of entrees.map((entree) => entree.cle)) {
assert.ok(!['tables.defaut.application', 'tables.defaut.total'].includes(cle), cle);
}
});
test('le numéro affiché nomme la table, non son identifiant ; une table inconnue lève', () => {
const reservations = [1, 2, 3, 4, 5, 6, 7].map((p) => resa(p, 2, null));
const charge = chargeDe({ tables: [{ id: 1, numero: 4 }, { id: 2, numero: 9 }], n: 10, reservations });
const bilan = bilanChangementDefaut(charge, 6);
assert.equal(enonceDefaut(bilan, charge.tables)[1].details.numero, 9);
assert.throws(() => enonceDefaut(bilan, []), TypeError);
});
test('moins de deux places : un refus de l’événement entier, que le champ porte', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const bilan = bilanChangementDefaut(charge, 1);
assert.deepEqual(refusDuChampDefaut(bilan), { code: 'MOINS_DE_DEUX', details: { remede: null } });
const entrees = enonceDefaut(bilan, charge.tables);
assert.equal(entrees[1].details.numero, null);
assert.equal(t(entrees[1].cle, entrees[1].details), t('MOINS_DE_DEUX', {}));
assert.equal(refusDuChampDefaut(bilanChangementDefaut(charge, 10)), null);
});
test('le réalignement refusé : son en-tête, la table 2 nommée avec son refus, et aucune forme d’application', () => {
const reservations = Array.from({ length: 11 }, (_, rang) => resa(rang + 1, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 12 }], n: 12, reservations });
assert.equal(bilanChangementDefaut(charge, 10).refus.length, 0);
const bilan = bilanChangementDefaut(charge, 10, { realigner: true });
assert.equal(bilan.refus.length, 1);
const entrees = enonceDefaut(bilan, charge.tables, { realigner: true });
assert.equal(entrees.length, 2);
assert.deepEqual(entrees[0], { cle: 'tables.defaut.realignementRefuse', details: { avant: 8, apres: 10 } });
assert.deepEqual(entrees[1], {
cle: 'tables.defaut.refusTable',
details: { numero: 2, refus: { code: 'SOUS_RESERVATIONS', details: bilan.refus[0].details } },
});
const texte = traduire(entrees);
assert.ok(texte.includes(`Table 2 \u{2014} ${t('SOUS_RESERVATIONS', bilan.refus[0].details)}`), texte);
assert.ok(!texte.includes('Le défaut ne passe pas'), texte);
assert.equal(enonceDefaut(bilan, charge.tables)[0].cle, 'tables.defaut.refusee');
});
});
describe('ce que nomment les fenêtres (§ 6.1, § 6.2, § 2.1 étape 5)', () => {
test('surchargesPerdues : chaque surcharge par numéro de table, celles qu’un réalignement efface', () => {
const elements = surchargesPerdues(treizeTables());
assert.equal(elements.length, 4);
assert.deepEqual(
elements.map(({ cle, details }) => t(cle, details)),
['Table 3 : 7 places', 'Table 6 : 7 places', 'Table 9 : 6 places', 'Table 12 : 12 places'],
);
});
test('elementsTours : les réservations détruites, personnes et tables, puis les placements touchés', () => {
const charge = chargeDe({
tables: [{ id: 1, numero: 1 }, { id: 2, numero: 5 }],
n: 4,
tours: 4,
reservations: [resa(1, 2, null, 4), resa(2, 1, null, 4), resa(3, 1, null, 2)],
});
charge.propositions = [{ id: 1, tours: 4 }, { id: 2, tours: 4 }];
charge.retenu = { proposition: 1, tours: 4 };
const bilan = bilanTours(charge, 3);
assert.equal(annonceTours(bilan, 4, 3), true);
const textes = elementsTours(charge, bilan).map(({ cle, details }) => t(cle, details));
assert.equal(textes.length, 5);
assert.deepEqual(textes, [
'Iris Ombrelle : réservation à la table 5 au tour 4, détruite',
'Théo Pervenche : réservation à la table 1 au tour 4, détruite',
t('tables.touchee.proposition', { id: 1 }),
t('tables.touchee.proposition', { id: 2 }),
t('tables.touchee.retenu'),
]);
});
test('annonceTours : rien à annoncer sans réservation détruite ni placement touché', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 4, reservations: [resa(1, 1, null, 2)] });
assert.equal(annonceTours(bilanTours(charge, 3), 4, 3), false);
assert.equal(annonceTours(bilanTours(charge, 4), 4, 4), false);
});
test('annonceTours : une hausse ne détruit rien et n’annonce rien ; une baisse annonce les placements qui perdent des tours', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 });
charge.propositions = [{ id: 1, tours: 2 }];
charge.retenu = { proposition: 1, tours: 2 };
const hausse = bilanTours(charge, 3);
assert.deepEqual(hausse.propositionsTouchees, [1]);
assert.equal(hausse.retenuTouche, true);
assert.equal(annonceTours(hausse, 2, 3), false);
assert.equal(annonceTours(bilanTours(charge, 1), 2, 1), true);
charge.retenu = null;
assert.equal(annonceTours(bilanTours(charge, 1), 2, 1), true);
assert.equal(annonceTours(bilanTours(charge, 5), 2, 5), false);
});
test('elementsSuppression : la réservation d’Iris Ombrelle et le titre « animateur » ; une table nue n’annonce rien', () => {
const charge = chargeDe({
tables: [{ id: 1 }, { id: 2 }],
n: 2,
attribuerSieges: true,
reservations: [resa(1, 1, 3)],
titres: [{ table: 1, siege: 3, libelle: 'animateur' }],
});
const bilan = bilanSuppressionTable(charge, 1);
assert.equal(annonceSuppression(bilan), true);
const textes = elementsSuppression(charge, bilan).map(({ cle, details }) => t(cle, details));
assert.equal(textes.length, 2);
assert.deepEqual(textes, ['Iris Ombrelle : réservation pour tous les tours, siège 3', 'Titre « animateur », siège 3']);
assert.equal(annonceSuppression(bilanSuppressionTable(charge, 2)), false);
assert.deepEqual(elementsSuppression(charge, bilanSuppressionTable(charge, 2)), []);
});
});
describe('les compléments d’un refus et d’un avertissement (§ 6.2)', () => {
test('siegesRetiresNommes : le siège 8 et son titre, puis le siège 7 et Iris Ombrelle, dans cet ordre', () => {
const charge = chargeDe({
tables: [{ id: 1 }],
n: 2,
attribuerSieges: true,
reservations: [resa(1, 1, 7)],
titres: [{ table: 1, siege: 8, libelle: 'animateur' }],
});
const { refus } = controlerCapacite(charge, 1, 6, { origine: 'champ' });
assert.equal(refus.code, 'SIEGES_RETIRES');
const lignes = siegesRetiresNommes(charge, refus);
assert.equal(lignes.length, 2);
assert.deepEqual(
lignes.map(({ cle, details, nom }) => [t(cle, details), nom]),
[
['Siège 8 : titre « animateur »', null],
['Siège 7 :', 'Iris Ombrelle'],
],
);
const sept = siegesRetiresNommes(charge, controlerCapacite(charge, 1, 7, { origine: 'champ' }).refus);
assert.deepEqual(sept.map(({ details }) => details.siege), [8]);
});
test('siegesRetiresNommes : rien pour un autre refus', () => {
assert.deepEqual(siegesRetiresNommes(chargeDe(), { code: 'MOINS_DE_DEUX', details: { remede: null } }), []);
});
test('avertissementsNommes : SURNUMERAIRES nomme ses personnes, dans l’ordre reçu, et sa table par son numéro', () => {
const charge = chargeDe({ tables: [{ id: 3, numero: 7 }], n: 4 });
const nommes = avertissementsNommes(charge, [
{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [4, 2] } },
{ code: 'AU_DELA_LISIBLE', details: { seuil: 16 } },
]);
assert.equal(nommes.length, 2);
assert.deepEqual(nommes[0], {
code: 'SURNUMERAIRES',
details: { table: 3, tour: 2, personnes: [4, 2] },
numero: 7,
noms: ['Grisaille', 'Théo Pervenche'],
});
assert.deepEqual(nommes[1], { code: 'AU_DELA_LISIBLE', details: { seuil: 16 }, numero: null, noms: [] });
});
});
describe('refusDeCommande : la commande essayée sans rien écrire', () => {
test('zéro tour : le code que la commande donne à moins d’un tour ; trois tours : aucun refus', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 4 });
const zero = refusDeCommande(charge, 'changerTours', { tours: 0 });
assert.equal(zero.code, 'MOINS_D_UN_TOUR');
assert.equal(zero.details.remede, null);
assert.equal(refusDeCommande(charge, 'changerTours', { tours: 3 }), null);
assert.equal(charge.evenement.tours, 4);
});
});

View file

@ -149,6 +149,12 @@ const estContenuVerrou = (contenu) =>
* ce que rend typeSupport, 'fixe' pour une racine absente de l'objet
* @param {string} [reglages.hote] machine du processus ; un verrou d'un autre
* hôte rend vivant à null
* @param {string[]} [reglages.dossiersChoisis] chemins absolus que rend
* choisirDossier, un par appel, dans l'ordre, puis null comme quand
* l'opérateur annule : chacun sous l'identifiant choisi-<n>, n compté
* depuis 1, et inscrit parmi les racines du système, comme le fait la
* coquille (electron/fichiers.js). La racine choisie n'existe qu'une
* fois créée ; racines() ne la rend pas
* @returns {import('../src/stockage/systeme_fichiers.js').SystemeFichiers & Object}
* le système, et pour l'épreuve :
* - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque
@ -196,7 +202,11 @@ export function creerFichiersSimules({
sonde = {},
support = {},
hote = 'poste-epreuve',
dossiersChoisis = [],
} = {}) {
if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || chemin === '')) {
throw new TypeError('dossiersChoisis : un tableau de chemins, chaînes non vides');
}
const etat = etatDuDisque(disque, insensibleCasse, separateur);
const pid = etat.prochainPid;
etat.prochainPid += 1;
@ -205,6 +215,10 @@ export function creerFichiersSimules({
const joindre = (dossier, nom) => (dossier.endsWith(separateur) ? dossier + nom : dossier + separateur + nom);
const racines = new Map([['documents', documents]]);
if (executable !== null) racines.set('portable', joindre(executable, 'data'));
// Les dossiers que le dialogue rendra encore, et le nombre de ceux qu'il a
// rendus.
const aChoisir = [...dossiersChoisis];
let choisis = 0;
// Cause qui refuse toute écriture d'une racine : celle de sa sonde déclarée,
// puis celle de pannes.ecriture. Renommages refusés restants d'une racine,
@ -365,8 +379,17 @@ export function creerFichiersSimules({
documents: { id: 'documents', chemin: documents },
})),
// Les dialogues natifs rendent null, comme quand l'opérateur annule.
choisirDossier: primitive(null, () => null),
// Les dialogues natifs rendent null, comme quand l'opérateur annule ;
// choisirDossier rend d'abord les dossiersChoisis, chacun inscrit comme
// racine pour le reste du processus.
choisirDossier: primitive(null, () => {
if (aChoisir.length === 0) return null;
const chemin = aChoisir.shift();
choisis += 1;
const id = `choisi-${choisis}`;
racines.set(id, chemin);
return { id, chemin };
}),
// Le témoin s'écrit par-dessus celui qu'une séance précédente a pu
// laisser, puis s'efface ; sa relecture ne peut différer de ce qui vient

View file

@ -207,6 +207,41 @@ describe('fichiers simulés : racines et emplacements', () => {
assert.equal(await fs.ouvrirDansExplorateur(DOC), undefined);
await echoue(fs.ouvrirDansExplorateur(PORTABLE), 'CHEMIN_REFUSE', { chemin: '' });
});
test("dossiersChoisis : choisirDossier rend chacun dans l'ordre, choisi-<n>, puis null ; la racine rendue s'inscrit, et n'existe qu'une fois créée", async () => {
const SECOURS = 'D:\\Secours';
const fs = creerFichiersSimules({ dossiersChoisis: [SECOURS, 'F:\\Repli'] });
const choisi = { id: 'choisi-1', chemin: SECOURS };
// Avant le choix, la racine n'est pas connue du système.
await echoue(fs.lister(choisi, ''), 'CHEMIN_REFUSE', { chemin: '' });
const avant = { ...fs.compteurs };
assert.deepEqual(await fs.choisirDossier(), choisi);
assert.deepEqual(await fs.choisirDossier(), { id: 'choisi-2', chemin: 'F:\\Repli' });
assert.equal(await fs.choisirDossier(), null);
assert.equal(await fs.choisirDossier(), null);
assert.deepEqual(fs.compteurs, avant, 'le dialogue ne compte pas');
assert.deepEqual(await fs.racines(), { portable: null, documents: DOC }, 'les racines du système restent les deux');
await echoue(fs.lister(choisi, ''), 'ABSENT', { chemin: '' });
const absent = { chemin: 'soiree.gtt.json', dossier: SECOURS, cause: 'ENOENT' };
await echoue(fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'x'), 'ECRITURE', absent);
await fs.creerDossier(choisi, '');
await fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'état');
assert.equal(fs.contenu('choisi-1', 'soiree.gtt.json'), 'état');
assert.deepEqual(noms(await fs.lister(choisi, '')), ['soiree.gtt.json']);
fs.pannes.ecriture('choisi-2');
await echoue(fs.creerDossier({ id: 'choisi-2', chemin: 'F:\\Repli' }, ''), 'ECRITURE', {
chemin: '',
dossier: 'F:\\Repli',
cause: 'EROFS',
});
await echoue(fs.lister({ id: 'choisi-3', chemin: 'G:\\' }, ''), 'CHEMIN_REFUSE', { chemin: '' });
});
test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide lève TypeError à la création', () => {
for (const fautif of [[''], [null], 'D:\\Secours']) {
assert.ok(erreurDe(() => creerFichiersSimules({ dossiersChoisis: fautif })) instanceof TypeError, JSON.stringify(fautif));
}
});
});
describe('fichiers simulés : casse des noms', () => {

View file

@ -0,0 +1,39 @@
{
"entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":0}},
"charge": {
"evenement": {"id":"epreuve:toutes-en-conflit","nom":"Toutes en conflit","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"propose","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":13,"table":5,"proposition":2},
"participants": [
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Grisaille","prenom":null,"appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":5,"nom":"Tourmaline","prenom":"Basile","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":6,"nom":"Arrosoir","prenom":"Capucine","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":7,"nom":"Cerfeuil","prenom":"Aurèle","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":8,"nom":"Bruyère","prenom":"Mélisande","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":9,"nom":"Galet","prenom":"Firmin","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":10,"nom":"Mistral","prenom":"Clélia","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":11,"nom":"Houblon","prenom":"Anatole","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":12,"nom":"Sarcelle","prenom":"Solveig","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}
],
"reservations": [],
"titres": [],
"propositions": [
{"id":1,"graine":2215483850,"arret":4000,"historique":1000,"produitVersion":"epreuve","siegesAttribues":false,"tables":[1,2,3,4],"capacites":[3,3,3,3],"tours":4,"participants":[1,2,3,4,5,6,7,8,9,10,11,12],"placement":[
{"sieges":[[4,7,8],[5,6,10],[3,9,12],[1,2,11]],"reserve":[]},
{"sieges":[[2,7,12],[3,5,11],[1,4,10],[6,8,9]],"reserve":[]},
{"sieges":[[2,9,10],[7,8,11],[4,5,12],[1,3,6]],"reserve":[]},
{"sieges":[[9,11,12],[3,4,6],[5,7,10],[1,2,8]],"reserve":[]}
]}
],
"retenu": null
}
}