Compare commits

..

No commits in common. "811cd16b764f55376f1a4cc972ca7c9f3b2ecad3" and "abae4fa9b8a4a1eb98dc42626762429fb122d075" have entirely different histories.

171 changed files with 207 additions and 37054 deletions

View file

@ -337,8 +337,7 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
* @param {number} [reglages.pid]
* @param {string} [reglages.hote]
* @param {() => Date} [reglages.maintenant]
* @returns {Object<string, Function>} les primitives, par nom, et
* verrousTenus(), qui n'en est pas une
* @returns {Object<string, Function>} les primitives, par nom
*/
export function creerFichiers({
emplacements,
@ -365,9 +364,6 @@ export function creerFichiers({
const demarrageDe = demarrage ?? lireDemarrage;
// Le démarrage du processus courant, lu une fois qu'il est connu.
let demarrageCourant = null;
// Les verrous que verrouiller a pris et que deverrouiller n'a pas rendus :
// chemin absolu → séance, que verrousTenus rend au processus principal.
const tenus = new Map();
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
@ -861,7 +857,6 @@ export function creerFichiers({
await effacer(loc.absolu).catch(() => {});
throw erreur;
}
tenus.set(loc.absolu, seance);
return { pris: true };
}
} catch (erreur) {
@ -880,28 +875,15 @@ export function creerFichiers({
try {
texte = (await fs.readFile(loc.absolu)).toString('utf8');
} catch (erreur) {
if (ABSENCE_EN_LECTURE.has(erreur?.code)) {
tenus.delete(loc.absolu);
return;
}
if (ABSENCE_EN_LECTURE.has(erreur?.code)) return;
throw erreur;
}
if (analyserVerrou(texte)?.seance === seance) {
await effacer(loc.absolu);
tenus.delete(loc.absolu);
}
if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu);
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Hors des primitives, que servir ne publie pas : les verrous pris et
// pas rendus, [{ chemin, seance }], chemin absolu, dans l'ordre de leur
// prise. Le processus principal les efface à sa sortie.
verrousTenus() {
return [...tenus].map(([chemin, seance]) => ({ chemin, seance }));
},
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
// de l'échec.
async ouvrirDansExplorateur(racine) {

View file

@ -4,9 +4,7 @@
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
// charge la page construite par Vite dans www/, et le système de fichiers de
// la plateforme (electron/fichiers.js), offert à la page par un canal IPC
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers ;
// un canal de plus, gtt:copier, remet un texte au presse-papiers du système
// (window.gtt.copier).
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers.
//
// La page n'a ni Node ni accès au disque : isolation de contexte active,
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
@ -14,8 +12,8 @@
// une racine que ce processus connaît et un chemin relatif à elle. Un nom
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
// non un accès au disque.
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, rmSync } from 'node:fs';
import { readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -77,28 +75,6 @@ const fichiers = creerFichiers({
});
servir(fichiers, (canal, gestionnaire) => ipcMain.handle(canal, gestionnaire));
// Le presse-papiers : le texte reçu, une chaîne, remplace son contenu.
ipcMain.handle('gtt:copier', (_evenement, texte) => {
if (typeof texte !== 'string') throw new TypeError("le texte à copier n'est pas une chaîne");
clipboard.writeText(texte);
});
// Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) :
// la page ne survit pas au processus principal, et une fermeture de la fenêtre
// ne lui laisse pas le temps de les rendre elle-même. Chacun se relit, de
// façon synchrone comme le retrait du profil — Electron n'attend aucune
// promesse d'un écouteur de sortie —, et ne s'efface que s'il porte encore la
// séance qui l'a pris : un verrou repris par une autre séance reste.
app.on('quit', () => {
for (const { chemin, seance } of fichiers.verrousTenus()) {
try {
if (JSON.parse(readFileSync(chemin, 'utf8'))?.seance === seance) rmSync(chemin, { force: true });
} catch {
// Absent, illisible ou inaccessible : rien à rendre.
}
}
});
function creerFenetre() {
fenetre = new BrowserWindow({
width: 1366,

View file

@ -2,13 +2,11 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
// processus principal. Il expose window.gtt et rien d'autre : sous
// gtt.fichiers, une fonction par primitive du système de fichiers, qui
// invoque le canal de cette primitive, gtt:fichiers:<primitive>, et rend la
// réponse du processus principal (electron/fichiers.js) ; gtt.copier(texte),
// qui invoque le canal gtt:copier et remet le texte au presse-papiers du
// système. La page ne reçoit jamais ipcRenderer lui-même, ni une fonction
// qui invoquerait un canal de son choix.
// processus principal. Il expose window.gtt.fichiers et rien d'autre : une
// fonction par primitive du système de fichiers, qui invoque le canal de
// cette primitive, gtt:fichiers:<primitive>, et rend la réponse du processus
// principal (electron/fichiers.js). La page ne reçoit jamais ipcRenderer
// lui-même, ni une fonction qui invoquerait un canal de son choix.
//
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
// l'extension .cjs dans un paquet de type module. Son require ne connaît
@ -44,6 +42,4 @@ for (const primitive of PRIMITIVES) {
fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres);
}
const copier = (texte) => ipcRenderer.invoke('gtt:copier', texte);
contextBridge.exposeInMainWorld('gtt', { fichiers, copier });
contextBridge.exposeInMainWorld('gtt', { fichiers });

View file

@ -7,10 +7,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
<title></title>
<!-- Le collecteur d'erreurs (§ 19.2), script classique copié tel quel
depuis public/ : il s'exécute avant le module d'entrée et en
recueille aussi les erreurs d'évaluation. -->
<script src="./collecteur.js"></script>
<script type="module" src="/src/main.js"></script>
</head>
<body></body>

View file

@ -1,47 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le collecteur d'erreurs de la page (§ 19.2) : un script classique, que
// index.html charge avant le module d'entrée, et qui accumule dans
// globalThis.__gttErreurs ce qu'aucun pilote ne lit de façon portable dans la
// console — une erreur non rattrapée, une promesse rejetée sans
// gestionnaire, chaque appel de console.error —, dans l'ordre de leur
// arrivée. Le pilote relit le tableau à chaque étape et échoue sur sa
// première entrée.
//
// Une entrée est { type, message } : type vaut 'error', 'unhandledrejection'
// ou 'console.error' ; message est le texte de l'erreur, la raison du rejet,
// ou les arguments de console.error joints par une espace. Le script ne
// s'installe qu'une fois par page : chargé de nouveau, il garde le tableau
// et l'enveloppe déjà posés.
(function installer() {
'use strict';
if (Array.isArray(globalThis.__gttErreurs)) return;
var erreurs = [];
globalThis.__gttErreurs = erreurs;
// Le texte d'une valeur levée, rejetée ou passée à console.error : le
// message d'une erreur, la chaîne elle-même, sinon sa conversion.
function texte(valeur) {
if (valeur instanceof Error) return valeur.message;
try {
return String(valeur);
} catch (_) {
return Object.prototype.toString.call(valeur);
}
}
globalThis.addEventListener('error', function (evenement) {
erreurs.push({ type: 'error', message: evenement.error !== undefined && evenement.error !== null ? texte(evenement.error) : String(evenement.message) });
});
globalThis.addEventListener('unhandledrejection', function (evenement) {
erreurs.push({ type: 'unhandledrejection', message: texte(evenement.reason) });
});
var original = console.error;
console.error = function () {
var morceaux = [];
for (var i = 0; i < arguments.length; i += 1) morceaux.push(texte(arguments[i]));
erreurs.push({ type: 'console.error', message: morceaux.join(' ') });
return original.apply(console, arguments);
};
})();

View file

@ -2,25 +2,13 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Essai de démarrage : construit la page, lance la vraie coquille Electron
// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier
// écran, puis ferme. Il prouve ce que la coquille factice des épreuves
// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce
// système, avec ses bibliothèques, y charge la page, et que l'application y
// monte jusqu'à la liste des événements.
// par Playwright, attend sa fenêtre, lit le titre et le bandeau, puis ferme.
// Il prouve ce que la coquille factice des épreuves (test/coquille.test.js)
// ne peut pas dire : qu'Electron démarre sur ce système, avec ses
// bibliothèques, et y charge la page.
//
// node scripts/essai_demarrage.js
//
// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et
// le bandeau — qui porte aussi provenance, thème, mode et « À propos » —
// contenir la version affichée.
//
// La coquille se lance comme publiée par le lanceur portable dans un dossier
// neuf, créé sous node_modules/.cache/ et retiré en sortant : la sonde de la
// détermination y écrit son dossier de travail. Sans lui, elle créerait le
// dossier du produit dans les vrais Documents de la machine ; et un dossier
// sous le dossier temporaire du système relève des données applicatives
// (§ 8.6, point 2), qui renvoient aux mêmes Documents.
//
// Sous Linux sans session graphique, Electron se lance sur la plateforme
// Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge
// et s'y met en page, sans serveur d'affichage. Sous root, ou avec
@ -28,8 +16,6 @@
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
// Sort à 0 sur un démarrage conforme, à 1 sinon.
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { _electron } from 'playwright';
import { VERSION } from '../src/version.genere.js';
@ -49,43 +35,28 @@ execFileSync('npm', ['run', '--silent', 'build'], {
shell: process.platform === 'win32',
});
const cache = join(RACINE, 'node_modules', '.cache');
mkdirSync(cache, { recursive: true });
const publie = mkdtempSync(join(cache, 'essai-demarrage-'));
const attendu = titreAvecVersion(VERSION.affichee);
const application = await _electron.launch({
args: [
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE,
timeout: ATTENTE_MS,
});
let conforme = false;
try {
const application = await _electron.launch({
args: [
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE,
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie },
timeout: ATTENTE_MS,
});
try {
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
// le bandeau s'y voit par sa largeur minimale, la liste n'y est que
// présente.
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
await fenetre.locator('[data-ecran="accueil"]').waitFor({ state: 'attached', timeout: ATTENTE_MS });
const titre = await fenetre.title();
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
const erreurs = await fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
conforme = titre === attendu && bandeau.includes(VERSION.affichee) && Array.isArray(erreurs) && erreurs.length === 0;
console.log(`Titre de la fenêtre : ${titre}`);
console.log(`Bandeau : ${bandeau}`);
console.log(`Erreurs recueillies : ${JSON.stringify(erreurs)}`);
if (!conforme) console.error(`Attendu : titre « ${attendu} », bandeau portant ${VERSION.affichee}, collecteur posé et vide`);
} finally {
await application.close();
}
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
await fenetre.locator('header.bandeau .titre').waitFor({ timeout: ATTENTE_MS });
const titre = await fenetre.title();
const bandeau = (await fenetre.locator('header.bandeau .titre').textContent()).trim();
conforme = titre === attendu && bandeau === attendu;
console.log(`Titre de la fenêtre : ${titre}`);
console.log(`Bandeau : ${bandeau}`);
if (!conforme) console.error(`Attendu : ${attendu}`);
} finally {
rmSync(publie, { recursive: true, force: true });
await application.close();
}
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
process.exit(conforme ? 0 : 1);

View file

@ -170,13 +170,11 @@ const EN_TETE_LICENCE = [
/**
* Contenu exact de src/version.genere.js pour une version affichée :
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
* formes affichée, technique et Windows — celle que le panneau « À propos »
* met en regard de l'affichée (§ 18.6), sans la recalculer. Le module ne
* porte que la version ; la provenance de la construction vient du mode de
* Vite.
* formes affichée et technique. Le module ne porte que la version ; la
* provenance de la construction vient du mode de Vite.
*/
export function engendrerModule(versionAffichee) {
const { affichee, technique, windows } = deriver(versionAffichee);
const { affichee, technique } = deriver(versionAffichee);
return [
...EN_TETE_LICENCE,
'',
@ -184,7 +182,6 @@ export function engendrerModule(versionAffichee) {
'export const VERSION = Object.freeze({',
` affichee: '${affichee}',`,
` technique: '${technique}',`,
` windows: '${windows}',`,
'});',
'',
].join('\n');

View file

@ -16,7 +16,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import {
controler,
@ -242,7 +242,6 @@ const MODULE = [
'export const VERSION = Object.freeze({',
" affichee: '2026.10.05.01',",
" technique: '2026.1005.1',",
" windows: '2026.1005.1.0',",
'});',
'',
].join('\n');
@ -773,20 +772,6 @@ describe('version : engendrement', () => {
assert.equal(engendrerModule(VERSION), MODULE);
});
test('le module engendré porte les formes affichée, technique et Windows de deriver, et elles seules', async () => {
const dossier = mkdtempSync(join(tmpdir(), 'version-module-'));
try {
const chemin = join(dossier, 'version.genere.js');
writeFileSync(chemin, engendrerModule('2026.10.06.02'));
const { VERSION: engendree } = await import(pathToFileURL(chemin).href);
const { affichee, technique, windows } = deriver('2026.10.06.02');
assert.deepEqual(engendree, { affichee, technique, windows });
assert.ok(Object.isFrozen(engendree));
} finally {
rmSync(dossier, { recursive: true, force: true });
}
});
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');

View file

@ -1,144 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5) : ce qu'un changement
// de défaut, de tours ou la suppression d'une table emporterait, compté sur
// la charge courante sans rien écrire, pour l'énoncer avant d'appliquer. La
// commande qui applique lit le même bilan : changerDefaut lève le premier de
// ses refus. Les refus et avertissements d'une capacité sont ceux de
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
// modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js';
import { presents } from './places.js';
const croissant = (a, b) => a - b;
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const parNumero = (a, b) => a.numero - b.numero || a.id - b.id;
// Un nombre entier attendu ; sa lecture depuis une saisie appartient à
// lireEntier.
function exigerEntier(valeur, nom) {
if (!Number.isSafeInteger(valeur)) throw new TypeError(`${nom} : entier attendu, reçu ${JSON.stringify(valeur)}`);
}
// L'enregistrement de la table d'identifiant id, ou TypeError.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// Les identifiants croissants des placements de la liste qui satisfont
// touche ; un placement fautif, de forme quelconque, se lit sans lever.
const identifiants = (placements, touche) =>
placements.filter((placement) => touche(placement)).map(({ id }) => id).sort(croissant);
/**
* Le changement du défaut à sieges places (§ 6.1), énoncé avant d'être
* appliqué : suivent, le nombre de tables qui suivent le défaut ; avant et
* apres, le défaut d'aujourd'hui et sieges ; surchargees, les capacités des
* tables surchargées par numéro de table ; totalAvant et totalApres, la
* somme des capacités avant et après ; participants, les personnes non
* exclues. realigner : les surchargées passent aussi à sieges.
*
* Chaque table dont la capacité change se contrôle comme une saisie au champ
* (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
* details}, par ordre de charge.tables ; avertissements, de même, ceux des
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} sieges
* @param {{realigner?: boolean}} [options]
* @returns {{suivent: number, avant: number, apres: number, surchargees: number[], totalAvant: number,
* totalApres: number, participants: number, refus: Array<{table: number|null, code: string, details: Object}>,
* avertissements: Array<{table: number, code: string, details: Object}>}}
* @throws {TypeError} sieges qui n'est pas un entier
*/
export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}) {
exigerEntier(sieges, 'sieges');
const apresDe = (table) => (table.sieges === null || realigner ? sieges : table.sieges);
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
const changements = new Map(
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
);
const controle =
sieges < 2
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
: controlerCapacites(charge, changements, { origine: 'champ' });
return {
suivent: charge.tables.filter((table) => table.sieges === null).length,
avant: charge.evenement.siegesParDefaut,
apres: sieges,
surchargees: charge.tables
.filter((table) => table.sieges !== null)
.sort(parNumero)
.map((table) => table.sieges),
totalAvant: somme(charge.tables.map((table) => capacite(charge, table))),
totalApres: somme(charge.tables.map(apresDe)),
participants: presents(charge).size,
refus: controle.refus,
avertissements: controle.avertissements,
};
}
/**
* Le changement du nombre de tours à tours (§ 2.1 étape 5, § 4.3) :
* reservationsDetruites, les réservations « tour désigné » au-delà, dans
* l'ordre canonique ; propositionsTouchees, les identifiants croissants des
* propositions qui ne porteront pas tours tours, et retenuTouche de même
* pour le retenu — ils restent, en dérive TOURS_CHANGES (§ 9). Le nombre de
* tours d'aujourd'hui ne touche rien. La portée « tous » ne se détruit
* jamais : elle n'énumère aucun tour.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} tours
* @returns {{reservationsDetruites: Array<{participant: number, table: number, tour: number}>,
* propositionsTouchees: number[], retenuTouche: boolean}}
* @throws {TypeError} tours qui n'est pas un entier
*/
export function bilanTours(charge, tours) {
exigerEntier(tours, 'tours');
const change = tours !== charge.evenement.tours;
const touche = (placement) => change && placement?.tours !== tours;
return {
reservationsDetruites: charge.reservations
.filter(({ portee, tour }) => portee === 'tour' && tour > tours)
.sort(ordreDesReservations)
.map(({ participant, table, tour }) => ({ participant, table, tour })),
propositionsTouchees: identifiants(charge.propositions, touche),
retenuTouche: charge.retenu !== null && touche(charge.retenu),
};
}
/**
* La suppression de la table id (§ 6.2, § 4) : ses réservations, suspendues
* comprises, dans l'ordre canonique, et ses titres par siège puis libellé,
* qui partent avec elle ; propositionsTouchees, les identifiants croissants
* des propositions qui la déclarent, et retenuTouche de même — ils restent,
* en dérive TABLE_SUPPRIMEE (§ 9). Une table nue rend deux listes vides.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} id
* @returns {{reservations: Array<{participant: number, portee: 'tous'|'tour', tour: number|null, siege: number|null}>,
* titres: Array<{siege: number, libelle: string}>, propositionsTouchees: number[], retenuTouche: boolean}}
* @throws {TypeError} une table inconnue
*/
export function bilanSuppressionTable(charge, id) {
tableDe(charge, id);
const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id);
return {
reservations: charge.reservations
.filter(({ table }) => table === id)
.sort(ordreDesReservations)
.map(({ participant, portee, tour, siege }) => ({ participant, portee, tour, siege })),
titres: charge.titres
.filter(({ table }) => table === id)
.sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, libelle })),
propositionsTouchees: identifiants(charge.propositions, declare),
retenuTouche: charge.retenu !== null && declare(charge.retenu),
};
}

View file

@ -1,232 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5, § 4.3) : ce
// qu'un changement de défaut, de tours ou une suppression de table emporte,
// énoncé avant d'appliquer, sans rien écrire. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nombrePlaces, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
import { COMMANDES } from './commandes.js';
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length],
prenom: id === 2 ? 'Théo' : null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant),
// n personnes dont les exclus, R tours, un défaut.
function chargeDe({ tables, n, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] }) {
const charge = creerCharge({ id: 'evt-bilans', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
charge.tables = tables.map(({ id, sieges = null }) => ({ id, numero: id, 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;
}
// Ajoute la proposition id, dont les listes de chaque tour suivent 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: t }) => t),
capacites: charge.tables.map(({ sieges }) => sieges ?? charge.evenement.siegesParDefaut),
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: 'essai', ...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;
}
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// 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(options = {}) {
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], ...options });
}
describe('bilans : changer le défaut (§ 6.1)', () => {
test("l'énoncé chiffré du § 6.1 : 9 tables passent de 8 à 10, 4 surchargées ne changent pas, 104 → 122 pour 118", () => {
const charge = treizeTables();
const bilan = bilanChangementDefaut(charge, 10);
assert.deepEqual(bilan, {
suivent: 9,
avant: 8,
apres: 10,
surchargees: [7, 7, 6, 12],
totalAvant: 104,
totalApres: 122,
participants: 118,
refus: [],
avertissements: [],
});
// Le total et les participants sont ceux de l'instance de la charge appliquée.
const apres = COMMANDES.get('changerDefaut')(charge, { sieges: 10, realigner: false }).charge;
const instance = normaliser(configurationDepuisCharge(apres));
assert.equal(bilan.totalApres, nombrePlaces(instance));
assert.equal(bilan.participants, instance.N);
});
test('réaligner : les surchargées changent aussi, et leurs refus en sont', () => {
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
const realigne = bilanChangementDefaut(charge, 10, { realigner: true });
assert.equal(realigne.totalApres, 130);
assert.deepEqual(realigne.refus, [
{
table: 12,
code: 'SIEGES_RETIRES',
details: {
table: 12,
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
},
},
]);
assert.deepEqual(bilanChangementDefaut(charge, 10).refus, []);
});
test('une baisse se contrôle table par table : la table 2, sous ses 7 réservations, refuse ; les autres non', () => {
const reservations = [1, 2, 3, 4, 5, 6, 7].map((id) => resa(id, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
const bilan = bilanChangementDefaut(charge, 6);
assert.deepEqual(bilan.refus, [
{ table: 2, code: 'SOUS_RESERVATIONS', details: { table: 2, n: 7, tour: 1, remede: afficherTour(1) } },
]);
assert.equal(bilan.totalApres, 18);
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
});
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const bilan = bilanChangementDefaut(charge, 6);
assert.deepEqual(bilan.refus, []);
assert.deepEqual(bilan.avertissements, [
{ table: 3, code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } },
]);
});
test("le bilan n'écrit rien : la charge reçue, gelée, ne change pas", () => {
const charge = Object.freeze(treizeTables());
assert.equal(bilanChangementDefaut(charge, 6).totalApres, 9 * 6 + 32);
});
});
describe('bilans : changer les tours (§ 2.1 étape 5, § 4.3)', () => {
// R = 4 : deux réservations au tour 4, une au tour 2, une « tous » ; deux
// propositions et un retenu.
function quatreTours() {
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
const tour = [[1, 2, 3, 5], [4, 6, 7, 8]];
const listes = [tour, [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
return charge;
}
test('retirer le tour 4 détruit ses deux réservations « tour » ; propositions et retenu touchés', () => {
const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 3), {
reservationsDetruites: [
{ participant: 1, table: 1, tour: 4 },
{ participant: 2, table: 2, tour: 4 },
],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
});
test("le même nombre ne touche rien ; en ajouter ne détruit rien, mais périme les placements", () => {
const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 4), { reservationsDetruites: [], propositionsTouchees: [], retenuTouche: false });
assert.deepEqual(bilanTours(charge, 5), { reservationsDetruites: [], propositionsTouchees: [1, 2], retenuTouche: true });
});
});
describe("bilans : supprimer une table (§ 6.2, § 4)", () => {
function garnie() {
const charge = chargeDe({
tables: [{ id: 1 }, { id: 2 }, { id: 3 }],
n: 6,
reservations: [resa(2, 2, 2), resa(5, 1, null)],
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
});
const listes = [[[1, 5], [2, 3], [4, 6]], [[3, 5], [2, 6], [1, 4]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
return charge;
}
test('la table 2 porte la réservation « tous » de Théo Pervenche et un titre : le bilan les nomme, et les placements touchés', () => {
assert.deepEqual(bilanSuppressionTable(garnie(), 2), {
reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }],
titres: [{ siege: 2, libelle: 'animateur' }],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
});
test('une table nue : deux listes vides ; une table créée après les placements ne les touche pas', () => {
const charge = garnie();
const nue = bilanSuppressionTable(charge, 3);
assert.deepEqual([nue.reservations, nue.titres], [[], []]);
const creee = COMMANDES.get('creerTable')(charge, { forme: 'carree' }).charge;
const idCree = creee.tables.at(-1).id;
// creerTable fait entrer la table au retenu ; aucune proposition ne la déclare.
assert.deepEqual(bilanSuppressionTable(creee, idCree), { reservations: [], titres: [], propositionsTouchees: [], retenuTouche: true });
});
test('les titres se rangent par siège puis libellé', () => {
const charge = garnie();
charge.titres.push({ table: 2, siege: 1, libelle: 'hôte' }, { table: 2, siege: 2, libelle: 'accueil' });
assert.deepEqual(bilanSuppressionTable(charge, 2).titres, [
{ siege: 1, libelle: 'hôte' },
{ siege: 2, libelle: 'accueil' },
{ siege: 2, libelle: 'animateur' },
]);
});
test('une table inconnue est une faute du code ; des places ou des tours non entiers aussi', () => {
assert.throws(() => bilanChangementDefaut(garnie(), '6'), TypeError);
assert.throws(() => bilanTours(garnie(), 2.5), TypeError);
assert.throws(() => bilanSuppressionTable(garnie(), 9), TypeError);
});
});

View file

@ -1,402 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le contrôle de capacité (§ 6.2, § 7.1) : ce qu'une capacité de table
// refuse et ce dont elle avertit, et le seul — les commandes du mobilier et
// leurs bilans l'appellent, aucun composant ne le refait (§ 13.2). Aussi la
// seule lecture d'un entier saisi (lireEntier), et la réécriture du retenu
// qu'une capacité changée entraîne (retenuAuxCapacites).
//
// Un refus a la forme { code, details }, le remède dans details.remede. Ils
// se contrôlent dans cet ordre, et le premier seul est rendu :
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
// porte un titre ou une réservation, suspendue comprise — l'analyse du
// fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
// l'occupation de chaque tour telle qu'etatPlaces la montre. Le champ et la
// poignée décrivent deux gestes (§ 6.2, § 7.1) : la poignée, geste de
// géométrie fait sans regarder les autres tours, ne désassoit personne ; le
// champ, saisie délibérée, verse dans la réserve les occupants des chaises
// retirées et le dit (SURNUMERAIRES).
//
// Le retenu suit les capacités : ce qui ne tient plus va dans la réserve de
// chaque tour, puis la capacité s'écrit dans retenu.capacites, de sorte que
// le retenu ne gagne aucune raison de dérive. Sièges attribués, le siège est
// le rang : une personne que la table réserve à ce tour, assise sur une
// chaise que le plan montrait et que la nouvelle capacité retire, revient
// d'abord sur une chaise libre dans la capacité, sinon sur celle du plus
// haut rang qu'occupe un non-réservé ; puis les rangs au-delà de la capacité
// partent, et la liste perd ses chaises vides de fin. Aucune réservation que
// le plan montrait tenue ne cesse ainsi de l'être, et un rang au-delà de la
// capacité courante, que le plan ne montre pas, n'assied personne. Sans
// attribution, la liste garde les personnes que
// le plan montre assises à la table avant et après le geste — l'affichage
// élit d'abord celles que la table réserve —, puis, tant qu'elle dépasse,
// perd ses absents, le plus petit identifiant d'abord : un geste n'assied
// personne qu'il ne nomme. Un retenu fautif ne se réécrit pas. Aucune
// fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { constante } from '../geometrie/constantes.js';
import { capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
import { reservationsParTour } from './pose.js';
const croissant = (a, b) => a - b;
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Un entier écrit en chiffres décimaux seuls : ni signe, ni séparateur, ni
// exposant.
const CHIFFRES = /^[0-9]+$/;
const ORIGINES = new Set(['champ', 'poignee']);
// Le seuil de lisibilité, nommé dans le registre des constantes mesurées
// (§ 14.1) : un nom de constante, que la table des libellés ne connaît pas.
const SEUIL_LISIBLE = { nom: 'SEUIL_SIEGES_LISIBLES' };
const refusDe = (code, details, remede) => ({ code, details: { ...details, remede } });
const avertissement = (code, details) => ({ code, details });
// Le remède d'une capacité sous un compte : montrer le tour qui l'atteint,
// sur le plan du retenu ou des réservations.
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
/**
* Lit un entier saisi (§ 6.2) : des chiffres décimaux seuls, blancs de bord
* admis, ou un nombre entier ≥ 0 ; tout autre texte — signe, décimale,
* virgule, lettres, vide, ou un entier au-delà de 2^53 − 1 — refuse
* NON_ENTIER {saisie}, remède null. Le domaine de la valeur — au moins 2
* places, au moins un tour — appartient à son contrôle.
*
* @param {string|number} saisie
* @returns {{valeur: number}|{refus: {code: 'NON_ENTIER', details: {saisie: string|number, remede: null}}}}
* @throws {TypeError} une saisie qui n'est ni un texte ni un nombre
*/
export function lireEntier(saisie) {
if (typeof saisie === 'number') {
if (Number.isSafeInteger(saisie) && saisie >= 0) return { valeur: saisie + 0 };
} else if (typeof saisie === 'string') {
const texte = saisie.trim();
const valeur = CHIFFRES.test(texte) ? Number(texte) : Number.NaN;
if (Number.isSafeInteger(valeur)) return { valeur };
} else {
throw new TypeError(`lireEntier : texte ou nombre attendu, reçu ${JSON.stringify(saisie)}`);
}
return { refus: refusDe('NON_ENTIER', { saisie }, null) };
}
// L'enregistrement de la table d'identifiant id : une table inconnue est une
// faute de l'appelant, que l'interface n'offre jamais.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// Le plus grand compte d'une liste par tour, et le premier tour qui
// l'atteint.
function maximum(comptes) {
const n = Math.max(0, ...comptes);
return { n, tour: comptes.indexOf(n) + 1 };
}
// Les sièges au-delà de sieges qui portent une saisie, par numéro
// décroissant ; à un même siège, ses titres par libellé, puis ses
// réservations dans l'ordre canonique. Une réservation suspendue — sa
// personne exclue — y est, sièges attribués ou non.
function siegesPortes(charge, idTable, sieges) {
const ids = presents(charge);
const titres = charge.titres.filter(({ table, siege }) => table === idTable && siege > sieges);
const reservations = charge.reservations.filter(({ table, siege }) => table === idTable && siege !== null && siege > sieges);
const numeros = [...new Set([...titres, ...reservations].map(({ siege }) => siege))].sort((a, b) => b - a);
return numeros.flatMap((numero) => [
...titres
.filter(({ siege }) => siege === numero)
.sort((a, b) => comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, cause: 'SIEGE_TITRE', libelle })),
...reservations
.filter(({ siege }) => siege === numero)
.sort(ordreDesReservations)
.map(({ siege, participant }) => ({ siege, cause: 'SIEGE_RESERVE', participant, suspendue: !ids.has(participant) })),
]);
}
// Le remède d'un siège retiré : le retrait de son titre, ou de sa
// réservation, désignée par ses quatre arguments.
function remedeDuSiege(charge, idTable, retire) {
if (retire.cause === 'SIEGE_TITRE') return { commande: 'retirerTitre', arguments: { table: idTable, siege: retire.siege } };
const { portee, tour } = charge.reservations
.filter((r) => r.table === idTable && r.siege === retire.siege && r.participant === retire.participant)
.sort(ordreDesReservations)[0];
return { commande: 'retirerReservation', arguments: { participant: retire.participant, table: idTable, portee, tour } };
}
// L'occupation de la table à chaque tour, lue dans etatPlaces : les chaises
// qui portent un occupant ou une réservation ; sièges attribués, le plus
// haut numéro de ces chaises quand il est plus grand.
function occupation(charge, idTable) {
return Array.from({ length: charge.evenement.tours }, (_, rang) => {
const etat = etatPlaces(charge, { tour: rang + 1 });
const { chaises } = etat.tables.find(({ id }) => id === idTable);
const portees = chaises.filter(({ occupant, reservation }) => occupant !== null || reservation !== null);
const plusHaut = portees.length === 0 ? 0 : portees.at(-1).siege;
return etat.siegesAttribues ? Math.max(portees.length, plusHaut) : portees.length;
});
}
// Le premier refus d'une capacité, ou null.
function refusDeCapacite(charge, idTable, sieges, origine) {
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
const reservees = maximum(reservationsParTour(charge, idTable));
if (sieges < reservees.n) {
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
}
const retires = siegesPortes(charge, idTable, sieges);
if (retires.length > 0) {
const titres = charge.reglages.attribuerSieges ? {} : { titres: retires.filter(({ cause }) => cause === 'SIEGE_TITRE').length };
return refusDe('SIEGES_RETIRES', { table: idTable, sieges: retires, ...titres }, remedeDuSiege(charge, idTable, retires[0]));
}
if (origine === 'poignee') {
const occupees = maximum(occupation(charge, idTable));
if (sieges < occupees.n) return refusDe('SOUS_OCCUPES', { table: idTable, ...occupees }, afficherTour(occupees.tour));
}
return null;
}
// Les personnes que le plan montre assises à la table au tour, dans l'ordre
// de leurs chaises : celles d'etatPlaces (etat) pour un tour de la soirée.
// Un tour du retenu que la soirée n'a plus ne se montre pas : il se lit
// selon la même règle, la liste de la source répartie aux places courantes
// par repartirSieges, les réservations « tous » de la table seules
// couvrantes.
function assises(charge, etat, idTable, tour) {
if (etat !== null) {
return etat.tables
.find(({ id }) => id === idTable)
.chaises.map(({ occupant }) => occupant)
.filter((occupant) => occupant !== null);
}
const { retenu } = charge;
const ids = presents(charge);
const places = capacite(charge, tableDe(charge, idTable));
const liste = retenu.placement[tour - 1].sieges[retenu.tables.indexOf(idTable)];
if (retenu.siegesAttribues) return liste.slice(0, places).filter((id) => id !== null && ids.has(id));
const tous = reservationsActives(charge).filter(({ table, portee }) => table === idTable && portee === 'tous');
const siegeDe = new Map();
for (const { participant, siege } of tous) if (siege !== null && !siegeDe.has(participant)) siegeDe.set(participant, siege);
const prioritaires = new Set(tous.map(({ participant }) => participant));
return repartirSieges(liste.filter((id) => ids.has(id)), siegeDe, places, prioritaires).filter((id) => id !== null);
}
// Sièges attribués, la liste de la table réduite à ses limite premiers
// rangs, sans chaise vide de fin. reservees (Map personne → siège réservé ou
// null) : les personnes que la table réserve à ce tour. Chacune, assise à un
// rang que le plan montrait — de limite à montres, la capacité courante — et
// que la réduction retire, revient, dans l'ordre des rangs, à son siège
// réservé quand il est libre, sinon à la première chaise libre — vide, ou
// d'un absent, qui passe alors en réserve —, sinon à la chaise du plus haut
// rang qu'occupe un non-réservé, qui passe en réserve. Un rang à partir de
// montres ne ramène personne : une hausse (limite égale à montres) n'en
// ramène aucun. La capacité a passé SIEGES_RETIRES et SOUS_RESERVATIONS :
// chaque siège réservé est dans limite, et les réservés de la table à ce
// tour n'y sont pas plus nombreux que les chaises ; une chaise se trouve
// toujours.
function ramenerReservees(liste, limite, montres, reservees, ids) {
const reste = Array.from({ length: limite }, (_, rang) => liste[rang] ?? null);
const libre = (occupant) => occupant === null || !ids.has(occupant);
for (const id of liste.slice(limite, montres)) {
if (id === null || !reservees.has(id)) continue;
const voulu = reservees.get(id);
let rang = voulu !== null && libre(reste[voulu - 1]) ? voulu - 1 : reste.findIndex(libre);
if (rang < 0) rang = reste.findLastIndex((occupant) => !reservees.has(occupant));
reste[rang] = id;
}
while (reste.length > 0 && reste.at(-1) === null) reste.pop();
return reste;
}
// Les personnes que la table idTable réserve au tour, chacune avec son siège
// réservé ou null : les réservations actives (actives, dans l'ordre
// canonique) qui couvrent ce tour ; un tour que la soirée n'a plus, l'analyse
// n'admettant aucune portée « tour » au-delà des tours, seules les « tous »
// le couvrent. Le siège réservé d'une personne est le premier siège non nul
// de ses réservations, comme l'affichage le lit.
function reserveesAuTour(actives, idTable, tour) {
const reservees = new Map();
for (const reservation of actives) {
const { table, participant, siege } = reservation;
if (table !== idTable || !couvre(reservation, tour)) continue;
if (!reservees.has(participant) || reservees.get(participant) === null) reservees.set(participant, siege);
}
return reservees;
}
// La charge où chaque table de changements (Map identifiant → places) porte
// ses nouvelles places, le retenu tel quel.
const avecCapacites = (charge, changements) => ({
...charge,
tables: charge.tables.map((table) => (changements.has(table.id) ? { ...table, sieges: changements.get(table.id) } : table)),
});
/**
* Ce que de nouvelles capacités font au retenu de la charge, par table de
* changements qu'il déclare, dans l'ordre de charge.tables, et par tour du
* retenu : la liste qui reste, ce qui en est versé à la réserve, et les
* personnes que le plan du tour montrait assises à la table et qui passent
* dans la réserve, dans l'ordre de leurs chaises — pour un tour que la
* soirée n'a plus, celles qu'assises y lit. null sans retenu, ou pour un
* retenu fautif, qui ne se réécrit pas.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @returns {null|Array<{table: number, rang: number, sieges: number,
* tours: Array<{tour: number, liste: Array<number|null>, verses: number[], personnes: number[]}>}>}
*/
function versements(charge, changements) {
const { retenu } = charge;
if (retenu === null || fautes(retenu).length > 0) return null;
const hypothese = avecCapacites(charge, changements);
const ids = presents(charge);
const actives = reservationsActives(charge);
const touchees = charge.tables
.filter(({ id }) => changements.has(id) && retenu.tables.includes(id))
.map(({ id }) => ({ table: id, rang: retenu.tables.indexOf(id), sieges: changements.get(id), tours: [] }));
for (let tour = 1; tour <= retenu.tours; tour += 1) {
const visible = tour <= charge.evenement.tours;
const etatAvant = visible ? etatPlaces(charge, { tour }) : null;
// Sièges attribués, ce qui reste ne se lit pas dans le plan d'après.
const etatApres = visible && !retenu.siegesAttribues ? etatPlaces(hypothese, { tour }) : null;
for (const touchee of touchees) {
const { table, rang, sieges } = touchee;
const liste = retenu.placement[tour - 1].sieges[rang];
const avant = assises(charge, etatAvant, table, tour);
let reste;
if (retenu.siegesAttribues) {
// Le siège est le rang : un rang au-delà des places d'aujourd'hui ne
// se montre pas, et une hausse ne l'assied pas.
const montres = capacite(charge, tableDe(charge, table));
const limite = Math.min(sieges, montres);
reste = ramenerReservees(liste, limite, montres, reserveesAuTour(actives, table, tour), ids);
} else {
const apres = new Set(assises(hypothese, etatApres, table, tour));
const gardees = new Set(avant.filter((id) => apres.has(id)));
reste = liste.filter((id) => !ids.has(id) || gardees.has(id));
const absents = reste.filter((id) => !ids.has(id)).sort(croissant);
// Les présents gardés tiennent dans sieges : ôter les absents suffit.
while (reste.length > sieges && absents.length > 0) {
const absent = absents.shift();
reste = reste.filter((id) => id !== absent);
}
}
const restants = new Set(reste);
const verses = liste.filter((id) => id !== null && !restants.has(id));
const personnes = avant.filter((id) => !restants.has(id));
touchee.tours.push({ tour, liste: reste, verses, personnes });
}
}
return touchees;
}
/**
* Le retenu de la charge aux nouvelles capacités (§ 6.2, § 9) : à chaque
* tour, ce qui ne tient plus versé dans la réserve, puis la capacité de
* chaque table touchée écrite dans retenu.capacites. Un objet neuf ; le
* retenu reçu tel quel sans retenu, pour un retenu fautif, ou quand il ne
* déclare aucune table touchée.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @returns {import('../stockage/types.js').Retenu|null}
*/
export function retenuAuxCapacites(charge, changements) {
const touchees = versements(charge, changements);
if (touchees === null || touchees.length === 0) return charge.retenu;
const retenu = structuredClone(charge.retenu);
for (const { rang, sieges, tours } of touchees) {
for (const { tour, liste, verses } of tours) {
const etage = retenu.placement[tour - 1];
etage.sieges[rang] = liste;
etage.reserve = [...etage.reserve, ...verses].sort(croissant);
}
retenu.capacites[rang] = sieges;
}
return retenu;
}
/**
* Les refus et les avertissements de plusieurs capacités à la fois, chacune
* contrôlée comme le ferait controlerCapacite, sur la même charge : les
* tables ne partagent aucune chaise. Les tables de changements dans l'ordre
* de charge.tables ; refus, le premier de chaque table qui refuse ;
* avertissements, ceux des tables qui ne refusent pas.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @param {{origine: 'champ'|'poignee'}} options
* @returns {{refus: Array<{table: number, code: string, details: Object}>,
* avertissements: Array<{table: number, code: string, details: Object}>}}
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
*/
export function controlerCapacites(charge, changements, { origine }) {
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
for (const [idTable, sieges] of changements) {
tableDe(charge, idTable);
if (!Number.isSafeInteger(sieges)) throw new TypeError(`places : entier attendu, reçu ${JSON.stringify(sieges)}`);
}
const ordre = charge.tables.filter(({ id }) => changements.has(id)).map(({ id }) => id);
const refus = [];
const acceptees = new Map();
for (const idTable of ordre) {
const premier = refusDeCapacite(charge, idTable, changements.get(idTable), origine);
if (premier === null) acceptees.set(idTable, changements.get(idTable));
else refus.push({ table: idTable, ...premier });
}
const parTable = new Map(ordre.map((id) => [id, []]));
for (const { table, tours } of versements(charge, acceptees) ?? []) {
for (const { tour, personnes } of tours) {
if (personnes.length > 0) parTable.get(table).push(avertissement('SURNUMERAIRES', { table, tour, personnes }));
}
}
const seuil = constante(SEUIL_LISIBLE.nom);
const avertissements = [];
for (const [idTable, sieges] of acceptees) {
for (const { code, details } of parTable.get(idTable)) avertissements.push({ table: idTable, code, details });
if (sieges > seuil) avertissements.push({ table: idTable, code: 'AU_DELA_LISIBLE', details: { seuil } });
}
return { refus, avertissements };
}
/**
* Le contrôle d'une capacité (§ 6.2, § 7.1), sans rien écrire : sieges
* places pour la table idTable, saisies au champ ou tirées à la poignée.
* refus, le premier dans l'ordre de l'en-tête du module, ou null ;
* avertissements, quand il n'y a pas de refus : SURNUMERAIRES {table, tour,
* personnes}, un par tour du retenu — un tour que la soirée n'a plus
* compris — où il verse dans la réserve des personnes que le plan montrait
* assises à la table, nommées dans
* l'ordre de leurs chaises — la poignée, qui refuse sous l'occupation, n'en
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
* SEUIL_SIEGES_LISIBLES places.
*
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n,
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
* remède { geste: 'afficherTour', tour, proposition: null } ;
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un
* titre {siege, cause: 'SIEGE_TITRE', libelle} ou une réservation {siege,
* cause: 'SIEGE_RESERVE', participant, suspendue}, par numéro décroissant ;
* titres, le compte des titres, sous des sièges non attribués seulement —,
* remède celui du premier siège listé, retirerTitre ou retirerReservation.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} idTable
* @param {number} sieges
* @param {{origine: 'champ'|'poignee'}} options
* @returns {{refus: {code: string, details: Object}|null, avertissements: Array<{code: string, details: Object}>}}
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
*/
export function controlerCapacite(charge, idTable, sieges, { origine }) {
const { refus, avertissements } = controlerCapacites(charge, new Map([[idTable, sieges]]), { origine });
if (refus.length > 0) return { refus: { code: refus[0].code, details: refus[0].details }, avertissements: [] };
return { refus: null, avertissements: avertissements.map(({ code, details }) => ({ code, details })) };
}

View file

@ -1,284 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du contrôle de capacité (§ 6.2, § 7.1, § 14.1) : la lecture d'un
// entier saisi, puis les refus d'une capacité — moins de deux places, sous
// les réservations, sièges retirés qui portent une saisie, sous l'occupation
// pour la poignée — et l'avertissement de lisibilité. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { constante } from '../geometrie/constantes.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { controlerCapacite, lireEntier } from './capacite.js';
import { COMMANDES } from './commandes.js';
import { libelle } from './libelles.js';
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length],
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// Une charge d'une table d'identifiant 1, numéro 1, qui suit un défaut de
// huit places ; n personnes, R tours.
function chargeUneTable({ n = 8, tours = 3, attribuerSieges = false, reservations = [], titres = [], exclus = [] } = {}) {
const charge = creerCharge({ id: 'evt-capacite', nom: 'Soirée des Merles', siegesParDefaut: 8, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }];
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: 2, proposition: 1 };
return charge;
}
// La proposition 1, retenue, dont la table 1 assied listes[r] au tour r + 1 ;
// les autres déclarés attendent en réserve.
function retenir(charge, listes, { siegesAttribues = charge.reglages.attribuerSieges } = {}) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id);
const plan = {
siegesAttribues,
tables: [1],
capacites: [8],
tours: listes.length,
participants,
placement: listes.map((liste) => ({ sieges: [liste], reserve: participants.filter((id) => !liste.includes(id)) })),
};
charge.propositions = [{ id: 1, graine: 7, arret: 40, historique: 1000, produitVersion: 'essai', ...plan }];
charge.retenu = { proposition: 1, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
charge.prochainsIds.proposition = 2;
return charge;
}
const champ = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'champ' });
const poignee = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'poignee' });
describe("capacité : la lecture d'un entier saisi (§ 6.2)", () => {
test('un entier sans signe ni décimale se lit ; tout autre texte refuse NON_ENTIER, remède null', () => {
assert.deepEqual(lireEntier('2'), { valeur: 2 });
assert.deepEqual(lireEntier('0'), { valeur: 0 });
assert.deepEqual(lireEntier(' 12 '), { valeur: 12 });
assert.deepEqual(lireEntier(9), { valeur: 9 });
const refusees = ['-3', '2.5', '2,5', '8abc', 'huit', '', '+4', '1e3', '9007199254740993', -3, 2.5];
assert.equal(refusees.length, 11);
for (const saisie of refusees) {
assert.deepEqual(lireEntier(saisie), { refus: { code: 'NON_ENTIER', details: { saisie, remede: null } } }, String(saisie));
}
});
test("une saisie qui n'est ni un texte ni un nombre est une faute du code", () => {
assert.throws(() => lireEntier(null), TypeError);
assert.throws(() => lireEntier(undefined), TypeError);
});
});
describe("capacité : les refus d'une capacité (§ 6.2)", () => {
test('moins de deux places : MOINS_DE_DEUX, remède null ; deux places passent', () => {
const charge = chargeUneTable();
for (const sieges of [1, 0]) {
assert.deepEqual(champ(charge, sieges).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
}
assert.equal(champ(charge, 2).refus, null);
});
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => {
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
assert.deepEqual(champ(cinq, 4).refus, {
code: 'SOUS_RESERVATIONS',
details: { table: 1, n: 5, tour: 1, remede: afficherTour(1) },
});
assert.equal(champ(cinq, 5).refus, null);
// Trois réservations « tour », chacune à un tour différent : une à la fois.
const parTour = chargeUneTable({ reservations: [resa(1, 1, null, 1), resa(2, 1, null, 2), resa(3, 1, null, 3)] });
assert.equal(champ(parTour, 2).refus, null);
// Deux au tour 3 : le premier tour qui atteint deux est le 3.
parTour.reservations.push(resa(4, 1, null, 3));
assert.deepEqual(champ(parTour, 1).refus.code, 'MOINS_DE_DEUX');
parTour.reservations.push(resa(5, 1, null, 3));
assert.deepEqual(champ(parTour, 2).refus, { code: 'SOUS_RESERVATIONS', details: { table: 1, n: 3, tour: 3, remede: afficherTour(3) } });
// La réservation « tous » d'une personne exclue est suspendue : elle ne compte pas.
const exclue = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)), exclus: [5] });
assert.equal(champ(exclue, 4).refus, null);
});
test('sièges retirés : réservation au siège 7 et titre au siège 8, listés par numéro décroissant, remède du premier', () => {
const titre = { table: 1, siege: 8, libelle: 'animateur' };
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres: [titre] });
const reservee = { siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: false };
const titree = { siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' };
assert.deepEqual(champ(charge, 6).refus, {
code: 'SIEGES_RETIRES',
details: { table: 1, sieges: [titree, reservee], remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } } },
});
assert.deepEqual(champ(charge, 7).refus.details.sieges, [titree]);
// Sans le titre : la réservation seule, et son remède s'exécute tel quel.
const sansTitre = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)] });
const refus = champ(sansTitre, 6).refus;
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
assert.deepEqual(refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede } });
const leve = COMMANDES.get(remede.commande)(sansTitre, remede.arguments);
assert.equal(champ(leve.charge, 6).refus, null);
// Une réservation « tour » : le remède porte sa portée et son tour, et
// s'exécute tel quel.
const duTour = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7, 2)] });
const remedeDuTour = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tour', tour: 2 } };
assert.deepEqual(champ(duTour, 6).refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede: remedeDuTour } });
const leveDuTour = COMMANDES.get(remedeDuTour.commande)(duTour, remedeDuTour.arguments);
assert.deepEqual(leveDuTour.charge.reservations, []);
assert.equal(champ(leveDuTour.charge, 6).refus, null);
// La personne du siège 7 exclue : sa réservation suspendue reste listée.
const suspendue = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] });
assert.deepEqual(champ(suspendue, 6).refus.details.sieges, [{ ...reservee, suspendue: true }]);
// Sièges non attribués : la même liste, et le compte des titres.
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres: [titre] });
assert.deepEqual(champ(enTable, 6).refus.details, {
table: 1,
sieges: [titree, reservee],
titres: 1,
remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } },
});
});
test('deux titres à un même siège se listent par libellé ; le texte du refus les énonce dans cet ordre', () => {
const titres = [{ table: 1, siege: 8, libelle: 'animateur' }, { table: 1, siege: 8, libelle: 'accueil' }];
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres, exclus: [1] });
const { refus } = champ(charge, 6);
assert.deepEqual(refus.details.sieges.map(({ siege, libelle: texte }) => [siege, texte ?? null]), [[8, 'accueil'], [8, 'animateur'], [7, null]]);
assert.equal(
libelle(refus.code, refus.details),
'Réduire la table retire des sièges qui portent une saisie : le siège 8 porte le titre « accueil », ' +
'le siège 8 porte le titre « animateur » et le siège 7 est réservé, par une réservation suspendue.',
);
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres });
const refusEnTable = champ(enTable, 6).refus;
assert.equal(refusEnTable.details.titres, 2);
assert.equal(
libelle(refusEnTable.code, refusEnTable.details),
'Réduire la table retire des sièges qui portent une saisie : le siège 7 est réservé et 2 sièges portent un titre.',
);
});
test('chaque refus et chaque avertissement de capacité se dit en français, ses nombres compris', () => {
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
const textes = [
libelle('MOINS_DE_DEUX', champ(cinq, 1).refus.details),
libelle('SOUS_RESERVATIONS', champ(cinq, 4).refus.details),
libelle('NON_ENTIER', lireEntier('8abc').refus.details),
libelle('NON_ENTIER', lireEntier('').refus.details),
libelle('AU_DELA_LISIBLE', { seuil: 16 }),
libelle('SOUS_OCCUPES', { table: 1, n: 7, tour: 3 }),
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4] }),
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4, 5] }),
libelle('MOINS_D_UN_TOUR', { tours: 0 }),
];
assert.deepEqual(textes, [
'Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.',
'La table porte 5 réservations au tour 1 : elle ne descend pas sous 5 places.',
'« 8abc » n\'est pas un nombre entier : un nombre entier, sans signe ni décimale, est attendu.',
'La saisie est vide : un nombre entier, sans signe ni décimale, est attendu.',
"Au-delà de 16 places, la liste des noms de la table cesse d'être lisible sur le plan.",
'Au tour 3, la table occupe 7 places : la poignée ne descend pas en deçà.',
"Au tour 2, 1 personne passe dans la réserve : la table n'a plus de chaise pour elle.",
"Au tour 2, 2 personnes passent dans la réserve : la table n'a plus de chaise pour elles.",
'Une soirée compte au moins un tour.',
]);
});
test("sièges retirés : la charge réduite sans ce refus, suspendue ou en mode table, est celle que l'analyse refuse", () => {
for (const charge of [
chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] }),
chargeUneTable({ reservations: [resa(1, 1, 7)] }),
]) {
assert.equal(champ(charge, 6).refus.code, 'SIEGES_RETIRES');
const reduite = structuredClone(charge);
reduite.tables[0].sieges = 6;
assert.throws(
() => analyser(serialiser(reduite, { revision: 1, produitVersion: 'essai' })),
(erreur) => erreur.code === 'ETAT_ILLISIBLE' && erreur.details.raison === 'REFERENCE',
);
}
});
test('une table inconnue, ou une capacité qui n\'est pas un entier, est une faute du code', () => {
const charge = chargeUneTable();
assert.throws(() => controlerCapacite(charge, 9, 6, { origine: 'champ' }), TypeError);
assert.throws(() => controlerCapacite(charge, 1, '6', { origine: 'champ' }), TypeError);
assert.throws(() => controlerCapacite(charge, 1, 6, { origine: 'clavier' }), TypeError);
});
});
describe("capacité : la poignée, sous l'occupation de tous les tours (§ 7.1)", () => {
test('sièges non attribués : 5, 5, 7 et 5 personnes aux tours 1 à 4 ; 6 refuse SOUS_OCCUPES {n: 7, tour: 3}', () => {
const cinq = [1, 2, 3, 4, 5];
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7], cinq]);
assert.deepEqual(poignee(charge, 6).refus, {
code: 'SOUS_OCCUPES',
details: { table: 1, n: 7, tour: 3, remede: afficherTour(3) },
});
assert.equal(poignee(charge, 7).refus, null);
// Le champ, lui, accepte et avertit.
assert.equal(champ(charge, 6).refus, null);
assert.deepEqual(poignee(charge, 1).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
});
test("sièges attribués : huit occupants au tour 3 refusent 7 ; une réservation ou un titre au siège 8 refusent SIEGES_RETIRES d'abord", () => {
const cinq = [1, 2, 3, 4, 5];
const huit = retenir(chargeUneTable({ n: 8, attribuerSieges: true }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7, 8]]);
assert.deepEqual(poignee(huit, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 3, remede: afficherTour(3) } });
const assise = [1, 2, 3, 4, 5, null, null, 6];
const reservee = retenir(chargeUneTable({ n: 6, attribuerSieges: true, reservations: [resa(6, 1, 8)] }), [assise, assise, assise]);
const refus = poignee(reservee, 7).refus;
assert.equal(refus.code, 'SIEGES_RETIRES');
assert.deepEqual(refus.details.sieges, [{ siege: 8, cause: 'SIEGE_RESERVE', participant: 6, suspendue: false }]);
const titree = retenir(chargeUneTable({ n: 5, attribuerSieges: true, titres: [{ table: 1, siege: 8, libelle: 'accueil' }] }), [cinq, cinq, cinq]);
assert.deepEqual(poignee(titree, 7).refus.details.sieges, [{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'accueil' }]);
// Six occupants, deux chaises vides avant le sixième, aucune saisie : le
// plus haut numéro occupé, 8, compte plus que les six occupants.
const creuse = retenir(chargeUneTable({ n: 6, attribuerSieges: true }), [assise, assise, assise]);
assert.deepEqual(poignee(creuse, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 1, remede: afficherTour(1) } });
});
test("la poignée ne reçoit aucun tour affiché : l'occupation se lit à chaque tour du retenu", () => {
const cinq = [1, 2, 3, 4, 5];
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, cinq, [1, 2, 3, 4, 5, 6, 7]]);
assert.deepEqual(poignee(charge, 6).refus.details, { table: 1, n: 7, tour: 4, remede: afficherTour(4) });
});
});
describe('capacité : la lisibilité (§ 6.2, § 14.1)', () => {
test('S + 1 places avertissent AU_DELA_LISIBLE {seuil: S} ; S places, non', () => {
const seuil = constante('SEUIL_SIEGES_LISIBLES');
assert.ok(Number.isInteger(seuil) && seuil > 2);
const charge = chargeUneTable();
for (const origine of ['champ', 'poignee']) {
const au = controlerCapacite(charge, 1, seuil + 1, { origine });
assert.equal(au.refus, null);
assert.deepEqual(au.avertissements, [{ code: 'AU_DELA_LISIBLE', details: { seuil } }]);
assert.deepEqual(controlerCapacite(charge, 1, seuil, { origine }).avertissements, []);
}
});
});

View file

@ -634,14 +634,6 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['deplacerDansRetenu', { tour: 1, participant: autre, table: tableAutre, siege: null }],
['echangerDansRetenu', { tour: 1, a: voisin, b: autre }],
['retirerDuTour', { tour: 1, participant: autre }],
['creerTable', { forme: 'carree' }],
['supprimerTable', { id: 2 }],
['deplacerTable', { id: 1, position: { x: 40, y: 40 } }],
['changerPlacesTable', { id: 2, sieges: 3, origine: 'champ' }],
['realignerTable', { id: 1 }],
['changerDefaut', { sieges: 3, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
]);
}

View file

@ -1,266 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 9), que commandes.js réunit aux autres. Ses
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui,
// non l'inverse.
//
// Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
// intacte ; un refus lève ErreurCommande(code, details), details.remede
// compris, calculé avant toute modification ; un argument de forme fausse ou
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
// lit par lireEntier, la seule lecture d'un entier saisi.
//
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
// pas (§ 4). Les capacités passent par controlerCapacite (capacite.js) ; le
// retenu les suit, ses surnuméraires versés en réserve, et une table créée y
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
// table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre } from '../geometrie/tables.js';
import { SCHEMA, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { leverRefus } from './pose.js';
const FORMES = new Set(['ronde', 'carree']);
const ORIGINES = new Set(['champ', 'poignee']);
// Le plus grand identifiant de table qu'admet la règle du schéma : le
// compteur, qui la suit, n'aurait plus de valeur au-delà de lui.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge'), 'tables').element, 'id').max;
// Les réglages que modifierReglage change : un interrupteur, ou un entier ≥ 1
// de la génération (§ 5.3, § 5.10).
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
}
// L'enregistrement de la table d'identifiant id : une table inconnue est une
// faute de l'appelant.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// La valeur d'un entier saisi, ou le refus NON_ENTIER levé.
function entierSaisi(saisie) {
const lu = lireEntier(saisie);
if (lu.refus) throw new ErreurCommande(lu.refus.code, lu.refus.details);
return lu.valeur;
}
// La charge reçue, copiée, chaque table de changements (Map identifiant →
// sieges) à son nombre de sièges, null pour une table qui suit le défaut, et
// le retenu aux capacités qui en résultent.
function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefaut) {
const suivante = structuredClone(charge);
suivante.evenement.siegesParDefaut = defaut;
for (const table of suivante.tables) if (changements.has(table.id)) table.sieges = changements.get(table.id);
const capacites = new Map(suivante.tables.map((table) => [table.id, capacite(suivante, table)]));
const touchees = new Map(
charge.tables
.filter((table) => changements.has(table.id) || capacites.get(table.id) !== capacite(charge, table))
.map(({ id }) => [id, capacites.get(id)]),
);
suivante.retenu = structuredClone(retenuAuxCapacites(charge, touchees));
return suivante;
}
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
// refus levé.
function exigerCapacite(charge, id, sieges, origine) {
const { refus: refuse, avertissements } = controlerCapacite(charge, id, sieges, { origine });
if (refuse !== null) throw new ErreurCommande(refuse.code, refuse.details);
return avertissements;
}
/**
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
* prochainsIds.table, numérotée après la plus grande, posée sur la première
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
* capacité et une liste vide à chaque tour.
*/
function creerTable(charge, { forme } = {}) {
exigerModifiable(charge);
if (!FORMES.has(forme)) throw new TypeError(`forme : 'ronde' ou 'carree' attendue, reçu ${JSON.stringify(forme)}`);
const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
const position = caseLibre(charge.tables.map((table) => table.position));
const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1;
const { retenu } = suivante;
if (retenu !== null && fautes(retenu).length === 0) {
retenu.tables.push(id);
retenu.capacites.push(charge.evenement.siegesParDefaut);
for (const etage of retenu.placement) etage.sieges.push([]);
}
return { charge: suivante, libelle: libelle('ENTREE_CREER_TABLE', { numero }), avertissements: [] };
}
/**
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
* autres tables gardent leur numéro, propositions et retenu restent, en
* dérive TABLE_SUPPRIMEE.
*/
function supprimerTable(charge, { id } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
const suivante = structuredClone(charge);
suivante.tables = suivante.tables.filter((candidate) => candidate.id !== id);
suivante.reservations = suivante.reservations.filter((reservation) => reservation.table !== id);
suivante.titres = suivante.titres.filter((titre) => titre.table !== id);
const details = {
numero: table.numero,
reservations: charge.reservations.length - suivante.reservations.length,
titres: charge.titres.length - suivante.titres.length,
};
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
}
/** deplacerTable {id, position: {x, y}} (§ 7.1) : la position du centre, arrondie au centimètre. */
function deplacerTable(charge, { id, position } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) {
throw new TypeError(`position : {x, y} finis attendus, reçu ${JSON.stringify(position)}`);
}
const suivante = structuredClone(charge);
// + 0 écrit 0 là où l'arrondi d'une petite valeur négative rend −0.
suivante.tables.find((candidate) => candidate.id === id).position = { x: Math.round(position.x) + 0, y: Math.round(position.y) + 0 };
return { charge: suivante, libelle: libelle('ENTREE_DEPLACER_TABLE', { numero: table.numero }), avertissements: [] };
}
/**
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
* par controlerCapacite selon leur origine ; le retenu suit.
*/
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
const sieges = entierSaisi(saisie);
const avertissements = exigerCapacite(charge, id, sieges, origine);
return {
charge: avecSieges(charge, new Map([[id, sieges]])),
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
avertissements,
};
}
/**
* realignerTable {id} (§ 6.1) : la table suit de nouveau le défaut ; une
* table surchargée au-dessus de lui subit une réduction, sous la règle du
* champ (§ 6.2). Le retenu suit.
*/
function realignerTable(charge, { id } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
const sieges = charge.evenement.siegesParDefaut;
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
return {
charge: avecSieges(charge, new Map([[id, null]])),
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
avertissements,
};
}
/**
* changerDefaut {sieges, realigner: boolean} (§ 6.1) : le défaut change, et
* avec lui chaque table qui le suit ; realigner, les surchargées le suivent
* aussi. Une seule entrée. Chaque table touchée se contrôle avant
* d'appliquer (bilanChangementDefaut) : un seul refus refuse le tout, le
* premier levé, les suivants dans details.autres. Les avertissements sont
* ceux du bilan ; AU_DELA_LISIBLE, de mêmes détails à chaque table, n'est
* dit qu'une fois. Le retenu suit.
*/
function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
exigerModifiable(charge);
if (typeof realigner !== 'boolean') throw new TypeError(`realigner : booléen attendu, reçu ${JSON.stringify(realigner)}`);
const sieges = entierSaisi(saisie);
const bilan = bilanChangementDefaut(charge, sieges, { realigner });
leverRefus(bilan.refus.map(({ code, details }) => ({ code, details })));
const avertissements = [];
for (const { code, details } of bilan.avertissements) {
if (code === 'AU_DELA_LISIBLE' && avertissements.some((deja) => deja.code === code)) continue;
avertissements.push({ code, details });
}
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
return {
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
avertissements,
};
}
/**
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
* dérive TOURS_CHANGES, jamais tronqués.
*/
function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge);
const tours = entierSaisi(saisie);
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
const suivante = structuredClone(charge);
suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
return {
charge: suivante,
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
avertissements: [],
};
}
/**
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
* generation.arret, generation.historique, un entier ≥ 1, que le contrôleur
* de génération a lu et borné. Une autre clé, ou une autre valeur, est une
* faute du code.
*/
function modifierReglage(charge, { cle, valeur } = {}) {
exigerModifiable(charge);
const suivante = structuredClone(charge);
if (INTERRUPTEURS.has(cle)) {
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages[cle] = valeur;
} else if (ENTIERS_DE_GENERATION.has(cle)) {
if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages.generation[cle.slice('generation.'.length)] = valeur;
} else {
throw new TypeError(`réglage inconnu : ${JSON.stringify(cle)}`);
}
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
}
// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2),
// que commandes.js réunit aux autres. Ses imports n'atteignent jamais
// commandes.js : le registre réuni dépend de lui, non l'inverse.
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable],
['supprimerTable', supprimerTable],
['deplacerTable', deplacerTable],
['changerPlacesTable', changerPlacesTable],
['realignerTable', realignerTable],
['changerDefaut', changerDefaut],
['changerTours', changerTours],
['modifierReglage', modifierReglage],
]);
export const COMMANDES_MOBILIER = new Map();

View file

@ -1,870 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des commandes du mobilier (§ 4, § 4.3, § 5.9, § 6, § 7.1, § 8.2,
// § 9, § 15.4) : tables, défaut, tours et réglages. Chaque commande acceptée
// rend une charge que l'analyse relit et qui se normalise quand la charge
// reçue se normalisait ; un refus ne change rien. Les gestes qui comptent
// les entrées du journal, ou les avertissements que la séance ajoute,
// passent par seance.executer sur le système de fichiers d'épreuve. Les noms
// d'épreuve sont inventés ; la version vient du module engendré.
import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
import { caseLibre } from '../geometrie/tables.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { derive, examiner, fautes } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { invariantViole } from './avertissements.js';
import { COMMANDES, appliquerCommande } from './commandes.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { ErreurCommande } from './erreurs.js';
import { libelle } from './libelles.js';
import { etatPlaces } from './places.js';
import { creerSeance } from './seance.js';
const V1 = VERSION.affichee;
const NOM = 'Soirée des Merles';
const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' };
const NOMS = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Grisaille', null],
['Bruyère', 'Anouk'],
['Sarcelle', 'Capucine'],
['Ancolie', 'Amandine'],
['Mélèze', 'Basile'],
['Quenouille', 'Hortense'],
['Pivoine', 'Gaspard'],
];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length][0],
prenom: NOMS[(id - 1) % NOMS.length][1],
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
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);
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant
// sauf indication), 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-mobilier', nom: NOM, siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: 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;
}
// 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: t }) => t),
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: V1, ...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;
}
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
function figer(valeur) {
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
Object.freeze(valeur);
for (const element of Object.values(valeur)) figer(element);
}
return valeur;
}
// Vrai quand la configuration de la charge se normalise.
function seNormalise(charge) {
try {
normaliser(configurationDepuisCharge(charge));
return true;
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return false;
throw erreur;
}
}
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
// se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) {
const recue = figer(structuredClone(charge));
const rendu = appliquerCommande(nom, recue, args);
analyser(serialiser(rendu.charge, { revision: 1, produitVersion: 'essai' }));
if (seNormalise(recue)) assert.ok(seNormalise(rendu.charge), `${nom} : la charge rendue ne se normalise plus`);
return rendu;
}
// Le refus d'une ErreurCommande de ce code ; ses détails contiennent ceux
// donnés.
function refuse(appel, code, attendus = {}) {
let leve = null;
try {
appel();
} catch (erreur) {
leve = erreur;
}
assert.ok(leve instanceof ErreurCommande, `ErreurCommande ${code} attendue, reçu ${leve}`);
assert.equal(leve.code, code, leve.message);
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(leve.details[cle], valeur, cle);
return leve;
}
// Le même refus, rendu par une promesse.
async function refuseAsync(promesse, code, attendus = {}) {
let leve = null;
try {
await promesse;
} catch (erreur) {
leve = erreur;
}
return refuse(() => {
if (leve !== null) throw leve;
}, code, attendus);
}
// --- La séance d'épreuve ----------------------------------------------------
// Une séance sur le système d'épreuve, l'événement de la charge posé par le
// dépôt, ouvert et passé en écriture. entrees() compte les entrées du
// journal ; ecritures() les écritures du système.
async function seanceSur(charge) {
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
await creerDansLeDepot(fs, DOC, charge, {
libelle: libelle('ENTREE_CREATION', { nom: NOM }),
nomJalon: libelle('JALON_CREATION'),
horodatage: '2031-05-17T07:00:00-04:00',
produitVersion: V1,
});
let tirage = 0;
const seance = creerSeance({
fs,
racine: DOC,
horloge: { horodatage: () => '2031-05-17T08:00:00-04:00' },
identifiant: () => {
tirage += 1;
return `seance-${tirage}`;
},
produitVersion: V1,
planifier: () => 1,
annuler: () => {},
});
await seance.ouvrir(NOM);
await seance.passerEnEcriture();
return {
seance,
entrees: () => lireJournal(fs.contenu('documents', `${NOM}${SUFFIXES.journal}`)).entrees.length,
ecritures: () => fs.compteurs.ecritures,
};
}
// --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => {
test('COMMANDES_MOBILIER porte les huit commandes du mobilier, réunies dans COMMANDES', () => {
const noms = ['creerTable', 'supprimerTable', 'deplacerTable', 'changerPlacesTable', 'realignerTable', 'changerDefaut', 'changerTours', 'modifierReglage'];
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom);
});
test('un plan bloqué refuse chacune, PLAN_BLOQUE, remède debloquer', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
charge.evenement.etat = 'bloque';
const gestes = [
['creerTable', { forme: 'ronde' }],
['supprimerTable', { id: 1 }],
['deplacerTable', { id: 1, position: { x: 5, y: 5 } }],
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
['realignerTable', { id: 1 }],
['changerDefaut', { sieges: 6, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
];
assert.equal(gestes.length, COMMANDES_MOBILIER.size);
for (const [nom, args] of gestes) {
refuse(() => COMMANDES.get(nom)(charge, args), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
}
});
});
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
assert.deepEqual(charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [2, 2], [3, 3]]);
charge = accepter('supprimerTable', charge, { id: 3 }).charge;
const cree = accepter('creerTable', charge, { forme: 'carree' });
assert.equal(cree.libelle, 'Ajouté la table 3');
assert.deepEqual(cree.charge.tables.at(-1), {
id: 4,
numero: 3,
sieges: null,
forme: 'carree',
position: caseLibre(charge.tables.map(({ position }) => position)),
});
assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
assert.equal(deplace.libelle, 'Déplacé la table 3');
assert.deepEqual(deplace.charge.tables.at(-1).position, { x: 612, y: 0 });
assert.ok(Object.is(deplace.charge.tables.at(-1).position.y, 0));
// Une position que l'arrondi ne change pas : le geste est sans effet.
refuse(() => appliquerCommande('deplacerTable', deplace.charge, { id: 4, position: { x: 611.6, y: 0.2 } }), 'SANS_EFFET');
// Supprimer une table du milieu : le numéro suit encore le plus grand,
// et non le nombre de tables.
let milieu = chargeDe();
for (let i = 0; i < 3; i += 1) milieu = accepter('creerTable', milieu, { forme: 'ronde' }).charge;
milieu = accepter('supprimerTable', milieu, { id: 2 }).charge;
const apresMilieu = accepter('creerTable', milieu, { forme: 'ronde' });
assert.equal(apresMilieu.libelle, 'Ajouté la table 4');
assert.deepEqual(apresMilieu.charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3], [4, 4]]);
});
test('une forme inconnue, une table inconnue ou une position non finie sont des fautes du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }] });
assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), TypeError);
assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), TypeError);
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), TypeError);
});
test('une table créée suit le défaut : sieges null, 8 places sous un défaut de 8, 10 sous un défaut de 10', () => {
for (const defaut of [8, 10]) {
const { charge } = accepter('creerTable', chargeDe({ defaut }), { forme: 'ronde' });
assert.equal(charge.tables[0].sieges, null);
assert.equal(capacite(charge, charge.tables[0]), defaut);
}
const huit = accepter('creerTable', chargeDe(), { forme: 'ronde' }).charge;
const dix = accepter('changerDefaut', huit, { sieges: 10, realigner: false }).charge;
assert.equal(capacite(dix, dix.tables[0]), 10);
});
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
const { charge: apres, libelle: texte } = accepter('creerTable', charge, { forme: 'ronde' });
assert.equal(texte, 'Ajouté la table 3');
assert.deepEqual(apres.retenu.tables, [1, 2, 3]);
assert.deepEqual(apres.retenu.capacites, [8, 8, 8]);
for (const { sieges } of apres.retenu.placement) assert.deepEqual(sieges.at(-1), []);
assert.deepEqual(fautes(apres.retenu), []);
assert.deepEqual(derive(apres.retenu, apres), []);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(examiner(apres).derives, [
{ id: 1, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
{ id: 2, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
]);
// Un retenu fautif ne se réécrit pas.
const fautif = structuredClone(charge);
fautif.retenu.capacites = [8];
const sansRetouche = accepter('creerTable', fautif, { forme: 'ronde' }).charge;
assert.deepEqual(sansRetouche.retenu, fautif.retenu);
});
test("identifiant contre numéro : supprimer la table 2 laisse la réservation et les listes de la table 3 du retenu", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 6, reservations: [resa(1, 3, null)] });
placer(charge, [[[2, 3], [4, 5], [1, 6]], [[4, 6], [2, 3], [1, 5]]], { id: 1, retenu: true });
const { charge: apres, libelle: texte } = accepter('supprimerTable', charge, { id: 2 });
assert.equal(texte, 'Supprimé la table 2');
assert.deepEqual(apres.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3]]);
assert.deepEqual(apres.reservations, [resa(1, 3, null)]);
assert.deepEqual(apres.retenu, charge.retenu);
assert.ok(examiner(apres).retenu.raisons.some(({ code, table }) => code === 'TABLE_SUPPRIMEE' && table === 2));
});
test("supprimer une table garnie retire, dans la même entrée, ses réservations et ses titres", async () => {
const charge = chargeDe({
tables: [{ id: 1 }, { id: 2 }],
n: 4,
reservations: [resa(2, 2, 2), resa(1, 1, null), resa(3, 2, null, 1)],
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
});
const { seance, entrees } = await seanceSur(charge);
const avant = entrees();
const rendu = await seance.executer('supprimerTable', { id: 2 });
assert.equal(rendu.libelle, 'Supprimé la table 2 ; retirés : 2 réservations et 1 titre');
assert.equal(entrees() - avant, 1);
assert.deepEqual(seance.charge.reservations, [resa(1, 1, null)]);
assert.deepEqual(seance.charge.titres, [{ table: 1, siege: 1, libelle: 'accueil' }]);
// Au singulier : une réservation, un titre ; une table nue ne retire rien.
const seule = await seance.executer('supprimerTable', { id: 1 });
assert.equal(seule.libelle, 'Supprimé la table 1 ; retirés : 1 réservation et 1 titre');
const vide = accepter('supprimerTable', chargeDe({ tables: [{ id: 4 }] }), { id: 4 });
assert.equal(vide.libelle, 'Supprimé la table 4');
});
test('le manque : supprimer une table de 8 sous Σ c = N = 16 avertit PLACES_MANQUANTES {8} ; ramener une table à 6, {2}', async () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 16 });
const un = await seanceSur(charge);
const rendu = await un.seance.executer('supprimerTable', { id: 2 });
assert.equal(nombrePlacesManquantes(normaliser(configurationDepuisCharge(un.seance.charge))), 8);
assert.deepEqual(rendu.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 8 } }]);
const deux = await seanceSur(charge);
const reduit = await deux.seance.executer('changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' });
assert.deepEqual(reduit.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } }]);
});
});
describe("mobilier : les places d'une table, par le champ et par la poignée (§ 6.2, § 7.1)", () => {
test('les saisies : « 1 » et « 0 » refusent MOINS_DE_DEUX ; un texte non entier, NON_ENTIER ; « 2 » passe', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
for (const sieges of ['1', '0']) {
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'MOINS_DE_DEUX', { remede: null });
}
const textes = ['-3', '2.5', '2,5', '8abc', 'huit', ''];
assert.equal(textes.length, 6);
for (const sieges of textes) {
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'NON_ENTIER', { saisie: sieges, remede: null });
}
const deux = accepter('changerPlacesTable', charge, { id: 1, sieges: '2', origine: 'champ' });
assert.equal(deux.charge.tables[0].sieges, 2);
assert.equal(deux.libelle, 'Table 1 : 2 places');
});
test("une surcharge au défaut est une surcharge ; réaligner la retire, et le libellé nomme le défaut", () => {
const charge = chargeDe({ tables: [{ id: 5, numero: 2 }], n: 2 });
const surchargee = accepter('changerPlacesTable', charge, { id: 5, sieges: 8, origine: 'champ' });
assert.equal(surchargee.charge.tables[0].sieges, 8);
assert.equal(surchargee.libelle, 'Table 2 : 8 places');
const realignee = accepter('realignerTable', surchargee.charge, { id: 5 });
assert.equal(realignee.charge.tables[0].sieges, null);
assert.equal(realignee.libelle, 'Table 2 : suit le défaut (8 places)');
refuse(() => appliquerCommande('realignerTable', realignee.charge, { id: 5 }), 'SANS_EFFET');
});
test("le champ dans le retenu, sièges attribués : les occupants des chaises 7 et 8 passent en réserve, la capacité du retenu suit", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, attribuerSieges: true });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), [18, 11, 12, 13, 14, 15, 16, 17]],
],
{ id: 1, retenu: true },
);
const chaises = etatPlaces(charge, { tour: 2 }).tables[2].chaises;
const personnes = [chaises[6].occupant, chaises[7].occupant];
assert.deepEqual(personnes, [16, 17]);
const { charge: apres, avertissements, libelle: texte } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
assert.equal(texte, 'Table 3 : 6 places');
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes } }]);
const etage = apres.retenu.placement[1];
assert.deepEqual(etage.sieges[2], [18, 11, 12, 13, 14, 15]);
assert.deepEqual(etage.reserve, [16, 17]);
assert.deepEqual([...etage.sieges.flat(), ...etage.reserve].sort((a, b) => a - b), suite(1, 18));
assert.deepEqual(apres.retenu.placement[0], charge.retenu.placement[0]);
assert.deepEqual(apres.retenu.capacites, [8, 8, 6]);
assert.deepEqual(fautes(apres.retenu), []);
assert.deepEqual(derive(apres.retenu, apres), []);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(derive(apres.propositions[0], apres), [{ code: 'CAPACITE_CHANGEE', table: 3, avant: 8, maintenant: 6 }]);
});
test("le champ dans le retenu, sièges non attribués : la personne réservée au siège 2 reste, les deux suivantes partent", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, reservations: [resa(18, 3, 2)] });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [16, 17] } }]);
assert.deepEqual(apres.retenu.placement[1].sieges[2], [11, 12, 13, 14, 15, 18]);
assert.deepEqual(apres.retenu.placement[1].reserve, [16, 17]);
assert.deepEqual(derive(apres.retenu, apres), []);
// La personne réservée garde sa place au plan, au siège 2.
assert.equal(etatPlaces(apres, { tour: 2 }).tables[2].chaises[1].occupant, 18);
});
test('sièges attribués : une liste réduite ne finit jamais par une chaise vide ; fautes(retenu) reste vide', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, attribuerSieges: true });
placer(charge, [[suite(1, 6), [7, 8, 9, 10, 11, null, null, 12]]], { id: 1, retenu: true });
for (const sieges of [6, 7]) {
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 2, sieges, origine: 'champ' });
assert.deepEqual(apres.retenu.placement[0].sieges[1], [7, 8, 9, 10, 11]);
assert.deepEqual(apres.retenu.placement[0].reserve, [12]);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 1, personnes: [12] } }]);
assert.deepEqual(fautes(apres.retenu), []);
}
});
test('la poignée : 8 → 9 accepté, la capacité du retenu suit ; sous l\'occupation, SOUS_OCCUPES et rien ne change', () => {
const cinq = suite(1, 5);
const charge = chargeDe({ tables: [{ id: 1 }], n: 7, tours: 4 });
placer(charge, [cinq, cinq, suite(1, 7), cinq].map((liste) => [liste]), { id: 1, retenu: true });
const neuf = accepter('changerPlacesTable', charge, { id: 1, sieges: 9, origine: 'poignee' });
assert.deepEqual(neuf.charge.retenu.capacites, [9]);
assert.deepEqual(neuf.charge.retenu.placement, charge.retenu.placement);
assert.deepEqual(neuf.avertissements, []);
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'poignee' }), 'SOUS_OCCUPES', {
table: 1,
n: 7,
tour: 3,
remede: afficherTour(3),
});
});
test('réaligner une table surchargée à 10 qui en assied dix, sous un défaut de 8 : deux surnuméraires', () => {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 16, tours: 1 });
placer(charge, [[suite(1, 10), suite(11, 16)]], { id: 1, retenu: true });
const { charge: apres, avertissements, libelle: texte } = accepter('realignerTable', charge, { id: 1 });
assert.equal(texte, 'Table 1 : suit le défaut (8 places)');
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [9, 10] } }]);
assert.deepEqual(apres.retenu.placement[0].reserve, [9, 10]);
assert.deepEqual(apres.retenu.capacites, [8, 8]);
assert.deepEqual(derive(apres.retenu, apres), []);
});
test("une hausse n'assied personne que le plan ne montrait pas : un retenu déclaré à 10 sur une table de 8", () => {
// Avec la personne 9 réservée à la table 1 sans siège : sièges attribués,
// son rang 9 ne se montre pas et la hausse ne l'assied pas ; sans
// attribution, l'affichage l'élit déjà, et la personne 8 n'est pas montrée.
// La dérive du retenu masquait la réservation non tenue de la personne 9 ;
// la capacité rejointe, invariantViole la nomme.
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
const cas = [
{ attribuerSieges: true, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
{ attribuerSieges: false, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
{ attribuerSieges: true, reservations: [resa(9, 1, null)], liste: suite(1, 8), reserve: [9, 10], invariant: nonTenue },
{ attribuerSieges: false, reservations: [resa(9, 1, null)], liste: [1, 2, 3, 4, 5, 6, 7, 9], reserve: [8, 10], invariant: null },
];
for (const { attribuerSieges, reservations, liste, reserve, invariant } of cas) {
for (const origine of ['champ', 'poignee']) {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations });
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
charge.tables[0].sieges = null;
const montrees = etatPlaces(charge, { tour: 1 }).tables[0].chaises.map(({ occupant }) => occupant);
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 10, origine });
const nom = `attribuerSieges ${attribuerSieges}, ${reservations.length} réservation, ${origine}`;
assert.deepEqual(montrees, liste, nom);
assert.deepEqual(avertissements, [], nom);
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
assert.deepEqual(apres.retenu.capacites, [10, 8], nom);
assert.deepEqual(derive(apres.retenu, apres), [], nom);
assert.equal(invariantViole(charge), null, nom);
assert.deepEqual(invariantViole(apres), invariant, nom);
}
}
});
test('un retenu de plus de tours que la soirée se réduit à chacun de ses tours, sans faute', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
// Le tour 3 n'est plus de la soirée, mais le retenu le porte encore : ses
// surnuméraires se nomment comme ceux d'un tour montré.
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [11, 12] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], suite(5, 10));
assert.deepEqual(apres.retenu.placement[2].reserve, [11, 12]);
assert.deepEqual(fautes(apres.retenu), []);
});
test('sans attribution, un tour du retenu que la soirée n\'a plus élit aussi la personne que la table réserve', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, reservations: [resa(12, 2, null)] });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
assert.deepEqual(fautes(apres.retenu), []);
});
test("une réduction acceptée garde chaque réservation que le retenu tenait : invariantViole reste null", () => {
// La personne 8, réservée à la table 1 sans siège, est assise à la chaise 8.
const gestes = [
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
['changerDefaut', { sieges: 6, realigner: false }],
];
let acceptees = 0;
for (const attribuerSieges of [true, false]) {
for (const [nom, args] of gestes) {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(8, 1, null)] });
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
assert.equal(invariantViole(charge), null);
const { charge: apres, avertissements } = accepter(nom, charge, args);
const cas = `${nom}, attribuerSieges ${attribuerSieges}`;
assert.equal(invariantViole(apres), null, cas);
// Un non-réservé cède sa chaise : la chaise 6, la plus haute, sous des
// sièges attribués ; sinon l'affichage élit d'abord la réservée.
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 8], cas);
assert.deepEqual(apres.retenu.placement[0].reserve, [6, 7], cas);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [6, 7] } }], cas);
assert.deepEqual(derive(apres.retenu, apres), [], cas);
acceptees += 1;
}
}
assert.equal(acceptees, 4);
// Sièges attribués, une chaise vide dans la capacité reçoit la réservée
// avant qu'un non-réservé ne cède la sienne.
const vide = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges: true, reservations: [resa(8, 1, null)] });
placer(vide, [[[1, 2, 3, null, 5, 6, 7, 8], suite(9, 12)]], { id: 1, retenu: true });
const { charge: apres, avertissements } = accepter('changerPlacesTable', vide, { id: 1, sieges: 6, origine: 'champ' });
assert.equal(invariantViole(apres), null);
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 8, 5, 6]);
assert.deepEqual(apres.retenu.placement[0].reserve, [4, 7]);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [7] } }]);
// Réaligner une table surchargée à 10, la personne 10 réservée sans siège.
const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges: true, reservations: [resa(10, 1, null)] });
placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true });
const realignee = accepter('realignerTable', dix, { id: 1 });
assert.equal(invariantViole(realignee.charge), null);
assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10]);
assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }]);
});
test('sièges attribués, le retour des réservés : siège voulu, chaise libre ou d\'un absent, non-réservé du plus haut rang, ordre des rangs, tour couvert', () => {
// La table 1, de 8, réduite à 6 par le champ ; les exclus le deviennent
// après la retenue. Par tour : la liste qui reste, la réserve, et les
// personnes de SURNUMERAIRES.
const cas = [
{
nom: "une chaise tenue par un absent est libre : la personne 8 la prend, l'absent 2 passe en réserve",
reservations: [resa(8, 1, null)],
exclus: [2],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[[1, 8, 3, 4, 5, 6], [2, 7], [7]]],
},
{
nom: 'le siège réservé, libre, passe avant la première chaise libre',
reservations: [resa(9, 1, 3), resa(9, 1, 4, 1)],
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
attendus: [[[1, null, 9, null, 5, 6], [7], [7]]],
},
{
nom: "le siège réservé est le premier siège non nul des réservations de la personne, dans l'ordre canonique",
reservations: [resa(9, 1, null), resa(9, 1, 4, 1)],
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
attendus: [[[1, null, null, 9, 5, 6], [7], [7]]],
},
{
nom: "un siège réservé tenu par une personne présente n'est pas libre : le non-réservé du plus haut rang cède le sien",
reservations: [resa(9, 1, 3)],
listes: [[[1, 2, 3, 4, 5, 6, 7, 9], [8, 10, 11, 12]]],
attendus: [[[1, 2, 3, 4, 5, 9], [6, 7], [6, 7]]],
},
{
nom: 'une personne réservée ne cède pas sa chaise : la personne 5 part, la 6 reste',
reservations: [resa(6, 1, null), resa(8, 1, null)],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[[1, 2, 3, 4, 8, 6], [5, 7], [5, 7]]],
},
{
nom: "dans l'ordre des rangs : la personne 7 prend la chaise vide, la 8 celle du plus haut rang",
reservations: [resa(7, 1, null), resa(8, 1, null)],
listes: [[[1, null, 3, 4, 5, 6, 7, 8], [2, 9, 10, 11, 12]]],
attendus: [[[1, 7, 3, 4, 5, 8], [6], [6]]],
},
{
nom: 'une réservation « tour » ramène sa personne au tour 2 seulement',
reservations: [resa(8, 1, null, 2)],
listes: [
[suite(1, 8), suite(9, 12)],
[suite(1, 8), suite(9, 12)],
],
attendus: [
[suite(1, 6), [7, 8], [7, 8]],
[[1, 2, 3, 4, 5, 8], [6, 7], [6, 7]],
],
},
{
nom: 'la réservation suspendue de la personne 8, exclue, ne la ramène pas',
reservations: [resa(8, 1, null)],
exclus: [8],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[suite(1, 6), [7, 8], [7]]],
},
];
for (const { nom, reservations, exclus = [], listes, attendus } of cas) {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: listes.length, attribuerSieges: true, reservations });
placer(charge, listes, { id: 1, retenu: true });
for (const id of exclus) charge.participants[id - 1].exclu = true;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
attendus.forEach(([liste, reserve], rang) => {
assert.deepEqual(apres.retenu.placement[rang].sieges[0], liste, `${nom}, tour ${rang + 1}`);
assert.deepEqual(apres.retenu.placement[rang].reserve, reserve, `${nom}, tour ${rang + 1}`);
});
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
assert.deepEqual(avertissements, surnumeraires, nom);
assert.equal(invariantViole(apres), null, nom);
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne.
assert.deepEqual(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
assert.deepEqual(fautes(apres.retenu), [], nom);
}
});
test("sièges attribués, un tour du retenu que la soirée n'a plus ramène la personne qu'une réservation « tous » tient", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true, reservations: [resa(12, 2, null)] });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
assert.deepEqual(fautes(apres.retenu), []);
assert.equal(invariantViole(apres), null);
});
test('une baisse ne ramène pas la personne réservée d\'un rang au-delà de la capacité courante, que le plan ne montrait pas', () => {
// Le retenu déclare la table 1 à 10, qui n'en a plus que 8 : sièges
// attribués, la personne 9, réservée sans siège, est au rang 9.
// Sièges attribués, la réservation que le plan montrait non tenue le reste,
// et invariantViole la nomme une fois la dérive levée ; sans attribution,
// l'affichage élisait la personne 9, qui reste.
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
const cas = [
{ attribuerSieges: true, liste: suite(1, 6), reserve: [7, 8, 9, 10], personnes: [7, 8], invariant: nonTenue },
{ attribuerSieges: false, liste: [1, 2, 3, 4, 5, 9], reserve: [6, 7, 8, 10], personnes: [6, 7], invariant: null },
];
for (const { attribuerSieges, liste, reserve, personnes, invariant } of cas) {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(9, 1, null)] });
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
charge.tables[0].sieges = null;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
const nom = `attribuerSieges ${attribuerSieges}`;
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes } }], nom);
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve.includes(9), attribuerSieges, nom);
assert.deepEqual(invariantViole(apres), invariant, nom);
}
});
test('sans attribution, une liste qui dépasse encore perd ses absents, le plus petit identifiant d\'abord', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1 });
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
for (const id of [2, 3]) charge.participants[id - 1].exclu = true;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
// Six présents s'assoient ; les deux absents partent, le plus petit d'abord, jusqu'à six.
assert.deepEqual(avertissements, []);
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 4, 5, 6, 7, 8]);
assert.deepEqual(apres.retenu.placement[0].reserve, [2, 3]);
const sept = accepter('changerPlacesTable', charge, { id: 1, sieges: 7, origine: 'champ' }).charge;
assert.deepEqual(sept.retenu.placement[0].sieges[0], [1, 3, 4, 5, 6, 7, 8]);
assert.deepEqual(sept.retenu.placement[0].reserve, [2]);
});
test('un compteur de tables saturé refuse COMPTEUR_SATURE ; une origine ou un réalignement de forme fausse sont des fautes du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
charge.prochainsIds.table = 2 ** 31 - 1;
refuse(() => COMMANDES.get('creerTable')(charge, { forme: 'ronde' }), 'COMPTEUR_SATURE', { compteur: 'table', remede: null });
assert.throws(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'clavier' }), TypeError);
assert.throws(() => COMMANDES.get('changerDefaut')(charge, { sieges: 6 }), TypeError);
});
});
describe('mobilier : changer le défaut (§ 6.1, § 6.2)', () => {
function treizeTables(options = {}) {
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], ...options });
}
const capacites = (charge) => charge.tables.map((table) => capacite(charge, table));
test('appliquer : les quatre surchargées gardent 7, 7, 6 et 12, les neuf autres valent 10, une entrée ; réaligner : les treize suivent 10', async () => {
const { seance, entrees } = await seanceSur(treizeTables());
const avant = entrees();
const rendu = await seance.executer('changerDefaut', { sieges: 10, realigner: false });
assert.equal(rendu.libelle, 'Sièges par défaut : 10');
assert.equal(entrees() - avant, 1);
assert.deepEqual(capacites(seance.charge), [10, 10, 7, 10, 10, 7, 10, 10, 6, 10, 10, 12, 10]);
assert.deepEqual(seance.charge.tables.filter(({ sieges }) => sieges !== null).map(({ id }) => id), [3, 6, 9, 12]);
const realigne = await seance.executer('changerDefaut', { sieges: 10, realigner: true });
assert.equal(realigne.libelle, 'Sièges par défaut : 10 ; 4 tables réalignées');
assert.equal(entrees() - avant, 2);
assert.ok(seance.charge.tables.every(({ sieges }) => sieges === null));
assert.deepEqual(capacites(seance.charge), Array(13).fill(10));
});
test('réaligner refusé en bloc : le titre du siège 12 refuse, table et remède compris, et rien ne s\'écrit ; sans réaligner, accepté', async () => {
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
const { seance, ecritures } = await seanceSur(charge);
const avant = ecritures();
const leve = await refuseAsync(seance.executer('changerDefaut', { sieges: 10, realigner: true }), 'SIEGES_RETIRES', {
table: 12,
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
});
assert.deepEqual(leve.details.autres, []);
assert.equal(ecritures(), avant);
assert.deepEqual(capacites(seance.charge), capacites(charge));
await seance.executer('changerDefaut', { sieges: 10, realigner: false });
assert.equal(seance.charge.tables[11].sieges, 12);
});
test('baisse refusée en bloc : la table 2 porte 7 réservations ; le défaut 6 refuse, nommant la table 2, et les trois tables restent à 8', async () => {
const reservations = suite(1, 7).map((id) => resa(id, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
const { seance, ecritures } = await seanceSur(charge);
const avant = ecritures();
await refuseAsync(seance.executer('changerDefaut', { sieges: 6, realigner: false }), 'SOUS_RESERVATIONS', {
table: 2,
n: 7,
tour: 1,
remede: afficherTour(1),
});
assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.siegesParDefaut, 8);
assert.deepEqual(capacites(seance.charge), [8, 8, 8]);
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: '1', realigner: false }), 'MOINS_DE_DEUX', { remede: null });
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: 'six', realigner: false }), 'NON_ENTIER');
});
test('une baisse sous un retenu occupé : accepté, SURNUMERAIRES, et ces deux personnes dans la réserve du tour 2', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const { charge: apres, avertissements } = accepter('changerDefaut', charge, { sieges: 6, realigner: false });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } }]);
assert.deepEqual(apres.retenu.placement[1].reserve, [17, 18]);
assert.deepEqual(apres.retenu.capacites, [6, 6, 6]);
assert.deepEqual(derive(apres.retenu, apres), []);
});
test('un défaut au-delà du seuil de lisibilité avertit une fois', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
const { avertissements } = accepter('changerDefaut', charge, { sieges: 40, realigner: false });
assert.equal(avertissements.length, 1);
assert.equal(avertissements[0].code, 'AU_DELA_LISIBLE');
});
});
describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
test('R = 4 → 3 → 4 : les réservations reviennent telles quelles, et la portée « tous » ancre encore au tour 4', () => {
const debut = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4, tours: 4, reservations: [resa(1, 2, null)] });
const trois = accepter('changerTours', debut, { tours: 3 });
assert.equal(trois.libelle, 'Nombre de tours : 3');
const quatre = accepter('changerTours', trois.charge, { tours: '4' }).charge;
assert.deepEqual(quatre.reservations, debut.reservations);
const instance = normaliser(configurationDepuisCharge(quatre));
const p = instance.indexDe.get(1);
assert.equal(instance.statut[p], STATUT.ANCRE);
assert.equal(instance.fixe[p * instance.R + 3], instance.indexTableDe.get(2));
});
test("retirer le tour 4 détruit ses réservations « tour », garde les autres, et laisse propositions et retenu en dérive", () => {
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
const { charge: apres, libelle: texte } = accepter('changerTours', charge, { tours: 3 });
assert.equal(texte, 'Nombre de tours : 3 ; 2 réservations retirées');
assert.deepEqual(apres.reservations, [resa(3, 1, null, 2), resa(4, 2, null)]);
analyser(serialiser(apres, { revision: 2, produitVersion: 'essai' }));
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(apres.retenu, charge.retenu);
assert.deepEqual(derive(apres.retenu, apres), [{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }]);
});
test('3 → 4 : trois réservations « tour » à une table rendent B partiellement fixé ; propositions et retenu gardés, TOURS_CHANGES', () => {
const b = 5;
const reservations = [1, 2, 3].map((tour) => resa(b, 1, null, tour));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 8, tours: 3, reservations });
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[4, 5, 6, 7], [1, 2, 3, 8]], [[1, 5, 7, 8], [2, 3, 4, 6]]];
placer(charge, listes, { id: 1, retenu: true });
const avant = normaliser(configurationDepuisCharge(charge));
assert.equal(avant.statut[avant.indexDe.get(b)], STATUT.ANCRE);
const { charge: apres } = accepter('changerTours', charge, { tours: 4 });
const instance = normaliser(configurationDepuisCharge(apres));
assert.equal(instance.statut[instance.indexDe.get(b)], STATUT.PARTIELLEMENT_FIXE);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(apres.retenu, charge.retenu);
for (const placement of [apres.propositions[0], apres.retenu]) {
assert.deepEqual(derive(placement, apres), [{ code: 'TOURS_CHANGES', avant: 3, maintenant: 4 }]);
}
});
test('« 0 » refuse MOINS_D_UN_TOUR {tours: 0}, « 2.5 » et « x » NON_ENTIER, sans écriture', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
await refuseAsync(seance.executer('changerTours', { tours: '0' }), 'MOINS_D_UN_TOUR', { tours: 0, remede: null });
for (const tours of ['2.5', 'x']) {
await refuseAsync(seance.executer('changerTours', { tours }), 'NON_ENTIER', { saisie: tours, remede: null });
}
assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.tours, 2);
});
});
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
test('« Séparer les appartenances : désactivé », une entrée', async () => {
const { seance, entrees } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2 }));
const avant = entrees();
const rendu = await seance.executer('modifierReglage', { cle: 'separerAppartenances', valeur: false });
assert.equal(rendu.libelle, 'Séparer les appartenances : désactivé');
assert.equal(entrees() - avant, 1);
assert.equal(seance.charge.reglages.separerAppartenances, false);
});
test('chaque réglage nommé se change ; une clé inconnue ou une valeur de forme fausse est une faute du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const attendus = [
['nouveauxVoisins', false, 'Nouveaux voisins à chaque tour : désactivé'],
['nouvelleTable', false, 'Nouvelle table à chaque tour : désactivé'],
['varierAppartenances', false, 'Varier les appartenances rencontrées : désactivé'],
['attribuerSieges', true, 'Attribuer les sièges : activé'],
['generation.nombre', 8, 'Propositions par génération : 8'],
['generation.arret', 300000, 'Mouvements par proposition : 300000'],
['generation.historique', 500, "Historique d'acceptation : 500"],
];
for (const [cle, valeur, texte] of attendus) {
const rendu = accepter('modifierReglage', charge, { cle, valeur });
assert.equal(rendu.libelle, texte);
const [tete, queue] = cle.split('.');
assert.equal(queue === undefined ? rendu.charge.reglages[tete] : rendu.charge.reglages[tete][queue], valeur);
}
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'couleur', valeur: true }), TypeError);
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'nouveauxVoisins', valeur: 'non' }), TypeError);
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'generation.nombre', valeur: 0 }), TypeError);
refuse(() => appliquerCommande('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: true }), 'SANS_EFFET');
});
});

View file

@ -10,7 +10,6 @@ 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,7 +8,6 @@
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';
@ -18,7 +17,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, cleNormalisee et CHAMPS sont ceux de src/csv', () => {
test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => {
const attendus = [
['decoder', encodage.decoder],
['apercevoir', apercu.apercevoir],
@ -31,9 +30,8 @@ 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(), [
'CHAMPS', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
]);
});

View file

@ -1,34 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le diagnostic de la configuration d'une charge (§ 5.6), pour l'interface,
// qui n'importe pas le moteur (§ 13.4). Le calcul est celui de diagnostiquer,
// sur configurationDepuisCharge : ce module n'en refait aucun chiffre.
//
// Une charge ouverte peut ne pas se normaliser — une table surréservée, que
// le stockage admet et que le moteur refuse (CONFIGURATION_INCOHERENTE, A2).
// L'ErreurConfiguration se rend alors par son code et ses détails, au lieu
// de lever : le panneau dit le refus, et l'application reste vivante. Toute
// autre erreur est une faute du code et remonte.
import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { configurationDepuisCharge } from '../stockage/document.js';
/**
* Le diagnostic de la configuration de la charge (§ 5.6). Ne modifie pas la
* charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {{diagnostic: import('../moteur/diagnostic.js').Diagnostic|null,
* erreur: {code: string, details: Object}|null}}
* diagnostic null et erreur, le code et les détails de l'ErreurConfiguration,
* quand la configuration ne se normalise pas ; erreur null sinon
*/
export function diagnostiquerCharge(charge) {
try {
return { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null };
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { diagnostic: null, erreur: { code: erreur.code, details: erreur.details } };
}
}

View file

@ -1,83 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du diagnostic d'une charge (§ 5.6, § 15.3) : la valeur rendue est
// celle de diagnostiquer sur la configuration de la charge, sollicitée une
// seconde fois (§ 19.1) ; une configuration qui ne se normalise pas rend son
// code et ses détails, sans lever. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { texteLivre } from '../demo/livrees.js';
import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { normaliser } from '../moteur/configuration.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { diagnostiquerCharge } from './diagnostic.js';
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// La petite démonstration où quatre personnes portent une réservation de
// portée « tous les tours », sans siège, à la table 1, de 3 places.
function quatreReserveesSurTrois() {
const charge = chargeDemo('petite');
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
return charge;
}
describe('diagnostiquerCharge', () => {
test('petite démonstration : le diagnostic de sa configuration, erreur null', () => {
const charge = chargeDemo('petite');
const rendu = diagnostiquerCharge(charge);
assert.deepEqual(rendu, { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null });
assert.ok(rendu.diagnostic.plafondAPriori.repartition.length > 0);
});
test('petite démonstration au conflit inévitable : le plancher du groupe de 5', () => {
const charge = chargeDemo('petite-conflit');
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
assert.equal(diagnostic.collisions.length, 1);
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
});
test('quatre réservées sur une table de 3 : analyser admet, normaliser refuse ; le code rendu, sans lever', () => {
const charge = quatreReserveesSurTrois();
// Le fichier qui la porte se relit : la charge est admise par le stockage.
const relue = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })).charge;
assert.equal(relue.reservations.length, 4);
let attendue = null;
try {
normaliser(configurationDepuisCharge(relue));
} catch (erreur) {
assert.ok(erreur instanceof ErreurConfiguration);
attendue = erreur;
}
assert.notEqual(attendue, null);
const rendu = diagnostiquerCharge(relue);
assert.deepEqual(rendu, { diagnostic: null, erreur: { code: 'SURRESERVATION', details: attendue.details } });
assert.equal(rendu.erreur.code, attendue.code);
});
test('un événement neuf, sans participant ni table : un diagnostic, aucune erreur', () => {
const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 });
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
});
test('une erreur qui n\u{2019}est pas une ErreurConfiguration remonte', () => {
const charge = chargeDemo('petite');
charge.tables = null;
assert.throws(() => diagnostiquerCharge(charge), TypeError);
});
test('la charge reçue ne change pas', () => {
const charge = quatreReserveesSurTrois();
const avant = structuredClone(charge);
diagnostiquerCharge(charge);
diagnostiquerCharge(chargeDemo('grande'));
assert.deepEqual(charge, avant);
});
});

View file

@ -1,140 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La table d'évaluation (§ 5.4, § 5.5, § 5.7, § 12.6, § 13.2) : chaque
// proposition et le retenu, mesurés par la mesure qui fait foi, et le
// classement de celles qui se comparent. Le classement, les colonnes et le
// retenu la lisent ; ce module est le seul de l'application qui importe les
// mesures du moteur, et il n'en refait aucune.
//
// Un placement se compare quand il décrit la configuration courante : sans
// faute (fautes, S2) et sans dérive (derive, S2). Fautif ou en dérive, il
// n'est ni mesuré ni classé, et ses raisons disent pourquoi — la partition
// se fait avant classer, qui laisse passer deux populations de même taille.
// derive et planDepuisFichier, qui lèvent sur une faute, ne reçoivent que
// des placements sans faute.
//
// Les imposés viennent du diagnostic, les mêmes pour toute ligne ; les
// rencontres répétées imposées dépendent du plan, et chaque ligne les lit
// dans ses mesures, à part des choisies.
import { classer } from '../moteur/classement.js';
import { indexerPlan, normaliser } from '../moteur/configuration.js';
import { diagnostiquer } from '../moteur/diagnostic.js';
import { mesurer } from '../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js';
import { plafondsRealises } from '../moteur/plafond.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
/**
* @typedef {Object} LigneEvaluation
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu
* @property {'comparable'|'derive'|'fautif'} statut
* @property {object[]} raisons fautif : ses fautes ; derive : ses raisons ; [] sinon
* @property {object[]} violations comparable : les réservations non honorées
* (verifierInvariants, RESERVATION) ; [] sinon
* @property {object|null} mesures mesurer, telles quelles
* @property {number[]|null} plafondsRealises
* @property {object|null} troisChiffres
* @property {number|null} ecartAuPlafondAPrioriMax le plus grand de
* ecartsAuPlafondAPriori ; null quand l'a priori est inconnu ou la
* population vide
* @property {boolean|null} minimumAtteint
* @property {object[]} refusDessin verifierIndicateurs, chaque violation
* complétée de participant, l'identifiant de son index ; []
*
* @typedef {Object} TableEvaluation
* @property {number[]} population identifiants non exclus, croissants
* @property {LigneEvaluation[]} propositions comparables dans l'ordre du
* classement, puis les autres par identifiant
* @property {LigneEvaluation|null} retenu
* @property {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} classement
* classer, tel quel, sur les comparables
* @property {{retours: number, ancrages: number}} imposes
*/
// Le plus grand élément d'une liste ; null pour une liste vide ou absente.
function plusGrand(valeurs) {
if (valeurs === null || valeurs.length === 0) return null;
let plus = valeurs[0];
for (const valeur of valeurs) if (valeur > plus) plus = valeur;
return plus;
}
// Une ligne qui ne se mesure pas.
const nonMesuree = (id, statut, raisons) => ({
id,
statut,
raisons,
violations: [],
mesures: null,
plafondsRealises: null,
troisChiffres: null,
ecartAuPlafondAPrioriMax: null,
minimumAtteint: null,
refusDessin: [],
});
/**
* La table d'évaluation d'une charge (contrat de A6). Ne modifie pas la
* charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {{plafondsAPriori?: 'calcul'|null}} [options]
* 'calcul' : le plafond a priori par personne du diagnostic, une seule
* maximisation ; null : a priori inconnu (§ 17, point 4)
* @returns {TableEvaluation}
* @throws {ErreurConfiguration} la configuration de la charge ne se
* normalise pas : rien ne se mesure, et la génération refuse la même
* @throws {TypeError} une autre valeur de plafondsAPriori
*/
export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) {
throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`);
}
const configuration = configurationDepuisCharge(charge);
const diagnostic = diagnostiquer(configuration);
const instance = normaliser(configuration);
const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null;
// La ligne d'un placement, proposition ou retenu, sous l'identifiant id.
function ligneDe(placement, id) {
const sesFautes = fautes(placement);
if (sesFautes.length > 0) return nonMesuree(id, 'fautif', sesFautes);
const raisons = derive(placement, charge);
if (raisons.length > 0) return nonMesuree(id, 'derive', raisons);
const plan = planDepuisFichier(placement);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
return {
id,
statut: 'comparable',
raisons: [],
violations: verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION'),
mesures,
plafondsRealises: realises,
troisChiffres: troisChiffres(instance, mesures, aPriori, realises),
ecartAuPlafondAPrioriMax: plusGrand(ecartsAuPlafondAPriori(mesures, aPriori)),
minimumAtteint: minimumAtteint(mesures, aPriori),
refusDessin: verifierIndicateurs(mesures, aPriori, realises).map((violation) => ({
...violation,
participant: instance.ids[violation.index],
})),
};
}
const lignes = charge.propositions.map((proposition) => ligneDe(proposition, proposition.id));
const comparables = lignes.filter(({ statut }) => statut === 'comparable');
const classement = classer(comparables.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })));
const rangDe = new Map(classement.ordre.map((id, rang) => [id, rang]));
const autres = lignes.filter(({ statut }) => statut !== 'comparable').sort((a, b) => a.id - b.id);
return {
population: [...instance.ids],
propositions: [...comparables.sort((a, b) => rangDe.get(a.id) - rangDe.get(b.id)), ...autres],
retenu: charge.retenu === null ? null : ligneDe(charge.retenu, charge.retenu.proposition),
classement,
imposes: { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours },
};
}

View file

@ -1,361 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la table d'évaluation (§ 5.4, § 5.5, § 5.7, § 8.9, § 12.6,
// § 13.2, § 15.1, § 15.3, § 15.4, § 17) : la mesure qui fait foi, le
// classement, les placements en dérive ou fautifs, les imposés. Les charges
// partent des démonstrations livrées, et leurs propositions s'enregistrent
// par la commande ; les plans construits à la main sont écrits ici, sur la
// petite démonstration au conflit inévitable. La valeur attendue vient des
// fonctions du moteur, sollicitées une seconde fois (§ 19.1).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { texteLivre } from '../demo/livrees.js';
import { classer } from '../moteur/classement.js';
import { indexerPlan, normaliser } from '../moteur/configuration.js';
import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { mesurer } from '../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori } from '../moteur/manque.js';
import { plafondsAPriori } from '../moteur/plafond.js';
import { rechercher } from '../moteur/recherche.js';
import { analyser, capacite, configurationDepuisCharge } from '../stockage/document.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js';
import { evaluer } from './evaluation.js';
const V1 = VERSION.affichee;
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// La charge, une génération du moteur de plus, enregistrée par la commande.
function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
// Une proposition écrite à la main, sièges non attribués, sur les tables de
// la charge dans leur ordre ; ses listes et réserves telles que données.
function propositionDe(charge, id, tours, reserves = tours.map(() => [])) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
return {
id,
graine: id,
arret: 40,
historique: 1000,
produitVersion: V1,
siegesAttribues: false,
tables: charge.tables.map(({ id: t }) => t),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: tours.length,
participants,
placement: tours.map((listes, rang) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: reserves[rang] })),
};
}
// Le retenu tiré d'une proposition.
function retenuDe(proposition) {
const { id, siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition);
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
}
// Gèle en profondeur : evaluer qui modifierait sa charge lèverait.
function figer(valeur) {
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
Object.freeze(valeur);
for (const element of Object.values(valeur)) figer(element);
}
return valeur;
}
// Chaque nombre atteint depuis la valeur, à toute profondeur.
function nombres(valeur, trouves = []) {
if (typeof valeur === 'number') trouves.push(valeur);
else if (valeur !== null && typeof valeur === 'object') for (const element of Object.values(valeur)) nombres(element, trouves);
return trouves;
}
// Les plans écrits à la main sur la petite démonstration au conflit
// inévitable — appartenances de tailles 5, 4 et 3 —, nommés par leurs
// collisions (cumulées, distinctes). Les trois premiers ont le même plus
// grand écart au plafond a priori, 3 ; le quatrième garde ensemble, aux
// quatre tours, les personnes 1, 2 et 3, d'appartenances distinctes, et ses
// quatre collisions tombent sur quatre paires.
const PLANS_CONFLIT = [
{
nom: '(4, 1)',
tours: [
[[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]],
[[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]],
[[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]],
[[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]],
],
collisions: [4, 1],
},
{
nom: '(4, 2)',
tours: [
[[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]],
[[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]],
[[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]],
[[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]],
],
collisions: [4, 2],
},
{
nom: '(6, 6)',
tours: [
[[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]],
[[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]],
[[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]],
[[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]],
],
collisions: [6, 6],
},
{
nom: 'trio',
tours: [
[[1, 2, 3], [6, 9, 10], [7, 11, 12], [4, 5, 8]],
[[1, 2, 3], [5, 6, 9], [7, 11, 12], [4, 8, 10]],
[[1, 2, 3], [7, 8, 9], [10, 11, 12], [4, 5, 6]],
[[1, 2, 3], [7, 8, 9], [6, 10, 11], [4, 5, 12]],
],
collisions: [4, 4],
},
];
describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => {
test('chaque ligne comparable porte mesurer, les plafonds, les trois chiffres ; le classement est celui de classer', () => {
const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
const table = evaluer(charge);
const instance = normaliser(configurationDepuisCharge(charge));
const aPriori = plafondsAPriori(instance);
assert.deepEqual(table.population, instance.ids);
assert.equal(table.propositions.length, 3);
const entrees = [];
for (const proposition of charge.propositions) {
const ligne = table.propositions.find(({ id }) => id === proposition.id);
const mesures = mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition)));
assert.equal(ligne.statut, 'comparable');
assert.deepEqual(ligne.raisons, []);
assert.deepEqual(ligne.violations, []);
assert.deepEqual(ligne.refusDessin, []);
assert.deepEqual(ligne.mesures, mesures);
assert.equal(ligne.plafondsRealises.length, 12);
assert.equal(ligne.minimumAtteint, false);
assert.deepEqual(Object.keys(ligne.troisChiffres).sort(), ['ecartItineraireMax', 'effectifManque', 'manqueMax']);
assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecartsAuPlafondAPriori(mesures, aPriori)));
entrees.push({ id: proposition.id, mesures, plafondsAPriori: aPriori });
}
assert.deepEqual(table.classement, classer(entrees));
assert.deepEqual(table.propositions.map(({ id }) => id), table.classement.ordre);
assert.equal(table.retenu, null);
});
test("trois plans au même premier critère se rangent par l'excédent, puis les cumulées ; le trio vient en dernier", () => {
const charge = structuredClone(chargeDemo('petite-conflit'));
charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours));
charge.prochainsIds.proposition = 5;
charge.evenement.etat = 'propose';
const table = evaluer(figer(charge));
const parId = new Map(table.propositions.map((ligne) => [ligne.id, ligne]));
PLANS_CONFLIT.forEach(({ nom, collisions }, rang) => {
const { mesures } = parId.get(rang + 1);
assert.deepEqual([mesures.collisionsCumulees, mesures.pairesDistinctes], collisions, nom);
});
assert.deepEqual([1, 2, 3].map((id) => parId.get(id).ecartAuPlafondAPrioriMax), [3, 3, 3]);
assert.ok(parId.get(4).ecartAuPlafondAPrioriMax > 3);
assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]);
assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax');
for (const ligne of table.propositions) {
const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDepuisCharge(charge))));
assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecarts));
}
});
test('evaluer ne modifie pas la charge, et deux appels rendent la même table', () => {
const charge = figer(generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 }));
assert.deepEqual(evaluer(charge), evaluer(charge));
});
});
describe('évaluation : une réservation que le plan ne tient pas (§ 5.8, § 12.6)', () => {
test("une réservation posée après la génération laisse la ligne comparable et classée, la violation RESERVATION nommée", () => {
const avant = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
const [proposition] = avant.propositions;
// La table de la personne 1 au premier tour, puis une autre où la réserver.
const listes = planDepuisFichier(proposition).tours[0];
const sienne = avant.tables[listes.findIndex((liste) => liste.includes(1))].id;
const autre = avant.tables.find(({ id }) => id !== sienne).id;
const charge = executer(avant, 'reserver', { participant: 1, table: autre, siege: null, portee: 'tour', tour: 1 });
const table = evaluer(figer(charge));
const [ligne] = table.propositions;
assert.equal(ligne.id, proposition.id);
assert.equal(ligne.statut, 'comparable');
assert.deepEqual(ligne.raisons, []);
assert.deepEqual(ligne.violations, [{ code: 'RESERVATION', participant: 1, table: autre, tour: 1 }]);
assert.notEqual(ligne.mesures, null);
assert.deepEqual(table.classement.ordre, [proposition.id]);
});
});
describe('évaluation : les formes closes ne corrigent rien (§ 5.4)', () => {
test("un retenu qui laisse une personne en réserve à trois tours mesure moins de collisions que le plancher, et la table porte la mesure", () => {
const charge = structuredClone(chargeDemo('petite-conflit'));
// Le plan parfait de la petite démonstration, la personne 10 en réserve
// aux tours 2 à 4 : elle seule fait deux membres d'une même appartenance
// à une table, au premier tour.
const tours = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5], [1, 4, 7]],
[[4, 8], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9], [3, 4, 12], [2, 6, 8]],
];
const proposition = propositionDe(charge, 1, tours, [[], [10], [10], [10]]);
charge.propositions = [proposition];
charge.retenu = retenuDe(proposition);
charge.prochainsIds.proposition = 2;
charge.evenement.etat = 'retenu';
const table = evaluer(figer(charge));
const plancher = diagnostiquer(configurationDepuisCharge(charge)).collisions.reduce((total, { plancherCumulees }) => total + plancherCumulees, 0);
assert.equal(plancher, 4);
assert.equal(table.retenu.statut, 'comparable');
assert.equal(table.retenu.id, 1);
assert.equal(table.retenu.mesures.collisionsCumulees, 1);
assert.ok(table.retenu.mesures.collisionsCumulees < plancher);
});
});
describe('évaluation : les populations différentes (§ 9, § 12.6)', () => {
test("générer, exclure une personne, générer encore : les premières sont en dérive, hors classement, leurs raisons nomment la personne", () => {
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 });
charge = executer(charge, 'exclureParticipant', { id: 4 });
charge = generee(charge, { graine: 2, nombre: 2, arret: 200 });
const table = evaluer(figer(charge));
assert.deepEqual(table.population, [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12]);
assert.deepEqual(table.classement.ordre.slice().sort(), [3, 4]);
for (const id of [1, 2]) {
const ligne = table.propositions.find((candidate) => candidate.id === id);
assert.equal(ligne.statut, 'derive');
assert.deepEqual(ligne.raisons, [{ code: 'PARTICIPANT_EXCLU', participant: 4 }]);
assert.deepEqual([ligne.mesures, ligne.plafondsRealises, ligne.troisChiffres, ligne.ecartAuPlafondAPrioriMax, ligne.minimumAtteint], [null, null, null, null, null]);
assert.deepEqual([ligne.violations, ligne.refusDessin], [[], []]);
}
assert.deepEqual(table.propositions.map(({ id }) => id), [...table.classement.ordre, 1, 2]);
});
test('une exclusion et un ajout, à effectif égal, mettent en dérive ; une table ajoutée aussi, TABLE_AJOUTEE', () => {
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
charge = executer(charge, 'exclureParticipant', { id: 4 });
charge = executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' } });
const echange = evaluer(figer(charge));
assert.equal(echange.population.length, 12);
assert.equal(echange.propositions[0].statut, 'derive');
assert.deepEqual(echange.propositions[0].raisons.map(({ code }) => code), ['PARTICIPANT_EXCLU', 'PARTICIPANT_NON_PLACE']);
assert.deepEqual(echange.classement.ordre, []);
let ajout = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
ajout = executer(ajout, 'creerTable', { forme: 'ronde' });
const table = evaluer(figer(ajout));
assert.equal(table.propositions[0].statut, 'derive');
assert.deepEqual(table.propositions[0].raisons, [{ code: 'TABLE_AJOUTEE', table: 5 }]);
});
});
describe('évaluation : un placement fautif (§ 8.9, point 3)', () => {
test("un retenu dont une liste dépasse sa longueur déclarée est fautif, non mesuré, et les propositions se classent sans lui", () => {
const base = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300 });
const attendue = evaluer(figer(structuredClone(base)));
const charge = structuredClone(base);
const retenu = retenuDe(charge.propositions[1]);
const [premiere, seconde] = retenu.placement[0].sieges;
premiere.push(seconde.shift());
charge.retenu = retenu;
charge.evenement.etat = 'retenu';
const table = evaluer(figer(charge));
assert.equal(table.retenu.statut, 'fautif');
assert.equal(table.retenu.id, charge.propositions[1].id);
assert.equal(table.retenu.mesures, null);
assert.ok(table.retenu.raisons.length > 0);
assert.ok(table.retenu.raisons.every(({ code }) => code === 'LONGUEUR'), JSON.stringify(table.retenu.raisons));
assert.deepEqual(table.propositions, attendue.propositions);
assert.deepEqual(table.classement, attendue.classement);
});
});
describe('évaluation : le plafond a priori inconnu (§ 17, point 4)', () => {
test("plafondsAPriori null : le premier critère est sauté et nommé ; écart d'itinéraire et certificat inconnus", () => {
const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 2, arret: 300 }));
const table = evaluer(charge, { plafondsAPriori: null });
assert.equal(table.classement.critereSaute, 'ecartAuPlafondAPrioriMax');
assert.equal(table.classement.criteresAppliques[0], 'excedentCollisions');
for (const ligne of table.propositions) {
assert.equal(ligne.troisChiffres.ecartItineraireMax, null);
assert.equal(ligne.minimumAtteint, null);
assert.equal(ligne.ecartAuPlafondAPrioriMax, null);
}
assert.throws(() => evaluer(charge, { plafondsAPriori: [] }), TypeError);
});
});
describe('évaluation : les imposés (§ 5.4, § 15.1)', () => {
test('la grande démonstration : 99 retours imposés, 33 ancrages qui en imposent, ceux de diagnostiquer', () => {
const charge = chargeDemo('grande');
const table = evaluer(figer(charge));
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
assert.deepEqual(table.imposes, { retours: 99, ancrages: 33 });
assert.deepEqual(table.imposes, { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours });
});
test("deux personnes d'appartenances distinctes réservées ensemble : une rencontre répétée imposée par ligne, hors des choisies", () => {
const charge = structuredClone(chargeDemo('petite'));
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
const avec = generee(charge, { graine: 6, nombre: 3, arret: 500 });
const table = evaluer(figer(avec));
assert.equal(table.propositions.length, 3);
for (const ligne of table.propositions) {
assert.equal(ligne.mesures.rencontresRepetees.imposees, 1);
// Les paires réunies deux fois au moins, comptées sur le plan : celle
// des deux réservés n'est pas parmi les choisies.
const plan = planDepuisFichier(avec.propositions.find(({ id }) => id === ligne.id));
const fois = new Map();
for (const listes of plan.tours) {
for (const liste of listes) {
for (const a of liste) for (const b of liste) if (a < b) fois.set(`${a}-${b}`, (fois.get(`${a}-${b}`) ?? 0) + 1);
}
}
const repetees = [...fois.values()].filter((n) => n >= 2).length;
assert.equal(fois.get('1-2'), 4);
assert.equal(ligne.mesures.rencontresRepetees.choisies, repetees - 1);
}
});
});
describe('évaluation : une table, un tour (§ 15.4)', () => {
test('six personnes à une table de 6, un tour : rien de répété, aucun NaN ni infini', () => {
const charge = structuredClone(chargeDemo('petite'));
charge.participants = charge.participants.slice(0, 6);
charge.tables = charge.tables.slice(0, 1).map((table) => ({ ...table, sieges: 6 }));
charge.evenement.tours = 1;
const table = evaluer(figer(generee(charge, { graine: 1, nombre: 1, arret: 10 })));
const [ligne] = table.propositions;
assert.equal(ligne.statut, 'comparable');
assert.deepEqual(ligne.mesures.rencontresRepetees, { choisies: 0, imposees: 0 });
assert.equal(ligne.mesures.excedentCollisions, 0);
assert.equal(ligne.mesures.totalRetoursChoisis, 0);
const tous = nombres(table);
assert.ok(tous.length > 0);
assert.ok(tous.every(Number.isFinite), 'NaN ou infini dans la table');
});
});
describe("évaluation : une configuration qui ne se tient pas", () => {
test("evaluer lève l'ErreurConfiguration de normaliser", () => {
const charge = structuredClone(chargeDemo('petite'));
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
assert.throws(() => evaluer(figer(charge)), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION');
});
});

View file

@ -1,97 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'exécuteur de production de la génération (§ 5.10, § 13.4) : la
// recherche tourne dans un travailleur, hors du fil de l'interface, et
// l'exécuteur adapte son protocole — celui de generation_travailleur.js —
// au contrat Executeur du contrôleur de génération (generation.js).
//
// → { type: 'lancer', configuration, reglages } le premier et seul message
// ← { type: 'avancement', fait, total } remis aux abonnés
// ← { type: 'resultat', propositions } résout le résultat
// ← { type: 'erreur', code, details } rejette par ErreurGeneration
//
// Chaque lancer prend un travailleur neuf de la fabrique et le termine à
// l'issue : résultat, erreur, faute du travailleur ou annulation. Terminé,
// le travailleur ne remet plus rien : un message déjà en file à cet instant
// ne parvient ni aux abonnés ni au résultat. L'annulation est terminate(),
// dans le même tour : immédiate, sans résultat partiel. La fabrique est un
// paramètre : src/main.js la construit sur le travailleur inclus dans la
// page, une épreuve sur un travailleur factice. Ce module ne touche aucune
// plateforme.
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
/**
* L'exécuteur du travailleur.
*
* @param {() => {postMessage: (message: Object) => void, terminate: () => void,
* onmessage: ((evenement: {data: Object}) => void)|null,
* onerror?: ((evenement: {message?: string}) => void)|null}} fabrique
* rend un travailleur neuf à chaque appel
* @returns {import('./generation.js').Executeur}
* lancer(configuration, reglages) rend une Execution : avancement →
* surAvancement ; resultat → la promesse résolue aux propositions reçues,
* puis terminate() ; erreur {code, details} → rejet par
* ErreurGeneration(code, details) ; faute du travailleur, son canal
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
* avancement. Après l'issue, annuler() ne fait rien.
*/
export function creerExecuteurTravailleur(fabrique) {
return {
lancer(configuration, reglages) {
const travailleur = fabrique();
const abonnes = new Set();
let termine = false;
let resoudre;
let rejeter;
const resultat = new Promise((ok, ko) => {
resoudre = ok;
rejeter = ko;
});
// L'issue : le travailleur se termine, ses gestionnaires sont retirés,
// et rien de ce qu'il remettrait encore ne passe.
function terminer() {
termine = true;
travailleur.onmessage = null;
travailleur.onerror = null;
travailleur.terminate();
}
travailleur.onmessage = ({ data }) => {
if (termine) return;
if (data.type === 'avancement') {
for (const fn of [...abonnes]) fn(data.fait, data.total);
} else if (data.type === 'resultat') {
resoudre(data.propositions);
terminer();
} else if (data.type === 'erreur') {
rejeter(new ErreurGeneration(data.code, data.details));
terminer();
}
};
travailleur.onerror = (evenement) => {
if (termine) return;
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
terminer();
};
travailleur.postMessage({ type: 'lancer', configuration, reglages });
return {
surAvancement(fn) {
abonnes.add(fn);
return () => {
abonnes.delete(fn);
};
},
resultat,
annuler() {
if (termine) return;
terminer();
rejeter(new ErreurAnnulee());
},
};
},
};
}

View file

@ -1,174 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'exécuteur du travailleur (§ 5.10) : creerExecuteurTravailleur
// sur une fabrique de travailleurs factices. Un travailleur factice garde
// chaque message reçu et compte ses terminate() ; l'épreuve lui fait poster
// les messages du contrat de données par son onmessage, comme le ferait le
// vrai travailleur. Aucun calcul n'a lieu : les propositions et la
// configuration sont des objets d'épreuve.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { creerExecuteurTravailleur } from './executeur_travailleur.js';
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
const CONFIGURATION = Object.freeze({ marque: 'configuration d’épreuve', tours: 3 });
const REGLAGES = Object.freeze({ graine: 11, nombre: 2, arret: 40_000, historique: 1000 });
// Une fabrique de travailleurs factices, et les travailleurs qu'elle a
// rendus, dans l'ordre. Chacun : recus, les messages reçus, clonés ;
// terminaisons, le compte de terminate() ; poster(data), un message du
// travailleur, remis à son onmessage courant.
function fabriqueFactice() {
const travailleurs = [];
const fabrique = () => {
const travailleur = {
recus: [],
terminaisons: 0,
onmessage: null,
postMessage(message) {
this.recus.push(structuredClone(message));
},
terminate() {
this.terminaisons += 1;
},
poster(data) {
this.onmessage?.({ data });
},
};
travailleurs.push(travailleur);
return travailleur;
};
return { fabrique, travailleurs };
}
// L'issue d'une promesse, sans attendre plus d'un tour de la file des
// microtâches : { etat: 'attente' }, { etat: 'resolue', valeur } ou
// { etat: 'rejetee', erreur }.
async function issue(promesse) {
let lue = { etat: 'attente' };
promesse.then(
(valeur) => {
lue = { etat: 'resolue', valeur };
},
(erreur) => {
lue = { etat: 'rejetee', erreur };
},
);
await Promise.resolve();
await Promise.resolve();
return lue;
}
describe('exécuteur du travailleur (§ 5.10)', () => {
test('un travailleur neuf par lancer, dont le premier message est { type: lancer, configuration, reglages }', () => {
const { fabrique, travailleurs } = fabriqueFactice();
const executeur = creerExecuteurTravailleur(fabrique);
assert.equal(travailleurs.length, 0, 'aucun travailleur avant le premier lancer');
const premiere = executeur.lancer(CONFIGURATION, REGLAGES);
const seconde = executeur.lancer(CONFIGURATION, { ...REGLAGES, graine: 12 });
premiere.resultat.catch(() => undefined);
seconde.resultat.catch(() => undefined);
assert.equal(travailleurs.length, 2);
assert.notEqual(travailleurs[0], travailleurs[1]);
assert.deepEqual(travailleurs[0].recus, [{ type: 'lancer', configuration: CONFIGURATION, reglages: REGLAGES }]);
assert.deepEqual(travailleurs[1].recus, [
{ type: 'lancer', configuration: CONFIGURATION, reglages: { ...REGLAGES, graine: 12 } },
]);
assert.deepEqual(
travailleurs.map(({ terminaisons }) => terminaisons),
[0, 0],
);
});
test('deux avancements parviennent à surAvancement dans leur ordre ; un abonné retiré ne reçoit plus rien', () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
execution.resultat.catch(() => undefined);
const recus = [];
const retires = [];
execution.surAvancement((fait, total) => recus.push([fait, total]));
const desabonner = execution.surAvancement((fait, total) => retires.push([fait, total]));
const [travailleur] = travailleurs;
travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 });
desabonner();
travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 });
assert.deepEqual(recus, [
[50_000, 80_000],
[80_000, 80_000],
]);
assert.deepEqual(retires, [[50_000, 80_000]]);
});
test('resultat : la promesse se résout aux propositions reçues, puis le travailleur est terminé ; un message tardif ne change rien', async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const avancements = [];
execution.surAvancement((fait) => avancements.push(fait));
const [travailleur] = travailleurs;
const propositions = [
{ id: 1, graine: 101, arret: 40_000, historique: 1000, plan: [[[1, 2]]] },
{ id: 2, graine: 102, arret: 40_000, historique: 1000, plan: [[[2, 1]]] },
];
assert.equal((await issue(execution.resultat)).etat, 'attente');
travailleur.poster({ type: 'resultat', propositions });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'resolue');
assert.deepEqual(lue.valeur, propositions);
assert.equal(travailleur.terminaisons, 1);
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
execution.annuler();
assert.deepEqual(avancements, []);
assert.equal(travailleur.terminaisons, 1, 'annuler après le résultat ne termine pas une seconde fois');
});
test('erreur { code, details } : rejet par une ErreurGeneration de ce code et de ces détails, le travailleur terminé', async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const [travailleur] = travailleurs;
travailleur.poster({ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
assert.equal(lue.erreur.code, 'PLACES_MANQUANTES');
assert.deepEqual(lue.erreur.details, { placesManquantes: 1 });
assert.equal(travailleur.terminaisons, 1);
});
test("annuler : terminate() aussitôt, rejet par ErreurAnnulee ; un avancement ou un résultat posté ensuite n'atteint plus rien", async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const avancements = [];
execution.surAvancement((fait, total) => avancements.push([fait, total]));
const [travailleur] = travailleurs;
// Le gestionnaire posé au lancement : un message déjà en file chez le
// navigateur au moment de l'annulation l'atteindrait encore.
const gestionnaire = travailleur.onmessage;
travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 });
execution.annuler();
assert.equal(travailleur.terminaisons, 1, 'terminate() dans le même tour que annuler()');
gestionnaire({ data: { type: 'avancement', fait: 80_000, total: 80_000 } });
gestionnaire({ data: { type: 'resultat', propositions: [] } });
travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof ErreurAnnulee, String(lue.erreur));
assert.deepEqual(avancements, [[50_000, 80_000]]);
execution.annuler();
assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus');
});
test('le canal d’erreur du travailleur : rejet par une erreur sans code, le travailleur terminé', async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const [travailleur] = travailleurs;
assert.equal(typeof travailleur.onerror, 'function');
travailleur.onerror({ message: 'faute d’épreuve' });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof Error);
assert.equal(lue.erreur.code, undefined);
assert.match(lue.erreur.message, /faute d’épreuve/);
assert.equal(travailleur.terminaisons, 1);
});
});

View file

@ -1,441 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La génération (§ 5.7, § 5.8, § 5.10) : les exécuteurs qui font tourner la
// recherche du moteur, et le contrôleur qui la lance depuis la séance.
//
// Un exécuteur rend, pour une configuration et des réglages, une exécution :
// son résultat, une promesse des propositions de rechercher ; ses
// avancements ; son annulation, qui rejette le résultat par ErreurAnnulee,
// sans résultat partiel. L'exécuteur direct calcule dans le fil, une
// microtâche après le lancement : il sert les épreuves et le premier
// montage de l'application ; celui du travailleur (I13) calcule hors du fil
// et s'annule par terminate(). Une erreur de la recherche devient la même
// dans les deux, par versErreurGeneration.
//
// Le contrôleur ne numérote rien et ne mesure aucun plan. Il refuse avant
// tout lancement un réglage hors de son domaine (REGLAGE_HORS_DOMAINE), puis
// ce que l'enregistrement refuserait — les refus de la séance, lus dans son
// état observable, dans l'ordre de son contrat —, puis
// ce qui rend la génération vaine ou impossible ; il garde la graine
// proposée et la durée du dernier calcul, en mémoire, pour la séance de
// l'application ; il vérifie les invariants de chaque proposition rendue,
// écarte celles dont le plan est déjà présent, et confie les autres à la
// commande enregistrerGeneration, une seule entrée du journal. Le nombre de
// propositions d'une génération est un réglage, jamais une constante d'ici.
//
// Le compte d'inactivité de la séance est suspendu pendant toute la
// génération (debuterCalcul), et repart à sa fin, quelle qu'elle soit
// (finirCalcul, dans un finally). Passer en lecture, ou fermer l'événement,
// annule la génération en cours (surAnnulation).
import { normaliser, nombrePlacesManquantes } from '../moteur/configuration.js';
import { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js';
import { rechercher, verifierReglages } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.js';
import { GENERATION_PAR_DEFAUT, configurationDepuisCharge } from '../stockage/document.js';
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
import { lireEntier } from './capacite.js';
// src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur.
export { ErreurAnnulee } from '../moteur/erreurs.js';
/** Au-delà de cette durée estimée, en secondes, la génération demande confirmation (§ 5.10). */
export const SEUIL_GENERATION_LONGUE_S = 60;
/** Le plafond d'usage d'une génération, en secondes (§ 5.10) : un repère dit, jamais un refus. */
export const PLAFOND_USAGE_S = 30 * 60;
// Sans durée mesurée, une génération de plus de mouvements que ce compte —
// dix fois le réglage par défaut — demande confirmation.
const MOUVEMENTS_SANS_MESURE = 10 * GENERATION_PAR_DEFAUT.nombre * GENERATION_PAR_DEFAUT.arret;
// La plus grande graine, et le modulo de la suivante.
const GRAINE_MAX = 2 ** 32 - 1;
const GRAINES = 2 ** 32;
// Les champs des réglages, dans l'ordre où leurs refus se rendent, et le
// domaine de chacun ; max null : sans borne.
const CHAMPS = [
['graine', 0, GRAINE_MAX],
['nombre', 1, null],
['arret', 1, null],
['historique', 1, null],
];
const versSection = (section) => ({ geste: 'ouvrirSection', section });
/**
* Refus ou échec d'une génération : son code et ses détails — ceux de la
* séance, du moteur ou du contrôleur, tels quels. Le texte affiché est celui
* de son code, nu (§ 14.6) ; le message, code et détails en JSON, sert aux
* traces.
*/
export class ErreurGeneration extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurGeneration.prototype.name = 'ErreurGeneration';
/**
* Les réglages d'une génération, contrôlés par la règle de rechercher
* (verifierReglages) avant tout calcul. Lève, pour le premier réglage hors
* de son domaine, ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage,
* remede: null }), reglage étant son nom — graine, arret, historique ou
* nombre —, sans le message du moteur ; ne rend rien sinon. Toute autre
* erreur du contrôle est relevée telle quelle.
*
* @param {Object} reglages
*/
export function exigerReglages(reglages) {
try {
verifierReglages(reglages);
} catch (erreur) {
if (erreur instanceof RangeError && typeof erreur.reglage === 'string') {
throw new ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage: erreur.reglage, remede: null });
}
throw erreur;
}
}
/**
* L'erreur d'une recherche, la même dans les deux exécuteurs : ErreurAnnulee
* telle quelle ; une ErreurConfiguration devient ErreurGeneration de son code
* et de ses détails ; toute autre, RangeError comprise, est rendue telle
* quelle, pour être relevée. Les réglages sont contrôlés avant le calcul
* (exigerReglages) : une RangeError qui survient pendant lui vient d'ailleurs,
* d'un abonné aux avancements par exemple, et ne se dit pas comme un réglage.
*
* @param {unknown} erreur
* @returns {unknown}
*/
export function versErreurGeneration(erreur) {
if (erreur instanceof ErreurAnnulee) return erreur;
if (erreur instanceof ErreurConfiguration) return new ErreurGeneration(erreur.code, erreur.details);
return erreur;
}
/**
* @typedef {Object} Execution
* @property {(fn: (fait: number, total: number) => void) => () => void} surAvancement
* abonne fn aux avancements ; rend la fonction qui désabonne
* @property {Promise<import('../moteur/recherche.js').Proposition[]>} resultat
* rejetée par ErreurAnnulee à l'annulation, par ErreurGeneration sur
* une erreur du moteur
* @property {() => void} annuler sans résultat partiel
*
* @typedef {{lancer: (configuration: Object, reglages: Object) => Execution}} Executeur
*/
/**
* L'exécuteur direct : rechercher dans le fil, une microtâche après lancer.
* Rien d'autre ne s'exécute pendant la recherche. annuler(), avant ce départ
* ou depuis un rappel d'avancement, fait lever ErreurAnnulee à la lecture
* suivante du signal par le moteur : avant la proposition suivante, au plus
* 1 000 mouvements plus tard. Chaque avancement est celui du moteur, toutes
* les 1 000 itérations.
*
* @returns {Executeur}
*/
export function creerExecuteurDirect() {
return {
lancer(configuration, reglages) {
const signal = { aborted: false };
const abonnes = new Set();
const progression = (fait, total) => {
for (const fn of [...abonnes]) fn(fait, total);
};
const resultat = Promise.resolve().then(() => {
exigerReglages(reglages);
try {
return rechercher(configuration, reglages, { signal, progression });
} catch (erreur) {
throw versErreurGeneration(erreur);
}
});
return {
surAvancement(fn) {
abonnes.add(fn);
return () => {
abonnes.delete(fn);
};
},
resultat,
annuler() {
signal.aborted = true;
},
};
},
};
}
// Le plan d'une proposition du moteur, rangé comme planDepuisFichier rend
// celui d'une proposition enregistrée : la même mise en forme, versFichier
// puis planDepuisFichier, pour que deux plans égaux s'écrivent pareil.
function planRange(proposition, configuration, produitVersion) {
const fichier = versFichier(proposition, configuration, { produitVersion, attribuerSieges: false, decalage: 0 });
return JSON.stringify(planDepuisFichier(fichier));
}
/**
* Le contrôleur de génération d'une séance (§ 5.7, § 5.10).
*
* @param {Object} parametres
* @param {Object} parametres.seance la séance d'A1 et A2 : etat,
* subscribe, surAnnulation, executer, debuterCalcul, finirCalcul
* @param {Executeur} parametres.executeur
* @param {import('./horloge.js').Horloge} parametres.horloge instant() mesure
* la durée d'un calcul
* @param {string} parametres.produitVersion
* @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider,
* estimer, derniereGeneration, generer, annuler, subscribe
*/
export function creerControleurGeneration({ seance, executeur, horloge, produitVersion }) {
// graine : la graine proposée ; base : celle de l'événement ouvert, null
// sans événement ; mesure : le dernier calcul abouti, null avant lui ;
// execution : celle en cours, null hors génération ; enCours vaut vrai du
// lancement à la fin de l'enregistrement.
let graine = 1;
let base = null;
let mesure = null;
let execution = null;
let observable = { enCours: false, fait: 0, total: 0, annulee: null };
const abonnes = new Set();
function publier(changements) {
observable = { ...observable, ...changements };
for (const fn of [...abonnes]) fn(observable);
}
// Une ouverture : l'événement passe de null, ou d'une autre base, à une
// base ; la graine proposée repart de 1.
seance.subscribe((etat) => {
const suivante = etat.evenement?.base ?? null;
if (suivante !== null && suivante !== base) graine = 1;
base = suivante;
});
// Les réglages de génération de la charge ouverte, ceux d'une charge
// neuve sans événement.
const reglagesDeLaCharge = () => seance.etat().charge?.reglages.generation ?? GENERATION_PAR_DEFAUT;
function annulerAvec(cause) {
if (execution === null) return;
const courante = execution;
publier({ annulee: cause });
courante.annuler();
}
function estimer({ nombre, arret }) {
const mouvements = nombre * arret;
if (mesure === null) return { inconnue: true };
const secondes = (mouvements * mesure.dureeMs) / (mesure.mouvements * 1000);
return {
secondes,
longue: secondes > SEUIL_GENERATION_LONGUE_S,
auDelaDuPlafond: secondes > PLAFOND_USAGE_S,
mesure: { ...mesure },
};
}
// Longue : au-delà du seuil estimé, ou, sans mesure, au-delà de dix fois
// le réglage par défaut.
function estLongue(reglages, estimation) {
if (estimation.inconnue === true) return reglages.nombre * reglages.arret > MOUVEMENTS_SANS_MESURE;
return estimation.longue;
}
// Les refus d'avant lancement : les réglages d'abord, puis l'ordre du
// contrat ; rend la configuration et son instance quand la génération peut
// partir.
function exigerLancement(reglages, confirmee) {
exigerReglages(reglages);
const etat = seance.etat();
if (etat.evenement === null) throw new ErreurGeneration('AUCUN_EVENEMENT', { remede: null });
if (etat.refus !== null) throw new ErreurGeneration(etat.refus.code, etat.refus.details);
if (execution !== null || observable.enCours) throw new ErreurGeneration('GENERATION_EN_COURS', { remede: null });
const { charge } = etat;
if (!charge.participants.some(({ exclu }) => !exclu)) {
throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') });
}
if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') });
const configuration = configurationDepuisCharge(charge);
let instance;
try {
instance = normaliser(configuration);
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) throw new ErreurGeneration(erreur.code, { ...erreur.details, remede: null });
throw erreur;
}
const placesManquantes = nombrePlacesManquantes(instance);
if (placesManquantes > 0) {
throw new ErreurGeneration('PLACES_MANQUANTES', { placesManquantes, remede: versSection('tables') });
}
const estimation = estimer(reglages);
if (!confirmee && estLongue(reglages, estimation)) {
throw new ErreurGeneration('CONFIRMATION_REQUISE', { estimation, remede: null });
}
return { configuration, instance };
}
// Le calcul confié à l'exécuteur : ses avancements publiés, son annulation
// par la séance suivie ; rend les propositions et la durée du calcul. Une
// annulation lève ErreurAnnulee, même quand l'exécuteur a rendu un
// résultat entre-temps.
async function calculer(configuration, reglages) {
const debut = horloge.instant();
execution = executeur.lancer(configuration, reglages);
const desabonner = execution.surAvancement((fait, total) => publier({ fait, total }));
const desannuler = seance.surAnnulation(() => annulerAvec('seance'));
try {
const propositions = await execution.resultat;
if (observable.annulee !== null) throw new ErreurAnnulee();
return { propositions, dureeMs: horloge.instant() - debut };
} finally {
desabonner();
desannuler();
execution = null;
}
}
// Les propositions à enregistrer, par identifiant du moteur croissant :
// chacune sans violation des invariants, sinon GENERATION_INVARIANT_VIOLE,
// rang compris — la place de la proposition dans la suite des graines du
// lancement, qui n'est l'identifiant d'aucune proposition enregistrée ; puis sans
// celles dont le plan est celui d'une proposition présente sans dérive, ou
// d'une proposition déjà gardée de ce lancement.
function trier(propositions, configuration, instance) {
const ordonnees = [...propositions].sort((a, b) => a.id - b.id);
for (const proposition of ordonnees) {
const violations = verifierInvariants(instance, proposition.plan);
if (violations.length > 0) {
throw new ErreurGeneration('GENERATION_INVARIANT_VIOLE', { rang: proposition.id, violations, remede: null });
}
}
const charge = seance.etat().charge;
const presents = new Set(
charge.propositions.filter((p) => derive(p, charge).length === 0).map((p) => JSON.stringify(planDepuisFichier(p))),
);
const gardees = [];
for (const proposition of ordonnees) {
const plan = planRange(proposition, configuration, produitVersion);
if (presents.has(plan)) continue;
presents.add(plan);
gardees.push(proposition);
}
return { gardees, identiques: ordonnees.length - gardees.length };
}
// Les identifiants que l'enregistrement a donnés : ceux des propositions
// de la charge qui n'étaient pas avant le geste, et qui portent la graine,
// l'arrêt et l'historique d'une proposition gardée ; croissants.
function ajouteesDans(charge, avant, gardees) {
const cles = new Set(gardees.map(({ graine: g, arret, historique }) => `${g}|${arret}|${historique}`));
return charge.propositions
.filter(({ id, graine: g, arret, historique }) => !avant.has(id) && cles.has(`${g}|${arret}|${historique}`))
.map(({ id }) => id)
.sort((a, b) => a - b);
}
async function generer(reglages, { confirmee = false } = {}) {
const { configuration, instance } = exigerLancement(reglages, confirmee);
graine = (reglages.graine + 1) % GRAINES;
publier({ enCours: true, fait: 0, total: reglages.nombre * reglages.arret, annulee: null });
seance.debuterCalcul();
try {
const { propositions, dureeMs } = await calculer(configuration, reglages);
mesure = {
dureeMs,
mouvements: reglages.nombre * reglages.arret,
participants: instance.N,
nombre: reglages.nombre,
arret: reglages.arret,
};
const { gardees, identiques } = trier(propositions, configuration, instance);
if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs };
const avant = new Set(seance.etat().charge.propositions.map(({ id }) => id));
await seance.executer('enregistrerGeneration', { propositions: gardees, configuration, produitVersion });
return { ajoutees: ajouteesDans(seance.etat().charge, avant, gardees), identiques, dureeMs };
} finally {
seance.finirCalcul();
publier({ enCours: false });
}
}
return {
/** La graine proposée : 1 à chaque ouverture, puis la dernière lancée plus un, modulo 2^32. */
grainePropose: () => graine,
/** { graine, nombre, arret, historique } : la graine proposée, les trois autres de la charge. */
reglagesProposes() {
const { nombre, arret, historique } = reglagesDeLaCharge();
return { graine, nombre, arret, historique };
},
/**
* Les saisies des quatre champs, chacune lue par lireEntier (NON_ENTIER
* {saisie}), puis bornée à son domaine (HORS_DOMAINE {min, max}, max null
* sans borne) ; un historique non saisi — absent, null ou blanc — vaut
* celui de la charge. Rend { reglages, refus } : reglages null dès
* qu'un champ est refusé, refus dans l'ordre des champs.
*/
valider(saisies) {
const reglages = {};
const refus = [];
for (const [champ, min, max] of CHAMPS) {
const saisie = saisies[champ];
if (champ === 'historique' && (saisie === undefined || saisie === null || (typeof saisie === 'string' && saisie.trim() === ''))) {
reglages.historique = reglagesDeLaCharge().historique;
continue;
}
const lu = lireEntier(saisie);
if (lu.refus !== undefined) {
refus.push({ champ, code: lu.refus.code, details: lu.refus.details });
} else if (lu.valeur < min || (max !== null && lu.valeur > max)) {
refus.push({ champ, code: 'HORS_DOMAINE', details: { min, max, remede: null } });
} else {
reglages[champ] = lu.valeur;
}
}
return { reglages: refus.length === 0 ? reglages : null, refus };
},
/**
* L'estimation des réglages, proportionnelle aux mouvements de la
* dernière mesure : { inconnue: true } sans elle, sinon { secondes,
* longue, auDelaDuPlafond, mesure }.
*/
estimer,
/** La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null avant lui. */
derniereGeneration: () => (mesure === null ? null : { ...mesure }),
/**
* Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }.
* Refuse avant tout lancement, par ErreurGeneration, dans cet ordre :
* REGLAGE_HORS_DOMAINE {reglage} ; AUCUN_EVENEMENT ; le refus courant de
* la séance ; GENERATION_EN_COURS ;
* AUCUN_PARTICIPANT ; AUCUNE_TABLE ; le refus de normaliser ;
* PLACES_MANQUANTES ; CONFIRMATION_REQUISE {estimation}, que
* confirmee lève. Après la recherche : GENERATION_INVARIANT_VIOLE
* {rang, violations}, puis ce
* qu'executer lève, tel quel. Annulée : ErreurAnnulee.
*/
generer,
/** Annule la génération en cours, sans résultat partiel ; annulee vaut 'commande'. */
annuler: () => annulerAvec('commande'),
/**
* Le contrat des magasins de Svelte sur { enCours, fait, total, annulee } :
* annulee, la cause de la dernière annulation, 'commande' ou 'seance',
* null dès le lancement suivant.
*/
subscribe(fn) {
abonnes.add(fn);
fn(observable);
return () => {
abonnes.delete(fn);
};
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le côté travailleur de la génération (§ 5.10) : la recherche du moteur
// hors du fil de l'interface. Le travailleur reçoit un message, calcule, et
// poste ses avancements puis son résultat ou son erreur :
//
// → { type: 'lancer', configuration, reglages }
// ← { type: 'avancement', fait, total } au plus une fois toutes les
// CADENCE_AVANCEMENT progressions
// du moteur, fait croissant
// ← { type: 'resultat', propositions } celles de rechercher, telles quelles
// ← { type: 'erreur', code, details } REGLAGE_HORS_DOMAINE {reglage}
// d'exigerReglages, avant tout
// calcul ; puis, par
// versErreurGeneration, le code et
// les détails d'une
// ErreurConfiguration
//
// L'annulation est terminate() du côté de l'interface : immédiate, sans
// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un
// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit
// pas est relevée, et le travailleur la signale par son propre canal
// d'erreur.
import { rechercher } from '../moteur/recherche.js';
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
/** Progressions du moteur — une toutes les 1 000 itérations — entre deux messages d'avancement. */
export const CADENCE_AVANCEMENT = 50;
/**
* Branche une portée de travailleur — { onmessage, postMessage } — sur
* rechercher, selon les messages de l'en-tête du module.
*
* @param {{onmessage: ((evenement: {data: unknown}) => void)|null, postMessage: (message: Object) => void}} portee
*/
export function brancherTravailleur(portee) {
portee.onmessage = ({ data }) => {
if (data === null || typeof data !== 'object' || data.type !== 'lancer') return;
let progressions = 0;
const progression = (fait, total) => {
progressions += 1;
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
};
let propositions;
try {
exigerReglages(data.reglages);
propositions = rechercher(data.configuration, data.reglages, { progression });
} catch (erreur) {
const convertie = versErreurGeneration(erreur);
if (!(convertie instanceof ErreurGeneration)) throw convertie;
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
return;
}
portee.postMessage({ type: 'resultat', propositions });
};
}

View file

@ -1,66 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du travailleur de génération (§ 5.10) : brancherTravailleur sur
// une portée factice, qui garde chaque message posté. Les configurations
// viennent des démonstrations livrées et d'une charge aux noms inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { texteLivre } from '../demo/livrees.js';
import { rechercher } from '../moteur/recherche.js';
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
import { CADENCE_AVANCEMENT, brancherTravailleur } from './generation_travailleur.js';
const configurationDe = (cle) => configurationDepuisCharge(analyser(texteLivre(cle)).charge);
// Une portée de travailleur factice : ce que le travailleur poste, et
// l'envoi d'un message au travailleur.
function porteeFactice() {
const postes = [];
const portee = {
onmessage: null,
postMessage: (message) => postes.push(structuredClone(message)),
};
brancherTravailleur(portee);
return { postes, envoyer: (data) => portee.onmessage({ data }) };
}
describe('travailleur de génération (§ 5.10)', () => {
test("après lancer, des avancements à fait croissant, au plus un tous les CADENCE_AVANCEMENT, puis le résultat de rechercher", () => {
assert.equal(CADENCE_AVANCEMENT, 50);
const configuration = configurationDe('petite-conflit');
const reglages = { graine: 9, nombre: 2, arret: 60000, historique: 1000 };
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'lancer', configuration, reglages });
const avancements = postes.filter(({ type }) => type === 'avancement');
assert.ok(avancements.length > 0, 'aucun avancement');
assert.ok(avancements.length <= Math.ceil((reglages.nombre * reglages.arret) / 1000 / CADENCE_AVANCEMENT));
assert.deepEqual(avancements, [
{ type: 'avancement', fait: 50000, total: 120000 },
{ type: 'avancement', fait: 100000, total: 120000 },
]);
assert.deepEqual(postes.at(-1), { type: 'resultat', propositions: rechercher(configuration, reglages) });
assert.equal(postes.length, avancements.length + 1);
});
test('des places manquantes postent erreur PLACES_MANQUANTES, les détails du moteur', () => {
const configuration = configurationDe('petite');
configuration.tables = configuration.tables.slice(1);
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'lancer', configuration, reglages: { graine: 1, nombre: 1, arret: 10 } });
assert.deepEqual(postes, [{ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]);
});
test('une graine de 2^32 poste erreur REGLAGE_HORS_DOMAINE, qui nomme le réglage, sans le message du moteur', () => {
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'lancer', configuration: configurationDe('petite'), reglages: { graine: 2 ** 32, nombre: 1, arret: 10 } });
assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]);
});
test("un message d'un autre type ne lance rien", () => {
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'inconnu' });
envoyer(null);
assert.deepEqual(postes, []);
});
});

View file

@ -16,22 +16,11 @@
// La table se partage en fichiers, un par domaine, sous libelles/ : chacun
// exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique
// des fichiers ; les tables, comme l'assemblage, sont hors du balayage.
//
// src/moteur se relève à part, épreuves exclues : chaque littéral en
// capitales de ses sources, hors commentaires, y est un code — celui d'une
// ErreurConfiguration ou d'une violation de ses vérificateurs —, que le
// moteur lève par une constante ou par une expression autant que par un
// appel. Une seule exception, nommée : les valeurs de domine dans manque.js,
// qui disent laquelle de deux propositions sert le mieux et ne s'affichent
// pas comme un code.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { CRITERES } from '../moteur/classement.js';
import { TABLES_INTERFACE } from '../interface/libelles.js';
import { creerTraduction } from './traduction.js';
import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
@ -101,40 +90,8 @@ function modulesDe(arbre) {
.sort();
}
// --- Le relevé du moteur -----------------------------------------------------
const MOTEUR = 'src/moteur';
// Les littéraux en capitales du moteur qui ne sont pas des codes, par module.
const PAS_DES_CODES = new Map([[join(MOTEUR, 'manque.js'), new Set(['A', 'B'])]]);
// Les modules du moteur qui portent des codes.
const MODULES_A_CODES = ['configuration.js', 'plafond.js', 'recherche.js', 'verification.js'].map((nom) => join(MOTEUR, nom));
const LITTERAL_CODE = new RegExp(CODE, 'g');
/** Chaque littéral en capitales d'un texte source, hors commentaires. */
function litterauxEnCapitales(texte) {
return [...sansCommentaires(texte).matchAll(LITTERAL_CODE)].map((trouve) => trouve[1]);
}
// Relevé du moteur : chaque code de ses modules, hors exceptions nommées,
// le module qui émet chacun en premier, et les modules qui en portent.
function releverMoteur() {
const codes = new Set();
const emetteurs = new Map();
const porteurs = [];
for (const chemin of modulesDe(MOTEUR)) {
const exclus = PAS_DES_CODES.get(chemin) ?? new Set();
const trouves = litterauxEnCapitales(readFileSync(join(RACINE, chemin), 'utf8')).filter((code) => !exclus.has(code));
if (trouves.length > 0) porteurs.push(chemin);
for (const code of trouves) {
codes.add(code);
if (!emetteurs.has(code)) emetteurs.set(code, chemin);
}
}
return { codes, emetteurs, porteurs };
}
// Relevé de l'arbre réel : par arbre, les codes émis et le fichier qui émet
// chacun en premier ; puis ceux des tables de contrat ; puis ceux du moteur.
// chacun en premier ; puis ceux des tables de contrat.
function releverArbre() {
const parArbre = new Map();
const emetteurs = new Map();
@ -150,15 +107,9 @@ function releverArbre() {
}
const tables = new Set(TABLES.flatMap((chemin) => codesDesTables(readFileSync(join(RACINE, chemin), 'utf8'))));
for (const code of tables) if (!emetteurs.has(code)) emetteurs.set(code, 'table de contrat');
const moteur = releverMoteur();
for (const [code, chemin] of moteur.emetteurs) if (!emetteurs.has(code)) emetteurs.set(code, chemin);
parArbre.set(MOTEUR, moteur.codes);
return { parArbre, tables, emetteurs };
}
// Tous les codes relevés : tables de contrat, arbres balayés et moteur.
const tousReleves = ({ parArbre, tables }) => new Set([...tables, ...[...parArbre.values()].flatMap((codes) => [...codes])]);
describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
test("le relevé lit le premier argument d'un appel, une clé code, un code de détail, et rien d'un commentaire", () => {
const source = [
@ -231,10 +182,9 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
assert.ok(tables.has('VERROU_PRIS') && tables.has('IDENTIFIANT_HORS_COMPTEUR'));
});
test('chaque code que src/stockage, src/csv, src/application et src/moteur émettent a son texte', () => {
const releve = releverArbre();
const { emetteurs } = releve;
const releves = tousReleves(releve);
test('chaque code que src/stockage, src/csv et src/application émettent a son texte', () => {
const { parArbre, tables, emetteurs } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
assert.ok(releves.size > 0, 'balayage vide');
const connus = new Set(CODES);
const sansTexte = [...releves].filter((code) => !connus.has(code)).sort();
@ -246,7 +196,8 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
});
test('chaque texte de la table a un émetteur : aucune entrée morte', () => {
const releves = tousReleves(releverArbre());
const { parArbre, tables } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
assert.ok(CODES.length > 0, 'table vide');
assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur');
});
@ -383,61 +334,6 @@ const EXEMPLES = new Map([
// Moteur
['PLACES_MANQUANTES', { placesManquantes: 3 }],
['SURRESERVATION', { table: 2, tour: 1, reservees: 9, capacite: 8, remede: null }],
['CAPACITE', { table: 2, capacite: 1 }],
['TOURS', { tours: 0 }],
['PARTICIPANT_DOUBLON', { participant: 4 }],
['TABLE_DOUBLON', { table: 3 }],
['RESERVATION_INCONNUE', { reservation: 0, participant: 42 }],
['RESERVATION_TOUR', { reservation: 2, tour: 5 }],
['RESERVATION_CONFLIT', { participant: 4, tour: 2, tables: [1, 3] }],
['ENUMERATION_TROP_GRANDE', { participant: 7, tablesAdmissibles: 40, toursLibres: 9, limite: 1000000 }],
['PLAN_TOURS', { attendu: 4, tours: 3, reserves: 3 }],
['PLAN_TABLE_INCONNUE', { table: 9 }],
['PLAN_TABLE_DOUBLON', { table: 2 }],
['PLAN_TABLE_ABSENTE', { table: 5 }],
['PLAN_LISTES', { tour: 2, listes: 3, tables: 4 }],
['PLAN_INCONNU', { participant: 42, tour: 1 }],
['PLAN_EXCLU_PLACE', { participant: 3, tour: 2 }],
['PLAN_DOUBLE_PLACE', { participant: 5, tour: 3 }],
['PLAN_NON_ASSIS', { participant: 6, tour: 4 }],
['TABLE_INCONNUE', { table: 9, detail: 'inconnue' }],
['DOUBLE_PLACE', { participant: 5, tour: 3 }],
['EXCLU_PLACE', { participant: 3, tour: 2 }],
['NON_ASSIS', { participant: 6, tour: 4 }],
['RESERVATION', { participant: 4, table: 5, tour: 1 }],
['PLAFOND_A_PRIORI', { index: 2, participant: 3 }],
['ORDRE_PLAFONDS', { index: 2, participant: 3 }],
['PLAFOND_REALISE', { index: 0, participant: 1 }],
['DEPASSEMENT', { index: 4, participant: 9 }],
// Génération
['GENERATION_EN_COURS', { remede: null }],
['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }],
['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
[
'CONFIRMATION_REQUISE',
{
estimation: {
secondes: 1200,
longue: true,
auDelaDuPlafond: false,
mesure: { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 },
},
remede: null,
},
],
['HORS_DOMAINE', { min: 1, max: null, remede: null }],
['REGLAGE_HORS_DOMAINE', { reglage: 'arret', remede: null }],
[
'GENERATION_INVARIANT_VIOLE',
{
rang: 2,
violations: [
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
{ code: 'CAPACITE', table: 5, tour: 2 },
],
remede: null,
},
],
// Refus et avertissements des commandes
['NOM_REQUIS', { remede: null }],
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
@ -503,35 +399,6 @@ const EXEMPLES = new Map([
['ENTREE_DEPLACER_DANS_RETENU', { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 }],
['ENTREE_ECHANGER_DANS_RETENU', { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 }],
['ENTREE_RETIRER_DU_TOUR', { personne: 'Iris Ombrelle', numero: 4, tour: 2 }],
// Mobilier : saisies, capacité, sièges retirés, libellés figés
['NON_ENTIER', { saisie: '8abc', remede: null }],
['MOINS_DE_DEUX', { remede: null }],
['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } }],
['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
[
'SIEGES_RETIRES',
{
table: 2,
sieges: [
{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' },
{ siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: true },
],
remede: { commande: 'retirerTitre', arguments: { table: 2, siege: 8 } },
},
],
['SIEGE_RESERVE', { siege: 7, participant: 1, suspendue: false }],
['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }],
['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }],
['AU_DELA_LISIBLE', { seuil: 16 }],
['ENTREE_CREER_TABLE', { numero: 3 }],
['ENTREE_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }],
['ENTREE_DEPLACER_TABLE', { numero: 3 }],
['ENTREE_CHANGER_PLACES_TABLE', { numero: 5, sieges: 10 }],
['ENTREE_REALIGNER_TABLE', { numero: 5, sieges: 8 }],
['ENTREE_CHANGER_DEFAUT', { sieges: 10, realignees: 4 }],
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
]);
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
@ -801,142 +668,3 @@ describe('libellés : les tables par domaine (§ 14.6)', () => {
assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
});
});
// --- Le moteur et la génération (A6) -------------------------------------------
describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7)', () => {
test('le relevé du moteur porte des codes, dans ses quatre modules à codes, et rien des valeurs de domine', () => {
const { codes, porteurs } = releverMoteur();
assert.ok(codes.size >= 20, `${codes.size} codes relevés`);
assert.deepEqual(porteurs, MODULES_A_CODES);
assert.ok(!codes.has('A') && !codes.has('B'));
for (const code of ['PLAN_EXCLU_PLACE', 'PLAN_INCONNU', 'ENUMERATION_TROP_GRANDE', 'PLACES_MANQUANTES', 'RESERVATION']) {
assert.ok(codes.has(code), code);
}
assert.deepEqual(litterauxEnCapitales("// 'DANS_UN_COMMENTAIRE'\nconst x = a ? 'PREMIER' : 'SECOND';"), ['PREMIER', 'SECOND']);
});
test("chaque code du moteur a sa clé, une seule ; un code que seul le moteur lève l'a dans libelles/moteur.js, aux exceptions nommées près", async () => {
const { codes } = releverMoteur();
const releve = releverArbre();
const ailleurs = new Set([...releve.tables, ...ARBRES.flatMap((arbre) => [...releve.parArbre.get(arbre)])]);
const { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.js');
const { TABLE: STOCKAGE_TABLE } = await import('./libelles/stockage.js');
// INCONNU est déjà la clé de la table d'A1, une faute de placement du
// stockage aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION sont
// dans libelles/moteur.js depuis la séance (A2), et ne s'y redéfinissent pas.
const EXCEPTIONS = new Map([['INCONNU', STOCKAGE_TABLE]]);
let comptes = 0;
for (const code of codes) {
comptes += 1;
const porteuses = TABLES_APPLICATION.filter((table) => table.has(code));
assert.equal(porteuses.length, 1, `${code} : ${porteuses.length} tables`);
if (EXCEPTIONS.has(code)) assert.equal(porteuses[0], EXCEPTIONS.get(code), code);
else if (!ailleurs.has(code)) assert.equal(porteuses[0], MOTEUR_TABLE, `${code} hors de libelles/moteur.js`);
}
assert.ok(comptes > 0);
for (const code of ['PLACES_MANQUANTES', 'SURRESERVATION']) assert.ok(MOTEUR_TABLE.has(code), code);
});
test('chaque critère de CRITERES a son libellé moteur.critere.<nom>', () => {
assert.ok(CRITERES.length > 0);
for (const critere of CRITERES) {
const texte = libelle(`moteur.critere.${critere}`);
assert.ok(texte.length > 0, critere);
for (const trace of TRACES) assert.doesNotMatch(texte, trace);
}
assert.equal(new Set(CRITERES.map((critere) => libelle(`moteur.critere.${critere}`))).size, CRITERES.length);
});
test("les tables de l'application et des écrans s'assemblent sans clé en double", () => {
assert.ok(TABLES_INTERFACE.length > 0);
assert.doesNotThrow(() => creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]));
});
test('CAPACITE et TOURS, levés sous deux formes, ont un seul texte qui les distingue par leurs détails', () => {
const configuration = libelle('CAPACITE', { table: 2, capacite: 1 });
const violation = libelle('CAPACITE', { table: 2, tour: 3 });
assert.notEqual(configuration, violation);
assert.match(configuration, /\b1\b/);
assert.match(violation, /tour 3/);
const tours = libelle('TOURS', { tours: 0 });
const toursViolation = libelle('TOURS', {});
assert.notEqual(tours, toursViolation);
for (const texte of [configuration, violation, tours, toursViolation]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte);
});
test("une violation d'indicateur nomme la personne quand l'appelant la traduit, son rang sinon ; un détail de table inconnu lève", () => {
assert.match(libelle('DEPASSEMENT', { index: 4, participant: 9 }), /n° 9/);
assert.match(libelle('DEPASSEMENT', { index: 4 }), /rang 5/);
for (const detail of ['inconnue', 'doublon', 'absente']) assert.match(libelle('TABLE_INCONNUE', { table: 9, detail }), /9/);
assert.match(libelle('TABLE_INCONNUE', { tour: 2, detail: 'listes' }), /tour 2/);
assert.throws(() => libelle('TABLE_INCONNUE', { table: 9, detail: 'inventee' }), RangeError);
});
test('chaque refus de valider et de generer a son texte, dans la table de son domaine', async () => {
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
// Les refus que lèvent les épreuves de valider et de generer
// (generation.test.js), et la table qui porte chacun. Ceux de la séance,
// du stockage, du mobilier et du moteur sont relayés tels quels ;
// GENERATION_INVARIANT_VIOLE, propre à la génération, se distingue
// d'INVARIANT_VIOLE, la violation du retenu, que la séance porte.
const REFUS = [
['NON_ENTIER', 'mobilier'],
['HORS_DOMAINE', 'generation'],
['AUCUN_EVENEMENT', 'seance'],
['LECTURE_SEULE', 'stockage'],
['MODE_LECTURE', 'seance'],
['SUPPORT_ROMPU', 'seance'],
['GENERATION_EN_COURS', 'generation'],
['AUCUN_PARTICIPANT', 'generation'],
['AUCUNE_TABLE', 'generation'],
['SURRESERVATION', 'moteur'],
['PLACES_MANQUANTES', 'moteur'],
['CONFIRMATION_REQUISE', 'generation'],
['REGLAGE_HORS_DOMAINE', 'generation'],
['GENERATION_INVARIANT_VIOLE', 'generation'],
['COMPTEUR_SATURE', 'stockage'],
];
assert.equal(REFUS.length, 15);
for (const [code, table] of REFUS) {
assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`);
assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code);
}
});
test("la confirmation d'une génération longue dit l'ordre de grandeur, la mesure, le plafond ; sans mesure, le seuil", () => {
const mesure = { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 };
const longue = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 1200, longue: true, auDelaDuPlafond: false, mesure }, remede: null });
assert.match(longue, /environ vingt minutes/);
assert.match(longue, /260 participants/);
assert.doesNotMatch(longue, /trente minutes/);
const plafond = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 7200, longue: true, auDelaDuPlafond: true, mesure }, remede: null });
assert.match(plafond, /trente minutes/);
const inconnue = libelle('CONFIRMATION_REQUISE', { estimation: { inconnue: true }, remede: null });
assert.match(inconnue, /dix fois le réglage par défaut/);
for (const texte of [longue, plafond, inconnue]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte);
});
test("une violation d'invariant rendue par la génération nomme son rang et chaque règle, sans retenu ni dérive", () => {
const details = {
rang: 2,
violations: [
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
{ code: 'CAPACITE', table: 5, tour: 2 },
],
remede: null,
};
const texte = libelle('GENERATION_INVARIANT_VIOLE', details);
assert.match(texte, /rang 2\b/);
assert.match(texte, /2 règles/);
assert.ok(texte.includes(libelle('RESERVATION', details.violations[0])), texte);
assert.ok(texte.includes(libelle('CAPACITE', details.violations[1])), texte);
assert.doesNotMatch(texte, /retenu|dérive|proposition n°/);
assert.match(libelle('GENERATION_INVARIANT_VIOLE', { ...details, violations: details.violations.slice(0, 1) }), /1 règle\b/);
});
test('un domaine borné nomme ses deux bornes, un domaine ouvert son minimum', () => {
assert.match(libelle('HORS_DOMAINE', { min: 0, max: 4294967295, remede: null }), /0 et 4\u{202F}294\u{202F}967\u{202F}295/u);
assert.match(libelle('HORS_DOMAINE', { min: 1, max: null, remede: null }), /au moins 1\b/);
});
});

View file

@ -2,39 +2,8 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes de la génération et des propositions (§ 5.7,
// § 14.6) : refus et libellés figés, puis les refus du contrôleur de
// génération et de la saisie de ses réglages (§ 5.9, § 5.10, § 15.4). Cette
// table ne porte que des codes. Un nombre et une durée se mettent en forme
// par creerFormat, sur la composition que la traduction passe à l'entrée.
import { creerFormat } from '../format.js';
import { cite, compte, phraseDeRaison } from './aides.js';
// Le nom de chaque réglage de génération, celui que la saisie affiche.
const NOMS_DES_REGLAGES = new Map([
['graine', 'Graine'],
['nombre', 'Propositions par génération'],
['arret', 'Mouvements par proposition'],
['historique', "Historique d'acceptation"],
]);
// L'estimation d'une génération longue, en mots : l'ordre de grandeur et la
// mesure dont il vient, ou le seuil d'une génération sans mesure.
function phraseEstimation(estimation, texte) {
if (estimation.inconnue === true) {
return (
"Aucune durée n'est encore mesurée sur ce poste dans cette séance, et ce réglage demande plus de " +
'dix fois le réglage par défaut'
);
}
const format = creerFormat(texte);
const { mesure } = estimation;
const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : '';
return (
`Ce réglage prendrait ${format.ordreDeGrandeur(estimation.secondes)}${plafond}, d'après le dernier calcul, ` +
`${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} mouvements ` +
`sur ${compte(mesure.participants, 'participant', 'participants')}`
);
}
// § 14.6) : refus et libellés figés. Cette table ne porte que des codes.
import { compte } from './aides.js';
export const TABLE = new Map([
// --- Les refus des propositions (§ 5.7) ------------------------------------
@ -54,45 +23,4 @@ export const TABLE = new Map([
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
// --- Le contrôleur de génération (§ 5.9, § 5.10, § 15.4) -------------------
['GENERATION_EN_COURS', () => "Une génération est déjà en cours : l'annuler, ou attendre qu'elle s'achève."],
[
'AUCUN_PARTICIPANT',
() => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.",
],
['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."],
[
'CONFIRMATION_REQUISE',
({ estimation }, texte) =>
`${phraseEstimation(estimation, texte)}. Confirmer « Générer » lance quand même la génération.`,
],
[
'REGLAGE_HORS_DOMAINE',
({ reglage }) =>
`Le réglage ${cite(NOMS_DES_REGLAGES.get(reglage) ?? String(reglage))} sort de son domaine : ` +
"la génération n'a pas eu lieu.",
],
// rang : la place du placement dans la suite des graines du lancement,
// jamais un numéro de proposition, puisque rien n'est enregistré ; chaque
// violation se dit par le texte de son code (libelles/moteur.js).
[
'GENERATION_INVARIANT_VIOLE',
({ rang, violations }, texte) =>
`Le placement que la génération a rendu au rang ${rang} de ce lancement enfreint ` +
`${compte(violations.length, 'règle', 'règles')} ; rien n'est enregistré. ` +
violations.map((violation) => phraseDeRaison(violation.code, violation, texte)).join(' '),
],
// --- La saisie des réglages (§ 5.7) -----------------------------------------
// max null : le domaine n'a pas de borne supérieure.
[
'HORS_DOMAINE',
({ min, max }, texte) => {
const { nombre } = creerFormat(texte);
return max === null || max === undefined
? `La valeur doit être d'au moins ${nombre(min)}.`
: `La valeur doit être comprise entre ${nombre(min)} et ${nombre(max)}.`;
},
],
]);

View file

@ -1,114 +1,5 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 14.6) : les refus d'une capacité et d'un nombre
// de tours, leurs avertissements, les causes d'un siège retiré et les
// libellés figés du journal. Les détails désignent tables et personnes par
// identifiant : le refus se pose sur la table qu'il concerne, et son texte ne
// les nomme pas. Un libellé figé reçoit le numéro de table que la commande
// lit dans la charge.
import { cite, compte, enumeration, nomDans } from './aides.js';
// Le nom de chaque réglage que modifierReglage change, tel que le journal le
// fige (§ 5.3, § 5.10).
const NOMS_DES_REGLAGES = new Map([
['separerAppartenances', 'Séparer les appartenances'],
['nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['nouvelleTable', 'Nouvelle table à chaque tour'],
['varierAppartenances', 'Varier les appartenances rencontrées'],
['attribuerSieges', 'Attribuer les sièges'],
['generation.nombre', 'Propositions par génération'],
['generation.arret', 'Mouvements par proposition'],
['generation.historique', "Historique d'acceptation"],
]);
// La valeur d'un réglage dans un libellé : activé ou désactivé pour un
// interrupteur, le nombre sinon.
const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur));
// Ce qu'une saisie refusée est : vide, ou un texte qui n'est pas un entier.
const saisieCitee = (saisie) => (String(saisie).trim() === '' ? 'La saisie est vide' : `${cite(String(saisie))} n'est pas un nombre entier`);
export const TABLE = new Map([
// --- Les saisies (§ 6.2) ---------------------------------------------------
[
'NON_ENTIER',
({ saisie }) => `${saisieCitee(saisie)} : un nombre entier, sans signe ni décimale, est attendu.`,
],
[
'MOINS_DE_DEUX',
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.",
],
['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
// --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
[
'SOUS_RESERVATIONS',
({ n, tour }) =>
`La table porte ${compte(n, 'réservation', 'réservations')} au tour ${tour} : ` +
`elle ne descend pas sous ${compte(n, 'place', 'places')}.`,
],
[
'SOUS_OCCUPES',
({ n, tour }) =>
`Au tour ${tour}, la table occupe ${compte(n, 'place', 'places')} : la poignée ne descend pas en deçà.`,
],
[
'SIEGES_RETIRES',
// Sièges non attribués (titres présent), un titre se compte : son siège
// est une place d'affichage, que l'opérateur n'a pas choisie.
({ sieges, titres }, texte) => {
const listes = titres === undefined ? sieges : sieges.filter(({ cause }) => cause !== 'SIEGE_TITRE');
const termes = listes.map((siege) => texte(siege.cause, siege));
if (titres !== undefined && titres > 0) termes.push(compte(titres, 'siège porte un titre', 'sièges portent un titre'));
return `Réduire la table retire des sièges qui portent une saisie : ${enumeration(termes)}.`;
},
],
[
'SIEGE_RESERVE',
({ siege, suspendue }) => `le siège ${siege} est réservé${suspendue ? ', par une réservation suspendue' : ''}`,
],
['SIEGE_TITRE', ({ siege, libelle }) => `le siège ${siege} porte le titre ${cite(libelle)}`],
// --- Les avertissements d'une capacité (§ 6.2) -----------------------------
[
'SURNUMERAIRES',
({ tour, personnes }) =>
`Au tour ${tour}, ${compte(personnes.length, 'personne passe', 'personnes passent')} dans la réserve : ` +
`la table n'a plus de chaise pour ${personnes.length <= 1 ? 'elle' : 'elles'}.`,
],
[
'AU_DELA_LISIBLE',
({ seuil }) => `Au-delà de ${seuil} places, la liste des noms de la table cesse d'être lisible sur le plan.`,
],
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
[
'ENTREE_SUPPRIMER_TABLE',
({ numero, reservations, titres }) => {
const retires = [];
if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations'));
if (titres > 0) retires.push(compte(titres, 'titre', 'titres'));
return `Supprimé la table ${numero}${retires.length > 0 ? ` ; retirés : ${enumeration(retires)}` : ''}`;
},
],
['ENTREE_DEPLACER_TABLE', ({ numero }) => `Déplacé la table ${numero}`],
['ENTREE_CHANGER_PLACES_TABLE', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`],
['ENTREE_REALIGNER_TABLE', ({ numero, sieges }) => `Table ${numero} : suit le défaut (${compte(sieges, 'place', 'places')})`],
[
'ENTREE_CHANGER_DEFAUT',
({ sieges, realignees }) =>
`Sièges par défaut : ${sieges}${realignees > 0 ? ` ; ${compte(realignees, 'table réalignée', 'tables réalignées')}` : ''}`,
],
[
'ENTREE_CHANGER_TOURS',
({ tours, retirees }) =>
`Nombre de tours : ${tours}${retirees > 0 ? ` ; ${compte(retirees, 'réservation retirée', 'réservations retirées')}` : ''}`,
],
[
'ENTREE_MODIFIER_REGLAGE',
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
],
]);
// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6).
export const TABLE = new Map();

View file

@ -2,33 +2,8 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
// libellés de ses critères (§ 5, § 14.6) : les codes d'une
// ErreurConfiguration, ceux des violations de verifierInvariants et de
// verifierIndicateurs, et moteur.critere.<nom> pour chaque nom de CRITERES.
// Un code garde un seul texte : CAPACITE et TOURS, que le moteur lève sous
// deux formes de détails — ErreurConfiguration et violation —, se
// distinguent par les détails qu'ils portent. Une réservation que le moteur
// désigne sans identifiant l'est par son rang à partir de 0, que le texte
// affiche à partir de 1 ; une personne qu'une violation d'indicateur désigne
// par son index se nomme par participant quand l'appelant l'a traduit, par
// son rang sinon. INCONNU, aux mêmes détails que la faute de placement du
// stockage, a son texte dans libelles/stockage.js.
import { capitale, compte, nomDans } from './aides.js';
// Les cas d'une table que le plan ne rattache pas à l'instance, par detail.
const CAS_DE_TABLE = new Map([
['inconnue', (table) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`],
['doublon', (table) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`],
['absente', (table) => `Le plan ne déclare pas la table d'identifiant ${table}.`],
]);
// La personne d'une violation d'indicateur : son numéro, ou son rang dans
// la population quand l'appelant ne l'a pas traduit.
const personneDe = ({ index, participant }) =>
participant === undefined || participant === null ? `la personne de rang ${index + 1}` : `la personne n° ${participant}`;
// Un rang de réservation, compté à partir de 0 par le moteur.
const reservationDe = (rang) => `la réservation n° ${rang + 1}`;
// libellés de ses critères (§ 5, § 14.6).
import { compte } from './aides.js';
export const TABLE = new Map([
// Une salle trop petite : avertissement d'un geste qui fait croître le
@ -45,110 +20,4 @@ export const TABLE = new Map([
`La table d'identifiant ${table} reçoit ${compte(reservees, 'personne réservée', 'personnes réservées')} ` +
`au tour ${tour}, pour ${compte(capacite, 'place', 'places')}.`,
],
// --- La configuration (normaliser, § 4, § 5.2) ------------------------------
// CAPACITE {table, capacite} : une capacité refusée ; {table, tour} : une
// liste du plan plus longue que sa table.
[
'CAPACITE',
({ table, capacite, tour }) =>
capacite === undefined
? `Au tour ${tour}, la table d'identifiant ${table} reçoit plus de personnes qu'elle n'a de places.`
: `La table d'identifiant ${table} a une capacité de ${capacite} : une table compte au moins 2 places.`,
],
// TOURS {tours} : un nombre de tours refusé ; {} : un plan qui n'a pas un
// tour par tour de l'événement.
[
'TOURS',
({ tours }) =>
tours === undefined
? "Le plan ne porte pas autant de tours que l'événement."
: `Le nombre de tours, ${tours}, n'est pas un entier d'au moins 1.`,
],
['PARTICIPANT_DOUBLON', ({ participant }) => `La personne n° ${participant} figure plus d'une fois dans la liste.`],
['TABLE_DOUBLON', ({ table }) => `La table d'identifiant ${table} figure plus d'une fois.`],
[
'RESERVATION_INCONNUE',
({ reservation, participant, table }) =>
participant === undefined
? `${capitale(reservationDe(reservation))} désigne la table d'identifiant ${table}, que l'événement n'a pas.`
: `${capitale(reservationDe(reservation))} désigne la personne n° ${participant}, que la liste n'a pas.`,
],
[
'RESERVATION_TOUR',
({ reservation, tour }) => `${capitale(reservationDe(reservation))} désigne le tour ${tour}, que l'événement n'a pas.`,
],
[
'RESERVATION_CONFLIT',
({ participant, tour, tables }) =>
`La personne n° ${participant} est réservée au tour ${tour} à deux tables, ` +
`d'identifiants ${tables[0]} et ${tables[1]}.`,
],
[
'ENUMERATION_TROP_GRANDE',
({ participant, limite }) =>
`Le plafond a priori de la personne n° ${participant} demanderait d'énumérer plus de ${limite} itinéraires.`,
],
// --- Le plan confronté à la configuration (indexerPlan) -------------------------
[
'PLAN_TOURS',
({ attendu, tours, reserves }) =>
`Le plan porte ${compte(tours, 'tour', 'tours')} et ${compte(reserves, 'réserve', 'réserves')}, ` +
`pour ${compte(attendu, 'tour', 'tours')} dans l'événement.`,
],
['PLAN_TABLE_INCONNUE', ({ table }) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`],
['PLAN_TABLE_DOUBLON', ({ table }) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`],
['PLAN_TABLE_ABSENTE', ({ table }) => `Le plan ne déclare pas la table d'identifiant ${table}.`],
[
'PLAN_LISTES',
({ tour, listes, tables }) =>
`Au tour ${tour}, le plan porte ${compte(listes, 'liste', 'listes')} pour ${compte(tables, 'table', 'tables')}.`,
],
['PLAN_INCONNU', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, que la liste n'a pas.`],
['PLAN_EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, qui est exclue.`],
['PLAN_DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place plus d'une fois la personne n° ${participant}.`],
['PLAN_NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, le plan ne place nulle part la personne n° ${participant}.`],
// --- Les violations de verifierInvariants (§ 14.12) -----------------------------
[
'TABLE_INCONNUE',
({ table, tour, detail }) =>
detail === 'listes'
? `Au tour ${tour}, le plan n'a pas une liste par table déclarée.`
: nomDans(CAS_DE_TABLE, detail, 'cas de table')(table),
],
['DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
['EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée alors qu'elle est exclue.`],
['NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} n'est assise à aucune table.`],
[
'RESERVATION',
({ participant, table, tour }) =>
`Au tour ${tour}, la personne n° ${participant}, réservée à la table d'identifiant ${table}, est placée ailleurs.`,
],
// --- Les violations de verifierIndicateurs (§ 12.3) -----------------------------
[
'PLAFOND_A_PRIORI',
(details) => `Le plafond a priori de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`,
],
[
'ORDRE_PLAFONDS',
(details) => `Le plafond réalisé de ${personneDe(details)} dépasse son plafond a priori : le calcul est faux.`,
],
[
'PLAFOND_REALISE',
(details) => `Le plafond réalisé de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`,
],
[
'DEPASSEMENT',
(details) => `Les rencontres de ${personneDe(details)} dépassent son plafond réalisé : le calcul est faux.`,
],
// --- Les critères du classement (§ 5.7) -----------------------------------------
['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'],
['moteur.critere.excedentCollisions', 'excédent de collisions'],
['moteur.critere.collisionsCumulees', 'collisions cumulées'],
['moteur.critere.rencontresRepetees', 'rencontres répétées'],
['moteur.critere.redondance', "redondance d'appartenance"],
]);

View file

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

View file

@ -88,7 +88,6 @@ 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';
@ -430,35 +429,21 @@ 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 qu'il reprend, rompt le support sous l'échec
// d'effacer celui du mort, rompt le support sous l'échec
// VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les
// avertissements.
//
// 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) {
async function prendreVerrou(ouvert) {
let verrou;
const avertissements = [];
try {
verrou = await verrouiller(ouvert, id);
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
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);
if (!verrou.pris && verrou.vivant === false) {
const mort = verrou;
await deverrouiller(ouvert, mort.seance);
verrou = await verrouiller(ouvert, id);
if (verrou.pris) {
avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis }));
avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis }));
}
}
} catch (erreur) {
@ -475,18 +460,6 @@ 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.
@ -938,33 +911,7 @@ export function creerSeance({
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
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);
const avertissements = await prendreVerrou(ouvert);
avertissements.push(...(await relireSousVerrou(ouvert)));
const relu = etat.ouvert;
let refus = null;
@ -972,7 +919,6 @@ 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();
@ -1224,14 +1170,6 @@ 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,165 +420,6 @@ 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();
@ -2509,7 +2350,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 ne le reprend seul : seul reprendreVerrou, que l'opérateur confirme, 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 de la séance ne le reprend", async () => {
const disque = new Map();
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
await enEcriture(premier);
@ -2524,6 +2365,7 @@ 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 () => {
@ -2799,7 +2641,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 la dit d'une autre séance, d'un poste ou d'un autre", () => {
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", () => {
const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]);
assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive);
assert.match(derive, /n° 2/);
@ -2807,11 +2649,7 @@ 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/);
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('VERROU_REPRIS', { seance: 's', depuis: null }), /interrompue/);
assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/);
});
});

View file

@ -83,12 +83,10 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'LARGEUR_BLOC_NOMS_CM',
valeur: 500,
valeur: 180,
unite: 'cm',
mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège",
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)',
raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)',
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', 500, 'cm', 'manuel'],
['LARGEUR_BLOC_NOMS_CM', 180, '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'), 500);
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180);
assert.equal(constante('HAUTEUR_LIGNE'), null);
assert.throws(() => constante('INCONNUE'), RangeError);
assert.throws(() => constante('nom'), RangeError);

View file

@ -1,76 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le panneau « À propos » (§ 18.6) : le bloc d'une ligne que l'opérateur
// copie dans un courriel, tel que blocAPropos l'a composé, et sa copie dans
// le presse-papiers de la plateforme. « Copier » remet exactement le texte
// affiché ; l'issue de la copie se lit sous les commandes.
import { getContext } from 'svelte';
const t = getContext('t');
let { bloc, pressePapiers, fermer } = $props();
// null avant toute copie, puis 'faite' ou 'refusee'.
let issue = $state(null);
async function copier() {
try {
await pressePapiers.copier(bloc);
issue = 'faite';
} catch {
issue = 'refusee';
}
}
</script>
<section class="apropos" data-ecran="apropos">
<h1>{t('coquille.apropos.titre')}</h1>
<p class="bloc" data-bloc>{bloc}</p>
<div class="commandes">
<button type="button" data-commande="copier" onclick={copier}>{t('coquille.apropos.copier')}</button>
<button type="button" data-commande="fermerApropos" onclick={fermer}>{t('coquille.apropos.fermer')}</button>
</div>
{#if issue === 'faite'}
<p role="status">{t('coquille.apropos.copie')}</p>
{:else if issue === 'refusee'}
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
{/if}
</section>
<style>
.bloc {
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
font-family: monospace;
user-select: all;
overflow-wrap: anywhere;
}
.commandes {
display: flex;
flex-wrap: wrap;
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;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.refus {
color: var(--couleur-refus);
}
</style>

View file

@ -1,40 +1,27 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La coquille de l'application dans un vrai moteur de rendu : le titre de la
// fenêtre et le bandeau (§ 8.4, § 18.6), « Liste des événements » (§ 8.1),
// le panneau « À propos », le réglage de thème (§ 19.2), le collecteur
// d'erreurs, l'injection des plateformes (§ 13.4), l'ordre du montage, et la
// fermeture sans question (§ 8.4, § 8.8). Chaque épreuve monte App par
// monterApp (test/navigateur/montage.js), sur le système de fichiers simulé.
//
// Le test interroge le rendu, jamais la présence d'une classe (§ 14.2) : la
// visibilité de chaque élément, la boîte où la mise en page le pose et
// l'élément qu'atteint le centre de cette boîte, le style calculé, le texte,
// le titre de la fenêtre. Il ne lit aucun pixel : le contraste se calcule sur
// les couleurs calculées, qui ne sont les couleurs peintes que sans opacité
// partielle, filtre ni mode de fusion — ce qu'une épreuve vérifie dans
// l'ascendance de chaque texte (§ 14.5). Les épreuves de visibilité, de
// contraste et d'opacité passent par les deux thèmes, que l'attribut
// data-theme de <html> choisit (§ 19.2). Les mesures viennent du module
// partagé test/navigateur/rendu.js, dont le contraste est celui de
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
// l'attribut data-theme de <html> choisit (§ 19.2). Les mesures viennent du
// module partagé test/navigateur/rendu.js, dont le contraste est celui de
// palette.js, seule formule des WCAG du dépôt.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
// expect, et prend test et describe chez Vitest plutôt que dans
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
import { LIVREES } from '../demo/livrees.js';
import { FORMAT } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { VERSION } from '../version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { monterApp } from '../../test/navigateur/montage.js';
import {
attenuations,
composantes,
@ -46,676 +33,47 @@ import {
sousTheme,
visible,
} from '../../test/navigateur/rendu.js';
import { blocAPropos } from './apropos.js';
import { TABLES_INTERFACE, libelleProvenance } from './libelles.js';
import { SECTION_OUVERTURE } from './evenement/sections.js';
import { cleDeMode } from './mode.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['clair', 'sombre'];
// Un événement d'épreuve : le fichier d'état d'une démonstration livrée, son
// nom remplacé par un nom inventé, au format donné.
const PETITE = LIVREES.find(({ cle }) => cle === 'petite').texte;
const CONFLIT = LIVREES.find(({ cle }) => cle === 'petite-conflit').texte;
function evenement(nom, { texte = PETITE, format = FORMAT } = {}) {
const renomme = texte.replace(/"nom":"[^"]*"/, `"nom":${JSON.stringify(nom)}`);
const auFormat = renomme.replace(`"format":${FORMAT}`, `"format":${format}`);
if (!renomme.includes(JSON.stringify(nom)) || !auFormat.includes(`"format":${format}`)) {
throw new Error("le fichier d'épreuve ne se compose pas");
}
return auFormat;
}
const fichier = (base, nom, options) => ({ racine: 'documents', chemin: `${base}.gtt.json`, texte: evenement(nom, options) });
const verrouDe = (m, base) => m.fs.contenu('documents', `${base}.gtt.verrou`);
let composant;
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
// n'a rien reçu pendant l'épreuve, sauf quand elle le veut.
let montes = [];
let erreursAdmises = false;
let erreursAvant = 0;
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
beforeEach(() => {
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
erreursAdmises = false;
});
afterEach(() => {
for (const m of montes.reverse()) m.demonter();
montes = [];
vi.restoreAllMocks();
const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? [];
if (!erreursAdmises) expect(recues, 'erreurs reçues par le collecteur').toEqual([]);
});
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const bandeau = (m) => dans(m, '[data-bandeau]');
const libelleDeMode = (m) => dans(m, '[data-mode-libelle="bandeau"]');
const ecran = (m) => dans(m, '[data-ecran]');
// Un montage dont l'événement « Bal des Grisailles » est ouvert, en lecture.
async function avecEvenementOuvert(options = {}) {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')], ...options });
await m.seance.ouvrir('bal');
composant = mount(App, { target: document.body });
flushSync();
return m;
});
afterEach(() => {
unmount(composant);
});
function bandeau() {
return document.body.querySelector('header.bandeau');
}
// Le bandeau, puis chacun de ses porteurs de texte.
const bandeauEtTextes = (m) => [{ element: bandeau(m), pseudo: null }, ...porteursDeTexte(bandeau(m))];
function bandeauEtTextes() {
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
}
describe('titre et bandeau (§ 8.4, § 18.6)', () => {
test('le titre de la fenêtre vaut exactement le nom suivi de la version affichée', async () => {
await monter();
expect(document.title).toBe(`Gestion table tournante Libre — ${VERSION.affichee}`);
describe("bandeau d'application", () => {
test('existe et porte la version affichée', () => {
expect(bandeau()).not.toBeNull();
expect(bandeau().textContent).toContain(VERSION.affichee);
});
test('le bandeau est visible, dans la fenêtre, et porte la version affichée et la provenance ; sans événement, aucun libellé de mode', async () => {
const m = await monter();
expect(visible(bandeau(m))).toBe(true);
expect(bandeau(m).textContent).toContain(VERSION.affichee);
expect(bandeau(m).textContent).toContain(libelleProvenance('test'));
expect(libelleDeMode(m)).toBeNull();
});
test('un événement en lecture, en écriture, puis bloqué : le libellé de mode de chaque état, de la clé que rend cleDeMode', async () => {
const m = await avecEvenementOuvert();
const attendus = [];
const relever = () => {
flushSync();
const cle = cleDeMode(m.seance.etat());
attendus.push(cle);
expect(visible(libelleDeMode(m))).toBe(true);
expect(libelleDeMode(m).textContent).toBe(t(cle));
};
relever();
await m.seance.passerEnEcriture();
relever();
await m.seance.executer('changerEtat', { etat: 'bloque' });
relever();
expect(attendus).toEqual(['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']);
});
test('le bandeau ne défile pas : la page défilée jusqu’en bas, il reste dans la fenêtre et son centre l’atteint', async () => {
const m = await monter();
const haut = document.createElement('div');
haut.style.height = '3000px';
ecran(m).append(haut);
window.scrollTo(0, document.documentElement.scrollHeight);
expect(window.scrollY).toBeGreaterThan(0);
const boite = bandeau(m).getBoundingClientRect();
expect(boite.top).toBeGreaterThanOrEqual(0);
expect(boite.bottom).toBeLessThanOrEqual(window.innerHeight);
const atteint = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
expect(bandeau(m).contains(atteint)).toBe(true);
window.scrollTo(0, 0);
});
test('construction de documentation : la marque remplace le numéro ; en livraison, le numéro', async () => {
const documentation = await monter({ provenance: 'documentation' });
expect(bandeau(documentation).textContent).not.toContain(VERSION.affichee);
expect(bandeau(documentation).textContent).toContain(t('coquille.version.documentation'));
const accueil = (m) => dans(m, '[data-ecran="accueil"]');
expect(accueil(documentation).textContent).not.toContain(VERSION.affichee);
expect(accueil(documentation).textContent).toContain(t('coquille.version.documentation'));
documentation.demonter();
montes = [];
const livraison = await monter({ provenance: 'livraison' });
expect(bandeau(livraison).textContent).toContain(VERSION.affichee);
expect(bandeau(livraison).textContent).toContain(libelleProvenance('livraison'));
expect(accueil(livraison).textContent).toContain(VERSION.affichee);
expect(accueil(livraison).textContent).not.toContain(t('coquille.version.documentation'));
});
});
describe('annonce de format (§ 8.8, § 18.6)', () => {
const annonce = (m) => dans(m, '[data-avertissement="annonceFormat"]');
test('un fichier au format FORMAT + 1 : « ce fichier s’ouvre en lecture seule » dans le bandeau', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles', { format: FORMAT + 1 })] });
await m.seance.ouvrir('bal');
flushSync();
expect(bandeau(m).contains(annonce(m))).toBe(true);
expect(annonce(m).textContent).toBe(t('coquille.format.plusRecent'));
expect(annonce(m).textContent).toMatch(/s.ouvre en lecture seule/);
});
test('au format courant : aucune annonce', async () => {
const m = await avecEvenementOuvert();
expect(annonce(m)).toBeNull();
});
test('formatCourant à FORMAT + 1, fichier au format FORMAT : « ce fichier se convertira… », et l’ouverture n’ajoute rien au relevé', async () => {
const m = await monter({ formatCourant: FORMAT + 1, fichiers: [fichier('bal', 'Bal des Grisailles')] });
const avant = m.trace.length;
await m.seance.ouvrir('bal');
flushSync();
expect(annonce(m).textContent).toBe(t('coquille.format.plusAncien'));
expect(annonce(m).textContent).toMatch(/se convertira à votre première modification/);
expect(m.trace.length).toBe(avant);
});
});
describe('« Liste des événements » (§ 8.1, § 8.4)', () => {
const commande = (m) => dans(m, '[data-commande="liste"]');
test('absente sans événement ouvert', async () => {
const m = await monter();
expect(commande(m)).toBeNull();
});
test('un événement en écriture, la commande cliquée : l’accueil revient, sans fenêtre ni question, le verrou rendu, aucun libellé de mode', async () => {
const fenetres = ['confirm', 'alert', 'prompt'].map((nom) => vi.spyOn(window, nom));
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
expect(verrouDe(m, 'bal')).not.toBeNull();
flushSync();
expect(visible(commande(m))).toBe(true);
commande(m).click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
flushSync();
expect(ecran(m).dataset.ecran).toBe('accueil');
expect(verrouDe(m, 'bal')).toBeNull();
expect(libelleDeMode(m)).toBeNull();
for (const fenetre of fenetres) expect(fenetre).not.toHaveBeenCalled();
});
test('sous un support rompu, la séance refuse de fermer : le refus s’affiche à côté de la commande, et l’événement reste ouvert', async () => {
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
m.fs.pannes.ecriture('documents');
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } })).rejects.toMatchObject({
code: 'ECRITURE',
});
expect(m.seance.etat().supportRompu).not.toBeNull();
const leve = await m.seance.fermer().then(
() => null,
(erreur) => erreur,
);
expect(leve?.code).toBe('SUPPORT_ROMPU_NON_SAUVE');
flushSync();
commande(m).click();
const refus = await vi.waitFor(() => {
const element = dans(m, '[data-refus="liste"]');
expect(element).not.toBeNull();
return element;
});
const remede = leve.details.remede;
expect(remede.geste).toBe('ecrireAilleurs');
const cleRemede = `communs.remede.ecrireAilleurs.${remede.racine ? 'documents' : 'choisi'}`;
expect(refus.textContent).toContain(t(leve.code, leve.details));
const commandeRemede = refus.querySelector('[data-commande="ecrireAilleurs"]');
expect(commandeRemede?.textContent).toBe(t(cleRemede));
expect(visible(refus)).toBe(true);
expect(visible(commandeRemede)).toBe(true);
// Posé dans le bandeau, le refus garde sa lisibilité : chaque texte tient
// 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes.
for (const theme of THEMES) {
await sousTheme(theme, () => {
for (const porteur of porteursDeTexte(refus)) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
for (const fond of fondPeintSous(porteur)) {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
});
}
expect(m.seance.etat().evenement?.base).toBe('bal');
expect(ecran(m).dataset.ecran).toBe('evenement');
});
});
describe('la liste relue', () => {
test('un événement chargé puis fermé : la liste revenue le porte', async () => {
const m = await monter();
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
const { base } = await m.facade.chargerDemonstration('petite');
flushSync();
expect(ecran(m).dataset.ecran).toBe('evenement');
dans(m, '[data-commande="liste"]').click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-evenement]').dataset.evenement).toBe(base);
});
test('une relecture refusée : le refus traduit tient lieu de liste, sans ligne d’avant ; la relecture suivante le retire', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1);
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
// L'ouverture relève aussi le dossier : le relevé ne se refuse qu'après
// elle.
await m.seance.ouvrir('bal');
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
const lister = m.fs.lister;
m.fs.lister = async () => {
throw refuse;
};
await m.seance.fermer();
const refus = await vi.waitFor(() => {
const element = dans(m, '[data-refus="accueil"]');
expect(element).not.toBeNull();
return element;
});
expect(refus.textContent).toBe(t(refuse.code, refuse.details));
expect(visible(refus)).toBe(true);
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
m.fs.lister = lister;
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
});
test('une détermination refusée : pret se résout, et l’accueil montre le refus traduit, sans ligne', async () => {
// Un disque dont le relevé d'un dossier se refuse : la détermination du
// dossier de travail, qui relève le dossier retenu, rejette.
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
class DisqueIllisible extends Map {
values() {
throw refuse;
}
}
const m = await monter({ systeme: { disque: new DisqueIllisible() } });
expect(ecran(m)?.dataset.ecran).toBe('accueil');
const refus = dans(m, '[data-refus="accueil"]');
expect(refus?.textContent).toBe(t(refuse.code, refuse.details));
expect(visible(refus)).toBe(true);
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
});
test('une détermination refusée au montage, puis réussie par un nouveau demarrer : la liste se lit, et le refus part', async () => {
// Un disque dont seul le premier relevé se refuse : la façade ne garde
// rien d'une détermination refusée, et le demarrer suivant la refait.
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
let releves = 0;
class DisqueUneFoisIllisible extends Map {
values() {
releves += 1;
if (releves === 1) throw refuse;
return super.values();
}
}
const m = await monter({ systeme: { disque: new DisqueUneFoisIllisible() } });
expect(dans(m, '[data-refus="accueil"]')?.textContent).toBe(t(refuse.code, refuse.details));
await m.facade.demarrer();
await vi.waitFor(() => expect(dans(m, '[data-refus="accueil"]')).toBeNull());
});
test('deux lectures qui se chevauchent : la plus ancienne, finie la dernière, ne remet pas sa liste', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1);
// La première lecture d'après la fermeture reste suspendue sur le
// verrou de « bal », après avoir relevé le dossier.
let liberer;
const barriere = new Promise((resoudre) => {
liberer = resoudre;
});
let suspendue = null;
const lireTexte = m.fs.lireTexte;
m.fs.lireTexte = (racine, chemin) => {
if (chemin === 'bal.gtt.verrou' && suspendue === null) {
suspendue = barriere.then(() => lireTexte.call(m.fs, racine, chemin));
return suspendue;
}
return lireTexte.call(m.fs, racine, chemin);
};
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(suspendue).not.toBeNull());
// Un second événement paraît ; une seconde fermeture relit la liste, qui
// le porte.
m.fs.deposer('documents', 'club.gtt.json', evenement('Club des Merles', { texte: CONFLIT }));
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2));
liberer();
await suspendue.catch(() => undefined);
// Le verrou est la dernière lecture de la liste suspendue : une image
// plus tard, elle a rendu ses lignes.
await new Promise((resoudre) => requestAnimationFrame(() => resoudre()));
flushSync();
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2);
});
});
describe('l’événement ouvert et sa navigation', () => {
test('chaque ouverture remet la navigation à son départ : la section d’ouverture, montrée', async () => {
const m = await avecEvenementOuvert();
expect(m.ui.etat().section).toBe(SECTION_OUVERTURE);
expect(dans(m, `[data-section="${SECTION_OUVERTURE}"]`)).not.toBeNull();
dans(m, '[data-commande="section"][data-cible="tables"]').click();
flushSync();
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
dans(m, '[data-commande="liste"]').click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
await m.seance.ouvrir('bal');
flushSync();
expect(m.ui.etat().section).toBe(SECTION_OUVERTURE);
expect(dans(m, '[data-section]').dataset.section).toBe(SECTION_OUVERTURE);
});
});
describe('« À propos » (§ 18.6)', () => {
const panneau = (m) => dans(m, '[data-ecran="apropos"]');
test('le panneau s’ouvre depuis le bandeau sur la liste puis sur un événement ; « Copier » remet exactement le bloc affiché', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
const attendu = blocAPropos({ version: VERSION, plateforme: m.fs.nature, provenance: 'test', format: FORMAT }, t);
dans(m, '[data-commande="apropos"]').click();
flushSync();
expect(visible(panneau(m))).toBe(true);
const bloc = dans(m, '[data-bloc]');
expect(bloc.textContent).toBe(attendu);
dans(m, '[data-commande="copier"]').click();
await vi.waitFor(() => expect(m.presse.copies).toEqual([bloc.textContent]));
dans(m, '[data-commande="fermerApropos"]').click();
flushSync();
expect(ecran(m).dataset.ecran).toBe('accueil');
await m.seance.ouvrir('bal');
flushSync();
expect(ecran(m).dataset.ecran).toBe('evenement');
dans(m, '[data-commande="apropos"]').click();
flushSync();
expect(visible(panneau(m))).toBe(true);
expect(dans(m, '[data-bloc]').textContent).toBe(attendu);
});
});
describe('thème (§ 19.2)', () => {
const preferer = (valeur) =>
cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: valeur }] });
const themePose = () => document.documentElement.getAttribute('data-theme');
test('(a) monté sans réglage, préférence sombre du système : thème sombre', async () => {
await preferer('dark');
try {
expect(window.matchMedia('(prefers-color-scheme: dark)').matches).toBe(true);
const m = await monter({ theme: null });
flushSync();
expect(themePose()).toBe('sombre');
expect(dans(m, '[data-commande="theme"][data-theme-choix="sombre"]').getAttribute('aria-pressed')).toBe('true');
} finally {
await preferer('');
}
});
test('(b) le réglage basculé à clair ne survit pas : un second montage sur le même disque repart de la préférence, et rien n’est écrit au stockage du navigateur', async () => {
const ecritures = vi.spyOn(Storage.prototype, 'setItem');
await preferer('dark');
try {
const premier = await monter({ theme: null });
dans(premier, '[data-commande="theme"][data-theme-choix="clair"]').click();
flushSync();
expect(themePose()).toBe('clair');
premier.demonter();
montes = [];
await monter({ theme: null, systeme: { disque: premier.disque } });
flushSync();
expect(themePose()).toBe('sombre');
expect(ecritures).not.toHaveBeenCalled();
} finally {
await preferer('');
}
});
test('(c) basculer le réglage change les couleurs calculées du corps et du bandeau, sans rechargement', async () => {
const m = await monter();
const couleurs = () =>
[document.body, bandeau(m)].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
});
const clair = couleurs();
const boutonSombre = dans(m, '[data-commande="theme"][data-theme-choix="sombre"]');
expect(boutonSombre.getAttribute('aria-pressed')).toBe('false');
boutonSombre.click();
flushSync();
expect(themePose()).toBe('sombre');
expect(boutonSombre.getAttribute('aria-pressed')).toBe('true');
const sombre = couleurs();
expect(sombre[0]).not.toBe(clair[0]);
expect(sombre[1]).not.toBe(clair[1]);
});
});
describe('collecteur d’erreurs (§ 19.2)', () => {
// Un cadre neuf dont la page charge le collecteur, puis le contenu donné.
async function cadre(contenu) {
const element = document.createElement('iframe');
const charge = new Promise((resoudre) => element.addEventListener('load', resoudre, { once: true }));
element.srcdoc = `<!doctype html><html><head><script>${COLLECTEUR}</` + `script>${contenu}</head><body><button id="b"></button></body></html>`;
document.body.append(element);
await charge;
return element;
}
// Exécute du code dans la page du cadre, par un script classique inséré.
function executer(element, code) {
const doc = element.contentDocument;
const script = doc.createElement('script');
script.textContent = code;
doc.body.append(script);
}
test('une erreur à l’évaluation d’un module chargé après lui est au tableau', async () => {
const element = await cadre(`<script type="module">throw new Error('évaluation du module');</` + 'script>');
try {
const erreurs = element.contentWindow.__gttErreurs;
expect(erreurs.map(({ type }) => type)).toEqual(['error']);
expect(erreurs[0].message).toContain('évaluation du module');
} finally {
element.remove();
}
});
test('une erreur levée dans un gestionnaire, une promesse rejetée sans gestionnaire, un console.error : trois entrées, dans cet ordre', async () => {
const element = await cadre('');
try {
const erreurs = element.contentWindow.__gttErreurs;
expect(erreurs).toEqual([]);
executer(element, "document.getElementById('b').addEventListener('click', () => { throw new Error('gestionnaire'); }); document.getElementById('b').click();");
expect(erreurs.length).toBe(1);
executer(element, "Promise.reject(new Error('rejet'));");
await vi.waitFor(() => expect(erreurs.length).toBe(2));
executer(element, "console.error('console', 1);");
expect(erreurs.map(({ type }) => type)).toEqual(['error', 'unhandledrejection', 'console.error']);
expect(erreurs.map(({ message }) => message)).toEqual([
expect.stringContaining('gestionnaire'),
expect.stringContaining('rejet'),
'console 1',
]);
} finally {
element.remove();
}
});
test('sous monterApp, un console.error paraît dans erreurs()', async () => {
erreursAdmises = true;
const m = await monter();
expect(m.erreurs()).toEqual([]);
console.error('épreuve du collecteur');
expect(m.erreurs()).toEqual([{ type: 'console.error', message: 'épreuve du collecteur' }]);
});
});
describe('injection des plateformes (§ 13.4)', () => {
test('deux événements au dossier : ni OPFS ni pont, et le montage n’écrit que la sonde du dossier retenu', async () => {
const opfs = vi.spyOn(navigator.storage, 'getDirectory');
const acces = [];
const pont = new Proxy({}, {
get(_cible, cle) {
acces.push(String(cle));
return undefined;
},
});
window.gtt = pont;
try {
const m = await monter({
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
});
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2);
expect(opfs).not.toHaveBeenCalled();
expect(acces).toEqual([]);
expect(m.trace).toEqual([['sonder', 'documents', '']]);
} finally {
delete window.gtt;
}
});
test('deux montages simultanés, chacun dans son conteneur, ne se mêlent pas', async () => {
const a = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
const b = await monter({ fichiers: [fichier('club', 'Club des Merles', { texte: CONFLIT })] });
expect(a.racine).not.toBe(b.racine);
expect(a.fs).not.toBe(b.fs);
expect(a.seance).not.toBe(b.seance);
expect(a.racine.textContent).toContain('Bal des Grisailles');
expect(b.racine.textContent).toContain('Club des Merles');
expect(a.racine.textContent).not.toContain('Club des Merles');
expect(b.racine.textContent).not.toContain('Bal des Grisailles');
await a.seance.ouvrir('bal');
flushSync();
expect(libelleDeMode(a)).not.toBeNull();
expect(libelleDeMode(b)).toBeNull();
expect(b.seance.etat().evenement).toBeNull();
});
});
describe('le montage d’épreuve', () => {
test('aImporter : le dialogue rend chaque fichier, dans l’ordre, puis null ; les identifiants portent le rang du montage, distincts d’un montage à l’autre', async () => {
const premier = { nom: 'invites.csv', octets: new Uint8Array([0x6e, 0x6f, 0x6d]) };
const second = { nom: 'accueil.csv', octets: new Uint8Array([0x41]) };
const a = await monter({ aImporter: [premier, second] });
expect(await a.facade.choisirCsv()).toBe(premier);
expect(await a.facade.choisirCsv()).toBe(second);
expect(await a.facade.choisirCsv()).toBeNull();
const b = await monter();
expect(await b.facade.choisirCsv()).toBeNull();
expect(a.seance.id).toMatch(/^epreuve-\d+-\d+$/);
expect(b.seance.id).toMatch(/^epreuve-\d+-\d+$/);
expect(a.seance.id.split('-')[1]).not.toBe(b.seance.id.split('-')[1]);
});
});
describe('l’ordre du montage', () => {
test('non publié, Documents non inscriptible : pret se résout, la façade rend sa question, la séance n’a pas de racine', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } } });
// Lu dès pret, avant tout appel de l'épreuve à la façade : la
// détermination est celle qu'App a faite.
expect(m.trace).toContainEqual(['sonder', 'documents', '']);
expect(m.seance.etat().racine).toBeNull();
expect(ecran(m)).not.toBeNull();
const { dossier } = await m.facade.demarrer();
expect(dossier.question?.code).toBe('DOCUMENTS_NON_INSCRIPTIBLE');
expect(m.seance.etat().racine).toBeNull();
});
test('sans question : dès pret, la racine de la séance est celle du dossier retenu', async () => {
const m = await monter();
// Lu dès pret, avant tout appel de l'épreuve à la façade : la racine
// est celle qu'App a fait poser.
expect(m.trace).toEqual([['sonder', 'documents', '']]);
const posee = m.seance.etat().racine;
expect(posee?.id).toBe('documents');
const { dossier } = await m.facade.demarrer();
expect(dossier.question).toBeNull();
expect(m.seance.etat().racine).toEqual(dossier.racine);
expect(m.seance.etat().racine).toBe(posee);
});
test('sans question : dès pret, le premier écran porte la liste du dossier retenu', async () => {
// Lu aussitôt après pret, sans attente : pret ne se résout qu'une fois
// les lignes de la liste rendues.
const m = await monter({
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
});
const lignes = [...m.racine.querySelectorAll('[data-evenement]')];
expect(lignes.length).toBe(2);
expect(lignes.map((ligne) => ligne.dataset.evenement).sort()).toEqual(['bal', 'club']);
expect(m.racine.textContent).toContain('Bal des Grisailles');
expect(m.racine.textContent).toContain('Club des Merles');
});
test('Documents non inscriptible : dès pret, l’accueil est rendu sans aucune ligne, la question attend', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(ecran(m)?.dataset.ecran).toBe('accueil');
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
expect(m.racine.querySelectorAll('[data-demonstration]').length).toBeGreaterThan(0);
// Une lecture de la liste pendant que la question attend serait refusée
// (QUESTION_EN_ATTENTE), et ce refus s'afficherait à la place des lignes.
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
});
test('Documents non inscriptible, « continuer » répondu : la liste est lue après la réponse, ses lignes rendues, sans refus', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
// Chaque lecture de la liste passe par la façade de ce montage ; une
// lecture faite pendant que la question attend se voit à son refus.
const issues = [];
const lister = m.facade.lister;
vi.spyOn(m.facade, 'lister').mockImplementation(async () => {
try {
const lues = await lister();
issues.push('lue');
return lues;
} catch (erreur) {
issues.push(erreur?.code ?? 'faute');
throw erreur;
}
});
await m.facade.repondre('continuer');
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-evenement]').dataset.evenement).toBe('bal');
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
expect(issues).not.toContain('QUESTION_EN_ATTENTE');
expect(issues).toContain('lue');
});
});
describe('fermer sans question (§ 8.1, § 8.4, § 8.8)', () => {
test('aucun écouteur beforeunload ; au pagehide, la séance se ferme et rend son verrou', async () => {
const ecouteurs = vi.spyOn(window, 'addEventListener');
const m = await avecEvenementOuvert();
const poses = ecouteurs.mock.calls.map(([type]) => type);
expect(poses).toContain('pagehide');
expect(poses).not.toContain('beforeunload');
await m.seance.passerEnEcriture();
expect(verrouDe(m, 'bal')).not.toBeNull();
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false }));
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
expect(verrouDe(m, 'bal')).toBeNull();
});
});
describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
// Un événement ouvert en écriture : le bandeau porte alors tout ce qu'il
// peut porter — libellé de mode, « Liste des événements », thèmes, « À
// propos ».
async function bandeauComplet() {
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
flushSync();
expect(libelleDeMode(m)).not.toBeNull();
return m;
}
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre', async () => {
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
// Le texte, le titre et le style calculé se relisent sur un élément qui
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
// échoue.
const m = await bandeauComplet();
for (const theme of THEMES) {
await sousTheme(theme, () => {
const porteurs = bandeauEtTextes(m);
expect(porteurs.length).toBeGreaterThan(4);
const porteurs = bandeauEtTextes();
expect(porteurs.length).toBeGreaterThan(1);
for (const porteur of porteurs) {
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
}
@ -723,20 +81,29 @@ describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
}
});
test('le texte et le fond du bandeau ont une couleur calculée', async () => {
const m = await bandeauComplet();
const style = getComputedStyle(bandeau(m));
test('porte la provenance de la construction', () => {
expect(import.meta.env.MODE).toBe('test');
expect(bandeau().textContent).toContain('épreuve');
});
test('le titre de la fenêtre porte le nom et la version affichée', () => {
expect(document.title).toBe(
`Gestion table tournante Libre — ${VERSION.affichee}`,
);
});
test('le texte et le fond du bandeau ont une couleur calculée', () => {
const style = getComputedStyle(bandeau());
expect(style.color).not.toBe(TRANSPARENT);
expect(style.backgroundColor).not.toBe(TRANSPARENT);
});
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui', async () => {
const m = await bandeauComplet();
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes (§ 14.5)', async () => {
const couleursParTheme = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const porteur of textes) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
@ -747,7 +114,7 @@ describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
return [document.body, bandeau(m)].map((el) => {
return [document.body, bandeau()].map((el) => {
const style = getComputedStyle(el);
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
});
@ -762,15 +129,14 @@ describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
).not.toEqual(couleursParTheme[0]);
});
test('aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte', async () => {
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
// le pixel peint sans changer le style calculé du texte. attenuations
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
const m = await bandeauComplet();
for (const theme of THEMES) {
await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const porteur of textes) {
const fautes = attenuations(porteur).map(
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,

View file

@ -2,276 +2,27 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
// l'application — système de fichiers, horloge, exécuteur de génération,
// presse-papiers, identifiants, langue, provenance, démonstrations, format
// courant, version du produit, et en option le thème de départ — que
// src/main.js construit pour la machine et monterApp pour une épreuve : App
// ne choisit aucune plateforme.
//
// À son initialisation, App crée une fois chacun la séance, sans racine,
// le contrôleur de génération et la façade, et les donne aux composants par
// le contexte de Svelte — clés 'seance', 'facade', 'generation', 'ui', 't',
// 'format', 'horloge' et 'relireListe' —, posé de façon synchrone avant que
// la détermination du dossier de travail, asynchrone, ne rende. La façade
// pose la racine de la séance par changerRacine : aussitôt sans question,
// après la réponse sinon. App lit la liste du dossier de travail par la
// façade et la donne à l'accueil, une fois la détermination réglée — sans
// question au démarrage, après la réponse sinon : pret se résout quand la
// détermination est faite, la liste lue quand aucune question n'attend, et
// le premier écran — les lignes de la liste, ou la question — rendu.
//
// L'écran suit la séance : la liste sans événement ouvert, l'événement
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque
// ouverture d'un événement remet l'état de navigation à son départ. Le
// bandeau, présent sur tout écran, ne défile pas : un écran plus haut que
// la fenêtre défile sous lui.
//
// Le thème part de la propriété themeInitial, ou de la préférence du
// système lue au montage ; le réglage de l'opérateur le change pour la
// séance, en mémoire, posé en data-theme sur <html> (§ 19.2). À la sortie
// de la page (pagehide), la séance ferme l'événement et rend son verrou,
// sans question ; aucun beforeunload ne retient la page (§ 8.4).
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
// porte le nom, la version affichée et la provenance de la construction ;
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
// commande de construction.
import './jetons.css';
import { onDestroy, setContext, tick, untrack } from 'svelte';
import { creerFacade } from '../application/evenements.js';
import { creerFormat } from '../application/format.js';
import { creerControleurGeneration } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerSeance } from '../application/seance.js';
import { creerTraduction } from '../application/traduction.js';
import { VERSION } from '../version.genere.js';
import APropos from './APropos.svelte';
import Accueil from './accueil/Accueil.svelte';
import Bandeau from './Bandeau.svelte';
import Evenement from './evenement/Evenement.svelte';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { creerEtatUi } from './etat_ui.js';
import { TABLES_INTERFACE } from './libelles.js';
import { themeInitial as themeDuSysteme } from './theme.js';
import { libelleProvenance, titreAvecVersion } from './libelles.js';
// Les dépendances se lisent une fois, au montage : App crée ses services
// une seule fois, et un changement de propriété ne les recrée pas.
const dependances = $props();
const {
fs,
horloge,
executeur,
pressePapiers,
identifiant,
langue = 'fr',
provenance,
demonstrations,
formatCourant,
produitVersion,
themeInitial,
} = untrack(() => dependances);
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
const format = creerFormat(t);
const seanceApp = creerSeance({
fs,
horloge,
identifiant,
produitVersion,
planifier: horloge.planifier,
annuler: horloge.annuler,
formatCourant,
});
const generationApp = creerControleurGeneration({ seance: seanceApp, executeur, horloge, produitVersion });
const facadeApp = creerFacade({
fs,
horloge,
identifiant,
produitVersion,
demonstrations,
ouvert: () => seanceApp.etat().evenement?.base ?? null,
ouvrir: (base) => seanceApp.ouvrir(base),
fermer: () => seanceApp.fermer(),
creer: (champs) => seanceApp.creer(champs),
racine: () => seanceApp.etat().racine,
changerRacine: (racine) => seanceApp.changerRacine(racine),
});
// La façade que reçoivent les composants, et que rend l'export facade :
// celle de creerFacade, dont demarrer et repondre relisent la liste une
// fois la détermination réglée sans question. La façade appelle
// changerRacine avant de retirer sa question, si bien qu'une lecture
// lancée de ce rappel serait refusée (QUESTION_EN_ATTENTE). Une
// détermination refusée ne laisse rien : le demarrer suivant la refait, et
// relit alors la liste.
const facadeContexte = {
...facadeApp,
demarrer: async () => {
const issue = await facadeApp.demarrer();
if (issue.dossier.question === null) await relireListe();
return issue;
},
repondre: async (choix) => {
const issue = await facadeApp.repondre(choix);
if (issue.dossier.question === null) await relireListe();
return issue;
},
};
const uiApp = creerEtatUi();
setContext('seance', seanceApp);
setContext('facade', facadeContexte);
setContext('relireListe', () => relireListe());
setContext('generation', generationApp);
setContext('ui', uiApp);
setContext('t', t);
setContext('format', format);
setContext('horloge', horloge);
// Un refus de la séance ou de la façade porte un code, que l'écran
// traduit ; toute autre erreur est une faute, qui remonte.
const estRefus = (erreur) => typeof erreur?.code === 'string';
const refusDe = (erreur) => ({ code: erreur.code, details: erreur.details });
// La liste du dossier de travail, que l'accueil montre, et le refus qui en
// tient lieu quand la façade refuse de la lire : une liste refusée ne
// montre plus les lignes d'une lecture d'avant. lecture est la promesse de
// la dernière lecture lancée, que preparer attend ; quand deux lectures se
// chevauchent, seule la dernière pose ce qu'elle lit. La lecture passe par
// facadeContexte, la façade que voient les composants et les épreuves.
let lignes = $state.raw([]);
let refusLecture = $state.raw(null);
let lecture = Promise.resolve();
let lectures = 0;
async function lireListe() {
lectures += 1;
const rang = lectures;
try {
const lues = await facadeContexte.lister();
if (rang !== lectures) return;
lignes = lues;
refusLecture = null;
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
if (rang !== lectures) return;
lignes = [];
refusLecture = refusDe(erreur);
}
}
// Lance une lecture de la liste, qui devient la dernière, et rend sa
// promesse. L'accueil l'appelle par le contexte après toute opération qui
// change le dossier de travail.
function relireListe() {
lecture = lireListe();
return lecture;
}
// L'état observable de la séance, et la base de l'événement ouvert : une
// base qui change vers un événement remet la navigation à son départ. Le
// refus de « Liste des événements » tient jusqu'au changement suivant.
// La liste se relit à chaque fermeture d'un événement, qui a pu en créer,
// en changer ou écrire ailleurs ; elle se lit aussi quand la détermination
// se règle — preparer sans question, repondre de facadeContexte après la
// réponse.
let etatSeance = $state.raw(seanceApp.etat());
let refusListe = $state.raw(null);
let baseOuverte = null;
const desabonner = seanceApp.subscribe((etat) => {
const base = etat.evenement?.base ?? null;
if (base !== null && base !== baseOuverte) uiApp.reinitialiser();
const fermeture = baseOuverte !== null && base === null;
baseOuverte = base;
etatSeance = etat;
refusListe = null;
if (fermeture) relireListe();
});
onDestroy(desabonner);
let theme = $state(themeInitial ?? themeDuSysteme(window.matchMedia('(prefers-color-scheme: dark)').matches));
$effect(() => {
document.documentElement.setAttribute('data-theme', theme);
});
const titre = t('coquille.titre', { version: produitVersion });
const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, t);
const bloc = blocAPropos({ version: VERSION, plateforme: fs.nature, provenance, format: formatCourant }, t);
// « Liste des événements » : ferme l'événement, sans question ; sous un
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
// commande.
async function revenirALaListe() {
try {
await seanceApp.fermer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusListe = refusDe(erreur);
}
}
// La page se retire : la séance ferme l'événement et rend son verrou. Sous
// un support rompu, elle le refuse et rien ne s'écrit.
function quitterLaPage() {
seanceApp.fermer().catch((erreur) => {
if (!estRefus(erreur)) throw erreur;
});
}
// La détermination faite sans question, la liste du dossier retenu se
// lit ; une question laisse l'accueil sans lignes, jusqu'à la réponse. Un
// refus de la détermination s'affiche à la place de la liste.
async function preparer() {
try {
await facadeContexte.demarrer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusLecture = refusDe(erreur);
}
await lecture;
await tick();
}
/** Résolue quand la détermination du dossier de travail est faite et le
* premier écran rendu : les lignes de la liste lue, ou la question. */
export const pret = preparer();
/** La séance de ce montage. */
export function seance() {
return seanceApp;
}
/** Le contrôleur de génération de ce montage. */
export function generation() {
return generationApp;
}
/** La façade des événements de ce montage. */
export const facade = facadeContexte;
/** L'état de navigation de ce montage. */
export const ui = uiApp;
const titre = titreAvecVersion(VERSION.affichee);
const provenance = libelleProvenance(import.meta.env.MODE);
</script>
<svelte:window onpagehide={quitterLaPage} />
<svelte:head>
<title>{titre}</title>
</svelte:head>
<div class="app">
<Bandeau
etat={etatSeance}
{version}
provenance={provenanceAffichee}
{theme}
{refusListe}
choisirTheme={(choix) => (theme = choix)}
ouvrirApropos={() => uiApp.ouvrirApropos()}
{revenirALaListe}
/>
<main class="ecran">
{#if $uiApp.apropos}
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
{:else if etatSeance.evenement === null}
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {refusLecture} />
{:else}
<Evenement />
{/if}
</main>
</div>
<header class="bandeau">
<span class="titre">{titre}</span>
<span class="provenance">{provenance}</span>
</header>
<style>
:global(body) {
@ -281,14 +32,21 @@
font-family: var(--police-interface);
}
.app {
.bandeau {
display: flex;
flex-direction: column;
min-height: 100vh;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font-size: var(--taille-texte-bandeau);
}
.ecran {
flex: 1;
padding: var(--espace-4);
.provenance {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
}
</style>

View file

@ -1,151 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le bandeau d'application, présent sur tout écran (§ 8.4, § 18.6) : le
// nom, la version — ou la marque de provenance d'une construction de
// documentation —, la provenance, et, quand un événement est ouvert, le
// libellé de mode, l'annonce de format du fichier et la commande « Liste
// des événements ». Il porte aussi le réglage de thème, deux commandes à
// aria-pressed, et l'ouverture du panneau « À propos ». Collé en haut de la
// fenêtre, il ne défile pas.
//
// Le bandeau ne décide de rien : App lui donne l'état de la séance, la
// version et la provenance telles qu'elles s'affichent, le thème et les
// 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. 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';
import { THEMES, cleDeTheme } from './theme.js';
const t = getContext('t');
let { etat, version, provenance, theme, refusListe, choisirTheme, ouvrirApropos, revenirALaListe } = $props();
const cleMode = $derived(cleDeMode(etat));
const cleAnnonce = $derived(cleAnnonceFormat(etat));
</script>
<header class="bandeau" data-bandeau>
<span class="nom">{t('coquille.nom')}</span>
<span class="version">{version}</span>
<span class="provenance">{provenance}</span>
{#if cleMode !== null}
<span class="mode" data-mode-libelle="bandeau">{t(cleMode)}</span>
{/if}
{#if cleAnnonce !== null}
<span class="annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
{/if}
<span class="commandes">
{#if etat.evenement !== null}
<button type="button" data-commande="liste" onclick={revenirALaListe}>{t('coquille.liste')}</button>
{#if refusListe !== null}
<div class="refus-liste" data-refus="liste">
<Refus refus={refusListe} />
</div>
{/if}
{/if}
<span class="themes" role="group" aria-label={t('coquille.theme.titre')}>
{#each THEMES as choix (choix)}
<button
type="button"
data-commande="theme"
data-theme-choix={choix}
aria-pressed={theme === choix}
onclick={() => choisirTheme(choix)}>{t(cleDeTheme(choix))}</button
>
{/each}
</span>
<button type="button" data-commande="apropos" onclick={ouvrirApropos}>{t('coquille.apropos.titre')}</button>
</span>
</header>
<style>
.bandeau {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2) var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font-size: var(--taille-texte-bandeau);
}
.provenance,
.mode,
.annonce {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
}
.mode {
font-weight: bold;
}
/* 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. 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);
}
.commandes,
.themes {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2);
}
.commandes {
position: relative;
margin-left: auto;
}
button {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font: inherit;
cursor: pointer;
}
/* Le thème retenu se distingue par sa forme autant que par ses couleurs :
texte souligné et gras, couleurs du bandeau inversées. */
button[aria-pressed='true'] {
background-color: var(--couleur-bandeau-texte);
color: var(--couleur-bandeau-fond);
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,350 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// 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();
// Vrai au premier accueil de la séance : ses avis s'y disent.
const annoncer = premiereAnnonce(facade);
const uid = $props.id();
// 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));
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 {
const lue = await facade.corbeille();
if (!detruit) corbeille = lue;
} catch (erreur) {
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>
{#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>
.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);
}
.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 {
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

@ -1,71 +0,0 @@
<!-- © 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

@ -1,84 +0,0 @@
<!-- © 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

@ -1,140 +0,0 @@
<!-- © 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

@ -1,158 +0,0 @@
<!-- © 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

@ -1,152 +0,0 @@
<!-- © 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

@ -1,81 +0,0 @@
<!-- © 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

@ -1,272 +0,0 @@
// © 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

@ -1,303 +0,0 @@
// © 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

@ -1,61 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le panneau « À propos » et la version à l'écran (§ 18.6). Le bloc tient en
// une ligne, que l'opérateur copie dans un courriel : il porte les deux
// formes de la version, l'affichée et celle que montrent les propriétés du
// fichier sous Windows, que scripts/version.js engendre. Sa composition est
// une seule clé de la table de la coquille : la langue témoin l'encadre une
// fois, en entier.
import { aUnLibelle } from './libelles.js';
/**
* Bloc d'une ligne du panneau « À propos » (§ 18.6) : nom, version affichée,
* version Windows, plateforme, provenance, version de format, séparés par
* « · ». La provenance s'y lit sous son libellé, ou sous son propre nom
* quand le mode n'en a pas ; la plateforme sous sa nature.
*
* @param {Object} champs
* @param {{affichee: string, windows: string}} champs.version VERSION
* @param {string} champs.plateforme la nature du système de fichiers
* @param {string} champs.provenance le mode de construction de Vite
* @param {number} champs.format la version de format des fichiers
* @param {(cle: string, details?: Object) => string} t
* @returns {string} sans saut de ligne
*/
export function blocAPropos({ version, plateforme, provenance, format }, t) {
return t('coquille.apropos.bloc', {
affichee: version.affichee,
windows: version.windows,
plateforme,
provenance,
format,
});
}
/**
* Ce qui tient lieu de version à l'écran : la marque de provenance dans une
* construction de documentation, dont les captures d'écran ne changent pas
* d'une livraison à l'autre (§ 19.4) ; la version affichée sinon.
*
* @param {string} provenance le mode de construction de Vite
* @param {string} version la version affichée
* @param {(cle: string) => string} t
* @returns {string}
*/
export function versionVisible(provenance, version, t) {
return provenance === 'documentation' ? t('coquille.version.documentation') : version;
}
/**
* La provenance à l'écran : le libellé du mode de construction par la
* traduction reçue, ou le mode lui-même quand il n'en a pas — celui d'un
* « vite build » lancé sans --mode —, qui s'affiche alors comme une donnée.
*
* @param {string} provenance le mode de construction de Vite
* @param {(cle: string) => string} t
* @returns {string}
*/
export function provenanceVisible(provenance, t) {
return aUnLibelle(provenance) ? t(`coquille.provenance.${provenance}`) : provenance;
}

View file

@ -1,89 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le bloc « À propos » (§ 18.6) : une ligne que l'opérateur copie dans un
// courriel — nom, version affichée, version Windows, plateforme, provenance,
// version de format —, et ce qui tient lieu de version à l'écran, la marque
// de provenance dans une construction de documentation (§ 19.4).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
import { FORMAT } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { TABLES_INTERFACE, libelleProvenance } from './libelles.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const temoin = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }).t;
describe('blocAPropos (§ 18.6)', () => {
test('une ligne : nom, version affichée, version Windows, plateforme, provenance, format, séparés par « · »', () => {
assert.match(VERSION.windows ?? '', /^\d+\.\d+\.\d+\.0$/, 'VERSION.windows engendré');
const bloc = blocAPropos({ version: VERSION, plateforme: 'electron', provenance: 'livraison', format: FORMAT }, t);
assert.equal(typeof bloc, 'string');
assert.doesNotMatch(bloc, /[\n\r\u{2028}\u{2029}]/u);
assert.deepEqual(bloc.split(' \u{00B7} '), [
t('coquille.nom'),
VERSION.affichee,
VERSION.windows,
'electron',
libelleProvenance('livraison'),
t('coquille.apropos.format', { format: FORMAT }),
]);
assert.ok(bloc.includes(String(FORMAT)));
});
test('chaque plateforme et chaque provenance s’y lisent ; un mode sans libellé sous son propre nom', () => {
const cas = [
['web', 'developpement', libelleProvenance('developpement')],
['epreuve', 'test', libelleProvenance('test')],
['electron', 'documentation', libelleProvenance('documentation')],
['web', 'production', 'production'],
];
assert.ok(cas.length > 0);
for (const [plateforme, provenance, libelle] of cas) {
const champs = blocAPropos({ version: VERSION, plateforme, provenance, format: FORMAT }, t).split(' \u{00B7} ');
assert.equal(champs[3], plateforme, provenance);
assert.equal(champs[4], libelle, provenance);
}
});
test('en langue témoin, le bloc entier est encadré une fois', () => {
const bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
const { ouverture, fermeture } = MARQUE_TEMOIN;
assert.ok(bloc.startsWith(ouverture) && bloc.endsWith(fermeture), bloc);
assert.equal(bloc.split(ouverture).length, 2, bloc);
assert.equal(bloc.split(fermeture).length, 2, bloc);
});
});
describe('versionVisible (§ 18.6, § 19.4)', () => {
test('la version affichée, hors d’une construction de documentation', () => {
for (const provenance of ['developpement', 'livraison', 'test']) {
assert.equal(versionVisible(provenance, VERSION.affichee, t), VERSION.affichee, provenance);
}
});
test('la marque de provenance, sans le numéro, dans une construction de documentation', () => {
const marque = versionVisible('documentation', VERSION.affichee, t);
assert.equal(marque, t('coquille.version.documentation'));
assert.ok(!marque.includes(VERSION.affichee), marque);
});
});
describe('provenanceVisible (§ 18.6)', () => {
test('le libellé du mode par la traduction reçue ; un mode sans libellé sous son propre nom', () => {
for (const mode of ['developpement', 'livraison', 'documentation', 'test']) {
assert.equal(provenanceVisible(mode, t), libelleProvenance(mode), mode);
}
assert.equal(provenanceVisible('production', t), 'production');
assert.equal(provenanceVisible('constructor', t), 'constructor');
});
test('en langue témoin, le libellé est encadré, le mode sans libellé non : il est une donnée', () => {
const { ouverture, fermeture } = MARQUE_TEMOIN;
assert.equal(provenanceVisible('test', temoin), `${ouverture}${libelleProvenance('test')}${fermeture}`);
assert.equal(provenanceVisible('production', temoin), 'production');
});
});

View file

@ -1,364 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le diagnostic dans un vrai moteur de rendu (§ 2.1 étape 8, § 5.4, § 5.5,
// § 5.6, § 7.5, § 8.4, § 15.1, § 15.3, § 15.4, § 19.3 capture 10) : le
// panneau compact du plan, lisible dès l'ouverture sans aucun clic ; la
// section complète, vivante en lecture et sur un plan bloqué ; le remède qui
// désactive la séparation, exécuté en écriture et refusé en lecture ; une
// configuration que le moteur refuse ; le bandeau du plan qui énonce le
// diagnostic quand toutes les tables sont en conflit. Chaque épreuve monte
// App par monterApp, au cadre de 1 366 × 700, et lit le rendu : le texte,
// la boîte, les attributs data-… — jamais une classe.
//
// Un énoncé porte data-enonce (sa clé), son texte data-phrase, une
// appartenance data-donnee, un remède data-remede. La valeur attendue vient
// de diagnostiquer, sollicité une seconde fois sur la configuration de la
// charge ouverte (§ 19.1) ; le texte, 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 } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { ErreurConfiguration } from '../../moteur/erreurs.js';
import { normaliser } from '../../moteur/configuration.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
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 { enonces, numeroDeCharge } from './phrases.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
// --- Les charges d'épreuve -------------------------------------------------------
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// Douze personnes inventées, quatre tables de 3, quatre tours ; appartenance
// donne celle de la personne de rang p.
function chargeDouze(appartenance) {
const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours: 4 });
charge.participants = Array.from({ length: 12 }, (_, p) => ({
id: p + 1,
nom: `Pervenche${p + 1}`,
prenom: 'Théo',
appartenance: appartenance(p),
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return charge;
}
// La petite démonstration où quatre personnes portent une réservation de
// portée « tous les tours », sans siège, à la table 1, de 3 places : le
// stockage l'admet, le moteur la refuse (SURRESERVATION).
function quatreReserveesSurTrois() {
const charge = chargeDemo('petite');
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
return charge;
}
// --- Le montage --------------------------------------------------------------------
let montes = [];
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([]);
});
// 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));
}
}
}
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
// La démonstration de clé donnée, chargée par la façade et ouverte en
// lecture, sur la section qu'ouvre un événement.
async function demonstration(cle) {
const m = await monter();
await m.facade.chargerDemonstration(cle);
flushSync();
return m;
}
// La charge posée en fichier d'état sous base, ouverte en lecture.
async function ouvrirTexte(texte, base = 'soiree') {
const m = await monter({ fichiers: [{ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte }] });
await m.seance.ouvrir(base);
flushSync();
return m;
}
const ouvrirCharge = (charge) => ouvrirTexte(serialiser(charge, { revision: 1, produitVersion: V1 }));
// --- Les lectures du rendu ---------------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="diagnostic"]');
const panneau = (m) => dans(m, '[data-panneau-diagnostic]');
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
// Chaque énoncé rendu sous la racine : sa clé, son appartenance, son texte,
// et son remède, null quand il n'en porte pas.
function lus(racine) {
return [...racine.querySelectorAll('[data-enonce]')].map((li) => {
const donnee = li.querySelector('[data-donnee]');
const remede = li.querySelector('[data-remede]');
return {
cle: li.dataset.enonce,
groupe: donnee === null ? null : texteDe(donnee),
phrase: texteDe(li.querySelector('[data-phrase]')),
remede: remede === null ? null : texteDe(remede),
};
});
}
// Les énoncés attendus pour la charge, du moteur sollicité une seconde fois ;
// avecRemede : la forme complète, qui écrit le texte du remède.
function attendus(chargeLue, { avecRemede }) {
const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(chargeLue), tours: chargeLue.evenement.tours });
return liste.map(({ cle, details, remede }) => ({
cle,
groupe: details.groupe ?? null,
phrase: t(cle, details),
remede: avecRemede && remede !== null ? t(remede.cle, remede.details) : null,
}));
}
// --- Les épreuves ------------------------------------------------------------------
describe('le diagnostic se lit sans rien demander (§ 2.1 étape 8, § 5.6)', () => {
test('la petite au conflit inévitable, sans proposition, ouverte : le panneau du plan l\u{2019}énonce, dans la fenêtre ; une table ajoutée le change sans autre geste', async () => {
const m = await demonstration('petite-conflit');
expect(m.ui.etat().section).toBe('plan');
expect(charge(m).propositions.length).toBe(0);
const avant = attendus(charge(m), { avecRemede: false });
expect(avant.length).toBeGreaterThan(0);
expect(avant.map(({ cle }) => cle)).toContain('diagnostic.collisions');
await attendre(() => expect(lus(panneau(m))).toEqual(avant));
const premier = panneau(m).querySelector('[data-enonce]');
expect(visible(premier)).toBe(true);
expect(visible(premier.querySelector('[data-phrase]'))).toBe(true);
// Compact : aucun remède, aucune commande dans le panneau.
expect(panneau(m).querySelectorAll('[data-remede], [data-commande]').length).toBe(0);
await m.seance.passerEnEcriture();
await m.seance.executer('creerTable', { forme: 'ronde' });
const apres = attendus(charge(m), { avecRemede: false });
expect(apres).not.toEqual(avant);
expect(apres.map(({ cle }) => cle)).not.toContain('diagnostic.collisions');
await attendre(() => expect(lus(panneau(m))).toEqual(apres));
});
});
describe('vivant en lecture (§ 8.4)', () => {
test('en lecture, la section se lit, remèdes compris, sans rien écrire ; sur un plan bloqué, de même', async () => {
const m = await demonstration('petite-conflit');
const ecrituresAvant = m.trace.length;
m.ui.allerA('diagnostic');
flushSync();
expect(m.seance.etat().mode).toBe('lecture');
const attendue = attendus(charge(m), { avecRemede: true });
expect(attendue.filter(({ remede }) => remede !== null).length).toBeGreaterThan(0);
await attendre(() => expect(lus(section(m))).toEqual(attendue));
expect(visible(section(m).querySelector('[data-phrase]'))).toBe(true);
expect(m.trace.length).toBe(ecrituresAvant);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
expect(m.seance.etat().refus.code).toBe('LECTURE_SEULE');
m.ui.allerA('diagnostic');
await attendre(() => expect(lus(section(m))).toEqual(attendus(charge(m), { avecRemede: true })));
m.ui.allerA('plan');
await attendre(() => expect(lus(panneau(m))).toEqual(attendus(charge(m), { avecRemede: false })));
});
});
describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => {
test('les valeurs affichées égalent celles de diagnostiquer : 24, 99, le plancher de l\u{2019}écart d\u{2019}itinéraire', async () => {
const m = await demonstration('grande');
m.ui.allerA('diagnostic');
const diagnostic = diagnostiquer(configurationDepuisCharge(charge(m)));
expect(diagnostic.plafondAPriori.repartition[0]).toEqual({ valeur: 24, ancres: 4, mobiles: 0 });
expect(diagnostic.retoursImposes).toBe(99);
expect(diagnostic.ecartItineraire).not.toBeNull();
const attendue = attendus(charge(m), { avecRemede: true });
await attendre(() => expect(lus(section(m))).toEqual(attendue));
const phrases = lus(section(m)).map(({ phrase }) => phrase);
expect(phrases).toContain(t('diagnostic.plafond', diagnostic.plafondAPriori.repartition[0]));
expect(phrases).toContain(
t('diagnostic.imposes', { retoursImposes: diagnostic.retoursImposes, ancragesImposantRetours: diagnostic.ancragesImposantRetours }),
);
expect(phrases).toContain(t('diagnostic.ecartItineraire', diagnostic.ecartItineraire));
expect(phrases.join(' ')).toMatch(/\b24 rencontres\b/);
expect(phrases.join(' ')).toMatch(/\b99 retours imposés\b/);
expect(phrases.join(' ')).toMatch(new RegExp(`plancher ${diagnostic.ecartItineraire.plancher},`));
});
});
describe('une seule appartenance (§ 15.4)', () => {
test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => {
const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles'));
m.ui.allerA('diagnostic');
flushSync();
const [premier] = lus(section(m));
expect(premier).toEqual({
cle: 'diagnostic.uneSeuleAppartenance',
groupe: null,
phrase: t('diagnostic.uneSeuleAppartenance'),
remede: t('diagnostic.remede.separation'),
});
const ligne = section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]');
const bouton = ligne.querySelector('[data-commande="modifierReglage"]');
expect(bouton).not.toBeNull();
expect(texteDe(bouton)).toBe(t('communs.remede.modifierReglage'));
expect(visible(bouton)).toBe(true);
// En lecture : refusé, le refus posé à côté, avec « Modifier » ; rien d'écrit.
const ecrituresAvant = m.trace.length;
const revisionAvant = revision(m);
bouton.click();
const refus = await attendre(() => {
const trouve = ligne.querySelector('[data-refus="MODE_LECTURE"]');
expect(trouve).not.toBeNull();
return trouve;
});
const modifier = refus.querySelector('[data-commande="modifier"]');
expect(modifier).not.toBeNull();
expect(texteDe(modifier)).toBe(t('communs.remede.modifier'));
expect(revision(m)).toBe(revisionAvant);
expect(m.trace.length).toBe(ecrituresAvant);
// « Modifier » passe en écriture ; le remède s'exécute, une entrée.
modifier.click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
const revisionEcriture = revision(m);
ligne.querySelector('[data-commande="modifierReglage"]').click();
await attendre(() => expect(charge(m).reglages.separerAppartenances).toBe(false));
expect(revision(m)).toBe(revisionEcriture + 1);
await attendre(() => expect(section(m).querySelector('[data-enonce="diagnostic.uneSeuleAppartenance"]')).toBeNull());
});
});
describe('la configuration refusée (§ 5.6, § 5.9)', () => {
test('quatre réservées sur une table de 3 : le code traduit, aucun énoncé, aucun message brut ; les autres sections vivent', async () => {
const chargeRefusee = quatreReserveesSurTrois();
let refusMoteur = null;
try {
normaliser(configurationDepuisCharge(chargeRefusee));
} catch (erreur) {
expect(erreur).toBeInstanceOf(ErreurConfiguration);
refusMoteur = erreur;
}
expect(refusMoteur?.code).toBe('SURRESERVATION');
const m = await ouvrirCharge(chargeRefusee);
const message = t('SURRESERVATION', refusMoteur.details);
// Le panneau du plan, ouvert d'emblée.
await attendre(() => expect(panneau(m).querySelector('[data-refus="SURRESERVATION"]')).not.toBeNull());
for (const racine of [panneau(m)]) {
expect(texteDe(racine.querySelector('[data-refus="SURRESERVATION"]'))).toBe(message);
expect(racine.querySelectorAll('[data-enonce]').length).toBe(0);
expect(racine.textContent).not.toContain(refusMoteur.message);
}
// La section.
m.ui.allerA('diagnostic');
await attendre(() => expect(section(m)).not.toBeNull());
const porteur = section(m).querySelector('[data-refus="SURRESERVATION"]');
expect(texteDe(porteur)).toBe(message);
expect(visible(porteur)).toBe(true);
expect(texteDe(section(m))).toContain(t('diagnostic.nonCalcule'));
expect(section(m).querySelectorAll('[data-enonce]').length).toBe(0);
expect(section(m).textContent).not.toContain(refusMoteur.message);
// Les participants se lisent toujours.
m.ui.allerA('participants');
await attendre(() => expect(m.racine.querySelectorAll('[data-section="participants"] [data-participant]').length).toBe(12));
});
});
describe('le bandeau du plan énonce le diagnostic (§ 7.5)', () => {
// Les entrées attendues du bandeau, du moteur sollicité une seconde fois :
// la phrase, collisionPartout, puis chaque plancher de groupe.
function bandeauAttendu(chargeLue) {
const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
expect(diagnostic.collisionPartout).toBe(true);
expect(diagnostic.collisions.length).toBe(2);
return [
t('plan.bandeau.toutesEnConflit'),
t('plan.bandeau.collisionPartout'),
...diagnostic.collisions.map(
({ groupe, effectif, plancherCumulees }) => `${groupe} ${t('plan.bandeau.plancherGroupe', { groupe, effectif, plancherCumulees })}`,
),
];
}
const bandeau = (m) => dans(m, '[data-bandeau-plan]');
// Chaque entrée, ses nœuds — l'appartenance, puis le texte — joints par
// une espace.
const entrees = (m) => [...bandeau(m).querySelectorAll('li')].map((li) => [...li.children].map(texteDe).join(' '));
test('toutes en conflit, la proposition montrée : la phrase, collisionPartout, puis les planchers de chaque groupe', async () => {
const m = await ouvrirTexte(TEXTE_TOUTES_EN_CONFLIT, 'toutes_en_conflit');
m.ui.montrerProposition(1);
const attendu = bandeauAttendu(charge(m));
await attendre(() => expect(bandeau(m)).not.toBeNull());
await attendre(() => expect(entrees(m)).toEqual(attendu));
expect(visible(bandeau(m).querySelector('li'))).toBe(true);
// Les chiffres du moteur, au chiffre près, dans le rendu.
const { collisions } = diagnostiquer(configurationDepuisCharge(charge(m)));
for (const { groupe, plancherCumulees } of collisions) {
const ligne = [...bandeau(m).querySelectorAll('li')].find((li) => li.textContent.startsWith(groupe));
expect(ligne.textContent).toContain(`${plancherCumulees} collisions`);
}
});
test('une table rendue saine au tour montré : ni phrase ni plancher', async () => {
// La personne 8, à la table 1 du tour 1 avec 4 et 7, prend une troisième
// appartenance : la table 1 réunit trois appartenances à ce tour.
const lue = analyser(TEXTE_TOUTES_EN_CONFLIT).charge;
lue.participants.find(({ id }) => id === 8).appartenance = 'Grisaille';
expect(lue.propositions[0].placement[0].sieges[0]).toEqual([4, 7, 8]);
const m = await ouvrirCharge(lue);
m.ui.montrerProposition(1);
await attendre(() => expect(m.racine.querySelectorAll('[data-plan] [data-table]').length).toBe(4));
await attendre(() => expect(bandeau(m)).toBeNull());
expect(m.racine.textContent).not.toContain(t('plan.bandeau.toutesEnConflit'));
expect(m.racine.querySelector('[data-surface]').textContent).not.toContain(t('plan.bandeau.collisionPartout'));
});
});

View file

@ -1,150 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le diagnostic de la configuration (§ 2.1 étape 8, § 5.6) : ce que la
// configuration rend inévitable, combien, et ce qui le rendrait évitable.
// Il se calcule sur la charge de l'état observable à chaque charge écrite,
// par diagnostiquerCharge, et s'énonce par phrases.js : le composant ne
// fait que traduire. Regarder n'écrit jamais (§ 8.4) : il se lit en
// lecture comme sur un plan bloqué.
//
// compact : la forme du panneau latéral du plan, que SectionPlan monte
// dans [data-panneau-diagnostic] — son titre et les énoncés seuls. Sinon, la section,
// [data-section="diagnostic"] : chaque énoncé suivi du texte de son
// remède et, quand une commande le réalise, de la Commande qui l'exécute
// par la séance ; un refus de la séance — mode lecture, plan bloqué —
// s'affiche à côté du bouton, avec son propre remède.
//
// Une configuration que le moteur refuse n'a pas de diagnostic : son
// code, traduit, se pose à sa place, jamais le message de l'erreur.
// L'appartenance d'un groupe est une donnée : elle occupe son propre
// nœud, avant le texte.
import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { actionRemede } from '../primitives/commande.js';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { enonces, numeroDeCharge } from './phrases.js';
let { compact = false } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
const charge = $derived($seance.charge);
const resultat = $derived(charge === null ? null : diagnostiquerCharge(charge));
const liste = $derived(
resultat === null || resultat.diagnostic === null
? []
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
);
// L'action d'un remède que réalise une commande ; null pour un remède de
// texte seul.
const actionDe = (remede) => (remede?.commande === undefined ? null : actionRemede(remede, { seance, ui }));
</script>
{#snippet contenu()}
{#if resultat !== null && resultat.erreur !== null}
<p>{t('diagnostic.nonCalcule')}</p>
<Refus refus={resultat.erreur} />
{:else if liste.length > 0}
<ul aria-label={t('diagnostic.enonces')}>
{#each liste as enonce, rang (rang)}
<li data-enonce={enonce.cle}>
<p class="enonce">
{#if enonce.details.groupe !== undefined}<span class="donnee" data-donnee>{enonce.details.groupe}</span>{/if}<span
data-phrase>{t(enonce.cle, enonce.details)}</span
>
</p>
{#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)}
<p class="remede" data-remede>{t(enonce.remede.cle, enonce.remede.details)}</p>
{#if action !== null}
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
{/snippet}
{#if compact}
<div class="diagnostic compact">
<h3>{t('coquille.section.diagnostic')}</h3>
{@render contenu()}
</div>
{:else}
<section class="diagnostic" data-section="diagnostic">
<h2>{t('coquille.section.diagnostic')}</h2>
{@render contenu()}
</section>
{/if}
<style>
.diagnostic {
display: flex;
flex-direction: column;
gap: var(--espace-2);
min-width: 0;
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
ul {
display: flex;
flex-direction: column;
gap: var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
}
.compact ul {
gap: var(--espace-2);
}
li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
p {
margin: 0;
}
.enonce {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: 700;
}
.remede {
color: var(--couleur-page-texte-secondaire);
}
</style>

View file

@ -1,192 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les énoncés du diagnostic (§ 5.6) : ce que la configuration rend
// inévitable, combien, et ce qui le rendrait évitable. Chaque énoncé est
// {cle, details, remede}, que l'écran traduit ; ses détails sont les
// chiffres du diagnostic tels que le moteur les rend — aucun ne se calcule
// ici —, plus le numéro affiché d'une table, que donne numeroDe, et le
// nombre de tours de l'événement, une donnée de la charge.
//
// remede : null, quand l'énoncé ne dit qu'une absence ; {cle, details}, le
// texte de ce qui lèverait l'inévitable ; {cle, details, commande,
// arguments} quand une commande le réalise sans rien détruire, exécutable
// telle quelle par seance.executer, comme le remède d'un refus. Retirer des
// tours détruit les réservations d'un tour retiré : le remède en tours reste
// un texte.
//
// L'ordre est celui du § 5.6 — collisions et excédent, redondance,
// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce
// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a
// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que
// strictement positif : un plancher nul laisserait croire qu'un plancher a
// été calculé là où il n'y en a pas.
// Désactiver la séparation : le seul remède que réalise une commande. Un
// objet neuf à chaque appel.
const separer = () => ({
cle: 'diagnostic.remede.separation',
details: {},
commande: 'modifierReglage',
arguments: { cle: 'separerAppartenances', valeur: false },
});
// Un remède neuf de la clé donnée.
const remede = (cle, details = {}) => ({ cle, details });
// La séparation des appartenances vidée de son sens : une seule
// appartenance, ou une collision forcée à chaque table de chaque tour.
function separation({ uneSeuleAppartenance, collisionPartout }) {
const liste = [];
if (uneSeuleAppartenance) liste.push({ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: separer() });
if (collisionPartout) liste.push({ cle: 'diagnostic.collisionPartout', details: {}, remede: separer() });
return liste;
}
// Les tables à ajouter que rend le moteur, ou, quand aucune ne suffit, la
// redistribution de l'appartenance.
const remedeCollisions = ({ tablesManquantes }) =>
tablesManquantes === null ? remede('diagnostic.remede.redistribuer') : remede('diagnostic.remede.tables', { tablesManquantes });
// Chaque groupe en collision : son plancher de collisions cumulées et de
// paires distinctes, puis son excédent quand il est strictement positif.
function collisions(diagnostic, tours) {
return diagnostic.collisions.flatMap((ligne) => {
const { groupe, effectif, plancherCumulees, plancherPairesDistinctes, plancherExcedent } = ligne;
const liste = [
{
cle: 'diagnostic.collisions',
details: { groupe, effectif, plancherCumulees, plancherPairesDistinctes, tours },
remede: remedeCollisions(ligne),
},
];
if (plancherExcedent !== null) {
liste.push({ cle: 'diagnostic.excedent', details: { groupe, plancherExcedent }, remede: remedeCollisions(ligne) });
}
return liste;
});
}
// La redondance minimale, quand elle est positive, et le nombre de tours qui
// l'annule ; sans lui, ce qui la fait tenir.
function redondance({ redondanceMinimale, remedes }, tours) {
if (redondanceMinimale === null || redondanceMinimale === 0) return [];
const { toursSansRedondance } = remedes;
return [
{
cle: 'diagnostic.redondance',
details: { redondanceMinimale },
remede:
toursSansRedondance === null
? remede('diagnostic.remede.appartenances')
: remede('diagnostic.remede.tours', { toursSansRedondance, tours }),
},
];
}
// Les animateurs d'une même appartenance, quand ils imposent au moins une
// redondance.
function animateurs({ animateursMemeAppartenance, redondanceImposeeParAnimateurs }) {
if (!animateursMemeAppartenance || redondanceImposeeParAnimateurs === 0) return [];
return [
{
cle: 'diagnostic.animateurs',
details: { redondanceImposeeParAnimateurs },
remede: remede('diagnostic.remede.animateurs'),
},
];
}
// Chaque table gelée ou à moitié ancrée, dans l'ordre du diagnostic,
// désignée par son numéro affiché.
function ancrage(diagnostic, numeroDe) {
return diagnostic.ancrage
.filter(({ gelee, moitie }) => gelee || moitie)
.map(({ table, ancres, capacite, gelee }) => ({
cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
details: { numero: numeroDe(table), ancres, capacite },
remede: remede('diagnostic.remede.ancrage'),
}));
}
// Le plancher de l'écart d'itinéraire maximal, là où le moteur le prouve.
function ecartItineraire({ ecartItineraire: ecart }) {
if (ecart === null) return [];
return [
{
cle: 'diagnostic.ecartItineraire',
details: { plancher: ecart.plancher, mobilesAuMoins: ecart.mobilesAuMoins },
remede: remede('diagnostic.remede.ecartItineraire'),
},
];
}
// Le manque de places, quand il est positif.
function placesManquantes({ placesManquantes: manquantes }) {
if (manquantes === 0) return [];
return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }];
}
// Le plafond a priori, une ligne par valeur de la répartition ; une
// population vide n'en a aucun, et l'énoncé le dit par le tiret.
function plafond({ plafondAPriori }) {
if (plafondAPriori.repartition.length === 0) return [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }];
return plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
cle: 'diagnostic.plafond',
details: { valeur, ancres, mobiles },
remede: remede('diagnostic.remede.plafond'),
}));
}
// Les retours que les réservations imposent, à part des retours choisis.
function imposes({ retoursImposes, ancragesImposantRetours }) {
if (retoursImposes === 0) return [];
return [
{
cle: 'diagnostic.imposes',
details: { retoursImposes, ancragesImposantRetours },
remede: remede('diagnostic.remede.imposes'),
},
];
}
/**
* Les énoncés d'un diagnostic, dans l'ordre décrit en tête. Ne modifie pas
* le diagnostic.
*
* @param {Object} diagnostic celui que rend diagnostiquerCharge
* @param {{numeroDe: (table: number) => number, tours: number}} options
* numeroDe : le numéro affiché d'un identifiant de table ; tours : le
* nombre de tours de l'événement
* @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object,
* commande?: string, arguments?: Object}}>}
*/
export function enonces(diagnostic, { numeroDe, tours }) {
return [
...separation(diagnostic),
...collisions(diagnostic, tours),
...redondance(diagnostic, tours),
...animateurs(diagnostic),
...ancrage(diagnostic, numeroDe),
...ecartItineraire(diagnostic),
...placesManquantes(diagnostic),
...plafond(diagnostic),
...imposes(diagnostic),
];
}
/**
* Le numéro affiché de chaque table de la charge, par identifiant.
*
* @param {{tables: Array<{id: number, numero: number}>}} charge
* @returns {(table: number) => number} lève RangeError pour un identifiant
* que la charge n'a pas
*/
export function numeroDeCharge(charge) {
const numeros = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
return (table) => {
const numero = numeros.get(table);
if (numero === undefined) throw new RangeError(`table inconnue : ${JSON.stringify(table)}`);
return numero;
};
}

View file

@ -1,494 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des énoncés du diagnostic (§ 5.4, § 5.5, § 5.6, § 12.10.4,
// § 15.1, § 15.3, § 15.4) : chaque énoncé porte les chiffres que rend
// diagnostiquer, sollicité une seconde fois (§ 19.1), et la clé de son
// remède ; le texte se lit par la même traduction que celle de
// l'application. Les charges partent des démonstrations livrées ou se
// construisent ici, sur des noms inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { enonces, numeroDeCharge } from './phrases.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TIRET = '\u{2014}';
// --- Les charges d'épreuve ---------------------------------------------------------
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
/**
* Une charge construite : une personne par appartenance donnée (null : sans
* appartenance), d'identifiants 1 à N ; une table par capacité, d'identifiant
* 1 à T et de numéro numeros[rang] (rang + 1 par défaut) ; les réservations
* de portée « tous les tours », sans siège, données en [participant, table].
*/
function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer = true, numeros = null }) {
const charge = creerCharge({ id: 'evt-diagnostic', nom: 'Soirée des Merles', siegesParDefaut: 3, tours });
charge.participants = appartenances.map((appartenance, rang) => ({
id: rang + 1,
nom: `Ombrelle${rang + 1}`,
prenom: 'Iris',
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = capacites.map((sieges, rang) => ({
id: rang + 1,
numero: numeros === null ? rang + 1 : numeros[rang],
sieges,
forme: 'ronde',
position: { x: 300 * rang, y: 0 },
}));
charge.reservations = ancres.map(([participant, table]) => ({ participant, table, siege: null, portee: 'tous', tour: null }));
charge.reglages.separerAppartenances = separer;
charge.prochainsIds = { participant: appartenances.length + 1, table: capacites.length + 1, proposition: 1 };
return charge;
}
const fois = (n, valeur) => Array(n).fill(valeur);
// Le diagnostic d'une charge, par l'application, et ses énoncés.
function lire(charge) {
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) };
}
const cles = (liste) => liste.map(({ cle }) => cle);
const avecCle = (liste, cle) => liste.filter((enonce) => enonce.cle === cle);
const texte = ({ cle, details }) => t(cle, details);
// Les configurations nommées de l'épreuve.
const CONFIGURATIONS = {
// 28 personnes en 18 appartenances, dix de 2 et huit de 1, quatre tables
// de 7, quatre tours : la redondance du remède en tours (M1).
redondance: () =>
chargeDe({
appartenances: [
...Array.from({ length: 10 }, (_, g) => fois(2, `Cercle ${g + 1}`)).flat(),
...Array.from({ length: 8 }, (_, g) => `Atelier ${g + 1}`),
],
capacites: fois(4, 7),
}),
// Quatre tables de 3, quatre tours, un animateur par table, tous du Club
// des Merles ; les huit autres sans appartenance.
animateurs: () =>
chargeDe({
appartenances: [...fois(4, 'Club des Merles'), ...fois(8, null)],
capacites: fois(4, 3),
ancres: [[1, 1], [2, 2], [3, 3], [4, 4]],
}),
// Table 1, numérotée 7 : 3 places, 3 ancrés ; table 2, numérotée 8 :
// 4 places, 2 ancrés ; table 3, numérotée 9 : 4 places, aucun.
ancrage: () =>
chargeDe({
appartenances: fois(10, null),
capacites: [3, 4, 4],
numeros: [7, 8, 9],
ancres: [[1, 1], [2, 1], [3, 1], [4, 2], [5, 2]],
}),
// Trois membres d'une appartenance, deux tables de 2, quatre tours.
excedent: () => chargeDe({ appartenances: fois(3, 'Grisaille'), capacites: [2, 2] }),
// Deux membres réservés à la même table : aucune table ajoutée ne les
// sépare.
memeTable: () =>
chargeDe({ appartenances: [...fois(2, 'Grisaille'), ...fois(4, null)], capacites: [3, 3], ancres: [[1, 1], [2, 1]] }),
// Treize personnes, quatre tables de 3.
manque: () => chargeDe({ appartenances: fois(13, null), capacites: fois(4, 3) }),
// Douze personnes en deux appartenances de six, quatre tables de 3.
toutesEnConflit: () =>
chargeDe({
appartenances: Array.from({ length: 12 }, (_, p) => (p % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons')),
capacites: fois(4, 3),
}),
// Douze personnes d'une seule appartenance, séparation active.
uneSeule: () => chargeDe({ appartenances: fois(12, 'Club des Merles'), capacites: fois(4, 3) }),
// Huit personnes en deux appartenances de quatre, une table de 8, un tour.
unTour: () => chargeDe({ appartenances: [...fois(4, 'Grisaille'), ...fois(4, 'Club des Merles')], capacites: [8], tours: 1 }),
petite: () => chargeDemo('petite'),
petiteConflit: () => chargeDemo('petite-conflit'),
grande: () => chargeDemo('grande'),
grandeSansException: () => chargeDemo('grande-sans-exception'),
};
// --- Chaque champ, son énoncé ------------------------------------------------------
// Pour chaque champ du diagnostic : ce qui le rend non nul, et les énoncés
// qu'il appelle alors — la clé et les détails exacts, tirés du diagnostic
// même ; numero vient de numeroDe, tours de la charge.
const SEPARATION = {
cle: 'diagnostic.remede.separation',
details: {},
commande: 'modifierReglage',
arguments: { cle: 'separerAppartenances', valeur: false },
};
const remedeCollisions = ({ tablesManquantes }) =>
tablesManquantes === null
? { cle: 'diagnostic.remede.redistribuer', details: {} }
: { cle: 'diagnostic.remede.tables', details: { tablesManquantes } };
const CHAMPS = [
{
champ: 'uneSeuleAppartenance',
attendus: (d) => (d.uneSeuleAppartenance ? [{ cle: 'diagnostic.uneSeuleAppartenance', details: {}, remede: SEPARATION }] : []),
},
{
champ: 'collisionPartout',
attendus: (d) => (d.collisionPartout ? [{ cle: 'diagnostic.collisionPartout', details: {}, remede: SEPARATION }] : []),
},
{
champ: 'collisions',
attendus: (d, tours) =>
d.collisions.map((ligne) => ({
cle: 'diagnostic.collisions',
details: {
groupe: ligne.groupe,
effectif: ligne.effectif,
plancherCumulees: ligne.plancherCumulees,
plancherPairesDistinctes: ligne.plancherPairesDistinctes,
tours,
},
remede: remedeCollisions(ligne),
})),
},
{
champ: 'collisions[].plancherExcedent',
attendus: (d) =>
d.collisions
.filter(({ plancherExcedent }) => plancherExcedent !== null)
.map((ligne) => ({
cle: 'diagnostic.excedent',
details: { groupe: ligne.groupe, plancherExcedent: ligne.plancherExcedent },
remede: remedeCollisions(ligne),
})),
},
{
champ: 'redondanceMinimale',
attendus: (d, tours) =>
d.redondanceMinimale === null || d.redondanceMinimale === 0
? []
: [
{
cle: 'diagnostic.redondance',
details: { redondanceMinimale: d.redondanceMinimale },
remede:
d.remedes.toursSansRedondance === null
? { cle: 'diagnostic.remede.appartenances', details: {} }
: { cle: 'diagnostic.remede.tours', details: { toursSansRedondance: d.remedes.toursSansRedondance, tours } },
},
],
},
{
champ: 'animateursMemeAppartenance',
attendus: (d) =>
d.animateursMemeAppartenance && d.redondanceImposeeParAnimateurs > 0
? [
{
cle: 'diagnostic.animateurs',
details: { redondanceImposeeParAnimateurs: d.redondanceImposeeParAnimateurs },
remede: { cle: 'diagnostic.remede.animateurs', details: {} },
},
]
: [],
},
{
champ: 'ancrage',
attendus: (d, _tours, numeroDe) =>
d.ancrage
.filter(({ gelee, moitie }) => gelee || moitie)
.map(({ table, ancres, capacite, gelee }) => ({
cle: gelee ? 'diagnostic.ancrage.gelee' : 'diagnostic.ancrage.moitie',
details: { numero: numeroDe(table), ancres, capacite },
remede: { cle: 'diagnostic.remede.ancrage', details: {} },
})),
},
{
champ: 'ecartItineraire',
attendus: (d) =>
d.ecartItineraire === null
? []
: [
{
cle: 'diagnostic.ecartItineraire',
details: { plancher: d.ecartItineraire.plancher, mobilesAuMoins: d.ecartItineraire.mobilesAuMoins },
remede: { cle: 'diagnostic.remede.ecartItineraire', details: {} },
},
],
},
{
champ: 'placesManquantes',
attendus: (d) =>
d.placesManquantes > 0
? [
{
cle: 'diagnostic.placesManquantes',
details: { placesManquantes: d.placesManquantes },
remede: { cle: 'diagnostic.remede.places', details: {} },
},
]
: [],
},
{
champ: 'plafondAPriori',
attendus: (d) =>
d.plafondAPriori.repartition.length === 0
? [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]
: d.plafondAPriori.repartition.map(({ valeur, ancres, mobiles }) => ({
cle: 'diagnostic.plafond',
details: { valeur, ancres, mobiles },
remede: { cle: 'diagnostic.remede.plafond', details: {} },
})),
},
{
champ: 'retoursImposes',
attendus: (d) =>
d.retoursImposes > 0
? [
{
cle: 'diagnostic.imposes',
details: { retoursImposes: d.retoursImposes, ancragesImposantRetours: d.ancragesImposantRetours },
remede: { cle: 'diagnostic.remede.imposes', details: {} },
},
]
: [],
},
];
describe('enonces : chaque champ a son énoncé', () => {
test('sur la suite des configurations, chaque champ non nul a ses énoncés, dans l\u{2019}ordre, et rien d\u{2019}autre', () => {
const exerces = new Set();
for (const [nom, construire] of Object.entries(CONFIGURATIONS)) {
const charge = construire();
const { diagnostic, liste } = lire(charge);
const numeroDe = numeroDeCharge(charge);
// Le moteur, sollicité une seconde fois, sur la même configuration.
const moteur = diagnostiquer(configurationDepuisCharge(charge));
const attendue = CHAMPS.flatMap(({ champ, attendus }) => {
const lignes = attendus(moteur, charge.evenement.tours, numeroDe);
if (lignes.length > 0 && !(champ === 'plafondAPriori' && moteur.plafondAPriori.repartition.length === 0)) {
exerces.add(champ);
}
return lignes;
});
assert.ok(attendue.length > 0, nom);
assert.deepEqual(liste, attendue, nom);
assert.deepEqual(diagnostic, moteur, nom);
for (const enonce of liste) {
// Chaque texte existe ; chaque énoncé d'un champ non nul a un remède.
assert.equal(typeof texte(enonce), 'string');
if (enonce.cle !== 'diagnostic.plafondInconnu') assert.notEqual(enonce.remede, null, enonce.cle);
if (enonce.remede !== null) assert.equal(typeof texte(enonce.remede), 'string');
}
}
assert.deepEqual([...exerces].sort(), CHAMPS.map(({ champ }) => champ).sort());
});
test('collisionPartout vrai : l\u{2019}énoncé et son remède, exécutable par modifierReglage', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.toutesEnConflit());
assert.equal(diagnostic.collisionPartout, true);
assert.deepEqual(avecCle(liste, 'diagnostic.collisionPartout'), [
{
cle: 'diagnostic.collisionPartout',
details: {},
remede: {
cle: 'diagnostic.remede.separation',
details: {},
commande: 'modifierReglage',
arguments: { cle: 'separerAppartenances', valeur: false },
},
},
]);
});
});
describe('enonces : les phrases du § 5.6', () => {
test('collisions : la petite au conflit inévitable, le groupe de 5, ses deux planchers et leurs unités', () => {
const charge = CONFIGURATIONS.petiteConflit();
const { diagnostic, liste } = lire(charge);
const [ligne] = diagnostic.collisions;
assert.equal(diagnostic.collisions.length, 1);
const [enonce] = avecCle(liste, 'diagnostic.collisions');
assert.equal(enonce.details.effectif, 5);
assert.equal(enonce.details.plancherCumulees, ligne.plancherCumulees);
assert.equal(enonce.details.plancherPairesDistinctes, ligne.plancherPairesDistinctes);
assert.equal(enonce.details.groupe, ligne.groupe);
const phrase = texte(enonce);
assert.ok(phrase.includes('au moins 4 collisions cumulées sur les 4 tours, portées par au moins 1 paire distincte'), phrase);
assert.ok(phrase.includes('5 personnes'), phrase);
assert.ok(!phrase.includes(ligne.groupe), 'l\u{2019}appartenance occupe son propre nœud');
assert.doesNotMatch(phrase, /\b4 paires?\b/);
// Chaque chiffre nomme son unité.
const nombres = [...phrase.matchAll(/\d+/g)];
assert.equal(nombres.length, 4);
for (const nombre of nombres) {
assert.match(phrase.slice(nombre.index), /^\d+ (personnes?|collisions? cumulées?|tours?|paires? distinctes?)\b/, phrase);
}
// 4 ≤ C(5, 2) : aucun excédent.
assert.equal(ligne.plancherExcedent, null);
assert.equal(avecCle(liste, 'diagnostic.excedent').length, 0);
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.tables', details: { tablesManquantes: 1 } });
assert.ok(texte(enonce.remede).includes('1 table'));
});
test('excédent : trois membres, deux tables, quatre tours — « excédent d\u{2019}au moins 1 », jamais un plancher nul', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.excedent());
assert.equal(diagnostic.collisions[0].plancherExcedent, 1);
const [enonce] = avecCle(liste, 'diagnostic.excedent');
const phrase = texte(enonce);
assert.ok(phrase.includes("excédent d'au moins 1 "), phrase);
assert.doesNotMatch(phrase, /≥|\b0\b/);
// Une configuration à excédent porte au moins un énoncé d'excédent, et
// chacun un plancher positif.
for (const cle of ['excedent', 'memeTable']) {
const portes = avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.excedent');
assert.ok(portes.length > 0, cle);
for (const { details } of portes) assert.ok(details.plancherExcedent > 0, `${cle} : ${details.plancherExcedent}`);
}
// Une configuration sans excédent n'en porte aucun énoncé : chaque ligne
// de collisions y a un plancher d'excédent null, jamais 0. Les deux
// configurations en conflit ont au moins une ligne, la petite aucune.
for (const [cle, avecCollisions] of [['petiteConflit', true], ['toutesEnConflit', true], ['petite', false]]) {
const lu = lire(CONFIGURATIONS[cle]());
assert.equal(avecCle(lu.liste, 'diagnostic.excedent').length, 0, cle);
assert.equal(lu.diagnostic.collisions.length > 0, avecCollisions, cle);
for (const ligne of lu.diagnostic.collisions) assert.equal(ligne.plancherExcedent, null, `${cle} : ${ligne.groupe}`);
}
});
test('deux membres réservés à une même table : le remède redistribue, aucune table ne suffit', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.memeTable());
assert.equal(diagnostic.collisions[0].tablesManquantes, null);
const portes = [...avecCle(liste, 'diagnostic.collisions'), ...avecCle(liste, 'diagnostic.excedent')];
assert.equal(portes.length, 2);
for (const enonce of portes) {
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.redistribuer', details: {} });
}
});
test('redondance : la configuration de M1 — au moins 6 appartenances recroisées ; avec 3 tours au lieu de 4, aucune', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.redondance());
assert.equal(diagnostic.redondanceMinimale, 6);
const [enonce] = avecCle(liste, 'diagnostic.redondance');
assert.equal(texte(enonce), 'au moins 6 appartenances recroisées par personne');
assert.equal(texte(enonce.remede), 'avec 3 tours au lieu de 4, aucune ne le serait');
});
test('redondance sans remède en tours : un seul tour, toursSansRedondance null', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.unTour());
assert.ok(diagnostic.redondanceMinimale > 0);
assert.equal(diagnostic.remedes.toursSansRedondance, null);
const [enonce] = avecCle(liste, 'diagnostic.redondance');
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.appartenances', details: {} });
assert.doesNotMatch(texte(enonce.remede), /\d/);
for (const { remede } of liste) assert.notEqual(remede?.cle, 'diagnostic.remede.tours');
});
test('animateurs d\u{2019}une même appartenance : la phrase porte 3 et son remède ; absente de la grande', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.animateurs());
assert.equal(diagnostic.redondanceImposeeParAnimateurs, 3);
const [enonce] = avecCle(liste, 'diagnostic.animateurs');
assert.ok(texte(enonce).includes('au moins 3 redondances'), texte(enonce));
assert.equal(enonce.remede.cle, 'diagnostic.remede.animateurs');
assert.ok(texte(enonce.remede).includes('sans appartenance déclarée'));
assert.equal(avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.animateurs').length, 0);
});
test('ancrage : une table de 3 à 3 ancrés ne tourne plus, par son numéro affiché ; une de 4 à 2 ancrés, 2 sur 4', () => {
const { liste } = lire(CONFIGURATIONS.ancrage());
const [gelee] = avecCle(liste, 'diagnostic.ancrage.gelee');
const [moitie] = avecCle(liste, 'diagnostic.ancrage.moitie');
assert.equal(avecCle(liste, 'diagnostic.ancrage.gelee').length, 1);
assert.equal(avecCle(liste, 'diagnostic.ancrage.moitie').length, 1);
assert.equal(texte(gelee), 'la table 7 ne tourne plus : ses 3 places sont ancrées');
assert.deepEqual(moitie.details, { numero: 8, ancres: 2, capacite: 4 });
assert.ok(texte(moitie).startsWith('la table 8 a 2 places ancrées sur 4'), texte(moitie));
});
test('écart d\u{2019}itinéraire : la grande, plancher 1, au moins 24 mobiles ; la petite, aucune mention, jamais 0', () => {
const grande = lire(CONFIGURATIONS.grande());
const [enonce] = avecCle(grande.liste, 'diagnostic.ecartItineraire');
assert.deepEqual(enonce.details, grande.diagnostic.ecartItineraire);
assert.ok(texte(enonce).includes('plancher 1, au moins 24 mobiles'), texte(enonce));
for (const cle of ['petite', 'petiteConflit', 'grandeSansException']) {
assert.equal(avecCle(lire(CONFIGURATIONS[cle]()).liste, 'diagnostic.ecartItineraire').length, 0, cle);
}
});
test('manque de places : treize personnes, quatre tables de 3 — 1 place manque à chaque tour', () => {
const [enonce] = avecCle(lire(CONFIGURATIONS.manque()).liste, 'diagnostic.placesManquantes');
assert.equal(texte(enonce), '1 place manque à chaque tour');
});
test('plafond a priori : la grande, 24 pour les 4 ancrés, 28 pour 29 ancrés et 227 mobiles, depuis repartition', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.grande());
const plafonds = avecCle(liste, 'diagnostic.plafond');
assert.deepEqual(
plafonds.map(({ details }) => details),
diagnostic.plafondAPriori.repartition,
);
assert.deepEqual(diagnostic.plafondAPriori.repartition, [
{ valeur: 24, ancres: 4, mobiles: 0 },
{ valeur: 28, ancres: 29, mobiles: 227 },
]);
assert.equal(texte(plafonds[0]), 'plafond a priori 24 rencontres pour 4 ancrés');
assert.equal(texte(plafonds[1]), 'plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles');
});
test('imposés : la grande, « 99 retours imposés par 33 ancrages »', () => {
const [enonce] = avecCle(lire(CONFIGURATIONS.grande()).liste, 'diagnostic.imposes');
assert.equal(texte(enonce), '99 retours imposés par 33 ancrages');
});
test('une seule appartenance : la phrase, puis le remède qui désactive la séparation', () => {
const { liste } = lire(CONFIGURATIONS.uneSeule());
assert.equal(liste[0].cle, 'diagnostic.uneSeuleAppartenance');
assert.deepEqual(liste[0].remede, SEPARATION);
});
});
describe('enonces : aux bords', () => {
test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => {
const { liste } = lire(chargeDe({ appartenances: [], capacites: [] }));
assert.deepEqual(liste, [{ cle: 'diagnostic.plafondInconnu', details: {}, remede: null }]);
assert.ok(texte(liste[0]).includes(TIRET));
assert.doesNotMatch(texte(liste[0]), /\d/);
});
test('deux personnes sans table : 2 places manquent à chaque tour', () => {
const [enonce] = avecCle(lire(chargeDe({ appartenances: [null, null], capacites: [] })).liste, 'diagnostic.placesManquantes');
assert.equal(texte(enonce), '2 places manquent à chaque tour');
});
test('aucune appartenance : ni collisions ni redondance', () => {
const { liste } = lire(chargeDe({ appartenances: fois(12, null), capacites: fois(4, 3) }));
assert.ok(liste.length > 0);
for (const cle of ['diagnostic.collisions', 'diagnostic.excedent', 'diagnostic.redondance', 'diagnostic.uneSeuleAppartenance', 'diagnostic.collisionPartout']) {
assert.equal(avecCle(liste, cle).length, 0, cle);
}
});
test('numeroDeCharge : le numéro affiché d\u{2019}un identifiant de table ; un identifiant inconnu lève', () => {
const numeroDe = numeroDeCharge(CONFIGURATIONS.ancrage());
assert.deepEqual([1, 2, 3].map(numeroDe), [7, 8, 9]);
assert.throws(() => numeroDe(4), RangeError);
});
test('enonces ne modifie pas le diagnostic reçu', () => {
const charge = CONFIGURATIONS.ancrage();
const { diagnostic } = diagnostiquerCharge(charge);
const avant = structuredClone(diagnostic);
enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 });
assert.deepEqual(diagnostic, avant);
});
});

View file

@ -1,204 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// 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, la fiche ouverte, le choix en attente
// du plan et le refus posé sur une place. 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.
//
// Il suit le contrat des magasins de Svelte : subscribe(fn) appelle fn(etat)
// tout de suite, puis à chaque nouvel état ; un état est figé, et une
// transition qui ne change rien n'en publie pas de nouveau. L'écran — liste
// ou événement — ne s'y lit pas : il suit la séance.
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
/**
* @typedef {Object} EtatUi
* @property {string} section l'une de SECTIONS
* @property {boolean} apropos le panneau « À propos » est ouvert
* @property {number} tour le tour montré, entier positif
* @property {number|null} miseEnEvidence l'identifiant de la personne mise en évidence
* @property {number|null} proposition la proposition demandée au plan
* @property {null|{texte: string, origine: string}} importEnAttente
* @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
* @property {null|{genre: 'personne'|'reservation', participant: number,
* depuis: {table: number, siege: number|null}|null,
* de?: {table: number, portee: 'tous'|'tour', tour: number|null}}} choix
* le choix en attente du plan (§ 5.8, § 7.1) : la personne choisie, ou sa
* réservation qui tient la place depuis ; depuis, sa place, null hors du
* dessin ; de, pour une réservation, ce qui la désigne dans la commande
* deplacerReservation
* @property {null|{place: {table: number, siege?: number|null}, refus: Object|null,
* avertissements: Object[]}} pose
* le refus, ou les avertissements, posés sur une place du plan, que
* Plan.svelte montre sur la place (§ 5.9)
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
// excepté : celui-ci ne tient pas à l'événement.
const DEPART = Object.freeze({
section: SECTION_OUVERTURE,
tour: 1,
miseEnEvidence: null,
proposition: null,
importEnAttente: null,
listes: true,
pleinEcran: false,
selection: null,
fiche: null,
choix: null,
pose: null,
});
const GENRES_CHOIX = new Set(['personne', 'reservation']);
const PORTEES = new Set(['tous', 'tour']);
const entierPositif = (n) => Number.isInteger(n) && n >= 1;
// Vrai pour une place {table, siege|null} : table entière positive, siège
// entier positif ou null.
const estPlace = (place) =>
place !== null && typeof place === 'object' && entierPositif(place.table) && (place.siege === null || entierPositif(place.siege));
// Le choix du contrat, figé ; RangeError hors du contrat.
function choixValide(choix) {
const forme = choix !== null && typeof choix === 'object';
const valide =
forme &&
GENRES_CHOIX.has(choix.genre) &&
entierPositif(choix.participant) &&
Object.hasOwn(choix, 'depuis') &&
(choix.depuis === null || estPlace(choix.depuis)) &&
(choix.genre === 'personne' ||
(choix.de !== null &&
typeof choix.de === 'object' &&
entierPositif(choix.de.table) &&
PORTEES.has(choix.de.portee) &&
(choix.de.portee === 'tous' ? choix.de.tour === null : entierPositif(choix.de.tour))));
if (!valide) throw new RangeError(`choix hors du contrat : ${JSON.stringify(choix)}`);
const depuis = choix.depuis === null ? null : Object.freeze({ table: choix.depuis.table, siege: choix.depuis.siege });
const fige = { genre: choix.genre, participant: choix.participant, depuis };
if (choix.genre === 'reservation') fige.de = Object.freeze({ table: choix.de.table, portee: choix.de.portee, tour: choix.de.tour });
return Object.freeze(fige);
}
/**
* Un état de navigation neuf, à son départ.
*
* @returns {{subscribe: Function, etat: () => EtatUi, allerA: Function,
* reinitialiser: Function, ouvrirApropos: Function, fermerApropos: Function,
* montrerTour: Function, mettreEnEvidence: Function,
* montrerProposition: Function, ouvrirImport: Function,
* prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
const abonnes = new Set();
// Publie l'état que donnent les changements, s'il diffère du courant sur
// l'une de ses propriétés.
function changer(changements) {
const suivant = { ...etat, ...changements };
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
etat = Object.freeze(suivant);
for (const fn of [...abonnes]) fn(etat);
}
return {
subscribe(fn) {
abonnes.add(fn);
fn(etat);
return () => {
abonnes.delete(fn);
};
},
etat: () => etat,
/** Ouvre une section ; RangeError hors de SECTIONS. Une autre section
* que la courante retire la pose : elle ne revient pas avec le plan. */
allerA(section) {
if (!SECTIONS.includes(section)) throw new RangeError(`section inconnue : ${JSON.stringify(section)}`);
changer(section === etat.section ? {} : { section, pose: null });
},
/** Le départ d'un événement qui s'ouvre ; le panneau « À propos » reste tel. */
reinitialiser() {
changer(DEPART);
},
ouvrirApropos() {
changer({ apropos: true });
},
fermerApropos() {
changer({ apropos: false });
},
/** Montre le tour r, entier positif ; RangeError sinon. Le choix en
* attente s'oublie, et la pose, qui tient à l'état des places d'un tour. */
montrerTour(r) {
if (!Number.isInteger(r) || r < 1) throw new RangeError(`tour : entier positif attendu, reçu ${String(r)}`);
changer({ tour: r, choix: null, pose: null });
},
mettreEnEvidence(id) {
changer({ miseEnEvidence: id });
},
/** Demande la proposition id au plan, ou aucune (null) ; la pose, lue
* sur l'état des places d'avant, se retire. */
montrerProposition(id) {
changer({ proposition: id, pose: null });
},
/** Garde un texte à importer et ouvre la section de l'import. */
ouvrirImport({ texte, origine }) {
changer({ importEnAttente: Object.freeze({ texte, origine }), section: 'import' });
},
/** Rend le texte en attente, une fois, puis null. */
prendreImport() {
const enAttente = etat.importEnAttente;
changer({ importEnAttente: null });
return enAttente;
},
basculerListes() {
changer({ listes: !etat.listes });
},
basculerPleinEcran() {
changer({ pleinEcran: !etat.pleinEcran });
},
/** Sélectionne une place ; null retire la sélection et oublie le choix en attente. */
selectionner(place) {
changer(place === null ? { selection: null, choix: null } : { selection: place });
},
/** Pose le choix en attente (EtatUi.choix), ou l'oublie (null) ; RangeError hors du contrat. */
choisir(choix) {
changer({ choix: choix === null ? null : choixValide(choix) });
},
/** Pose sur une place un refus, ou des avertissements, que le plan montre ; null les retire.
* RangeError sans place. */
poserSurPlace(pose) {
if (pose === null) {
changer({ pose: null });
return;
}
const { place, refus = null, avertissements = [] } = pose;
if (place === null || typeof place !== 'object' || !entierPositif(place.table)) {
throw new RangeError(`pose sans place : ${JSON.stringify(pose)}`);
}
changer({ pose: Object.freeze({ place, refus, avertissements }) });
},
/** 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

@ -1,270 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'état de navigation de l'interface : chaque transition, le contrat des
// magasins de Svelte, et l'absence de toute écriture — l'état ne connaît pas
// la séance, et regarder n'écrit jamais (§ 8.2, § 8.4).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { creerEtatUi } from './etat_ui.js';
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
const DEPART = {
section: SECTION_OUVERTURE,
apropos: false,
tour: 1,
miseEnEvidence: null,
proposition: null,
importEnAttente: null,
listes: true,
pleinEcran: false,
selection: null,
fiche: null,
choix: null,
pose: null,
};
// Un état et le relevé de ce que reçoit son abonné.
function suivi() {
const ui = creerEtatUi();
const recus = [];
const desabonner = ui.subscribe((etat) => recus.push(etat));
return { ui, recus, desabonner };
}
describe('état de navigation : départ et magasin', () => {
test('le départ : la section d’ouverture, le tour 1, rien de choisi, les listes montrées', () => {
assert.deepEqual(creerEtatUi().etat(), DEPART);
});
test('subscribe rend l’état tout de suite, puis chaque nouvel état, figé ; désabonné, plus rien', () => {
const { ui, recus, desabonner } = suivi();
assert.deepEqual(recus, [DEPART]);
ui.allerA('tables');
assert.equal(recus.length, 2);
assert.equal(recus[1], ui.etat());
assert.ok(Object.isFrozen(recus[1]));
assert.throws(() => {
recus[1].section = 'plan';
}, TypeError);
desabonner();
ui.allerA('plan');
assert.equal(recus.length, 2);
});
test('l’état ne reçoit rien à sa création : il ne connaît ni la séance, ni la façade, ni un système de fichiers', () => {
assert.equal(creerEtatUi.length, 0);
const source = readFileSync(new URL('./etat_ui.js', import.meta.url), 'utf8');
// Le dernier mot de chaque ligne d'import : le module qu'elle charge.
const imports = source
.split('\n')
.filter((ligne) => ligne.startsWith('import '))
.map((ligne) => ligne.slice(ligne.lastIndexOf(' ') + 1));
assert.deepEqual(imports, ["'./evenement/sections.js';"]);
});
});
describe('état de navigation : chaque transition', () => {
test('allerA mène à chaque section ; hors de SECTIONS, RangeError sans rien changer', () => {
const ui = creerEtatUi();
assert.ok(SECTIONS.length > 0);
for (const section of SECTIONS) {
ui.allerA(section);
assert.deepEqual(ui.etat(), { ...DEPART, section });
}
const avant = ui.etat();
for (const faute of ['accueil', 'apropos', '', undefined, 'Plan', 'constructor']) {
assert.throws(() => ui.allerA(faute), RangeError, String(faute));
}
assert.equal(ui.etat(), avant);
});
test('reinitialiser rend l’état de départ, le panneau « À propos » excepté', () => {
const ui = creerEtatUi();
ui.allerA('generation');
ui.montrerTour(3);
ui.mettreEnEvidence(7);
ui.montrerProposition(2);
ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'liste.csv' });
ui.basculerListes();
ui.basculerPleinEcran();
ui.selectionner({ table: 1, siege: 2 });
ui.ouvrirFiche(4);
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
assert.equal(ui.etat().section, SECTION_OUVERTURE);
});
test('ouvrirApropos et fermerApropos', () => {
const ui = creerEtatUi();
ui.ouvrirApropos();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
ui.fermerApropos();
assert.deepEqual(ui.etat(), DEPART);
});
test('montrerTour prend un entier positif ; sinon RangeError', () => {
const ui = creerEtatUi();
ui.montrerTour(4);
assert.equal(ui.etat().tour, 4);
for (const faute of [0, -1, 1.5, Number.NaN, '2', null]) {
assert.throws(() => ui.montrerTour(faute), RangeError, String(faute));
}
assert.equal(ui.etat().tour, 4);
});
test('mettreEnEvidence, montrerProposition et selectionner posent puis retirent leur choix', () => {
const ui = creerEtatUi();
ui.mettreEnEvidence(12);
ui.montrerProposition(3);
ui.selectionner({ table: 2, siege: 1 });
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
ui.mettreEnEvidence(null);
ui.montrerProposition(null);
ui.selectionner(null);
assert.deepEqual(ui.etat(), DEPART);
});
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
const ui = creerEtatUi();
const enAttente = { texte: 'nom;prénom\nPervenche;Théo', origine: 'invites.csv' };
ui.ouvrirImport(enAttente);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'import', importEnAttente: enAttente });
assert.deepEqual(ui.prendreImport(), enAttente);
assert.equal(ui.etat().importEnAttente, null);
assert.equal(ui.prendreImport(), null);
});
test('basculerListes et basculerPleinEcran alternent', () => {
const ui = creerEtatUi();
ui.basculerListes();
ui.basculerPleinEcran();
assert.deepEqual(ui.etat(), { ...DEPART, listes: false, pleinEcran: true });
ui.basculerListes();
ui.basculerPleinEcran();
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);
});
});
describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } };
const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null };
const RESERVATION = {
genre: 'reservation',
participant: 5,
depuis: { table: 3, siege: 2 },
de: { table: 3, portee: 'tous', tour: null },
};
test('choisir pose un choix de chaque genre, figé, puis null l’oublie', () => {
const ui = creerEtatUi();
for (const choix of [PERSONNE, DEPUIS_LA_RESERVE, RESERVATION]) {
ui.choisir(choix);
assert.deepEqual(ui.etat(), { ...DEPART, choix });
assert.ok(Object.isFrozen(ui.etat().choix));
ui.choisir(null);
assert.deepEqual(ui.etat(), DEPART);
}
});
test('un choix hors du contrat lève RangeError sans rien changer', () => {
const ui = creerEtatUi();
ui.choisir(PERSONNE);
const avant = ui.etat();
const fautes = [
{ genre: 'table', participant: 2, depuis: null },
{ genre: 'personne', participant: 0, depuis: null },
{ genre: 'personne', participant: '2', depuis: null },
{ genre: 'personne', participant: 2 },
{ genre: 'personne', participant: 2, depuis: { siege: 1 } },
{ genre: 'personne', participant: 2, depuis: { table: 1, siege: 0 } },
{ genre: 'reservation', participant: 5, depuis: { table: 3, siege: 2 } },
{ genre: 'reservation', participant: 5, depuis: null, de: { table: 3, portee: 'parfois', tour: null } },
undefined,
];
for (const faute of fautes) assert.throws(() => ui.choisir(faute), RangeError, JSON.stringify(faute));
assert.equal(ui.etat(), avant);
});
test('montrerTour et selectionner(null) oublient le choix ; selectionner une place le garde', () => {
const ui = creerEtatUi();
ui.choisir(PERSONNE);
ui.selectionner({ table: 2, siege: 3 });
assert.deepEqual(ui.etat().choix, PERSONNE);
ui.selectionner(null);
assert.equal(ui.etat().choix, null);
ui.choisir(RESERVATION);
ui.montrerTour(2);
assert.equal(ui.etat().choix, null);
ui.choisir(PERSONNE);
ui.montrerTour(2);
assert.equal(ui.etat().choix, null, 'le même tour remontré oublie aussi');
});
test('poserSurPlace pose un refus ou des avertissements sur une place, puis null les retire ; aucune transition ne reçoit la séance', () => {
const ui = creerEtatUi();
const pose = {
place: { table: 3, siege: null },
refus: { code: 'PERSONNE_EXCLUE', details: { participant: 1 } },
avertissements: [{ code: 'TABLE_GELEE', details: { table: 3 } }],
};
ui.poserSurPlace(pose);
assert.deepEqual(ui.etat(), { ...DEPART, pose });
assert.ok(Object.isFrozen(ui.etat().pose));
ui.poserSurPlace(null);
assert.deepEqual(ui.etat(), DEPART);
assert.throws(() => ui.poserSurPlace({ place: null, refus: null, avertissements: [] }), RangeError);
assert.equal(ui.choisir.length, 1);
assert.equal(ui.poserSurPlace.length, 1);
});
test('montrerTour, montrerProposition et un changement de section retirent la pose ; la section remontrée la garde', () => {
const ui = creerEtatUi();
const pose = { place: { table: 7, siege: 1 }, refus: { code: 'PLACE_RESERVEE', details: { participant: 4 } }, avertissements: [] };
ui.poserSurPlace(pose);
ui.montrerTour(2);
assert.equal(ui.etat().pose, null, 'montrerTour');
ui.poserSurPlace(pose);
ui.montrerProposition(1);
assert.equal(ui.etat().pose, null, 'montrerProposition');
ui.allerA('plan');
ui.poserSurPlace(pose);
ui.allerA('plan');
assert.deepEqual(ui.etat().pose, pose, 'la même section');
ui.allerA('tables');
assert.equal(ui.etat().pose, null, 'une autre section');
});
});

View file

@ -1,108 +0,0 @@
<!-- © 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

@ -1,210 +0,0 @@
<!-- © 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

@ -1,67 +0,0 @@
<!-- © 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

@ -1,869 +0,0 @@
// © 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

@ -1,125 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// 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';
import SectionImport from '../import/SectionImport.svelte';
import SectionParticipants from '../participants/SectionParticipants.svelte';
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');
const ui = getContext('ui');
const seance = getContext('seance');
// Le composant de chaque section.
const COMPOSANTS = new Map([
['import', SectionImport],
['participants', SectionParticipants],
['tables', SectionTables],
['plan', SectionPlan],
['diagnostic', Diagnostic],
['generation', SectionGeneration],
['propositions', SectionPropositions],
]);
const Section = $derived(COMPOSANTS.get($ui.section));
</script>
{#snippet tete()}
<EnteteEvenement />
<nav aria-label={t('coquille.navigation')}>
{#each SECTIONS as nom (nom)}
<button
type="button"
data-commande="section"
data-cible={nom}
aria-current={$ui.section === nom ? 'page' : undefined}
onclick={() => ui.allerA(nom)}>{t(cleDeSection(nom))}</button
>
{/each}
</nav>
{/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;
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;
cursor: pointer;
}
/* La section ouverte se distingue par sa forme : texte souligné et gras. */
button[aria-current='page'] {
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -1,72 +0,0 @@
<!-- © 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

@ -1,134 +0,0 @@
// © 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

@ -1,218 +0,0 @@
// © 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

@ -1,40 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les sections de l'événement ouvert. Aucune n'en exige une autre : la
// navigation les offre toutes, dans l'ordre du parcours de l'opérateur
// (§ 2.1), et la séance refuse elle-même ce qui ne peut pas se faire.
// Chaque section et la clé de son titre, qui la nomme aussi dans la
// navigation, dans l'ordre du § 2.1.
const CLES = new Map([
['import', 'coquille.section.import'],
['participants', 'coquille.section.participants'],
['tables', 'coquille.section.tables'],
['plan', 'coquille.section.plan'],
['diagnostic', 'coquille.section.diagnostic'],
['generation', 'coquille.section.generation'],
['propositions', 'coquille.section.propositions'],
]);
/** Les sections de l'itération, dans l'ordre du § 2.1 : import,
* participants, tables, plan, diagnostic, generation, propositions. Aucune
* n'en exige une autre. Le titre et le nom de navigation de chacune sont la
* clé coquille.section.<nom> de coquille.js. */
export const SECTIONS = Object.freeze([...CLES.keys()]);
/** La section qu'un événement montre à son ouverture. */
export const SECTION_OUVERTURE = 'plan';
/**
* La clé du titre d'une section, coquille.section.<nom>.
*
* @param {string} nom
* @returns {string}
* @throws {RangeError} hors de SECTIONS
*/
export function cleDeSection(nom) {
const cle = CLES.get(nom);
if (cle === undefined) throw new RangeError(`section inconnue : ${JSON.stringify(nom)}`);
return cle;
}

View file

@ -1,40 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les sections de l'événement ouvert (§ 2.1) : sept noms distincts, dans
// l'ordre du parcours de l'opérateur, chacun nommé par sa clé
// coquille.section.<nom> de la table de la coquille ; la section d'ouverture
// en est une.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLE as COQUILLE } from '../libelles/coquille.js';
import { SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
describe('sections de l’événement (§ 2.1)', () => {
test('sept noms distincts, dans l’ordre du parcours, figés', () => {
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
assert.equal(new Set(SECTIONS).size, 7);
assert.ok(Object.isFrozen(SECTIONS));
});
test('chaque section a sa clé coquille.section.<nom>, une chaîne non vide, que rend cleDeSection', () => {
assert.ok(SECTIONS.length > 0);
for (const nom of SECTIONS) {
assert.equal(cleDeSection(nom), `coquille.section.${nom}`);
const texte = COQUILLE.get(cleDeSection(nom));
assert.equal(typeof texte, 'string', nom);
assert.notEqual(texte.trim(), '', nom);
}
});
test('la section d’ouverture est le plan, l’une des sections', () => {
assert.equal(SECTION_OUVERTURE, 'plan');
assert.ok(SECTIONS.includes(SECTION_OUVERTURE));
});
test('cleDeSection lève RangeError hors de SECTIONS', () => {
for (const faute of ['accueil', '', 'Plan', 'constructor', undefined]) {
assert.throws(() => cleDeSection(faute), RangeError, String(faute));
}
});
});

View file

@ -1,61 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'avancement d'une génération en cours (§ 5.10) : les mouvements évalués
// sur le total, en mots et en barre — une barre de progression accessible,
// dont aria-valuenow porte les mouvements évalués —, et « Annuler la
// génération », qui appelle annuler. fait et total viennent du contrôleur
// de génération ; total est le produit du nombre de propositions et du
// compte d'arrêt, jamais nul pendant un calcul.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
let { fait, total, annuler } = $props();
const t = getContext('t');
const largeur = $derived(total > 0 ? `${(fait / total) * 100}%` : '0%');
</script>
<div class="avancement" data-avancement>
<p>{t('generation.avancement.texte', { fait, total })}</p>
<div
class="barre"
role="progressbar"
aria-label={t('generation.avancement.barre')}
aria-valuemin="0"
aria-valuemax={total}
aria-valuenow={fait}
>
<div class="plein" style:width={largeur}></div>
</div>
<Commande cle="generation.commande.annuler" nom="annuler" executer={annuler} />
</div>
<style>
.avancement {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
p {
margin: 0;
}
.barre {
box-sizing: border-box;
width: min(30rem, 100%);
height: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
}
.plein {
height: 100%;
background-color: var(--couleur-page-texte);
}
</style>

View file

@ -1,636 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La section de la génération dans un vrai moteur de rendu (§ 5.7, § 5.9,
// § 5.10, § 2.1 étape 9 et « ce qui doit avoir eu lieu », § 15.4, § 8.4,
// § 9) : ses réglages, leur libellé et leur unité ; la graine, saisie de la
// commande ; le travailleur, qui rend ce que rend rechercher ; l'application
// vivante pendant le calcul ; l'annulation ; les refus posés à côté de
// « Générer » avec leur remède ; la durée observée ; la génération longue,
// annoncée en place.
//
// Chaque épreuve monte App par monterApp, sur le système de fichiers
// simulé, et nomme son exécuteur : le travailleur — inclus en ligne comme
// src/main.js l'inclut — pour un calcul hors du fil ; un exécuteur que
// l'épreuve résout à la main quand l'horloge pilotée doit avancer pendant
// le calcul ; le direct ailleurs, compté. L'épreuve lit le rendu par les
// attributs data-… du contrat, et ce que la séance a écrit : la révision, le
// journal, l'état sur le disque, le relevé des écritures. Le texte attendu
// vient de la même traduction que celle de l'application ; 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 } from 'vitest';
import { flushSync } from 'svelte';
import { creerExecuteurTravailleur } from '../../application/executeur_travailleur.js';
import { ErreurAnnulee, creerExecuteurDirect } from '../../application/generation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Travailleur from './travailleur.js?worker&inline';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Les champs de la section, dans l'ordre de l'écran.
const CHAMPS = ['graine', 'generation.nombre', 'generation.arret', 'generation.historique'];
// 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 exécuteurs ---------------------------------------------------------------
// L'exécuteur du travailleur, celui de src/main.js ; travailleurs compte les
// travailleurs créés.
function executeurTravailleur() {
const suivi = { travailleurs: 0 };
const executeur = creerExecuteurTravailleur(() => {
suivi.travailleurs += 1;
return new Travailleur();
});
return { executeur, suivi };
}
// Un exécuteur dont chaque lancer est relevé : configuration, reglages, et
// ce que rend son résultat. lancements en garde la liste.
function releve(base) {
const lancements = [];
const executeur = {
lancer(configuration, reglages) {
const execution = base.lancer(configuration, reglages);
const lancement = { configuration, reglages, propositions: null };
lancements.push(lancement);
execution.resultat.then(
(propositions) => {
lancement.propositions = propositions;
},
() => undefined,
);
return execution;
},
};
return { executeur, lancements };
}
// Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé,
// avec resoudre(propositions), avancer(fait, total) et annulee.
function executeurAMain() {
const executions = [];
const executeur = {
lancer(configuration, reglages) {
const abonnes = new Set();
let resoudre;
let rejeter;
const resultat = new Promise((ok, ko) => {
resoudre = ok;
rejeter = ko;
});
const execution = {
configuration,
reglages,
resoudre,
annulee: false,
avancer: (fait, total) => {
for (const fn of [...abonnes]) fn(fait, total);
},
};
executions.push(execution);
return {
surAvancement(fn) {
abonnes.add(fn);
return () => abonnes.delete(fn);
},
resultat,
annuler() {
execution.annulee = true;
rejeter(new ErreurAnnulee());
},
};
},
};
return { executeur, executions };
}
// --- Les lectures du rendu ----------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="generation"]');
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
const fenetre = () => document.querySelector('dialog[open]');
const duree = (m) => section(m).querySelector('[data-duree]');
const question = (m) => section(m).querySelector('[data-question="longue"]');
const barre = (m) => section(m)?.querySelector('[data-avancement] [role="progressbar"]') ?? null;
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
const versSection = (m, cible) => dans(m, `[data-commande="section"][data-cible="${cible}"]`);
// La signature de ce que le plan montre : la tête de chaque ligne des
// listes — son rang et ce qui l'occupe, sans le nom, que la largeur mesurée
// abrège —, puis les marques de chaque chaise.
const montre = (m) => ({
listes: tous(m, '[data-liste]').map((liste) =>
[...liste.querySelectorAll('[data-partie="tete"]')].map((tete) => tete.textContent),
),
marques: tous(m, '[data-siege]').map((chaise) => [
chaise.getAttribute('data-siege'),
[...chaise.querySelectorAll('[data-marque]')].map((marque) => marque.getAttribute('data-marque')),
]),
});
// Choisit le tour r dans le sélecteur de la barre de vue du plan.
function choisirTour(m, r) {
const select = dans(m, '[data-section="plan"] [data-champ="tour"]');
expect(select, 'sélecteur de tour').not.toBeNull();
select.value = String(r);
select.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
// Cadre le plan sur ses tables, puis le rapproche, un zoom après l'autre,
// jusqu'à ce que les listes de noms et les chaises paraissent : elles ne se
// rendent qu'au-delà d'un zoom. Partie du même cadrage, la vue atteinte est
// la même à chaque appel.
async function cadrerPourLesListes(m) {
const commande = (nom) => dans(m, `[data-section="plan"] [data-commande="${nom}"]`);
commande('ajuster').click();
flushSync();
await images(2);
for (let cran = 0; tous(m, '[data-liste]').length === 0 || tous(m, '[data-siege]').length === 0; cran += 1) {
expect(cran, 'zooms avant les listes et les chaises').toBeLessThan(30);
commande('zoomer').click();
flushSync();
await images(2);
}
}
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
const controleur = (m) => m.app.generation();
const baseDe = (m) => m.seance.etat().evenement.base;
const etatSurDisque = (m) => m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.etat}`);
const journalDe = (m) => lireJournal(m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.journal}`));
// Attend que l'assertion passe, le rendu de Svelte à jour, image après image ;
// images borne l'attente.
async function attendre(assertion, images = 1200) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= images) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// Laisse passer n images.
async function images(n) {
for (let i = 0; i < n; i += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
// Attend que la file de la séance ait passé ce qui s'y trouve, par une
// lecture admise dans tout mode qui n'écrit rien, puis le rendu.
async function passer(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await images(1);
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 { left, top, width, height } = element.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await passer(m);
}
// Saisit texte dans le champ, la valeur courante remplacée, puis le quitte
// par Tab, ou le valide par Entrée.
async function saisir(m, entree, texte, { fin = 'Tab' } = {}) {
await cliquer(m, entree);
entree.select();
for (const caractere of texte) await touche(caractere);
if (fin !== null) await touche(fin);
await passer(m);
}
// Vrai tant que le contrôleur publie une génération en cours.
function enCours(m) {
let lu;
controleur(m).subscribe((etat) => {
lu = etat;
})();
return lu.enCours;
}
// --- Les événements d'épreuve -----------------------------------------------------
// Un montage sur l'exécuteur donné, où la démonstration cle est chargée,
// en écriture sauf ecriture faux, la section de la génération ouverte.
async function demonstration(cle, { executeur, ecriture = true } = {}) {
const m = await monter({ executeur });
await m.facade.chargerDemonstration(cle);
if (ecriture) await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
return m;
}
// Les réglages de génération de la charge changés par la séance.
async function regler(m, reglages) {
for (const [cle, valeur] of Object.entries(reglages)) {
await m.seance.executer('modifierReglage', { cle: `generation.${cle}`, valeur });
}
flushSync();
}
// --- Les épreuves -----------------------------------------------------------------
describe('les réglages (§ 5.7, § 5.10)', () => {
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const entrees = [...section(m).querySelectorAll('input')];
expect(entrees.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of CHAMPS) {
const cleLibelle = `generation.reglage.${cle.replace('generation.', '')}`;
expect(etiquette(m, cle).textContent).toBe(t(cleLibelle));
}
expect(t('generation.reglage.arret')).toContain('mouvements évalués par proposition');
expect(t('generation.reglage.nombre')).toContain('propositions');
expect(t('generation.reglage.historique')).toContain('mouvements');
expect(t('generation.reglage.graine')).toContain('entier');
expect(champ(m, 'generation.historique').value).toBe('1000');
expect(champ(m, 'generation.nombre').value).toBe('5');
expect(champ(m, 'generation.arret').value).toBe('200000');
for (const entree of entrees) {
expect(etiquette(m, entree.dataset.champ).textContent).not.toMatch(/durée|seconde|minute/i);
}
});
test('exécuteur direct : changer chaque réglage et quitter le champ — une entrée, ce réglage de la charge changé, les deux autres non', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
// Chaque réglage de la charge, et une valeur neuve, distincte de celle
// de la démonstration.
const saisies = { nombre: 3, arret: 40000, historique: 500 };
const noms = Object.keys(saisies);
expect(noms.length).toBe(3);
for (const nom of noms) {
const avant = { ...charge(m).reglages.generation };
expect(avant[nom], nom).not.toBe(saisies[nom]);
const r0 = revision(m);
const entrees = journalDe(m).entrees.length;
await saisir(m, champ(m, `generation.${nom}`), String(saisies[nom]));
expect(revision(m), nom).toBe(r0 + 1);
expect(journalDe(m).entrees.length, nom).toBe(entrees + 1);
expect(charge(m).reglages.generation, nom).toEqual({ ...avant, [nom]: saisies[nom] });
expect(champ(m, `generation.${nom}`).value, nom).toBe(String(saisies[nom]));
}
});
test('exécuteur direct : en lecture, la frappe dans un réglage est refusée avec « Modifier » ; la graine se saisit sans rien écrire', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
const ecritures = m.trace.length;
const arret = champ(m, 'generation.arret');
await saisir(m, arret, '9', { fin: null });
expect(arret.value).toBe('200000');
const refus = refusPres(arret, 'MODE_LECTURE');
expect(refus).not.toBeNull();
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
await touche('Escape');
await saisir(m, champ(m, 'graine'), '77');
expect(champ(m, 'graine').value).toBe('77');
expect(champ(m, 'graine').parentElement.querySelector('[data-refus]')).toBeNull();
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
});
test('exécuteur direct, relevé : la graine vaut 1 à l’ouverture, 2 après une génération lancée à 1', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
await regler(m, { nombre: 2, arret: 2000 });
expect(champ(m, 'graine').value).toBe('1');
await cliquer(m, generer(m));
await attendre(() => expect(charge(m).propositions.length).toBe(2));
expect(lancements.map(({ reglages }) => reglages.graine)).toEqual([1]);
await attendre(() => expect(champ(m, 'graine').value).toBe('2'));
});
test('exécuteur direct, relevé : graine -1, 1,5 ou 2^32 — refus sur le champ, rien n’est lancé ni écrit', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
const saisies = ['-1', '1,5', String(2 ** 32)];
expect(saisies.length).toBe(3);
for (const saisie of saisies) {
const attendu = controleur(m).valider({ ...controleur(m).reglagesProposes(), graine: saisie }).refus;
expect(attendu.map(({ champ: nom }) => nom)).toEqual(['graine']);
await saisir(m, champ(m, 'graine'), saisie);
await cliquer(m, generer(m));
const graine = champ(m, 'graine');
const refus = refusPres(graine, attendu[0].code);
expect(refus, saisie).not.toBeNull();
expect(refus.querySelector('p').textContent).toBe(t(attendu[0].code, attendu[0].details));
expect(graine.getAttribute('aria-invalid')).toBe('true');
expect(generer(m).parentElement.querySelector('[data-refus]')).toBeNull();
}
expect(lancements).toEqual([]);
expect(charge(m).propositions).toEqual([]);
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
});
});
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
const { executeur: travailleur, suivi } = executeurTravailleur();
const { executeur, lancements } = releve(travailleur);
const m = await demonstration('petite', { executeur });
await regler(m, { nombre: 3, arret: 20000 });
const configuration = configurationDepuisCharge(charge(m));
await saisir(m, champ(m, 'graine'), '4242');
await cliquer(m, generer(m));
await attendre(() => expect(lancements[0]?.propositions ?? null).not.toBeNull());
expect(suivi.travailleurs).toBe(1);
const reglages = { graine: 4242, nombre: 3, arret: 20000, historique: 1000 };
expect(lancements[0].reglages).toEqual(reglages);
const attendues = rechercher(configuration, reglages);
expect(attendues.length).toBe(3);
expect(lancements[0].propositions).toEqual(attendues);
await attendre(() => expect(charge(m).propositions.length).toBe(3));
expect(charge(m).propositions.map(({ graine }) => graine)).toEqual(attendues.map(({ graine }) => graine));
}, 60_000);
test('exécuteur du travailleur : grande démonstration, compte élevé — pendant le calcul, la navigation et le changement du tour affiché mettent le DOM à jour ; l’avancement croît, aria-busy suit le calcul', async () => {
const { executeur } = executeurTravailleur();
const m = await demonstration('grande', { executeur });
await regler(m, { nombre: 4, arret: 400000 });
// Une réservation du seul tour 1, à un siège : une marque et une ligne
// qui ne paraissent qu'à ce tour. Le plan de chaque tour est relevé
// avant le calcul.
const { participants, reservations } = charge(m);
const libre = participants.find(({ id }) => !reservations.some(({ participant }) => participant === id));
await m.seance.executer('reserver', { participant: libre.id, table: 1, siege: 2, portee: 'tour', tour: 1 });
await cliquer(m, versSection(m, 'plan'));
await cadrerPourLesListes(m);
choisirTour(m, 1);
await images(2);
const tour1 = montre(m);
choisirTour(m, 2);
await images(2);
const tour2 = montre(m);
expect(tour1.listes.length).toBeGreaterThan(0);
expect(tour1.marques.length).toBeGreaterThan(0);
expect(tour2.listes).not.toEqual(tour1.listes);
choisirTour(m, 1);
await cliquer(m, versSection(m, 'generation'));
expect(section(m).getAttribute('aria-busy')).toBe('false');
await cliquer(m, generer(m));
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
expect(section(m).getAttribute('aria-busy')).toBe('true');
const valeurs = [Number(barre(m).getAttribute('aria-valuenow'))];
// Un geste de navigation pendant le calcul : le DOM change avant la fin.
await cliquer(m, versSection(m, 'participants'));
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
expect(section(m)).toBeNull();
expect(enCours(m)).toBe(true);
// Le plan pendant le calcul : il montre le tour 1, puis, le tour
// changé, les listes et les marques du tour 2, le calcul toujours en
// cours.
await cliquer(m, versSection(m, 'plan'));
await cadrerPourLesListes(m);
await attendre(() => expect(montre(m)).toEqual(tour1));
choisirTour(m, 2);
await attendre(() => expect(montre(m)).toEqual(tour2));
expect(m.ui.etat().tour).toBe(2);
expect(enCours(m)).toBe(true);
await cliquer(m, versSection(m, 'generation'));
expect(section(m).getAttribute('aria-busy')).toBe('true');
while (enCours(m)) {
const lue = barre(m)?.getAttribute('aria-valuenow');
if (lue !== undefined && Number(lue) !== valeurs.at(-1)) valeurs.push(Number(lue));
await images(1);
}
expect(valeurs.length).toBeGreaterThanOrEqual(2);
for (let i = 1; i < valeurs.length; i += 1) expect(valeurs[i]).toBeGreaterThan(valeurs[i - 1]);
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
expect(barre(m)).toBeNull();
await attendre(() => expect(charge(m).propositions.length).toBeGreaterThan(0));
}, 120_000);
test('exécuteur du travailleur : annuler est immédiat ; plus aucun avancement ; l’état prêt revient ; propositions, journal et état sur le disque inchangés', async () => {
const { executeur } = executeurTravailleur();
const m = await demonstration('grande', { executeur });
await regler(m, { nombre: 4, arret: 400000 });
const avant = { propositions: charge(m).propositions, journal: journalDe(m), etat: etatSurDisque(m) };
await cliquer(m, generer(m));
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
const recus = [];
const desabonner = controleur(m).subscribe(({ fait }) => recus.push(fait));
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
expect(enCours(m)).toBe(false);
expect(section(m).getAttribute('aria-busy')).toBe('false');
expect(barre(m)).toBeNull();
expect(generer(m)).not.toBeNull();
const apresAnnulation = recus.length;
await images(30);
expect(recus.length).toBe(apresAnnulation);
desabonner();
expect(charge(m).propositions).toEqual(avant.propositions);
expect(journalDe(m)).toEqual(avant.journal);
expect(etatSurDisque(m)).toBe(avant.etat);
expect(fenetre()).toBeNull();
}, 60_000);
});
describe('les refus à la commande (§ 5.9, § 15.4, § 9)', () => {
test('exécuteur direct, relevé : 13 participants, 4 tables de 3 — PLACES_MANQUANTES {placesManquantes: 1} près de « Générer », remède vers les tables ; rien de lancé ni d’écrit', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
flushSync();
expect(charge(m).participants.length).toBe(13);
expect(charge(m).tables.length).toBe(4);
const r0 = revision(m);
const ecritures = m.trace.length;
await cliquer(m, generer(m));
const refus = refusPres(generer(m), 'PLACES_MANQUANTES');
expect(refus).not.toBeNull();
const message = refus.querySelector('p').textContent;
expect(message).toBe(t('PLACES_MANQUANTES', { placesManquantes: 1 }));
expect(message).toContain('1 place');
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.tables'));
expect(fenetre()).toBeNull();
await cliquer(m, remede);
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
expect(lancements).toEqual([]);
expect(charge(m).propositions).toEqual([]);
expect(revision(m)).toBe(r0);
expect(m.trace.length).toBe(ecritures);
});
test('exécuteur direct, relevé : un événement neuf en écriture — AUCUN_PARTICIPANT puis, des participants sans table, AUCUNE_TABLE ; chaque remède ouvre sa section', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await monter({ executeur });
await m.seance.creer({ nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 });
await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
let ecritures = m.trace.length;
await cliquer(m, generer(m));
const sansPersonne = refusPres(generer(m), 'AUCUN_PARTICIPANT');
expect(sansPersonne).not.toBeNull();
expect(sansPersonne.querySelector('p').textContent).toBe(t('AUCUN_PARTICIPANT', {}));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await cliquer(m, sansPersonne.querySelector('[data-commande="ouvrirSection"]'));
expect(m.ui.etat().section).toBe('participants');
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
for (const nom of ['Pervenche', 'Lacasse']) await m.seance.executer('ajouterParticipant', { champs: { nom } });
m.ui.allerA('generation');
flushSync();
ecritures = m.trace.length;
await cliquer(m, generer(m));
const sansTable = refusPres(generer(m), 'AUCUNE_TABLE');
expect(sansTable).not.toBeNull();
expect(sansTable.querySelector('p').textContent).toBe(t('AUCUNE_TABLE', {}));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await cliquer(m, sansTable.querySelector('[data-commande="ouvrirSection"]'));
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
expect(lancements).toEqual([]);
});
test('exécuteur direct, relevé : en lecture, « Générer » refusé à côté de lui avec « Modifier » ; sur un plan bloqué, avec « Débloquer » ; aucune fenêtre', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur, ecriture: false });
const ecritures = m.trace.length;
await cliquer(m, generer(m));
const lecture = refusPres(generer(m), 'MODE_LECTURE');
expect(lecture).not.toBeNull();
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
const configuration = configurationDepuisCharge(charge(m));
const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 2000 });
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
await m.seance.executer('retenirProposition', { id: charge(m).propositions[0].id });
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, generer(m));
const bloque = refusPres(generer(m), 'LECTURE_SEULE');
expect(bloque).not.toBeNull();
expect(bloque.querySelector('p').textContent).toBe(t('LECTURE_SEULE', m.seance.etat().refus.details));
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecrituresBloque);
expect(lancements).toEqual([]);
});
});
describe('la durée du calcul (§ 5.10, § 19.4)', () => {
test('exécuteur résolu à la main : sans mesure, le panneau le dit sans écrire 0 ; 1 800 ms d’horloge pilotée pour 5 × 200 000 mouvements, durée et réglages affichés', async () => {
const { executeur, executions } = executeurAMain();
const m = await demonstration('petite', { executeur });
expect(duree(m).textContent).toBe(t('generation.duree.inconnue'));
expect(duree(m).textContent).not.toMatch(/\b0\b/);
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
expect(executions[0].reglages).toEqual({ graine: 1, nombre: 5, arret: 200000, historique: 1000 });
expect(section(m).getAttribute('aria-busy')).toBe('true');
m.horloge.avancer(1800);
const propositions = rechercher(executions[0].configuration, { graine: 1, nombre: 5, arret: 2000 });
executions[0].resoudre(propositions);
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
await attendre(() => expect(charge(m).propositions.length).toBe(5));
const attendu = t('generation.duree.observee', { dureeMs: 1800, nombre: 5, arret: 200000 });
expect(duree(m).textContent).toBe(attendu);
expect(attendu).toContain('1,8 s');
expect(attendu).toContain('5 \u{00D7} 200\u{202F}000');
});
test('exécuteur résolu à la main : 10^6 mouvements en 2 s ; 6 × 10^8 annoncés en place, « environ vingt minutes à ce réglage » ; renoncer n’écrit rien, confirmer lance ; sous le seuil, sans question', async () => {
const { executeur, executions } = executeurAMain();
const m = await demonstration('petite', { executeur });
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
m.horloge.avancer(2000);
executions[0].resoudre([]);
await attendre(() => expect(enCours(m)).toBe(false));
expect(controleur(m).derniereGeneration()).toMatchObject({ dureeMs: 2000, mouvements: 1_000_000 });
await saisir(m, champ(m, 'generation.arret'), '120000000');
expect(charge(m).reglages.generation).toMatchObject({ nombre: 5, arret: 120_000_000 });
const journal = journalDe(m);
const ecritures = m.trace.length;
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
expect(question(m)).not.toBeNull();
expect(question(m).textContent).toContain('environ vingt minutes à ce réglage');
expect(fenetre()).toBeNull();
await cliquer(m, question(m).querySelector('[data-commande="renoncerGeneration"]'));
expect(question(m)).toBeNull();
expect(executions.length).toBe(1);
expect(journalDe(m)).toEqual(journal);
expect(m.trace.length).toBe(ecritures);
await cliquer(m, generer(m));
await cliquer(m, question(m).querySelector('[data-commande="confirmerGeneration"]'));
expect(executions.length).toBe(2);
expect(executions[1].reglages).toMatchObject({ nombre: 5, arret: 120_000_000 });
expect(question(m)).toBeNull();
expect(section(m).getAttribute('aria-busy')).toBe('true');
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
expect(executions[1].annulee).toBe(true);
await attendre(() => expect(enCours(m)).toBe(false));
await regler(m, { arret: 200000 });
await cliquer(m, generer(m));
expect(question(m)).toBeNull();
expect(executions.length).toBe(3);
executions[2].resoudre([]);
await attendre(() => expect(enCours(m)).toBe(false));
});
});

View file

@ -1,128 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les réglages d'une génération (§ 5.7, § 5.10) : la graine, puis le
// nombre de propositions, le compte d'arrêt et l'historique, chacun sous
// son libellé, qui porte son unité ; puis la durée observée du dernier
// calcul de la séance, et ce qu'elle laisse attendre des réglages courants.
// Aucun champ ne prend une durée : la recherche s'arrête sur un compte.
//
// Les trois réglages de la charge (generation.nombre, generation.arret,
// generation.historique) sont des Champ : une saisie se lit par la
// validation du contrôleur de génération, la même que celle de
// « Générer », et s'écrit à la sortie du champ par modifierReglage — une
// entrée par changement ; en lecture, la séance refuse la première frappe.
//
// La graine n'est pas au fichier : c'est une saisie de la commande, vivante
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire.
import { getContext } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
let { charge, mesure, estimation, graine = $bindable(), refusGraine = null, saisirGraine } = $props();
const t = getContext('t');
const seance = getContext('seance');
const generation = getContext('generation');
const uid = $props.id();
const idGraine = `${uid}-graine`;
const idRefusGraine = `${uid}-graine-refus`;
// Les réglages de la charge, dans l'ordre de l'écran.
const REGLAGES = ['nombre', 'arret', 'historique'];
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
// à défaut le premier, sinon la valeur lue.
const lecteur = (nom) => (texte) => {
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), [nom]: texte });
const propre = refus.find(({ champ }) => champ === nom) ?? refus[0];
if (propre !== undefined) return { refus: { code: propre.code, details: propre.details } };
return { valeur: reglages[nom] };
};
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
</script>
<div class="reglages" data-reglages>
<div class="champs">
<div class="champ">
<label for={idGraine}>{t('generation.reglage.graine')}</label>
<input
id={idGraine}
type="text"
data-champ="graine"
bind:value={graine}
aria-invalid={refusGraine === null ? undefined : 'true'}
aria-describedby={refusGraine === null ? undefined : idRefusGraine}
oninput={() => saisirGraine()}
/>
{#if refusGraine !== null}
<Refus id={idRefusGraine} refus={refusGraine} />
{/if}
</div>
{#each REGLAGES as nom (nom)}
<Champ
cle={`generation.${nom}`}
etiquette={`generation.reglage.${nom}`}
valeur={charge.reglages.generation[nom]}
lire={lecteur(nom)}
executer={ecrire(nom)}
/>
{/each}
</div>
{#if mesure === null}
<p data-duree>{t('generation.duree.inconnue')}</p>
{:else}
<p data-duree>{t('generation.duree.observee', mesure)}</p>
<p data-estimation>{t('generation.duree.estimee', { secondes: estimation.secondes })}</p>
{/if}
</div>
<style>
.reglages {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
.champs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-4);
}
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
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;
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
p {
margin: 0;
}
</style>

View file

@ -1,173 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de la génération (§ 2.1 étape 9, § 5.7, § 5.9, § 5.10,
// § 15.4) : les réglages, « Générer », la question d'une génération longue,
// l'avancement d'un calcul et l'issue du dernier. Tout passe par le
// contrôleur de génération du contexte, qui seul contrôle, lance, mesure et
// enregistre ; la section ne refait aucun de ses contrôles.
//
// « Générer » lit la graine saisie et les réglages de la charge par
// controleur.valider : un refus de la graine se pose contre elle, et rien
// ne part. Un refus du contrôleur — l'état de la séance, ce qui doit avoir
// eu lieu, les places manquantes — se pose à côté de « Générer », avec son
// remède ; aucune fenêtre ne s'ouvre. Une génération longue
// (CONFIRMATION_REQUISE) pose en place la question qui nomme l'ordre de
// grandeur attendu : « Générer quand même » la relance confirmée, « Ne pas
// générer » la retire sans rien écrire. La question tombe à toute autre
// frappe dans la graine, au clic de « Générer », et dès que la charge
// change : elle ne vaut que pour les réglages qu'elle nomme.
//
// La graine est pré-remplie de controleur.grainePropose() au montage, puis,
// à chaque lancement, de la graine suivante. Pendant le calcul, la section
// porte aria-busy="true" et montre l'avancement ; « Générer » reste offert,
// et le contrôleur refuse un second lancement. Une annulation n'est pas un
// refus : l'état prêt revient, et l'issue le dit. Le contrôleur vit plus
// longtemps que la section : remontée pendant un calcul, elle en retrouve
// l'avancement par subscribe.
import { getContext, onDestroy, untrack } from 'svelte';
import { ErreurCommande } from '../../application/erreurs.js';
import { ErreurAnnulee } from '../../application/generation.js';
import Commande from '../primitives/Commande.svelte';
import Avancement from './Avancement.svelte';
import Reglages from './Reglages.svelte';
const t = getContext('t');
const seance = getContext('seance');
const generation = getContext('generation');
// La graine saisie, et son refus ; la question d'une génération longue,
// { estimation } ; l'issue de la dernière génération lancée d'ici,
// { ajoutees, identiques }.
let graine = $state(String(generation.grainePropose()));
let refusGraine = $state.raw(null);
let question = $state.raw(null);
let issue = $state.raw(null);
// Un lancement — enCours qui passe à vrai — met dans le champ la graine
// suivante et retire la question, le refus de la graine et l'issue d'avant.
let enCoursAvant = false;
const desabonner = generation.subscribe((etat) => {
if (etat.enCours && !enCoursAvant) {
graine = String(generation.grainePropose());
refusGraine = null;
question = null;
issue = null;
}
enCoursAvant = etat.enCours;
});
onDestroy(desabonner);
const charge = $derived($seance.charge);
// La question tombe quand la charge change.
$effect.pre(() => {
void charge;
untrack(() => {
question = null;
});
});
// La mesure du dernier calcul de la séance, relue à chaque publication du
// contrôleur, et l'estimation des réglages de la charge qu'elle donne.
const mesure = $derived.by(() => {
void $generation;
return generation.derniereGeneration();
});
const estimation = $derived(
mesure === null || charge === null ? null : generation.estimer(charge.reglages.generation),
);
function saisirGraine() {
refusGraine = null;
question = null;
}
// Lance une génération sur la graine saisie et les réglages de la charge ;
// confirmee : la réponse à la question d'une génération longue.
async function lancer(confirmee) {
refusGraine = null;
issue = null;
if (!confirmee) question = null;
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
const deGraine = refus.find(({ champ }) => champ === 'graine');
if (deGraine !== undefined) {
refusGraine = { code: deGraine.code, details: deGraine.details };
question = null;
return;
}
if (reglages === null) throw new ErreurCommande(refus[0].code, refus[0].details);
try {
const rendu = await generation.generer(reglages, { confirmee });
issue = { ajoutees: rendu.ajoutees.length, identiques: rendu.identiques };
} catch (erreur) {
if (erreur instanceof ErreurAnnulee) return;
if (erreur?.code === 'CONFIRMATION_REQUISE') {
question = { estimation: erreur.details.estimation };
return;
}
throw erreur;
}
}
</script>
<section data-section="generation" aria-busy={$generation.enCours ? 'true' : 'false'}>
<h2>{t('coquille.section.generation')}</h2>
{#if charge !== null}
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} />
<div class="lancer">
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
</div>
{#if question !== null}
<div class="question" data-question="longue" role="status">
<p>{t('generation.longue.question', question)}</p>
<div class="issues">
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
</div>
</div>
{/if}
{#if $generation.enCours}
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
{:else if issue !== null}
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
{:else if $generation.annulee === 'commande'}
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
{:else if $generation.annulee === 'seance'}
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
{/if}
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
.question {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border-left: 3px solid var(--couleur-avis);
}
.issues {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
p {
margin: 0;
}
</style>

View file

@ -1,12 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le point d'entrée du travailleur de génération (§ 5.10) : un module qui
// s'exécute dans la portée du travailleur, self, et la branche sur la
// recherche du moteur par brancherTravailleur. src/main.js seul l'importe,
// par « ?worker&inline » : Vite l'inclut dans la page, et la coquille, qui
// charge la page par file://, n'a aucun fichier de travailleur à charger.
// L'exécuteur qui lui parle est celui d'executeur_travailleur.js.
import { brancherTravailleur } from '../../application/generation_travailleur.js';
brancherTravailleur(self);

View file

@ -1,322 +0,0 @@
<!-- © 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

@ -1,824 +0,0 @@
// © 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

@ -1,73 +0,0 @@
<!-- © 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

@ -1,293 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// 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>
<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

@ -1,309 +0,0 @@
// © 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

@ -1,408 +0,0 @@
// © 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,115 +3,5 @@
// 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. ». 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'],
]);
// récupération (§ 14.6). Chaque clé commence par « accueil. ».
export const TABLE = new Map();

View file

@ -3,85 +3,4 @@
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
// refus (§ 14.6). Chaque clé commence par « communs. ».
//
// communs.commande.* nomme les commandes de mode et de secours, que la place
// fixe de l'événement et les écrans de la liste reprennent. communs.remede.*
// nomme le remède d'un refus : communs.remede.<commande> pour chaque commande
// du registre (src/application/commandes.js), communs.remede.<geste> pour un
// geste du contrat, suivi de .<variante> quand ses arguments changent son
// libellé (primitives/commande.js, libelleRemede). Un remède qui fait ce que
// fait une commande de mode en reprend le texte, par texte.
// Le libellé d'une autre clé de cette table.
const meme = (cle) => (details, texte) => texte(cle);
export const TABLE = new Map([
// --- Les commandes de mode et de secours (§ 8.4, § 8.5, § 8.6, § 8.8) -----
['communs.commande.modifier', 'Modifier'],
['communs.commande.debloquer', 'Débloquer'],
['communs.commande.confirmerDeblocage', 'Confirmer le déblocage'],
['communs.commande.lecture', 'Revenir en lecture'],
['communs.commande.ecrireDocuments', 'Écrire dans les Documents'],
['communs.commande.choisirDossier', 'Choisir un dossier\u{2026}'],
['communs.commande.ouvrirPrecedent', 'Ouvrir la version précédente'],
['communs.commande.ouvrirJournal', 'Ouvrir le dernier instant du journal'],
// --- La fenêtre à confirmer (§ 2.1) -------------------------------------------
['communs.confirmation.confirmer', 'Confirmer'],
['communs.confirmation.annuler', 'Annuler'],
['communs.confirmation.enCours', 'Opération en cours\u{2026}'],
// --- Les remèdes des gestes du contrat ------------------------------------
['communs.remede.modifier', meme('communs.commande.modifier')],
['communs.remede.debloquer', meme('communs.commande.debloquer')],
['communs.remede.reprendreVerrou', 'Reprendre le verrou'],
['communs.remede.afficherTour', ({ tour }) => `Afficher le tour ${tour}`],
['communs.remede.choisirPlace', 'Choisir une place'],
['communs.remede.ouvrirSecours.precedent', meme('communs.commande.ouvrirPrecedent')],
['communs.remede.ouvrirSecours.journal', meme('communs.commande.ouvrirJournal')],
['communs.remede.ecrireAilleurs.documents', meme('communs.commande.ecrireDocuments')],
['communs.remede.ecrireAilleurs.choisi', meme('communs.commande.choisirDossier')],
['communs.remede.ouvrirSection.import', "Ouvrir l'import"],
['communs.remede.ouvrirSection.participants', 'Ouvrir les participants'],
['communs.remede.ouvrirSection.tables', 'Ouvrir les tables'],
['communs.remede.ouvrirSection.plan', 'Ouvrir le plan'],
['communs.remede.ouvrirSection.diagnostic', 'Ouvrir le diagnostic'],
['communs.remede.ouvrirSection.generation', 'Ouvrir la génération'],
['communs.remede.ouvrirSection.propositions', 'Ouvrir les propositions'],
// --- Les remèdes de commande : un par commande du registre ------------------
// debloquer, commande et geste, porte la clé des gestes, plus haut.
['communs.remede.ajouterParticipant', 'Ajouter la personne'],
['communs.remede.modifierParticipant', 'Modifier la personne'],
['communs.remede.exclureParticipant', 'Exclure la personne'],
['communs.remede.reintegrerParticipant', 'Réintégrer la personne'],
['communs.remede.supprimerParticipant', 'Supprimer la personne'],
['communs.remede.importerParticipants', 'Importer la liste'],
['communs.remede.changerEtat', "Changer l'état du plan"],
['communs.remede.enregistrerGeneration', 'Enregistrer la génération'],
['communs.remede.retenirProposition', 'Retenir la proposition'],
['communs.remede.effacerPropositions', 'Effacer les propositions'],
['communs.remede.creerTable', 'Créer une table'],
['communs.remede.supprimerTable', 'Supprimer la table'],
['communs.remede.deplacerTable', 'Déplacer la table'],
// sieges : le nombre de places que le remède donne à la table, quand il le
// fixe (SURRESERVATION, § 5.9).
[
'communs.remede.changerPlacesTable',
({ sieges }) => (Number.isInteger(sieges) ? `Passer la table à ${sieges} places` : 'Changer le nombre de places'),
],
['communs.remede.realignerTable', 'Réaligner la table'],
['communs.remede.changerDefaut', 'Changer le nombre de places par défaut'],
['communs.remede.changerTours', 'Changer le nombre de tours'],
['communs.remede.modifierReglage', 'Modifier le réglage'],
['communs.remede.reserver', 'Réserver la place'],
['communs.remede.retirerReservation', 'Retirer la réservation'],
['communs.remede.deplacerReservation', 'Déplacer la réservation'],
['communs.remede.poserTitre', 'Poser le titre'],
['communs.remede.retirerTitre', 'Retirer le titre'],
['communs.remede.designer', 'Désigner la personne'],
['communs.remede.convertirEnTous', 'Réserver pour tous les tours'],
['communs.remede.deplacerDansRetenu', 'Déplacer dans le placement retenu'],
['communs.remede.echangerDansRetenu', 'Échanger les deux places'],
['communs.remede.retirerDuTour', 'Retirer du tour'],
]);
export const TABLE = new Map();

View file

@ -1,13 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la coquille (§ 8.4, § 18.6, § 19.2) : le nom de
// l'application, le titre du bandeau et de la fenêtre, la provenance de la
// construction par mode de Vite, le libellé de mode, l'annonce de format, le
// réglage de thème, « Liste des événements », le panneau « À propos » et son
// bloc, et le titre de chaque section de l'événement, que la navigation et
// chaque section reprennent. Un mode sans clé s'affiche sous son propre nom
// (libelles.js). Chaque clé commence par « coquille. ».
// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du
// bandeau et de la fenêtre, et la provenance de la construction par mode de
// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js).
export const TABLE = new Map([
['coquille.nom', 'Gestion table tournante Libre'],
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
@ -15,60 +11,4 @@ export const TABLE = new Map([
['coquille.provenance.livraison', 'livraison'],
['coquille.provenance.documentation', 'documentation'],
['coquille.provenance.test', 'épreuve'],
// La version à l'écran : le mot qui la précède au premier écran, et la
// marque qui la remplace dans une construction de documentation (§ 19.4).
['coquille.version.libelle', 'version'],
['coquille.version.documentation', 'construction de documentation'],
// Le libellé de mode, que rend cleDeMode (mode.js).
['coquille.mode.lecture', 'Lecture'],
['coquille.mode.ecriture', 'Écriture'],
['coquille.mode.bloque', 'Plan bloqué'],
// L'annonce de la version de format du fichier ouvert (§ 18.6).
['coquille.format.plusRecent', 'Ce fichier s\u{2019}ouvre en lecture seule'],
['coquille.format.plusAncien', 'Ce fichier se convertira à votre première modification'],
// Le réglage de thème (§ 19.2).
['coquille.theme.titre', 'Thème'],
['coquille.theme.clair', 'Clair'],
['coquille.theme.sombre', 'Sombre'],
['coquille.liste', 'Liste des événements'],
// Le panneau « À propos ». Le bloc : nom, version affichée, version
// Windows, plateforme, provenance — son libellé, ou le mode lui-même quand
// il n'en a pas —, version de format.
['coquille.apropos.titre', 'À propos'],
[
'coquille.apropos.bloc',
({ affichee, windows, plateforme, provenance, format }, texte) => {
const cle = `coquille.provenance.${provenance}`;
return [
texte('coquille.nom'),
affichee,
windows,
plateforme,
texte.existe(cle) ? texte(cle) : provenance,
texte('coquille.apropos.format', { format }),
].join(' \u{00B7} ');
},
],
['coquille.apropos.format', ({ format }) => `format ${format}`],
['coquille.apropos.copier', 'Copier'],
['coquille.apropos.fermer', 'Fermer'],
['coquille.apropos.copie', 'Bloc copié dans le presse-papiers'],
['coquille.apropos.copieRefusee', 'Le presse-papiers a refusé la copie'],
// Les sections de l'événement, dans l'ordre du § 2.1 (sections.js), et
// leur navigation.
['coquille.navigation', 'Sections de l\u{2019}événement'],
['coquille.section.import', 'Import'],
['coquille.section.participants', 'Participants'],
['coquille.section.tables', 'Tables et tours'],
['coquille.section.plan', 'Plan'],
['coquille.section.diagnostic', 'Diagnostic'],
['coquille.section.generation', 'Génération'],
['coquille.section.propositions', 'Propositions'],
]);

View file

@ -1,126 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés du diagnostic d'une configuration (§ 5.6, § 14.6). Chaque clé
// commence par « diagnostic. ». Un énoncé (phrases.js) a son texte sous sa
// clé, son remède sous diagnostic.remede.<nom>. Chaque chiffre nomme son
// unité : une collision se compte en occurrences, une paire distincte à
// part (§ 5.4). L'appartenance d'un groupe est une donnée : l'écran la pose
// dans son propre nœud, avant le texte, qu'aucune entrée de cette table ne
// lui fait porter.
import { compte, enumeration } from '../../application/libelles/aides.js';
// « sur le tour » ou « sur les R tours ».
const surLesTours = (tours) => (tours === 1 ? 'sur le tour' : `sur les ${tours} tours`);
export const TABLE = new Map([
// --- Le cadre de l'écran ---------------------------------------------------
['diagnostic.enonces', 'Ce que la configuration impose'],
['diagnostic.nonCalcule', 'La configuration ne se normalise pas : le diagnostic ne se calcule pas.'],
// --- La séparation vidée de son sens (§ 7.5, § 15.4) -----------------------
[
'diagnostic.uneSeuleAppartenance',
'une seule appartenance, portée par chaque personne, sous séparation active : la séparation est vide de sens',
],
['diagnostic.collisionPartout', 'tout placement porte une collision à chaque table, à chaque tour'],
// --- Les collisions et l'excédent (§ 5.4, § 5.6) ---------------------------
[
'diagnostic.collisions',
({ effectif, plancherCumulees, plancherPairesDistinctes, tours }) =>
`${compte(effectif, 'personne', 'personnes')} : au moins ` +
`${compte(plancherCumulees, 'collision cumulée', 'collisions cumulées')} ${surLesTours(tours)}, ` +
`${plancherCumulees === 1 ? 'portée' : 'portées'} par au moins ` +
compte(plancherPairesDistinctes, 'paire distincte', 'paires distinctes'),
],
[
'diagnostic.excedent',
({ plancherExcedent }) =>
`excédent d'au moins ${compte(plancherExcedent, 'collision', 'collisions')} : des retrouvailles sont forcées`,
],
// --- La redondance et les animateurs (§ 5.6) -------------------------------
[
'diagnostic.redondance',
({ redondanceMinimale }) =>
`au moins ${compte(redondanceMinimale, 'appartenance recroisée', 'appartenances recroisées')} par personne`,
],
[
'diagnostic.animateurs',
({ redondanceImposeeParAnimateurs }) =>
'tous les animateurs partagent une appartenance : chaque mobile accumule au moins ' +
`${compte(redondanceImposeeParAnimateurs, 'redondance', 'redondances')} qu'aucune recherche ne réduit`,
],
// --- L'ancrage par table (§ 5.6, § 5.9) ------------------------------------
['diagnostic.ancrage.gelee', ({ numero, capacite }) => `la table ${numero} ne tourne plus : ses ${capacite} places sont ancrées`],
[
'diagnostic.ancrage.moitie',
({ numero, ancres, capacite }) =>
`la table ${numero} a ${compte(ancres, 'place ancrée', 'places ancrées')} sur ${capacite} : ` +
"elle réduit d'autant le brassage de tout l'événement",
],
// --- L'écart d'itinéraire, le manque de places (§ 5.6, § 12.10.4) ----------
[
'diagnostic.ecartItineraire',
({ plancher, mobilesAuMoins }) =>
`écart d'itinéraire maximal : plancher ${plancher}, au moins ` +
(mobilesAuMoins === 1 ? '1 mobile finit sous son plafond a priori' : `${mobilesAuMoins} mobiles finissent sous leur plafond a priori`),
],
[
'diagnostic.placesManquantes',
({ placesManquantes }) =>
placesManquantes === 1 ? '1 place manque à chaque tour' : `${placesManquantes} places manquent à chaque tour`,
],
// --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
[
'diagnostic.plafond',
({ valeur, ancres, mobiles }) =>
`plafond a priori ${compte(valeur, 'rencontre', 'rencontres')} pour ` +
enumeration([
...(ancres > 0 ? [compte(ancres, 'ancré', 'ancrés')] : []),
...(mobiles > 0 ? [compte(mobiles, 'mobile', 'mobiles')] : []),
]),
],
['diagnostic.plafondInconnu', (_, texte) => `plafond a priori : ${texte('format.vide')}`],
[
'diagnostic.imposes',
({ retoursImposes, ancragesImposantRetours }) =>
`${compte(retoursImposes, 'retour imposé', 'retours imposés')} par ${compte(ancragesImposantRetours, 'ancrage', 'ancrages')}`,
],
// --- Les remèdes -----------------------------------------------------------
['diagnostic.remede.separation', 'désactiver la séparation des appartenances'],
[
'diagnostic.remede.tables',
({ tablesManquantes }) =>
tablesManquantes === 1
? '1 table de plus, de la plus grande capacité présente, la rend évitable'
: `${tablesManquantes} tables de plus, de la plus grande capacité présente, la rendent évitable`,
],
[
'diagnostic.remede.redistribuer',
"les réservations assoient des membres ensemble : répartir autrement l'appartenance la rend évitable",
],
[
'diagnostic.remede.tours',
({ toursSansRedondance, tours }) =>
`avec ${compte(toursSansRedondance, 'tour', 'tours')} au lieu de ${tours}, aucune ne le serait`,
],
['diagnostic.remede.appartenances', "aucun nombre de tours plus petit ne l'annule : elle tient aux appartenances déclarées"],
['diagnostic.remede.animateurs', 'laisser les animateurs sans appartenance déclarée, ou leur en donner des distinctes'],
[
'diagnostic.remede.ancrage',
'retirer des réservations de portée « tous les tours » à cette table, ou lui ajouter des places, la remet en rotation',
],
[
'diagnostic.remede.ecartItineraire',
"aucune relance ne le réduit : il tient aux tables pleines dont les capacités ou les ancrages diffèrent",
],
['diagnostic.remede.places', "ajouter des tables ou des places, ou exclure des personnes : d'ici là, la génération est refusée"],
['diagnostic.remede.plafond', 'seules les capacités et les réservations le changent : aucune relance ne le dépasse'],
['diagnostic.remede.imposes', 'les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur'],
]);
// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence
// par « diagnostic. ».
export const TABLE = new Map();

View file

@ -1,43 +1,7 @@
// © 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, 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"],
]);
// 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();

View file

@ -1,97 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la génération : réglages, durée observée, question d'une
// génération longue, avancement et issue (§ 5.7, § 5.10, § 14.6). Chaque
// clé commence par « generation. ». Le libellé d'un réglage porte son unité,
// entre parenthèses : l'étiquette du champ la dit, et le nom accessible du
// champ avec elle. Un nombre et une durée se mettent en forme par
// creerFormat, sur la composition que la traduction passe à l'entrée ; les
// détails reçus sont des nombres.
import { creerFormat } from '../../application/format.js';
import { compte } from '../../application/libelles/aides.js';
// Ce que vaut un calcul mesuré : sa durée, puis ses réglages, nombre de
// propositions et compte d'arrêt, dont le produit est le nombre de
// mouvements évalués.
function calculMesure({ dureeMs, nombre, arret }, texte) {
const format = creerFormat(texte);
return `${format.duree(dureeMs)} pour ${format.nombre(nombre)} \u{00D7} ${format.nombre(arret)} mouvements`;
}
export const TABLE = new Map([
// --- Les réglages (§ 5.7, § 5.10) ---------------------------------------------
['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'],
['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'],
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
// --- La durée du dernier calcul de cette séance (§ 5.10) --------------------
[
'generation.duree.inconnue',
"Aucun calcul n'est encore mesuré sur ce poste pendant cette séance : la durée d'une génération est inconnue.",
],
['generation.duree.observee', (details, texte) => `Dernier calcul sur ce poste : ${calculMesure(details, texte)}.`],
// secondes : l'estimation des réglages courants, d'après le dernier calcul.
[
'generation.duree.estimee',
({ secondes }, texte) => `À ces réglages, une génération prendrait ${creerFormat(texte).ordreDeGrandeur(secondes)}.`,
],
// --- La génération longue, annoncée avant de partir (§ 5.10) ----------------
// estimation : celle de CONFIRMATION_REQUISE, { inconnue: true } sans
// mesure, sinon { secondes, auDelaDuPlafond, mesure: { dureeMs, mouvements } }.
[
'generation.longue.question',
({ estimation }, texte) => {
if (estimation.inconnue === true) {
return (
"Aucune durée n'est encore mesurée sur ce poste pendant cette séance, et ce réglage demande plus de dix " +
'fois le réglage par défaut. La lancer quand même ?'
);
}
const format = creerFormat(texte);
const { mesure } = estimation;
const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : '';
return (
`Cette génération prendrait ${format.ordreDeGrandeur(estimation.secondes)} à ce réglage${plafond}, ` +
`d'après le dernier calcul : ${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} ` +
'mouvements. La lancer quand même ?'
);
},
],
['generation.longue.confirmer', 'Générer quand même'],
['generation.longue.renoncer', 'Ne pas générer'],
// --- Les commandes ----------------------------------------------------------------
['generation.commande.generer', 'Générer'],
['generation.commande.annuler', 'Annuler la génération'],
// --- L'avancement (§ 5.10) --------------------------------------------------------
['generation.avancement.barre', 'Avancement de la génération'],
[
'generation.avancement.texte',
({ fait, total }, texte) => {
const { nombre } = creerFormat(texte);
return `Génération en cours : ${nombre(fait)} mouvements évalués sur ${nombre(total)}.`;
},
],
// --- L'issue d'une génération -------------------------------------------------------
// ajoutees : le nombre de propositions enregistrées ; identiques : celles
// que le plan d'une proposition présente a écartées.
[
'generation.issue.ajoutees',
({ ajoutees, identiques }) => {
const nouvelles =
ajoutees === 0 ? 'Aucune proposition nouvelle' : `${compte(ajoutees, 'proposition ajoutée', 'propositions ajoutées')}`;
const ecartees =
identiques === 0
? ''
: ` ; ${compte(identiques, 'placement identique à une proposition présente, écarté', 'placements identiques à des propositions présentes, écartés')}`;
return `${nouvelles}${ecartees}.`;
},
],
['generation.issue.annuleeCommande', "Génération annulée : rien n'est enregistré."],
['generation.issue.annuleeSeance', "Génération annulée par le retour en lecture : rien n'est enregistré."],
]);
// Les libellés de la génération : réglages et avancement (§ 14.6). Chaque clé
// commence par « generation. ».
export const TABLE = new Map();

View file

@ -2,133 +2,5 @@
// 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. ». 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 :'],
]);
// clé commence par « import. ».
export const TABLE = new Map();

View file

@ -3,89 +3,4 @@
// Les libellés de la liste des participants : grille, formulaire, fiche (§
// 14.6). Chaque clé commence par « participants. ».
//
// 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'],
]);
export const TABLE = new Map();

View file

@ -3,180 +3,4 @@
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
// travail (§ 14.6). Chaque clé commence par « plan. ».
//
// 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, enumeration } 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'],
// --- La barre de vue (§ 7.2, § 7.7, § 8.4) ---------------------------------
// Le numéro d'un tour, dans le sélecteur, est une donnée : son option ne
// porte que lui.
['plan.vue.barre', 'Vue du plan'],
['plan.vue.tour', 'Tour affiché'],
['plan.vue.zoomer', 'Zoom +'],
['plan.vue.dezoomer', 'Zoom \u{2212}'],
['plan.vue.ajuster', 'Ajuster'],
['plan.vue.listes', 'Listes de noms'],
['plan.vue.pleinEcran', 'Plein écran'],
['plan.vue.dessin', 'Plan de salle'],
// --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) -------------------------
// Le fantôme d'une personne glissée dit ce que le geste modifie : l'état
// des données, non le geste (§ 5.8). Le nom de la personne est une donnée,
// dans son propre nœud.
['plan.fantome.reservations', 'pose une réservation'],
['plan.fantome.retenu', 'déplace dans le placement retenu'],
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
// La place où un refus ou un avertissement du plan se pose.
['plan.refus.table', ({ numero }) => `Table ${numero}`],
['plan.refus.siege', ({ numero, siege }) => `Table ${numero}, siège ${siege}`],
['plan.refus.fermer', 'Fermer'],
// --- Le clavier et l'infobulle (§ 7.1, § 7.3, § 13.5) ----------------------
// Le plan est un seul arrêt de tabulation : sa description dit comment s'y
// déplacer. L'infobulle d'une table en conflit nomme chaque règle violée,
// puis ses groupes : les noms sont des données, chacun dans son nœud,
// reliés par « et » dans une paire et séparés d'un groupe à l'autre.
['plan.vue.role', 'plan de salle interactif'],
['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`],
['plan.infobulle.separerAppartenances', 'appartenances réunies : '],
['plan.infobulle.nouveauxVoisins', 'voisins revus : '],
['plan.infobulle.nouvelleTable', 'table revue : '],
['plan.infobulle.et', ' et '],
['plan.infobulle.separateur', ', '],
// --- Les panneaux latéraux (§ 2.1, étape 8) --------------------------------
['plan.panneaux', 'Panneaux du plan'],
['plan.listesTravail.titre', 'Listes de travail'],
['plan.reserve.titre', 'Réserve'],
// --- Le choix en attente (§ 5.8, § 7.1) ------------------------------------
// Le nom de la personne choisie est une donnée, dans son propre nœud.
['plan.choix.personne', 'Personne choisie : '],
['plan.choix.reservation', 'Réservation choisie : '],
['plan.choix.oublier', 'Oublier le choix'],
[
'plan.choix.inviter',
"Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.",
],
// --- Le panneau d'une place (§ 7.1, § 2.1 étape 6, § 4) --------------------
['plan.action.assigner', 'Assigner'],
['plan.action.reserver', 'Réserver'],
['plan.action.donnerTitre', 'Donner un titre'],
['plan.action.designer', 'Désigner'],
['plan.action.retirerReservation', 'Retirer la réservation'],
['plan.action.retirerTitre', 'Retirer le titre'],
['plan.action.deplacerReservationIci', 'Déplacer la réservation ici'],
['plan.portee.etiquette', 'Portée'],
['plan.portee.tour', ({ tour }) => `Tour ${tour}`],
['plan.portee.tous', 'Tous les tours'],
['plan.titre.etiquette', 'Libellé du titre'],
// --- Le panneau d'une table (§ 7.3, § 2.1 étape 6) -------------------------
['plan.panneauTable.sieges', 'Sièges'],
[
'plan.panneauTable.titres',
({ titres, pourvus }) => `${compte(titres, 'titre', 'titres')} dont ${compte(pourvus, 'pourvu', 'pourvus')}`,
],
['plan.panneauTable.siege', 'Siège'],
// --- Le menu par personne (§ 7.1) ------------------------------------------
// Le nom de la personne, titre du menu, est une donnée dans son nœud.
['plan.menu.titre', 'Menu de '],
['plan.menu.deplacer', 'Déplacer'],
['plan.menu.echanger', 'Échanger'],
['plan.menu.retirerDuTour', 'Retirer du tour'],
['plan.menu.reserverIci', 'Réserver ici'],
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
['plan.menu.exclure', 'Exclure'],
['plan.menu.reintegrer', 'Réintégrer'],
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
// des données, chacun dans son nœud, entre ces textes.
['plan.listesTravail.titres', 'Titres de place non pourvus'],
['plan.listesTravail.pressentis', 'Titres pressentis sans réservation'],
['plan.listesTravail.conversions', 'Conversions proposées'],
['plan.listesTravail.aucun', 'Aucun.'],
['plan.listesTravail.titreNonPourvu', ({ numero }) => `table ${numero} : titre `],
['plan.listesTravail.nonPourvu', ' non pourvu'],
[
'plan.listesTravail.pourvuAuxTours',
({ tours }) => ` (pourvu ${tours.length === 1 ? 'au tour' : 'aux tours'} ${enumeration(tours.map(String))})`,
],
['plan.listesTravail.pressenti', ' \u{2014} titre pressenti '],
['plan.listesTravail.conversion', ({ numero }) => ` : une réservation à chaque tour, table ${numero}`],
['plan.listesTravail.convertir', 'Convertir en « tous les tours »'],
['plan.listesTravail.ignorer', 'Ignorer'],
// --- La réserve (§ 7.1, § 5.8) ---------------------------------------------
['plan.reserve.tour', ({ tour }) => `Personnes en attente au tour ${tour}`],
['plan.reserve.vide', 'Personne en attente à ce tour.'],
]);
export const TABLE = new Map();

View file

@ -1,107 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9,
// § 14.6). Chaque clé commence par « propositions. ».
//
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées,
// paires distinctes et excédent ne se confondent jamais. Des rencontres, le
// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la
// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant
// croissant, qui départage deux propositions égales sur tous (§ 5.7).
import { compte } from '../../application/libelles/aides.js';
// Le nom d'un critère du classement, celui de la table du moteur.
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
export const TABLE = new Map([
// --- La section (§ 5.7) ------------------------------------------------------
['propositions.tableau.titre', 'Propositions classées'],
['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."],
[
'propositions.ordre.criteres',
({ criteres }, texte) =>
`Ordre du classement, chaque critère au plus faible d'abord : ${criteres
.map((nom) => critere(nom, texte))
.join(', puis ')}, puis l'identifiant croissant.`,
],
[
'propositions.ordre.saute',
({ saute, applique }, texte) =>
`Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}. ` +
`Appliqué à sa place : ${critere(applique, texte)}.`,
],
// --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ----------------------
['propositions.colonne.rang', 'Rang'],
['propositions.colonne.proposition', 'Proposition'],
['propositions.colonne.rencontresTous', 'Personnes distinctes rencontrées, minimum : tous'],
['propositions.colonne.rencontresMobiles', 'Personnes distinctes rencontrées, minimum : mobiles'],
['propositions.colonne.rencontresAncres', 'Personnes distinctes rencontrées, minimum : ancrés'],
['propositions.colonne.collisionsCumulees', 'Collisions cumulées (paire et tour)'],
['propositions.colonne.pairesDistinctes', 'Paires distinctes de même appartenance'],
['propositions.colonne.excedentCollisions', 'Excédent de collisions (retrouvailles au-delà de la première)'],
['propositions.colonne.rencontresRepetees', 'Rencontres répétées choisies (paires)'],
['propositions.colonne.maxRencontresPaire', "Rencontres d'une même paire, au plus"],
['propositions.colonne.retoursChoisis', 'Retours choisis à une table déjà occupée'],
['propositions.colonne.appartenancesVues', 'Appartenances rencontrées A(p), minimum'],
['propositions.colonne.totalRedondance', "Redondance d'appartenance, somme Σ r(p)"],
['propositions.colonne.diversite', 'Taux de diversité d(p), minimum'],
['propositions.colonne.manqueMax', 'Plus grand manque (personnes)'],
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'],
['propositions.colonne.certificat', 'Certificat'],
['propositions.colonne.imposes', 'Imposé par les réservations'],
['propositions.colonne.commandes', 'Commandes'],
// --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------
['propositions.valeur.inconnu', 'inconnu'],
['propositions.certificat.atteint', 'minimum atteint'],
['propositions.certificat.inconnue', 'atteignabilité inconnue'],
// --- Ce que les réservations imposent, à part (§ 5.4) --------------------------
[
'propositions.imposes.retours',
({ retours, ancrages }) =>
retours === 0
? 'Aucun retour imposé.'
: `${compte(retours, 'retour imposé', 'retours imposés')} par ${compte(ancrages, 'ancrage', 'ancrages')}.`,
],
[
'propositions.imposes.repetees',
({ repetees }) =>
repetees === 0
? 'Aucune rencontre répétée imposée.'
: `${compte(repetees, 'rencontre répétée imposée', 'rencontres répétées imposées')}.`,
],
// --- Les lignes (§ 5.7, § 9, § 12.6) -----------------------------------------
['propositions.marque.retenue', 'retenue'],
['propositions.retenu.titre', 'Placement retenu'],
['propositions.retenu.origine', ({ proposition }) => `Tel que la proposition n° ${proposition}.`],
['propositions.retenu.ajuste', ({ proposition }) => `Ajusté à la main depuis la proposition n° ${proposition}.`],
['propositions.horsClassement.titre', 'Hors classement'],
['propositions.statut.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'],
['propositions.statut.fautif', 'Non comparable : le placement se contredit.'],
// --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
['propositions.commande.voir', 'Voir sur le plan'],
['propositions.commande.retenir', 'Retenir'],
['propositions.commande.effacer', 'Effacer les propositions'],
// --- Retenir sur un retenu ajusté (§ 2.1) ----------------------------------------
[
'propositions.confirmation.titre',
({ proposition }) => `Retenir la proposition n° ${proposition} détruit les ajustements du placement retenu`,
],
[
'propositions.confirmation.retenuAjuste',
({ proposition }) => `Le placement retenu, ajusté à la main depuis la proposition n° ${proposition}`,
],
[
'propositions.confirmation.remplacant',
({ proposition }) => `remplacé par la proposition n° ${proposition}, telle que générée`,
],
]);
// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par «
// propositions. ».
export const TABLE = new Map();

View file

@ -1,131 +1,6 @@
// © 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 (§ 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'],
]);
// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par «
// tables. ».
export const TABLE = new Map();

View file

@ -1,45 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Ce que le bandeau dit de l'événement ouvert, en clés de la table de la
// coquille : le libellé de mode (§ 8.4, § 8.5), calculé ici une fois pour le
// bandeau et pour le cadre de mode, et l'annonce de la version de format du
// fichier (§ 18.6). Les deux se lisent dans l'état observable de la séance.
// La clé de chaque annonce de format de la séance.
const ANNONCES = new Map([
['plusRecent', 'coquille.format.plusRecent'],
['plusAncien', 'coquille.format.plusAncien'],
]);
/**
* La clé du libellé de mode : null sans événement ; 'coquille.mode.bloque'
* pour un plan bloqué, que la séance tient en lecture ;
* 'coquille.mode.lecture' ou 'coquille.mode.ecriture' sinon, le mode de la
* séance.
*
* @param {{mode: 'lecture'|'ecriture', evenement: null|{etat: string}}} etatSeance
* @returns {null|'coquille.mode.lecture'|'coquille.mode.ecriture'|'coquille.mode.bloque'}
*/
export function cleDeMode(etatSeance) {
if (etatSeance.evenement === null) return null;
if (etatSeance.evenement.etat === 'bloque') return 'coquille.mode.bloque';
return etatSeance.mode === 'ecriture' ? 'coquille.mode.ecriture' : 'coquille.mode.lecture';
}
/**
* La clé de l'annonce de format du bandeau : null sans événement ou quand le
* fichier est au format courant ; sinon celle de l'annonce de la séance,
* plus récent ou plus ancien.
*
* @param {{evenement: null|{annonceFormat: null|'plusRecent'|'plusAncien'}}} etatSeance
* @returns {null|'coquille.format.plusRecent'|'coquille.format.plusAncien'}
* @throws {RangeError} une annonce que la séance ne rend pas
*/
export function cleAnnonceFormat(etatSeance) {
const annonce = etatSeance.evenement?.annonceFormat ?? null;
if (annonce === null) return null;
const cle = ANNONCES.get(annonce);
if (cle === undefined) throw new RangeError(`annonce de format inconnue : ${JSON.stringify(annonce)}`);
return cle;
}

View file

@ -1,66 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La clé du libellé de mode (§ 8.4, § 8.5), que lisent le bandeau et le
// cadre de mode : aucune sans événement ; le plan bloqué avant le mode, qu'il
// force en lecture ; sinon le mode de la séance. Et la clé de l'annonce de
// format du bandeau (§ 18.6), une par annonce de la séance.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLE as COQUILLE } from './libelles/coquille.js';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
// Un EtatSeance réduit à ce que lit cleDeMode.
const etat = (mode, etatDuPlan, annonceFormat = null) => ({
mode,
evenement: etatDuPlan === null ? null : { base: 'bal', id: 'evt-1', nom: 'Bal', etat: etatDuPlan, revision: 1, annonceFormat },
});
describe('cleDeMode (§ 8.4)', () => {
test('null sans événement, dans les deux modes', () => {
assert.equal(cleDeMode(etat('lecture', null)), null);
assert.equal(cleDeMode(etat('ecriture', null)), null);
});
test('un plan bloqué rend coquille.mode.bloque, quel que soit le mode', () => {
assert.equal(cleDeMode(etat('lecture', 'bloque')), 'coquille.mode.bloque');
assert.equal(cleDeMode(etat('ecriture', 'bloque')), 'coquille.mode.bloque');
});
test('sinon le mode de la séance, pour chaque état non bloqué', () => {
const etats = ['brouillon', 'propose', 'retenu'];
assert.ok(etats.length > 0);
for (const etatDuPlan of etats) {
assert.equal(cleDeMode(etat('lecture', etatDuPlan)), 'coquille.mode.lecture', etatDuPlan);
assert.equal(cleDeMode(etat('ecriture', etatDuPlan)), 'coquille.mode.ecriture', etatDuPlan);
}
});
test('chaque clé rendue a son texte dans la table de la coquille', () => {
for (const cle of ['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']) {
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
}
});
});
describe('cleAnnonceFormat (§ 18.6)', () => {
test('null sans événement, ou au format courant', () => {
assert.equal(cleAnnonceFormat(etat('lecture', null)), null);
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon')), null);
});
test('une clé par annonce, chacune avec son texte', () => {
const cas = [
['plusRecent', 'coquille.format.plusRecent'],
['plusAncien', 'coquille.format.plusAncien'],
];
for (const [annonce, cle] of cas) {
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon', annonce)), cle, annonce);
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
}
});
test('une annonce inconnue lève RangeError', () => {
assert.throws(() => cleAnnonceFormat(etat('lecture', 'brouillon', 'plusVieux')), RangeError);
});
});

View file

@ -1,214 +0,0 @@
<!-- © 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

@ -1,221 +0,0 @@
<!-- © 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

@ -1,443 +0,0 @@
<!-- © 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

@ -1,742 +0,0 @@
// © 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

@ -1,161 +0,0 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// 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

@ -1,234 +0,0 @@
// © 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

@ -1,305 +0,0 @@
// © 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

@ -1,60 +0,0 @@
<!-- © 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>

Some files were not shown because too many files have changed in this diff Show more