Compare commits

...

11 commits

Author SHA1 Message Date
811cd16b76 [ADD] interface: plan keyboard, tooltip, side panels, person menu, lists
Every gesture the pointer offers on the plan is reachable from the
keyboard (§ 7.3): one stop enters the plan, arrows move between tables and
seats, and the table and place panels choose a seat and keep the focus.
A tooltip names who sits where; a per-person menu moves, assigns or
reserves without dragging; the reserve and the work lists choose a person
to designate on the plan. Escape, a tour change and the executed command
forget the choice, and a click never moves the view.
Checked: 2537 node, 466 browser (one expected failure kept), 60 node-long.

--- FR ---

[ADD] interface : clavier du plan, infobulle, panneaux, menu, listes

Chaque geste que le pointeur offre sur le plan s'atteint au clavier
(§ 7.3) : un seul arrêt entre dans le plan, les flèches vont de table en
table et de siège en siège, et les panneaux de table et de place
choisissent un siège en gardant le focus. Une infobulle nomme qui est
assis où ; un menu par personne déplace, assigne ou réserve sans glisser ;
la réserve et les listes de travail choisissent une personne à désigner
sur le plan. Échap, un changement de tour et la commande exécutée
oublient le choix, et un clic ne déplace jamais la vue.
Vérifié : 2537 node, 466 navigateur (un échec attendu gardé), 60 node-long.

Assisted-by: Claude Opus 5.5
2026-10-07 11:08:59 -04:00
7f3dad24af [ADD] interface: plan gestures — drag a person, a table, a handle
The plan is where the operator changes the seating. A drag starts only
past a movement threshold, so a click is never a drag (§ 8.4), and Escape
cancels it; a person dropped on a chair is moved or swapped through the
places commands, a table is moved, and a handle resizes it after the
capacity check. A refusal appears on the place where the gesture ended.
A new gesture waits for the previous write, so a second drag never starts
from a stale plan, and nothing reads layout while a drag runs.
Checked: 2499 node and 436 browser tests.

--- FR ---

[ADD] interface : gestes du plan — glisser une personne, une table

Le plan est l'endroit où l'opérateur change le placement. Un glissement ne
commence qu'au-delà d'un seuil de mouvement — un clic n'en est jamais un
(§ 8.4) — et Échap l'annule ; une personne déposée sur une chaise est
déplacée ou échangée par les commandes des places, une table se déplace,
et une poignée la redimensionne après le contrôle de capacité. Un refus se
pose sur la place où le geste a fini. Un geste attend l'écriture du
précédent, si bien qu'un second glissement ne part jamais d'un plan
périmé, et rien ne lit la mise en page pendant un glissement.
Vérifié : 2499 node et 436 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 10:01:21 -04:00
626159db5f [ADD] interface: diagnostic — what a configuration makes unavoidable
Before generating, the operator must know what no plan can avoid: the
encounters a table size forces, the conflicts when every table holds
colleagues, the anchors that impose a return. The engine's diagnosis is
turned into sentences with their figures and their remedies, shown in its
section and, compact, beside the plan, whose banner states the floors
when every table is in conflict (§ 7.5).
Checked: 2425 node and 413 browser tests.

--- FR ---

[ADD] interface : diagnostic — ce qu'une configuration rend inévitable

Avant de générer, l'opérateur doit savoir ce qu'aucun plan n'évite : les
rencontres qu'une taille de table impose, les conflits quand chaque table
réunit des collègues, les ancrages qui imposent un retour. Le diagnostic du
moteur devient des phrases, avec leurs chiffres et leurs remèdes, montrées
dans sa section et, en compact, à côté du plan, dont le bandeau énonce les
planchers quand toutes les tables sont en conflit (§ 7.5).
Vérifié : 2425 node et 413 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 09:41:51 -04:00
ad19131954 [ADD] interface: propositions — ranked table, retain, clear
The operator chooses among the generated plans. The ranked table orders
propositions by the criteria of § 5.4 and shows, for encounters, the
minimum only — the mean is the same for every proposition at full tables
— with redundancy read through its sum. A proposition is retained or the
list cleared through named commands; clearing spares the retained one,
and a proposition that broke is shown apart, not ranked.
Checked: 2400 node and 406 browser tests.

--- FR ---

[ADD] interface : propositions — tableau classé, retenir, effacer

L'opérateur choisit parmi les plans générés. Le tableau classé ordonne les
propositions selon les critères du § 5.4 et ne montre, pour les
rencontres, que le minimum — la moyenne est la même pour toute
proposition à tables pleines —, la redondance se lisant par sa somme. Une
proposition se retient, ou la liste s'efface, par des commandes nommées ;
effacer épargne la retenue, et une proposition abîmée se montre à part,
hors classement.
Vérifié : 2400 node et 406 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 09:10:39 -04:00
d19394957f [ADD] interface: generation screen — settings, progress, worker, cancel
The operator launches a generation without the window freezing. The
screen sets the number of propositions, the seed, the stop count and the
history, refuses an out-of-range setting where it was typed, and asks
before a run estimated far beyond the default. The search runs in a worker
that terminate() cancels with no partial result, and the progress shows a
count, never a promised duration. The last duration lives for the session
only.
Checked: 2374 node and 391 browser tests.

--- FR ---

[ADD] interface : écran de génération — réglages, avancement, annulation

L'opérateur lance une génération sans figer la fenêtre. L'écran règle le
nombre de propositions, la graine, l'arrêt et l'historique, refuse un
réglage hors de son domaine là où il a été saisi, et demande avant un
calcul estimé bien au-delà du défaut. La recherche tourne dans un
travailleur que terminate() annule sans résultat partiel, et l'avancement
montre un compte, jamais une durée promise. La dernière durée ne vit que
le temps de la séance.
Vérifié : 2374 node et 391 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 09:10:12 -04:00
682e69876b [ADD] interface: plan view — zoom, framing, fit, scale, side panels
The drawing becomes a plan the operator can look around. One transform on
the drawing group holds the view, so the screen and the drawing share the
formula of src/geometrie; the wheel zooms about the pointer without
scrolling the page, « + », « − » and « Ajuster » bound the scale, and a
resize keeps the view inside its limits. Only a deliberate gesture saves
the framing, deferred, into reglages_locaux.json — in read mode and on a
blocked plan too. The side panels take their first form.
Checked: 2368 node and 378 browser tests.

--- FR ---

[ADD] interface : vue du plan — zoom, cadrage, ajuster, échelle

Le dessin devient un plan que l'opérateur parcourt. Une seule
transformation du groupe de dessin tient la vue : l'écran et le dessin
partagent la formule de src/geometrie ; la molette zoome autour du
pointeur sans faire défiler la page, « + », « − » et « Ajuster » bornent
l'échelle, et un redimensionnement garde la vue dans ses limites. Seul un
geste voulu enregistre le cadrage, en différé, dans reglages_locaux.json
— en lecture et sur un plan bloqué aussi. Les panneaux latéraux prennent
leur première forme.
Vérifié : 2368 node et 378 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 09:09:45 -04:00
134e7850b7 [ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.

--- FR ---

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

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

Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
5fbbab6477 [ADD] interface: application shell — banner, sections, theme, about
The window showed only a banner; it now holds the application. App mounts
the session, the facade and the generation, settles the working folder
and lists its events before it reports ready, re-reading the list once a
question is answered or a refused determination succeeds. The banner
carries version, mode and the event list command; an open event shows its
sections in their first form; the theme follows the system at each start
and the operator's choice lasts the session; À propos names the build.
Errors are collected for a report, and Electron removes the session's
locks at exit.
Checked: 2189 node, 193 browser, 60 node-long; the Electron start check.

--- FR ---

[ADD] interface : coquille — bandeau, sections, thème, À propos

La fenêtre ne montrait qu'un bandeau ; elle tient désormais l'application.
App monte la séance, la façade et la génération, règle le dossier de
travail et liste ses événements avant de se dire prête, et relit la liste
une fois une question répondue ou une détermination refusée réussie. Le
bandeau porte version, mode et la commande de la liste ; un événement
ouvert montre ses sections dans leur première forme ; le thème suit le
système à chaque démarrage et le choix de l'opérateur vaut pour la séance ;
À propos nomme la construction. Les erreurs se collectent pour un
signalement, et Electron efface à la sortie les verrous de la séance.
Vérifié : 2189 node, 193 navigateur, 60 node-long ; démarrage d'Electron.

Assisted-by: Claude Opus 5.5
2026-10-07 07:22:05 -04:00
876ea0f2ce [ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.

--- FR ---

[ADD] interface : primitives — champ, refus, confirmation, commande

Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
cc6b9fe996 [ADD] application: generation controller, worker, evaluation table
Generating must never block the screen or record a plan that breaks a
rule. The controller refuses before launching, in a fixed order: an
out-of-range setting named by the engine's own check, the session's
refusals, then what makes the run useless. It runs the search in a worker
or in the thread, records only propositions whose invariants hold, skips
plans already present, and keeps the last duration for the session only.
The evaluation table ranks propositions on the minimum of encounters and
names the reservations a proposition does not honour.
Checked: 2112 node and 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : génération, travailleur, table d'évaluation

Générer ne doit jamais bloquer l'écran ni enregistrer un plan qui enfreint
une règle. Le contrôleur refuse avant tout lancement, dans un ordre fixe :
un réglage hors de son domaine, nommé par la règle même du moteur, les
refus de la séance, puis ce qui rend le calcul vain. Il lance la recherche
dans un travailleur ou dans le fil, n'enregistre que les propositions dont
les invariants tiennent, écarte les plans déjà présents, et garde la
dernière durée pour la seule séance. La table d'évaluation classe les
propositions sur le minimum de rencontres et nomme les réservations
qu'une proposition ne tient pas.
Vérifié : 2112 node et 60 node-long ; seuils de couverture tenus.

Assisted-by: Claude Opus 5.5
2026-10-07 06:09:45 -04:00
964532fa19 [ADD] application: furniture — tables, default, tours, capacity checks
Changing a table, the default seat count or the number of tours reshapes
a plan the operator may already have retained. Eight commands create,
remove and resize tables and set the default and the tours; every change
of capacity first passes controlerCapacite, which refuses to cut a reserved
or titled seat, states who goes to the reserve, and only brings back a
reserved person the display showed seated (§ 6.1, § 6.2). bilans.js states
the figures of a change before it applies.
Checked: 2033 node and 127 browser tests.

--- FR ---

[ADD] application : mobilier — tables, défaut, tours, contrôles

Changer une table, le défaut de sièges ou le nombre de tours remodèle un
plan que l'opérateur a peut-être retenu. Huit commandes créent, retirent et
redimensionnent les tables, et règlent le défaut et les tours ; tout
changement de capacité passe d'abord par controlerCapacite, qui refuse de
couper un siège réservé ou titré, dit qui part en réserve, et ne ramène une
personne réservée que si l'écran la montrait assise (§ 6.1, § 6.2).
bilans.js énonce les chiffres d'un changement avant qu'il s'applique.
Vérifié : 2033 node et 127 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 05:12:43 -04:00
171 changed files with 37051 additions and 204 deletions

View file

@ -337,7 +337,8 @@ 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
* @returns {Object<string, Function>} les primitives, par nom, et
* verrousTenus(), qui n'en est pas une
*/
export function creerFichiers({
emplacements,
@ -364,6 +365,9 @@ 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
@ -857,6 +861,7 @@ export function creerFichiers({
await effacer(loc.absolu).catch(() => {});
throw erreur;
}
tenus.set(loc.absolu, seance);
return { pris: true };
}
} catch (erreur) {
@ -875,15 +880,28 @@ export function creerFichiers({
try {
texte = (await fs.readFile(loc.absolu)).toString('utf8');
} catch (erreur) {
if (ABSENCE_EN_LECTURE.has(erreur?.code)) return;
if (ABSENCE_EN_LECTURE.has(erreur?.code)) {
tenus.delete(loc.absolu);
return;
}
throw erreur;
}
if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu);
if (analyserVerrou(texte)?.seance === seance) {
await effacer(loc.absolu);
tenus.delete(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,7 +4,9 @@
// 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.
// 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).
//
// 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
@ -12,8 +14,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, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, rmSync } from 'node:fs';
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -75,6 +77,28 @@ 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,11 +2,13 @@
// 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.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.
// 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.
//
// 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
@ -42,4 +44,6 @@ for (const primitive of PRIMITIVES) {
fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres);
}
contextBridge.exposeInMainWorld('gtt', { fichiers });
const copier = (texte) => ipcRenderer.invoke('gtt:copier', texte);
contextBridge.exposeInMainWorld('gtt', { fichiers, copier });

View file

@ -7,6 +7,10 @@
<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>

47
public/collecteur.js Normal file
View file

@ -0,0 +1,47 @@
// © 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,13 +2,25 @@
// 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 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.
// 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.
//
// 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
@ -16,6 +28,8 @@
// 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';
@ -35,28 +49,43 @@ 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 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}`);
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();
}
} finally {
await application.close();
rmSync(publie, { recursive: true, force: true });
}
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
process.exit(conforme ? 0 : 1);

View file

@ -170,11 +170,13 @@ 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 et technique. Le module ne porte que la version ; la
* provenance de la construction vient du mode de Vite.
* 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.
*/
export function engendrerModule(versionAffichee) {
const { affichee, technique } = deriver(versionAffichee);
const { affichee, technique, windows } = deriver(versionAffichee);
return [
...EN_TETE_LICENCE,
'',
@ -182,6 +184,7 @@ 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 } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import {
controler,
@ -242,6 +242,7 @@ const MODULE = [
'export const VERSION = Object.freeze({',
" affichee: '2026.10.05.01',",
" technique: '2026.1005.1',",
" windows: '2026.1005.1.0',",
'});',
'',
].join('\n');
@ -772,6 +773,20 @@ 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');

144
src/application/bilans.js Normal file
View file

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

@ -0,0 +1,232 @@
// © 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);
});
});

402
src/application/capacite.js Normal file
View file

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

@ -0,0 +1,284 @@
// © 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,6 +634,14 @@ 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,9 +1,266 @@
// © 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 (§ 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.
// 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: [] };
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map();
export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable],
['supprimerTable', supprimerTable],
['deplacerTable', deplacerTable],
['changerPlacesTable', changerPlacesTable],
['realignerTable', realignerTable],
['changerDefaut', changerDefaut],
['changerTours', changerTours],
['modifierReglage', modifierReglage],
]);

View file

@ -0,0 +1,870 @@
// © 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,6 +10,7 @@ import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
import { libelle } from './libelles.js';
export { apercevoir } from '../csv/apercu.js';
export { CHAMPS } from '../csv/colonnes.js';
export { decoder } from '../csv/encodage.js';
export { exporterParticipants } from '../csv/export.js';
export { bilanRemplacement } from '../csv/import.js';

View file

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

View file

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

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

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

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

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

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

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

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

@ -0,0 +1,66 @@
// © 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,11 +16,22 @@
// 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));
@ -90,8 +101,40 @@ 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.
// chacun en premier ; puis ceux des tables de contrat ; puis ceux du moteur.
function releverArbre() {
const parArbre = new Map();
const emetteurs = new Map();
@ -107,9 +150,15 @@ 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 = [
@ -182,9 +231,10 @@ 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 et src/application émettent a son texte', () => {
const { parArbre, tables, emetteurs } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
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);
assert.ok(releves.size > 0, 'balayage vide');
const connus = new Set(CODES);
const sansTexte = [...releves].filter((code) => !connus.has(code)).sort();
@ -196,8 +246,7 @@ 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 { parArbre, tables } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
const releves = tousReleves(releverArbre());
assert.ok(CODES.length > 0, 'table vide');
assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur');
});
@ -334,6 +383,61 @@ 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 }],
@ -399,6 +503,35 @@ 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
@ -668,3 +801,142 @@ 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,8 +2,39 @@
// 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. Cette table ne porte que des codes.
import { compte } from './aides.js';
// § 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')}`
);
}
export const TABLE = new Map([
// --- Les refus des propositions (§ 5.7) ------------------------------------
@ -23,4 +54,45 @@ 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,5 +1,114 @@
// © 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 (§ 6, § 14.6).
export const TABLE = new Map();
// 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)}`,
],
]);

View file

@ -2,8 +2,33 @@
// 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).
import { compte } from './aides.js';
// 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}`;
export const TABLE = new Map([
// Une salle trop petite : avertissement d'un geste qui fait croître le
@ -20,4 +45,110 @@ 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 séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
({ depuis }, texte) => `Le verrou qu'une autre séance tenait${depuisQuand(depuis, texte)} est repris.`,
],
[
'RETOUR_AUTOMATIQUE',

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,76 @@
<!-- © 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,27 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// É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
// 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
// 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 } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
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 { VERSION } from '../version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { monterApp } from '../../test/navigateur/montage.js';
import {
attenuations,
composantes,
@ -33,47 +46,676 @@ 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'];
let composant;
// 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`);
// 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(() => {
composant = mount(App, { target: document.body });
flushSync();
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
erreursAdmises = false;
});
afterEach(() => {
unmount(composant);
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([]);
});
function bandeau() {
return document.body.querySelector('header.bandeau');
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');
flushSync();
return m;
}
// Le bandeau, puis chacun de ses porteurs de texte.
function bandeauEtTextes() {
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
}
const bandeauEtTextes = (m) => [{ element: bandeau(m), pseudo: null }, ...porteursDeTexte(bandeau(m))];
describe("bandeau d'application", () => {
test('existe et porte la version affichée', () => {
expect(bandeau()).not.toBeNull();
expect(bandeau().textContent).toContain(VERSION.affichee);
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}`);
});
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
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 () => {
// 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();
expect(porteurs.length).toBeGreaterThan(1);
const porteurs = bandeauEtTextes(m);
expect(porteurs.length).toBeGreaterThan(4);
for (const porteur of porteurs) {
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
}
@ -81,29 +723,20 @@ describe("bandeau d'application", () => {
}
});
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());
test('le texte et le fond du bandeau ont une couleur calculée', async () => {
const m = await bandeauComplet();
const style = getComputedStyle(bandeau(m));
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, dans les deux thèmes (§ 14.5)', async () => {
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui', async () => {
const m = await bandeauComplet();
const couleursParTheme = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
for (const porteur of textes) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
@ -114,7 +747,7 @@ describe("bandeau d'application", () => {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
return [document.body, bandeau()].map((el) => {
return [document.body, bandeau(m)].map((el) => {
const style = getComputedStyle(el);
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
});
@ -129,14 +762,15 @@ describe("bandeau d'application", () => {
).not.toEqual(couleursParTheme[0]);
});
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
test('aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte', 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());
expect(textes.length).toBeGreaterThan(0);
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
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,27 +2,276 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// 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.
// 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).
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 { libelleProvenance, titreAvecVersion } from './libelles.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';
const titre = titreAvecVersion(VERSION.affichee);
const provenance = libelleProvenance(import.meta.env.MODE);
// 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;
</script>
<svelte:window onpagehide={quitterLaPage} />
<svelte:head>
<title>{titre}</title>
</svelte:head>
<header class="bandeau">
<span class="titre">{titre}</span>
<span class="provenance">{provenance}</span>
</header>
<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>
<style>
:global(body) {
@ -32,21 +281,14 @@
font-family: var(--police-interface);
}
.bandeau {
.app {
display: flex;
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);
flex-direction: column;
min-height: 100vh;
}
.provenance {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
.ecran {
flex: 1;
padding: var(--espace-4);
}
</style>

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

61
src/interface/apropos.js Normal file
View file

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

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

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

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

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

@ -0,0 +1,494 @@
// © 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);
});
});

204
src/interface/etat_ui.js Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,134 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'événement ouvert, pur : ce que ses composants montrent de
// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre
// de mode et sa forme, la commande de la place fixe, les états du plan et
// leurs clés, la reprise de verrou que l'écran offre, et les avertissements
// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé
// que la séance ne décide déjà : le refus courant, le mode et l'état du plan
// se lisent dans EtatSeance, jamais se recalculent.
import { cleDeMode } from '../mode.js';
// La forme du cadre pour chaque clé du libellé de mode : un trait différent
// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5).
const FORMES = new Map([
['coquille.mode.lecture', 'tirets'],
['coquille.mode.ecriture', 'plein'],
['coquille.mode.bloque', 'double'],
]);
/**
* Libellé et forme du cadre de mode : cle, celle de cleDeMode (mode.js) — le
* libellé du bandeau — ; forme : lecture (tirets), écriture (trait plein),
* bloqué (trait double), quel que soit le mode d'un plan bloqué. null sans
* événement.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{cle: string, forme: 'tirets'|'plein'|'double'}}
*/
export function cadreDeMode(etatSeance) {
const cle = cleDeMode(etatSeance);
if (cle === null) return null;
return { cle, forme: FORMES.get(cle) };
}
/**
* La commande de la place fixe (§ 8.4, § 8.5, § 8.8) : 'format' quand le
* refus courant est celui d'un format plus récent — l'explication remplace
* alors toute commande, plan bloqué compris ; 'debloquer' sur un plan
* bloqué ; 'lecture' — « Revenir en lecture » — en écriture ; 'modifier'
* sinon. null sans événement.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|'format'|'debloquer'|'lecture'|'modifier'}
*/
export function commandeFixe(etatSeance) {
if (etatSeance.evenement === null) return null;
const { refus } = etatSeance;
if (refus?.code === 'LECTURE_SEULE' && refus.details?.raison === 'FORMAT') return 'format';
if (etatSeance.evenement.etat === 'bloque') return 'debloquer';
return etatSeance.mode === 'ecriture' ? 'lecture' : 'modifier';
}
/** Les états d'un plan (§ 9), dans l'ordre du spec. */
export const ETATS_PLAN = Object.freeze(['brouillon', 'propose', 'retenu', 'bloque']);
/**
* La clé du libellé d'un état du plan, evenement.etat.<etat>.
*
* @param {string} etat
* @returns {string}
* @throws {RangeError} hors d'ETATS_PLAN
*/
export function cleEtat(etat) {
if (!ETATS_PLAN.includes(etat)) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
return `evenement.etat.${etat}`;
}
/**
* Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a
* refusé le dernier passage en écriture quand sa vie ne se prouve pas
* (vivant null) — un autre poste, un verrou illisible. Un verrou vivant ne
* se reprend jamais, et celui d'une séance morte, la séance le reprend
* seule. null sinon.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{seance: string|null, depuis: string|null, vivant: null}}
*/
export function repriseOfferte(etatSeance) {
const { evenement, mode, verrouAutre } = etatSeance;
if (evenement === null || mode !== 'lecture' || verrouAutre === null || verrouAutre.vivant !== null) return null;
return verrouAutre;
}
// Le remède que l'écran joint à un avertissement : rouvrir la version
// précédente d'un retenu fautif ; ouvrir les propositions devant une dérive,
// et les tables devant des places manquantes. Les autres n'en ont pas.
const VERS_PROPOSITIONS = Object.freeze({ geste: 'ouvrirSection', section: 'propositions' });
const REMEDES = new Map([
['RETENU_FAUTIF', Object.freeze({ geste: 'ouvrirSecours', source: 'precedent' })],
['DERIVE', VERS_PROPOSITIONS],
['DERIVE_NOUVELLE', VERS_PROPOSITIONS],
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
]);
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
// que l'examen ne porte pas n'apparaît pas.
function derivesNommees({ ids = [], retenu = false }, examen) {
if (examen === null || examen === undefined) return [];
const nommees = [];
if (retenu && examen.retenu.raisons.length > 0) nommees.push({ id: 'retenu', raisons: examen.retenu.raisons });
for (const id of ids) {
const derive = examen.derives.find((candidate) => candidate.id === id);
if (derive !== undefined) nommees.push({ id, raisons: derive.raisons });
}
return nommees;
}
/**
* Les avertissements à montrer, dans l'ordre d'EtatSeance.avertissements —
* celui de la séance : ceux de l'Ouvert courant, INVARIANT_VIOLE, ceux du
* dernier geste, RETOUR_AUTOMATIQUE —, sans en retirer ni en ajouter. Chacun
* garde son code et ses détails, et reçoit son remède : RETENU_FAUTIF →
* { geste: 'ouvrirSecours', source: 'precedent' } ; DERIVE et
* DERIVE_NOUVELLE → { geste: 'ouvrirSection', section: 'propositions' } ;
* PLACES_MANQUANTES → { geste: 'ouvrirSection', section: 'tables' } ; null
* pour les autres. PROPOSITIONS_FAUTIVES porte en plus fautives, celles
* d'EtatSeance.fautives ; DERIVE, derives, ce que l'examen dit de chaque
* placement qu'elle nomme ({id: number|'retenu', raisons}). Le texte vient
* de la traduction, sur le code.
*
* @param {Object} etatSeance EtatSeance
* @returns {Array<{code: string, details: Object, remede: Object|null, fautives?: Object[], derives?: Object[]}>}
*/
export function avertissementsOrdonnes(etatSeance) {
return etatSeance.avertissements.map(({ code, details }) => {
const rendu = { code, details, remede: REMEDES.get(code) ?? null };
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
return rendu;
});
}

View file

@ -0,0 +1,218 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'événement ouvert (§ 8.4, § 8.5, § 8.8, § 8.9, § 9) : le cadre
// de mode et sa forme, la commande de la place fixe, les états du plan et
// leurs clés, la reprise de verrou offerte, et les avertissements ordonnés,
// chacun avec son remède. Chaque épreuve part d'un EtatSeance réduit à ce que
// la fonction lit ; les noms et identifiants sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { ETATS_PLAN, avertissementsOrdonnes, cadreDeMode, cleEtat, commandeFixe, repriseOfferte } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const FORMAT_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'FORMAT', remede: null } };
const BLOQUE_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } };
// Un EtatSeance réduit : mode, événement (null, ou son état du plan), refus,
// avertissements, fautives, examen, verrouAutre.
function etat({
mode = 'lecture',
plan = 'brouillon',
refus = null,
avertissements = [],
fautives = [],
examen = null,
verrouAutre = null,
} = {}) {
return {
mode,
evenement:
plan === null ? null : { base: 'merles', id: 'evt-merles', nom: 'Club des Merles', etat: plan, revision: 3, annonceFormat: null },
refus,
avertissements,
fautives,
examen,
verrouAutre,
};
}
describe('cadreDeMode (§ 8.4, § 14.5)', () => {
test('null sans événement', () => {
assert.equal(cadreDeMode(etat({ plan: null })), null);
});
test('lecture : tirets ; écriture : trait plein ; bloqué : trait double, quel que soit le mode', () => {
assert.deepEqual(cadreDeMode(etat({ mode: 'lecture' })), { cle: 'coquille.mode.lecture', forme: 'tirets' });
assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture' })), { cle: 'coquille.mode.ecriture', forme: 'plein' });
assert.deepEqual(cadreDeMode(etat({ mode: 'lecture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' });
});
test('trois formes distinctes pour trois libellés distincts, chaque libellé traduit', () => {
const cadres = [etat(), etat({ mode: 'ecriture' }), etat({ plan: 'bloque' })].map(cadreDeMode);
assert.equal(new Set(cadres.map(({ forme }) => forme)).size, 3);
assert.equal(new Set(cadres.map(({ cle }) => t(cle))).size, 3);
});
});
describe('commandeFixe (§ 8.4, § 8.5, § 8.8)', () => {
test('null sans événement ; modifier en lecture ; lecture en écriture ; debloquer sur un plan bloqué', () => {
assert.equal(commandeFixe(etat({ plan: null })), null);
assert.equal(commandeFixe(etat()), 'modifier');
assert.equal(commandeFixe(etat({ mode: 'ecriture', plan: 'retenu' })), 'lecture');
assert.equal(commandeFixe(etat({ plan: 'bloque', refus: BLOQUE_REFUS })), 'debloquer');
});
test("un format plus récent remplace la commande par l'explication, avant le plan bloqué", () => {
assert.equal(commandeFixe(etat({ refus: FORMAT_REFUS })), 'format');
assert.equal(commandeFixe(etat({ plan: 'bloque', refus: FORMAT_REFUS })), 'format');
});
});
describe('les états du plan (§ 9)', () => {
test("les quatre états, dans l'ordre du § 9, figés, chacun sous sa clé evenement.etat.<etat>, traduite", () => {
assert.deepEqual(ETATS_PLAN, ['brouillon', 'propose', 'retenu', 'bloque']);
assert.ok(Object.isFrozen(ETATS_PLAN));
assert.ok(ETATS_PLAN.length > 0);
for (const nom of ETATS_PLAN) {
assert.equal(cleEtat(nom), `evenement.etat.${nom}`);
assert.notEqual(t(cleEtat(nom)).trim(), '');
}
assert.equal(new Set(ETATS_PLAN.map((nom) => t(cleEtat(nom)))).size, 4);
});
test('un état inconnu lève RangeError', () => {
assert.throws(() => cleEtat('archive'), RangeError);
assert.throws(() => cleEtat('toString'), RangeError);
});
});
describe('repriseOfferte (§ 8.8)', () => {
const lointain = { seance: 'lointaine-1', depuis: '2031-05-17T09:00:00-04:00', vivant: null };
test('en lecture, un verrou refusé dont la vie ne se prouve pas : le verrou, à reprendre', () => {
assert.deepEqual(repriseOfferte(etat({ verrouAutre: lointain })), lointain);
const illisible = { seance: null, depuis: null, vivant: null };
assert.deepEqual(repriseOfferte(etat({ verrouAutre: illisible })), illisible);
});
test('ni sans verrou refusé, ni en écriture, ni sans événement, ni pour un verrou vivant ou mort', () => {
assert.equal(repriseOfferte(etat()), null);
assert.equal(repriseOfferte(etat({ mode: 'ecriture', verrouAutre: lointain })), null);
assert.equal(repriseOfferte(etat({ plan: null, verrouAutre: lointain })), null);
assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: true } })), null);
assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: false } })), null);
});
});
describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
// Un avertissement de chaque code du contrat, dans l'ordre où la séance
// les rend, avec des détails de forme quelconque : la fonction n'en lit
// aucun, hors de ceux qu'elle complète.
const CODES = [
'RETENU_FAUTIF',
'JOURNAL_REPRIS',
'JOURNAL_EN_AVANCE_DE',
'JOURNAL_INCOMPLET',
'JOURNAL_ECARTEES',
'JOURNAL_RECREE',
'JOURNAL_FORMAT_PLUS_RECENT',
'JOURNAL_ILLISIBLE',
'JOURNAL_ETRANGER',
'PROPOSITIONS_FAUTIVES',
'PROPOSITIONS_MANQUANTES',
'DERIVE',
'INVARIANT_VIOLE',
'VERROU_REPRIS',
'EVENEMENT_CHANGE',
'VERROU_NON_RENDU',
'JALON_NON_ECRIT',
'SUPPORT_AMOVIBLE',
'RESERVATIONS_SUSPENDUES',
'IMPORT_EN_RETENU',
'PLACES_MANQUANTES',
'DERIVE_NOUVELLE',
'RESERVATION_NON_HONOREE',
'CONFIGURATION_INCOHERENTE',
'RETOUR_AUTOMATIQUE',
];
const REMEDES = new Map([
['RETENU_FAUTIF', { geste: 'ouvrirSecours', source: 'precedent' }],
['DERIVE', { geste: 'ouvrirSection', section: 'propositions' }],
['DERIVE_NOUVELLE', { geste: 'ouvrirSection', section: 'propositions' }],
['PLACES_MANQUANTES', { geste: 'ouvrirSection', section: 'tables' }],
]);
const avertissement = (code, rang) => ({ code, details: { rang, ids: [], retenu: false } });
test('[] sans avertissement', () => {
assert.deepEqual(avertissementsOrdonnes(etat()), []);
assert.deepEqual(avertissementsOrdonnes(etat({ plan: null })), []);
});
test("l'ordre de la séance, codes et détails gardés, chacun avec son remède, null hors des quatre", () => {
const recus = CODES.map(avertissement);
const rendus = avertissementsOrdonnes(etat({ avertissements: recus }));
assert.equal(rendus.length, CODES.length);
assert.ok(rendus.length > 0);
rendus.forEach((rendu, rang) => {
assert.equal(rendu.code, CODES[rang]);
assert.deepEqual(rendu.details, recus[rang].details);
assert.deepEqual(rendu.remede, REMEDES.get(rendu.code) ?? null, rendu.code);
});
});
test("l'ordre est celui reçu, jamais un ordre propre : la liste renversée revient renversée", () => {
const renverses = [...CODES].reverse().map(avertissement);
assert.deepEqual(
avertissementsOrdonnes(etat({ avertissements: renverses })).map(({ code }) => code),
[...CODES].reverse(),
);
});
test('PROPOSITIONS_FAUTIVES porte les fautives de la séance, et lui seul', () => {
const fautives = [{ rang: 1, id: 4, fautes: [{ code: 'LONGUEUR', chemin: 'tours' }] }];
const rendus = avertissementsOrdonnes(
etat({ fautives, avertissements: [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [4] } }, avertissement('DERIVE_NOUVELLE', 0)] }),
);
assert.deepEqual(rendus[0].fautives, fautives);
assert.equal(Object.hasOwn(rendus[1], 'fautives'), false);
});
test("DERIVE porte ce que l'examen dit de chaque placement nommé : le retenu d'abord, puis chaque proposition de ids", () => {
const raisonRetenu = { code: 'PARTICIPANT_EXCLU', participant: 2 };
const raisonCinq = { code: 'TOURS_CHANGES', avant: 3, maintenant: 2 };
const examen = {
derives: [
{ id: 5, raisons: [raisonCinq] },
{ id: 9, raisons: [raisonCinq] },
],
retenu: { fautes: [], raisons: [raisonRetenu] },
};
const [derive] = avertissementsOrdonnes(
etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [5], retenu: true } }] }),
);
assert.deepEqual(derive.derives, [
{ id: 'retenu', raisons: [raisonRetenu] },
{ id: 5, raisons: [raisonCinq] },
]);
const [seule] = avertissementsOrdonnes(etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
assert.deepEqual(seule.derives, [{ id: 9, raisons: [raisonCinq] }]);
const [sansExamen] = avertissementsOrdonnes(etat({ avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] }));
assert.deepEqual(sansExamen.derives, []);
});
test('chaque remède rendu se traduit : sa clé existe', () => {
const rendus = avertissementsOrdonnes(etat({ avertissements: CODES.map(avertissement) }));
const avecRemede = rendus.filter(({ remede }) => remede !== null);
assert.equal(avecRemede.length, 4);
for (const { remede } of avecRemede) {
const cle = remede.geste === 'ouvrirSecours' ? `communs.remede.ouvrirSecours.${remede.source}` : `communs.remede.ouvrirSection.${remede.section}`;
assert.notEqual(t(cle).trim(), '');
}
});
});

View file

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,824 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2,
// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le
// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois
// modes, l'import selon l'état, le collage. Chaque épreuve monte App par
// monterApp, sur le système de fichiers simulé, ouvre un événement et sa
// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers
// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il
// reçoit (contrat de données, « Les dialogues de fichiers »).
//
// Les octets de chaque cas se construisent ici ; seuls les CSV livrés se
// lisent, par leur adresse (?url) et fetch : ?raw les lirait en UTF-8 et
// détruirait la variante windows-1252. Les valeurs attendues viennent des
// mêmes implémentations que l'écran sollicite — decoder, apercevoir,
// exporterRefus et libelle, bilanRemplacement —, appelées ici une seconde
// fois (§ 19.1). Les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { creerFormat } from '../../application/format.js';
import { creerTraduction } from '../../application/traduction.js';
import { apercevoir } from '../../csv/apercu.js';
import { decoder } from '../../csv/encodage.js';
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
import { decouper } from '../../csv/lecture.js';
import { serialiser } from '../../stockage/canonique.js';
import { creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js';
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
import { TABLES_INTERFACE } from '../libelles.js';
import { ENCODAGES, SEPARATEURS } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const BASE = 'soiree';
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const OUVERTURE_TEMOIN = '\u{2045}';
const FERMETURE_TEMOIN = '\u{2046}';
// Les octets d'un CSV livré, tels que le fichier les porte.
const lireLivre = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
// Un texte en UTF-8 avec marque, en UTF-16 LE ou BE avec marque.
const enUtf8 = (texte) => Uint8Array.from([...MARQUE_UTF8, ...new TextEncoder().encode(texte)]);
function enUtf16(texte, ordre) {
const octets = [ordre === 'le' ? 0xff : 0xfe, ordre === 'le' ? 0xfe : 0xff];
for (let i = 0; i < texte.length; i += 1) {
const unite = texte.charCodeAt(i);
const fort = unite >> 8;
const faible = unite & 0xff;
octets.push(...(ordre === 'le' ? [faible, fort] : [fort, faible]));
}
return Uint8Array.from(octets);
}
// Un participant de la charge d'épreuve.
const personne = (id, nom, prenom = null, autres = {}) => ({
id, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres,
});
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
// n'a rien reçu pendant l'épreuve.
let montes = [];
let erreursAvant = 0;
beforeEach(() => {
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
});
afterEach(() => {
for (const m of montes.reverse()) m.demonter();
montes = [];
vi.restoreAllMocks();
expect(globalThis.__gttErreurs?.slice(erreursAvant) ?? [], 'erreurs reçues par le collecteur').toEqual([]);
});
/**
* Un montage dont l'événement « Soirée des Merles » est ouvert sur sa
* section d'import : les participants donnés, en écriture sauf demande,
* préparé par prepare(seance, generation) avant que l'espion se pose. Le
* dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
* enregistres et rend le nom proposé.
*/
async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
charge.participants = participants;
charge.prochainsIds.participant = participants.length + 1;
const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
montes.push(m);
await m.seance.ouvrir(BASE);
await m.seance.passerEnEcriture();
if (prepare !== null) await prepare(m.seance, m.app.generation());
if (!ecriture && m.seance.etat().mode === 'ecriture') await m.seance.passerEnLecture();
m.ui.allerA('import');
flushSync();
const aServir = [];
const enregistres = [];
m.fs.choisirFichierAImporter = async () => aServir.shift() ?? null;
m.fs.enregistrerSous = async (nomPropose, octets) => {
enregistres.push({ nomPropose, octets });
return nomPropose;
};
const espion = vi.spyOn(m.seance, 'executer');
return { m, aServir, enregistres, espion };
}
const section = ({ m }) => m.racine.querySelector('[data-section="import"]');
const dans = (e, selecteur) => section(e).querySelector(selecteur);
const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)];
const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const ecritures = ({ m }) => m.trace.length;
const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants');
// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
async function choisir(e, nom, octets) {
e.aServir.push({ nom, octets });
commande(e, 'choisirFichier').click();
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe(nom));
}
// Remet un texte comme le collage de la grille (I5) : ui.ouvrirImport,
// puis ui.allerA('import').
async function coller(e, texte) {
e.m.ui.ouvrirImport({ texte, origine: 'collage' });
e.m.ui.allerA('import');
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-collage]')).not.toBeNull());
}
// Choisit un mode d'import.
function choisirMode(e, mode) {
dans(e, `[data-champ="mode"] input[value="${mode}"]`).click();
flushSync();
}
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait :
// rend ce que rend seance.executer, ou le refus.
async function importer(e) {
const avant = imports(e).length;
commande(e, 'importer').click();
await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1));
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1);
try {
const rendu = await resultat.value;
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
return { rendu, refus: null };
} catch (erreur) {
await vi.waitFor(() => expect(dans(e, `[data-refus="${erreur.code}"]`)).not.toBeNull());
return { rendu: null, refus: erreur };
}
}
// Les lignes valides montrées sous un conteneur : le texte de chaque
// cellule, dans l'ordre.
const lignesMontrees = (conteneur) =>
[...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) =>
[...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent));
const cellule = (conteneur, ligne, champ) =>
conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent;
const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
const association = (conteneur) =>
[...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
colonne.querySelector('[data-entete]').textContent,
colonne.querySelector('[data-devient]').textContent,
]);
const participantsDe = ({ m }) => m.seance.etat().charge.participants;
// Les entrées du journal de l'événement, 0 avant le premier geste.
function entrees({ m }) {
const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`);
return texte === null ? 0 : lireJournal(texte).entrees.length;
}
// Les comptes du rapport, chacun lu à son [data-compte] ; et ceux qu'il
// doit afficher : le résumé que rend seance.executer, par format.nombre.
const COMPTES_DU_RAPPORT = ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees'];
const comptesAffiches = (e) =>
COMPTES_DU_RAPPORT.map((nom) => [nom, dans(e, `[data-rapport] [data-compte="${nom}"] [data-nombre]`).textContent]);
const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom])]);
// Un glissement sur la fenêtre, portant un fichier ou un texte seul : rend
// l'événement distribué, dont defaultPrevented dit s'il a été accepté.
function glisser({ fichier = null, texte = null }) {
const transfert = new DataTransfer();
if (fichier !== null) transfert.items.add(fichier);
if (texte !== null) transfert.setData('text/plain', texte);
const evenement = new DragEvent('dragover', { dataTransfer: transfert, bubbles: true, cancelable: true });
document.body.dispatchEvent(evenement);
return evenement;
}
// Amène un élément dans la fenêtre, puis dit s'il y est rendu visible
// (test/navigateur/rendu.js).
function vu(element) {
element.scrollIntoView({ block: 'center' });
return visible(element);
}
describe('l’aperçu est obligatoire (§ 10.1)', () => {
test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
const e = await ouvrir();
const avant = ecritures(e);
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
expect(ecritures(e)).toBe(avant);
const octets = enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n');
const deposee = new File([octets], 'deposee.csv', { type: 'text/csv' });
// Le survol qui porte un fichier est accepté, sans quoi le navigateur ne
// remet pas le dépôt et ouvre le fichier à la place de l'application ;
// un survol sans fichier ne l'est pas.
expect(glisser({ fichier: deposee }).defaultPrevented).toBe(true);
expect(glisser({ texte: 'Ombrelle' }).defaultPrevented).toBe(false);
const transfert = new DataTransfer();
transfert.items.add(deposee);
document.body.dispatchEvent(new DragEvent('drop', { dataTransfer: transfert, bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe('deposee.csv'));
const controles = tous(e, '[data-apercu] button, [data-apercu] input').filter(
(element) => element.dataset.commande !== 'importer' && element.dataset.commande !== 'fermerApercu',
);
expect(controles.length).toBeGreaterThan(0);
for (const controle of controles) {
controle.click();
flushSync();
}
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
expect(imports(e)).toEqual([]);
expect(ecritures(e)).toBe(avant);
});
});
describe('encodage et séparateur nommés (§ 10.1, § 19.3)', () => {
test('les cas limites, puis leur variante windows-1252 : encodage et séparateur de decoder et de l’aperçu, « Héloïse » intacte, mêmes lignes', async () => {
const e = await ouvrir();
const vus = [];
for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
const octets = await lireLivre(url);
await choisir(e, nom, octets);
const { texte, encodage } = decoder(octets);
expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(encodage)));
expect(dans(e, '[data-separateur]').textContent).toBe(t(SEPARATEURS.get(apercevoir(texte).separateur)));
const apercu = dans(e, '[data-apercu]');
expect(apercu.textContent).toContain('Héloïse');
expect(apercu.textContent).not.toContain('Héloïse');
vus.push({ encodage, lignes: lignesMontrees(apercu) });
}
expect(vus.map(({ encodage }) => encodage)).toEqual(['utf-8-bom', 'windows-1252']);
expect(dans(e, '[data-separateur]').textContent).toBe(t('import.separateur.virgule'));
expect(vus[0].lignes.length).toBe(13);
expect(vus[1].lignes).toEqual(vus[0].lignes);
});
test('un même texte en UTF-16 LE puis BE, à marque : chacun nommé, mêmes lignes', async () => {
const e = await ouvrir();
const texte = 'nom;prenom;appartenance\r\nOmbrelle;Iris;Club des Merles\r\nPervenche;Benoît;Grisaille\r\n';
const vus = [];
for (const ordre of ['le', 'be']) {
await choisir(e, `liste-${ordre}.csv`, enUtf16(texte, ordre));
expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(`utf-16${ordre}`)));
vus.push(lignesMontrees(dans(e, '[data-apercu]')));
}
expect(vus[0].length).toBe(2);
expect(vus[1]).toEqual(vus[0]);
expect(vus[0][1].slice(0, 3)).toEqual(['Pervenche', 'Benoît', 'Grisaille']);
});
test('un fichier d’une seule colonne, en-tête « nom » : « une seule colonne », « Ombrelle, Iris » entière', async () => {
const e = await ouvrir();
await choisir(e, 'noms.csv', enUtf8('nom\r\nPervenche\r\nOmbrelle, Iris\r\nLacasse\r\n'));
expect(dans(e, '[data-separateur]').textContent).toBe('une seule colonne');
expect(cellule(dans(e, '[data-apercu]'), 3, 'nom')).toBe('Ombrelle, Iris');
});
});
describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => {
test('deux fichiers aux colonnes permutées, « NOM », « Équipe », « Rôle » : même association, mêmes participants ; « Allergies » non reconnue', async () => {
const e = await ouvrir();
const premier = 'NOM;Équipe;Rôle;Allergies\r\nOmbrelle;Club des Merles;animation;noix\r\nPervenche;Grisaille;;\r\n';
const second = 'Allergies;Rôle;Équipe;NOM\r\nnoix;animation;Club des Merles;Ombrelle\r\n;;Grisaille;Pervenche\r\n';
const vus = [];
for (const [nom, texte] of [['premier.csv', premier], ['second.csv', second]]) {
await choisir(e, nom, enUtf8(texte));
const apercu = dans(e, '[data-apercu]');
vus.push({ association: new Map(association(apercu)), lignes: lignesMontrees(apercu) });
}
expect(vus[0].association.size).toBe(4);
expect(vus[1].association).toEqual(vus[0].association);
expect(vus[0].association.get('NOM')).toBe(t('import.champ.nom'));
expect(vus[0].association.get('Équipe')).toBe(t('import.champ.appartenance'));
expect(vus[0].association.get('Rôle')).toBe(t('import.champ.titrePressenti'));
expect(vus[0].association.get('Allergies')).toBe(t('import.colonne.nonReconnue'));
expect(vus[1].lignes).toEqual(vus[0].lignes);
expect(vus[0].lignes.length).toBe(2);
});
test('« organisation » et « entreprise » : l’aperçu demande laquelle vaut appartenance ; importer est refusé à côté de la commande tant qu’elle demeure ; le choix l’associe, et l’import passe', async () => {
const e = await ouvrir();
await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
const question = dans(e, '[data-champ="choix-appartenance"]');
expect(vu(question)).toBe(true);
expect(texteDe(question)).toContain('organisation');
expect(texteDe(question)).toContain('entreprise');
const avant = ecritures(e);
const { refus } = await importer(e);
expect(refus.code).toBe('AMBIGUITE');
const posee = dans(e, '[data-refus="AMBIGUITE"]');
expect(commande(e, 'importer').parentElement.contains(posee)).toBe(true);
expect(ecritures(e)).toBe(avant);
question.querySelector('input[value="2"]').click();
flushSync();
expect(new Map(association(dans(e, '[data-apercu]'))).get('entreprise')).toBe(t('import.champ.appartenance'));
expect(dans(e, '[data-champ="choix-appartenance"]')).toBeNull();
const { rendu } = await importer(e);
expect(rendu.resume.ajoutes).toBe(1);
expect(participantsDe(e).map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Ombrelle', 'Grisaille']]);
});
test('sous « Remplacer », l’ambiguïté refuse l’import à côté de la commande, sans ouvrir la fenêtre ; le choix fait, la fenêtre s’ouvre', async () => {
const e = await ouvrir({ participants: QUATRE() });
await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n'));
choisirMode(e, 'remplacer');
const avant = ecritures(e);
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, '[data-refus="AMBIGUITE"]')).not.toBeNull());
expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="AMBIGUITE"]'))).toBe(true);
expect(dans(e, 'dialog')).toBeNull();
expect(imports(e)).toEqual([]);
expect(ecritures(e)).toBe(avant);
dans(e, '[data-champ="choix-appartenance"] input[value="1"]').click();
flushSync();
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
dans(e, 'dialog [data-commande="renoncer"]').click();
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
expect(imports(e)).toEqual([]);
expect(ecritures(e)).toBe(avant);
});
});
describe('exclu : une liste fermée (§ 10.1)', () => {
test('« Oui », « X », « 0 » et vide acceptés ; « peut-être » refuse sa ligne, le motif citant la valeur ; les autres s’importent', async () => {
const e = await ouvrir();
await choisir(
e,
'exclus.csv',
enUtf8('nom;exclu\r\nOmbrelle;Oui\r\nPervenche;X\r\nLacasse;0\r\nFauvelle;\r\nMorillon;peut-être\r\n'),
);
const apercu = dans(e, '[data-apercu]');
expect(lignesMontrees(apercu).length).toBe(4);
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').length).toBe(1);
expect(texteDe(refusee(apercu, 6))).toContain('« peut-être »');
await importer(e);
expect(participantsDe(e).map(({ nom, exclu }) => [nom, exclu])).toEqual([
['Ombrelle', true], ['Pervenche', true], ['Lacasse', false], ['Fauvelle', false],
]);
});
});
// Un fichier par refus global, ses octets, et la clé du remède attendue.
const REFUS_GLOBAUX = [
['AUCUN_ENTETE', enUtf8('alpha;beta\r\nOmbrelle;Iris\r\n'), 'import.remede.aucunEntete'],
['NOM_NON_ASSOCIE', enUtf8('prenom;notes\r\nIris;x\r\n'), 'import.remede.nomNonAssocie'],
['AUCUNE_LIGNE_VALIDE', enUtf8('nom;prenom\r\n;Iris\r\n'), 'import.remede.aucuneLigneValide'],
['UTF16_SANS_MARQUE', Uint8Array.from([0x6e, 0x00, 0x6f, 0x00, 0x6d, 0x00]), 'ENREGISTRER_EN_UTF8'],
['UTF16_INVALIDE', Uint8Array.from([0xff, 0xfe, 0x6e, 0x00, 0x6f]), 'ENREGISTRER_EN_UTF8'],
['UTF8_INVALIDE', Uint8Array.from([...MARQUE_UTF8, 0x6e, 0x6f, 0xe9, 0x6d]), 'ENREGISTRER_EN_UTF8'],
['CARACTERE_NUL', enUtf8('nom\r\nOmbrelle\u{0}\r\n'), 'ENREGISTRER_EN_UTF8'],
['GUILLEMET_OUVERT', enUtf8('nom;prenom\r\n"Ombrelle;Iris\r\n'), 'import.remede.guillemetOuvert'],
['SEPARATEUR_INTROUVABLE', enUtf8('nom;prenom\r\nOmbrelle;Iris;x\r\nPervenche\r\n'), 'import.remede.separateurIntrouvable'],
];
describe('les refus globaux (§ 10.1)', () => {
test('un fichier par cas : la cause et le remède de la table affichés, aucune commande d’import, rien d’écrit', async () => {
const e = await ouvrir();
expect(REFUS_GLOBAUX.length).toBe(9);
const avant = ecritures(e);
for (const [code, octets, remede] of REFUS_GLOBAUX) {
await choisir(e, `${code}.csv`, octets);
const pose = dans(e, `[data-apercu] [data-refus="${code}"]`);
expect(pose, code).not.toBeNull();
expect(vu(pose), code).toBe(true);
const texte = pose.textContent;
expect(texte, code).toContain(t(remede, { ligne: 2 }));
expect(texte.length, code).toBeGreaterThan(t(remede, { ligne: 2 }).length);
expect(commande(e, 'importer'), code).toBeNull();
}
expect(ecritures(e)).toBe(avant);
});
test('un collage qui porte le caractère nul : la cause et le remède du collage', async () => {
const e = await ouvrir();
const avant = ecritures(e);
await coller(e, 'nom\nOmbrelle\u{0}\n');
const pose = dans(e, '[data-apercu] [data-refus="CARACTERE_NUL"]');
expect(pose.textContent).toContain(t('CARACTERE_NUL'));
expect(pose.textContent).toContain(t('import.remede.caractereNulCollage'));
expect(commande(e, 'importer')).toBeNull();
expect(dans(e, '[data-encodage]')).toBeNull();
expect(ecritures(e)).toBe(avant);
});
});
describe('une ligne fausse n’arrête pas le reste (§ 10.1, § 2.1)', () => {
test('la grande démonstration, « peut-être » à l’enregistrement 213 : 259 lignes valides, la ligne 213 et sa valeur ; l’import en ajoute 259', async () => {
const e = await ouvrir();
const { texte } = decoder(await lireLivre(urlGrande));
const enregistrements = texte.split('\r\n');
expect(enregistrements[212]).toMatch(/;non;$/);
enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;');
await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n')));
const apercu = dans(e, '[data-apercu]');
expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259');
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['213']);
expect(texteDe(refusee(apercu, 213))).toContain('213');
expect(texteDe(refusee(apercu, 213))).toContain(t('EXCLU_INCONNU', { valeur: 'peut-être' }));
const { rendu } = await importer(e);
expect(rendu.resume.ajoutes).toBe(259);
expect(participantsDe(e).length).toBe(259);
});
test('le rang : la ligne refusée des cas limites, sans nom, s’affiche 13, son enregistrement, et non 14, sa ligne de texte', async () => {
const e = await ouvrir();
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
const apercu = dans(e, '[data-apercu]');
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['13']);
expect(texteDe(refusee(apercu, 13))).toContain('13');
expect(texteDe(refusee(apercu, 13))).toContain(t('NOM_ABSENT'));
expect(refusee(apercu, 14)).toBeNull();
});
});
describe('le rapport (§ 10.1, § 19.3)', () => {
test('« Coopérative Bleue » existante, « cooperative bleue » et « COOPÉRATIVE BLEUE » lues : un groupe affiché, ses trois orthographes et leurs comptes, dans l’aperçu puis le rapport', async () => {
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' })] });
await choisir(
e,
'bleue.csv',
enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;cooperative bleue\r\nLacasse;Ondine;COOPÉRATIVE BLEUE\r\nFauvelle;;\r\n'),
);
const attendu = [['Coopérative Bleue', '0'], ['cooperative bleue', '1'], ['COOPÉRATIVE BLEUE', '1']];
const groupes = (conteneur) =>
[...conteneur.querySelectorAll('[data-fusion]')].map((fusion) => ({
affichee: fusion.querySelector('[data-affichee]').textContent,
orthographes: [...fusion.querySelectorAll('[data-orthographe]')].map((o) => [
o.querySelector('[data-texte]').textContent,
o.querySelector('[data-nombre]').textContent,
]),
}));
expect(groupes(dans(e, '[data-apercu]'))).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
await importer(e);
const rapport = dans(e, '[data-rapport]');
expect(vu(rapport.querySelector('h3'))).toBe(true);
expect(groupes(rapport)).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]);
for (const texte of tous(e, '[data-orthographe] [data-texte]')) expect(texte.textContent.trim()).not.toBe('');
});
test('les cas limites : au moins un groupe et une ligne refusée, dans l’aperçu puis le rapport ; aucune appartenance vide listée', async () => {
const e = await ouvrir();
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
for (const conteneur of ['[data-apercu]', null]) {
if (conteneur === null) await importer(e);
const ici = dans(e, conteneur ?? '[data-rapport]');
expect(ici.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1);
expect(ici.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1);
const orthographes = [...ici.querySelectorAll('[data-orthographe] [data-texte]')];
expect(orthographes.length).toBeGreaterThan(0);
for (const texte of orthographes) expect(texte.textContent.trim()).not.toBe('');
}
});
});
describe('le CSV des lignes refusées (§ 10.1)', () => {
test('« Enregistrer les lignes refusées » remet les octets d’exporterRefus de src/csv, motif de libelle ; corrigé et réimporté, la ligne entre, ligne et motif non reconnues', async () => {
const e = await ouvrir();
const octets = await lireLivre(urlCasLimites);
await choisir(e, 'participants_cas_limites.csv', octets);
commande(e, 'enregistrerRefus').click();
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
const apercu = apercevoir(decoder(octets).texte, { participants: [], mode: 'ajouter' });
expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`);
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]);
const corrige = decoder(e.enregistres[0].octets).texte.replace('\r\n;Clotilde;', '\r\nRavenelle;Clotilde;');
expect(corrige).toContain('Ravenelle;Clotilde;');
await choisir(e, 'corrige.csv', enUtf8(corrige));
const vu = new Map(association(dans(e, '[data-apercu]')));
expect(vu.get('ligne')).toBe(t('import.colonne.nonReconnue'));
expect(vu.get('motif')).toBe(t('import.colonne.nonReconnue'));
await importer(e);
expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ravenelle', 'Clotilde']]);
});
test('après une mise à jour qui refuse des lignes, le rapport remet exporterRefus(apercu, libelle, resume.refusees), refus de la mise à jour compris', async () => {
const e = await ouvrir({
participants: [personne(1, 'Lacasse', 'Ondine'), personne(2, 'Lacasse', 'Odile'), personne(3, 'Ombrelle', 'Iris')],
});
await choisir(e, 'maj.csv', enUtf8('nom;prenom\r\nLacasse;\r\nOmbrelle;Irène\r\n;Théo\r\n'));
const { rendu } = await importer(e);
const { apercu } = imports(e).at(-1)[1];
expect(rendu.resume.refusees.map(({ code }) => code)).toEqual(['PLUSIEURS_CORRESPONDENT', 'NOM_ABSENT']);
commande(e, 'enregistrerRefus').click();
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle, rendu.resume.refusees)]);
});
test('en langue témoin, le motif affiché porte les marques, la colonne motif du CSV aucune', async () => {
const e = await ouvrir({ langue: 'temoin' });
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
const motif = refusee(dans(e, '[data-apercu]'), 13).querySelector('[data-motif]').textContent;
expect(motif.startsWith(OUVERTURE_TEMOIN) && motif.endsWith(FERMETURE_TEMOIN)).toBe(true);
commande(e, 'enregistrerRefus').click();
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
const { texte } = decoder(e.enregistres[0].octets);
const [entete, ...refus] = decouper(texte, ';').enregistrements;
const rang = entete.indexOf('motif');
expect(rang).toBeGreaterThan(0);
expect(refus.length).toBe(1);
expect(refus[0][rang]).toBe(libelle('NOM_ABSENT', { ligne: 13, valeur: null }));
expect(texte).not.toContain(OUVERTURE_TEMOIN);
expect(texte).not.toContain(FERMETURE_TEMOIN);
});
});
describe('une seule entrée, des doublons signalés, une mise à jour qui n’efface pas (§ 10.1)', () => {
test('les 260 lignes de la grande démonstration : une entrée du journal, un état écrit', async () => {
// Un premier geste pose le journal, et l'entrée de reprise qui l'ouvre.
const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
expect(entrees(e)).toBeGreaterThan(0);
await choisir(e, 'participants_demo_grande.csv', await lireLivre(urlGrande));
const avant = ecritures(e);
const entreesAvant = entrees(e);
const { rendu } = await importer(e);
expect(rendu.resume.ajoutes).toBe(260);
expect(entrees(e)).toBe(entreesAvant + 1);
const etats = e.m.trace.slice(avant).filter(([primitive, , chemin]) => primitive === 'ecrireAtomique' && chemin === `${BASE}${SUFFIXES.etat}`);
expect(etats.length).toBe(1);
});
test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => {
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
await choisir(
e,
'doublons.csv',
enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;Club des Merles\r\npervenche;THÉO;club des merles\r\nOmbrelle;Iris;Grisaille\r\n'),
);
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
choisirMode(e, 'ajouter');
expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(2);
const { rendu } = await importer(e);
expect(rendu.resume.ajoutes).toBe(3);
const ids = participantsDe(e).map(({ id }) => id);
expect(ids.length).toBe(4);
expect(new Set(ids).size).toBe(4);
});
test('mettre à jour : aucun courriel vidé ; une ligne qui désigne deux personnes, refusée, les deux nommées ; une seconde ligne pour la même personne, refusée, la première nommée', async () => {
const e = await ouvrir({
participants: [
personne(1, 'Ombrelle', 'Iris', { courriel: 'iris@exemple.test' }),
personne(2, 'Lacasse', 'Ondine', { courriel: 'ondine@exemple.test' }),
personne(3, 'Lacasse', 'Odile'),
personne(4, 'Pervenche', 'Théo', { courriel: 'theo@exemple.test' }),
],
});
await choisir(
e,
'noms_prenoms.csv',
enUtf8('nom;prenom\r\nOmbrelle;Iris\r\nLacasse;\r\nPervenche;Théo\r\nPervenche;\r\nMorillon;Aurèle\r\n'),
);
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
const { rendu } = await importer(e);
expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 0, inchanges: 2, retires: 0, reservationsRetirees: 0 });
expect(comptesAffiches(e)).toEqual(comptesDu(rendu.resume));
const courriels = new Map(participantsDe(e).map(({ id, courriel }) => [id, courriel]));
expect([courriels.get(1), courriels.get(2), courriels.get(4)]).toEqual(['iris@exemple.test', 'ondine@exemple.test', 'theo@exemple.test']);
const rapport = dans(e, '[data-rapport]');
const plusieurs = refusee(rapport, 3);
expect(texteDe(plusieurs)).toContain(t('PLUSIEURS_CORRESPONDENT', { participants: [2, 3] }));
expect([...plusieurs.querySelectorAll('[data-participant]')].map((p) => [p.dataset.participant, texteDe(p)])).toEqual([
['2', 'Ondine Lacasse'],
['3', 'Odile Lacasse'],
]);
const deja = refusee(rapport, 5);
expect(texteDe(deja)).toContain(t('DEJA_DESIGNE', { premiereLigne: 4 }));
});
});
// Deux tables de trois places, puis une génération enregistrée jusqu'à
// compter nombre propositions.
async function avecPropositions(seance, generation, nombre) {
await seance.executer('creerTable', { forme: 'ronde' });
await seance.executer('creerTable', { forme: 'ronde' });
for (let essai = 0; essai < 5 && seance.etat().charge.propositions.length < nombre; essai += 1) {
await generation.generer({ ...generation.reglagesProposes(), nombre, arret: 500 });
}
expect(seance.etat().charge.propositions.length).toBeGreaterThanOrEqual(nombre);
}
const QUATRE = () => [
personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' }),
personne(2, 'Pervenche', 'Théo', { appartenance: 'Grisaille' }),
personne(3, 'Lacasse', 'Ondine', { appartenance: 'Club des Merles' }),
personne(4, 'Fauvelle', 'Aurèle'),
];
describe('remplacer annonce ce qu’il détruit (§ 10.1)', () => {
test('l’aperçu se recalcule sous remplacer, la fenêtre porte les quatre comptes ; Annuler n’écrit rien ; Confirmer, une entrée, des identifiants neufs', async () => {
const e = await ouvrir({
participants: QUATRE(),
siegesParDefaut: 3,
prepare: async (seance, generation) => {
await avecPropositions(seance, generation, 2);
const [table] = seance.etat().charge.tables;
await seance.executer('reserver', { participant: 1, table: table.id, siege: null, portee: 'tous', tour: null });
await seance.executer('designer', { participant: 2, table: table.id, siege: null, libelle: 'Animation' });
},
});
await choisir(e, 'remplace.csv', enUtf8('nom;prenom;appartenance\r\nTisserin;Basilie;cooperative bleue\r\nMorillon;Aurèle;\r\n'));
expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(1);
choisirMode(e, 'remplacer');
expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(0);
expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(0);
expect(cellule(dans(e, '[data-apercu]'), 2, 'appartenance')).toBe('cooperative bleue');
const charge = e.m.seance.etat().charge;
const bilan = bilanRemplacement(charge);
expect(bilan.reservations).toBeGreaterThan(0);
expect(bilan.titresPourvus).toBeGreaterThan(0);
expect(bilan.propositions).toBeGreaterThanOrEqual(2);
const attendus = [
t('import.remplacer.participants', { n: bilan.participants }),
t('import.remplacer.reservations', { n: bilan.reservations }),
t('import.remplacer.titres', { n: bilan.titresPourvus }),
t('import.remplacer.propositions', { n: bilan.propositions }),
];
const avant = ecritures(e);
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus);
dans(e, 'dialog [data-commande="renoncer"]').click();
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
expect(imports(e)).toEqual([]);
expect(ecritures(e)).toBe(avant);
const prochain = charge.prochainsIds.participant;
const entreesAvant = entrees(e);
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
dans(e, 'dialog [data-commande="confirmer"]').click();
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
expect(imports(e).length).toBe(1);
expect(imports(e)[0][1].mode).toBe('remplacer');
expect(entrees(e)).toBe(entreesAvant + 1);
const { resume } = await e.espion.mock.results.at(-1).value;
expect(resume).toMatchObject({ ajoutes: 2, misAJour: 0, inchanges: 0, retires: 4, reservationsRetirees: 2 });
expect(comptesAffiches(e)).toEqual(comptesDu(resume));
const apres = participantsDe(e);
expect(apres.map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Tisserin', 'cooperative bleue'], ['Morillon', null]]);
for (const { id } of apres) expect(id).toBeGreaterThanOrEqual(prochain);
});
});
describe('importer selon l’état (§ 10.1, § 9)', () => {
const FICHIER = 'nom;prenom\r\nMorillon;Aurèle\r\n';
test('brouillon et proposé sans avertissement ; retenu avec IMPORT_EN_RETENU dans le rendu de seance.executer', async () => {
const preparations = [
['brouillon', null],
['propose', (seance, generation) => avecPropositions(seance, generation, 1)],
[
'retenu',
async (seance, generation) => {
await avecPropositions(seance, generation, 1);
await seance.executer('retenirProposition', { id: seance.etat().charge.propositions[0].id });
},
],
];
for (const [etat, prepare] of preparations) {
const e = await ouvrir({ participants: QUATRE(), siegesParDefaut: 3, prepare });
expect(e.m.seance.etat().charge.evenement.etat).toBe(etat);
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
const { rendu } = await importer(e);
const codes = rendu.avertissements.map(({ code }) => code);
if (etat === 'retenu') expect(codes, etat).toContain('IMPORT_EN_RETENU');
else expect(codes, etat).not.toContain('IMPORT_EN_RETENU');
expect(participantsDe(e).length, etat).toBe(5);
}
});
test('bloqué : l’aperçu s’ouvre, l’import est refusé à côté de la commande, le message portant « Débloquer », rien d’écrit', async () => {
const e = await ouvrir({
participants: QUATRE(),
prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }),
});
const avant = ecritures(e);
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
const { refus } = await importer(e);
expect(refus.code).toBe('LECTURE_SEULE');
const pose = dans(e, '[data-refus="LECTURE_SEULE"]');
expect(commande(e, 'importer').parentElement.contains(pose)).toBe(true);
expect(texteDe(pose)).toContain('Débloquer');
expect(ecritures(e)).toBe(avant);
expect(participantsDe(e).length).toBe(4);
});
test('en lecture : l’aperçu s’ouvre, l’import est refusé avec « Modifier », rien d’écrit', async () => {
const e = await ouvrir({ ecriture: false });
expect(e.m.seance.etat().mode).toBe('lecture');
const avant = ecritures(e);
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
expect(vu(dans(e, '[data-apercu] h3'))).toBe(true);
const { refus } = await importer(e);
expect(refus.code).toBe('MODE_LECTURE');
const pose = dans(e, '[data-refus="MODE_LECTURE"]');
expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(ecritures(e)).toBe(avant);
});
});
describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => {
const TEXTE = [
['nom', 'prenom', 'appartenance'],
['Ombrelle', 'Iris', '"Club\tdes ""Merles"""'],
['Pervenche', 'Benoît', 'Grisaille'],
].map((champs) => champs.join('\t')).join('\n');
test('un texte à tabulations collé : un aperçu sans encodage ; l’import rend les mêmes participants, champ par champ, que le même texte servi en fichier', async () => {
const collage = await ouvrir();
await coller(collage, TEXTE);
expect(dans(collage, '[data-encodage]')).toBeNull();
expect(dans(collage, '[data-separateur]').textContent).toBe(t('import.separateur.tabulation'));
await importer(collage);
const fichier = await ouvrir();
await choisir(fichier, 'colle.csv', enUtf8(TEXTE));
await importer(fichier);
expect(participantsDe(collage).length).toBe(2);
expect(participantsDe(collage)[0].appartenance).toBe('Club\tdes "Merles"');
expect(participantsDe(collage)[1].prenom).toBe('Benoît');
expect(participantsDe(collage)).toEqual(participantsDe(fichier));
});
test('sans sa ligne d’en-tête : AUCUN_ENTETE, puis « Lire les colonnes dans l’ordre de la grille » en fait nom, prénom et appartenance', async () => {
const e = await ouvrir();
await coller(e, TEXTE.split('\n').slice(1).join('\n'));
const pose = dans(e, '[data-refus="AUCUN_ENTETE"]');
expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage'));
commande(e, 'ordreDeLaGrille').click();
await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
expect(association(dans(e, '[data-apercu]')).map(([, devient]) => devient)).toEqual([
t('import.champ.nom'), t('import.champ.prenom'), t('import.champ.appartenance'),
]);
expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['2', '3']);
await importer(e);
expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ombrelle', 'Iris'], ['Pervenche', 'Benoît']]);
});
test('une liste de noms d’une colonne : SEPARATEUR_INTROUVABLE, la même offre, chaque ligne entière en nom', async () => {
const e = await ouvrir();
await coller(e, 'Ombrelle\nLacasse, Ondine\nPervenche');
const pose = dans(e, '[data-refus="SEPARATEUR_INTROUVABLE"]');
expect(pose.textContent).toContain(t('import.remede.separateurIntrouvableCollage'));
commande(e, 'ordreDeLaGrille').click();
await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull());
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Lacasse, Ondine', 'Pervenche']);
});
});
describe('les cas du § 10.3', () => {
// Chacun des neuf cas, lu dans un conteneur : l'aperçu, puis le rapport.
function neufCas(conteneur) {
const colonnes = new Map(association(conteneur));
expect(colonnes.get('Téléphone'), 'colonne non reconnue').toBe(t('import.colonne.nonReconnue'));
expect(cellule(conteneur, 6, 'appartenance'), 'sans appartenance').toBe('');
expect(cellule(conteneur, 7, 'exclu'), 'exclusion').toBe(t('import.exclu.oui'));
expect(cellule(conteneur, 8, 'notes'), 'champ cité avec le séparateur').toBe('végétarien; sans noix, ni arachides');
expect(cellule(conteneur, 9, 'notes'), 'champ cité sur deux lignes').toMatch(/premier tour\r?\nrepart/);
const doublons = [...conteneur.querySelectorAll('[data-doublon]')].map((d) =>
[...d.querySelectorAll('[data-nombre]')].map((n) => n.textContent));
expect(doublons, 'doublon').toEqual([['2', '10']]);
expect(cellule(conteneur, 11, 'prenom'), 'sans prénom').toBe('');
const fusions = [...conteneur.querySelectorAll('[data-fusion] [data-orthographe] [data-texte]')].map((o) => o.textContent);
expect(fusions, 'paire d’orthographes').toEqual(['Société des Hérons', 'Societe des herons']);
expect(refusee(conteneur, 13), 'ligne refusée').not.toBeNull();
}
test('l’aperçu et le rapport des cas limites montrent chacun des neuf cas', async () => {
const e = await ouvrir();
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
neufCas(dans(e, '[data-apercu]'));
await importer(e);
neufCas(dans(e, '[data-rapport]'));
});
test('la variante windows-1252 nomme windows-1252 et la virgule, et rend les mêmes participants', async () => {
const parVariante = [];
for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) {
const e = await ouvrir();
await choisir(e, nom, await lireLivre(url));
parVariante.push({ encodage: dans(e, '[data-encodage]').textContent, separateur: dans(e, '[data-separateur]').textContent });
await importer(e);
parVariante.at(-1).participants = participantsDe(e);
}
expect(parVariante[1].encodage).toBe(t('import.encodage.windows1252'));
expect(parVariante[1].separateur).toBe(t('import.separateur.virgule'));
expect(parVariante[0].participants.length).toBe(13);
expect(parVariante[1].participants).toEqual(parVariante[0].participants);
});
});

View file

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

View file

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

@ -0,0 +1,309 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre,
// en clés de libellés et en données, d'un fichier décodé, d'un fichier que
// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
// quels.
import { CHAMPS } from '../../application/csv.js';
/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
* quatre temps. */
export const ENCODAGES = new Map([
['utf-8-bom', 'import.encodage.utf8Marque'],
['utf-16le', 'import.encodage.utf16le'],
['utf-16be', 'import.encodage.utf16be'],
['utf-8', 'import.encodage.utf8'],
['windows-1252', 'import.encodage.windows1252'],
]);
/** La clé du nom de chaque séparateur de l'aperçu ; null : le fichier n'a
* qu'une colonne. */
export const SEPARATEURS = new Map([
[';', 'import.separateur.pointVirgule'],
[',', 'import.separateur.virgule'],
['\t', 'import.separateur.tabulation'],
[null, 'import.separateur.uneColonne'],
]);
// La clé du nom de chaque champ, dans l'ordre de CHAMPS.
const CLES_DES_CHAMPS = new Map([
['nom', 'import.champ.nom'],
['prenom', 'import.champ.prenom'],
['appartenance', 'import.champ.appartenance'],
['courriel', 'import.champ.courriel'],
['titre_pressenti', 'import.champ.titrePressenti'],
['exclu', 'import.champ.exclu'],
['notes', 'import.champ.notes'],
]);
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
const CLES_D_EXCLU = new Map([
[true, 'import.exclu.oui'],
[false, 'import.exclu.non'],
]);
// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les
* offre, et la clé de leur nom. */
export const MODES_D_IMPORT = Object.freeze(
[
['ajouter', 'import.mode.ajouter'],
['mettreAJour', 'import.mode.mettreAJour'],
['remplacer', 'import.mode.remplacer'],
].map(([mode, cle]) => Object.freeze({ mode, cle })),
);
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
* CHAMPS, et la clé de son nom. */
export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
// Le remède d'un refus de decoder : le code que ses détails portent, que le
// texte du refus compose déjà (contrat « Refus et avertissements »).
const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']);
// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y
// arrive que d'un texte collé : celui d'un fichier, decoder le refuse.
const REMEDES_DE_L_APERCU = new Map([
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', 'import.remede.aucunEntete'],
['NOM_NON_ASSOCIE', 'import.remede.nomNonAssocie'],
['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'],
['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'],
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
]);
// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
const REMEDES_DE_LA_GRILLE = new Map([
['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'],
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvableCollage'],
]);
// La valeur d'une table de clés, ou RangeError qui nomme ce qui manque :
// jamais une clé devinée.
function dans(table, valeur, quoi) {
if (!table.has(valeur)) throw new RangeError(`import : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return table.get(valeur);
}
// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur.
function offreLaGrille(apercu) {
if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null;
}
// Le refus global d'un refus de decoder : sa cause, le code ; son remède,
// le code de details.remede, déjà dans la cause.
function globalDeDecoder({ code, details }) {
if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`);
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } };
}
// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu
// offre l'ordre de la grille prend le remède qui le nomme.
function globalDeLApercu(apercu, collage) {
const code = apercu.refusGlobal;
if (code === null) return null;
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
const rang = apercu.refusGlobalLigne;
const details = rang === null ? {} : { ligne: rang };
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } };
}
// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère
// nul, un séparateur introuvable ou un texte blanc, où l'aperçu porte null
// sans que le texte ait une seule colonne.
function separateurDe(apercu) {
const nonEtabli =
apercu.refusGlobal === 'CARACTERE_NUL' ||
apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' ||
apercu.entetes.length === 0;
return nonEtabli ? null : dans(SEPARATEURS, apercu.separateur, 'séparateur');
}
// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la
// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne
// écartée par le choix d'une autre, une colonne non reconnue.
function colonnesDe({ entetes, association }) {
const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
const nonReconnues = new Set(association.nonReconnues);
return entetes.map((entete, rang) => {
let cle = 'import.colonne.ecartee';
if (champDuRang.has(rang)) cle = CLES_DES_CHAMPS.get(champDuRang.get(rang));
else if (aTrancher.has(rang)) cle = 'import.colonne.aTrancher';
else if (nonReconnues.has(rang)) cle = 'import.colonne.nonReconnue';
return { rang, entete, cle };
});
}
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu ».
function lignesDe(lignes) {
return lignes.map(({ ligne, champs }) => ({
ligne,
cellules: CHAMPS.map((champ) => {
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
return { champ, texte: valeur, cle: null };
}),
}));
}
/**
* Les lignes refusées, par l'aperçu ou par la mise à jour, telles que
* l'aperçu et le rapport les montrent : le rang, le code, et les détails que
* reçoit le motif — la ligne refusée sans son code ni ses champs découpés,
* comme exporterRefus les passe au motif du CSV.
*
* @param {Array<{ligne: number, code: string, brut: string[]}>} refusees
* @returns {Array<{ligne: number, code: string, details: Object}>}
*/
export function refuseesDe(refusees) {
return refusees.map(({ code, brut, ...details }) => ({ ligne: details.ligne, code, details }));
}
/**
* Les personnes que désigne une liste d'identifiants, dans son ordre ; un
* identifiant que la liste des participants ne porte plus est omis.
*
* @param {Array<{id: number, nom: string, prenom: string|null}>} participants
* @param {number[]} ids
* @returns {Array<{id: number, nom: string, prenom: string|null}>}
*/
export function nommer(participants, ids) {
const parId = new Map(participants.map((participant) => [participant.id, participant]));
return ids.filter((id) => parId.has(id)).map((id) => {
const { nom, prenom } = parId.get(id);
return { id, nom, prenom };
});
}
/**
* Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans
* participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se
* choisit.
*
* @param {Array<Object>} participants
* @returns {'ajouter'|'mettreAJour'}
*/
export function modePropose(participants) {
return participants.length === 0 ? 'ajouter' : 'mettreAJour';
}
// Un modèle vide : rien de lu.
const RIEN = Object.freeze({
encodage: null,
separateur: null,
colonnes: [],
ambigus: [],
valides: 0,
lignes: [],
refusees: [],
fusions: [],
doublons: [],
homonymes: [],
multilignes: [],
global: null,
});
/**
* Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour
* un collage) et séparateur nommés — « une seule colonne » pour un
* séparateur null —, association par colonne, ambiguïtés, lignes valides et
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage,
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause
* et celle de son remède — en clés de libellés et en données.
*
* La cause est le code du refus, sa propre clé ; ses détails sont ceux de
* decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus
* de decoder est le code de details.remede, que le texte de la cause compose
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas.
*
* @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree
* encodage : celui que rend decoder, null pour un collage
* @returns {{
* encodage: string|null, separateur: string|null,
* colonnes: Array<{rang: number, entete: string, cle: string}>,
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
* valides: number,
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>,
* refusees: Array<{ligne: number, code: string, details: Object}>,
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
* doublons: Array<{lignes: number[], existants: number[]}>,
* homonymes: Array<{ligne: number, participants: number[]}>,
* multilignes: Array<{ligne: number, rangs: number[]}>,
* global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}},
* }}
* encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de
* traduction, comme lignes[].cellules[].cle, celle de la valeur
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de
* l'orthographe, 0 pour celle que seuls des participants existants portent
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé
*/
export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) {
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) };
if (apercu === null) return { ...RIEN };
const { entetes, association } = apercu;
return {
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
separateur: separateurDe(apercu),
colonnes: colonnesDe(apercu),
ambigus: association.ambigus.map(({ champ, rangs }) => ({
champ,
cle: CLES_DES_CHAMPS.get(champ),
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
})),
valides: apercu.lignes.length,
lignes: lignesDe(apercu.lignes),
refusees: refuseesDe(apercu.refusees),
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({
affichee,
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
})),
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })),
homonymes: apercu.homonymes,
multilignes: apercu.multilignes,
global: globalDeLApercu(apercu, encodage === null),
};
}
/**
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de sept —,
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un
* collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et
* les rangs de ce second aperçu comptent la ligne ajoutée.
*
* @param {string} texte le texte de l'aperçu
* @param {Object} apercu l'aperçu de ce texte
* @returns {string|null}
*/
export function avecEntetesDeLaGrille(texte, apercu) {
if (!offreLaGrille(apercu)) return null;
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;
}
/**
* Le motif d'une ligne refusée, tel que l'aperçu et le rapport l'affichent :
* (code, details) → t(code, details). Le CSV des refus ne le reçoit pas :
* son motif est celui de libelle, que lie exporterRefus de
* src/application/csv.js.
*
* @param {(cle: string, details?: Object) => string} t
* @returns {(code: string, details: Object) => string}
*/
export function motifLigne(t) {
return (code, details) => t(code, details);
}

View file

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

View file

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

View file

@ -3,4 +3,85 @@
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
// refus (§ 14.6). Chaque clé commence par « communs. ».
export const TABLE = new Map();
//
// 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'],
]);

View file

@ -1,9 +1,13 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// 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).
// 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. ».
export const TABLE = new Map([
['coquille.nom', 'Gestion table tournante Libre'],
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
@ -11,4 +15,60 @@ 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,6 +1,126 @@
// © 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 (§ 14.6). Chaque clé commence
// par « diagnostic. ».
export const TABLE = new Map();
// 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'],
]);

View file

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

View file

@ -1,6 +1,97 @@
// © 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 et avancement (§ 14.6). Chaque clé
// commence par « generation. ».
export const TABLE = new Map();
// 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é."],
]);

View file

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

View file

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

View file

@ -3,4 +3,180 @@
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
// travail (§ 14.6). Chaque clé commence par « plan. ».
export const TABLE = new Map();
//
// Une ligne de la liste de noms (§ 7.6) se compose de parties, chacune son
// tspan : la tête — numéro, puis état de la place —, le nom, puis le titre
// entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre
// sont des données : aucun texte de cette table ne les porte. La tête finit
// par une espace quand un nom la suit (avecNom).
import { compte, 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.'],
]);

View file

@ -1,6 +1,107 @@
// © 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 (§ 14.6). Chaque clé commence par «
// propositions. ».
export const TABLE = new Map();
// 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`,
],
]);

View file

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

45
src/interface/mode.js Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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