Compare commits

...

10 commits

Author SHA1 Message Date
fca74b6672 [ADD] tests: spec coverage guards, delivery checks, spec corrections
New guards close the spec coverage gaps: only the configuration and the
diagnostic read reservations in the engine, no second language in the
sources, the engine and geometry never read the version, a test lives
beside its module, fixtures never copy a shipped file, and the property
library stays in a closed list. The delivery test pins the www output and
the French language passed by the entry point. The spec names three test
projects and the three shell files. Checked: 2778 node, 550 browser and
62 long tests from the index alone; the version check passes.

--- FR ---

[ADD] épreuves : gardes de couverture du spec, livrable, spec corrigé

De nouvelles gardes ferment les manques de couverture : seules la
configuration et le diagnostic lisent les réservations dans le moteur,
aucun second langage dans les sources, le moteur et la géométrie ne lisent
pas la version, une épreuve vit près de son module, aucune donnée d'épreuve
ne copie un fichier livré, la bibliothèque de propriétés reste en liste
fermée. Le livrable fige la sortie www et la langue française du point
d'entrée. Le spec nomme trois projets et trois fichiers de coquille.
Vérifié : 2778 node, 550 navigateur, 62 longues ; version conforme.

Assisted-by: Claude Opus 5.5
2026-10-07 17:51:49 -04:00
ddd587da87 [FIX] iteration 3 review: seat targets, gestures, warnings, settings
The wheel no longer zooms during a gesture, so a drop lands under the
ghost. A seat is a target the size of its pitch. A floor drop refuses to
remove an every-tour reservation. A seat reservation the retained plan
does not hold is flagged at the seat, with its swap as remedy. DERIVE names
the person; capacities have an upper bound; Generate waits for a pending
setting; a worker that fails to load shows a refusal; § 5.3 constraints
get their switches. Commands are not re-entrant and a double click does not
confirm. Checked: 2755 node, 550 browser tests, from the index alone.

--- FR ---

[FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages

La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme.
Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse
de retirer une réservation de tous les tours. Une réservation à un siège
que le retenu ne tient pas est relevée au siège, l'échange en remède. La
dérive nomme la personne ; les capacités ont une borne ; Générer attend un
réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le
§ 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double
clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul.

Assisted-by: Claude Opus 5.5
2026-10-07 17:50:35 -04:00
03a6f73337 [ADD] build: no remote load in the bundle, portable startup trial
The delivery build is scanned for test modules, runner traces, the Vite
client and any loading http(s) URL, the worker bundle included and quote
entities decoded. The startup trial runs the real shell in portable mode
from separate exe, home and temp folders: it creates an event, loads the
small demonstration, generates in the worker, closes, and checks that the
files sit under exe/data and no lock is left. It runs only when launched
as itself, symlinks resolved, so it never exits green without running.
Checked: 2699 node, 62 long tests, make essai_demarrage conforme.

--- FR ---

[ADD] construction : rien de distant, essai de démarrage portable

La construction de livraison est balayée : ni module d'épreuve, ni trace du
lanceur, ni client de Vite, ni URL http(s) qui charge, paquet du travailleur
compris et entités de guillemets décodées. L'essai lance la vraie coquille
en mode portable, depuis des dossiers exe, home et temp distincts : il crée
un événement, charge la petite démonstration, génère dans le travailleur,
ferme, et vérifie les fichiers sous exe/data et aucun verrou restant. Il ne
part que lancé lui-même, liens résolus : jamais un vert sans essai.
Vérifié : 2699 node, 62 longues, make essai_demarrage conforme.

Assisted-by: Claude Opus 5.5
2026-10-07 16:08:15 -04:00
dd7ad19cc9 [UPD] spec: follow the code and the iteration 3 decisions
The spec now states what the code does: nothing persists between sessions
under Electron, the seat plan is planning and a removed person frees her
chair, the ranked table shows the minimum of encounters and r(p) by its
sum, refusals with no remedy form a named class, the diagnostic says what
would make a conflict avoidable while the quality segment never does, the
geometry layer is guarded on its own, and created tables keep name lists
off their neighbours. Checked against each module and its tests; the
version check passes all eight points of 18.5.

--- FR ---

[UPD] spec : suivre le code et les décisions de l'itération 3

Le spec dit ce que fait le code : rien ne persiste d'une séance à l'autre
sous Electron, le plan de places est une planification et une personne
retirée libère sa chaise, le tableau classé montre le minimum des
rencontres et r(p) par sa somme, les refus sans remède forment une classe
nommée, le diagnostic dit ce qui rendrait un conflit évitable et le segment
de qualité jamais, la géométrie est une couche gardée à part, et une table
créée garde les listes loin des voisines. Vérifié contre chaque module et
ses épreuves ; le contrôle de version passe les huit points du § 18.5.

Assisted-by: Claude Opus 5.5
2026-10-07 16:04:52 -04:00
b3d59f2079 [ADD] interface: operator walk, reading order, windows, read-only mode
One walk at 1366 x 700, by gestures only from a fresh folder: create an
event, import the edge-case CSV, sort and filter, tables, default and
tours, the plan at scale, two hosts, a refusal on its place, three
propositions retained, an adjustment, then reading mode where every plan
gesture leaves the state untouched and undo is refused. Non-destructive
gestures open no window; destructive ones name exactly what leaves, and
Cancel writes nothing. In reading mode and on a blocked plan, every gesture
family writes nothing. Checked: 517 browser tests, from the index alone.

--- FR ---

[ADD] interface : parcours, ordre libre, fenêtres, gestes en lecture

Un parcours à 1366 x 700, par gestes depuis un dossier neuf : créer un
événement, importer le CSV des cas limites, trier et filtrer, tables,
défaut et tours, le plan à l'échelle, deux animateurs, un refus sur sa
place, trois propositions dont une retenue, un ajustement, puis la lecture
où chaque geste du plan laisse l'état intact et défaire est refusé. Un geste
non destructeur n'ouvre aucune fenêtre ; un destructeur nomme exactement ce
qui part, et Annuler n'écrit rien. En lecture et sur un plan bloqué, aucune
famille de gestes n'écrit. Vérifié : 517 épreuves navigateur, index seul.

Assisted-by: Claude Opus 5.5
2026-10-07 16:02:33 -04:00
8186b06713 [FIX] event: page never scrolls, broken-support remedies always shown
At 1366 x 700 an open event that shows warnings no longer scrolls the page:
the warnings scroll within their zone and the plan keeps at least 75 % of
its height. A broken support never shrinks, so its three remedies stay in
view, the only way to write the event elsewhere (spec 8.6). Tests now
prove the working-folder path and duplicate nodes are visible, the guard
of « Remplacer » in import, and which header a refused re-alignment shows.
Checked: 2692 node, 499 browser tests, from the index alone.

--- FR ---

[FIX] événement : sans défilement, remèdes du support rompu visibles

À 1366 x 700, un événement ouvert qui montre des avertissements ne fait
plus défiler la page : les avertissements défilent dans leur zone et le
plan garde au moins 75 % de sa hauteur. Un support rompu ne rétrécit
jamais : ses trois remèdes, seul moyen d'écrire l'événement ailleurs
(§ 8.6), restent visibles. Les épreuves prouvent que le chemin et les
doublons du dossier sont visibles, la garde de « Remplacer » à l'import, et
l'en-tête que montre un réalignement refusé. Vérifié : 2692 node, 499.

Assisted-by: Claude Opus 5.5
2026-10-07 15:05:59 -04:00
a2812cddb9 [FIX] plan: named warning, one places state, highlight, table spacing
The unheld-reservation warning names the person and the table number
instead of their identifiers. The places state is computed once per change
and handed to the side panels, not once per panel. A person can be
highlighted from their menu or from the grid, and the view bar names them.
A created table takes a cell a full pitch away from every table, its own
capacity included, so no name list runs over a neighbour's chairs. The
rendering guards fail on an empty walk and also check focus and selection.
Checked: 2692 node, 496 browser, 60 long tests, from the index alone.

--- FR ---

[FIX] plan : avertissement nommé, état des places unique, évidence, pas

L'avertissement de réservation non honorée nomme la personne et le numéro
de la table plutôt que leurs identifiants. L'état des places se calcule une
fois par changement et passe aux panneaux, non une fois par panneau. Une
personne se met en évidence par son menu ou depuis la grille, et la barre
de vue la nomme. Une table créée prend une case à un pas plein de chaque
table, sa capacité comprise : aucune liste ne court sur les chaises
voisines. Les gardes du rendu échouent sur un parcours vide et éprouvent
aussi le focus et la sélection. Vérifié : 2692 node, 496 navigateur, 60.

Assisted-by: Claude Opus 5.5
2026-10-07 15:05:01 -04:00
71b9e75363 [ADD] interface: cross-screen rendering checks, frame marks, badge plate
A screen walker reaches every screen of the iteration by gestures; on each,
in both themes, text holds 4.5:1 and marks 3:1 against what is painted
under them, every text passes through the translation tables, the conflict
hue paints only conflict marks, and no state differs by colour alone. Mode
frames now carry their mark, the count badge sits on a plate over the
conflict hatch, the name list clears the focus square, the participant form
is novalidate. Demonstrations space columns so a name list never runs over
a neighbour's chairs. Checked: 2676 node, 489 browser, 60 long tests.

--- FR ---

[ADD] interface : rendu transversal, marques des cadres, socle, pastille

Un parcours atteint par gestes chaque écran de l'itération ; sur chacun,
dans les deux thèmes, le texte tient 4,5:1 et les marques 3:1 sur ce qui
est peint dessous, tout texte passe par les tables de libellés, la teinte
de conflit ne peint que les marques de conflit, et aucun état ne diffère par
la seule couleur. Les cadres de mode portent leur marque, la pastille pose
sur un socle au-dessus de la trame, la liste des noms s'écarte du focus, le
formulaire est novalidate. Les démonstrations espacent leurs colonnes : une
liste ne court plus sur les chaises voisines. Vérifié : 2676, 489, 60.

Assisted-by: Claude Opus 5.5
2026-10-07 15:06:27 -04:00
819a3853e5 [ADD] application: command registry tested whole, remedy on tour removal
Every command of the registry has an accepted, a refused and, when it
admits one, a no-effect example. In reading mode or on a blocked plan
nothing is written; in writing mode one journal entry precedes the state,
undo restores the charge, a refusal writes nothing and names its remedy
unless it is on the admitted null-remedy list. Removing a reserved person
from a tour now offers « Retirer la réservation ». The invariant checker
runs on each retouch, at opening and after undo, redo and revert.
Checked: 2673 node tests, from the index alone.

--- FR ---

[ADD] application : registre des commandes éprouvé en entier, remède

Chaque commande du registre a un exemple accepté, un refusé et, quand elle
en admet un, un sans effet. En lecture ou sur un plan bloqué rien ne
s'écrit ; en écriture une entrée de journal précède l'état, défaire rend la
charge, un refus n'écrit rien et nomme son remède, hors de la liste des
remèdes nuls admis. Retirer d'un tour une personne réservée propose
désormais « Retirer la réservation ». Le vérificateur d'invariants s'appelle
à chaque retouche, à l'ouverture, après défaire, refaire et revenir.
Vérifié : 2673 épreuves node, depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-07 13:21:37 -04:00
4b442ded14 [ADD] tests: guards for layers, clock, sources, colours, watch patterns
Guards scan the tree file by file and line by line, fail on an empty scan,
and are each shown red on a temporary tree. They cover the interface import
graph, the Electron bridge, one clock, refused dependencies, how tests are
written, hard-coded strings, translation keys, forbidden words, colours and
the conflict hue, markup, pruned selectors and watch patterns. Coverage
keeps the engine integration test on a small machine. Fixes: delays in
tests, the conflict hatch class; « la rend évitable » is a named exception
(§ 5.6). Checked: 2587 node, 465 browser tests, from the index alone.

--- FR ---

[ADD] épreuves : gardes des couches, horloge, sources, couleurs, motifs

Les gardes relèvent par fichier et par ligne, échouent sur un balayage vide
et sont chacune montrées rouges sur un arbre temporaire. Elles couvrent le
graphe d'imports de l'interface, le pont d'Electron, l'horloge unique, les
dépendances refusées, l'écriture des épreuves, les chaînes en dur, les clés
de traduction, les mots interdits, les couleurs et la teinte de conflit, le
balisage, les sélecteurs élagués et les motifs de surveillance. Corrigés :
délais d'épreuves, classe de la trame de conflit ; « la rend évitable » est
une exception nommée (§ 5.6). Vérifié : 2587 node, 465 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 13:21:21 -04:00
119 changed files with 13187 additions and 764 deletions

View file

@ -77,7 +77,7 @@ verifier: ## Contrôle la version, le nom du livrable et le changelog (§ 18.5)
$(AVEC_NODE) node scripts/version.js controler $(AVEC_NODE) node scripts/version.js controler
.PHONY: essai_demarrage .PHONY: essai_demarrage
essai_demarrage: ## Lance Electron, lit le titre de sa fenêtre et ferme essai_demarrage: ## Construit la page et joue une séance par gestes dans Electron, en mode portable
$(AVEC_NODE) node scripts/essai_demarrage.js $(AVEC_NODE) node scripts/essai_demarrage.js
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)

View file

@ -28,9 +28,13 @@
// plus grande à égalité ; une table d'une autre capacité porte la sienne // plus grande à égalité ; une table d'une autre capacité porte la sienne
// en surcharge (§ 6.1) ; // en surcharge (§ 6.1) ;
// - les tables rondes, rangées en grille dans l'ordre de la configuration, // - les tables rondes, rangées en grille dans l'ordre de la configuration,
// rangée par rangée, leurs centres espacés de 300 cm ; la grille a la plus // rangée par rangée ; la grille a la plus petite largeur dont le carré
// petite largeur dont le carré contient toutes les tables, et seule sa // contient toutes les tables, et seule sa dernière rangée est incomplète.
// dernière rangée est incomplète ; // Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que
// l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de
// noms d'une table, de toute sa largeur, tienne entre deux colonnes —
// aucune liste ne passe sur une autre table (§ 7.4) —, ses rangées de
// 300 cm au moins ;
// - les contraintes de la configuration, les sièges non attribués, la // - les contraintes de la configuration, les sièges non attribués, la
// génération par défaut de la recherche ; // génération par défaut de la recherche ;
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour // - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
@ -47,6 +51,7 @@ import { decoder } from '../src/csv/encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js'; import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js'; import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js'; import { CATALOGUE } from '../src/demo/catalogue.js';
import { ecartsGrille } from '../src/geometrie/tables.js';
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js'; import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
import { analyser, creerCharge } from '../src/stockage/document.js'; import { analyser, creerCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js'; import { VERSION } from '../src/version.genere.js';
@ -66,9 +71,6 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
// Séparateur de la variante windows-1252 (§ 10.3). // Séparateur de la variante windows-1252 (§ 10.3).
const VIRGULE = ','; const VIRGULE = ',';
// Écart entre les centres de deux tables voisines de la grille, en cm.
const ECART_CM = 300;
// Le siège de l'animateur, et le titre qu'il porte. // Le siège de l'animateur, et le titre qu'il porte.
const SIEGE_ANIMATEUR = 1; const SIEGE_ANIMATEUR = 1;
const ANIMATION = 'animation'; const ANIMATION = 'animation';
@ -134,6 +136,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
const animateurs = animateursDe(reservations, cle); const animateurs = animateursDe(reservations, cle);
const reserves = new Set(animateurs.values()); const reserves = new Set(animateurs.values());
const colonnes = colonnesDe(tables.length); const colonnes = colonnesDe(tables.length);
const pas = ecartsGrille(tables.map(({ capacite }) => ({ forme: 'ronde', places: capacite })));
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes; const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
return { return {
...neuve, ...neuve,
@ -154,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
numero, numero,
sieges: capacite === siegesParDefaut ? null : capacite, sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde', forme: 'ronde',
position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM }, position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees },
})), })),
reservations: [...animateurs].map(([table, participant]) => ({ reservations: [...animateurs].map(([table, participant]) => ({
participant, participant,

View file

@ -209,18 +209,22 @@ describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => {
}); });
}); });
test('les tables : rondes, en grille rangée par rangée, centres à 300 cm, la dernière rangée seule incomplète', () => { test('les tables : rondes, en grille rangée par rangée, la dernière rangée seule incomplète ; rangées à 300 cm, colonnes assez écartées pour le bloc de noms', () => {
// Écart des colonnes : 2 × l'emprise de la plus grande table, deux écarts
// de liste (36 cm) et la largeur du bloc de noms (500 cm), arrondi au cm
// supérieur — petite, 3 places, emprise 79 : 158 + 72 + 500 = 730 ;
// grande, 8 places, emprise 120,39 : ⌈240,79 + 72 + 500⌉ = 813.
assert.deepEqual(chargeDeDemonstration(entreeDe('petite')).tables, [ assert.deepEqual(chargeDeDemonstration(entreeDe('petite')).tables, [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, { id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } }, { id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 730, y: 0 } },
{ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 0, y: 300 } }, { id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 0, y: 300 } },
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 300, y: 300 } }, { id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 730, y: 300 } },
]); ]);
// 33 tables : six colonnes, cinq rangées pleines, puis trois tables. // 33 tables : six colonnes, cinq rangées pleines, puis trois tables.
const { tables } = chargeDeDemonstration(entreeDe('grande')); const { tables } = chargeDeDemonstration(entreeDe('grande'));
const positions = tables.map(({ position: { x, y } }) => [x, y]); const positions = tables.map(({ position: { x, y } }) => [x, y]);
assert.deepEqual(positions.slice(0, 7), [[0, 0], [300, 0], [600, 0], [900, 0], [1200, 0], [1500, 0], [0, 300]]); assert.deepEqual(positions.slice(0, 7), [[0, 0], [813, 0], [1626, 0], [2439, 0], [3252, 0], [4065, 0], [0, 300]]);
assert.deepEqual(positions.slice(-4), [[1500, 1200], [0, 1500], [300, 1500], [600, 1500]]); assert.deepEqual(positions.slice(-4), [[4065, 1200], [0, 1500], [813, 1500], [1626, 1500]]);
assert.equal(new Set(positions.map(String)).size, 33); assert.equal(new Set(positions.map(String)).size, 33);
assert.ok(tables.every(({ forme }) => forme === 'ronde')); assert.ok(tables.every(({ forme }) => forme === 'ronde'));
assert.deepEqual(tables.map(({ id, numero }) => [id, numero]), positions.map((_, rang) => [rang + 1, rang + 1])); assert.deepEqual(tables.map(({ id, numero }) => [id, numero]), positions.map((_, rang) => [rang + 1, rang + 1]));

View file

@ -2,90 +2,393 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Essai de démarrage : construit la page, lance la vraie coquille Electron // Essai de démarrage : construit la page, lance la vraie coquille Electron
// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier // par Playwright, et y joue une courte séance par gestes. Il prouve ce que la
// écran, puis ferme. Il prouve ce que la coquille factice des épreuves // coquille factice des épreuves (test/coquille.test.js) ne peut pas dire :
// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce // qu'Electron démarre sur ce système, avec ses bibliothèques, y charge la
// système, avec ses bibliothèques, y charge la page, et que l'application y // page, que l'application y monte, que la détermination du dossier de
// monte jusqu'à la liste des événements. // travail retient le mode portable, qu'un travailleur de génération naît et
// rend son issue dans la page chargée sous file://, et que la sortie rend
// les verrous.
// //
// node scripts/essai_demarrage.js // node scripts/essai_demarrage.js
// //
// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et // La séance, dans l'ordre :
// le bandeau — qui porte aussi provenance, thème, mode et « À propos » — // 1. le titre de la fenêtre vaut le nom suivi de la version affichée, le
// contenir la version affichée. // bandeau porte la version affichée, le collecteur d'erreurs est posé ;
// 2. la liste affiche le dossier de travail exe/data ; créer un événement,
// qui s'ouvre, revenir à la liste, puis charger la petite
// démonstration, qui s'ouvre ;
// 3. le mode portable : leurs deux .gtt.json sous exe/data, aucun sous home
// ni sous temp ;
// 4. « Modifier » prend un verrou sous exe/data ; le compte d'arrêt passe à
// 2 000 mouvements par son champ, puis « Générer », dans la page chargée
// sous file://, fait naître un travailleur et ajoute des propositions,
// que le fichier d'état porte, sans refus dans la section ni erreur
// recueillie ;
// 5. le collecteur est vide ; la fenêtre se ferme, le processus sort, et
// aucun .gtt.verrou ne reste sous exe/data.
// //
// La coquille se lance comme publiée par le lanceur portable dans un dossier // 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 // neuf, créé par mkdtemp sous le dossier temporaire du système et retiré en
// détermination y écrit son dossier de travail. Sans lui, elle créerait le // sortant. Il y prend trois dossiers distincts : exe, que le lanceur publie
// dossier du produit dans les vrais Documents de la machine ; et un dossier // (PORTABLE_EXECUTABLE_DIR) ; home, le dossier personnel (HOME) ; temp, le
// sous le dossier temporaire du système relève des données applicatives // dossier temporaire (TMPDIR, TEMP, TMP). XDG_CONFIG_HOME est retiré : sous
// (§ 8.6, point 2), qui renvoient aux mêmes Documents. // Linux, les données applicatives et les Documents se déduisent alors de
// home seul. Sous Windows, le profil (USERPROFILE), AppData et LOCALAPPDATA
// passent eux aussi sous home : le temporaire du poste, où mkdtemp prend la
// racine, se range d'ordinaire sous LOCALAPPDATA.
// La coquille range le dossier temporaire et LOCALAPPDATA parmi les données
// applicatives (electron/fichiers.js, § 8.6 point 2) : un exe placé sous
// l'un d'eux ferait retenir les Documents. Sous Windows, les Documents sont
// un dossier connu qu'aucune variable ne déplace : leur sonde y créerait le
// dossier du produit, que la suppression de la racine ne retire pas. Avant
// tout lancement, l'essai refuse donc un exe que l'une des données
// applicatives prévisibles de la coquille porterait.
// //
// Sous Linux sans session graphique, Electron se lance sur la plateforme // 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 // 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 // et s'y met en page, sans serveur d'affichage. Sous root, ou avec
// GTT_SANS_BAC_A_SABLE=1 là où le système refuse les espaces de noms // GTT_SANS_BAC_A_SABLE=1 là où le système refuse les espaces de noms
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable. // d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
// Sort à 0 sur un démarrage conforme, à 1 sinon. // Sort à 0 sur un démarrage conforme, à 1 sinon : chaque constat s'imprime,
// et le premier échec arrête la séance.
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { tmpdir } from 'node:os';
import path, { join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { _electron } from 'playwright'; import { decrireSysteme } from '../electron/fichiers.js';
import { estSous } from '../src/stockage/dossier_travail.js';
import { VERSION } from '../src/version.genere.js'; import { VERSION } from '../src/version.genere.js';
import { titreAvecVersion } from '../src/interface/libelles.js'; import { titreAvecVersion } from '../src/interface/libelles.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url)); const RACINE = fileURLToPath(new URL('..', import.meta.url));
const ATTENTE_MS = 60_000; const ATTENTE_MS = 60_000;
const SONDAGE_MS = 100;
const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0; // Le nom de l'événement créé, la démonstration chargée, le compte d'arrêt.
const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1'; const NOM_EVENEMENT = "Soirée d'essai";
const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY; const DEMONSTRATION = 'petite';
const ARRET = 2000;
// npm est un script .cmd sous Windows, que Node ne lance que par un shell. /**
execFileSync('npm', ['run', '--silent', 'build'], { * Les trois dossiers distincts de l'essai, sous racine.
* @param {string} racine
* @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes
* @returns {{exe: string, home: string, temp: string}}
*/
export function dossiersDEssai(racine, chemins = path) {
return { exe: chemins.join(racine, 'exe'), home: chemins.join(racine, 'home'), temp: chemins.join(racine, 'temp') };
}
/**
* L'environnement de la coquille : une copie de env où le lanceur portable
* publie exe, où home est le dossier personnel (HOME, USERPROFILE) et porte
* AppData (APPDATA) et LOCALAPPDATA, où temp est le dossier temporaire, sans
* XDG_CONFIG_HOME. Sous X11 (DISPLAY posé) sans XAUTHORITY, Xlib cherche son
* fichier d'autorisation dans le dossier personnel : XAUTHORITY nomme alors
* celui du dossier personnel de env, que le remplacement de HOME ferait
* perdre. env n'est pas modifié.
* @param {Record<string, string|undefined>} env
* @param {{exe: string, home: string, temp: string}} dossiers
* @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes
* @returns {Record<string, string|undefined>}
*/
export function environnementDEssai(env, { exe, home, temp }, chemins = path) {
const { XDG_CONFIG_HOME: _retire, ...garde } = env;
const autorisation =
garde.DISPLAY && !garde.XAUTHORITY && garde.HOME ? { XAUTHORITY: chemins.join(garde.HOME, '.Xauthority') } : {};
return {
...garde,
...autorisation,
PORTABLE_EXECUTABLE_DIR: exe,
HOME: home,
USERPROFILE: home,
APPDATA: chemins.join(home, 'AppData', 'Roaming'),
LOCALAPPDATA: chemins.join(home, 'AppData', 'Local'),
TMPDIR: temp,
TEMP: temp,
TMP: temp,
};
}
/**
* Les données applicatives que la coquille lancée sous env retiendrait et
* qui portent l'exécutable publié : vide quand le mode portable reste
* possible. La liste est celle que compose decrireSysteme
* (electron/fichiers.js, § 8.6 point 2), appelée comme la coquille l'appelle,
* et estSous compare comme le système, selon la casse et le séparateur
* qu'elle décrit. L'essai ne sait en propre que ce que rend app.getPath :
* appData prend chaque valeur qu'il peut rendre — sous Windows, l'APPDATA de
* env et celle du poste, dossier connu qu'aucune variable ne déplace ; sous
* macOS, Library/Application Support sous l'un et l'autre HOME ; ailleurs,
* XDG_CONFIG_HOME ou .config sous HOME —, decrireSysteme étant appelée une
* fois pour chacune ; temp est TMP, puis TEMP, sous Windows, TMPDIR, puis
* /tmp, ailleurs. Un autre nom demandé à app.getPath lève : l'essai
* jugerait une liste à laquelle il manque un emplacement. Les dossiers se
* rendent dans l'ordre des appels, sans doublon ; sans exécutable publié,
* rien ne le porte.
* @param {Record<string, string|undefined>} env l'environnement de la coquille
* @param {Record<string, string|undefined>} poste l'environnement du poste
* @param {string} plateforme process.platform
* @param {typeof decrireSysteme} [decrire] la description du poste par la coquille
* @returns {string[]}
*/
export function donneesApplicativesSurExe(env, poste, plateforme, decrire = decrireSysteme) {
const executable = env.PORTABLE_EXECUTABLE_DIR || null;
if (executable === null) return [];
const chemins = plateforme === 'win32' ? path.win32 : path.posix;
const sousMaison = (maison, ...segments) => (maison ? chemins.join(maison, ...segments) : undefined);
const appData =
plateforme === 'win32'
? [env.APPDATA, poste.APPDATA]
: plateforme === 'darwin'
? [env.HOME, poste.HOME].map((maison) => sousMaison(maison, 'Library', 'Application Support'))
: [env.XDG_CONFIG_HOME || sousMaison(env.HOME, '.config')];
const temp = plateforme === 'win32' ? env.TMP || env.TEMP : env.TMPDIR || '/tmp';
const portent = appData.flatMap((dossierAppData) => {
const connus = { appData: dossierAppData, temp, documents: '' };
const cheminSysteme = (nom) => {
if (!Object.hasOwn(connus, nom)) throw new Error(`app.getPath('${nom}') : l'essai ne sait pas l'émuler`);
return connus[nom];
};
const { emplacements } = decrire({ env, plateforme, cheminSysteme, nomProduit: '' });
return emplacements.donneesApplicatives.filter((dossier) => estSous(executable, dossier, emplacements));
});
return [...new Set(portent)];
}
// Les fichiers sous dossier, récursivement, dont le nom finit par suffixe,
// relatifs à dossier et triés ; aucun quand dossier n'existe pas.
function fichiersSous(dossier, suffixe) {
let entrees;
try {
entrees = readdirSync(dossier, { recursive: true, withFileTypes: true });
} catch (erreur) {
if (erreur?.code === 'ENOENT') return [];
throw erreur;
}
return entrees
.filter((e) => e.isFile() && e.name.endsWith(suffixe))
.map((e) => relative(dossier, join(e.parentPath, e.name)))
.sort();
}
// Sonde condition() jusqu'à ce qu'elle rende une valeur vraie, qu'elle rend ;
// au-delà d'ATTENTE_MS, rend la dernière valeur lue.
async function attendre(condition) {
const limite = performance.now() + ATTENTE_MS;
let valeur = await condition();
while (!valeur && performance.now() < limite) {
await new Promise((fin) => setTimeout(fin, SONDAGE_MS));
valeur = await condition();
}
return valeur;
}
// La charge d'un fichier d'état (§ 8.9), ou null tant qu'il ne se lit pas.
function lireEtat(chemin) {
try {
return JSON.parse(readFileSync(chemin, 'utf8')).charge ?? null;
} catch {
return null;
}
}
class EchecDEssai extends Error {}
// Imprime un constat ; un constat faux arrête la séance.
function constater(libelle, vrai, detail) {
console.log(`${vrai ? '[conforme]' : '[ÉCHEC] '} ${libelle}${detail === undefined ? '' : ` : ${detail}`}`);
if (!vrai) throw new EchecDEssai(libelle);
}
async function seance(application, dossiers) {
const data = join(dossiers.exe, 'data');
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
// la taille de la coquille lui est rendue, pour que chaque geste atteigne
// un élément visible.
await application.evaluate(({ BrowserWindow }) => {
for (const w of BrowserWindow.getAllWindows()) w.setContentSize(1366, 768);
});
const collecteur = () => fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
// 1. Titre, bandeau, collecteur.
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS });
const titre = await fenetre.title();
constater('titre de la fenêtre', titre === titreAvecVersion(VERSION.affichee), titre);
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
constater('bandeau portant la version affichée', bandeau.includes(VERSION.affichee), bandeau);
const erreursAuDepart = await collecteur();
constater('collecteur posé et vide', Array.isArray(erreursAuDepart) && erreursAuDepart.length === 0, JSON.stringify(erreursAuDepart));
// 2. Le dossier affiché, un événement créé, la démonstration chargée.
const affiche = (await fenetre.locator('[data-dossier-travail]').textContent()).trim();
constater('dossier de travail affiché', resolve(affiche) === resolve(data), affiche);
await fenetre.locator('[data-ecran="accueil"] input[data-champ="nom"]').fill(NOM_EVENEMENT);
await fenetre.locator('[data-ecran="accueil"] [data-commande="creer"]').click();
await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS });
constater('événement créé et ouvert', true, NOM_EVENEMENT);
await fenetre.locator('[data-commande="liste"]').first().click();
await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS });
constater('événement créé dans la liste', (await fenetre.locator('[data-evenement]').count()) === 1);
await fenetre.locator(`[data-demonstration="${DEMONSTRATION}"]`).click();
await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS });
constater('démonstration ouverte', true, DEMONSTRATION);
// 3. Le mode portable.
const etats = fichiersSous(data, '.gtt.json');
constater('deux .gtt.json sous exe/data', etats.length === 2, etats.join(', '));
const egares = [dossiers.home, dossiers.temp].flatMap((d) => fichiersSous(d, '.gtt.json').map((f) => join(d, f)));
constater('aucun .gtt.json sous home ni temp', egares.length === 0, egares.join(', ') || 'aucun');
// 4. « Modifier », le compte d'arrêt, « Générer ».
await fenetre.locator('[data-commande="modifier"]').first().click();
const verrous = await attendre(() => {
const trouves = fichiersSous(data, '.gtt.verrou');
return trouves.length > 0 ? trouves : null;
});
constater('« Modifier » prend un verrou sous exe/data', verrous !== null, (verrous ?? []).join(', '));
const avant = new Set(etats);
const ouvert = fichiersSous(data, '.gtt.json').find((f) => {
const etat = lireEtat(join(data, f));
return etat !== null && etat.evenement?.nom !== NOM_EVENEMENT && avant.has(f);
});
constater("fichier d'état de la démonstration", ouvert !== undefined, ouvert);
const cheminOuvert = join(data, ouvert);
await fenetre.locator('[data-commande="section"][data-cible="generation"]').click();
const section = fenetre.locator('[data-section="generation"]');
await section.waitFor({ timeout: ATTENTE_MS });
const arret = section.locator('input[data-champ="generation.arret"]');
await arret.fill(String(ARRET));
await arret.press('Tab');
const regle = await attendre(() => lireEtat(cheminOuvert)?.reglages?.generation?.arret === ARRET);
constater('compte d’arrêt écrit', regle, String(lireEtat(cheminOuvert)?.reglages?.generation?.arret));
const propositionsAvant = lireEtat(cheminOuvert)?.propositions?.length ?? 0;
// Chaque génération prend un travailleur neuf (src/main.js) : Playwright
// en signale la naissance, qui attend son rattachement avant d'exécuter
// son script. Un exécuteur qui calcule dans la page n'en fait naître aucun.
constater('page chargée sous file://', fenetre.url().startsWith('file:'), fenetre.url());
const travailleurs = [];
fenetre.on('worker', (travailleur) => travailleurs.push(travailleur.url()));
await section.locator('[data-commande="generer"]').click();
// La première fin qui se montre : l'issue, un refus dans la section, ou
// une erreur recueillie — un travailleur qui ne se charge pas n'affiche
// ni l'une ni l'autre, et le collecteur seul le dit.
const texte = async (selecteur) => (await section.locator(selecteur).first().textContent()).replace(/\s+/g, ' ').trim();
const fin = await attendre(async () => {
if ((await section.locator('[data-refus]').count()) > 0) return { issue: false, detail: await texte('[data-refus]') };
const recueillies = await collecteur();
if (recueillies?.length > 0) return { issue: false, detail: JSON.stringify(recueillies) };
if ((await section.locator('[data-issue]').count()) > 0) return { issue: true, detail: await texte('[data-issue]') };
return null;
});
constater('génération achevée, sans refus ni erreur', fin?.issue === true, fin?.detail ?? `rien en ${ATTENTE_MS} ms`);
constater(
'un travailleur de génération né après « Générer »',
travailleurs.length > 0,
travailleurs.length === 0 ? 'aucun' : travailleurs.map((url) => url.slice(0, 64)).join(', '),
);
const ajoutees = await attendre(() => {
const propositions = lireEtat(cheminOuvert)?.propositions ?? [];
return propositions.length > propositionsAvant ? propositions.slice(propositionsAvant) : null;
});
constater(
`propositions ajoutées au fichier d'état, chacune arrêtée à ${ARRET} mouvements`,
ajoutees !== null && ajoutees.every((p) => p.arret === ARRET),
ajoutees === null ? 'aucune' : ajoutees.map((p) => `${p.id}:${p.arret}`).join(', '),
);
// 5. Collecteur, fermeture, verrous.
const erreurs = await collecteur();
constater('collecteur vide après la séance', Array.isArray(erreurs) && erreurs.length === 0, JSON.stringify(erreurs));
const sortie = application.waitForEvent('close', { timeout: ATTENTE_MS });
await application.evaluate(({ BrowserWindow }) => {
for (const w of BrowserWindow.getAllWindows()) w.close();
});
await sortie;
constater('la fermeture de la fenêtre termine la coquille', true);
const restants = fichiersSous(data, '.gtt.verrou');
constater('aucun .gtt.verrou sous exe/data après la sortie', restants.length === 0, restants.join(', ') || 'aucun');
}
async function principal() {
const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0;
const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1';
const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
// npm est un script .cmd sous Windows, que Node ne lance que par un shell.
execFileSync('npm', ['run', '--silent', 'build'], {
cwd: RACINE, cwd: RACINE,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32',
}); });
const cache = join(RACINE, 'node_modules', '.cache'); const racine = mkdtempSync(join(tmpdir(), 'gtt-essai-demarrage-'));
mkdirSync(cache, { recursive: true }); const dossiers = dossiersDEssai(racine);
const publie = mkdtempSync(join(cache, 'essai-demarrage-')); const env = environnementDEssai(process.env, dossiers);
for (const dossier of [...Object.values(dossiers), env.APPDATA, env.LOCALAPPDATA]) mkdirSync(dossier, { recursive: true });
const attendu = titreAvecVersion(VERSION.affichee); let conforme = false;
let conforme = false; let retrait = null;
try { try {
// Un exe sous des données applicatives ferait retenir les Documents du
// poste, où la sonde écrirait : rien n'est lancé.
const portent = donneesApplicativesSurExe(env, process.env, process.platform);
constater(
'exe hors des données applicatives de la coquille',
portent.length === 0,
portent.length === 0 ? dossiers.exe : `${dossiers.exe} sous ${portent.join(', ')}`,
);
const { _electron } = await import('playwright');
const application = await _electron.launch({ const application = await _electron.launch({
args: [ args: [...(sansBacASable ? ['--no-sandbox'] : []), ...(sansEcran ? ['--ozone-platform=headless'] : []), '.'],
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE, cwd: RACINE,
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie }, env,
timeout: ATTENTE_MS, timeout: ATTENTE_MS,
}); });
try { try {
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS }); await seance(application, dossiers);
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté : conforme = true;
// 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 { } finally {
await application.close(); await application.close().catch(() => {});
} }
} finally { } catch (erreur) {
rmSync(publie, { recursive: true, force: true }); if (!(erreur instanceof EchecDEssai)) console.error(erreur);
} finally {
// Un retrait refusé (EBUSY sous Windows, un fichier encore ouvert) ne
// masque pas le verdict : il s'imprime après lui, en avertissement, et
// ne change pas le code de sortie.
try {
rmSync(racine, { recursive: true, force: true });
} catch (erreur) {
retrait = erreur;
}
}
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
if (retrait !== null) console.warn(`Avertissement : ${racine} reste en place, son retrait a échoué : ${retrait.message}`);
process.exit(conforme ? 0 : 1);
} }
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
process.exit(conforme ? 0 : 1); /**
* Vrai quand script, le fichier que node exécute (process.argv[1]), est
* fichier, ce module. Les deux chemins se comparent résolus par realpath :
* un script atteint par un lien symbolique est le fichier qu'il désigne. Un
* script absent ou illisible n'est pas ce module.
* @param {string|undefined} script
* @param {string} [fichier]
* @returns {boolean}
*/
export function estLanceDirectement(script, fichier = fileURLToPath(import.meta.url)) {
if (script === undefined) return false;
try {
return realpathSync(script) === realpathSync(fichier);
} catch {
return false;
}
}
// L'essai ne part que lorsque node exécute ce fichier ; l'épreuve qui
// l'importe n'en lit que les fonctions.
if (estLanceDirectement(process.argv[1])) await principal();

View file

@ -0,0 +1,177 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'environnement où l'essai de démarrage lance la coquille (§ 8.6) : trois
// dossiers distincts sous la racine de l'essai, aucun sous un autre ; le
// lanceur portable publie exe, le poste a home pour dossier personnel et
// temp pour dossier temporaire, et la configuration XDG du poste réel n'y
// entre pas. Le module se charge sans rien lancer : l'essai ne part que
// lorsque node l'exécute.
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path, { dirname, join, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decrireSysteme } from '../electron/fichiers.js';
import { estSous } from '../src/stockage/dossier_travail.js';
import { describe, test } from '../test/lanceur.js';
import { donneesApplicativesSurExe, dossiersDEssai, environnementDEssai, estLanceDirectement } from './essai_demarrage.js';
const RACINE_ESSAI = join('/', 'racine-essai', 'gtt-essai-demarrage-x');
// Vrai quand b est a ou se trouve sous a.
const sous = (a, b) => {
const chemin = relative(a, b);
return chemin === '' || (!chemin.startsWith('..') && !isAbsolute(chemin));
};
describe('essai de démarrage : dossiers et environnement (§ 8.6)', () => {
test('trois dossiers distincts sous la racine, aucun sous un autre', () => {
const dossiers = dossiersDEssai(RACINE_ESSAI);
const noms = ['exe', 'home', 'temp'];
assert.deepEqual(Object.keys(dossiers).sort(), noms);
const chemins = noms.map((nom) => dossiers[nom]);
assert.equal(chemins.length, 3);
for (const chemin of chemins) assert.ok(sous(RACINE_ESSAI, chemin) && chemin !== RACINE_ESSAI, chemin);
for (const a of chemins) {
for (const b of chemins) if (a !== b) assert.ok(!sous(a, b), `${b} sous ${a}`);
}
assert.equal(new Set(chemins).size, 3);
});
test('exe publié, home et temp posés, XDG_CONFIG_HOME retiré, le reste gardé', () => {
const dossiers = dossiersDEssai(RACINE_ESSAI);
const poste = Object.freeze({
HOME: join('/', 'maison-originale'),
XDG_CONFIG_HOME: join('/', 'maison-originale', 'configuration'),
TMPDIR: join('/', 'temporaire-du-poste'),
PATH: join('/', 'outils'),
LANG: 'fr_CA.UTF-8',
});
const env = environnementDEssai(poste, dossiers);
assert.equal(env.PORTABLE_EXECUTABLE_DIR, dossiers.exe);
assert.equal(env.HOME, dossiers.home);
assert.equal(env.TMPDIR, dossiers.temp);
assert.equal(env.TEMP, dossiers.temp);
assert.equal(env.TMP, dossiers.temp);
assert.ok(!Object.hasOwn(env, 'XDG_CONFIG_HOME'));
assert.equal(env.PATH, poste.PATH);
assert.equal(env.LANG, poste.LANG);
// L'environnement du poste reste tel quel.
assert.equal(poste.HOME, join('/', 'maison-originale'));
});
test('sous X11 sans XAUTHORITY, le fichier d’autorisation reste celui du dossier personnel réel', () => {
const dossiers = dossiersDEssai(RACINE_ESSAI);
const maison = join('/', 'maison-originale');
const avecX = environnementDEssai({ HOME: maison, DISPLAY: ':9' }, dossiers);
assert.equal(avecX.XAUTHORITY, join(maison, '.Xauthority'));
const pose = join('/', 'autorisation-posee');
assert.equal(environnementDEssai({ HOME: maison, DISPLAY: ':9', XAUTHORITY: pose }, dossiers).XAUTHORITY, pose);
assert.ok(!Object.hasOwn(environnementDEssai({ HOME: maison }, dossiers), 'XAUTHORITY'));
});
test('sous Windows, un temporaire du poste rangé sous LOCALAPPDATA ne met pas exe sous les données applicatives', () => {
const W = path.win32;
const profil = 'C:\\Users\\profil-essai';
const local = W.join(profil, 'AppData', 'Local');
const poste = Object.freeze({
USERPROFILE: profil,
APPDATA: W.join(profil, 'AppData', 'Roaming'),
LOCALAPPDATA: local,
TEMP: W.join(local, 'Temp'),
TMP: W.join(local, 'Temp'),
});
const dossiers = dossiersDEssai(W.join(poste.TEMP, 'gtt-essai-demarrage-x'), W);
const env = environnementDEssai(poste, dossiers, W);
// Les données applicatives de la coquille (§ 8.6 point 2) : AppData,
// dossier connu que l'environnement ne déplace pas, le LOCALAPPDATA de
// son environnement, et le temporaire, que TMP désigne.
const emplacements = { insensibleCasse: true, separateur: '\\' };
assert.equal(env.PORTABLE_EXECUTABLE_DIR, W.join(poste.TEMP, 'gtt-essai-demarrage-x', 'exe'));
assert.deepEqual(
[poste.APPDATA, env.LOCALAPPDATA, env.TMP].filter((dossier) => estSous(dossiers.exe, dossier, emplacements)),
[],
);
// Le profil, AppData et LOCALAPPDATA de la coquille sont sous home.
assert.equal(env.USERPROFILE, dossiers.home);
assert.ok(estSous(env.APPDATA, dossiers.home, emplacements) && env.APPDATA !== dossiers.home, env.APPDATA);
assert.ok(estSous(env.LOCALAPPDATA, dossiers.home, emplacements) && env.LOCALAPPDATA !== dossiers.home, env.LOCALAPPDATA);
assert.deepEqual(donneesApplicativesSurExe(env, poste, 'win32'), []);
assert.equal(poste.LOCALAPPDATA, local);
});
test("la garde nomme les données applicatives qui portent exe, avant tout lancement", () => {
// Sous Linux : des chemins composés par path.posix de bout en bout, que
// le système hôte de l'épreuve ne change pas.
const P = path.posix;
const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x');
const dossiers = dossiersDEssai(R, P);
const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P);
assert.deepEqual(donneesApplicativesSurExe(env, {}, 'linux'), []);
// Le temporaire de la coquille au-dessus d'exe.
assert.deepEqual(donneesApplicativesSurExe({ ...env, TMPDIR: R }, {}, 'linux'), [R]);
// Le dossier de configuration XDG au-dessus d'exe.
assert.deepEqual(donneesApplicativesSurExe({ ...env, XDG_CONFIG_HOME: R }, {}, 'linux'), [R]);
// Sous Windows : l'AppData du poste, dossier connu, que l'environnement
// de la coquille ne déplace pas ; puis le LOCALAPPDATA de la coquille.
const W = path.win32;
const racine = W.join('D:\\', 'essais', 'gtt-essai-demarrage-x');
const dossiersW = dossiersDEssai(racine, W);
const envW = environnementDEssai({}, dossiersW, W);
assert.deepEqual(donneesApplicativesSurExe(envW, {}, 'win32'), []);
assert.deepEqual(donneesApplicativesSurExe(envW, { APPDATA: W.join('D:\\', 'essais') }, 'win32'), [
W.join('D:\\', 'essais'),
]);
assert.deepEqual(donneesApplicativesSurExe({ ...envW, LOCALAPPDATA: 'd:\\ESSAIS' }, {}, 'win32'), ['d:\\ESSAIS']);
// Sans exécutable publié, rien ne le porte.
assert.deepEqual(donneesApplicativesSurExe({ TMPDIR: '/' }, {}, 'linux'), []);
});
test('la liste des données applicatives est celle que decrireSysteme compose : un emplacement qu’elle ajoute est jugé, et un app.getPath que l’essai ne sait pas émuler lève', () => {
const P = path.posix;
const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x');
const dossiers = dossiersDEssai(R, P);
const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P);
const autre = P.join('/', 'racine-essai');
// Une coquille qui range aussi GTT_DONNEES parmi ses données applicatives.
const avecUnDossier = (poste) => {
const decrit = decrireSysteme(poste);
const donneesApplicatives = [...decrit.emplacements.donneesApplicatives, poste.env.GTT_DONNEES];
return { ...decrit, emplacements: { ...decrit.emplacements, donneesApplicatives } };
};
assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: autre }, {}, 'linux', avecUnDossier), [autre]);
assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: P.join('/', 'ailleurs') }, {}, 'linux', avecUnDossier), []);
// Une coquille qui lit un autre dossier du système : l'essai le refuse
// plutôt que de juger une liste incomplète.
const avecUserData = (poste) => {
poste.cheminSysteme('userData');
return decrireSysteme(poste);
};
assert.throws(() => donneesApplicativesSurExe(env, {}, 'linux', avecUserData), /userData/);
});
});
describe('essai de démarrage : lancement direct', () => {
const MODULE = fileURLToPath(new URL('./essai_demarrage.js', import.meta.url));
// Le lien désigne le dossier du module : une jonction sous Windows, qui se
// crée sans privilège, un lien symbolique ailleurs.
test('le script atteint par un lien symbolique est le module', () => {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-essai-lien-'));
try {
const lien = join(dossier, 'scripts');
symlinkSync(dirname(MODULE), lien, 'junction');
assert.equal(estLanceDirectement(join(lien, 'essai_demarrage.js')), true);
assert.equal(estLanceDirectement(MODULE), true);
} finally {
rmSync(dossier, { recursive: true, force: true });
}
});
test('un autre script, un script absent ou aucun script ne lance pas l’essai', () => {
assert.equal(estLanceDirectement(fileURLToPath(import.meta.url)), false);
assert.equal(estLanceDirectement(join(tmpdir(), 'gtt-essai-absent', 'essai_demarrage.js')), false);
assert.equal(estLanceDirectement(undefined), false);
});
});

View file

@ -574,6 +574,16 @@ describe('version : contrôle du § 18.5, un défaut à la fois', () => {
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]); exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]);
})); }));
test('point 6 : un composant de src/interface qui porte la version affichée en dur', () =>
surArbre((racine) => {
ecrire(
racine,
'src/interface/Bandeau.svelte',
"<script>\n let { version } = $props();\n</script>\n\n<span class=\"version\">{version}</span>\n<span>v2026.10.05.01</span>\n",
);
exigerEchecs(racine, [6], [/^6\. src\/interface\/Bandeau\.svelte, ligne 6 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : une forme technique hors de package.json et du module engendré', () => test('point 6 : une forme technique hors de package.json et du module engendré', () =>
surArbre((racine) => { surArbre((racine) => {
ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n"); ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n");

380
spec.md
View file

@ -61,10 +61,11 @@ Aucun des deux ne dépend de l'étape atteinte.
jamais l'un sans l'autre (§ 8.2) ; jamais l'un sans l'autre (§ 8.2) ;
2. il se reprend par un retour à un instant, et le journal ne se tronque jamais 2. il se reprend par un retour à un instant, et le journal ne se tronque jamais
(§ 8.3) ; (§ 8.3) ;
3. s'il est refusé, le refus nomme sa cause et le geste qui la lève, **là où le 3. s'il est refusé, le refus nomme sa cause et, quand un geste la lève, ce
geste a eu lieu** : sur la place pour un geste du plan (§ 5.9), dans geste (§ 5.9), **là où le geste a eu lieu** : sur la place pour un geste du
l'aperçu d'import pour une ligne de CSV (§ 10.1), sur le champ pour une plan, dans l'aperçu d'import pour une ligne de CSV (§ 10.1), sur le champ
saisie. Seul un geste qui détruit de la saisie ouvre une fenêtre à confirmer. pour une saisie. Seul un geste qui détruit de la saisie ouvre une fenêtre à
confirmer.
Les étapes 8 et 10 — lire le diagnostic, comparer — ne modifient rien et restent Les étapes 8 et 10 — lire le diagnostic, comparer — ne modifient rien et restent
vivantes en lecture (§ 8.4). vivantes en lecture (§ 8.4).
@ -102,9 +103,10 @@ en bloc une baisse qu'une seule table refuse (§ 6.1).
retrouve les **ancrages** intacts, parce que la portée « tous les tours » n'est retrouve les **ancrages** intacts, parce que la portée « tous les tours » n'est
jamais développée (§ 4.3). La protection s'arrête là, et le logiciel le dit jamais développée (§ 4.3). La protection s'arrête là, et le logiciel le dit
**avant** d'appliquer : retirer le tour *r* détruit les réservations de portée **avant** d'appliquer : retirer le tour *r* détruit les réservations de portée
« tour désigné » portant sur *r*, et les tours au-delà de *r* des placements « tour désigné » portant sur *r*. Les placements déjà engendrés, eux, ne sont
déjà engendrés. Les annoncer après coup ferait découvrir la perte à pas tronqués : ils ne portent plus le nombre de tours de la soirée, restent
l'impression. entiers **en dérive** (§ 9), et le logiciel nomme ceux que le changement
touche. Les annoncer après coup ferait découvrir la perte à l'impression.
**6. Désigner les animateurs, table par table.** *Facultative — une soirée sans **6. Désigner les animateurs, table par table.** *Facultative — une soirée sans
aucun animateur est une configuration valide, et la petite démonstration en est aucun animateur est une configuration valide, et la petite démonstration en est
@ -339,7 +341,9 @@ aperçoive**.
Moyennant cet ajout, le moteur n'a **qu'un seul chemin de code** : la Moyennant cet ajout, le moteur n'a **qu'un seul chemin de code** : la
réservation est un prétraitement qui réduit les capacités, retire des réservation est un prétraitement qui réduit les capacités, retire des
participants et annote les tables — jamais un second algorithme. participants et annote les tables — jamais un second algorithme. Une garde de
l'arborescence le tient : dans le moteur, seuls la normalisation de la
configuration et le diagnostic lisent les réservations.
### 5.3 Les contraintes, activables une à une ### 5.3 Les contraintes, activables une à une
@ -391,10 +395,10 @@ une lecture et non un jugement :
| **manque** | plafond réalisé − rencontres (§ 5.5) — la grandeur qui compare deux propositions (§ 12.10) | | **manque** | plafond réalisé − rencontres (§ 5.5) — la grandeur qui compare deux propositions (§ 12.10) |
| **écart d'itinéraire** | plafond a priori − plafond réalisé (§ 5.5, § 12.10) | | **écart d'itinéraire** | plafond a priori − plafond réalisé (§ 5.5, § 12.10) |
**Les agrégats portent sur tous les participants placés.** Le minimum et la **Les agrégats portent sur tous les participants placés.** Le minimum se calcule
moyenne se calculent sur l'ensemble, ancrés compris, et **deux lignes sur l'ensemble, ancrés compris, et **deux lignes secondaires** donnent la même
secondaires** donnent la même mesure restreinte aux **mobiles** et aux mesure restreinte aux **mobiles** et aux **ancrés**. Un participant
**ancrés**. Un participant partiellement fixé compte parmi les mobiles. partiellement fixé compte parmi les mobiles.
> Un ancré n'est pas structurellement désavantagé : à table de 8 avec un seul > Un ancré n'est pas structurellement désavantagé : à table de 8 avec un seul
> ancré et R = 4, son plafond vaut 28, exactement celui d'un mobile visitant > ancré et R = 4, son plafond vaut 28, exactement celui d'un mobile visitant
@ -404,8 +408,16 @@ secondaires** donnent la même mesure restreinte aux **mobiles** et aux
> global désigne un défaut qu'une relance corrige, ou la conséquence de son > global désigne un défaut qu'une relance corrige, ou la conséquence de son
> propre plan de réservation. > propre plan de réservation.
**Le minimum compte plus que la moyenne.** Il décrit le sort de la personne la **Des rencontres, le minimum s'affiche, jamais la moyenne.** Le minimum décrit
plus mal servie, qui est celle qui se plaindra. le sort de la personne la plus mal servie, qui est celle qui se plaindra. La
moyenne ne sépare rien : quand les tables sont pleines à chaque tour, le nombre
de voisinages créés est fixé par le mobilier, et la moyenne est la même pour
toutes les propositions (§ 12.8).
**La redondance se lit par sa somme**, Σ r(p), la grandeur que la recherche et
le classement lisent (§ 5.7). Elle va dans l'autre sens que les autres
colonnes — plus elle est grande, pire est le sort —, et son minimum désignerait
la personne la mieux servie.
**Les deux unités de collision ne se mélangent jamais.** Un chiffre de collision **Les deux unités de collision ne se mélangent jamais.** Un chiffre de collision
cité quelque part — § 5.6, § 12.4, § 15.3, une étape du pilotage — **nomme son cité quelque part — § 5.6, § 12.4, § 15.3, une étape du pilotage — **nomme son
@ -668,6 +680,12 @@ Une fois un placement retenu, le même geste déplace une personne réelle. **Le
geste est identique ; ce qui change est l'état des données**, et l'interface le geste est identique ; ce qui change est l'état des données**, et l'interface le
dit, pour que l'opérateur sache lequel des deux il fait. dit, pour que l'opérateur sache lequel des deux il fait.
**Le placement se prépare, il ne suit pas la salle.** Retirer une personne du
placement retenu libère sa chaise, et personne d'autre ne change de siège ni de
table : la chaise reste vide jusqu'à ce qu'un geste y assoie quelqu'un, et un
geste n'assied que les personnes qu'il nomme. Quand les sièges sont attribués,
le fichier écrit cette chaise vide à sa place (§ 8.9).
**Il n'existe aucune commande qui répartit les animateurs sur les tables.** **Il n'existe aucune commande qui répartit les animateurs sur les tables.**
L'opérateur désigne lui-même quelle personne anime quelle table (§ 2.1, L'opérateur désigne lui-même quelle personne anime quelle table (§ 2.1,
étape 6). Une commande qui attribuerait un animateur par numéro de table le étape 6). Une commande qui attribuerait un animateur par numéro de table le
@ -715,6 +733,18 @@ Un refus **nomme sa cause et propose le geste qui le lève**, et se manifeste
détruit de la saisie ouvre une fenêtre à confirmer : une suite de modales détruit de la saisie ouvre une fenêtre à confirmer : une suite de modales
apprend à cliquer sans lire, et le premier refus qui comptait passe inaperçu. apprend à cliquer sans lire, et le premier refus qui comptait passe inaperçu.
**Quand aucun geste ne lève le refus, il nomme sa cause seule.** Ainsi d'une
valeur qui se corrige dans le champ même, dont le texte dit la valeur attendue —
moins de deux sièges, une saisie non entière, moins d'un tour, un libellé ou un
nom vides ; d'une cible absente ou disparue entre-temps — une personne, une
proposition, une réservation, un titre, des réservations à convertir —,
qu'aucune commande ne fait revenir et sur laquelle l'interface n'offre pas le
geste ; d'un compteur au bout de sa règle, d'un plan qui n'est plus bloqué, d'un
geste sans effet, et d'un refus du CSV relayé par l'import, qui se lève dans le
fichier ou dans l'association de ses colonnes — `NOM_NON_ASSOCIE` quand aucune
colonne n'est associée au nom. Un remède posé pour la forme enverrait
l'opérateur vers un geste qui ne change rien.
### 5.10 Le coût d'évaluation ### 5.10 Le coût d'évaluation
A(p) et r(p) demandent de parcourir les personnes rencontrées par chacun. A(p) et r(p) demandent de parcourir les personnes rencontrées par chacun.
@ -737,15 +767,20 @@ d'écran (§ 19.4) tomberait avec. L'interdit de `performance.now` dans le moteu
**Le compte est un réglage, et son plafond d'usage est de trente minutes.** Il **Le compte est un réglage, et son plafond d'usage est de trente minutes.** Il
est exposé dans les réglages de génération, avec son unité ; le logiciel affiche est exposé dans les réglages de génération, avec son unité ; le logiciel affiche
à côté la **durée réellement observée du dernier calcul sur cette machine**, qui à côté la **durée réellement observée du dernier calcul de la séance**, qui est
est la seule façon d'étalonner un compte. Le réglage livré par défaut est celui la seule façon d'étalonner un compte. Elle vaut pour la séance et ne se garde
qui, sur la machine de référence, reste bien en deçà de ce plafond. pas au-delà — rien ne survit à la séance hors du dossier de travail (§ 8.6) : au
premier calcul d'un lancement, aucune mesure n'existe, et l'estimation se dit
inconnue. Le réglage livré par défaut est celui qui, sur la machine de
référence, reste bien en deçà de ce plafond.
**Une génération longue s'annonce avant de partir.** À partir de la dernière **Une génération longue s'annonce avant de partir.** À partir de la dernière
durée mesurée, le logiciel énonce l'ordre de grandeur attendu — « environ vingt durée mesurée, le logiciel énonce l'ordre de grandeur attendu — « environ vingt
minutes à ce réglage » — et demande. Une recherche d'une demi-heure lancée par minutes à ce réglage » — et, **au-delà d'une minute estimée**, demande, par une
mégarde est une demi-heure de soirée perdue, et aucun réglage par défaut ne question posée en place, sans fenêtre. Sans mesure, il demande dès que les
protège de cela. mouvements demandés — propositions × compte d'arrêt — dépassent dix fois ceux du
réglage par défaut. Une recherche d'une demi-heure lancée par mégarde est une
demi-heure de soirée perdue, et aucun réglage par défaut ne protège de cela.
**Pendant la recherche, l'application reste vivante.** Le calcul ne tient pas le **Pendant la recherche, l'application reste vivante.** Le calcul ne tient pas le
fil d'exécution de l'interface : le plan reste lisible, la page de qualité des fil d'exécution de l'interface : le plan reste lisible, la page de qualité des
@ -821,10 +856,16 @@ table refuse, l'opération entière est refusée, nommément, et rien ne change.
sièges disparaissent par numéro décroissant ; le refus les liste. sièges disparaissent par numéro décroissant ; le refus les liste.
- Une capacité non entière, négative, ou saisie en texte. - Une capacité non entière, négative, ou saisie en texte.
Deux cas **avertissent sans refuser** : réduire sous le nombre de personnes Deux cas **avertissent sans refuser** : réduire **par le champ** sous le nombre
assises (les surnuméraires vont dans la réserve visible, nommément listées, de personnes assises (les surnuméraires vont dans la réserve visible, nommément
jamais effacées en silence) ; et dépasser le nombre de sièges au-delà duquel le listées, jamais effacées en silence) ; et dépasser le nombre de sièges au-delà
dessin cesse d'être lisible — seuil **mesuré** au sens du § 14.1. duquel le dessin cesse d'être lisible — seuil **mesuré** au sens du § 14.1.
**Le champ avertit, la poignée refuse.** Saisir une capacité est un geste
délibéré, fait en lisant le nombre ; tirer une poignée est un geste de
géométrie, fait sans regarder les autres tours : la même réduction y est refusée
(§ 7.1). Un changement de défaut (§ 6.1) se contrôle, table par table, comme une
saisie au champ.
--- ---
@ -840,7 +881,7 @@ l'opérateur agit directement dessus.
| cliquer une chaise | sélectionne la place — l'assigner, la réserver, lui donner un titre | | cliquer une chaise | sélectionne la place — l'assigner, la réserver, lui donner un titre |
| glisser une personne sur un siège libre | la déplace | | glisser une personne sur un siège libre | la déplace |
| glisser une personne sur une autre | les échange | | glisser une personne sur une autre | les échange |
| glisser une personne hors des tables | la retire du tour ; elle attend dans la **réserve** visible | | glisser une personne hors des tables | la retire du tour ; elle attend dans la **réserve** visible, et sa chaise reste vide (§ 5.8) |
| glisser une table | change sa position | | glisser une table | change sa position |
| tirer la poignée d'une table | **change son nombre de places** | | tirer la poignée d'une table | **change son nombre de places** |
| survoler une table en conflit | affiche le détail des règles violées | | survoler une table en conflit | affiche le détail des règles violées |
@ -858,7 +899,13 @@ chaises apparaissent et disparaissent sous le pointeur.
**La poignée refuse de descendre sous le nombre de sièges occupés, tous tours **La poignée refuse de descendre sous le nombre de sièges occupés, tous tours
confondus** — sinon un geste de géométrie désassoit deux personnes au troisième confondus** — sinon un geste de géométrie désassoit deux personnes au troisième
tour, que l'opérateur ne regarde pas. Le logiciel nomme le nombre bloquant et le tour, que l'opérateur ne regarde pas. Le logiciel nomme le nombre bloquant et le
tour concerné. tour concerné. Le champ de capacité, saisie délibérée, accepte la même réduction
et l'annonce (§ 6.2).
**Une personne qu'une réservation tient à sa table ne se retire pas du tour.**
Le refus nomme la réservation et offre « Retirer la réservation » : une
réservation se défait par sa propre commande, jamais en passant par un
glissement.
**La rotation d'une table carrée n'existe pas dans la première itération** : **La rotation d'une table carrée n'existe pas dans la première itération** :
elle introduirait une orientation dans la numérotation des sièges et dans la elle introduirait une orientation dans la numérotation des sièges et dans la
@ -897,7 +944,10 @@ unités, et le plan répondrait faux partout.
Une épreuve en navigateur confronte la fonction pure à Une épreuve en navigateur confronte la fonction pure à
`getScreenCTM().inverse()`. Ce n'est pas une seconde implémentation : c'est un `getScreenCTM().inverse()`. Ce n'est pas une seconde implémentation : c'est un
**oracle** fourni par le moteur de rendu. **oracle** fourni par le moteur de rendu. L'écart admis est d'**un millième de
pixel d'écran** : le moteur de rendu lit les nombres de l'attribut `transform`
en flottants de 32 bits et compose sa matrice à cette précision, quand une
violation de l'un des trois invariants décale le plan d'un pixel au moins.
**Le zoom se fait sous le curseur**, sinon le contenu fuit vers un coin et **Le zoom se fait sous le curseur**, sinon le contenu fuit vers un coin et
l'opérateur poursuit la table qu'il visait. l'opérateur poursuit la table qu'il visait.
@ -955,6 +1005,13 @@ et la table devient la cible**, le siège se choisissant dans son panneau. Le
logiciel le montre en cessant de dessiner les chaises individuellement, plutôt logiciel le montre en cessant de dessiner les chaises individuellement, plutôt
qu'en laissant l'opérateur manquer sa cible sans comprendre. qu'en laissant l'opérateur manquer sa cible sans comprendre.
**Le seuil se décide table par table**, sur le pas de chacune — la distance
entre deux sièges voisins au bord du plateau. Au même zoom, une petite table
ronde, dont le plancher du § 7.7 écarte les sièges de plus de 60 cm, garde ses
sièges pour cibles quand ses voisines de 8 n'en ont plus. Un seuil unique, réglé
sur la table la plus serrée, retirerait ses sièges à une table qui les montre
pointables.
**Il n'y a pas de survol au doigt** : le détail d'un conflit est accessible par **Il n'y a pas de survol au doigt** : le détail d'un conflit est accessible par
survol, par focus clavier **et par pression longue** — un seul composant survol, par focus clavier **et par pression longue** — un seul composant
d'infobulle, trois déclencheurs. d'infobulle, trois déclencheurs.
@ -1033,16 +1090,30 @@ diamètre = max(70, places × 60 / π) en centimètres
``` ```
Soit 115 cm pour 6 places, 153 pour 8, 191 pour 10. **Le plancher n'est pas Soit 115 cm pour 6 places, 153 pour 8, 191 pour 10. **Le plancher n'est pas
cosmétique** : en deçà de six convives ce n'est plus le périmètre qui borne la cosmétique** : en deçà de quatre convives — la formule atteint 70 cm à 3,67
table mais le plateau, et la formule seule dessinerait des guéridons. Les deux places — ce n'est plus le périmètre qui borne la table mais le plateau, et la
valeurs sont des **réglages par défaut, non des normes vérifiées** ; un diamètre formule seule dessinerait des guéridons. Les deux valeurs sont des **réglages
saisi à la main l'emporte. par défaut, non des normes vérifiées** ; un diamètre saisi à la main l'emporte,
dès que le fichier d'état peut le porter : le format 1 n'a pas de champ pour
lui, et toute table s'y dessine selon la formule.
**Les étiquettes ne sont pas à l'échelle, le mobilier l'est.** Une liste de dix **Les étiquettes ne sont pas à l'échelle, le mobilier l'est.** Une liste de dix
noms couvre plusieurs mètres carrés fictifs. **Le plan à l'échelle du bâtiment noms couvre plusieurs mètres carrés fictifs. **Le plan à l'échelle du bâtiment
est le plan listes masquées**, et le logiciel le dit sur chaque impression est le plan listes masquées**, et le logiciel le dit sur chaque impression
portant les listes. portant les listes.
**Le bloc de noms d'une table se pose à droite de son emprise** — le plateau et
ses chaises —, à 36 cm d'elle : l'écart laisse passer le contour de focus de la
table sans qu'aucune marque couvre la liste. Sa largeur est fixée dans le
dessin, et un nom trop long s'y abrège (§ 7.4). **Aucune liste ne passe sur les
chaises d'une voisine posée sur la grille** : une table créée prend la première
case libre d'une grille dont les colonnes s'écartent d'une emprise, de deux
écarts et d'un bloc de noms, à un pas entier au moins, sur l'un des deux axes,
de toute table déjà posée, et les démonstrations (§ 15) sont rangées sur la même
grille. Le prix est la place : au zoom ajusté, une grande salle descend sous le
seuil qui retire les listes (§ 17, point 6), et un grand événement peut s'ouvrir
sans listes de noms, qui se dessinent quand l'opérateur rapproche la vue.
**Tant qu'aucune dimension de salle n'est saisie, le plan est exact dans ses **Tant qu'aucune dimension de salle n'est saisie, le plan est exact dans ses
tables et arbitraire dans son cadre.** Le logiciel porte alors, sur le plan et tables et arbitraire dans son cadre.** Le logiciel porte alors, sur le plan et
sur **chaque impression**, la mention « disposition relative — l'encombrement sur **chaque impression**, la mention « disposition relative — l'encombrement
@ -1201,6 +1272,14 @@ qu'un calcul tourne. Le retour **automatique** ne peut donc jamais tomber
pendant un geste ; le retour **explicite**, lui, annule un glissement en cours pendant un geste ; le retour **explicite**, lui, annule un glissement en cours
comme le ferait un `pointercancel`. comme le ferait un `pointercancel`.
**Est une activité la fin d'un geste**, qui relance le compte : un geste achevé
ou refusé, une saisie validée ou abandonnée, une fenêtre à confirmer refermée,
et tout appui relâché sur le plan — un clic qui n'exécute rien et un déplacement
de vue compris. Regarder sans appuyer n'en est pas une : zoomer à la molette,
une commande de vue, changer de tour affiché ne relancent rien. Le retour
automatique se voit au cadre, au libellé et au bandeau, et un avertissement non
modal le dit jusqu'au passage en écriture suivant.
**Le mode est visible sur la surface de travail elle-même** — un cadre et un **Le mode est visible sur la surface de travail elle-même** — un cadre et un
libellé permanents autour du plan — et non dans un coin de barre d'outils : libellé permanents autour du plan — et non dans un coin de barre d'outils :
l'opérateur regarde le plan. l'opérateur regarde le plan.
@ -1299,7 +1378,9 @@ règle écrite, et l'affiche :
sauvegarde est la copie d'un seul dossier. sauvegarde est la copie d'un seul dossier.
5. L'épreuve échoue, ou le point 2 l'a interrompue → le dossier de travail est 5. L'épreuve échoue, ou le point 2 l'a interrompue → le dossier de travail est
un dossier nommé d'après le produit, dans les *Documents* de l'opérateur. Le un dossier nommé d'après le produit, dans les *Documents* de l'opérateur. Le
logiciel **dit pourquoi**, une fois, à la première ouverture, sans bloquer. logiciel **dit pourquoi**, une fois par séance, sans bloquer : s'en souvenir
d'une séance à l'autre demanderait un réglage local de plus, et le § 8.5 les
borne à deux.
**Le dossier retenu est éprouvé à son tour, et c'est cette épreuve qui le **Le dossier retenu est éprouvé à son tour, et c'est cette épreuve qui le
crée** : le dossier des *Documents* naît à la détermination, avant que la liste crée** : le dossier des *Documents* naît à la détermination, avant que la liste
@ -1454,7 +1535,8 @@ plateforme**.
parce que l'enregistrement automatique la rend définitive en une seconde. parce que l'enregistrement automatique la rend définitive en une seconde.
- **Version de format.** Un fichier **plus récent** s'ouvre en lecture seule - **Version de format.** Un fichier **plus récent** s'ouvre en lecture seule
sans passage possible en écriture ; un fichier **plus ancien** se convertit au sans passage possible en écriture ; un fichier **plus ancien** se convertit au
premier passage en écriture, jamais à l'ouverture. premier geste qui l'écrit, jamais à l'ouverture ni au passage en écriture, qui
ne réécrit pas le fichier d'état.
- **Une seule séance en écriture**, le verrou se prenant **à l'entrée en mode - **Une seule séance en écriture**, le verrou se prenant **à l'entrée en mode
écriture** et non à l'ouverture. écriture** et non à l'ouverture.
- **Contrôle de cohérence** : l'en-tête annonce ses comptes, sa révision, sa - **Contrôle de cohérence** : l'en-tête annonce ses comptes, sa révision, sa
@ -2342,9 +2424,10 @@ perd ?*
**Le minimum gouverne la page** : chaque vue place le cas le plus défavorable à **Le minimum gouverne la page** : chaque vue place le cas le plus défavorable à
une position **fixe et prévisible** — le premier point, la première ligne — une position **fixe et prévisible** — le premier point, la première ligne —
jamais là où il faut le chercher. La moyenne l'accompagne en encre secondaire et jamais là où il faut le chercher. La moyenne des rencontres ne s'affiche pas : à
n'apparaît jamais seule. Un placement où chacun rencontre 27 personnes sauf une tables pleines, elle est la même pour toutes les propositions (§ 12.8). Un
qui en rencontre 14 a une excellente moyenne et un problème. placement où chacun rencontre 27 personnes sauf une qui en rencontre 14 a une
excellente moyenne et un problème.
### 12.2 L'en-tête chiffré ### 12.2 L'en-tête chiffré
@ -2452,7 +2535,10 @@ et deux tris opposés dans une même page se contredisent devant l'opérateur.
relâchant les contraintes : c'est une **borne inférieure**, jamais un fait. relâchant les contraintes : c'est une **borne inférieure**, jamais un fait.
Nommer « évitable » le complément affirmerait qu'un meilleur placement le Nommer « évitable » le complément affirmerait qu'un meilleur placement le
retire, et condamnerait l'opérateur à relancer indéfiniment sur un conflit retire, et condamnerait l'opérateur à relancer indéfiniment sur un conflit
qu'aucun placement ne retire. qu'aucun placement ne retire. Le mot garde son sens dans les remèdes du
diagnostic (§ 5.6), et là seulement : une table de plus, ou une appartenance
répartie autrement, « la rend évitable » parce qu'elle change la
**configuration** — jamais parce qu'une relance ferait mieux.
**Les participants sans appartenance ne forment pas un groupe** : sinon un **Les participants sans appartenance ne forment pas un groupe** : sinon un
pseudo-groupe de quarante personnes produit la plus longue barre, un plancher pseudo-groupe de quarante personnes produit la plus longue barre, un plancher
@ -2836,11 +2922,12 @@ service.
| `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux | | `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux |
| `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison | | `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison |
**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne fournit **Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne
de plateforme de bureau que par une extension communautaire, et une plateforme fournit de plateforme de bureau que par une extension communautaire, et une
dont la mise à jour ne dépend pas du projet fige le moteur d'exécution embarqué : plateforme dont la mise à jour ne dépend pas du projet fige le moteur
l'exécutable livré porterait un Chromium que plus personne ne corrige. La coquille d'exécution embarqué : l'exécutable livré porterait un Chromium que plus
du projet tient en deux fichiers — le processus principal et le script de personne ne corrige. La coquille du projet tient en trois fichiers — le
processus principal, ses primitives de fichiers (`fichiers.js`) et le script de
préchargement — et fixe elle-même sa version d'Electron, que la construction met préchargement — et fixe elle-même sa version d'Electron, que la construction met
à jour comme n'importe quelle dépendance. à jour comme n'importe quelle dépendance.
@ -2864,16 +2951,24 @@ doit afficher une version et non une importation manquante, et **contrôlé par
rejeu du script**, parce qu'un fichier engendré et oublié affiche une version rejeu du script**, parce qu'un fichier engendré et oublié affiche une version
périmée avec l'assurance d'une constante. périmée avec l'assurance d'une constante.
**Ce qui n'est pas un composant s'éprouve sous `node`** : `src/application` et
les modules purs de `src/interface` — tri, filtre, mise en forme, modèle de
dessin, navigation au clavier — entrent dans le projet `node` du § 14.8, à côté
du moteur ; seuls les composants Svelte, et ce qu'ils peignent, passent au
navigateur.
### 13.2 Une seule langue, et c'est une exigence ### 13.2 Une seule langue, et c'est une exigence
Tout — le moteur, la géométrie, l'interface — est écrit en **JavaScript**. Tout — le moteur, la géométrie, l'interface — est écrit en **JavaScript**.
Aucune partie du calcul n'est dupliquée dans un second langage. Aucune partie du calcul n'est dupliquée dans un second langage.
Cette propriété doit être **préservée délibérément**. Dès que la même Cette propriété doit être **préservée délibérément**, et une garde de
arithmétique existe en deux endroits, une suite de tests peut prouver qu'ils l'arborescence la tient : sous `src/`, `electron/` et `scripts/`, rien d'autre
s'accordent **sans jamais prouver qu'ils ont raison** : les deux dérivent que du JavaScript, des composants Svelte, des feuilles de style, des données
ensemble et les tests restent verts. Une seule implémentation rend ce mode de JSON et les scripts d'installation nommés. Dès que la même arithmétique existe
défaillance impossible. en deux endroits, une suite de tests peut prouver qu'ils s'accordent **sans
jamais prouver qu'ils ont raison** : les deux dérivent ensemble et les tests
restent verts. Une seule implémentation rend ce mode de défaillance impossible.
**Une exception, nommée, et une seule** : le calcul incrémental des indicateurs **Une exception, nommée, et une seule** : le calcul incrémental des indicateurs
du § 5.10. Elle tient à une condition écrite — **le recalcul complet est la du § 5.10. Elle tient à une condition écrite — **le recalcul complet est la
@ -2936,8 +3031,13 @@ message d'erreur**.
listes, il rend des placements. C'est ce qui le rend testable sous `node`, en listes, il rend des placements. C'est ce qui le rend testable sous `node`, en
quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusement. quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusement.
**La géométrie du plan** est elle aussi un module de **fonctions pures**, séparé **La géométrie du plan** est une couche à part, de **fonctions pures**, séparée
du rendu. du rendu et placée plus bas que le moteur : elle n'importe ni le moteur, ni
l'analyseur CSV, ni le générateur de démonstrations, ni l'application, ni
l'interface, ni le stockage, et ne touche pas le navigateur. Elle est
**requise** : sa garde échoue quand son arbre ne fournit aucun module. Le
dessin, la désignation d'un siège et le dépôt partagent ses conversions et ses
chaises (§ 7.2, § 7.3).
Les **deux frontières de plateforme** sont le système de fichiers et Les **deux frontières de plateforme** sont le système de fichiers et
l'impression : une interface unique, deux implémentations — plus, pour le système l'impression : une interface unique, deux implémentations — plus, pour le système
@ -2958,12 +3058,15 @@ ou de `src/geometrie` importe de `src/interface`, de `src/application`, de
d'une plateforme, ou touche le navigateur hors de ses deux implémentations du d'une plateforme, ou touche le navigateur hors de ses deux implémentations du
système de fichiers, qu'aucun autre de ses modules n'importe ; que `src/csv`, système de fichiers, qu'aucun autre de ses modules n'importe ; que `src/csv`,
qui lit le modèle du stockage, importe de `src/application`, de `src/interface` qui lit le modèle du stockage, importe de `src/application`, de `src/interface`
ou d'une plateforme ; et que `src/application` importe de `src/interface` ou de ou d'une plateforme ; que `src/geometrie` importe en outre du moteur, de
Svelte. Un type que la documentation d'un module importe compte comme un `src/csv` ou de `src/demo` ; que `src/application` importe de `src/interface` ou
import. Le projet `node` ne chargeant pas le greffon Svelte (§ 14.8), un test de de Svelte ; et que `src/interface` importe autre chose que Svelte, ses propres
moteur qui importerait un composant échoue au chargement — mais cette garde-là modules, `src/application` et `src/geometrie` — elle atteint le CSV et le moteur
ne couvre que les composants, et un module qui touche `document` ne tomberait par l'application, jamais en direct. Un type que la documentation d'un module
qu'à l'exécution de la branche fautive. importe compte comme un import. Le projet `node` ne chargeant pas le greffon
Svelte (§ 14.8), un test de moteur qui importerait un composant échoue au
chargement — mais cette garde-là ne couvre que les composants, et un module qui
touche `document` ne tomberait qu'à l'exécution de la branche fautive.
### 13.5 Ce que le SVG donne et que le canvas ferait payer ### 13.5 Ce que le SVG donne et que le canvas ferait payer
@ -3042,15 +3145,16 @@ suivante ; une règle qui fait échouer la construction tient.
| niveau | ce qu'il couvre | quand | | niveau | ce qu'il couvre | quand |
|---|---|---| |---|---|---|
| **`node`, série surveillée** | moteur, géométrie, CSV, indicateurs et plafonds — des fonctions pures ; cohérence des versions (§ 18.5) ; appariement captures/manifeste (§ 19.7) ; aller-retour et canonicité du stockage sur la petite démonstration ; **une** production de planche de badges avec contrôle de géométrie et de couche de texte ; grille de propriétés sur petites instances | **à chaque modification**, quelques secondes (§ 14.14) | | **`node`, série surveillée** | moteur, géométrie, CSV, indicateurs et plafonds — des fonctions pures ; l'application et les modules purs de l'interface, sur le système de fichiers simulé et l'horloge pilotée ; cohérence des versions (§ 18.5) ; appariement captures/manifeste (§ 19.7) ; aller-retour et canonicité du stockage sur la petite démonstration ; **une** production de planche de badges avec contrôle de géométrie et de couche de texte ; grille de propriétés sur petites instances | **à chaque modification**, quelques secondes (§ 14.14) |
| **`node:long`** | accord de l'incrémental à 1, 10, 100, 1 000 et 10 000 mouvements ; rejeu du générateur et empreinte ; pagination des badges sur ses cinq comptes A4, ses quatre comptes Lettre et ses deux impositions ; reconstruction de tous les instants du journal ; aller-retour sur la grande démonstration ; coût d'enregistrement ; sonde d'écriture sur système de fichiers réel ; grande grille de propriétés | avant un commit, et à la construction | | **`node:long`** | accord de l'incrémental à 1, 10, 100, 1 000 et 10 000 mouvements ; rejeu du générateur et empreinte ; pagination des badges sur ses cinq comptes A4, ses quatre comptes Lettre et ses deux impositions ; reconstruction de tous les instants du journal ; aller-retour sur la grande démonstration ; coût d'enregistrement ; sonde d'écriture sur système de fichiers réel ; grande grille de propriétés | avant un commit, et à la construction |
| **navigateur** | ce que l'interface dessine, ce que les gestes produisent, les coordonnées ; le scénario de pilotage (§ 19.3) ; le banc de mesure (§ 19.10) ; les épreuves de style calculé | avant chaque livraison | | **navigateur** | ce que l'interface dessine, ce que les gestes produisent, les coordonnées ; le scénario de pilotage (§ 19.3) ; le banc de mesure (§ 19.10) ; les épreuves de style calculé | avant chaque livraison |
| **manuel** | une soirée complète, de la saisie à l'impression ; le décalage des imprimantes (§ 11.3) ; `pointercancel` et `touch-action` (§ 7.3) | avant chaque livraison au client | | **manuel** | une soirée complète, de la saisie à l'impression ; le décalage des imprimantes (§ 11.3) ; `pointercancel` et `touch-action` (§ 7.3) | avant chaque livraison au client |
| **sa propre commande** | l'audit par mutation (§ 14.13) | avant une livraison, sous aucun budget | | **sa propre commande** | l'audit par mutation (§ 14.13) | avant une livraison, sous aucun budget |
**La commande surveillée ne lance que le projet `node`.** Le lanceur en porte **La commande surveillée ne lance que le projet `node`.** Le lanceur en porte
deux (§ 14.8) ; lancés ensemble, ils démarrent un navigateur à chaque trois (§ 14.8) — `node`, `node-long` et `navigateur` ; lancés ensemble, ils
modification, et le niveau navigateur reste à « avant chaque livraison ». démarrent un navigateur et la série longue à chaque modification, et le niveau
navigateur reste à « avant chaque livraison ».
**Le partage entre les deux séries `node` est écrit avant la première mesure**, **Le partage entre les deux séries `node` est écrit avant la première mesure**,
et non après. Trois familles tombent naturellement dans la série surveillée sans et non après. Trois familles tombent naturellement dans la série surveillée sans
@ -3093,21 +3197,33 @@ chaîne venue de la table de traduction d'une chaîne écrite dans le composant
les deux s'affichent à l'identique. Les deux gardes sont un **balayage des les deux s'affichent à l'identique. Les deux gardes sont un **balayage des
sources** sous `node`, qui refuse une chaîne visible en dur et **échoue sur un sources** sous `node`, qui refuse une chaîne visible en dur et **échoue sur un
balayage vide** (§ 14.2), et une **exécution en langue témoin** où chaque chaîne balayage vide** (§ 14.2), et une **exécution en langue témoin** où chaque chaîne
traduite est décorée d'une marque : tout ce qui apparaît à l'écran sans sa marque traduite est encadrée d'une marque — `⁅` et `⁆`, U+2045 et U+2046, une fois,
est écrit en dur. autour du texte entier : tout ce qui apparaît à l'écran sans sa marque est écrit
en dur. La langue témoin est un mode du mécanisme, que la construction livrée
n'emploie jamais.
**Un seul mécanisme, deux familles de tables.** Les textes de l'application —
codes de refus et d'avertissement, mise en forme des nombres et des durées — et
ceux des écrans vivent dans des tables distinctes, que réunit un seul mécanisme
de traduction. Un code s'affiche par sa propre clé, un libellé par une clé qui
nomme sa table ; une clé portée par deux tables, ou absente, lève — l'écran ne
montre jamais une clé brute ni un texte vide. Un texte qui devient une donnée —
le libellé figé d'une entrée du journal (§ 8.2), le motif du CSV des lignes
refusées (§ 10.1) — se compose en français, sans la marque de la langue témoin.
### 14.7 Le déterminisme ### 14.7 Le déterminisme
Aucune source non reproductible — `Math.random`, `Date.now`, `performance.now`, Aucune source non reproductible — `Math.random`, `Date.now`, `performance.now`,
`crypto.getRandomValues` — dans le **moteur**, dans le **générateur de `crypto.getRandomValues` — dans le **moteur**, dans la **géométrie du plan**,
démonstrations**, ni dans le **stockage** et l'**analyseur CSV**, qui reçoivent dans le **générateur de démonstrations**, ni dans le **stockage** et
l'horloge et l'aléa en paramètre. Un test de l'arborescence le refuse, **et l'**analyseur CSV**, qui reçoivent l'horloge et l'aléa en paramètre. Un test de
échoue si son balayage ne trouve aucun fichier**. Son périmètre est nommé : l'arborescence le refuse, **et échoue si son balayage ne trouve aucun fichier**.
`src/moteur`, `src/demo`, `src/stockage` et `src/csv`, à l'exclusion du code Son périmètre est nommé : `src/moteur`, `src/geometrie`, `src/demo`,
d'épreuve, où un tirage sert légitimement (§ 14.12). L'interdit ne porte pas sur `src/stockage` et `src/csv`, à l'exclusion du code d'épreuve, où un tirage sert
le reste de l'application : l'application lit l'horloge pour horodater les légitimement (§ 14.12). L'interdit ne porte pas sur le reste de l'application :
entrées de l'historique, que le stockage écrit telles qu'il les reçoit, et le l'application lit l'horloge pour horodater les entrées de l'historique, que le
banc de mesure du § 19.10 relève des temps par image. stockage écrit telles qu'il les reçoit, et le banc de mesure du § 19.10 relève
des temps par image.
**Ce n'est pas une règle d'hygiène.** L'interdit de `performance.now` dans le **Ce n'est pas une règle d'hygiène.** L'interdit de `performance.now` dans le
moteur est ce qui rend vraie la phrase « à graine et entrée égales, placement moteur est ce qui rend vraie la phrase « à graine et entrée égales, placement
@ -3122,22 +3238,25 @@ d'horloge à l'exécution (§ 18.5) : le moteur et le générateur ne la lisent
### 14.8 L'outillage des épreuves ### 14.8 L'outillage des épreuves
**Un seul lanceur, deux projets : Vitest.** Le niveau `node` et le niveau **Un seul lanceur, trois projets : Vitest.** Les deux séries `node` et le niveau
navigateur du § 14.4 sont deux configurations du même lanceur, pas deux outils. navigateur du § 14.4 sont trois configurations du même lanceur, pas trois
outils.
| projet | environnement | ce qu'il inclut | greffon Svelte | | projet | environnement | ce qu'il inclut | greffon Svelte |
|---|---|---|---| |---|---|---|---|
| `node` | `node`, aucun DOM | `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`, `src/demo`, `src/pdf` | **absent** | | `node` | `node`, aucun DOM | `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`, `src/demo`, `src/pdf`, `src/application`, et les modules purs de `src/interface` | **absent** |
| `navigateur` | un vrai moteur de rendu, piloté par le mode navigateur de Vitest | `src/interface`, les épreuves de coordonnées et de style calculé | présent | | `node-long` | `node`, aucun DOM | les épreuves lourdes du niveau `node:long` (§ 14.4), suffixe `.long.test.js`, hors de la série surveillée | **absent** |
| `navigateur` | un vrai moteur de rendu, piloté par le mode navigateur de Vitest | les composants de `src/interface`, les épreuves de coordonnées et de style calculé | présent |
**Pourquoi Vitest.** Il lit la **configuration Vite qui construit le livrable** : un module importé par un test se résout exactement comme un module **Pourquoi Vitest.** Il lit la **configuration Vite qui construit le
livrable** : un module importé par un test se résout exactement comme un module
importé par l'application. Deux chaînes de résolution — l'une pour construire, importé par l'application. Deux chaînes de résolution — l'une pour construire,
l'autre pour éprouver — sont le mode de défaillance du § 13.2 transposé à l'autre pour éprouver — sont le mode de défaillance du § 13.2 transposé à
l'outillage : le test passe sur un fichier que l'application ne charge jamais, un l'outillage : le test passe sur un fichier que l'application ne charge jamais,
alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe de un alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe
dépendances**, donc une modification du moteur ne rejoue que les tests du moteur, de dépendances**, donc une modification du moteur ne rejoue que les tests du
et son mode navigateur offre **la même API d'assertion** au niveau 2, de sorte moteur, et son mode navigateur offre **la même API d'assertion** au niveau 2, de
que l'oracle de géométrie du § 7.2 s'écrit une fois. sorte que l'oracle de géométrie du § 7.2 s'écrit une fois.
**Les trois durées sont des cibles, mesurées la première semaine.** Le démarrage **Les trois durées sont des cibles, mesurées la première semaine.** Le démarrage
à froid, la relance sous surveillance et la série complète se mesurent sur un à froid, la relance sous surveillance et la série complète se mesurent sur un
@ -3192,12 +3311,15 @@ une donnée d'épreuve qui emprunte une racine d'organisation plausible le fait
échouer. Un test vérifie cette disjonction, faute de quoi la garde du § 15.6 échouer. Un test vérifie cette disjonction, faute de quoi la garde du § 15.6
tombe au premier exemple écrit de bonne foi. tombe au premier exemple écrit de bonne foi.
**La co-localisation se garde.** Un test de la construction affirme que le paquet **La co-localisation se garde.** Un test de la construction affirme que le
livré ne contient **aucun fichier de test ni aucun import du lanceur**. Le § 14.15 paquet livré ne contient **aucun fichier de test ni aucun import du lanceur**.
pose que la suite n'est pas livrée ; avec des tests rangés dans `src/`, cette Le § 14.15 pose que la suite n'est pas livrée ; avec des tests rangés dans
propriété tient par la seule analyse statique de Vite, c'est-à-dire par `src/`, cette propriété tient par la seule analyse statique de Vite,
chance, jusqu'au jour où un module de production importe une c'est-à-dire par chance, jusqu'au jour où un module de production importe une
donnée d'épreuve « pour un cas par défaut ». donnée d'épreuve « pour un cas par défaut ». Une garde de l'arborescence exige
aussi que chaque test de `src/` importe un module de son propre répertoire, hors
d'exceptions nommées avec leur raison, et qu'aucune donnée de `test/fixtures/`
n'égale, octet pour octet, un fichier livré.
### 14.10 La carte de ce qui s'éprouve ### 14.10 La carte de ce qui s'éprouve
@ -3209,7 +3331,8 @@ donnée d'épreuve « pour un cas par défaut ».
| analyseur CSV | `node` | encodage, séparateur, association des colonnes, réconciliation des appartenances, lignes refusées, aller-retour | | analyseur CSV | `node` | encodage, séparateur, association des colonnes, réconciliation des appartenances, lignes refusées, aller-retour |
| générateur de démonstrations | `node` | vecteur du générateur pseudo-aléatoire, empreinte des fichiers livrés, refus, noms forgés | | générateur de démonstrations | `node` | vecteur du générateur pseudo-aléatoire, empreinte des fichiers livrés, refus, noms forgés |
| PDF | `node` | couche de texte, pagination, police embarquée et table `ToUnicode`, géométrie de la planche, reproductibilité | | PDF | `node` | couche de texte, pagination, police embarquée et table `ToUnicode`, géométrie de la planche, reproductibilité |
| interface | navigateur | ce que les gestes produisent, le style calculé, les trois invariants du § 7.2 | | application | `node` | les commandes et leurs refus, la séance observable, les contrôles de pose et de capacité, la table d'évaluation — sur le système de fichiers simulé et l'horloge pilotée |
| interface | navigateur ; ses modules purs sous `node` | ce que les gestes produisent, le style calculé, les trois invariants du § 7.2 |
#### Le moteur #### Le moteur
@ -3278,14 +3401,15 @@ sait pas fragmenter un SVG ; c'est du code qu'il faut éprouver, pas une règle
style. style.
**Au niveau navigateur**, la fonction pure est confrontée à **Au niveau navigateur**, la fonction pure est confrontée à
`getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après défilement `getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après
de la page. **Les trois invariants du § 7.2 ont chacun leur test** : que le `<svg>` défilement de la page, à un millième de pixel d'écran près (§ 7.2). **Les trois
racine ne porte pas d'attribut `viewBox` ; que son style calculé donne une bordure invariants du § 7.2 ont chacun leur test** : que le `<svg>` racine ne porte pas
et un remplissage intérieur nuls — `getBoundingClientRect` les inclut, donc d'attribut `viewBox` ; que son style calculé donne une bordure et un remplissage
l'origine du contenu s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de intérieur nuls — `getBoundingClientRect` les inclut, donc l'origine du contenu
transformation CSS. Il refuse la feuille de style qui ajoute un jour s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de transformation CSS. Il
`transform: translateZ(0)` « pour la fluidité » : le plan répondrait faux partout, refuse la feuille de style qui ajoute un jour `transform: translateZ(0)` « pour
et la cause serait cherchée dans le module de géométrie. la fluidité » : le plan répondrait faux partout, et la cause serait cherchée
dans le module de géométrie.
#### Le stockage #### Le stockage
@ -3523,16 +3647,25 @@ produire une configuration à partir d'une graine. Une boucle sur une grille de
graines et de tailles, appelant les deux vérificateurs, couvre l'essentiel sans graines et de tailles, appelant les deux vérificateurs, couvre l'essentiel sans
dépendance nouvelle. dépendance nouvelle.
**Une bibliothèque de propriétés se justifie à deux endroits, et seulement deux** : **Une bibliothèque de propriétés se justifie à deux endroits du calcul, et
la formule du plafond et la détection du séparateur. Toutes deux prennent des seulement deux** : la formule du plafond et la détection du séparateur. Toutes
entrées petites et combinatoires, toutes deux bénéficient du **rétrécissement** — deux prennent des entrées petites et combinatoires, toutes deux bénéficient du
quand une configuration à 120 participants échoue, l'outil rend un contre-exemple à **rétrécissement** — quand une configuration à 120 participants échoue, l'outil
trois tables, là où la réduction manuelle coûte une heure. Leurs oracles ne sont rend un contre-exemple à trois tables, là où la réduction manuelle coûte une
pas de même nature et le texte ne les confond pas : le plafond s'arbitre par heure. Leurs oracles ne sont pas de même nature et le texte ne les confond pas :
**énumération exhaustive** ; le séparateur s'arbitre par **construction** — un le plafond s'arbitre par **énumération exhaustive** ; le séparateur s'arbitre
tableau de champs engendré, rendu avec un séparateur connu, et la détection doit le par **construction** — un tableau de champs engendré, rendu avec un séparateur
retrouver. Ailleurs, non : une propriété sur la recherche rejouerait la partie connu, et la détection doit le retrouver. Ailleurs, non : une propriété sur la
lente et épuiserait le budget du § 14.14. recherche rejouerait la partie lente et épuiserait le budget du § 14.14.
**Le stockage l'emploie pour deux propriétés de plus, nommées.** L'aller-retour
des correctifs du journal — appliquer à une charge la différence qui mène à une
autre rend l'autre, sur des paires de charges tirées — et la restitution de
chaque instant d'une séance tirée, qui vit dans la série longue parce que ses
séances lancent des générations, à compte d'arrêt court. Leurs entrées sont des
suites d'opérations, où le **rétrécissement** sert comme au plafond : le
contre-exemple rendu tient en quelques opérations. La liste est fermée : une
garde de l'arborescence refuse la bibliothèque hors de ces quatre familles.
**La bibliothèque tourne sur une graine fixée.** Une bibliothèque de propriétés **La bibliothèque tourne sur une graine fixée.** Une bibliothèque de propriétés
retire par défaut une graine neuve à chaque exécution : le même code échoue une retire par défaut une graine neuve à chaque exécution : le même code échoue une
@ -4045,7 +4178,9 @@ valeur soit fixée ; un seul reste entièrement ouvert.
les 33 tables avec listes. Aucune capture d'écran ne le mesure — une image est les 33 tables avec listes. Aucune capture d'écran ne le mesure — une image est
muette sur la durée qui l'a produite. Ce qui reste à dire est ce qu'est un temps muette sur la durée qui l'a produite. Ce qui reste à dire est ce qu'est un temps
par image **acceptable sur la machine de l'opérateur**, qui n'est pas celle du par image **acceptable sur la machine de l'opérateur**, qui n'est pas celle du
développement. développement. Sa valeur de travail a déjà une conséquence visible : sur un
écran de 1 366 × 700, la grande démonstration s'ajuste sous elle et s'ouvre
sans listes de noms, qui se dessinent en rapprochant la vue (§ 7.7).
7. **OUVERT, et hors périmètre de la première itération — les dégagements de 7. **OUVERT, et hors périmètre de la première itération — les dégagements de
référence d'une salle.** Le mot « conforme » reste absent de l'interface et la référence d'une salle.** Le mot « conforme » reste absent de l'interface et la
mention « disposition relative » reste sur chaque impression du plan (§ 7.7). mention « disposition relative » reste sur chaque impression du plan (§ 7.7).
@ -4187,10 +4322,12 @@ Tout le reste en dérive, par un script de construction :
script puis compare — la même discipline qu'au § 15.5 pour les fichiers de script puis compare — la même discipline qu'au § 15.5 pour les fichiers de
démonstration (§ 13.1). démonstration (§ 13.1).
**Le module porte aussi la provenance de la construction** — livraison, séance de **Le module engendré ne porte que la version.** La **provenance de la
développement, ou construction de documentation — posée par la commande qui construction** — livraison, séance de développement, ou construction de
l'engendre, jamais lue dans l'horloge ni dans l'environnement. Sans elle, une documentation — est le mode de construction que pose la commande qui construit,
capture d'écran prise pendant le développement annonce un numéro déjà livré, et le celui de Vite, que l'application reçoit avec ses dépendances ; elle n'est jamais
lue dans l'horloge ni dans l'environnement du poste. Sans elle, une capture
d'écran prise pendant le développement annonce un numéro déjà livré, et le
diagnostic porte sur un binaire que personne ne détient. diagnostic porte sur un binaire que personne ne détient.
### 18.4 Le `CHANGELOG.md` ### 18.4 Le `CHANGELOG.md`
@ -4321,7 +4458,7 @@ question. Le logiciel fait donc en sorte qu'on n'ait pas à la chercher.
| endroit | forme | | endroit | forme |
|---|---| |---|---|
| **bandeau d'application**, sur tout écran (§ 8.4) | `2026.10.05.01`, avec la provenance et — quand un plan est ouvert — le libellé de mode | | **bandeau d'application**, sur tout écran (§ 8.4) | `2026.10.05.01`, avec la provenance et — quand un plan est ouvert — le libellé de mode |
| **premier écran** (§ 8.1), liste des événements comme répertoire vide | `version 2026.10.05.01` | | **premier écran** (§ 8.1), liste des événements comme répertoire vide | `version 2026.10.05.01`, ou la marque de provenance d'une construction de documentation |
| **titre de la fenêtre** | `Gestion table tournante Libre — 2026.10.05.01` | | **titre de la fenêtre** | `Gestion table tournante Libre — 2026.10.05.01` |
| **panneau « À propos »** | un bloc d'une ligne que l'opérateur **copie** | | **panneau « À propos »** | un bloc d'une ligne que l'opérateur **copie** |
| **pied de chaque page imprimée** (§ 11.7) | la version, à côté de l'horodatage du placement | | **pied de chaque page imprimée** (§ 11.7) | la version, à côté de l'horodatage du placement |
@ -4345,7 +4482,7 @@ moment où l'opérateur peut encore se ménager un retour en arrière, c'est-à-
avant le premier geste. avant le premier geste.
**Le bloc copiable** du panneau « À propos » tient en une ligne — version affichée, **Le bloc copiable** du panneau « À propos » tient en une ligne — version affichée,
version technique, plateforme, provenance de la construction, version de format version Windows, plateforme, provenance de la construction, version de format
des fichiers — et se colle dans un courriel. Un chiffre recopié à la main se des fichiers — et se colle dans un courriel. Un chiffre recopié à la main se
recopie faux. Il porte les **deux** formes parce qu'un opérateur qui lit la fiche recopie faux. Il porte les **deux** formes parce qu'un opérateur qui lit la fiche
de propriétés du fichier sous Windows y trouve `2026.1005.1.0` et non de propriétés du fichier sous Windows y trouve `2026.1005.1.0` et non
@ -4363,12 +4500,13 @@ fois lors du premier geste suivant un remplacement d'exécutable, ce qui coûte
correctif et non du bruit à chaque geste. Il **n'est pas** la version de format, correctif et non du bruit à chaque geste. Il **n'est pas** la version de format,
qui reste un nombre séparé, comparé selon ses propres règles. qui reste un nombre séparé, comparé selon ses propres règles.
**Pendant une construction de documentation, le numéro est remplacé par une marque **Pendant une construction de documentation, le numéro est remplacé par une
de provenance** — dans le bandeau, au pied des pages imprimées et sur la feuille de marque de provenance** — dans le bandeau, sur le premier écran, au pied des
contrôle des badges. La raison est au § 19.4 : sans cette marque, le texte relevé pages imprimées et sur la feuille de contrôle des badges. La raison est au
de chaque capture d'écran change à chaque incrément de version, et toutes les § 19.4 : sans cette marque, le texte relevé de chaque capture d'écran change à
images se réécrivent. Le manifeste, lui, enregistre la **version réelle**, et le chaque incrément de version, et toutes les images se réécrivent. Le manifeste,
contrôle de version de la livraison la compare (§ 19.7). lui, enregistre la **version réelle**, et le contrôle de version de la livraison
la compare (§ 19.7).
--- ---
@ -4439,8 +4577,10 @@ ne les offre de façon portable :
- **L'émulation d'une préférence système.** Le logiciel règle son **propre** - **L'émulation d'une préférence système.** Le logiciel règle son **propre**
réglage de thème, qui est celui que l'opérateur manipule (§ 13.3), et injecte une réglage de thème, qui est celui que l'opérateur manipule (§ 13.3), et injecte une
feuille de style qui annule transitions et animations. La préférence système feuille de style qui annule transitions et animations. La préférence système
n'est consultée qu'au premier lancement, que le profil neuf du § 19.4 produit à est consultée à chaque lancement, et le réglage de l'opérateur vaut pour la
chaque exécution : **le scénario la pose explicitement et ne la déduit jamais.** séance : rien ne l'enregistre, ni fichier ni stockage du navigateur (§ 8.6).
**Le scénario pose donc le thème par le réglage, à chaque exécution, et ne le
déduit jamais de la machine.**
### 19.3 Le parcours, et ce qu'il photographie ### 19.3 Le parcours, et ce qu'il photographie
@ -4548,7 +4688,7 @@ neutralise chaque source de variation nommément.
| **le cadrage mémorisé** | le § 8.5 range le cadrage dans un fichier de réglages locaux : le scénario démarre sur un **profil neuf**, ce qui lui donne du même geste un dossier de travail neuf | | **le cadrage mémorisé** | le § 8.5 range le cadrage dans un fichier de réglages locaux : le scénario démarre sur un **profil neuf**, ce qui lui donne du même geste un dossier de travail neuf |
| **le numéro d'ordre du nom de démonstration** | charger une démonstration crée « ‹nom› 2 » à la deuxième exécution (§ 15) ; le répertoire neuf ramène chaque exécution au premier ordinal | | **le numéro d'ordre du nom de démonstration** | charger une démonstration crée « ‹nom› 2 » à la deuxième exécution (§ 15) ; le répertoire neuf ramène chaque exécution au premier ordinal |
| **l'horloge** — horodatage d'historique, pied de page, « dernière modification », retour automatique en lecture | **une seule source d'horloge dans toute l'application hors moteur**, et **elle possède aussi les minuteries**. Un instant figé ne suffit pas : un compte à rebours bâti sur une minuterie du moteur d'exécution continue d'avancer sous une horloge gelée, et **le retour automatique en lecture tombe au milieu d'une étape**. Le scénario installe une horloge dont il contrôle l'avance, ce qui lui permet aussi d'éprouver les durées | | **l'horloge** — horodatage d'historique, pied de page, « dernière modification », retour automatique en lecture | **une seule source d'horloge dans toute l'application hors moteur**, et **elle possède aussi les minuteries**. Un instant figé ne suffit pas : un compte à rebours bâti sur une minuterie du moteur d'exécution continue d'avancer sous une horloge gelée, et **le retour automatique en lecture tombe au milieu d'une étape**. Le scénario installe une horloge dont il contrôle l'avance, ce qui lui permet aussi d'éprouver les durées |
| **le numéro de version affiché** | une construction de documentation pose une **marque de provenance à la place du numéro** (§ 18.6), dans le bandeau comme au pied des pages imprimées et sur la feuille de contrôle des badges. Sans elle, le texte relevé de chaque capture change à chaque livraison, l'empreinte d'étape change avec lui, et **toutes les images se réécrivent à chaque incrément** — précisément le bruit que la réécriture conditionnelle du § 19.5 existe pour supprimer | | **le numéro de version affiché** | une construction de documentation pose une **marque de provenance à la place du numéro** (§ 18.6), dans le bandeau, sur le premier écran, au pied des pages imprimées et sur la feuille de contrôle des badges. Sans elle, le texte relevé de chaque capture change à chaque livraison, l'empreinte d'étape change avec lui, et **toutes les images se réécrivent à chaque incrément** — précisément le bruit que la réécriture conditionnelle du § 19.5 existe pour supprimer |
| **le chemin affiché** dans la liste des événements (§ 8.1) | racine de travail neutre, et le contrôle **refuse une capture dont le texte relevé — celui de tous les nœuds de texte de la région photographiée, pas des seuls éléments que l'étape nomme — porte un chemin hors de cette racine**. Un chemin réel porte un nom de compte, qu'une documentation publie | | **le chemin affiché** dans la liste des événements (§ 8.1) | racine de travail neutre, et le contrôle **refuse une capture dont le texte relevé — celui de tous les nœuds de texte de la région photographiée, pas des seuls éléments que l'étape nomme — porte un chemin hors de cette racine**. Un chemin réel porte un nom de compte, qu'une documentation publie |
| **la police réellement rendue** | la police embarquée (§ 11.1) est celle du pilotage. Le scénario **mesure une chaîne témoin** par `getComputedTextLength()` et la compare à la valeur enregistrée **avec une tolérance relative, constante mesurée** (§ 14.1) — exiger l'égalité exacte reproduirait, sur une mesure de texte, l'erreur que cette section refuse sur les pixels. La tolérance se fixe au-dessous de l'écart qu'introduit un repli de police, qui est d'un tout autre ordre | | **la police réellement rendue** | la police embarquée (§ 11.1) est celle du pilotage. Le scénario **mesure une chaîne témoin** par `getComputedTextLength()` et la compare à la valeur enregistrée **avec une tolérance relative, constante mesurée** (§ 14.1) — exiger l'égalité exacte reproduirait, sur une mesure de texte, l'erreur que cette section refuse sur les pixels. La tolérance se fixe au-dessous de l'écart qu'introduit un repli de police, qui est d'un tout autre ordre |
| **le thème** | réglé par le réglage de l'application, non par une préférence système émulée (§ 19.2) | | **le thème** | réglé par le réglage de l'application, non par une préférence système émulée (§ 19.2) |

View file

@ -13,10 +13,12 @@
// Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne // Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne
// décrit plus la configuration, le second sur un placement fautif. // décrit plus la configuration, le second sur un placement fautif.
import { ErreurConfiguration } from '../moteur/erreurs.js'; import { ErreurConfiguration } from '../moteur/erreurs.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { LIBRE, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js'; import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js'; import { capacite, configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js'; import { examiner, planDepuisFichier } from '../stockage/placements.js';
import { nomAffiche } from './personnes.js';
import { couvre, placesTenues, presents, reservationsActives } from './places.js';
const avertissement = (code, details) => ({ code, details }); const avertissement = (code, details) => ({ code, details });
@ -45,12 +47,139 @@ function raisonsParPlacement(examen) {
return raisons; return raisons;
} }
// Les violations du retenu cohérent d'une charge, contre son instance, la // Les réservations au siège que le retenu, sièges attribués, ne tient pas à
// réserve admise : des surnuméraires versés en réserve ne violent rien. // ce siège alors que sa personne est assise à la même table : celles qui
// [] sans retenu, pour un retenu fautif, ou sans instance. // tiennent une chaise selon la règle de l'affichage (placesTenues), dans la
function violationsDuRetenu(charge, examen, instance) { // capacité courante. Par tour, dans l'ordre de retenu.tables, par siège ;
// occupant, la personne présente sur ce siège, null pour une chaise vide ou
// celle d'un absent. Une personne assise à une autre table relève de la
// règle de la table, que verifierInvariants juge.
function siegesNonTenus(charge, retenu, instance) {
if (!retenu.siegesAttribues) return [];
const actives = reservationsActives(charge);
const ids = presents(charge);
const enregistrementDe = new Map(charge.tables.map((enregistrement) => [enregistrement.id, enregistrement]));
const violations = [];
const lus = Math.min(retenu.tours, instance.R, retenu.placement.length);
for (let tour = 1; tour <= lus; tour += 1) {
const couvrantes = actives.filter((reservation) => couvre(reservation, tour));
retenu.tables.forEach((idTable, rang) => {
const enregistrement = enregistrementDe.get(idTable);
if (enregistrement === undefined || retenu.tables.indexOf(idTable) !== rang) return;
const liste = retenu.placement[tour - 1].sieges[rang] ?? [];
const { tenu } = placesTenues(
couvrantes.filter(({ table }) => table === idTable),
capacite(charge, enregistrement),
);
for (const siege of [...tenu.keys()].sort((a, b) => a - b)) {
const { participant } = tenu.get(siege);
const assis = liste[siege - 1] ?? null;
if (assis === participant || !liste.includes(participant)) continue;
const occupant = assis !== null && ids.has(assis) ? assis : null;
violations.push({ code: 'RESERVATION', participant, table: idTable, tour, siege, occupant });
}
});
}
return violations;
}
/**
* Les violations d'un retenu lisible (fautes vide) contre l'instance de la
* charge (§ 14.12) : celles de verifierInvariants, la réserve admise — des
* surnuméraires versés en réserve ne violent rien —, complétées de deux
* réservations non tenues qu'il ne relève pas. Une personne que le retenu
* ne place pas à un tour, et qu'une réservation impose à une table à ce
* tour, reçoit RESERVATION {participant, table, tour} juste après son
* NON_ASSIS : ajoutée ou réintégrée depuis la retenue, elle n'est honorée
* nulle part. Sièges attribués, une réservation au siège que le retenu
* donne à un autre, sa personne assise à la même table, reçoit RESERVATION
* {participant, table, tour, siege, occupant} (siegesNonTenus), après les
* autres. Le retenu se juge tel quel, sans retouche (§ 5.8).
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('../stockage/types.js').Retenu} retenu
* @param {import('../moteur/types.js').Instance} instance celle de la charge
* @returns {Object[]} les violations, dans cet ordre
*/
export function violationsDuRetenu(charge, retenu, instance) {
const { R, indexDe, idsTables, fixe } = instance;
const violations = [];
for (const violation of verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true })) {
violations.push(violation);
if (violation.code !== 'NON_ASSIS' || violation.tour > R) continue;
const imposee = fixe[indexDe.get(violation.participant) * R + violation.tour - 1];
if (imposee !== LIBRE) {
violations.push({ code: 'RESERVATION', participant: violation.participant, table: idsTables[imposee], tour: violation.tour });
}
}
return [...violations, ...siegesNonTenus(charge, retenu, instance)];
}
// Les violations du retenu cohérent d'une charge (violationsDuRetenu). []
// sans retenu, pour un retenu fautif, ou sans instance.
function violationsCourantes(charge, examen, instance) {
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return []; if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true }); return violationsDuRetenu(charge, charge.retenu, instance);
}
// Le nom affiché de la personne id dans la première charge qui la porte,
// ou null ; le numéro de la table id de même.
const nomDans = (id, charges) => {
for (const charge of charges) {
const trouvee = charge.participants.find((candidate) => candidate.id === id);
if (trouvee !== undefined) return nomAffiche(trouvee);
}
return null;
};
const numeroDans = (id, charges) => {
for (const charge of charges) {
const trouvee = charge.tables.find((candidate) => candidate.id === id);
if (trouvee !== undefined) return trouvee.numero;
}
return null;
};
/**
* Les raisons de dérive nommées (§ 9) : chacune garde ses champs, et gagne
* personne, le nom affiché de la personne qu'elle désigne, et numero, le
* numéro de la table qu'elle désigne, lus dans la première des charges qui
* les porte ; une personne ou une table qu'aucune ne porte garde son seul
* identifiant. Rend des objets neufs.
*
* @param {Object[]} raisons
* @param {...import('../stockage/types.js').Charge} charges
* @returns {Object[]}
*/
export function raisonsNommees(raisons, ...charges) {
return raisons.map((raison) => {
const nommee = { ...raison };
if (raison.participant !== undefined) {
const personne = nomDans(raison.participant, charges);
if (personne !== null) nommee.personne = personne;
}
if (raison.table !== undefined) {
const numero = numeroDans(raison.table, charges);
if (numero !== null) nommee.numero = numero;
}
return nommee;
});
}
/**
* L'examen d'une charge (examiner, S2), ses raisons de dérive nommées sur
* elle (raisonsNommees) : celles du retenu et de chaque proposition en
* dérive. Un objet neuf ; l'examen reçu ne change pas.
*
* @param {import('../stockage/placements.js').Examen} examen
* @param {import('../stockage/types.js').Charge} charge
* @returns {import('../stockage/placements.js').Examen}
*/
export function examenNomme(examen, charge) {
return {
...examen,
derives: examen.derives.map((derive) => ({ ...derive, raisons: raisonsNommees(derive.raisons, charge) })),
retenu: { ...examen.retenu, raisons: raisonsNommees(examen.retenu.raisons, charge) },
};
} }
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur; const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
@ -77,7 +206,8 @@ function raisonQuiLaNomme(violation, raisons) {
* dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans * dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans
* retenu, pour un retenu fautif, ou sur une configuration qui ne se * retenu, pour un retenu fautif, ou sur une configuration qui ne se
* normalise pas. Une réservation que le retenu ne tient pas en est le cas * normalise pas. Une réservation que le retenu ne tient pas en est le cas
* ordinaire : la dérive ne compare pas les réservations. * ordinaire, à sa table ou à son siège, sa personne placée ou non
* (violationsDuRetenu) : la dérive ne compare pas les réservations.
* *
* @param {import('../stockage/types.js').Charge} charge * @param {import('../stockage/types.js').Charge} charge
* @param {import('../stockage/placements.js').Examen} [examen] celui de la * @param {import('../stockage/placements.js').Examen} [examen] celui de la
@ -87,7 +217,7 @@ function raisonQuiLaNomme(violation, raisons) {
export function invariantViole(charge, examen = examiner(charge)) { export function invariantViole(charge, examen = examiner(charge)) {
const { instance } = instanceDe(charge); const { instance } = instanceDe(charge);
const raisons = examen.retenu.raisons; const raisons = examen.retenu.raisons;
const violations = violationsDuRetenu(charge, examen, instance).filter( const violations = violationsCourantes(charge, examen, instance).filter(
(violation) => raisonQuiLaNomme(violation, raisons) === undefined, (violation) => raisonQuiLaNomme(violation, raisons) === undefined,
); );
if (violations.length === 0) return null; if (violations.length === 0) return null;
@ -109,13 +239,19 @@ export function invariantViole(charge, examen = examiner(charge)) {
* normalisait pas) ; * normalisait pas) ;
* DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les * DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant) * raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par * ne portait pas, comparées champ pour champ, puis nommées sur la charge
* identifiant, puis le retenu, sous id 'retenu' ; * d'après, ou d'avant pour ce que le geste retire (raisonsNommees) ; les
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation * propositions par identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION de verifierInvariants(instance d'après, * RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui * chaque violation RESERVATION de violationsDuRetenu(apres, apres.retenu,
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa * instance d'après) absente du même calcul sur avant, qui n'en compte
* configuration ne se normalisait pas. * aucune quand son retenu est nul ou fautif, ou que sa configuration ne
* se normalisait pas ; personne est le nom affiché du participant,
* numero celui de la table, lus sur la charge d'après, qui porte l'une et
* l'autre puisque l'instance qui les viole en vient. Au siège, les
* détails portent en plus siege et occupant, ceux de la violation : le
* geste qui la lève est l'échange de la personne avec l'occupant
* (echangerDansRetenu), ou, chaise vide, son déplacement au siège.
* *
* @param {import('../stockage/types.js').Charge} avant * @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres * @param {import('../stockage/types.js').Charge} apres
@ -142,16 +278,24 @@ export function avertissementsApres(avant, apres) {
for (const [id, raisons] of raisonsParPlacement(examenApres)) { for (const [id, raisons] of raisonsParPlacement(examenApres)) {
const connues = anciennes.get(id) ?? []; const connues = anciennes.get(id) ?? [];
const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison))); const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison)));
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles }); if (nouvelles.length > 0) touchees.push({ id, raisons: raisonsNommees(nouvelles, apres, avant) });
} }
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees })); if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
const reservations = (charge, examen, inst) => const reservations = (charge, examen, inst) =>
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION'); violationsCourantes(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
const tenues = reservations(avant, examenAvant, instanceAvant); const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) { for (const violation of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour); const { participant, table, tour, siege = null } = violation;
if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour })); const dejaLa = tenues.some(
(connue) =>
connue.participant === participant && connue.table === table && connue.tour === tour && (connue.siege ?? null) === siege,
);
if (dejaLa) continue;
const personne = nomAffiche(apres.participants.find(({ id }) => id === participant));
const { numero } = apres.tables.find(({ id }) => id === table);
const auSiege = siege === null ? {} : { siege, occupant: violation.occupant };
rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero, ...auSiege }));
} }
return rendus; return rendus;
} }

View file

@ -11,8 +11,9 @@ import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js'; import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { avertissementsApres, invariantViole } from './avertissements.js'; import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
const personne = (id, nom, exclu = false) => ({ const personne = (id, nom, exclu = false) => ({
id, id,
@ -184,7 +185,7 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
test('exclure une personne : DERIVE_NOUVELLE nomme chaque proposition par identifiant, puis le retenu, et la raison', () => { test('exclure une personne : DERIVE_NOUVELLE nomme chaque proposition par identifiant, puis le retenu, et la raison', () => {
const avant = petiteSoiree(); const avant = petiteSoiree();
const apres = avec(avant, (charge) => (charge.participants[2].exclu = true)); const apres = avec(avant, (charge) => (charge.participants[2].exclu = true));
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }]; const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
assert.deepEqual(avertissementsApres(avant, apres), [ assert.deepEqual(avertissementsApres(avant, apres), [
{ {
code: 'DERIVE_NOUVELLE', code: 'DERIVE_NOUVELLE',
@ -196,14 +197,14 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
test("une raison que le placement portait déjà ne se redit pas ; une raison au champ changé est nouvelle", () => { test("une raison que le placement portait déjà ne se redit pas ; une raison au champ changé est nouvelle", () => {
const une = avec(petiteSoiree(), (charge) => (charge.participants[2].exclu = true)); const une = avec(petiteSoiree(), (charge) => (charge.participants[2].exclu = true));
const deux = avec(une, (charge) => (charge.participants[3].exclu = true)); const deux = avec(une, (charge) => (charge.participants[3].exclu = true));
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4 }]; const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4, personne: 'Lacasse' }];
assert.deepEqual(avertissementsApres(une, deux), [ assert.deepEqual(avertissementsApres(une, deux), [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } }, { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
]); ]);
assert.deepEqual(avertissementsApres(deux, avec(deux, (charge) => (charge.participants[0].nom = 'Parasol'))), []); assert.deepEqual(avertissementsApres(deux, avec(deux, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
const trois = avec(petiteSoiree(), (charge) => (charge.tables[0].sieges = 3)); const trois = avec(petiteSoiree(), (charge) => (charge.tables[0].sieges = 3));
const quatre = avec(trois, (charge) => (charge.tables[0].sieges = 4)); const quatre = avec(trois, (charge) => (charge.tables[0].sieges = 4));
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4 }]; const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4, numero: 1 }];
assert.deepEqual(avertissementsApres(trois, quatre), [ assert.deepEqual(avertissementsApres(trois, quatre), [
{ {
code: 'DERIVE_NOUVELLE', code: 'DERIVE_NOUVELLE',
@ -218,23 +219,84 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
charge.participants[2].exclu = true; charge.participants[2].exclu = true;
reserverGrisaille(charge); reserverGrisaille(charge);
}); });
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }]; const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
assert.deepEqual(avertissementsApres(avant, apres), [ assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } }, { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } },
]); ]);
}); });
}); });
describe('avertissements : la dérive nomme les personnes et les tables (§ 9)', () => {
test("une personne se nomme par son nom affiché, une table par son numéro ; celles que le geste retire, par la charge d'avant", () => {
const avant = avec(petiteSoiree(), (charge) => {
charge.tables[1].numero = 7;
charge.participants[1].prenom = 'Aubépine';
});
const supprimees = avec(avant, (charge) => {
charge.participants = charge.participants.filter(({ id }) => id !== 2);
charge.reservations = charge.reservations.filter(({ participant }) => participant !== 2);
charge.tables = charge.tables.filter(({ id }) => id !== 2);
});
const raisons = [
{ code: 'PARTICIPANT_SUPPRIME', participant: 2, personne: 'Aubépine Grisaille' },
{ code: 'TABLE_SUPPRIMEE', table: 2, numero: 7 },
];
const [derive] = avertissementsApres(avant, supprimees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
assert.deepEqual(derive.details.touchees, [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }]);
const ajoutees = avec(avant, (charge) => {
charge.participants.push(personne(5, 'Ancolie'));
charge.tables.push({ ...table(3, null), numero: 9 });
charge.prochainsIds.participant = 6;
charge.prochainsIds.table = 4;
});
const [nouvelle] = avertissementsApres(avant, ajoutees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
assert.deepEqual(nouvelle.details.touchees[0].raisons, [
{ code: 'PARTICIPANT_NON_PLACE', participant: 5, personne: 'Ancolie' },
{ code: 'TABLE_AJOUTEE', table: 3, numero: 9 },
]);
});
test("examenNomme nomme chaque raison de l'examen sur la charge, sans toucher l'examen reçu ; une personne ou une table que la charge n'a plus garde son identifiant", () => {
const charge = avec(petiteSoiree(), (copie) => {
copie.participants[2].exclu = true;
copie.participants.pop();
});
const examen = examiner(charge);
const copie = structuredClone(examen);
const nomme = examenNomme(examen, charge);
assert.deepEqual(examen, copie);
const attendues = [
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' },
{ code: 'PARTICIPANT_SUPPRIME', participant: 4 },
];
assert.deepEqual(nomme.retenu.raisons, attendues);
assert.deepEqual(nomme.derives.map(({ raisons }) => raisons), [attendues, attendues]);
assert.deepEqual(nomme.fautives, examen.fautives);
});
});
describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => { describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => {
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => { test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}', () => {
const avant = petiteSoiree(); const avant = petiteSoiree();
const apres = avec(avant, reserverGrisaille); const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [ assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]); ]);
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []); assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
}); });
test('la personne se nomme par son nom affiché, et la table par son numéro, distinct de son identifiant', () => {
const avant = avec(petiteSoiree(), (charge) => {
charge.tables[1].numero = 7;
charge.participants[1].prenom = 'Aubépine';
});
const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Aubépine Grisaille', numero: 7 } },
]);
});
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => { test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille); const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
const apres = avec(avant, (charge) => { const apres = avec(avant, (charge) => {
@ -242,7 +304,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
charge.evenement.etat = 'retenu'; charge.evenement.etat = 'retenu';
}); });
assert.deepEqual(avertissementsApres(avant, apres), [ assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]); ]);
}); });
@ -250,7 +312,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif); const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = [])); const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
assert.deepEqual(avertissementsApres(avant, apres), [ assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]); ]);
}); });
@ -321,3 +383,110 @@ describe('avertissements : les violations du retenu hors de la dérive (§ 14.12
assert.equal(invariantViole(incoherente), null); assert.equal(invariantViole(incoherente), null);
}); });
}); });
// Huit personnes, deux tables de quatre, un tour, sièges attribués : le
// retenu assied 1 à 4 aux sièges 1 à 4 de la table 1, et 5 à 8 à la table 2.
function soireeAuxSieges() {
const charge = creerCharge({ id: 'evt-sieges', nom: 'Soirée des Chaises', siegesParDefaut: 4, tours: 1 });
const ids = [1, 2, 3, 4, 5, 6, 7, 8];
charge.participants = ids.map((id) => personne(id, `Convive ${id}`));
charge.tables = [table(1, null), table(2, null)];
charge.reglages.attribuerSieges = true;
const plan = { tables: [1, 2], capacites: [4, 4], tours: 1, participants: ids, placement: [{ sieges: [[1, 2, 3, 4], [5, 6, 7, 8]], reserve: [] }] };
charge.propositions = [
{ id: 1, graine: 1, arret: 40, historique: 1, produitVersion: VERSION.affichee, siegesAttribues: true, ...structuredClone(plan) },
];
charge.retenu = { proposition: 1, siegesAttribues: true, ...structuredClone(plan) };
charge.prochainsIds = { participant: 9, table: 3, proposition: 2 };
charge.evenement.etat = 'retenu';
return charge;
}
const reserverAuSiege = (participant, idTable, siege) => (charge) =>
charge.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
const deCode = (code) => (avertissements) => avertissements.filter((avertissement) => avertissement.code === code);
describe("avertissements : la réservation que le retenu n'honore pas au siège (§ 5.8, § 14.12)", () => {
test('sièges attribués, une réservation au siège 3 que le retenu donne à un autre, la personne assise à la même table : RESERVATION_NON_HONOREE au siège, avec son occupant', () => {
const avant = soireeAuxSieges();
const apres = avec(avant, reserverAuSiege(2, 1, 3));
assert.deepEqual(avertissementsApres(avant, apres), [
{
code: 'RESERVATION_NON_HONOREE',
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: 3 },
},
]);
assert.deepEqual(invariantViole(apres), {
code: 'INVARIANT_VIOLE',
details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 1, tour: 1, siege: 3, occupant: 3 }] },
});
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
});
test("la chaise réservée vide : l'occupant est null", () => {
const avant = avec(soireeAuxSieges(), (charge) => {
charge.retenu.placement[0].sieges[0] = [1, 2, null, 4];
charge.retenu.placement[0].reserve = [3];
});
const apres = avec(avant, reserverAuSiege(2, 1, 3));
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), [
{
code: 'RESERVATION_NON_HONOREE',
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: null },
},
]);
});
test("tenue à son siège, réservée sans siège, ou sans attribution des sièges : rien au siège", () => {
const avant = soireeAuxSieges();
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(3, 1, 3))), []);
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(2, 1, null))), []);
const sansAttribution = avec(avant, (charge) => {
charge.reglages.attribuerSieges = false;
charge.retenu.siegesAttribues = false;
charge.propositions[0].siegesAttribues = false;
});
assert.deepEqual(avertissementsApres(sansAttribution, avec(sansAttribution, reserverAuSiege(2, 1, 3))), []);
assert.equal(invariantViole(avec(sansAttribution, reserverAuSiege(2, 1, 3))), null);
});
test('deux réservations au même siège : la première dans l’ordre canonique le tient, la seconde se lit sans siège', () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
reserverAuSiege(3, 1, 3)(charge);
reserverAuSiege(4, 1, 3)(charge);
});
assert.deepEqual(avertissementsApres(avant, apres), []);
});
});
describe("avertissements : la réservation d'une personne que le retenu ne place pas (§ 9, § 14.12)", () => {
test("une personne ajoutée après la retenue et réservée : RESERVATION_NON_HONOREE, en plus de la dérive qui la dit non placée ; INVARIANT_VIOLE la garde", () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
charge.participants.push(personne(9, 'Ancolie'));
charge.prochainsIds.participant = 10;
reserverAuSiege(9, 2, null)(charge);
});
const rendus = avertissementsApres(avant, apres);
assert.deepEqual(codes(rendus), ['PLACES_MANQUANTES', 'DERIVE_NOUVELLE', 'RESERVATION_NON_HONOREE']);
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(rendus)[0].details, {
participant: 9,
table: 2,
tour: 1,
personne: 'Ancolie',
numero: 2,
});
assert.deepEqual(invariantViole(apres).details.violations, [{ code: 'RESERVATION', participant: 9, table: 2, tour: 1 }]);
});
test("non réservée, la personne ajoutée ne relève que de la dérive", () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
charge.participants.push(personne(9, 'Ancolie'));
charge.prochainsIds.participant = 10;
});
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), []);
assert.equal(invariantViole(apres), null);
});
});

View file

@ -9,7 +9,7 @@
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne // controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
// modifie ce qu'elle reçoit. // modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js'; import { ordreDesReservations } from '../csv/import.js';
import { capacite } from '../stockage/document.js'; import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js'; import { controlerCapacites } from './capacite.js';
import { presents } from './places.js'; import { presents } from './places.js';
@ -46,8 +46,10 @@ const identifiants = (placements, touche) =>
* Chaque table dont la capacité change se contrôle comme une saisie au champ * 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, * (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
* details}, par ordre de charge.tables ; avertissements, de même, ceux des * details}, par ordre de charge.tables ; avertissements, de même, ceux des
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour * autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places, ou plus de
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null. * SIEGES_MAX, refusent pour l'événement entier, qu'une table suive le
* défaut ou non : un seul refus, MOINS_DE_DEUX ou TROP_DE_PLACES {max},
* table null.
* *
* @param {import('../stockage/types.js').Charge} charge * @param {import('../stockage/types.js').Charge} charge
* @param {number} sieges * @param {number} sieges
@ -64,10 +66,11 @@ export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}
const changements = new Map( const changements = new Map(
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]), charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
); );
const controle = const pourLEvenement = (code, details) => ({ refus: [{ table: null, code, details: { ...details, remede: null } }], avertissements: [] });
sieges < 2 let controle;
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] } if (sieges < 2) controle = pourLEvenement('MOINS_DE_DEUX', {});
: controlerCapacites(charge, changements, { origine: 'champ' }); else if (sieges > SIEGES_MAX) controle = pourLEvenement('TROP_DE_PLACES', { max: SIEGES_MAX });
else controle = controlerCapacites(charge, changements, { origine: 'champ' });
return { return {
suivent: charge.tables.filter((table) => table.sieges === null).length, suivent: charge.tables.filter((table) => table.sieges === null).length,
avant: charge.evenement.siegesParDefaut, avant: charge.evenement.siegesParDefaut,

View file

@ -8,7 +8,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { nombrePlaces, normaliser } from '../moteur/configuration.js'; import { nombrePlaces, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js'; import { SIEGES_MAX, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js'; import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
import { COMMANDES } from './commandes.js'; import { COMMANDES } from './commandes.js';
@ -125,6 +125,15 @@ describe('bilans : changer le défaut (§ 6.1)', () => {
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]); assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
}); });
test("au-delà de SIEGES_MAX, un seul refus pour l'événement, TROP_DE_PLACES, même quand aucune table ne suit le défaut", () => {
const refus = [{ table: null, code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } }];
const suivent = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX + 1).refus, refus);
const surchargees = chargeDe({ tables: [{ id: 1, sieges: 6 }], n: 4 });
assert.deepEqual(bilanChangementDefaut(surchargees, SIEGES_MAX + 1).refus, refus);
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX).refus, []);
});
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => { 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 charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang); const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);

View file

@ -9,7 +9,8 @@
// //
// Un refus a la forme { code, details }, le remède dans details.remede. Ils // 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 : // se contrôlent dans cet ordre, et le premier seul est rendu :
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de // MOINS_DE_DEUX ; TROP_DE_PLACES, au-delà de SIEGES_MAX, la borne que
// l'analyse du fichier pose aussi ; SOUS_RESERVATIONS, sous le plus grand compte de
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui // reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
// porte un titre ou une réservation, suspendue comprise — l'analyse du // 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 // fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
@ -37,7 +38,7 @@
// fonction ne modifie ce qu'elle reçoit. // fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js'; import { ordreDesReservations } from '../csv/import.js';
import { constante } from '../geometrie/constantes.js'; import { constante } from '../geometrie/constantes.js';
import { capacite } from '../stockage/document.js'; import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js'; import { fautes } from '../stockage/placements.js';
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js'; import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
import { reservationsParTour } from './pose.js'; import { reservationsParTour } from './pose.js';
@ -148,6 +149,7 @@ function occupation(charge, idTable) {
// Le premier refus d'une capacité, ou null. // Le premier refus d'une capacité, ou null.
function refusDeCapacite(charge, idTable, sieges, origine) { function refusDeCapacite(charge, idTable, sieges, origine) {
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null); if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
if (sieges > SIEGES_MAX) return refusDe('TROP_DE_PLACES', { max: SIEGES_MAX }, null);
const reservees = maximum(reservationsParTour(charge, idTable)); const reservees = maximum(reservationsParTour(charge, idTable));
if (sieges < reservees.n) { if (sieges < reservees.n) {
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour)); return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
@ -379,7 +381,8 @@ export function controlerCapacites(charge, changements, { origine }) {
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de * verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
* SEUIL_SIEGES_LISIBLES places. * SEUIL_SIEGES_LISIBLES places.
* *
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n, * Refus : MOINS_DE_DEUX {}, remède null ; TROP_DE_PLACES {max}, au-delà de
* SIEGES_MAX, remède null ; SOUS_RESERVATIONS {table, n,
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n, * tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
* remède { geste: 'afficherTour', tour, proposition: null } ; * remède { geste: 'afficherTour', tour, proposition: null } ;
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un * SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un

View file

@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { constante } from '../geometrie/constantes.js'; import { constante } from '../geometrie/constantes.js';
import { serialiser } from '../stockage/canonique.js'; import { serialiser } from '../stockage/canonique.js';
import { analyser, creerCharge } from '../stockage/document.js'; import { SIEGES_MAX, analyser, creerCharge } from '../stockage/document.js';
import { controlerCapacite, lireEntier } from './capacite.js'; import { controlerCapacite, lireEntier } from './capacite.js';
import { COMMANDES } from './commandes.js'; import { COMMANDES } from './commandes.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
@ -96,6 +96,17 @@ describe("capacité : les refus d'une capacité (§ 6.2)", () => {
assert.equal(champ(charge, 2).refus, null); assert.equal(champ(charge, 2).refus, null);
}); });
test('au-delà de SIEGES_MAX places : TROP_DE_PLACES {max}, remède null, au champ comme à la poignée ; SIEGES_MAX passe, et la charge se relit', () => {
const charge = chargeUneTable();
for (const sieges of [SIEGES_MAX + 1, 99_999_999_999, 2 ** 32]) {
assert.deepEqual(champ(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
assert.deepEqual(poignee(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
}
assert.equal(champ(charge, SIEGES_MAX).refus, null);
const { charge: rendue } = COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: String(SIEGES_MAX), origine: 'champ' });
assert.doesNotThrow(() => analyser(serialiser(rendue, { revision: 1, produitVersion: 'essai' })));
});
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => { 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)) }); const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
assert.deepEqual(champ(cinq, 4).refus, { assert.deepEqual(champ(cinq, 4).refus, {

View file

@ -22,7 +22,7 @@
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer 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. // table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre } from '../geometrie/tables.js'; import { caseLibre } from '../geometrie/tables.js';
import { SCHEMA, capacite } from '../stockage/document.js'; import { SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js'; import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js'; import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js'; import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
@ -91,8 +91,9 @@ function exigerCapacite(charge, id, sieges, origine) {
/** /**
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant * 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 * 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 * case libre (caseLibre) de la grille des démonstrations, au pas que donnent
* capacité et une liste vide à chaque tour. * les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu
* lisible, elle y entre avec sa capacité et une liste vide à chaque tour.
*/ */
function creerTable(charge, { forme } = {}) { function creerTable(charge, { forme } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) {
const id = charge.prochainsIds.table; const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' }); if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1; const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
const position = caseLibre(charge.tables.map((table) => table.position)); const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) }));
const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut });
const suivante = structuredClone(charge); const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position }); suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1; suivante.prochainsIds.table = id + 1;
@ -212,7 +214,8 @@ function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
/** /**
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par * 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 * lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
* détruit les réservations « tour désigné » au-delà, dans la même entrée ; * 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 * la portée « tous » reste, symbolique. Propositions et retenu restent, en
* dérive TOURS_CHANGES, jamais tronqués. * dérive TOURS_CHANGES, jamais tronqués.
@ -221,6 +224,7 @@ function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
const tours = entierSaisi(saisie); const tours = entierSaisi(saisie);
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours }); if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
if (tours > TOURS_MAX) throw refus('TROP_DE_TOURS', { max: TOURS_MAX });
const suivante = structuredClone(charge); const suivante = structuredClone(charge);
suivante.evenement.tours = tours; suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours); suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);

View file

@ -11,12 +11,15 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { caseLibre } from '../geometrie/tables.js'; import { texteLivre } from '../demo/livrees.js';
import { constante } from '../geometrie/constantes.js';
import { caseLibre, geometrieTable } from '../geometrie/tables.js';
import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js'; import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js'; import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js'; import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js'; import { SIEGES_MAX, TOURS_MAX, analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js'; import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js'; import { SUFFIXES } from '../stockage/noms.js';
import { derive, examiner, fautes } from '../stockage/placements.js'; import { derive, examiner, fautes } from '../stockage/placements.js';
@ -118,6 +121,45 @@ function seNormalise(charge) {
} }
} }
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le
// dessin la mesure au navigateur.
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
// Les fautes « une liste passe sur une autre table » d'une charge : pour
// chaque paire ordonnée de tables, la bande du bloc de noms de la première —
// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de
// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la
// seconde, bords exclus. Avec avec, seules les paires où figure la table
// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires
// examinées.
function fautesBandes(charge, { avec = null } = {}) {
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) }));
const fautes = [];
let paires = 0;
for (const { g, places } of geometries) {
const bande = {
gauche: g.origineListe.x,
droite: g.origineListe.x + largeur,
haut: g.origineListe.y,
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
};
for (const { g: autre } of geometries) {
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
paires += 1;
const e = autre.rayonEmprise;
const recoupe =
autre.centre.x + e > bande.gauche &&
autre.centre.x - e < bande.droite &&
autre.centre.y + e > bande.haut &&
autre.centre.y - e < bande.bas;
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
}
}
return { fautes, paires };
}
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue // 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. // se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) { function accepter(nom, charge, args) {
@ -222,6 +264,56 @@ describe('mobilier : le registre (§ 8.2)', () => {
}); });
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => { describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
test('une table créée se pose sur la grille des démonstrations : le pas tient compte des emprises et du bloc de noms, aucune liste ne passe sur une autre table', () => {
let charge = chargeDe();
for (let i = 0; i < 5; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
// Rondes de 8 : colonnes à 813 cm, rangées à 300 (ecartsGrille).
assert.deepEqual(
charge.tables.map(({ position }) => [position.x, position.y]),
[[0, 0], [813, 0], [0, 300], [813, 300], [1626, 0]],
);
// La bande du bloc de noms de chaque table, de toute sa largeur, ne
// recoupe l'emprise d'aucune autre.
assert.deepEqual(fautesBandes(charge), { fautes: [], paires: 5 * 4 });
// Une table passée à 16 places : le pas devient 966 × 430 ; toute case
// à moins d'un pas d'une table posée, sur les deux axes, est prise, la
// suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
// table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
// la liste de sa voisine, ce que la création ne change pas.
charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge;
charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
});
test('dans la grande démonstration, la table 1 passée à 16 places : la table créée au pas 966 × 430 évite toute liste, (2898, 1720)', () => {
// La case (4830, 0) est à 765 cm de la table (4065, 0), dont la liste,
// de 4221,4 à 4721,4, couvrirait la table nouvelle, de 4709,6 à 4950,4 :
// elle est prise, comme toute case à moins d'un pas d'une table posée.
// La rangée 1720 est à 220 cm de la dernière rangée, de trois tables en
// 0, 813 et 1626 : la case (2898, 1720) est la première libre. Seules
// les paires où figure la table créée sont examinées : la table 1
// agrandie porte sa liste sur sa voisine, ce que la création ne change
// pas.
let charge = analyser(texteLivre('grande')).charge;
const premiere = charge.tables.find(({ numero }) => numero === 1);
assert.deepEqual(premiere.position, { x: 0, y: 0 });
charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge;
charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
const creee = charge.tables.at(-1).id;
assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
});
test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
// 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
// trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
const grande = analyser(texteLivre('grande')).charge;
assert.equal(grande.tables.length, 33);
const { charge } = accepter('creerTable', grande, { forme: 'ronde' });
assert.deepEqual(charge.tables.at(-1).position, { x: 2439, y: 1500 });
});
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => { 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(); let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge; for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
@ -234,7 +326,10 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
numero: 3, numero: 3,
sieges: null, sieges: null,
forme: 'carree', forme: 'carree',
position: caseLibre(charge.tables.map(({ position }) => position)), position: caseLibre(
charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })),
{ forme: 'carree', places: charge.evenement.siegesParDefaut },
),
}); });
assert.equal(cree.charge.prochainsIds.table, 5); assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } }); const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
@ -272,6 +367,20 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
assert.equal(capacite(dix, dix.tables[0]), 10); assert.equal(capacite(dix, dix.tables[0]), 10);
}); });
test('une table créée se pose au pas de sa propre capacité, le défaut : sous un défaut de 16, à côté d’une ronde de 8, en (966, 0) et non en (813, 0)', () => {
// Une ronde de 8 posée en (0, 0) : la grille a deux colonnes, et la case
// (0, 0) est prise. Le pas se calcule sur la plus grande emprise, celle
// de la table nouvelle à 16 places : 966 cm entre colonnes, là où deux
// rondes de 8 n'en demandent que 813.
const charge = chargeDe({ tables: [{ id: 1, sieges: 8 }], defaut: 16 });
charge.tables[0].position = { x: 0, y: 0 };
const { charge: apres } = accepter('creerTable', charge, { forme: 'ronde' });
const creee = apres.tables.at(-1);
assert.equal(capacite(apres, creee), 16);
assert.deepEqual(creee.position, { x: 966, y: 0 });
assert.deepEqual(fautesBandes(apres), { fautes: [], paires: 2 });
});
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => { 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 charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]]; const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
@ -628,7 +737,11 @@ describe("mobilier : les places d'une table, par le champ et par la poignée (§
}); });
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } })); const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
assert.deepEqual(avertissements, surnumeraires, nom); assert.deepEqual(avertissements, surnumeraires, nom);
assert.equal(invariantViole(apres), null, nom); // La réduction n'ajoute aucune violation : un siège réservé que le
// retenu donnait déjà à un autre peut le rester, rien d'autre.
const violations = (etat) => (invariantViole(etat)?.details.violations ?? []).map((v) => JSON.stringify(v));
const deja = violations(charge);
assert.deepEqual(violations(apres).filter((v) => !deja.includes(v)), [], nom);
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne. // 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(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
assert.deepEqual(fautes(apres.retenu), [], nom); assert.deepEqual(fautes(apres.retenu), [], nom);
@ -833,6 +946,29 @@ describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
assert.equal(ecritures(), avant); assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.tours, 2); assert.equal(seance.charge.evenement.tours, 2);
}); });
test('au-delà de TOURS_MAX : TROP_DE_TOURS {max}, remède null, sans écriture ; TOURS_MAX passe', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
for (const tours of [String(TOURS_MAX + 1), '99999999999']) {
await refuseAsync(seance.executer('changerTours', { tours }), 'TROP_DE_TOURS', { max: TOURS_MAX, remede: null });
}
assert.equal(ecritures(), avant);
await seance.executer('changerTours', { tours: String(TOURS_MAX) });
assert.equal(seance.charge.evenement.tours, TOURS_MAX);
});
test('au-delà de SIEGES_MAX, le défaut comme une table refusent TROP_DE_PLACES {max}, remède null, sans rien écrire', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
await refuseAsync(seance.executer('changerDefaut', { sieges: '1000000', realigner: false }), 'TROP_DE_PLACES', { max: SIEGES_MAX, remede: null });
await refuseAsync(
seance.executer('changerPlacesTable', { id: 1, sieges: '99999999999', origine: 'champ' }),
'TROP_DE_PLACES',
{ max: SIEGES_MAX, remede: null },
);
assert.equal(ecritures(), avant);
});
}); });
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => { describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {

View file

@ -27,9 +27,9 @@
// retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente, // retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente,
// celle d'une dérive (§ 9), ne bloque rien. // celle d'une dérive (§ 9), ne bloque rien.
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js'; import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
import { verifierInvariants } from '../moteur/verification.js';
import { capacite } from '../stockage/document.js'; import { capacite } from '../stockage/document.js';
import { fautes, planDepuisFichier } from '../stockage/placements.js'; import { fautes } from '../stockage/placements.js';
import { violationsDuRetenu } from './avertissements.js';
import { refus } from './erreurs.js'; import { refus } from './erreurs.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
import { conversionsPossibles } from './listes_travail.js'; import { conversionsPossibles } from './listes_travail.js';
@ -418,35 +418,57 @@ function rangDeTable(retenu, idTable, places) {
return retenu.tables.length - 1; return retenu.tables.length - 1;
} }
// Clé d'une violation : ses champs, dans un ordre fixe. // Clé d'une violation : ses champs, dans un ordre fixe ; l'occupant d'un
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]); // siège réservé n'y entre pas, la réservation non tenue reste la même.
const cleDeViolation = ({ code, participant, table, tour, detail, siege }) =>
JSON.stringify([code, participant, table, tour, detail, siege]);
// Les violations de verifierInvariants que suivant ajoute au retenu // Les violations du retenu (violationsDuRetenu, celles de verifierInvariants,
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la // la réserve étant une place du plan, et les réservations non tenues qu'il
// ne relève pas) que suivant ajoute au retenu precedent : celles dont la
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque // clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
// personne une fois, et verifierInvariants n'y rend jamais deux violations // personne une fois, et n'y rend jamais deux violations de même clé. []
// de même clé. [] quand la configuration ne se normalise pas. // quand la configuration ne se normalise pas.
function violationsNouvelles(charge, precedent, suivant) { function violationsNouvelles(charge, precedent, suivant) {
const instance = instanceDe(charge); const instance = instanceDe(charge);
if (instance === null) return []; if (instance === null) return [];
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true }); const juger = (retenu) => violationsDuRetenu(charge, retenu, instance);
const connues = new Set(juger(precedent).map(cleDeViolation)); const connues = new Set(juger(precedent).map(cleDeViolation));
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation))); return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
} }
// La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute // La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute
// une violation au retenu reçu. Le versement en réserve des personnes que le // une violation au retenu reçu, son remède celui que remedeDe(violations)
// plan n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute // rend, null par défaut. Le versement en réserve des personnes que le plan
// n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
// aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle // aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle
// réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2). // réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2).
function retouche(charge, suivante) { function retouche(charge, suivante, remedeDe = () => null) {
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu); const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
if (violations.length > 0) { if (violations.length > 0) {
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations }); throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations }, remedeDe(violations));
} }
return suivante; return suivante;
} }
// Le remède d'un retrait du tour que la réservation de la personne à sa
// table interdit : le retrait de cette réservation, la première dans l'ordre
// canonique qui tient la personne à la table de la violation au tour, « tous
// les tours » avant « ce tour ». null quand aucune violation RESERVATION ne
// nomme la personne.
function remedeDuRetrait(charge, participant, tour, violations) {
const violation = violations.find((v) => v.code === 'RESERVATION' && v.participant === participant);
if (violation === undefined) return null;
const [tenue] = charge.reservations
.filter((r) => r.participant === participant && r.table === violation.table && (r.portee === 'tous' || r.tour === tour))
.sort(ordreDesReservations);
if (tenue === undefined) return null;
return {
commande: 'retirerReservation',
arguments: { participant, table: tenue.table, portee: tenue.portee, tour: tenue.tour },
};
}
// Le numéro de la table où le retenu place id à ce tour, null en réserve, // Le numéro de la table où le retenu place id à ce tour, null en réserve,
// hors du retenu, ou à une table que la charge n'a plus. // hors du retenu, ou à une table que la charge n'a plus.
function numeroDuLieu(charge, tour, id) { function numeroDuLieu(charge, tour, id) {
@ -458,8 +480,9 @@ function numeroDuLieu(charge, tour, id) {
/** /**
* deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) : * deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) :
* la personne s'assied à cette table au tour donné, et sa place d'avant se * la personne s'assied à cette table au tour donné, et sa place d'avant se
* libère. Sièges attribués, au siège donné — sans siège, la première chaise * libère. Sièges attribués, au siège donné — sans siège, celui que sa
* libre —, les rangs sautés à null ; la chaise d'un présent refuse * réservation lui tient à cette table, sinon la première chaise libre que
* ne tient pas la réservation d'un autre —, les rangs sautés à null ; la chaise d'un présent refuse
* SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la * SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la
* charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste * charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste
* triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine * triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine
@ -498,8 +521,25 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
const liste = etage.sieges[rangTable]; const liste = etage.sieges[rangTable];
let cible = siege; let cible = siege;
if (cible === null) { if (cible === null) {
// Sans siège, la chaise que la réservation de la personne lui tient à
// cette table, sinon la première chaise libre — vide ou d'un absent —
// que ne tient pas la réservation active d'un autre : le contrôle de
// pose ne juge que le siège donné.
const tenues = siegesTenus(charge, tour, idTable);
const sienne = [...tenues.keys()].find((numero) => tenues.get(numero) === participant);
const libre = (numero) => {
const assis = liste[numero - 1] ?? null;
return (assis === null || !ids.has(assis)) && (!tenues.has(numero) || tenues.get(numero) === participant);
};
if (sienne !== undefined && (liste[sienne - 1] ?? null) === participant) {
throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
}
if (sienne !== undefined && libre(sienne)) {
cible = sienne;
} else {
cible = 1; cible = 1;
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1; while (cible <= places && !libre(cible)) cible += 1;
}
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places)); if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
} }
const occupant = liste[cible - 1] ?? null; const occupant = liste[cible - 1] ?? null;
@ -553,6 +593,13 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
}; };
} }
// Les sièges de la table idTable que tient au tour une réservation active,
// tels que le plan les montre (etatPlaces) : Map siège → titulaire.
function siegesTenus(charge, tour, idTable) {
const { chaises } = etatPlaces(charge, { tour }).tables.find(({ id }) => id === idTable);
return new Map(chaises.filter(({ reservation }) => reservation !== null).map(({ siege, reservation }) => [siege, reservation.participant]));
}
// Les personnes que le plan du tour montre assises (etatPlaces, source le // Les personnes que le plan du tour montre assises (etatPlaces, source le
// retenu). Une place du retenu à une table que la charge n'a plus, ou à un // retenu). Une place du retenu à une table que la charge n'a plus, ou à un
// rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme // rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme
@ -677,7 +724,9 @@ function echangerDansRetenu(charge, { tour, a, b } = {}) {
* tour donné et attend dans la réserve de ce tour ; sa chaise se libère. * tour donné et attend dans la réserve de ce tour ; sa chaise se libère.
* Une personne que le plan du tour ne montre pas assise (placeDe) — en * Une personne que le plan du tour ne montre pas assise (placeDe) — en
* réserve, hors du retenu, à une table que la charge n'a plus ou à un rang * réserve, hors du retenu, à une table que la charge n'a plus ou à un rang
* au-delà de sa capacité courante — refuse SANS_EFFET. * au-delà de sa capacité courante — refuse SANS_EFFET. Une personne que sa
* réservation tient à cette table refuse INVARIANT_VIOLE, remède le retrait
* de cette réservation.
*/ */
function retirerDuTour(charge, { tour, participant } = {}) { function retirerDuTour(charge, { tour, participant } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
@ -696,7 +745,7 @@ function retirerDuTour(charge, { tour, participant } = {}) {
oter(suivante.retenu, tour, participant); oter(suivante.retenu, tour, participant);
enReserve(suivante.retenu, tour, participant); enReserve(suivante.retenu, tour, participant);
return { return {
charge: retouche(charge, suivante), charge: retouche(charge, suivante, (violations) => remedeDuRetrait(charge, participant, tour, violations)),
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }), libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
avertissements: [], avertissements: [],
}; };

View file

@ -728,19 +728,31 @@ describe('commandes des places : le retenu et la dérive (§ 9, § 4.4)', () =>
}); });
describe('commandes des places : le vérificateur d’invariants du retenu (§ 14.12, § 14.15)', () => { describe('commandes des places : le vérificateur d’invariants du retenu (§ 14.12, § 14.15)', () => {
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, sans écriture', () => { test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, remède le retrait de la réservation', () => {
// Le retenu reçu porte déjà une violation : 7, réservé à la table 4 au // Le retenu reçu porte déjà une violation : 7, réservé à la table 4 au
// tour 2, y est assis à la table 7 ; elle n'entre pas dans le refus. // tour 2, y est assis à la table 7 ; elle n'entre pas dans le refus.
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null, 1), resa(7, 5, null, 2)] })); const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null, 1), resa(7, 5, null, 2)] }));
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', { const leve = refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
proposition: 1, proposition: 1,
violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }], violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }],
remede: null, remede: { commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee: 'tour', tour: 1 } },
}); });
// Le remède lève le refus : la réservation retirée, le retrait passe.
const { commande, arguments: argumentsDuRemede } = leve.details.remede;
const { charge: sansReservation } = executer(commande, charge, argumentsDuRemede);
const { charge: retire } = executer('retirerDuTour', sansReservation, { tour: 1, participant: 4 });
assert.deepEqual(reserveDe(retire, 1), [4]);
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 5 }); const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 5 });
assert.deepEqual(reserveDe(apres, 1), [5]); assert.deepEqual(reserveDe(apres, 1), [5]);
}); });
test('retirer du tour une personne réservée « tous les tours » à sa table : le remède désigne cette réservation', () => {
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null)] }));
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
remede: { commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee: 'tous', tour: null } },
});
});
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => { test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] }); const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
charge.participants.push(personne(13)); charge.participants.push(personne(13));
@ -752,6 +764,53 @@ describe('commandes des places : le vérificateur d’invariants du retenu (§ 1
}); });
}); });
describe('commandes des places : le dépôt sans siège et les réservations au siège (§ 5.9, § 8.9)', () => {
// À chaque tour, la table 4 assied 1, 2, 3 aux sièges 1 à 3, la table 7
// assied 4 à 11, et 12 attend en réserve.
const tours = [1, 2, 3].map(() => [[1, 2, 3], [4, 5, 6, 7, 8, 9, 10, 11]]);
test('sièges attribués, un dépôt sans siège saute la chaise que tient la réservation active d’un autre', () => {
const charge = figer(chargeRetenue({ tours, reservations: [resa(9, 5, 4)] }));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null });
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, 12]);
const chaise4 = etatPlaces(apres, { tour: 1 }).tables[0].chaises[3];
assert.deepEqual([chaise4.occupant, chaise4.reservation?.participant], [null, 9]);
});
test('sièges attribués, sans siège, la personne réservée à un siège de la table y va ; déjà assise sur lui, SANS_EFFET', () => {
const charge = figer(chargeRetenue({ tours, reservations: [resa(10, 5, 6)] }));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 10, table: 5, siege: null });
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, null, 10]);
refuse(() => executer('deplacerDansRetenu', apres, { tour: 1, participant: 10, table: 5, siege: null }), 'SANS_EFFET');
});
test('une personne ajoutée après la retenue et réservée à une table : déposée à cette table au tour 2, elle y est assise, sans INVARIANT_VIOLE', () => {
const charge = chargeRetenue();
charge.participants.push(personne(13));
charge.prochainsIds.participant = 14;
charge.reservations.push(resa(13, 5, null));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 13, table: 5, siege: null });
assert.deepEqual(listes(apres, 2)[0], [1, 8, 9, 10, 11, 12, 13]);
assert.deepEqual(reserveDe(apres, 1), [13]);
});
test('sièges attribués, désigner au siège 3 la personne assise au siège 2 : le retenu reste tel quel, RESERVATION_NON_HONOREE au siège, et son remède, l’échange, la lève', () => {
const charge = chargeRetenue();
const { charge: designee } = appliquerCommande('designer', charge, { participant: 2, table: 5, siege: 3, libelle: 'Animation' });
assert.deepEqual(designee.retenu, charge.retenu);
// Au tour 2, le retenu assied 2 à la table 7 : la réservation n'y est
// pas tenue à sa table ; aux tours 1 et 3, à la table 4, mais au siège 2.
const nonTenues = (avant, apres) =>
avertissementsApres(avant, apres)
.filter(({ code }) => code === 'RESERVATION_NON_HONOREE')
.map(({ details }) => [details.tour, details.siege ?? null, details.occupant ?? null]);
assert.deepEqual(nonTenues(charge, designee), [[2, null, null], [1, 3, 3], [3, 3, 3]]);
const { charge: echangee } = executer('echangerDansRetenu', designee, { tour: 1, a: 2, b: 3 });
assert.deepEqual(listes(echangee, 1)[0], [1, 3, 2, 4, 5, 6]);
assert.deepEqual(nonTenues(charge, echangee), [[2, null, null], [3, 3, 3]]);
});
});
describe('enregistrerGeneration : les sièges réservés dans une génération (§ 4, § 5.3, § 8.9)', () => { describe('enregistrerGeneration : les sièges réservés dans une génération (§ 4, § 5.3, § 8.9)', () => {
// Douze personnes, deux tables, Théo réservé « tous » au siège siege de la // Douze personnes, deux tables, Théo réservé « tous » au siège siege de la
// table 2, sièges attribués. // table 2, sièges attribués.
@ -882,6 +941,8 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']], ['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']], ['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']], ['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
// Le refus du dépôt sur le sol (src/interface/plan/depot.js), dont le texte vit ici.
['RESERVATION_TOUS_LES_TOURS', [{ participant: 1, table: 3 }, 'tous les tours']],
]); ]);
test('chaque code de la table des places a son texte, composé de ses détails', () => { test('chaque code de la table des places a son texte, composé de ses détails', () => {
@ -894,6 +955,12 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`); assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
} }
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours')); assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
// Le refus du dépôt sur le sol dit ce que le sol ne fait pas, sans rien
// promettre du glissement vers une autre chaise : deplacerReservation y
// garde la portée « tous », et la déplace à chaque tour.
const tous = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
assert.ok(tous.includes('hors des tables'), tous);
assert.ok(!tous.includes('ne change que'), tous);
assert.equal(libelle('places.titreParDefaut'), 'animateur'); assert.equal(libelle('places.titreParDefaut'), 'animateur');
}); });
}); });

View file

@ -10,9 +10,14 @@
// ← { type: 'avancement', fait, total } remis aux abonnés // ← { type: 'avancement', fait, total } remis aux abonnés
// ← { type: 'resultat', propositions } résout le résultat // ← { type: 'resultat', propositions } résout le résultat
// ← { type: 'erreur', code, details } rejette par ErreurGeneration // ← { type: 'erreur', code, details } rejette par ErreurGeneration
// ← { type: 'faute', message, pile } rejette par une Error, une faute
// ✕ canal d'erreur du travailleur rejette par TRAVAILLEUR_EN_ECHEC
//
// Le travailleur poste ses fautes de calcul en message : son canal d'erreur
// ne porte que l'échec de son script, qui ne se charge ou ne se compile pas.
// //
// Chaque lancer prend un travailleur neuf de la fabrique et le termine à // Chaque lancer prend un travailleur neuf de la fabrique et le termine à
// l'issue : résultat, erreur, faute du travailleur ou annulation. Terminé, // l'issue : résultat, erreur, faute, échec du script ou annulation. Terminé,
// le travailleur ne remet plus rien : un message déjà en file à cet instant // 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(), // 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 // dans le même tour : immédiate, sans résultat partiel. La fabrique est un
@ -32,8 +37,14 @@ import { ErreurAnnulee, ErreurGeneration } from './generation.js';
* lancer(configuration, reglages) rend une Execution : avancement → * lancer(configuration, reglages) rend une Execution : avancement →
* surAvancement ; resultat → la promesse résolue aux propositions reçues, * surAvancement ; resultat → la promesse résolue aux propositions reçues,
* puis terminate() ; erreur {code, details} → rejet par * puis terminate() ; erreur {code, details} → rejet par
* ErreurGeneration(code, details) ; faute du travailleur, son canal * ErreurGeneration(code, details) ; faute {message, pile} → rejet par
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ; * une Error sans code, dont le message porte celui du travailleur et la
* pile la sienne : elle remonte comme une faute, comme sous l'exécuteur
* direct ; canal d'erreur du travailleur — son script ne se charge ou ne
* se compile pas —, → rejet par ErreurGeneration('TRAVAILLEUR_EN_ECHEC',
* { remede: null }), l'événement traité : aucun geste de la page ne
* relance un travailleur que le navigateur refuse, et le texte du refus
* nomme sa cause (§ 5.9) ;
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun * annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
* avancement. Après l'issue, annuler() ne fait rien. * avancement. Après l'issue, annuler() ne fait rien.
*/ */
@ -69,11 +80,22 @@ export function creerExecuteurTravailleur(fabrique) {
} else if (data.type === 'erreur') { } else if (data.type === 'erreur') {
rejeter(new ErreurGeneration(data.code, data.details)); rejeter(new ErreurGeneration(data.code, data.details));
terminer(); terminer();
} else if (data.type === 'faute') {
const faute = new Error(`travailleur de génération : ${data.message}`);
faute.stack = `${faute.stack}\n${data.pile}`;
rejeter(faute);
terminer();
} }
}; };
// Le script du travailleur ne se charge ou ne se compile pas : le refus
// TRAVAILLEUR_EN_ECHEC, que la commande « Générer » pose à côté
// d'elle, jamais le message du moteur d'exécution. L'événement est
// traité : le navigateur ne le signale pas en plus comme une faute de
// la page. Une faute du calcul arrive en message, jamais ici.
travailleur.onerror = (evenement) => { travailleur.onerror = (evenement) => {
evenement?.preventDefault?.();
if (termine) return; if (termine) return;
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`)); rejeter(new ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null }));
terminer(); terminer();
}; };
travailleur.postMessage({ type: 'lancer', configuration, reglages }); travailleur.postMessage({ type: 'lancer', configuration, reglages });

View file

@ -158,17 +158,51 @@ describe('exécuteur du travailleur (§ 5.10)', () => {
assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus'); 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 () => { test('le canal d’erreur du travailleur — un script qui ne se charge ou ne se compile pas : rejet par ErreurGeneration TRAVAILLEUR_EN_ECHEC, remède null, sans le message ; l’événement est traité, le travailleur terminé', async () => {
const { fabrique, travailleurs } = fabriqueFactice(); const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES); const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const [travailleur] = travailleurs; const [travailleur] = travailleurs;
assert.equal(typeof travailleur.onerror, 'function'); assert.equal(typeof travailleur.onerror, 'function');
travailleur.onerror({ message: 'faute d’épreuve' }); const evenement = {
message: 'faute d’épreuve',
traite: false,
preventDefault() {
this.traite = true;
},
};
travailleur.onerror(evenement);
const lue = await issue(execution.resultat); const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee'); assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof Error); assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
assert.equal(lue.erreur.code, 'TRAVAILLEUR_EN_ECHEC');
assert.deepEqual(lue.erreur.details, { remede: null });
assert.doesNotMatch(JSON.stringify(lue.erreur.details), /épreuve/);
assert.equal(evenement.traite, true);
assert.equal(travailleur.terminaisons, 1);
// Un événement d'erreur sans preventDefault, comme le rendrait une portée factice, ne lève pas.
const seconde = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
travailleurs[1].onerror({});
assert.equal((await issue(seconde.resultat)).erreur.code, 'TRAVAILLEUR_EN_ECHEC');
});
test('une faute pendant le calcul, postée { type: faute, message, pile } : rejet par une Error sans code, qui porte le message et la pile du travailleur ; aucun refus, le travailleur terminé, la suite ignorée', 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;
travailleur.poster({ type: 'faute', message: 'TypeError: invariant d’épreuve rompu', pile: 'TypeError: invariant d’épreuve rompu\n at calcul (épreuve.js:1:1)' });
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
travailleur.poster({ type: 'resultat', propositions: [] });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof Error, String(lue.erreur));
assert.ok(!(lue.erreur instanceof ErreurGeneration), 'une faute n’est pas un refus');
assert.ok(!(lue.erreur instanceof ErreurAnnulee));
assert.equal(lue.erreur.code, undefined); assert.equal(lue.erreur.code, undefined);
assert.match(lue.erreur.message, /faute d’épreuve/); assert.match(lue.erreur.message, /TypeError: invariant d’épreuve rompu/);
assert.match(lue.erreur.stack, /at calcul \(épreuve\.js:1:1\)/);
assert.deepEqual(avancements, []);
assert.equal(travailleur.terminaisons, 1); assert.equal(travailleur.terminaisons, 1);
}); });
}); });

View file

@ -16,12 +16,17 @@
// versErreurGeneration, le code et // versErreurGeneration, le code et
// les détails d'une // les détails d'une
// ErreurConfiguration // ErreurConfiguration
// ← { type: 'faute', message, pile } une erreur que versErreurGeneration
// ne convertit pas : une faute de
// programmation, son texte et sa
// pile, que l'exécuteur relève
// comme une faute
// //
// L'annulation est terminate() du côté de l'interface : immédiate, sans // 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 // résultat partiel ; le travailleur ne lit aucun signal. Un message d'un
// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit // autre type ne lance rien. Le gestionnaire ne lève jamais : le canal
// pas est relevée, et le travailleur la signale par son propre canal // d'erreur du travailleur ne porte ainsi que l'échec de son script — qui ne
// d'erreur. // se charge ou ne se compile pas —, et l'exécuteur ne confond pas les deux.
import { rechercher } from '../moteur/recherche.js'; import { rechercher } from '../moteur/recherche.js';
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js'; import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
@ -42,16 +47,19 @@ export function brancherTravailleur(portee) {
progressions += 1; progressions += 1;
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total }); if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
}; };
let propositions;
try { try {
exigerReglages(data.reglages); exigerReglages(data.reglages);
propositions = rechercher(data.configuration, data.reglages, { progression }); const propositions = rechercher(data.configuration, data.reglages, { progression });
portee.postMessage({ type: 'resultat', propositions });
} catch (erreur) { } catch (erreur) {
const convertie = versErreurGeneration(erreur); const convertie = versErreurGeneration(erreur);
if (!(convertie instanceof ErreurGeneration)) throw convertie; if (convertie instanceof ErreurGeneration) {
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details }); portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
return; } else {
const message = String(convertie);
const pile = typeof convertie?.stack === 'string' ? convertie.stack : message;
portee.postMessage({ type: 'faute', message, pile });
}
} }
portee.postMessage({ type: 'resultat', propositions });
}; };
} }

View file

@ -57,6 +57,18 @@ describe('travailleur de génération (§ 5.10)', () => {
assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]); assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]);
}); });
test('une faute que versErreurGeneration ne convertit pas poste { type: faute, message, pile }, sans lever : le canal d’erreur du travailleur reste au script qui ne se charge pas', () => {
const { postes, envoyer } = porteeFactice();
assert.doesNotThrow(() => envoyer({ type: 'lancer', configuration: null, reglages: { graine: 1, nombre: 1, arret: 10 } }));
assert.equal(postes.length, 1);
const [faute] = postes;
assert.deepEqual(Object.keys(faute).sort(), ['message', 'pile', 'type']);
assert.equal(faute.type, 'faute');
assert.match(faute.message, /^TypeError: /);
assert.equal(typeof faute.pile, 'string');
assert.match(faute.pile, /TypeError/);
});
test("un message d'un autre type ne lance rien", () => { test("un message d'un autre type ne lance rien", () => {
const { postes, envoyer } = porteeFactice(); const { postes, envoyer } = porteeFactice();
envoyer({ type: 'inconnu' }); envoyer({ type: 'inconnu' });

View file

@ -36,9 +36,11 @@ import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url)); const RACINE = fileURLToPath(new URL('../..', import.meta.url));
// Les arbres balayés, et les fichiers qui portent une table de codes. // Les arbres balayés, et les fichiers qui portent une table de codes : le
// dépôt du plan lève un refus que la commande n'a pas à lever, et son texte
// vit ici, avec ceux des commandes des places.
const ARBRES = ['src/stockage', 'src/csv', 'src/application']; const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js']; const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js', 'src/interface/plan/depot.js'];
// L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce // L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce
// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre. // qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre.
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js'); const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
@ -411,6 +413,7 @@ const EXEMPLES = new Map([
['DEPASSEMENT', { index: 4, participant: 9 }], ['DEPASSEMENT', { index: 4, participant: 9 }],
// Génération // Génération
['GENERATION_EN_COURS', { remede: null }], ['GENERATION_EN_COURS', { remede: null }],
['TRAVAILLEUR_EN_ECHEC', { remede: null }],
['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }], ['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }],
['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }], ['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
[ [
@ -493,6 +496,7 @@ const EXEMPLES = new Map([
['PROPOSITION_MONTREE', { proposition: 4, remede: null }], ['PROPOSITION_MONTREE', { proposition: 4, remede: null }],
['SIEGE_OCCUPE', { table: 5, siege: 2, tour: 1, occupant: 2, remede: null }], ['SIEGE_OCCUPE', { table: 5, siege: 2, tour: 1, occupant: 2, remede: null }],
['TABLE_PLEINE', { table: 5, tour: 1, capacite: 6, remede: null }], ['TABLE_PLEINE', { table: 5, tour: 1, capacite: 6, remede: null }],
['RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null }],
['ENTREE_RESERVER', { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }], ['ENTREE_RESERVER', { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }],
['ENTREE_RETIRER_RESERVATION', { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }], ['ENTREE_RETIRER_RESERVATION', { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }],
['ENTREE_DEPLACER_RESERVATION', { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }], ['ENTREE_DEPLACER_RESERVATION', { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }],
@ -507,6 +511,8 @@ const EXEMPLES = new Map([
['NON_ENTIER', { saisie: '8abc', remede: null }], ['NON_ENTIER', { saisie: '8abc', remede: null }],
['MOINS_DE_DEUX', { remede: null }], ['MOINS_DE_DEUX', { remede: null }],
['MOINS_D_UN_TOUR', { tours: 0, remede: null }], ['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
['TROP_DE_PLACES', { max: 1000, remede: null }],
['TROP_DE_TOURS', { max: 100, remede: null }],
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: 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 } }], ['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
[ [
@ -593,6 +599,53 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
); );
}); });
test('RESERVATION_NON_HONOREE nomme la personne et le numéro de la table quand ils sont donnés, sinon leurs identifiants', () => {
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3 }),
"Le placement retenu n'honore pas la réservation de Iris Ombrelle à la table 3 au tour 1 : il la place ailleurs.",
);
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }),
"Le placement retenu n'honore pas la réservation de la personne n° 4 à la table d'identifiant 5 au tour 1 : il la place ailleurs.",
);
});
test('RESERVATION_NON_HONOREE au siège nomme le siège et ce qu’il porte', () => {
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3, siege: 2, occupant: 7 }),
"Le placement retenu n'honore pas la réservation de Iris Ombrelle au siège 2 de la table 3 au tour 1 : " +
"il l'assied à un autre siège de cette table, et donne le siège 2 à une autre personne.",
);
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, siege: 2, occupant: null }),
"Le placement retenu n'honore pas la réservation de la personne n° 4 au siège 2 de la table d'identifiant 5 au tour 1 : " +
"il l'assied à un autre siège de cette table, et laisse le siège 2 vide.",
);
});
test('une raison de dérive nomme la personne et la table quand ses détails les portent, sinon leurs identifiants', () => {
const nommees = [
['PARTICIPANT_EXCLU', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle est exclue depuis.'],
['PARTICIPANT_SUPPRIME', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle a été supprimée depuis.'],
[
'PARTICIPANT_NON_PLACE',
{ participant: 14, personne: 'Iris Ombrelle' },
"Iris Ombrelle, ajoutée ou réintégrée depuis, n'y est pas placée.",
],
['TABLE_SUPPRIMEE', { table: 9, numero: 3 }, 'La table 3 a été supprimée depuis.'],
['TABLE_AJOUTEE', { table: 9, numero: 3 }, 'La table 3 a été ajoutée depuis.'],
['CAPACITE_CHANGEE', { table: 9, numero: 3, avant: 8, maintenant: 6 }, 'La table 3 est passée de 8 à 6 places.'],
];
assert.equal(nommees.length, 6);
for (const [code, details, attendu] of nommees) {
assert.equal(libelle(code, details), attendu, code);
const { personne, numero, ...nus } = details;
assert.doesNotMatch(libelle(code, nus), /Iris|table 3/, code);
assert.match(libelle(code, nus), personne === undefined ? /identifiant 9/ : /n° 14/, code);
void numero;
}
});
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => { test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' }); const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/); assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
@ -888,6 +941,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
['MODE_LECTURE', 'seance'], ['MODE_LECTURE', 'seance'],
['SUPPORT_ROMPU', 'seance'], ['SUPPORT_ROMPU', 'seance'],
['GENERATION_EN_COURS', 'generation'], ['GENERATION_EN_COURS', 'generation'],
['TRAVAILLEUR_EN_ECHEC', 'generation'],
['AUCUN_PARTICIPANT', 'generation'], ['AUCUN_PARTICIPANT', 'generation'],
['AUCUNE_TABLE', 'generation'], ['AUCUNE_TABLE', 'generation'],
['SURRESERVATION', 'moteur'], ['SURRESERVATION', 'moteur'],
@ -897,7 +951,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
['GENERATION_INVARIANT_VIOLE', 'generation'], ['GENERATION_INVARIANT_VIOLE', 'generation'],
['COMPTEUR_SATURE', 'stockage'], ['COMPTEUR_SATURE', 'stockage'],
]; ];
assert.equal(REFUS.length, 15); assert.equal(REFUS.length, 16);
for (const [code, table] of REFUS) { for (const [code, table] of REFUS) {
assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`); assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`);
assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code); assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code);

View file

@ -62,6 +62,14 @@ export const TABLE = new Map([
() => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.", () => "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."], ['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."],
// Le travailleur de génération n'a pas démarré, ou s'est arrêté sans
// résultat : rien n'est enregistré, et aucun geste de la page ne le relance.
[
'TRAVAILLEUR_EN_ECHEC',
() =>
"Le calcul hors de la page n'a pas pu se faire : le navigateur n'a pas démarré le travailleur de génération, " +
"ou l'a arrêté sans résultat. Rien n'est enregistré ; relancer le logiciel le recharge.",
],
[ [
'CONFIRMATION_REQUISE', 'CONFIRMATION_REQUISE',
({ estimation }, texte) => ({ estimation }, texte) =>

View file

@ -41,6 +41,8 @@ export const TABLE = new Map([
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.", () => "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.'], ['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
['TROP_DE_PLACES', ({ max }) => `Une table compte au plus ${max} places : le plan ne dessinerait pas plus de chaises.`],
['TROP_DE_TOURS', ({ max }) => `Une soirée compte au plus ${max} tours.`],
// --- Les refus d'une capacité (§ 6.2, § 7.1) ------------------------------- // --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
[ [

View file

@ -74,6 +74,12 @@ export const TABLE = new Map([
({ siege, tour }) => `Le siège ${siege} est occupé au tour ${tour} : déposer la personne sur l'autre les échange.`, ({ siege, tour }) => `Le siège ${siege} est occupé au tour ${tour} : déposer la personne sur l'autre les échange.`,
], ],
['TABLE_PLEINE', ({ capacite, tour }) => `La table est pleine au tour ${tour} : ses ${capacite} places sont occupées.`], ['TABLE_PLEINE', ({ capacite, tour }) => `La table est pleine au tour ${tour} : ses ${capacite} places sont occupées.`],
[
'RESERVATION_TOUS_LES_TOURS',
() =>
'Cette personne est réservée ici pour tous les tours : la déposer hors des tables ne retire pas sa réservation. ' +
'« Retirer la réservation » la retire de tous les tours.',
],
// --- Les libellés figés du journal (§ 8.2) --------------------------------- // --- Les libellés figés du journal (§ 8.2) ---------------------------------
[ [

View file

@ -114,11 +114,25 @@ export const TABLE = new Map([
); );
}, },
], ],
// La personne se nomme par personne, et la table par numero, quand les
// détails les portent (avertissementsApres les lit sur la charge) ; une
// violation d'INVARIANT_VIOLE, qui ne les porte pas, garde les identifiants.
// Au siège, siege et occupant disent la chaise réservée et ce qu'elle
// porte : la personne est assise à la même table, à un autre siège.
[ [
'RESERVATION_NON_HONOREE', 'RESERVATION_NON_HONOREE',
({ participant, table, tour }) => ({ participant, table, tour, personne = null, numero = null, siege, occupant = null }) => {
`Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` + const qui = personne ?? `la personne n° ${participant}`;
`à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`, const ou = numero === null ? `table d'identifiant ${table}` : `table ${numero}`;
if (siege === undefined) {
return `Le placement retenu n'honore pas la réservation de ${qui} à la ${ou} au tour ${tour} : il la place ailleurs.`;
}
const chaise = occupant === null ? `laisse le siège ${siege} vide` : `donne le siège ${siege} à une autre personne`;
return (
`Le placement retenu n'honore pas la réservation de ${qui} au siège ${siege} de la ${ou} au tour ${tour} : ` +
`il l'assied à un autre siège de cette table, et ${chaise}.`
);
},
], ],
[ [
'INVARIANT_VIOLE', 'INVARIANT_VIOLE',

View file

@ -48,6 +48,11 @@ function touchees({ ids, retenu }) {
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 }; return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
} }
// La personne d'une raison de dérive, par son nom affiché quand les détails
// le portent ; la table, par son numéro de même.
const laPersonne = ({ participant, personne = null }) => personne ?? `La personne n° ${participant}`;
const laTable = ({ table, numero = null }) => (numero === null ? `La table d'identifiant ${table}` : `La table ${numero}`);
export const TABLE = new Map([ export const TABLE = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------ // --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------
[ [
@ -175,18 +180,17 @@ export const TABLE = new Map([
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`], ['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'], ['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'], ['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`], // Une raison de dérive nomme la personne par personne et la table par
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`], // numero quand ses détails les portent (raisonsNommees, de l'application) ;
[ // sinon par leur identifiant.
'PARTICIPANT_NON_PLACE', ['PARTICIPANT_EXCLU', (details) => `${laPersonne(details)} est exclue depuis.`],
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`, ['PARTICIPANT_SUPPRIME', (details) => `${laPersonne(details)} a été supprimée depuis.`],
], ['PARTICIPANT_NON_PLACE', (details) => `${laPersonne(details)}, ajoutée ou réintégrée depuis, n'y est pas placée.`],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`], ['TABLE_SUPPRIMEE', (details) => `${laTable(details)} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`], ['TABLE_AJOUTEE', (details) => `${laTable(details)} a été ajoutée depuis.`],
[ [
'CAPACITE_CHANGEE', 'CAPACITE_CHANGEE',
({ table, avant, maintenant }) => ({ avant, maintenant, ...details }) => `${laTable(details)} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
], ],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`], ['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],

View file

@ -138,13 +138,20 @@ function planEvalue(charge, instance, placement) {
} }
} }
// Les places que les réservations actives couvrantes tiennent à une table : /**
// tenu, Map siège → réservation, la première dans l'ordre canonique de * Les places que les réservations actives couvrantes d'une table tiennent,
// chaque siège dans la capacité, une personne n'en tenant qu'un ; sansSiege, * la règle de l'affichage : tenu, Map siège → réservation, la première dans
// Map personne → réservation, pour chaque autre personne réservée à la * l'ordre canonique de chaque siège dans la capacité, une personne n'en
// table — sans siège, ou dont le siège est pris : elle se lit comme une * tenant qu'un ; siegeDe, Map personne → son siège tenu ; sansSiege, Map
// réservation sans siège. * personne → réservation, pour chaque autre personne réservée à la table —
function placesTenues(couvrantes, places) { * sans siège, ou dont le siège est pris : elle se lit comme une réservation
* sans siège.
*
* @param {Object[]} couvrantes les réservations actives qui couvrent le tour à cette table, ordre canonique
* @param {number} places la capacité courante de la table
* @returns {{tenu: Map<number, Object>, siegeDe: Map<number, number>, sansSiege: Map<number, Object>}}
*/
export function placesTenues(couvrantes, places) {
const tenu = new Map(); const tenu = new Map();
const siegeDe = new Map(); const siegeDe = new Map();
const sansSiege = new Map(); const sansSiege = new Map();
@ -163,13 +170,17 @@ function placesTenues(couvrantes, places) {
return { tenu, siegeDe, sansSiege }; return { tenu, siegeDe, sansSiege };
} }
// Le remplissage et les marques d'une chaise (§ 7.6) : plein et cadenas // Le remplissage et les marques d'une chaise (§ 7.6) : une réservation
// fermé quand une réservation tient la place, l'anneau en plus pour la portée // porte le cadenas fermé, l'anneau en plus pour la portée « tous les
// « tous les tours » ; sinon cadenas ouvert pour un titre, hachure quand rien // tours » ; la chaise est pleine quand le placement la tient, hachurée — à
// ne le pourvoit à ce tour. // pourvoir — quand il la laisse vide ou y assied une autre personne. Le
function aspect(reservation, titre) { // cadenas ouvert reste au titre seul, hachuré quand rien ne le pourvoit à ce
// tour : la forme distingue toujours une réservation d'un titre.
function aspect(reservation, occupant, titre) {
if (reservation !== null) { if (reservation !== null) {
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] }; const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
const remplissage = reservation.participant === occupant ? 'plein' : 'hachure';
return { remplissage, marques: ['cadenasFerme', ...anneau] };
} }
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] }; if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
return { remplissage: 'normal', marques: [] }; return { remplissage: 'normal', marques: [] };
@ -184,7 +195,11 @@ function aspect(reservation, titre) {
* réservations, repartirSieges répartit les occupants de la table, chacun à * réservations, repartirSieges répartit les occupants de la table, chacun à
* son siège réservé, une personne réservée sans siège qui pourvoit un titre * son siège réservé, une personne réservée sans siège qui pourvoit un titre
* au siège de ce titre ; quand la liste dépasse la capacité, les personnes * au siège de ce titre ; quand la liste dépasse la capacité, les personnes
* réservées à la table passent avant les autres. Un exclu, une personne que la charge n'a plus, * réservées à la table passent avant les autres. Sièges attribués, la
* source se lit telle quelle : la réservation qui tient un siège que la
* liste donne à une autre personne, ou laisse vide, se lit sur la chaise
* sans la remplir — hachure et cadenas fermé —, et sa personne est là où
* la liste l'assied. Un exclu, une personne que la charge n'a plus,
* n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne * n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne
* reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au * reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au
* tour, null quand la source est en dérive ou que la configuration ne se * tour, null quand la source est en dérive ou que la configuration ne se
@ -271,7 +286,7 @@ export function etatPlaces(charge, { tour, proposition = null }) {
const reservation = tenue === null ? null : { participant: tenue.participant, portee: tenue.portee, tour: tenue.tour }; const reservation = tenue === null ? null : { participant: tenue.participant, portee: tenue.portee, tour: tenue.tour };
const rangTitre = titreDePlace.get(`${id}:${siege}`); const rangTitre = titreDePlace.get(`${id}:${siege}`);
const titre = rangTitre === undefined ? null : { libelle: titres[rangTitre].libelle, pourvu: pourvuAuTour(rangTitre) }; const titre = rangTitre === undefined ? null : { libelle: titres[rangTitre].libelle, pourvu: pourvuAuTour(rangTitre) };
return { siege, occupant, reservation, titre, ...aspect(reservation, titre) }; return { siege, occupant, reservation, titre, ...aspect(reservation, occupant, titre) };
}); });
return { return {

View file

@ -85,8 +85,10 @@ const PARFAIT = [
[[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]], [[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]],
]; ];
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1); const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
// Chaque appel rend des listes neuves : une épreuve qui modifie son plan ne
// touche pas PARFAIT.
const planParfait = (siegesAttribues = false) => const planParfait = (siegesAttribues = false) =>
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues }); planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: structuredClone(PARFAIT), siegesAttribues });
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et // La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
// retenu quand retenu est vrai. // retenu quand retenu est vrai.
@ -460,6 +462,58 @@ describe('etatPlaces : titres, réservations et marques (§ 4.1, § 4.4, § 7.6)
}); });
}); });
describe('etatPlaces : la réservation au siège que le placement ne tient pas (§ 5.8, § 7.6, § 14.12)', () => {
// La petite démonstration, son plan parfait retenu sous des sièges
// attribués : au tour 1, la table 1 assied 1, 2, 3 aux sièges 1 à 3.
function auxSieges() {
const charge = petite();
charge.reglages.attribuerSieges = true;
charge.propositions = [propositionDe(1, planParfait(true))];
charge.prochainsIds.proposition = 2;
charge.retenu = retenuDe(1, planParfait(true));
charge.evenement.etat = 'retenu';
return charge;
}
test("la chaise réservée à 2, qu'occupe 3 : la réservation se lit, mais ne la tient pas — hachure et cadenas fermé, l'anneau de sa portée ; la chaise de 2 reste sans marque", () => {
const charge = auxSieges();
charge.reservations.push(resa(2, 1, 3));
charge.titres.push({ table: 1, siege: 3, libelle: 'Animation' });
const [, deux, trois] = etatPlaces(charge, { tour: 1 }).tables[0].chaises;
assert.equal(trois.occupant, 3);
assert.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null });
assert.deepEqual([trois.remplissage, trois.marques], ['hachure', ['cadenasFerme', 'anneau']]);
assert.deepEqual([deux.occupant, deux.reservation, deux.remplissage, deux.marques], [2, null, 'normal', []]);
});
test('vide, la chaise réservée ne se tient pas non plus ; tenue par sa personne, elle est pleine', () => {
const vide = auxSieges();
vide.retenu.placement[0].sieges[0] = [1, 2];
vide.retenu.placement[0].reserve = [3];
vide.reservations.push(resa(2, 1, 3, 1));
const chaise = etatPlaces(vide, { tour: 1 }).tables[0].chaises[2];
assert.deepEqual([chaise.occupant, chaise.reservation?.participant, chaise.remplissage, chaise.marques], [null, 2, 'hachure', ['cadenasFerme']]);
const tenue = auxSieges();
tenue.reservations.push(resa(3, 1, 3));
const pleine = etatPlaces(tenue, { tour: 1 }).tables[0].chaises[2];
assert.deepEqual([pleine.occupant, pleine.remplissage, pleine.marques], [3, 'plein', ['cadenasFerme', 'anneau']]);
});
test("une réservation d'un tour que la chaise vide ne tient pas, et un titre que rien ne pourvoit : deux formes, cadenas fermé d'un côté, ouvert de l'autre (§ 7.6)", () => {
const charge = auxSieges();
charge.retenu.placement[0].sieges[0] = [1, 2];
charge.retenu.placement[0].reserve = [3];
charge.reservations.push(resa(2, 1, 3, 1));
charge.titres.push({ table: 2, siege: 1, libelle: 'Accueil' });
const etat = etatPlaces(charge, { tour: 1 });
const reservee = etat.tables[0].chaises[2];
const titree = etat.tables[1].chaises[0];
assert.deepEqual([reservee.reservation?.participant, reservee.titre, reservee.remplissage, reservee.marques], [2, null, 'hachure', ['cadenasFerme']]);
assert.deepEqual([titree.reservation, titree.titre, titree.remplissage, titree.marques], [null, { libelle: 'Accueil', pourvu: false }, 'hachure', ['cadenasOuvert']]);
});
});
describe('libellePropose et statuts (§ 4, § 4.2)', () => { describe('libellePropose et statuts (§ 4, § 4.2)', () => {
test('le titre pressenti, puis le libellé le plus fréquent, le plus petit à égalité, puis « animateur », jamais marqué', () => { test('le titre pressenti, puis le libellé le plus fréquent, le plus petit à égalité, puis « animateur », jamais marqué', () => {
const charge = creerCharge({ id: 'evt-libelles', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 }); const charge = creerCharge({ id: 'evt-libelles', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,7 @@ import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js'; import { fil, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js'; import { SUFFIXES } from '../stockage/noms.js';
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js'; import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
import { avertissementsApres, invariantViole } from './avertissements.js'; import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
@ -245,13 +245,14 @@ export function creerSeance({
const annulations = new Set(); const annulations = new Set();
let instantane = null; let instantane = null;
// Par Ouvert, sa charge et son examen figés, et INVARIANT_VIOLE : un // Par Ouvert, sa charge et son examen figés, et INVARIANT_VIOLE : un
// Ouvert ne change jamais, ses copies se font une fois. // Ouvert ne change jamais, ses copies se font une fois. L'examen de l'état
// nomme ses raisons de dérive sur la charge (examenNomme).
const parOuvert = new WeakMap(); const parOuvert = new WeakMap();
function figesDe(ouvert) { function figesDe(ouvert) {
let figes = parOuvert.get(ouvert); let figes = parOuvert.get(ouvert);
if (figes === undefined) { if (figes === undefined) {
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen }); const copie = structuredClone({ charge: ouvert.charge, examen: examenNomme(ouvert.examen, ouvert.charge) });
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) }; figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
parOuvert.set(ouvert, figes); parOuvert.set(ouvert, figes);
} }
@ -1245,6 +1246,13 @@ export function creerSeance({
* lecture, admise dans tout mode, qui n'écrit rien. * lecture, admise dans tout mode, qui n'écrit rien.
*/ */
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)), formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
/**
* Une promesse résolue quand les appels déjà en file ont fini, réussis
* ou refusés : ce qu'un lecteur de etat() lit ensuite porte leurs
* écritures. Rien ne s'écrit ni ne se publie, et rien ne s'ajoute à la
* file ; elle ne rejette jamais.
*/
attendre: () => file,
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ; // Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
// ils s'emboîtent, et un fini de trop ne compte pas. // ils s'emboîtent, et un fini de trop ne compte pas.

View file

@ -745,7 +745,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
await seance.executer('retenirProposition', { id: 1 }); await seance.executer('retenirProposition', { id: 1 });
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants }); const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' }); const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8 }]; const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8, personne: 'Epicéa' }];
assert.deepEqual(enRetenu.avertissements, [ assert.deepEqual(enRetenu.avertissements, [
{ code: 'IMPORT_EN_RETENU', details: {} }, { code: 'IMPORT_EN_RETENU', details: {} },
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }, { code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } },
@ -1981,7 +1981,9 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => {
// Laisse passer les tâches en attente : l'écrivain différé des réglages // Laisse passer les tâches en attente : l'écrivain différé des réglages
// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse. // écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse.
const laisser = () => new Promise((resolve) => setTimeout(resolve, 0)); // setImmediate rappelle une fois vidées les microtâches, où s'enchaînent
// les écritures du système de fichiers d'épreuve ; il n'attend aucune durée.
const laisser = () => new Promise((resolve) => setImmediate(resolve));
// Compte les objets atteints depuis valeur, chacun une fois, en affirmant // Compte les objets atteints depuis valeur, chacun une fois, en affirmant
// que chacun est figé. // que chacun est figé.
@ -2453,14 +2455,14 @@ describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)"
await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('enregistrerGeneration', generer(seance.charge));
await seance.executer('retenirProposition', { id: 1 }); await seance.executer('retenirProposition', { id: 1 });
const exclusion = await seance.executer('exclureParticipant', { id: 3 }); const exclusion = await seance.executer('exclureParticipant', { id: 3 });
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }]; const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' }];
assert.deepEqual(exclusion.avertissements, [ assert.deepEqual(exclusion.avertissements, [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } }, { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
]); ]);
const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } }); const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } });
assert.deepEqual(suivant.avertissements, []); assert.deepEqual(suivant.avertissements, []);
const autre = await seance.executer('exclureParticipant', { id: 2 }); const autre = await seance.executer('exclureParticipant', { id: 2 });
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2 }]; const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2, personne: 'Grisaille' }];
assert.deepEqual(autre.avertissements, [ assert.deepEqual(autre.avertissements, [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } }, { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } },
]); ]);
@ -2732,6 +2734,51 @@ describe('séance observable : fautives et examen (§ 8.9, A2)', () => {
}); });
}); });
describe('séance : attendre la file (§ 5.10, § 8.4)', () => {
test("attendre se résout quand les appels déjà en file ont fini, réussis ou refusés, sans rien écrire ni publier de plus", async () => {
const { seance, fs } = await ouvertSur();
await seance.passerEnEcriture();
const ecrit = seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
const refuse = seance.executer('ajouterParticipant', { champs: { nom: '' } }).catch((erreur) => erreur);
const avant = seance.charge.participants.length;
const ecritures = fs.compteurs.ecritures;
let publications = 0;
seance.subscribe(() => {
publications += 1;
});
publications = 0;
await seance.attendre();
assert.equal(seance.charge.participants.length, avant + 1);
assert.equal((await refuse).code !== undefined, true);
await ecrit;
const apres = publications;
const ecrituresApres = fs.compteurs.ecritures;
await seance.attendre();
assert.equal(publications, apres, 'attendre ne publie rien');
assert.equal(fs.compteurs.ecritures, ecrituresApres, 'attendre n’écrit rien');
assert.ok(ecrituresApres > ecritures);
});
});
describe('séance observable : la dérive nommée (§ 9, A2)', () => {
test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => {
const charge = avecRetenu(chargeEssai());
charge.participants[2].exclu = true;
charge.tables[1].numero = 7;
charge.tables[1].sieges = 3;
const { seance } = await ouvertSur(charge);
const { raisons } = seance.etat().examen.retenu;
assert.deepEqual(raisons, [
{ code: 'CAPACITE_CHANGEE', table: 2, avant: 2, maintenant: 3, numero: 7 },
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' },
]);
assert.deepEqual(
seance.ouvert.examen.retenu.raisons,
raisons.map(({ personne, numero, ...nue }) => nue),
);
});
});
describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => { describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => {
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => { test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => {
const fs = creerFichiersSimules(); const fs = creerFichiersSimules();

View file

@ -19,6 +19,8 @@ import { versionVoisine } from '../../test/version_voisine.js';
import { apercevoir } from '../csv/apercu.js'; import { apercevoir } from '../csv/apercu.js';
import { decoder } from '../csv/encodage.js'; import { decoder } from '../csv/encodage.js';
import { exporterParticipants } from '../csv/export.js'; import { exporterParticipants } from '../csv/export.js';
import { constante } from '../geometrie/constantes.js';
import { geometrieTable } from '../geometrie/tables.js';
import { serialiser } from '../stockage/canonique.js'; import { serialiser } from '../stockage/canonique.js';
import { analyser } from '../stockage/document.js'; import { analyser } from '../stockage/document.js';
import { examiner } from '../stockage/placements.js'; import { examiner } from '../stockage/placements.js';
@ -155,6 +157,25 @@ describe('démonstrations livrées : les fichiers (§ 15.5, point 5)', () => {
assert.deepEqual([charge.propositions, charge.retenu], [[], null], cle); assert.deepEqual([charge.propositions, charge.retenu], [[], null], cle);
} }
}); });
test("le bloc de noms d'une table, de toute sa largeur, ne rencontre l'emprise d'aucune autre table, d'aucune rangée (§ 7.4)", () => {
// Le bloc s'étend de origineListe.x sur LARGEUR_BLOC_NOMS_CM, où toute
// ligne s'abrège ; sa hauteur, mesurée sur la police, ne se connaît pas
// ici : l'épreuve exige la bande verticale entière libre de toute emprise.
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
for (const { cle, texte } of LIVREES) {
const { charge } = analyser(texte);
const geometries = charge.tables.map((table) => geometrieTable(table, table.sieges ?? charge.evenement.siegesParDefaut));
const rencontres = geometries.flatMap((g) =>
geometries
.filter((autre) => autre !== g)
.filter((autre) => autre.centre.x - autre.rayonEmprise < g.origineListe.x + largeur && autre.centre.x + autre.rayonEmprise > g.origineListe.x)
.map((autre) => `table ${g.numero} sur la table ${autre.numero}`),
);
assert.ok(geometries.length > 1, cle);
assert.deepEqual(rencontres, [], cle);
}
});
}); });
describe('démonstrations livrées : aller-retour sur la petite (§ 8.9, § 14.4)', () => { describe('démonstrations livrées : aller-retour sur la petite (§ 8.9, § 14.4)', () => {

View file

@ -268,38 +268,38 @@
], ],
"tables": [ "tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}, {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}},
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}}, {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}},
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}}, {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}},
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, {"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}, {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}},
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}}, {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}},
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}}, {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}},
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}}, {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}},
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}}, {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}},
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}}, {"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}}, {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}},
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}}, {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}},
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}}, {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}},
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}}, {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}},
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}}, {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}},
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}}, {"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}}, {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}},
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}}, {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}},
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}}, {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}},
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}}, {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}},
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}}, {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}},
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}}, {"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}}, {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}},
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}}, {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}},
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}}, {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}},
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}}, {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}},
{"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":1500,"y":1200}}, {"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":4065,"y":1200}},
{"id":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"y":1500}}, {"id":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"y":1500}},
{"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":300,"y":1500}}, {"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":813,"y":1500}},
{"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":600,"y":1500}} {"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1500}}
], ],
"reservations": [ "reservations": [
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null}, {"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},

View file

@ -268,38 +268,38 @@
], ],
"tables": [ "tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}, {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}},
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}}, {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}},
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}}, {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}},
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, {"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}, {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}},
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}}, {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}},
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}}, {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}},
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}}, {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}},
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}}, {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}},
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}}, {"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}}, {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}},
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}}, {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}},
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}}, {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}},
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}}, {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}},
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}}, {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}},
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}}, {"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}}, {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}},
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}}, {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}},
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}}, {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}},
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}}, {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}},
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}}, {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}},
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}}, {"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}}, {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}},
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}}, {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}},
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}}, {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}},
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}}, {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}},
{"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":1500,"y":1200}}, {"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":4065,"y":1200}},
{"id":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"y":1500}}, {"id":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"y":1500}},
{"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":300,"y":1500}}, {"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":813,"y":1500}},
{"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":600,"y":1500}} {"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":1626,"y":1500}}
], ],
"reservations": [ "reservations": [
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null}, {"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},

View file

@ -20,9 +20,9 @@
], ],
"tables": [ "tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}} {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
], ],
"reservations": [], "reservations": [],
"titres": [], "titres": [],

View file

@ -20,9 +20,9 @@
], ],
"tables": [ "tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}},
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}} {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
], ],
"reservations": [], "reservations": [],
"titres": [], "titres": [],

View file

@ -38,10 +38,13 @@ export function regimeCibles(k, geometrie) {
* de la peinture : la première dont la surface peinte contient le point * de la peinture : la première dont la surface peinte contient le point
* décide. En régime table, la surface est l'emprise — disque d'une ronde, * décide. En régime table, la surface est l'emprise — disque d'une ronde,
* carré d'une carrée — et la table est désignée. En régime sièges, elle est * carré d'une carrée — et la table est désignée. En régime sièges, elle est
* le plateau et les chaises : un point dans une chaise désigne son siège ; * le plateau et les cibles des sièges : un point à rayonCible au plus du
* un point sur le plateau ne désigne rien, et cache les chaises des tables * centre d'une chaise — max(RAYON_CHAISE, pas / 2), la cible a la taille du
* peintes dessous ; entre les chaises, le point passe aux tables de dessous. * pas — désigne son siège ; un point sur le plateau hors de toute cible ne
* Deux chaises d'une même table ne se recouvrent pas. * désigne rien, et cache les chaises des tables peintes dessous ; hors des
* cibles et du plateau, le point passe aux tables de dessous. Deux cibles
* d'une même table se touchent au plus : sur leur bord commun, le plus petit
* siège l'emporte.
* *
* @param {{x: number, y: number}} point cm * @param {{x: number, y: number}} point cm
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin * @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
@ -55,7 +58,7 @@ export function designer(point, geometries, k) {
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id }; if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
continue; continue;
} }
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon); const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= geometrie.rayonCible);
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege }; if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
if (dans(point, geometrie, geometrie.demiTaille)) return null; if (dans(point, geometrie, geometrie.demiTaille)) return null;
} }

View file

@ -17,6 +17,33 @@ const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, posit
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
// Écart sous le bord d'une cible, en cm.
const EPS = 0.01;
// Un point entre deux chaises voisines d'une table : leur milieu, éloigné de
// 4 cm de la droite qui les joint, du côté opposé au centre de la table. Sur
// un côté de carrée à sièges espacés du pas, le milieu est au bord des deux
// cibles ; éloigné, il est hors des deux et reste dans l'emprise. Quand le
// milieu est le centre — deux chaises opposées, de part et d'autre d'un côté
// sans siège —, le point est sur ce côté, à la distance de la première
// chaise plus 4 cm, du côté où tourne la première dans le sens horaire de
// l'écran.
function entre(g, a, b) {
const m = milieu(a, b);
const longueur = Math.hypot(b.x - a.x, b.y - a.y);
let nx = -(b.y - a.y) / longueur;
let ny = (b.x - a.x) / longueur;
const vers = { x: m.x - g.centre.x, y: m.y - g.centre.y };
const degenere = Math.hypot(vers.x, vers.y) < 1e-6;
const reference = degenere ? { x: -(a.y - g.centre.y), y: a.x - g.centre.x } : vers;
if (nx * reference.x + ny * reference.y < 0) {
nx = -nx;
ny = -ny;
}
const ecart = degenere ? Math.hypot(a.x - g.centre.x, a.y - g.centre.y) + 4 : 4;
return { x: m.x + nx * ecart, y: m.y + ny * ecart };
}
describe('regimeCibles (§ 7.3)', () => { describe('regimeCibles (§ 7.3)', () => {
test('le seuil est de 24 px', () => { test('le seuil est de 24 px', () => {
assert.equal(CIBLE_MIN_PX, 24); assert.equal(CIBLE_MIN_PX, 24);
@ -65,9 +92,16 @@ describe('l’emprise d’une carrée est un carré', () => {
assert.equal(surLeSol(point, geometries), false); assert.equal(surLeSol(point, geometries), false);
}); });
test('(140, 0) est le sol', () => { test('(140, 0) : hors de l’emprise, mais à 28 cm du siège 5, en (112, 0), dans sa cible de rayon pas/2 = 30', () => {
const point = { x: 140, y: 0 }; const point = { x: 140, y: 0 };
assert.equal(designer(point, geometries, 0.2), null); assert.equal(designer(point, geometries, 0.2), null);
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 5 });
assert.equal(surLeSol(point, geometries), true);
});
test('(150, 0) est le sol', () => {
const point = { x: 150, y: 0 };
assert.equal(designer(point, geometries, 0.2), null);
assert.equal(designer(point, geometries, 1), null); assert.equal(designer(point, geometries, 1), null);
assert.equal(surLeSol(point, geometries), true); assert.equal(surLeSol(point, geometries), true);
}); });
@ -112,20 +146,42 @@ describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)',
assert.equal(chaises, ATTENDUES[nom]); assert.equal(chaises, ATTENDUES[nom]);
}); });
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => { test(`${nom}, k = 1 : ni un point entre deux chaises voisines, à plus de pas/2 de chacune, ni le centre d’une table ne désigne rien`, () => {
let milieux = 0; let points = 0;
for (const g of geometries) { for (const g of geometries) {
const n = g.chaises.length; const n = g.chaises.length;
for (let i = 0; i < n; i += 1) { for (let i = 0; i < n; i += 1) {
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]); const point = entre(g, g.chaises[i], g.chaises[(i + 1) % n]);
for (const voisine of [g.chaises[i], g.chaises[(i + 1) % n]]) {
assert.ok(Math.hypot(point.x - voisine.x, point.y - voisine.y) > g.pas / 2, `table ${g.id}, siège ${voisine.siege}`);
}
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`); assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
assert.equal(surLeSol(point, geometries), false); assert.equal(surLeSol(point, geometries), false);
milieux += 1; points += 1;
} }
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`); assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
assert.equal(surLeSol(g.centre, geometries), false); assert.equal(surLeSol(g.centre, geometries), false);
} }
assert.equal(milieux, ATTENDUES[nom]); assert.equal(points, ATTENDUES[nom]);
});
test(`${nom}, au seuil k = 24/pas de chaque table : un point à pas/2 − ε d’une chaise, vers sa voisine, la désigne`, () => {
let points = 0;
for (const g of geometries) {
const k = (CIBLE_MIN_PX / g.pas) * (1 + 1e-12);
assert.equal(regimeCibles(k, g), 'sieges');
const n = g.chaises.length;
for (let i = 0; i < n; i += 1) {
const chaise = g.chaises[i];
const voisine = g.chaises[(i + 1) % n];
const d = Math.hypot(voisine.x - chaise.x, voisine.y - chaise.y);
const portee = g.pas / 2 - EPS;
const point = { x: chaise.x + ((voisine.x - chaise.x) * portee) / d, y: chaise.y + ((voisine.y - chaise.y) * portee) / d };
assert.deepEqual(designer(point, [g], k), { table: g.id, siege: chaise.siege }, `table ${g.id}, siège ${chaise.siege}`);
points += 1;
}
}
assert.equal(points, ATTENDUES[nom]);
}); });
test(`${nom} : un point à 10 m de tout est le sol`, () => { test(`${nom} : un point à 10 m de tout est le sol`, () => {

View file

@ -7,7 +7,9 @@
// dessin pose les chaises par geometrieTable et la désignation d'un siège // dessin pose les chaises par geometrieTable et la désignation d'un siège
// (designation.js) lit les mêmes chaises : une seule implémentation, que le // (designation.js) lit les mêmes chaises : une seule implémentation, que le
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne // dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
// porte aucun angle. // porte aucun angle. La grille où se posent les tables des démonstrations et
// celles que l'opérateur crée a un seul pas, ecartsGrille.
import { constante } from './constantes.js';
/** /**
* @typedef {Object} GeometrieTable en cm * @typedef {Object} GeometrieTable en cm
@ -19,6 +21,8 @@
* @property {number} pas distance entre deux sièges voisins, au bord du plateau * @property {number} pas distance entre deux sièges voisins, au bord du plateau
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places * @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
* @property {{x: number, y: number}} poignee centre du disque de la poignée * @property {{x: number, y: number}} poignee centre du disque de la poignée
* @property {number} rayonCible rayon du disque, centré sur une chaise, où un point désigne
* son siège en régime sièges : max(RAYON_CHAISE, pas / 2)
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise * @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
* (ronde) ou demi-côté du carré d'emprise (carrée) * (ronde) ou demi-côté du carré d'emprise (carrée)
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms * @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
@ -35,10 +39,14 @@ export const RAYON_CHAISE = 22;
export const RAYON_POIGNEE = 15; export const RAYON_POIGNEE = 15;
/** Distance entre l'emprise et le bord de la poignée, en cm. */ /** Distance entre l'emprise et le bord de la poignée, en cm. */
export const ECART_POIGNEE = 8; export const ECART_POIGNEE = 8;
/** Distance entre l'emprise et le bloc de noms, en cm. */ /** Distance entre l'emprise et le bloc de noms, en cm : le carré du focus
export const ECART_LISTE = 10; * que l'interface pose autour de l'emprise, ou d'une chaise, s'étend à
/** Distance entre deux cases de la grille des tables créées, en cm. */ * 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
export const PAS_GRILLE = 300; * aucune marque ne couvre la liste. */
export const ECART_LISTE = 36;
/** Écart minimal entre les centres de deux rangées de la grille des tables,
* en cm. */
export const ECART_RANGEES = 300;
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du // Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π. // plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
@ -55,42 +63,37 @@ const COTES = [
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } }, { depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
]; ];
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un // Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1 : la
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie // géométrie d'une table se calcule sans valeur de repli.
// d'une table se calcule sans valeur de repli. function exigerPlaces(places) {
function exigerPlaces(places, saisi) {
if (!Number.isInteger(places) || places < 1) { if (!Number.isInteger(places) || places < 1) {
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`); throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
} }
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
}
} }
/** /**
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le * Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π). Le format 1
* diamètre saisi, qui l'emporte. * ne porte aucun diamètre saisi : la formule dessine toute table, et le pas
* qu'en tire geometrieTable ne connaît qu'elle.
* *
* @param {number} places entier ≥ 1 * @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm * @returns {number} cm
*/ */
export function diametre(places, { saisi = null } = {}) { export function diametre(places) {
exigerPlaces(places, saisi); exigerPlaces(places);
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI); return Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
} }
/** /**
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus * Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte. * garni porte ⌈places / 4⌉ sièges.
* *
* @param {number} places entier ≥ 1 * @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm * @returns {number} cm
*/ */
export function cote(places, { saisi = null } = {}) { export function cote(places) {
exigerPlaces(places, saisi); exigerPlaces(places);
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE); return Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
} }
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE, // Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
@ -138,6 +141,12 @@ function chaisesCarree(centre, demiTaille, places) {
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de * sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
* sièges du côté le plus garni. * sièges du côté le plus garni.
* *
* La cible d'un siège est un disque de rayon max(RAYON_CHAISE, pas / 2)
* centré sur sa chaise : au zoom où le régime des cibles passe aux sièges,
* k × pas ≥ 24 px (designation.js), elle mesure au moins 24 px à l'écran.
* Deux chaises d'une même table sont à un pas au moins l'une de l'autre :
* deux cibles se touchent au plus, sans se recouvrir.
*
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45° * La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre * (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23 * est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
@ -172,6 +181,7 @@ export function geometrieTable(table, places) {
demiTaille, demiTaille,
pas, pas,
chaises, chaises,
rayonCible: Math.max(RAYON_CHAISE, pas / 2),
poignee: { x: centre.x + decalage, y: centre.y + decalage }, poignee: { x: centre.x + decalage, y: centre.y + decalage },
rayonEmprise, rayonEmprise,
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille }, origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
@ -179,9 +189,13 @@ export function geometrieTable(table, places) {
} }
/** /**
* Rectangle englobant des emprises : pour chaque table, le carré de * Rectangle englobant de ce que le dessin pose de chaque table et qui se
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une * pointe : le carré de demi-côté rayonEmprise centré sur elle — il
* ronde et égale l'emprise d'une carrée. * circonscrit le disque d'une ronde et égale l'emprise d'une carrée —, le
* carré circonscrit à la cible de chaque siège, qui déborde l'emprise de
* pas / 2 − RAYON_CHAISE, et celui du disque de la poignée, posée au-delà du
* coin d'une carrée. L'ajustement (vue.js) le fait tenir dans le cadre :
* toute cible et toute poignée y restent visibles.
* *
* @param {GeometrieTable[]} geometries * @param {GeometrieTable[]} geometries
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
@ -192,35 +206,77 @@ export function englobant(geometries) {
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
for (const { centre, rayonEmprise } of geometries) { const etendre = ({ x, y }, demi) => {
minX = Math.min(minX, centre.x - rayonEmprise); minX = Math.min(minX, x - demi);
minY = Math.min(minY, centre.y - rayonEmprise); minY = Math.min(minY, y - demi);
maxX = Math.max(maxX, centre.x + rayonEmprise); maxX = Math.max(maxX, x + demi);
maxY = Math.max(maxY, centre.y + rayonEmprise); maxY = Math.max(maxY, y + demi);
};
for (const { centre, rayonEmprise, chaises, rayonCible, poignee } of geometries) {
etendre(centre, rayonEmprise);
for (const chaise of chaises) etendre(chaise, rayonCible);
etendre(poignee, RAYON_POIGNEE);
} }
return { minX, minY, maxX, maxY }; return { minX, minY, maxX, maxY };
} }
/**
* Le pas de la grille des tables, en cm entiers, pour la plus grande emprise
* E des tables données, chacune de sa forme et de son nombre de places :
* entre deux colonnes, ⌈2E + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ — le bloc
* de noms d'une table, à ECART_LISTE de son emprise et large de
* LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège, s'achève à ECART_LISTE de
* l'emprise de la colonne suivante : aucune liste ne passe sur une autre
* table de la grille, d'aucune rangée ; entre deux rangées,
* max(ECART_RANGEES, ⌈2E + ECART_LISTE⌉) — deux emprises voisines restent à
* ECART_LISTE l'une de l'autre, la marge du carré de focus.
*
* @param {Array<{forme: 'ronde'|'carree', places: number}>} tables au moins une
* @returns {{colonnes: number, rangees: number}}
*/
export function ecartsGrille(tables) {
const emprise = Math.max(
...tables.map(({ forme, places }) => geometrieTable({ id: 0, numero: 0, forme, position: { x: 0, y: 0 } }, places).rayonEmprise),
);
return {
colonnes: Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')),
rangees: Math.max(ECART_RANGEES, Math.ceil(2 * emprise + ECART_LISTE)),
};
}
/** /**
* Position d'une table créée : la première case libre, ligne par ligne, d'une * Position d'une table créée : la première case libre, ligne par ligne, d'une
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant * grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou * d'ecartsGrille des tables posées et de la nouvelle — le pas des
* moins. centres : les positions de charge.tables, que l'appelant passe sans * démonstrations (scripts/engendrer_livrables.js). Une case est prise quand
* construire de géométrie. * une table posée est à moins d'un pas de son centre sur chacun des deux
* axes, bornes exclues. Une table à un pas de colonne ou plus a son emprise
* hors de la bande du bloc de noms de la table nouvelle, et la sienne
* s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
* la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
* emprise des deux. Une table à un pas de rangée ou plus a son emprise à
* ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors
* de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée,
* ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne
* à travers une colonne.
* *
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus * La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
* deux cases, le plus petit disque qui contient trois cases ayant un rayon * au plus deux multiples du pas, et une table prend donc au plus quatre
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc * cases ; l'une des 4T + 1 premières cases est libre.
* libre.
* *
* @param {Array<{x: number, y: number}>} centres * @param {Array<{position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} tables
* les tables posées, chacune à sa capacité courante
* @param {{forme: 'ronde'|'carree', places: number}} nouvelle la table à poser
* @returns {{x: number, y: number}} * @returns {{x: number, y: number}}
*/ */
export function caseLibre(centres) { export function caseLibre(tables, nouvelle) {
const colonnes = Math.ceil(Math.sqrt(centres.length + 1)); const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
for (let rang = 0; ; rang += 1) { for (let rang = 0; ; rang += 1) {
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE }; const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY };
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2); const prise = tables.some(
({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY,
);
if (!prise) return candidate; if (!prise) return candidate;
} }
} }

View file

@ -8,18 +8,22 @@
// attendues se calculent à la main dans le commentaire qui les précède. // attendues se calculent à la main dans le commentaire qui les précède.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { constante } from './constantes.js';
import { surLeSol } from './designation.js'; import { surLeSol } from './designation.js';
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
import { MARGE_AJUSTEMENT_PX, ajuster, ecranDepuisDessin } from './vue.js';
import { import {
ECART_LISTE, ECART_LISTE,
ECART_POIGNEE, ECART_POIGNEE,
ECART_RANGEES,
LARGEUR_PAR_PLACE, LARGEUR_PAR_PLACE,
PAS_GRILLE,
PLANCHER_DIAMETRE, PLANCHER_DIAMETRE,
RAYON_CHAISE, RAYON_CHAISE,
RAYON_POIGNEE, RAYON_POIGNEE,
caseLibre, caseLibre,
cote, cote,
diametre, diametre,
ecartsGrille,
englobant, englobant,
geometrieTable, geometrieTable,
placesSousPoignee, placesSousPoignee,
@ -42,6 +46,48 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
// Une table du fichier d'état : identifiant, numéro, forme et centre. // Une table du fichier d'état : identifiant, numéro, forme et centre.
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } }); const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le
// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas.
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
// Les fautes « une liste passe sur une autre table » d'un ensemble de tables
// posées {position, forme, places} : pour chaque paire ordonnée, la bande du
// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis
// origineListe, places + 1 lignes de haut, en-tête d'une table gelée
// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec
// avec, seules les paires où figure la table de ce rang, compté depuis 1,
// sont examinées. Rend les fautes et le nombre de paires examinées.
function fautesBandes(posees, { avec = null } = {}) {
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
const geometries = posees.map(({ position, forme, places }, rang) => ({
g: geometrieTable(table(rang + 1, forme, position.x, position.y), places),
places,
}));
const fautes = [];
let paires = 0;
for (const { g, places } of geometries) {
const bande = {
gauche: g.origineListe.x,
droite: g.origineListe.x + largeur,
haut: g.origineListe.y,
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
};
for (const { g: autre } of geometries) {
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
paires += 1;
const e = autre.rayonEmprise;
const recoupe =
autre.centre.x + e > bande.gauche &&
autre.centre.x - e < bande.droite &&
autre.centre.y + e > bande.haut &&
autre.centre.y - e < bande.bas;
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
}
}
return { fautes, paires };
}
describe('constantes', () => { describe('constantes', () => {
test('les valeurs du contrat', () => { test('les valeurs du contrat', () => {
assert.equal(PLANCHER_DIAMETRE, 70); assert.equal(PLANCHER_DIAMETRE, 70);
@ -49,8 +95,8 @@ describe('constantes', () => {
assert.equal(RAYON_CHAISE, 22); assert.equal(RAYON_CHAISE, 22);
assert.equal(RAYON_POIGNEE, 15); assert.equal(RAYON_POIGNEE, 15);
assert.equal(ECART_POIGNEE, 8); assert.equal(ECART_POIGNEE, 8);
assert.equal(ECART_LISTE, 10); assert.equal(ECART_LISTE, 36);
assert.equal(PAS_GRILLE, 300); assert.equal(ECART_RANGEES, 300);
}); });
}); });
@ -69,9 +115,9 @@ describe('diametre (§ 7.7)', () => {
assert.equal(Math.round(diametre(10)), 191); assert.equal(Math.round(diametre(10)), 191);
}); });
test('un diamètre saisi l’emporte', () => { test('une seule formule : le format 1 ne porte aucun diamètre saisi, et une option ne change rien', () => {
assert.equal(diametre(8, { saisi: 180 }), 180); assert.equal(diametre(8, { saisi: 180 }), diametre(8));
assert.equal(diametre(2, { saisi: 50 }), 50); assert.equal(diametre(2, { saisi: 50 }), 70);
}); });
}); });
@ -83,8 +129,8 @@ describe('cote', () => {
assert.equal(cote(9), 180); assert.equal(cote(9), 180);
}); });
test('un côté saisi l’emporte', () => { test('une seule formule : une option ne change pas le côté', () => {
assert.equal(cote(8, { saisi: 150 }), 150); assert.equal(cote(8, { saisi: 150 }), 120);
}); });
}); });
@ -119,7 +165,7 @@ describe('geometrieTable, ronde', () => {
proche(g.rayonEmprise, r + 44, 'rayon d’emprise'); proche(g.rayonEmprise, r + 44, 'rayon d’emprise');
const ecart = (r + 44 + 23) / Math.SQRT2; const ecart = (r + 44 + 23) / Math.SQRT2;
procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée'); procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée');
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste'); procheDuPoint(g.origineListe, { x: 300 + r + 80, y: 600 - r }, 'origine de la liste');
}); });
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => { test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
@ -163,10 +209,10 @@ describe('geometrieTable, carrée', () => {
}); });
}); });
test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => { test('emprise 104, poignée au-delà du coin, liste en (440, 540)', () => {
assert.equal(g.rayonEmprise, 104); assert.equal(g.rayonEmprise, 104);
procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée'); procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée');
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste'); procheDuPoint(g.origineListe, { x: 440, y: 540 }, 'origine de la liste');
}); });
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => { test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
@ -238,13 +284,34 @@ describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
}); });
}); });
describe('la cible d’un siège a la taille du pas (§ 7.3)', () => {
test('de 2 à 40 places, deux formes : rayon max(RAYON_CHAISE, pas/2) ; deux cibles ne se recouvrent pas ; le centre de la poignée n’est dans aucune', () => {
let tables = 0;
for (const forme of ['ronde', 'carree']) {
for (let places = 2; places <= 40; places += 1) {
const g = geometrieTable(table(1, forme, 10, -20), places);
assert.equal(g.rayonCible, Math.max(RAYON_CHAISE, g.pas / 2), `${forme} de ${places}`);
for (let i = 0; i < places; i += 1) {
for (let j = i + 1; j < places; j += 1) {
const d = distance(g.chaises[i], g.chaises[j]);
assert.ok(d >= 2 * g.rayonCible - EPSILON, `${forme} de ${places}, sièges ${i + 1} et ${j + 1} à ${d} cm`);
}
assert.ok(distance(g.poignee, g.chaises[i]) > g.rayonCible, `${forme} de ${places}, poignée et siège ${i + 1}`);
}
tables += 1;
}
}
assert.equal(tables, 78);
});
});
describe('aucune rotation (§ 7.1)', () => { describe('aucune rotation (§ 7.1)', () => {
test('la géométrie ne porte aucun angle et une seule poignée', () => { test('la géométrie ne porte aucun angle et une seule poignée', () => {
for (const forme of ['ronde', 'carree']) { for (const forme of ['ronde', 'carree']) {
const g = geometrieTable(table(1, forme, 0, 0), 8); const g = geometrieTable(table(1, forme, 0, 0), 8);
assert.deepEqual( assert.deepEqual(
Object.keys(g).sort(), Object.keys(g).sort(),
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'], ['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonCible', 'rayonEmprise'],
); );
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']); assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']); for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
@ -259,57 +326,166 @@ describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () =>
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError); assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
}); });
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => { test('diametre et cote : places invalides', () => {
assert.throws(() => diametre(0), RangeError); assert.throws(() => diametre(0), RangeError);
assert.throws(() => cote(1.5), RangeError); assert.throws(() => cote(1.5), RangeError);
assert.throws(() => diametre(8, { saisi: 0 }), RangeError); assert.throws(() => diametre(Number.NaN), RangeError);
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
}); });
}); });
describe('englobant', () => { describe('englobant : tout ce que le dessin pose de pointable', () => {
test('null sans table', () => { test('null sans table', () => {
assert.equal(englobant([]), null); assert.equal(englobant([]), null);
}); });
test('deux tables : min et max exacts de leurs emprises', () => { test('deux tables : min et max exacts de leurs emprises, des cibles de leurs sièges et de leurs poignées', () => {
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en // Ronde de 8 en (300, 600) : chaises à r + 22 du centre, r = 240/π,
// (−100, 50), emprise 104. // cibles de rayon pas/2 = 30, soit r + 52 de chaque côté — la poignée,
const r = 240 / Math.PI + 44; // à (r + 67)/√2 + 15 ≈ 116,4 sur chaque axe, reste dedans. Carrée de 8
// en (−100, 50) : emprise 104 ; chaises de gauche en x = −182 et du haut
// en y = −32, cibles de rayon 30 : −212 et −62.
const r = 240 / Math.PI;
const e = englobant([ const e = englobant([
geometrieTable(table(1, 'ronde', 300, 600), 8), geometrieTable(table(1, 'ronde', 300, 600), 8),
geometrieTable(table(2, 'carree', -100, 50), 8), geometrieTable(table(2, 'carree', -100, 50), 8),
]); ]);
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']); assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
proche(e.minX, -204, 'minX'); proche(e.minX, -212, 'minX');
proche(e.minY, -54, 'minY'); proche(e.minY, -62, 'minY');
proche(e.maxX, 300 + r, 'maxX'); proche(e.maxX, 300 + r + 52, 'maxX');
proche(e.maxY, 600 + r, 'maxY'); proche(e.maxY, 600 + r + 52, 'maxY');
});
test('une carrée seule : la poignée, au-delà du coin de l’emprise, borne le bas et la droite', () => {
// Carrée de 8 en (−100, 50) : centre de la poignée en (4 + 23/√2,
// 154 + 23/√2), son bord 15 cm plus loin ; les cibles du haut et de la
// gauche bornent l'autre coin.
const e = englobant([geometrieTable(table(2, 'carree', -100, 50), 8)]);
proche(e.minX, -212, 'minX');
proche(e.minY, -62, 'minY');
proche(e.maxX, 4 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxX');
proche(e.maxY, 154 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxY');
});
test('après ajuster, sur une carrée seule de 4 et de 8, le disque de la poignée et chaque cible tiennent dans le cadre moins ses marges', () => {
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
const dedans = (vue, { x, y }, rayon, libelle) => {
const p = ecranDepuisDessin(vue, cadre, x, y);
const r = rayon * vue.k;
assert.ok(p.x - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.x + r <= cadre.largeur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, x`);
assert.ok(p.y - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.y + r <= cadre.hauteur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, y`);
};
let disques = 0;
for (const places of [4, 8]) {
const g = geometrieTable(table(1, 'carree', 0, 0), places);
const vue = ajuster(englobant([g]), cadre);
dedans(vue, g.poignee, RAYON_POIGNEE, `carrée de ${places}, poignée`);
for (const chaise of g.chaises) dedans(vue, chaise, g.rayonCible, `carrée de ${places}, siège ${chaise.siege}`);
disques += 1 + g.chaises.length;
}
assert.equal(disques, 14);
}); });
}); });
describe('caseLibre', () => { describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
test('sans table, l’origine', () => { // colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
assert.deepEqual(caseLibre([]), { x: 0, y: 0 }); // rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la
// plus grande des tables données. LARGEUR_BLOC_NOMS_CM vaut 500.
test('la largeur du bloc de noms est celle du registre', () => {
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
}); });
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => { test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 }); assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 });
}); });
test('un centre en (150, 0) prend les deux premières cases', () => { test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 }); // 2 × 120,3944 + 72 + 500 = 812,789 → 813 ; 2 × 120,3944 + 36 = 276,8 → 300.
assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 8 }]), { colonnes: 813, rangees: 300 });
}); });
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => { test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 }); assert.deepEqual(ecartsGrille([{ forme: 'carree', places: 8 }]), { colonnes: 780, rangees: 300 });
}); });
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => { test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => {
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }]; // diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ;
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 }); // 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430.
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 }); const tables = [{ forme: 'ronde', places: 8 }, { forme: 'ronde', places: 16 }, { forme: 'carree', places: 8 }];
assert.deepEqual(ecartsGrille(tables), { colonnes: 966, rangees: 430 });
});
});
describe('caseLibre : la place d\u{2019}une table créée, sur la grille des démonstrations', () => {
const huit = { forme: 'ronde', places: 8 };
const posee = (x, y, autres = huit) => ({ position: { x, y }, ...autres });
test('sans table, l\u{2019}origine', () => {
assert.deepEqual(caseLibre([], huit), { x: 0, y: 0 });
});
test('deux rondes de 8 en (0, 0) et (813, 0) : deux colonnes, la case suivante est (0, 300)', () => {
assert.deepEqual(caseLibre([posee(0, 0), posee(813, 0)], huit), { x: 0, y: 300 });
});
test('un centre à moins d\u{2019}un pas de colonne, (406,6 ; 0) ou (812,9 ; 0), prend les deux premières cases ; à 813, la première est libre', () => {
// À 406,6 de (0, 0), le bloc de la table nouvelle, de 156,4 à 656,4,
// couvrirait l'emprise posée, de 286,2 à 527 : la case est refusée.
assert.deepEqual(caseLibre([posee(406.6, 0)], huit), { x: 0, y: 300 });
assert.deepEqual(caseLibre([posee(812.9, 0)], huit), { x: 0, y: 300 });
assert.deepEqual(caseLibre([posee(813, 0)], huit), { x: 0, y: 0 });
});
test('un centre à moins d\u{2019}un pas de rangée, (0 ; 150,1) ou (0 ; 299,9), prend la case (0, 0) ; à 300, elle est libre', () => {
assert.deepEqual(caseLibre([posee(0, 150.1)], huit), { x: 813, y: 0 });
assert.deepEqual(caseLibre([posee(0, 299.9)], huit), { x: 813, y: 0 });
assert.deepEqual(caseLibre([posee(0, 300)], huit), { x: 0, y: 0 });
});
test('une table déplacée en (330, 0) prend aussi (813, 0) : la case suivante est (0, 300), et aucun bloc ne passe sur l\u{2019}autre table', () => {
// En (813, 0), à 483 cm, la liste de la table déplacée, de 486,4 à
// 986,4, couvrirait la table nouvelle, de 692,6 à 933,4.
const posees = [posee(330, 0)];
const position = caseLibre(posees, huit);
assert.deepEqual(position, { x: 0, y: 300 });
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)]), { fautes: [], paires: 2 });
});
test('l\u{2019}épreuve des bandes voit la faute : une ronde de 8 en (330, 0) et une autre en (813, 0)', () => {
assert.equal(fautesBandes([posee(330, 0), posee(813, 0)]).fautes.length, 1);
});
test('une ronde de 8 agrandie à 16 au milieu de rondes de 8 : la table créée au nouveau pas ne porte ni ne subit aucune liste', () => {
// Vingt rondes de 8 sur cinq colonnes au pas 813 × 300, la première
// passée à 16 places : le pas devient 966 × 430, sur cinq colonnes. La
// case (3864, 0) est à 612 cm de la table (3252, 0), dont la liste, de
// 3408,4 à 3908,4, couvrirait la table nouvelle, de 3743,6 à 3984,4 :
// elle est prise, comme toute case à moins d'un pas d'une table posée,
// jusqu'à la rangée 1720, à 820 cm de la dernière. Seules les paires où
// figure la table nouvelle sont examinées : la table agrandie porte sa
// liste sur ses voisines, ce que la création ne change pas.
const posees = Array.from({ length: 20 }, (_, rang) => posee((rang % 5) * 813, Math.floor(rang / 5) * 300));
posees[0] = posee(0, 0, { forme: 'ronde', places: 16 });
const position = caseLibre(posees, huit);
assert.deepEqual(position, { x: 0, y: 1720 });
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)], { avec: 21 }), { fautes: [], paires: 2 * 20 });
});
test('le pas suit la table à créer et les tables posées : une ronde de 16 posée écarte la case suivante à 966', () => {
assert.deepEqual(caseLibre([posee(0, 0, { forme: 'ronde', places: 16 })], huit), { x: 966, y: 0 });
assert.deepEqual(caseLibre([posee(0, 0)], { forme: 'ronde', places: 16 }), { x: 966, y: 0 });
});
test('trente-trois rondes de 8 créées une à une : les cases de la grille des démonstrations, et aucun bloc de noms ne passe sur une autre table', () => {
const posees = [];
for (let n = 0; n < 33; n += 1) posees.push(posee(...Object.values(caseLibre(posees, huit))));
assert.equal(posees.length, 33);
assert.equal(new Set(posees.map(({ position: { x, y } }) => `${x},${y}`)).size, 33);
for (const { position } of posees) {
assert.equal(position.x % 813, 0, `x ${position.x}`);
assert.equal(position.y % 300, 0, `y ${position.y}`);
}
assert.deepEqual(fautesBandes(posees), { fautes: [], paires: 33 * 32 });
}); });
}); });

View file

@ -5,9 +5,11 @@
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de // 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, // l'application — système de fichiers, horloge, exécuteur de génération,
// presse-papiers, identifiants, langue, provenance, démonstrations, format // presse-papiers, identifiants, langue, provenance, démonstrations, format
// courant, version du produit, et en option le thème de départ — que // courant, version du produit et sa forme Windows, et en option le thème
// src/main.js construit pour la machine et monterApp pour une épreuve : App // de départ — que src/main.js construit pour la machine et monterApp pour
// ne choisit aucune plateforme. // une épreuve : App ne choisit aucune plateforme, et ne lit la version que
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
// le bloc « À propos ».
// //
// À son initialisation, App crée une fois chacun la séance, sans racine, // À 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 contrôleur de génération et la façade, et les donne aux composants par
@ -40,7 +42,6 @@
import { TABLES_APPLICATION } from '../application/libelles.js'; import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerSeance } from '../application/seance.js'; import { creerSeance } from '../application/seance.js';
import { creerTraduction } from '../application/traduction.js'; import { creerTraduction } from '../application/traduction.js';
import { VERSION } from '../version.genere.js';
import APropos from './APropos.svelte'; import APropos from './APropos.svelte';
import Accueil from './accueil/Accueil.svelte'; import Accueil from './accueil/Accueil.svelte';
import Bandeau from './Bandeau.svelte'; import Bandeau from './Bandeau.svelte';
@ -64,11 +65,19 @@
demonstrations, demonstrations,
formatCourant, formatCourant,
produitVersion, produitVersion,
versionWindows,
themeInitial, themeInitial,
} = untrack(() => dependances); } = untrack(() => dependances);
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue }); const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
const format = creerFormat(t); const format = creerFormat(t);
// Le bloc « À propos » se compose au montage, avant toute séance : une
// forme de la version que les dépendances ne portent pas lève RangeError
// ici, et App ne se monte pas avec un bloc copiable faux (§ 18.6).
const bloc = blocAPropos(
{ version: { affichee: produitVersion, windows: versionWindows }, plateforme: fs.nature, provenance, format: formatCourant },
t,
);
const seanceApp = creerSeance({ const seanceApp = creerSeance({
fs, fs,
horloge, horloge,
@ -192,7 +201,6 @@
const titre = t('coquille.titre', { version: produitVersion }); const titre = t('coquille.titre', { version: produitVersion });
const version = versionVisible(provenance, produitVersion, t); const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, 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 // « 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 // support rompu, la séance le refuse, et le refus s'affiche à côté de la

View file

@ -184,6 +184,22 @@ async function creerParLeFormulaire(m, nom, date = '') {
await cliquer(dans(m, '[data-commande="creer"]')); await cliquer(dans(m, '[data-commande="creer"]'));
} }
// Retient les ajouts de ligne du système simulé — le journal s'écrit avant
// l'état — jusqu'à liberer() : l'opération lancée reste en cours, comme sur
// un support lent, sans délai ni minuterie.
function retenirEcritures(m) {
const ajouterLigne = m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(m.fs, parametres);
};
return () => liberer();
}
// Aucune fenêtre ni boîte de dialogue dans le document. // Aucune fenêtre ni boîte de dialogue dans le document.
function aucuneFenetre() { function aucuneFenetre() {
expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0); expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0);
@ -460,7 +476,11 @@ describe('signalements et récupération (§ 8.1, § 8.8)', () => {
[b, 'Merles A'], [b, 'Merles A'],
]) { ]) {
expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre); const doublon = element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`);
expect(doublon?.textContent).toBe(autre);
// La base nommée se lit à l'écran, pas seulement dans l'arbre.
expect(visible(doublon), autre).toBe(true);
expect(atteint(doublon), autre).toBe(true);
const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles')); const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles'));
expect(noeuds).toEqual([autre]); expect(noeuds).toEqual([autre]);
} }
@ -678,7 +698,11 @@ describe('le dossier de travail (§ 8.5, § 8.6)', () => {
const cheminAutre = `${SOUS_APPDATA}\\data`; const cheminAutre = `${SOUS_APPDATA}\\data`;
expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 })); expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 }));
// Le chemin est une donnée : son propre nœud de texte, à part de la phrase. // Le chemin est une donnée : son propre nœud de texte, à part de la phrase.
expect(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre); const chemin = dans(m, '[data-autre-chemin]');
expect(chemin?.textContent).toBe(cheminAutre);
// Le chemin se lit à l'écran, pas seulement dans l'arbre.
expect(visible(chemin)).toBe(true);
expect(atteint(chemin)).toBe(true);
expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]); expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]);
expect(proposition.textContent).toContain(t('accueil.autre.originaux')); expect(proposition.textContent).toContain(t('accueil.autre.originaux'));
// En tête : avant la création, les démonstrations et toute ligne. // En tête : avant la création, les démonstrations et toute ligne.
@ -802,6 +826,10 @@ describe('les opérations de fichiers (§ 8.7)', () => {
]) { ]) {
expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]); expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]);
const doublon = signalement(base).querySelector(`[data-doublon="${CSS.escape(autre)}"]`);
expect(doublon?.textContent).toBe(autre);
expect(visible(doublon), autre).toBe(true);
expect(atteint(doublon), autre).toBe(true);
} }
}); });
@ -937,6 +965,55 @@ describe('les opérations de fichiers (§ 8.7)', () => {
// --- Les démonstrations (§ 15) ------------------------------------------------------------- // --- Les démonstrations (§ 15) -------------------------------------------------------------
describe('un geste en cours ne se relance pas (§ 8.7, § 15)', () => {
test('créer : une seconde soumission pendant l’écriture ne crée rien ; « Créer » se dit occupé', async () => {
const m = await monter();
const liberer = retenirEcritures(m);
saisir(dans(m, '[data-champ="nom"]'), 'Soirée des Merles');
const creer = dans(m, '[data-commande="creer"]');
await cliquer(creer);
expect(creer.getAttribute('aria-busy')).toBe('true');
expect(creer.getAttribute('aria-disabled')).toBe('true');
await cliquer(creer);
dans(m, '[data-champ="nom"]').form.requestSubmit();
await tick();
liberer();
await attendreEvenement(m, ({ nom }) => nom === 'Soirée des Merles');
await revenirALaListe(m, 1);
expect(lignes(m)).toHaveLength(1);
});
test('une démonstration cliquée deux fois pendant son chargement n’en charge qu’une', async () => {
const m = await monter();
const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom;
const liberer = retenirEcritures(m);
const bouton = dans(m, '[data-demonstration="petite"]');
await cliquer(bouton);
expect(bouton.getAttribute('aria-busy')).toBe('true');
await cliquer(bouton);
liberer();
await attendreEvenement(m, (e) => e.nom === `${nom} 1`);
await revenirALaListe(m, 1);
expect(lignes(m).map((ligne) => colonne(ligne, 'nom'))).toEqual([`${nom} 1`]);
});
test('copier : une seconde validation pendant l’écriture ne fait pas de seconde copie', async () => {
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
const ligne = ligneDe(m, 'bal');
await cliquer(ligne.querySelector('[data-commande="copier"]'));
saisir(ligne.querySelector('[data-champ="nomCopie"]'), 'Bal des Grisailles, variante');
const liberer = retenirEcritures(m);
const valider = ligne.querySelector('[data-commande="validerCopie"]');
await cliquer(valider);
expect(valider.getAttribute('aria-busy')).toBe('true');
await cliquer(valider);
liberer();
await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante');
await revenirALaListe(m, 2);
expect(lignes(m)).toHaveLength(2);
});
});
describe('les démonstrations (§ 15)', () => { describe('les démonstrations (§ 15)', () => {
test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => { test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => {
const m = await monter(); const m = await monter();

View file

@ -22,7 +22,9 @@
// Un état qui ne s'ouvre pas (ETAT_ILLISIBLE) mène à la récupération, qui // 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, // 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 // là où son geste a eu lieu, sans remède : sur la liste, aucun événement
// n'est ouvert pour l'exécuter (refusDeLaListe). // n'est ouvert pour l'exécuter (refusDeLaListe). Un geste qui écrit ne se
// relance pas pendant qu'il s'écrit : la création, comme chaque commande,
// ignore une seconde demande et se dit occupée.
import { getContext, onDestroy, untrack } from 'svelte'; import { getContext, onDestroy, untrack } from 'svelte';
import { versionVisible } from '../apropos.js'; import { versionVisible } from '../apropos.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
@ -59,6 +61,9 @@
let nom = $state(''); let nom = $state('');
let date = $state(''); let date = $state('');
let refusCreation = $state.raw(null); let refusCreation = $state.raw(null);
// Vrai pendant l'écriture d'une création : une seconde soumission ne
// crée rien, et « Créer » se dit occupé.
let creationEnCours = $state(false);
const champRefuse = $derived(champDuRefus(refusCreation)); const champRefuse = $derived(champDuRefus(refusCreation));
const question = $derived(issue?.dossier.question ?? null); const question = $derived(issue?.dossier.question ?? null);
@ -181,13 +186,17 @@
async function creer(evenement) { async function creer(evenement) {
evenement.preventDefault(); evenement.preventDefault();
if (creationEnCours) return;
refusCreation = null; refusCreation = null;
creationEnCours = true;
try { try {
await surLaListe(() => facade.creer({ nom, date })); await surLaListe(() => facade.creer({ nom, date }));
} catch (refus) { } catch (refus) {
if (typeof refus?.code !== 'string') throw refus; if (typeof refus?.code !== 'string') throw refus;
refusCreation = refus; refusCreation = refus;
return; return;
} finally {
creationEnCours = false;
} }
apresOperation(); apresOperation();
} }
@ -254,7 +263,12 @@
{/if} {/if}
</div> </div>
<div class="commandes"> <div class="commandes">
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button> <button
type="submit"
data-commande="creer"
aria-busy={creationEnCours ? 'true' : undefined}
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
>
{#if refusCreation !== null && champRefuse === null} {#if refusCreation !== null && champRefuse === null}
<Refus refus={refusCreation} /> <Refus refus={refusCreation} />
{/if} {/if}
@ -347,4 +361,10 @@
font-size: var(--taille-texte); font-size: var(--taille-texte);
cursor: pointer; cursor: pointer;
} }
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style> </style>

View file

@ -10,6 +10,10 @@
// //
// demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ; // demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ;
// refus : { cle, refus } du dernier échec, ou null. // refus : { cle, refus } du dernier échec, ou null.
//
// Un chargement en cours ne se relance pas : tant que charger n'a pas
// rendu la main, un clic sur une démonstration ne fait rien, et celle qui
// se charge porte aria-busy et aria-disabled.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
@ -17,6 +21,19 @@
const t = getContext('t'); const t = getContext('t');
const uid = $props.id(); const uid = $props.id();
// La clé de la démonstration qui se charge, ou null.
let enCours = $state(null);
async function choisir(cle) {
if (enCours !== null) return;
enCours = cle;
try {
await charger(cle);
} finally {
enCours = null;
}
}
</script> </script>
<section class="demonstrations" aria-labelledby={`${uid}-titre`}> <section class="demonstrations" aria-labelledby={`${uid}-titre`}>
@ -25,7 +42,13 @@
<ul> <ul>
{#each demonstrations as { cle, nom } (cle)} {#each demonstrations as { cle, nom } (cle)}
<li> <li>
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button> <button
type="button"
data-demonstration={cle}
aria-busy={enCours === cle ? 'true' : undefined}
aria-disabled={enCours === cle ? 'true' : undefined}
onclick={() => choisir(cle)}>{nom}</button
>
{#if refus !== null && refus.cle === cle} {#if refus !== null && refus.cle === cle}
<Refus refus={refus.refus} /> <Refus refus={refus.refus} />
{/if} {/if}
@ -81,4 +104,10 @@
outline: 2px solid var(--couleur-focus); outline: 2px solid var(--couleur-focus);
outline-offset: 2px; outline-offset: 2px;
} }
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style> </style>

View file

@ -10,7 +10,10 @@
// la corbeille et se confirme en place, par un second clic : aucune // 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 // fenêtre. Chaque refus s'affiche contre le champ ou à côté de sa
// commande, sans remède (surLaListe) ; apresOperation() suit chaque // commande, sans remède (surLaListe) ; apresOperation() suit chaque
// opération réussie. // opération réussie. Une validation en cours ne se relance pas : une
// seconde soumission pendant l'écriture ne fait rien, et le bouton de
// validation se dit occupé ; supprimer passe par la Commande, qui a la
// même garde.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
@ -26,6 +29,8 @@
// texte }, ou null ; et son refus. // texte }, ou null ; et son refus.
let saisie = $state(null); let saisie = $state(null);
let refus = $state.raw(null); let refus = $state.raw(null);
// Vrai pendant l'écriture d'une copie ou d'un renommage.
let validationEnCours = $state(false);
const surLeChamp = $derived(champDuRefus(refus) === 'nom'); const surLeChamp = $derived(champDuRefus(refus) === 'nom');
function ouvrirSaisie(operation) { function ouvrirSaisie(operation) {
@ -46,14 +51,18 @@
async function valider(evenement) { async function valider(evenement) {
evenement.preventDefault(); evenement.preventDefault();
if (validationEnCours) return;
refus = null; refus = null;
const { operation, texte } = saisie; const { operation, texte } = saisie;
validationEnCours = true;
try { try {
await surLaListe(() => GESTES[operation](texte)); await surLaListe(() => GESTES[operation](texte));
} catch (erreur) { } catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur; if (typeof erreur?.code !== 'string') throw erreur;
refus = erreur; refus = erreur;
return; return;
} finally {
validationEnCours = false;
} }
fermerSaisie(); fermerSaisie();
apresOperation(); apresOperation();
@ -87,7 +96,12 @@
{/if} {/if}
</div> </div>
<div class="commandes"> <div class="commandes">
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button> <button
type="submit"
data-commande={saisie.commande}
aria-busy={validationEnCours ? 'true' : undefined}
aria-disabled={validationEnCours ? 'true' : undefined}>{t(saisie.valider)}</button
>
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button> <button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
{#if refus !== null && !surLeChamp} {#if refus !== null && !surLeChamp}
<Refus {refus} /> <Refus {refus} />
@ -149,4 +163,10 @@
outline: 2px solid var(--couleur-focus); outline: 2px solid var(--couleur-focus);
outline-offset: 2px; outline-offset: 2px;
} }
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style> </style>

View file

@ -16,14 +16,24 @@ import { aUnLibelle } from './libelles.js';
* quand le mode n'en a pas ; la plateforme sous sa nature. * quand le mode n'en a pas ; la plateforme sous sa nature.
* *
* @param {Object} champs * @param {Object} champs
* @param {{affichee: string, windows: string}} champs.version VERSION * @param {{affichee: string, windows: string}} champs.version les deux formes
* de la version, celles que reçoit App (produitVersion, versionWindows)
* @param {string} champs.plateforme la nature du système de fichiers * @param {string} champs.plateforme la nature du système de fichiers
* @param {string} champs.provenance le mode de construction de Vite * @param {string} champs.provenance le mode de construction de Vite
* @param {number} champs.format la version de format des fichiers * @param {number} champs.format la version de format des fichiers
* @param {(cle: string, details?: Object) => string} t * @param {(cle: string, details?: Object) => string} t
* @returns {string} sans saut de ligne * @returns {string} sans saut de ligne
* @throws {RangeError} une forme de la version absente, vide ou qui n'est
* pas une chaîne, en la nommant : une dépendance d'App oubliée
* échoue au montage, plutôt que de donner un bloc copiable faux
*/ */
export function blocAPropos({ version, plateforme, provenance, format }, t) { export function blocAPropos({ version, plateforme, provenance, format }, t) {
for (const forme of ['affichee', 'windows']) {
const valeur = version[forme];
if (typeof valeur !== 'string' || valeur === '') {
throw new RangeError(`À propos : la version ${forme} manque (${JSON.stringify(valeur) ?? 'undefined'})`);
}
}
return t('coquille.apropos.bloc', { return t('coquille.apropos.bloc', {
affichee: version.affichee, affichee: version.affichee,
windows: version.windows, windows: version.windows,

View file

@ -6,6 +6,7 @@
// version de format —, et ce qui tient lieu de version à l'écran, la marque // version de format —, et ce qui tient lieu de version à l'écran, la marque
// de provenance dans une construction de documentation (§ 19.4). // de provenance dans une construction de documentation (§ 19.4).
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { TABLES_APPLICATION } from '../application/libelles.js'; import { TABLES_APPLICATION } from '../application/libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js'; import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
@ -49,6 +50,23 @@ describe('blocAPropos (§ 18.6)', () => {
} }
}); });
test('une forme de la version absente, vide ou qui n’est pas une chaîne lève RangeError en la nommant : jamais un bloc copiable faux', () => {
const fautes = [undefined, null, '', 7];
assert.equal(fautes.length, 4);
for (const faute of fautes) {
for (const [forme, version] of [
['windows', { affichee: VERSION.affichee, windows: faute }],
['affichee', { affichee: faute, windows: VERSION.windows }],
]) {
assert.throws(
() => blocAPropos({ version, plateforme: 'web', provenance: 'test', format: FORMAT }, t),
(erreur) => erreur instanceof RangeError && erreur.message.includes(forme),
`${forme} ${String(faute)}`,
);
}
}
});
test('en langue témoin, le bloc entier est encadré une fois', () => { 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 bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
const { ouverture, fermeture } = MARQUE_TEMOIN; const { ouverture, fermeture } = MARQUE_TEMOIN;
@ -87,3 +105,17 @@ describe('provenanceVisible (§ 18.6)', () => {
assert.equal(provenanceVisible('production', temoin), 'production'); assert.equal(provenanceVisible('production', temoin), 'production');
}); });
}); });
describe('la version de l’interface vient des dépendances (§ 13.4, § 18.6)', () => {
test('App.svelte ne lit pas la version engendrée : la version affichée et la forme Windows arrivent par ses propriétés, produitVersion et versionWindows', () => {
const source = readFileSync(new URL('./App.svelte', import.meta.url), 'utf8');
const imports = [...source.matchAll(/^\s*import\s[^;]*?from\s+'([^']+)'/gmu)].map(([, chemin]) => chemin);
assert.ok(imports.length > 0, 'des imports relevés');
assert.deepEqual(
imports.filter((chemin) => chemin.includes('version.genere')),
[],
);
assert.match(source, /\bversionWindows\b/u);
assert.match(source, /blocAPropos\(\s*\{\s*version:\s*\{\s*affichee:\s*produitVersion,\s*windows:\s*versionWindows\s*\}/u);
});
});

View file

@ -5,7 +5,8 @@
// propos », le tour montré, la personne mise en évidence, la proposition // 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 // 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 // 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 // du plan, le refus posé sur une place et l'appartenance collante du
// formulaire d'ajout. Ce qu'il tient survit au démontage des sections. 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 // é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. // rien de ce qu'il tient ne quitte la mémoire.
// //
@ -24,7 +25,7 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* @property {number|null} proposition la proposition demandée au plan * @property {number|null} proposition la proposition demandée au plan
* @property {null|{texte: string, origine: string}} importEnAttente * @property {null|{texte: string, origine: string}} importEnAttente
* @property {boolean} listes les listes de noms sont montrées * @property {boolean} listes les listes de noms sont montrées
* @property {boolean} pleinEcran * @property {boolean} pleinEcran vrai seulement sur la section du plan
* @property {Object|null} selection la place sélectionnée * @property {Object|null} selection la place sélectionnée
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte * @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
* @property {null|{genre: 'personne'|'reservation', participant: number, * @property {null|{genre: 'personne'|'reservation', participant: number,
@ -38,6 +39,8 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* avertissements: Object[]}} pose * avertissements: Object[]}} pose
* le refus, ou les avertissements, posés sur une place du plan, que * le refus, ou les avertissements, posés sur une place du plan, que
* Plan.svelte montre sur la place (§ 5.9) * Plan.svelte montre sur la place (§ 5.9)
* @property {string} appartenance l'appartenance collante du formulaire d'ajout
* des participants : celle du dernier ajout (§ 10.2), '' au départ
*/ */
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos » // L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@ -54,6 +57,7 @@ const DEPART = Object.freeze({
fiche: null, fiche: null,
choix: null, choix: null,
pose: null, pose: null,
appartenance: '',
}); });
const GENRES_CHOIX = new Set(['personne', 'reservation']); const GENRES_CHOIX = new Set(['personne', 'reservation']);
@ -98,16 +102,19 @@ function choixValide(choix) {
* prendreImport: () => null|{texte: string, origine: string}, * prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function, * basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function, ouvrirFiche: Function, choisir: Function, * selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function}} * poserSurPlace: Function, garderAppartenance: Function}}
*/ */
export function creerEtatUi() { export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false }); let etat = Object.freeze({ ...DEPART, apropos: false });
const abonnes = new Set(); const abonnes = new Set();
// Publie l'état que donnent les changements, s'il diffère du courant sur // Publie l'état que donnent les changements, s'il diffère du courant sur
// l'une de ses propriétés. // l'une de ses propriétés. Le plein écran ne vaut que pour le plan : hors
// de lui, il tombe, quelle que soit la transition qui quitte le plan, et
// la navigation des sections, que le plein écran efface, revient.
function changer(changements) { function changer(changements) {
const suivant = { ...etat, ...changements }; const suivant = { ...etat, ...changements };
if (suivant.section !== 'plan') suivant.pleinEcran = false;
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return; if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
etat = Object.freeze(suivant); etat = Object.freeze(suivant);
for (const fn of [...abonnes]) fn(etat); for (const fn of [...abonnes]) fn(etat);
@ -189,6 +196,12 @@ export function creerEtatUi() {
} }
changer({ pose: Object.freeze({ place, refus, avertissements }) }); changer({ pose: Object.freeze({ place, refus, avertissements }) });
}, },
/** Garde l'appartenance collante du formulaire d'ajout, une chaîne ;
* RangeError sinon. */
garderAppartenance(texte) {
if (typeof texte !== 'string') throw new RangeError(`appartenance : chaîne attendue, reçu ${String(texte)}`);
changer({ appartenance: texte });
},
/** Ouvre la fiche de la personne id, entier positif, dans la section des /** Ouvre la fiche de la personne id, entier positif, dans la section des
* participants ; null referme la fiche, la section reste. RangeError * participants ; null referme la fiche, la section reste. RangeError
* sinon. */ * sinon. */

View file

@ -23,6 +23,7 @@ const DEPART = {
fiche: null, fiche: null,
choix: null, choix: null,
pose: null, pose: null,
appartenance: '',
}; };
// Un état et le relevé de ce que reçoit son abonné. // Un état et le relevé de ce que reçoit son abonné.
@ -149,6 +150,29 @@ describe('état de navigation : chaque transition', () => {
assert.deepEqual(ui.etat(), DEPART); assert.deepEqual(ui.etat(), DEPART);
}); });
test('le plein écran ne vaut que pour le plan : quitter le plan le retire, par allerA, ouvrirFiche ou ouvrirImport ; hors du plan, il ne se pose pas', () => {
const quitters = [
...SECTIONS.filter((section) => section !== 'plan').map((section) => [`allerA ${section}`, (ui) => ui.allerA(section)]),
['ouvrirFiche', (ui) => ui.ouvrirFiche(4)],
['ouvrirImport', (ui) => ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'collage' })],
];
assert.ok(SECTIONS.includes('plan'));
assert.equal(quitters.length, SECTIONS.length - 1 + 2);
for (const [nom, quitter] of quitters) {
const ui = creerEtatUi();
ui.allerA('plan');
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, true, nom);
quitter(ui);
assert.notEqual(ui.etat().section, 'plan', nom);
assert.equal(ui.etat().pleinEcran, false, nom);
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, false, `${nom} : hors du plan, la bascule ne pose rien`);
ui.allerA('plan');
assert.equal(ui.etat().pleinEcran, false, `${nom} : revenu au plan, la tête est là`);
}
});
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => { test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
const ui = creerEtatUi(); const ui = creerEtatUi();
ui.allerA('plan'); ui.allerA('plan');
@ -169,6 +193,22 @@ describe('état de navigation : chaque transition', () => {
assert.equal(ui.etat(), avant); assert.equal(ui.etat(), avant);
}); });
test('garderAppartenance tient l’appartenance collante du formulaire d’ajout (§ 10.2) : elle survit au changement de section, et un autre événement repart vide', () => {
const ui = creerEtatUi();
ui.allerA('participants');
ui.garderAppartenance('Club des Merles');
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.allerA('plan');
ui.allerA('participants');
ui.ouvrirApropos();
ui.fermerApropos();
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.reinitialiser();
assert.equal(ui.etat().appartenance, '');
for (const faute of [null, undefined, 3, {}]) assert.throws(() => ui.garderAppartenance(faute), RangeError, String(faute));
assert.equal(ui.etat().appartenance, '');
});
test('une transition qui ne change rien ne publie pas d’état neuf', () => { test('une transition qui ne change rien ne publie pas d’état neuf', () => {
const { ui, recus } = suivi(); const { ui, recus } = suivi();
ui.allerA(SECTION_OUVERTURE); ui.allerA(SECTION_OUVERTURE);

View file

@ -6,7 +6,8 @@
// l'ordre de la séance (avertissementsOrdonnes, modele.js) : chacun par le // 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 // 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 —, // numéro et leurs fautes ; les placements en dérive et leurs raisons —,
// puis son remède en Commande, par actionRemede (primitives/commande.js). // puis son remède en Commande, par actionRemede (primitives/commande.js),
// confirmé en place quand actionRemede le demande — « Débloquer ».
// Un avertissement n'empêche rien. // Un avertissement n'empêche rien.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
@ -62,7 +63,13 @@
</ul> </ul>
{/if} {/if}
{#if action !== null} {#if action !== null}
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} /> <Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if} {/if}
</div> </div>
{/each} {/each}
@ -74,7 +81,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--espace-2); gap: var(--espace-2);
margin-bottom: var(--espace-3);
} }
.avertissement { .avertissement {

View file

@ -8,10 +8,30 @@
// à droite, la place fixe, le libellé de mode, puis « Défaire » et // à 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 // « 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 // 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 // les annonces, annonces, quand il y en a — support rompu et
// ne la précède dans l'écran de l'événement, si bien qu'un refus posé dans // avertissements —, puis la surface, children. La barre ouvre le cadre :
// la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête // rien ne la précède dans l'écran de l'événement, si bien qu'un refus posé
// sans déplacer la place fixe (§ 8.4). // 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). Tête, annonces et surface défilent
// chacune dans sa zone : des avertissements nombreux ne poussent pas la
// surface hors du cadre, ni le cadre hors de la fenêtre. La surface porte
// le plan, que l'opérateur regarde : elle réclame les trois quarts du
// cadre avant que les annonces ne dépassent leur première ligne, sauf
// devant un support rompu, dont les issues restent en vue.
//
// Sous une tête, et hors d'un support rompu, les annonces se posent à
// côté d'elle, dans la place que sa navigation laisse à droite : leur zone
// prend la hauteur de la tête et défile dedans, si bien qu'un
// avertissement ne retire aucune hauteur à la surface, ni au plan qu'elle
// porte. Une fenêtre trop étroite pour les deux les passe à la ligne, et
// la zone garde alors sa hauteur minimale.
//
// La tête se rend en un seul point du DOM, quelle que soit la place des
// annonces : seules les annonces passent de côté à dessous quand une
// annonce vient, s'en va, ou que le support se rompt. Un rendu de la tête
// dans deux blocs la démonterait à chacun de ces passages — la Commande
// en cours détruite, le refus posé à côté du geste perdu (§ 2.1), le
// focus du clavier rendu au corps de la page.
// //
// La place fixe porte la commande que rend commandeFixe (modele.js) : // La place fixe porte la commande que rend commandeFixe (modele.js) :
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —, // « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
@ -29,7 +49,7 @@
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js'; import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
let { children, tete = null } = $props(); let { children, tete = null, annonces = null } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -38,12 +58,17 @@
// forme de la lecture, et n'a pas de libellé. // forme de la lecture, et n'a pas de libellé.
const cadre = $derived(cadreDeMode($seance)); const cadre = $derived(cadreDeMode($seance));
const forme = $derived(cadre?.forme ?? 'tirets'); const forme = $derived(cadre?.forme ?? 'tirets');
// Le trait du cadre est une marque (§ 14.5) : data-marque nomme l'entrée
// du registre (marques.js) qui porte le jeton de sa couleur.
const MARQUE_DE_LA_FORME = { tirets: 'cadreLecture', plein: 'cadreEcriture', double: 'cadreBloque' };
const commande = $derived(commandeFixe($seance)); const commande = $derived(commandeFixe($seance));
const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null); const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null);
const reprise = $derived(repriseOfferte($seance)); const reprise = $derived(repriseOfferte($seance));
// Les annonces se posent à côté de la tête, ou dessous.
const aCote = $derived(tete !== null && annonces !== null && $seance.supportRompu === null);
</script> </script>
<div class="cadre" data-mode={forme} class:tirets={forme === 'tirets'} class:double={forme === 'double'}> <div class="cadre" data-mode={forme} data-marque={MARQUE_DE_LA_FORME[forme]} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
<div class="barre" role="group" aria-label={t('evenement.cadre.commandes')}> <div class="barre" role="group" aria-label={t('evenement.cadre.commandes')}>
<div class="ligne"> <div class="ligne">
<div class="fixe" data-place-fixe> <div class="fixe" data-place-fixe>
@ -96,9 +121,23 @@
{/if} {/if}
</div> </div>
{#if tete !== null} {#if tete !== null}
<div class="tete"> <div class="tete" class:cote={aCote}>
<div class="contenu">
{@render tete()} {@render tete()}
</div> </div>
{#if aCote}
<div class="place">
<div class="annonces" data-annonces>
{@render annonces()}
</div>
</div>
{/if}
</div>
{/if}
{#if annonces !== null && !aCote}
<div class="annonces seules" class:rompu={$seance.supportRompu !== null} data-annonces>
{@render annonces()}
</div>
{/if} {/if}
<div class="surface"> <div class="surface">
{@render children()} {@render children()}
@ -201,8 +240,81 @@
border-bottom: 1px solid var(--couleur-page-filet); border-bottom: 1px solid var(--couleur-page-filet);
} }
/* Les annonces défilent dans leur zone, au-delà de sa hauteur. */
.annonces {
display: flex;
flex-direction: column;
gap: var(--espace-2);
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
}
/* À côté de la tête : la tête passe en ligne, son contenu à gauche, la
place des annonces à droite. La place ne donne à la ligne que sa
hauteur minimale ; la zone des annonces la remplit, posée en absolu,
et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête.
La place prend une part fixe de la largeur, et le contenu le reste :
la zone ne bouge pas d'une section à l'autre, quand le libellé de la
section ouverte, en gras, change la largeur de la navigation. Sous
30rem de contenu, la place passe à la ligne. */
.tete.cote {
flex-flow: row wrap;
align-items: stretch;
gap: 0 var(--espace-3);
padding-right: 0;
padding-block: 0;
}
.contenu {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
.cote > .contenu {
flex: 1 1 0;
min-width: min(100%, 30rem);
padding-block: var(--espace-2);
}
.place {
position: relative;
flex: 0 0 32%;
min-width: 16rem;
min-height: 3rem;
border-left: 1px solid var(--couleur-page-filet);
}
.place > .annonces {
position: absolute;
inset: 0;
}
/* Seules — sans tête, en plein écran, ou devant un support rompu —, les
annonces ne prennent jamais plus du quart du cadre, et cèdent d'abord
leur hauteur à la surface : leur facteur de rétrécissement, mille fois
celui de la surface, leur fait absorber presque tout le manque de
place, jusqu'à leur hauteur minimale : un avertissement d'une ligne en
entier, et le haut du suivant, qui dit que la zone en porte d'autres. */
.annonces.seules {
flex: 0 1000 auto;
min-height: 3rem;
max-height: 25%;
border-bottom: 1px solid var(--couleur-page-filet);
}
/* Le support rompu, en tête des annonces, porte les seules issues de
l'opérateur : tant qu'il est là, les annonces ne cèdent rien à la
surface, et gardent leur hauteur jusqu'au quart du cadre ; les
avertissements qui le suivent défilent sous lui. */
.annonces.rompu {
flex-shrink: 0;
}
/* La surface part des trois quarts du cadre, s'étend à ce qui reste, et
ne rétrécit, après les annonces, que pour la barre et la tête. */
.surface { .surface {
flex: 1 1 auto; flex: 1 1 75%;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: var(--espace-3); padding: var(--espace-3);

View file

@ -18,6 +18,7 @@
// du support rompu. Le texte attendu vient de la même traduction que celle // du support rompu. Le texte attendu vient de la même traduction que celle
// de l'application ; les noms d'épreuve sont inventés. // de l'application ; les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js'; import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { INACTIVITE_MS } from '../../application/seance.js'; import { INACTIVITE_MS } from '../../application/seance.js';
@ -30,7 +31,7 @@ import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../s
import { lireJournal } from '../../stockage/journal.js'; import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js'; import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js'; import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { fixerCadre, molette } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js'; import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js'; import { visible } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
@ -87,6 +88,38 @@ const auCentre = (element) => {
return document.elementFromPoint(left + width / 2, top + height / 2); return document.elementFromPoint(left + width / 2, top + height / 2);
}; };
// Un appui réel annoncé par Chromium comme le rang-ième clic d'une série
// (clickCount) : son événement click porte detail rang. cible est un élément,
// visé en son centre, ou le point que rend l'appui précédent de la série. Les
// coordonnées du cadre de l'épreuve passent dans la fenêtre de la page qui le
// porte, d'un cadre à son parent, comme dans test/navigateur/entrees.js.
async function cliquerEnSerie(cible, rang) {
let { x, y } = cible;
if (cible instanceof Element) {
const { left, top, width, height } = cible.getBoundingClientRect();
[x, y] = [left + width / 2, top + height / 2];
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const porteur = fenetre.frameElement;
const boite = porteur.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(porteur);
const echelle = porteur.offsetWidth > 0 ? boite.width / porteur.offsetWidth : 1;
x = boite.left + (porteur.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (porteur.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
}
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: rang });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: rang });
flushSync();
return { x, y };
}
// 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.
async function viderFile(m) {
await m.seance.formeNommee(null).catch(() => undefined);
flushSync();
}
// Le refus posé à côté d'un bouton de Commande : dans son conteneur. // Le refus posé à côté d'un bouton de Commande : dans son conteneur.
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`); const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
@ -214,6 +247,27 @@ describe('la place fixe (§ 8.4, § 8.5)', () => {
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]); expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
expect(visible(fixe(m, 'debloquer'))).toBe(true); expect(visible(fixe(m, 'debloquer'))).toBe(true);
}); });
test('un double-clic sur « Modifier » passe en écriture et y reste : le second clic de la série tombe sur « Revenir en lecture », venu à sa place, qui ne l’exécute pas', async () => {
const m = await neuf();
const avant = m.trace.length;
const clics = [];
m.racine.addEventListener('click', (evenement) => clics.push([evenement.target.dataset.commande, evenement.detail]), true);
const point = await cliquerEnSerie(fixe(m, 'modifier'), 1);
await attendre(() => expect(fixe(m, 'lecture')).not.toBeNull());
await viderFile(m);
await cliquerEnSerie(point, 2);
await viderFile(m);
expect(clics).toEqual([
['modifier', 1],
['lecture', 2],
]);
expect(m.seance.etat().mode).toBe('ecriture');
expect(fixe(m, 'lecture')).not.toBeNull();
const verrous = m.trace.slice(avant).filter(([primitive]) => primitive === 'verrouiller' || primitive === 'deverrouiller');
expect(verrous.map(([primitive]) => primitive)).toEqual(['verrouiller']);
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
});
}); });
describe('un nom long ou de glyphes larges (point d’attention 3)', () => { describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
@ -230,6 +284,11 @@ describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
expect(titre.textContent).toBe(nom); expect(titre.textContent).toBe(nom);
expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth); expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth);
expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth); expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth);
// Ni le titre ni la tête qui le porte ne débordent : la tête défile en
// hauteur, et un nom qui ne passerait pas à la ligne s'étendrait en
// largeur sous elle sans élargir la page.
const tete = titre.closest('header').parentElement;
expect([titre.scrollWidth, tete.scrollWidth], motif).toEqual([titre.clientWidth, tete.clientWidth]);
expect(coin(fixe(m, 'modifier')), motif).toEqual(place); expect(coin(fixe(m, 'modifier')), motif).toEqual(place);
expect(visible(fixe(m, 'modifier')), motif).toBe(true); expect(visible(fixe(m, 'modifier')), motif).toBe(true);
await m.seance.fermer(); await m.seance.fermer();
@ -475,6 +534,25 @@ describe('défaire, refaire (§ 8.3)', () => {
await attendre(() => expect(historique(m, 'refaire')).toBeNull()); await attendre(() => expect(historique(m, 'refaire')).toBeNull());
}); });
test('un double-clic sur « Défaire » rend un seul geste : le second clic de la série, reçu une fois le premier retour écrit, n’en défait pas un autre', 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();
const { entrees } = journalDe(m);
const point = await cliquerEnSerie(historique(m, 'defaire'), 1);
await attendre(() => expect(nombre(m)).toBe(2));
await viderFile(m);
expect(historique(m, 'defaire').getAttribute('aria-busy')).toBeNull();
await cliquerEnSerie(point, 2);
await viderFile(m);
expect(nombre(m)).toBe(2);
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
// Un clic simple ensuite défait de nouveau.
await cliquerEnSerie(historique(m, 'defaire'), 1);
await attendre(() => expect(nombre(m)).toBe(1));
});
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 () => { 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(); const m = await neuf();
await m.seance.passerEnEcriture(); await m.seance.passerEnEcriture();
@ -552,6 +630,36 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
await attendre(() => expect(m.ui.etat().section).toBe('propositions')); await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
}); });
test('en plein écran, le remède d’une dérive mène à sa section, et la navigation des sections y revient : le plein écran ne vaut que pour le plan', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
m.ui.allerA('plan');
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
dans(m, '[data-commande="pleinEcran"]').click();
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(0);
const remede = await attendre(() => {
const trouve = dans(m, '[data-annonces] [data-avertissement="DERIVE_NOUVELLE"] [data-commande="ouvrirSection"]');
expect(trouve).not.toBeNull();
return trouve;
});
remede.click();
await attendre(() => expect(m.ui.etat().section).not.toBe('plan'));
flushSync();
const navigation = tous(m, '[data-commande="section"]');
expect(navigation).toHaveLength(SECTIONS.length);
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
navigation.find((bouton) => bouton.dataset.cible === 'tables').click();
flushSync();
expect(dans(m, '[data-section]').dataset.section).toBe('tables');
m.ui.allerA('plan');
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
});
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 () => { 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(); const m = await neuf();
await garnir(m); await garnir(m);
@ -578,6 +686,170 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
}); });
}); });
describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', () => {
// Ferme l'événement garni et généré de base, l'abîme, puis le rouvre : une
// proposition de longueur fausse, une périmée, et une entrée du journal
// abîmée lui donnent trois avertissements à l'ouverture, au moins.
async function rouvrirAbime(m, base) {
await m.seance.fermer();
reecrireEtat(m, base, (charge) => {
charge.propositions[0].placement.pop();
charge.participants[0].exclu = true;
});
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 === 4);
expect(rang).toBeGreaterThan(0);
lignes[rang] = '{"type":"entree", abîmée';
m.fs.deposer('documents', chemin, lignes.join('\n'));
await m.seance.ouvrir(base);
flushSync();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
test('à 1 366 × 700, trois avertissements et plus : la page ne défile pas, le plan garde sa hauteur ; les avertissements défilent à la molette, sans rien déplacer', async () => {
const m = await neuf();
await garnir(m);
await generer(m, { retenir: false });
const base = baseDe(m);
// La hauteur du dessin du plan sans aucune annonce : la mesure de
// référence de ce que les avertissements peuvent lui prendre.
m.ui.allerA('plan');
flushSync();
expect(dans(m, '[data-annonces]')).toBeNull();
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
expect(hauteurSeule).toBeGreaterThan(0);
await rouvrirAbime(m, base);
expect(m.ui.etat().section).toBe('plan');
const montres = tous(m, '[data-avertissement]');
expect(montres.length).toBeGreaterThanOrEqual(3);
const boite = (element) => element.getBoundingClientRect().toJSON();
// La page ne défile pas : le cadre tient dans la fenêtre, et la section
// du plan dans le cadre, sans que la surface qui la porte ait à défiler.
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
const section = dans(m, '[data-section]');
const porteur = section.parentElement;
expect(section.getBoundingClientRect().bottom).toBeLessThanOrEqual(porteur.getBoundingClientRect().bottom);
expect(porteur.scrollHeight).toBeLessThanOrEqual(porteur.clientHeight);
expect(visible(dans(m, '[data-plan]'))).toBe(true);
// Le plan reste la surface de travail : les annonces ne prennent pas
// plus du quart de la hauteur de son dessin.
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule * 0.75);
// Les avertissements débordent leur zone, qui défile seule, à la
// molette, comme sous la main de l'opérateur : le dernier, amené dans la
// zone, se voit, et ni la page, ni la place fixe, ni la section n'ont
// bougé.
const zone = montres[0].closest('[data-annonces]');
expect(zone).not.toBeNull();
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
expect(zone.scrollHeight).toBeGreaterThan(zone.clientHeight);
expect(['auto', 'scroll']).toContain(getComputedStyle(zone).overflowY);
expect(visible(montres[0])).toBe(true);
const place = coin(fixe(m, 'modifier'));
const avant = boite(section);
const surZone = zone.getBoundingClientRect();
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
expect(zone.scrollTop).toBeGreaterThan(0);
expect(window.scrollY).toBe(0);
expect(coin(fixe(m, 'modifier'))).toEqual(place);
expect(boite(section)).toEqual(avant);
// Sur chaque section, la page reste sans défilement, et la zone garde
// sa boîte.
const zoneAvant = boite(zone);
for (const nom of SECTIONS) {
m.ui.allerA(nom);
flushSync();
expect(document.documentElement.scrollHeight, nom).toBeLessThanOrEqual(innerHeight);
expect(boite(dans(m, '[data-annonces]')), nom).toEqual(zoneAvant);
}
});
test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan garde toute sa hauteur, et ses chaises au zoom ajusté', async () => {
const m = await monter();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
await m.seance.passerEnEcriture();
for (let n = 0; n < 18; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `${NOMS[n % NOMS.length]} ${n + 1}` } });
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
await generer(m);
m.ui.allerA('plan');
flushSync();
const ajuster = async () => {
dans(m, '[data-commande="ajuster"]').click();
flushSync();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
flushSync();
};
await ajuster();
expect(dans(m, '[data-annonces]')).toBeNull();
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
expect(hauteurSeule).toBeGreaterThan(0);
// Trois gestes admis sur le retenu, chacun une dérive annoncée.
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
await m.seance.executer('creerTable', { forme: 'ronde' });
await m.seance.executer('changerTours', { tours: 4 });
flushSync();
await ajuster();
const montres = tous(m, '[data-annonces] [data-avertissement]');
expect(montres.length).toBeGreaterThan(0);
// La zone se voit, et le premier avertissement s'ouvre dans elle.
const zone = dans(m, '[data-annonces]');
expect(visible(zone)).toBe(true);
const [boiteZone, premier] = [zone.getBoundingClientRect(), montres[0].getBoundingClientRect()];
expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top);
expect(premier.top).toBeLessThan(boiteZone.bottom - 16);
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - 1);
const tables = tous(m, '[data-plan] [data-table]');
expect(tables.length).toBe(4);
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);
// La navigation et la place fixe restent atteintes.
const navigation = tous(m, '[data-commande="section"]');
expect(navigation).toHaveLength(SECTIONS.length);
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
expect(fixe(m, 'lecture').contains(auCentre(fixe(m, 'lecture')))).toBe(true);
});
test('à 1 366 × 700, un support rompu et trois avertissements et plus : chaque issue du support rompu se voit sans défiler, la page ne défile pas, et les avertissements défilent sous lui', async () => {
const m = await neuf();
await garnir(m);
await generer(m, { retenir: false });
await rouvrirAbime(m, baseDe(m));
expect(tous(m, '[data-avertissement]').length).toBeGreaterThanOrEqual(3);
fixe(m, 'modifier').click();
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
m.fs.pannes.ecriture('documents');
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
flushSync();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
expect(m.seance.etat().supportRompu).not.toBeNull();
const montres = tous(m, '[data-avertissement]');
expect(montres.length).toBeGreaterThanOrEqual(3);
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
// Les issues du support rompu, seule sortie de l'opérateur, se voient
// sans que rien ne défile.
const message = dans(m, '[data-support-rompu]');
for (const nom of ['choisirDossier', 'abandonner']) {
const issue = message.querySelector(`[data-commande="${nom}"]`);
expect(issue, nom).not.toBeNull();
expect(visible(issue), nom).toBe(true);
}
expect(visible(fixe(m, 'lecture'))).toBe(true);
expect(visible(dans(m, '[data-plan]'))).toBe(true);
// Les avertissements restent dans la zone, qui défile seule : le
// dernier, amené à la molette, se voit, et la page n'a pas bougé.
const zone = message.closest('[data-annonces]');
expect(zone).not.toBeNull();
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
const surZone = zone.getBoundingClientRect();
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
expect(window.scrollY).toBe(0);
});
});
describe('le journal (§ 8.6, § 8.2)', () => { 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 () => { test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => {
const m = await neuf(); const m = await neuf();
@ -634,7 +906,12 @@ describe('la dérive (§ 9)', () => {
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu'); expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
const element = avertissement(m, 'DERIVE_NOUVELLE'); const element = avertissement(m, 'DERIVE_NOUVELLE');
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details)); expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue })); // La raison telle que la séance la rend, détails compris : son texte
// est celui que l'avertissement affiche.
const { raisons } = nouvelle.details.touchees.find(({ id }) => id === 'retenu');
const exclusion = raisons.find(({ code }) => code === 'PARTICIPANT_EXCLU');
expect(exclusion.participant).toBe(exclue);
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', exclusion));
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } }); await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1); expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
}); });
@ -666,6 +943,43 @@ describe('la dérive (§ 9)', () => {
}); });
}); });
describe('la tête reste en place quand les annonces vont et viennent (§ 2.1, § 8.4)', () => {
test('une dérive affichée, l’écriture refusée : « Bloqué » cliqué reste le même élément, et porte à côté de lui le refus ECRITURE', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
flushSync();
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
m.fs.pannes.ecriture('documents');
const avant = boutonEtat(m, 'bloque');
avant.click();
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
await viderFile(m);
// Le support rompu fait passer les annonces sous la tête : le bouton du
// geste reste celui qu'a reçu le clic, et le refus se pose à côté de lui.
expect(boutonEtat(m, 'bloque')).toBe(avant);
expect(avant.isConnected).toBe(true);
await attendre(() => expect(refusPres(avant, 'ECRITURE')).not.toBeNull());
expect(visible(refusPres(avant, 'ECRITURE'))).toBe(true);
});
test('le premier avertissement venu, le focus du clavier reste sur le bouton de la tête qui le portait', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
expect(dans(m, '[data-annonces]')).toBeNull();
const bouton = dans(m, '[data-commande="section"][data-cible="plan"]');
bouton.focus();
expect(document.activeElement).toBe(bouton);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
flushSync();
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
expect(dans(m, '[data-commande="section"][data-cible="plan"]')).toBe(bouton);
expect(document.activeElement).toBe(bouton);
});
});
describe('le verrou d’une autre séance (§ 8.8)', () => { 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 () => { 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(); const premier = await neuf();
@ -742,6 +1056,14 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
const message = (m) => dans(m, '[data-support-rompu]'); const message = (m) => dans(m, '[data-support-rompu]');
const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]'); const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]');
// Le message offre exactement les issues noms, et chacune se voit au cadre
// de l'épreuve : c'est la seule sortie de l'opérateur d'un support rompu.
function issuesVisibles(m, noms) {
const offertes = [...message(m).querySelectorAll('[data-commande]')].map((issue) => issue.dataset.commande);
expect(offertes).toEqual(noms);
for (const nom of noms) expect(visible(message(m).querySelector(`[data-commande="${nom}"]`)), nom).toBe(true);
}
// L'événement ouvert en écriture, puis le support de racine refuse // L'événement ouvert en écriture, puis le support de racine refuse
// l'écriture d'un geste. // l'écriture d'un geste.
async function rompre(m, racine) { async function rompre(m, racine) {
@ -776,7 +1098,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
expect(visible(fixe(m, 'lecture'))).toBe(true); expect(visible(fixe(m, 'lecture'))).toBe(true);
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true); expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
expect(m.seance.etat().evenement.base).toBe(base); expect(m.seance.etat().evenement.base).toBe(base);
expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull(); issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']);
message(m).querySelector('[data-commande="ecrireDocuments"]').click(); message(m).querySelector('[data-commande="ecrireDocuments"]').click();
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
const nouvelle = baseDe(m); const nouvelle = baseDe(m);
@ -798,7 +1120,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
const m = await neuf(); const m = await neuf();
await rompre(m, 'documents'); await rompre(m, 'documents');
expect(m.seance.etat().supportRompu.ailleurs).toBeNull(); expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull(); issuesVisibles(m, ['choisirDossier', 'abandonner']);
const choisir = message(m).querySelector('[data-commande="choisirDossier"]'); const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier')); expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
choisir.click(); choisir.click();
@ -810,6 +1132,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => { test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => {
const m = await neuf(); const m = await neuf();
await rompre(m, 'documents'); await rompre(m, 'documents');
issuesVisibles(m, ['choisirDossier', 'abandonner']);
const avant = m.trace.length; const avant = m.trace.length;
const abandonner = () => message(m).querySelector('[data-commande="abandonner"]'); const abandonner = () => message(m).querySelector('[data-commande="abandonner"]');
expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner')); expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner'));
@ -826,6 +1149,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => { 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'] } }); const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } });
await rompre(m, 'documents'); await rompre(m, 'documents');
issuesVisibles(m, ['choisirDossier', 'abandonner']);
message(m).querySelector('[data-commande="choisirDossier"]').click(); message(m).querySelector('[data-commande="choisirDossier"]').click();
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' }); expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' });

View file

@ -18,6 +18,12 @@
// globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran // 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 // — le conteneur de l'application et son <main> — tant qu'il porte cet
// écran, et à lui seul. // écran, et à lui seul.
//
// Le support rompu et les avertissements se posent dans la zone des
// annonces du cadre, qui défile seule, et non au-dessus de la section : la
// section garde la hauteur de la surface, plan compris, quel que soit le
// nombre des avertissements, et le support rompu, en tête de la zone,
// garde ses issues en vue (CadreMode.svelte).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte';
import SectionGeneration from '../generation/SectionGeneration.svelte'; import SectionGeneration from '../generation/SectionGeneration.svelte';
@ -30,6 +36,7 @@
import CadreMode from './CadreMode.svelte'; import CadreMode from './CadreMode.svelte';
import EnteteEvenement from './EnteteEvenement.svelte'; import EnteteEvenement from './EnteteEvenement.svelte';
import SupportRompu from './SupportRompu.svelte'; import SupportRompu from './SupportRompu.svelte';
import { avertissementsOrdonnes } from './modele.js';
import { SECTIONS, cleDeSection } from './sections.js'; import { SECTIONS, cleDeSection } from './sections.js';
const t = getContext('t'); const t = getContext('t');
@ -48,6 +55,9 @@
]); ]);
const Section = $derived(COMPOSANTS.get($ui.section)); const Section = $derived(COMPOSANTS.get($ui.section));
// La zone des annonces n'existe que lorsqu'elle a quelque chose à dire :
// sans annonce, le cadre ne lui garde ni hauteur ni trait.
const annoncer = $derived($seance.supportRompu !== null || avertissementsOrdonnes($seance).length > 0);
</script> </script>
{#snippet tete()} {#snippet tete()}
@ -65,12 +75,15 @@
</nav> </nav>
{/snippet} {/snippet}
<div class="evenement" data-ecran="evenement"> {#snippet annonces()}
<CadreMode tete={$ui.pleinEcran ? null : tete}>
{#if $seance.supportRompu !== null} {#if $seance.supportRompu !== null}
<SupportRompu /> <SupportRompu />
{/if} {/if}
<Avertissements /> <Avertissements />
{/snippet}
<div class="evenement" data-ecran="evenement">
<CadreMode tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
<Section /> <Section />
</CadreMode> </CadreMode>
</div> </div>

View file

@ -52,7 +52,6 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: var(--espace-2); gap: var(--espace-2);
margin-bottom: var(--espace-3);
padding: var(--espace-2) var(--espace-3); padding: var(--espace-2) var(--espace-3);
border-left: 3px solid var(--couleur-refus); border-left: 3px solid var(--couleur-refus);
color: var(--couleur-refus); color: var(--couleur-refus);

View file

@ -93,6 +93,19 @@ const REMEDES = new Map([
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })], ['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
]); ]);
// Le remède d'un avertissement : celui de REMEDES, sauf une réservation que
// le retenu ne tient pas à son siège (details.siege), que lève l'échange de
// sa personne avec l'occupant, ou, chaise vide, son déplacement au siège.
function remedeDe(code, details) {
if (code === 'RESERVATION_NON_HONOREE' && details.siege !== undefined) {
const { tour, participant, table, siege, occupant } = details;
return occupant === null
? { commande: 'deplacerDansRetenu', arguments: { tour, participant, table, siege } }
: { commande: 'echangerDansRetenu', arguments: { tour, a: participant, b: occupant } };
}
return REMEDES.get(code) ?? null;
}
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive // 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 // {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 // proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
@ -126,7 +139,7 @@ function derivesNommees({ ids = [], retenu = false }, examen) {
*/ */
export function avertissementsOrdonnes(etatSeance) { export function avertissementsOrdonnes(etatSeance) {
return etatSeance.avertissements.map(({ code, details }) => { return etatSeance.avertissements.map(({ code, details }) => {
const rendu = { code, details, remede: REMEDES.get(code) ?? null }; const rendu = { code, details, remede: remedeDe(code, details) };
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives; if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen); if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
return rendu; return rendu;

View file

@ -139,6 +139,7 @@ function executeurAMain() {
const dans = (m, selecteur) => m.racine.querySelector(selecteur); const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="generation"]'); const section = (m) => dans(m, '[data-section="generation"]');
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`); const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
const bascule = (m, cle) => section(m).querySelector(`input[type="checkbox"][data-champ="${cle}"]`);
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`); const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
const generer = (m) => section(m).querySelector('[data-commande="generer"]'); const generer = (m) => section(m).querySelector('[data-commande="generer"]');
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`); const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
@ -278,7 +279,7 @@ async function regler(m, reglages) {
describe('les réglages (§ 5.7, § 5.10)', () => { 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 () => { 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 m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const entrees = [...section(m).querySelectorAll('input')]; const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
expect(entrees.length).toBe(4); expect(entrees.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS); expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of CHAMPS) { for (const cle of CHAMPS) {
@ -370,6 +371,137 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
}); });
}); });
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
// Les cinq bascules, dans l'ordre du § 5.3.
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
const NOMS_DU_SPEC = [
'Séparer les appartenances',
'Nouveaux voisins à chaque tour',
'Nouvelle table à chaque tour',
'Varier les appartenances rencontrées',
'Attribuer les sièges',
];
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual(BASCULES);
BASCULES.forEach((cle, rang) => {
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
});
for (const cle of BASCULES) {
const avant = structuredClone(charge(m).reglages);
const r0 = revision(m);
const entrees = journalDe(m).entrees.length;
await cliquer(m, bascule(m, cle));
expect(revision(m), cle).toBe(r0 + 1);
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
}
// La barre d'espace bascule aussi, par le même geste.
const cle = BASCULES[0];
const avant = charge(m).reglages[cle];
bascule(m, cle).focus();
await touche(' ');
await passer(m);
expect(charge(m).reglages[cle]).toBe(!avant);
expect(bascule(m, cle).checked).toBe(!avant);
});
test('direct : bascule refusée en lecture (« Modifier ») et sur un plan bloqué (« Débloquer »), rien d’écrit', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
const cle = 'attribuerSieges';
const valeur = charge(m).reglages[cle];
const ecritures = m.trace.length;
await cliquer(m, bascule(m, cle));
const lecture = refusPres(bascule(m, cle), 'MODE_LECTURE');
expect(lecture).not.toBeNull();
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(bascule(m, cle).checked).toBe(valeur);
expect(charge(m).reglages[cle]).toBe(valeur);
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, bascule(m, 'nouvelleTable'));
const bloque = refusPres(bascule(m, 'nouvelleTable'), 'LECTURE_SEULE');
expect(bloque).not.toBeNull();
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(bascule(m, 'nouvelleTable').checked).toBe(charge(m).reglages.nouvelleTable);
expect(m.trace.length).toBe(ecrituresBloque);
expect(fenetre()).toBeNull();
});
});
describe('« Générer » derrière la file de la séance (§ 5.10, § 8.2)', () => {
test('relevé, disque lent : « Générer » pressé en quittant le compte d’arrêt part après son écriture, sur l’arrêt saisi', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
// Le disque tient chaque écriture jusqu'à ce que l'épreuve la libère.
const tenues = [];
for (const nom of ['ecrireAtomique', 'ajouterLigne']) {
const primitive = m.fs[nom];
m.fs[nom] = (...args) =>
new Promise((resoudre, rejeter) => {
tenues.push(() => Promise.resolve(primitive.apply(m.fs, args)).then(resoudre, rejeter));
});
}
const arret = champ(m, 'generation.arret');
await cliquer(m, arret);
arret.select();
for (const caractere of '1000') await touche(caractere);
const bouton = generer(m);
bouton.scrollIntoView({ block: 'center' });
const { left, top, width, height } = bouton.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await images(5);
expect(tenues.length, 'une écriture tenue').toBeGreaterThan(0);
expect(lancements, 'rien ne part avant l’écriture du réglage').toEqual([]);
for (let image = 0; lancements.length === 0; image += 1) {
expect(image, 'images avant le lancement').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
expect(lancements[0].reglages.arret).toBe(1000);
expect(charge(m).reglages.generation.arret).toBe(1000);
// Les écritures de l'enregistrement passent, et l'issue s'affiche.
for (let image = 0; dans(m, '[data-issue]') === null; image += 1) {
expect(image, 'images avant l’issue').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
});
});
describe('le travailleur qui ne démarre pas (§ 5.9, § 5.10)', () => {
test('travailleur qui ne se charge pas : TRAVAILLEUR_EN_ECHEC près de « Générer », rien d’écrit', async () => {
const adresse = URL.createObjectURL(new Blob(['ceci ( ne se compile pas'], { type: 'text/javascript' }));
const suivi = { travailleurs: 0 };
const executeur = creerExecuteurTravailleur(() => {
suivi.travailleurs += 1;
return new Worker(adresse);
});
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
await cliquer(m, generer(m));
await attendre(() => expect(refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC')).not.toBeNull());
const refus = refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC');
expect(refus.querySelector('p').textContent).toBe(t('TRAVAILLEUR_EN_ECHEC', { remede: null }));
expect(suivi.travailleurs).toBe(1);
expect(enCours(m)).toBe(false);
expect(section(m).getAttribute('aria-busy')).toBe('false');
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
URL.revokeObjectURL(adresse);
});
});
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => { 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 () => { 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: travailleur, suivi } = executeurTravailleur();

View file

@ -18,6 +18,14 @@
// en lecture, que le parent tient (graine, liée) et contrôle au clic de // 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 // « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire. // appelle saisirGraine, qui le retire.
//
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
// bascule. À l'issue du geste, la case montre la valeur de la charge,
// écrite ou inchangée. Un refus de la séance — lecture, plan
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
// rien ne s'écrit.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Champ from '../primitives/Champ.svelte'; import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
@ -45,6 +53,36 @@
}; };
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur }); const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
// Les bascules, dans l'ordre du § 5.3 : la clé du réglage et celle de son
// libellé.
const BASCULES = [
{ cle: 'separerAppartenances', etiquette: 'generation.contrainte.separerAppartenances' },
{ cle: 'nouveauxVoisins', etiquette: 'generation.contrainte.nouveauxVoisins' },
{ cle: 'nouvelleTable', etiquette: 'generation.contrainte.nouvelleTable' },
{ cle: 'varierAppartenances', etiquette: 'generation.contrainte.varierAppartenances' },
{ cle: 'attribuerSieges', etiquette: 'generation.contrainte.attribuerSieges' },
];
// Le refus de la dernière bascule, { cle, refus }, posé à côté d'elle.
let refusBascule = $state.raw(null);
// Le clic sur une case : la séance écrit la valeur contraire de celle de
// la charge ; à l'issue, la case montre celle de la charge, écrite ou
// inchangée. Annuler le clic ne suffirait pas : la séance peut avoir écrit
// avant que le navigateur rétablisse la case, à la fin de l'événement.
async function basculer(evenement, cle) {
const caseACocher = evenement.currentTarget;
refusBascule = null;
try {
await seance.executer('modifierReglage', { cle, valeur: !charge.reglages[cle] });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusBascule = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
} finally {
caseACocher.checked = charge.reglages[cle];
}
}
</script> </script>
<div class="reglages" data-reglages> <div class="reglages" data-reglages>
@ -74,6 +112,24 @@
/> />
{/each} {/each}
</div> </div>
<fieldset class="bascules">
<legend>{t('generation.contraintes.titre')}</legend>
{#each BASCULES as { cle, etiquette } (cle)}
<div class="bascule">
<input
id={`${uid}-${cle}`}
type="checkbox"
data-champ={cle}
checked={charge.reglages[cle]}
onclick={(evenement) => basculer(evenement, cle)}
/>
<label for={`${uid}-${cle}`}>{t(etiquette)}</label>
{#if refusBascule !== null && refusBascule.cle === cle}
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
{/if}
</div>
{/each}
</fieldset>
{#if mesure === null} {#if mesure === null}
<p data-duree>{t('generation.duree.inconnue')}</p> <p data-duree>{t('generation.duree.inconnue')}</p>
{:else} {:else}
@ -122,6 +178,32 @@
border-color: var(--couleur-refus); border-color: var(--couleur-refus);
} }
.bascules {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-4);
margin: 0;
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
legend {
padding: 0 var(--espace-1);
}
.bascule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1);
}
.bascule input {
margin: 0;
}
p { p {
margin: 0; margin: 0;
} }

View file

@ -85,12 +85,17 @@
} }
// Lance une génération sur la graine saisie et les réglages de la charge ; // 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. // confirmee : la réponse à la question d'une génération longue. Les
// réglages se lisent quand la file de la séance a passé ce qui s'y
// trouve : un réglage quitté par le clic même s'écrit d'abord, et la
// génération part sur lui, quelle que soit la lenteur du disque.
async function lancer(confirmee) { async function lancer(confirmee) {
refusGraine = null; refusGraine = null;
issue = null; issue = null;
if (!confirmee) question = null; if (!confirmee) question = null;
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine }); const saisie = graine;
await seance.attendre();
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine: saisie });
const deGraine = refus.find(({ champ }) => champ === 'graine'); const deGraine = refus.find(({ champ }) => champ === 'graine');
if (deGraine !== undefined) { if (deGraine !== undefined) {
refusGraine = { code: deGraine.code, details: deGraine.details }; refusGraine = { code: deGraine.code, details: deGraine.details };

View file

@ -34,6 +34,7 @@ import { creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js'; import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js'; import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js'; import { VERSION } from '../../version.genere.js';
import { pointer } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js'; import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js'; import { visible } from '../../../test/navigateur/rendu.js';
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url'; import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
@ -551,6 +552,41 @@ describe('une seule entrée, des doublons signalés, une mise à jour qui n’ef
expect(etats.length).toBe(1); expect(etats.length).toBe(1);
}); });
test('deux clics réels sur « Importer » pendant l’écriture d’un support lent : la liste s’importe une fois, une entrée du journal', async () => {
const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
await coller(e, 'nom;prenom\nOmbrelle;Iris\nPervenche;Théo\nLacasse;Ondine');
dans(e, '[data-champ="mode"] input[value="ajouter"]').click();
flushSync();
const entreesAvant = entrees(e);
// Le journal du système simulé retient ses ajouts de ligne jusqu'à
// liberer() : l'import reste en cours d'écriture.
const ajouterLigne = e.m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
e.m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(e.m.fs, parametres);
};
const bouton = commande(e, 'importer');
bouton.scrollIntoView({ block: 'center' });
const boite = bouton.getBoundingClientRect();
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
for (let clic = 0; clic < 2; clic += 1) {
await pointer('presser', x, y);
await pointer('relacher', x, y);
flushSync();
}
await vi.waitFor(() => expect(imports(e).length).toBeGreaterThan(0));
expect(imports(e)).toHaveLength(1);
liberer();
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
await e.m.seance.formeNommee(null).catch(() => undefined);
expect(e.m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(['Ombrelle', 'Pervenche', 'Lacasse']);
expect(entrees(e)).toBe(entreesAvant + 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 () => { 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' })] }); const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
await choisir( await choisir(
@ -731,6 +767,30 @@ describe('importer selon l’état (§ 10.1, § 9)', () => {
expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier')); expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(ecritures(e)).toBe(avant); expect(ecritures(e)).toBe(avant);
}); });
test('sous « Remplacer », bloqué puis en lecture : le refus à côté de la commande, avec son remède, aucune fenêtre, rien d’écrit', async () => {
const cas = [
['LECTURE_SEULE', 'debloquer', { prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) }],
['MODE_LECTURE', 'modifier', { ecriture: false }],
];
for (const [code, remede, options] of cas) {
const e = await ouvrir({ participants: QUATRE(), ...options });
expect(e.m.seance.etat().refus?.code, code).toBe(code);
const avant = ecritures(e);
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
choisirMode(e, 'remplacer');
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, `[data-refus="${code}"]`), code).not.toBeNull());
const pose = dans(e, `[data-refus="${code}"]`);
expect(commande(e, 'importer').parentElement.contains(pose), code).toBe(true);
expect(pose.querySelector(`[data-commande="${remede}"]`)?.textContent, code).toBe(t(`communs.remede.${remede}`));
expect(vu(pose), code).toBe(true);
expect(dans(e, 'dialog'), code).toBeNull();
expect(imports(e), code).toEqual([]);
expect(ecritures(e), code).toBe(avant);
expect(participantsDe(e).length, code).toBe(4);
}
});
}); });
describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => { describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => {

View file

@ -26,6 +26,14 @@ export const TABLE = new Map([
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"], ['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"], ['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
// --- Les contraintes et l'attribution des sièges, sous leur nom du § 5.3 ---
['generation.contraintes.titre', 'Contraintes de la génération'],
['generation.contrainte.separerAppartenances', 'Séparer les appartenances'],
['generation.contrainte.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'],
['generation.contrainte.attribuerSieges', 'Attribuer les sièges'],
// --- La durée du dernier calcul de cette séance (§ 5.10) -------------------- // --- La durée du dernier calcul de cette séance (§ 5.10) --------------------
[ [
'generation.duree.inconnue', 'generation.duree.inconnue',

View file

@ -81,6 +81,7 @@ export const TABLE = new Map([
['participants.fiche.itineraire', 'Placement retenu, tour par tour'], ['participants.fiche.itineraire', 'Placement retenu, tour par tour'],
['participants.fiche.tour', 'Tour'], ['participants.fiche.tour', 'Tour'],
['participants.fiche.enReserve', 'en réserve'], ['participants.fiche.enReserve', 'en réserve'],
['participants.fiche.horsRetenu', 'hors du placement retenu'],
['participants.fiche.siegeNonAttribue', 'non attribué'], ['participants.fiche.siegeNonAttribue', 'non attribué'],
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'], ['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],

View file

@ -84,12 +84,24 @@ export const TABLE = new Map([
['plan.vue.listes', 'Listes de noms'], ['plan.vue.listes', 'Listes de noms'],
['plan.vue.pleinEcran', 'Plein écran'], ['plan.vue.pleinEcran', 'Plein écran'],
['plan.vue.dessin', 'Plan de salle'], ['plan.vue.dessin', 'Plan de salle'],
// Le placement que le plan montre, et la commande qui quitte une
// proposition montrée.
['plan.source.retenu', 'Placement retenu'],
['plan.source.reservations', 'Réservations'],
['plan.source.retenuFautif', 'Réservations : le placement retenu ne se lit pas'],
['plan.source.propositionReservations', ({ proposition }) => `Proposition n° ${proposition} : un geste y pose une réservation`],
['plan.source.propositionVueSeule', ({ proposition }) => `Proposition n° ${proposition}, vue seule`],
['plan.source.revenirRetenu', 'Revenir au placement retenu'],
['plan.source.revenirReservations', 'Revenir aux réservations'],
// --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) ------------------------- // --- 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 // 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, // des données, non le geste (§ 5.8) ; une personne réservée déplace sa
// dans son propre nœud. // réservation, dont il dit la portée. Le nom de la personne est une
// donnée, dans son propre nœud.
['plan.fantome.reservations', 'pose une réservation'], ['plan.fantome.reservations', 'pose une réservation'],
['plan.fantome.reservationTour', 'déplace sa réservation de ce tour'],
['plan.fantome.reservationTous', 'déplace sa réservation, à tous les tours'],
['plan.fantome.retenu', 'déplace dans le placement retenu'], ['plan.fantome.retenu', 'déplace dans le placement retenu'],
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'], ['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
// La place où un refus ou un avertissement du plan se pose. // La place où un refus ou un avertissement du plan se pose.
@ -156,8 +168,17 @@ export const TABLE = new Map([
['plan.menu.deplacerReservation', 'Déplacer sa réservation'], ['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
['plan.menu.exclure', 'Exclure'], ['plan.menu.exclure', 'Exclure'],
['plan.menu.reintegrer', 'Réintégrer'], ['plan.menu.reintegrer', 'Réintégrer'],
['plan.menu.mettreEnEvidence', 'Mettre en évidence'],
['plan.menu.oterEvidence', 'Ôter la mise en évidence'],
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'], ['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
// --- La mise en évidence d'une personne (§ 7.1, § 8.4) ----------------------
// Le nom de la personne mise en évidence est une donnée dans son nœud.
['plan.evidence.personne', 'Mise en évidence : '],
['plan.evidence.absente', 'une personne qui n\u{2019}est plus dans la liste'],
['plan.evidence.oter', 'Ôter la mise en évidence'],
['plan.evidence.voir', 'Voir sur le plan'],
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) --------------------------- // --- 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 // 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. // des données, chacun dans son nœud, entre ces textes.

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,8 @@
<script> <script>
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom, // La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
// appartenance, courriel, titre pressenti, notes, statut calculé, ses // appartenance, courriel, titre pressenti, notes, statut calculé, ses
// réservations, et sa table et son siège à chaque tour du retenu. La fiche // réservations, et sa table et son siège à chaque tour du retenu ; un
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit // 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 // 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. // propre nœud de texte, et rien n'y est interprété comme du balisage.
@ -115,6 +116,8 @@
<td>{etape.tour}</td> <td>{etape.tour}</td>
{#if etape.reserve} {#if etape.reserve}
<td colspan="2">{t('participants.fiche.enReserve')}</td> <td colspan="2">{t('participants.fiche.enReserve')}</td>
{:else if etape.horsRetenu}
<td colspan="2">{t('participants.fiche.horsRetenu')}</td>
{:else} {:else}
<td>{etape.numero}</td> <td>{etape.numero}</td>
<td> <td>

View file

@ -10,23 +10,37 @@
// refus — le mode, l'état du plan — se pose contre « Ajouter ». // refus — le mode, l'état du plan — se pose contre « Ajouter ».
// //
// L'appartenance est collante : après un ajout, chaque champ se vide sauf // 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, // elle, et l'état de navigation la garde (garderAppartenance) : le
// formulaire remonté — retour d'une autre section, fermeture d'« À propos »
// — repart d'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 // les accents ou les blancs (orthographeExistante, grille.js) est signalée
// contre son champ, avec l'orthographe existante à reprendre d'un clic ; // contre son champ, avec l'orthographe existante à reprendre d'un clic ;
// rien ne fusionne seul. // rien ne fusionne seul.
// //
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est // Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
// un geste ouvert (geste.js), qui suspend le compte d'inactivité. // un geste ouvert (geste.js), qui suspend le compte d'inactivité.
import { getContext, onDestroy, tick } from 'svelte'; //
// L'envoi n'est pas ré-entrant : tant que l'ajout s'écrit, une seconde
// soumission ne fait rien, et « Ajouter » porte aria-busy et
// aria-disabled. Le formulaire envoie une copie des champs ; l'ajout
// abouti, il ne vide que ceux qui portent encore la valeur envoyée : ce
// que l'opérateur tape pendant l'écriture, la personne suivante, reste.
// Le focus revient au nom quand rien n'a été tapé entre-temps.
//
// Les notes sont une zone de texte, qui garde les sauts de ligne :
// Maj+Entrée y insère un saut de ligne, Entrée seule envoie
// (entreeValide, grille.js).
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { cleNormalisee } from '../../application/csv.js'; import { cleNormalisee } from '../../application/csv.js';
import { creerGeste } from '../primitives/geste.js'; import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
import { cleDeColonne, orthographeExistante } from './grille.js'; import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
let { participants } = $props(); let { participants } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const ui = getContext('ui');
const uid = $props.id(); const uid = $props.id();
const geste = creerGeste(seance); const geste = creerGeste(seance);
@ -35,10 +49,20 @@
// Ceux qu'un ajout vide : tous, l'appartenance exceptée. // Ceux qu'un ajout vide : tous, l'appartenance exceptée.
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance'); const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
let valeurs = $state({ nom: '', prenom: '', appartenance: '', courriel: '', titrePressenti: '', notes: '' }); let valeurs = $state({
nom: '',
prenom: '',
appartenance: untrack(() => ui.etat().appartenance),
courriel: '',
titrePressenti: '',
notes: '',
});
let refusNom = $state.raw(null); let refusNom = $state.raw(null);
let refusAjout = $state.raw(null); let refusAjout = $state.raw(null);
let entreeNom = $state(null); let entreeNom = $state(null);
let formulaire;
// Vrai pendant l'écriture d'un ajout.
let envoiEnCours = $state(false);
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee)); const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
const idRefusNom = `${uid}-refus-nom`; const idRefusNom = `${uid}-refus-nom`;
@ -55,6 +79,7 @@
async function ajouter(evenement) { async function ajouter(evenement) {
evenement.preventDefault(); evenement.preventDefault();
if (envoiEnCours) return;
refusNom = null; refusNom = null;
refusAjout = null; refusAjout = null;
const champs = { const champs = {
@ -65,6 +90,7 @@
titrePressenti: valeurs.titrePressenti, titrePressenti: valeurs.titrePressenti,
notes: valeurs.notes, notes: valeurs.notes,
}; };
envoiEnCours = true;
try { try {
await seance.executer('ajouterParticipant', { champs }); await seance.executer('ajouterParticipant', { champs });
} catch (erreur) { } catch (erreur) {
@ -73,12 +99,25 @@
if (refus.code === 'NOM_REQUIS') refusNom = refus; if (refus.code === 'NOM_REQUIS') refusNom = refus;
else refusAjout = refus; else refusAjout = refus;
return; return;
} finally {
envoiEnCours = false;
} }
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' }; ui.garderAppartenance(champs.appartenance);
const intacts = EPHEMERES.filter((cle) => valeurs[cle] === champs[cle]);
for (const cle of intacts) valeurs[cle] = '';
if (intacts.length < EPHEMERES.length) return;
await tick(); await tick();
entreeNom?.focus(); entreeNom?.focus();
} }
// Entrée dans les notes envoie le formulaire ; Maj+Entrée y garde le
// saut de ligne de la zone de texte.
function surToucheNotes(evenement) {
if (evenement.key !== 'Enter' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return;
evenement.preventDefault();
formulaire.requestSubmit();
}
function reprendre() { function reprendre() {
valeurs.appartenance = orthographe; valeurs.appartenance = orthographe;
} }
@ -86,7 +125,7 @@
onDestroy(() => geste.liberer()); onDestroy(() => geste.liberer());
</script> </script>
<form aria-labelledby={`${uid}-titre`} onsubmit={ajouter}> <form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter} bind:this={formulaire}>
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3> <h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
<div class="champs"> <div class="champs">
{#each CHAMPS as cle (cle)} {#each CHAMPS as cle (cle)}
@ -122,6 +161,8 @@
</button> </button>
</div> </div>
{/if} {/if}
{:else if cle === 'notes'}
<textarea id={`${uid}-${cle}`} rows="2" data-champ={cle} bind:value={valeurs.notes} onkeydown={surToucheNotes}></textarea>
{:else} {:else}
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} /> <input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
{/if} {/if}
@ -129,7 +170,12 @@
{/each} {/each}
</div> </div>
<div class="issue"> <div class="issue">
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button> <button
type="submit"
data-commande="ajouter"
aria-busy={envoiEnCours ? 'true' : undefined}
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
>
{#if refusAjout !== null} {#if refusAjout !== null}
<Refus refus={refusAjout} lever={() => (refusAjout = null)} /> <Refus refus={refusAjout} lever={() => (refusAjout = null)} />
{/if} {/if}
@ -162,7 +208,8 @@
max-width: 18rem; max-width: 18rem;
} }
input { input,
textarea {
padding: var(--espace-1) var(--espace-2); padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet); border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1); border-radius: var(--rayon-1);
@ -177,6 +224,7 @@
} }
input:focus-visible, input:focus-visible,
textarea:focus-visible,
button:focus-visible { button:focus-visible {
outline: 2px solid var(--couleur-focus); outline: 2px solid var(--couleur-focus);
outline-offset: 1px; outline-offset: 1px;
@ -218,4 +266,10 @@
font-size: var(--taille-texte); font-size: var(--taille-texte);
cursor: pointer; cursor: pointer;
} }
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style> </style>

View file

@ -13,7 +13,8 @@
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident, // 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 // É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 // 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 // retirerait de toute note retouchée. Maj+Entrée y insère un saut de
// ligne, Entrée seule valide (entreeValide). Une validation qui change la valeur
// exécute modifierParticipant par la séance, sur ce seul champ ; une // 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 // 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 // bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
@ -21,6 +22,9 @@
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant // 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. // d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
// //
// « Voir sur le plan » met la personne en évidence et ouvre le plan, au
// tour montré (§ 7.1, § 8.4) : un geste de regard, qui n'écrit rien.
//
// L'en-tête trie : un clic sur une colonne la trie croissante, un second // 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 // 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 (§ // la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
@ -31,13 +35,18 @@
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { creerGeste } from '../primitives/geste.js'; import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js'; import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js';
let { lignes, tri = $bindable(null), supprimer } = $props(); let { lignes, tri = $bindable(null), supprimer } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const ui = getContext('ui'); const ui = getContext('ui');
function voirSurLePlan(id) {
ui.mettreEnEvidence(id);
ui.allerA('plan');
}
const uid = $props.id(); const uid = $props.id();
const geste = creerGeste(seance); const geste = creerGeste(seance);
@ -171,6 +180,7 @@
evenement.preventDefault(); evenement.preventDefault();
abandonner(); abandonner();
} else if (touche === 'Enter') { } else if (touche === 'Enter') {
if (!entreeValide({ maj: evenement.shiftKey, multiligne: edition.colonne === 'notes' })) return;
evenement.preventDefault(); evenement.preventDefault();
valider(); valider();
} else if (touche === 'Tab') { } else if (touche === 'Tab') {
@ -327,6 +337,9 @@
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}> <button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
{t('participants.commande.fiche')} {t('participants.commande.fiche')}
</button> </button>
<button type="button" data-commande="voirSurLePlan" onclick={() => voirSurLePlan(personne.id)}>
{t('plan.evidence.voir')}
</button>
{#if personne.exclu} {#if personne.exclu}
<Commande <Commande
cle="participants.commande.reintegrer" cle="participants.commande.reintegrer"

View file

@ -180,6 +180,15 @@ async function majTab() {
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun }); await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
} }
// Maj+Entrée réel, le saut de ligne d'une zone de texte ; 8 est le masque
// de Maj.
async function majEntree() {
const commun = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, modifiers: 8 };
await cdp().send('Input.dispatchKeyEvent', { type: 'keyDown', ...commun, text: '\r', unmodifiedText: '\r' });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
await rendu();
}
// La valeur de la fiche que nomme l'étiquette de clé donnée. // La valeur de la fiche que nomme l'étiquette de clé donnée.
function valeurDeFiche(m, cle) { function valeurDeFiche(m, cle) {
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle)); const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
@ -199,6 +208,23 @@ function ecart(a, b) {
return Math.max(dx, dy); return Math.max(dx, dy);
} }
// Retient les ajouts de ligne au journal du système simulé jusqu'à
// liberer() : l'écriture d'un geste reste en cours, comme sur un support
// lent, sans délai ni minuterie. L'application lit la primitive sur fs au
// moment du geste.
function retenirJournal(m) {
const ajouterLigne = m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(m.fs, parametres);
};
return () => liberer();
}
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0); const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
describe('la saisie (§ 4, § 10.2)', () => { describe('la saisie (§ 4, § 10.2)', () => {
@ -227,6 +253,53 @@ describe('la saisie (§ 4, § 10.2)', () => {
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle'); expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
}); });
test('une seconde Entrée pendant l’écriture n’ajoute rien ; ce qui est tapé pendant l’écriture reste, ce qui a été envoyé se vide (§ 10.2)', async () => {
const m = await evenementOuvert();
const avant = entrees(m).length;
const liberer = retenirJournal(m);
const ajout = () => section(m).querySelector('form [data-commande="ajouter"]');
expect(ajout()).not.toBeNull();
const styleRepos = getComputedStyle(ajout()).borderTopStyle;
expect(ajout().getAttribute('aria-busy')).toBeNull();
expect(ajout().getAttribute('aria-disabled')).toBeNull();
await saisir(champ(m, 'nom'), 'Ombrelle');
await saisir(champ(m, 'notes'), 'arrive tard');
champ(m, 'nom').focus();
await touche('Enter');
await rendu();
// L'écriture retenue : « Ajouter » se dit occupé, d'un filet tireté.
expect(ajout().getAttribute('aria-busy')).toBe('true');
expect(ajout().getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(ajout()).borderTopStyle).toBe('dashed');
expect(getComputedStyle(ajout()).borderTopStyle).not.toBe(styleRepos);
await touche('Enter');
await rendu();
// La personne suivante se tape pendant l'écriture de la première.
await saisir(champ(m, 'prenom'), 'Théo');
await saisir(champ(m, 'nom'), 'Pervenche');
liberer();
await vi.waitFor(() => expect(participants(m)).toHaveLength(1));
await attendreSeance(m);
await rendu();
expect(ajout().getAttribute('aria-busy')).toBeNull();
expect(ajout().getAttribute('aria-disabled')).toBeNull();
expect(getComputedStyle(ajout()).borderTopStyle).toBe(styleRepos);
expect(participants(m)).toHaveLength(1);
expect(participants(m)[0]).toMatchObject({ nom: 'Ombrelle', prenom: null, notes: 'arrive tard' });
expect(entrees(m)).toHaveLength(avant + 1);
expect(champ(m, 'nom').value).toBe('Pervenche');
expect(champ(m, 'prenom').value).toBe('Théo');
expect(champ(m, 'notes').value).toBe('');
// La saisie gardée s'envoie à son tour.
champ(m, 'nom').focus();
await touche('Enter');
await attendreSeance(m);
expect(participants(m).map((p) => [p.nom, p.prenom])).toEqual([
['Ombrelle', null],
['Pervenche', 'Théo'],
]);
});
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => { 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 m = await evenementOuvert();
const trace = m.trace.length; const trace = m.trace.length;
@ -262,6 +335,28 @@ describe('la saisie (§ 4, § 10.2)', () => {
expect(participants(m).at(-1).appartenance).toBeNull(); expect(participants(m).at(-1).appartenance).toBeNull();
}); });
test('l’appartenance collante survit à un aller-retour par le plan et par « À propos » ; un autre événement repart d’un champ vide', async () => {
const m = await evenementOuvert();
await ajouter(m, { nom: 'Ombrelle', appartenance: CLUB });
m.ui.allerA('plan');
await rendu();
m.ui.allerA('participants');
await rendu();
expect(champ(m, 'appartenance').value).toBe(CLUB);
m.ui.ouvrirApropos();
await rendu();
m.ui.fermerApropos();
await rendu();
expect(champ(m, 'appartenance').value).toBe(CLUB);
await ajouter(m, { nom: 'Pervenche' });
expect(participants(m).at(-1).appartenance).toBe(CLUB);
await m.seance.fermer();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
m.ui.allerA('participants');
await rendu();
expect(champ(m, 'appartenance').value).toBe('');
});
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 () => { 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 m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
const trace = m.trace.length; const trace = m.trace.length;
@ -410,6 +505,34 @@ describe('la grille (§ 10.2, § 19.3)', () => {
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x'); expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
}); });
test('une note saisie sur deux lignes : Maj+Entrée insère le saut de ligne, dans la grille comme au formulaire ; Entrée valide (§ 10.2, § 10.3)', async () => {
const m = await evenementOuvert();
// Au formulaire : la note de deux lignes, puis Entrée dans les notes envoie.
await saisir(champ(m, 'nom'), 'Lacasse');
champ(m, 'notes').focus();
for (const caractere of 'a') await touche(caractere);
await majEntree();
for (const caractere of 'b') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(participants(m)).toHaveLength(1);
expect(participants(m)[0].notes).toBe('a\nb');
// Dans la grille : la cellule des notes, éditée, reçoit une troisième ligne.
const id = idDe(m, 'Lacasse');
const avant = entrees(m).length;
cellule(m, id, 'notes').focus();
await touche('Enter');
await rendu();
await majEntree();
for (const caractere of 'c') await touche(caractere);
await rendu();
expect(entrees(m)).toHaveLength(avant);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m)[0].notes).toBe('a\nb\nc');
});
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 () => { 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 m = await evenementOuvert({ personnes: TROIS });
const parNom = (sens) => const parNom = (sens) =>
@ -668,6 +791,34 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
} }
}); });
test('retenu à 3 tours, l’événement passé à 4 : la fiche garde les tours du retenu, et dit le quatrième hors du retenu (§ 9)', async () => {
const m = await evenementOuvert({ sieges: 3, tours: 3, tables: 2, personnes: NOMS.slice(0, 6).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 });
await m.seance.executer('changerTours', { tours: 4 });
await rendu();
const charge = m.seance.etat().charge;
expect(charge.evenement.tours).toBe(4);
expect(charge.retenu.placement).toHaveLength(3);
const forme = formeNommee(charge.retenu, charge);
expect(charge.participants).toHaveLength(6);
for (const { id: personne } of charge.participants) {
m.ui.ouvrirFiche(personne);
await rendu();
expect(fiche(m).textContent).not.toContain(t('participants.fiche.aucunRetenu'));
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, ['4', t('participants.fiche.horsRetenu')]]);
}
});
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => { 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); const long = 'Ombrelle'.repeat(25);
expect(long).toHaveLength(200); expect(long).toHaveLength(200);

View file

@ -179,20 +179,27 @@ export function orthographeExistante(saisie, participants, cleNormalisee) {
* L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque * 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 * 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) * 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 * = etatPlaces(charge, {tour: r}), jamais recalculé. Un tour dont la source
* n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni * n'est pas 'retenu' — le retenu gardé à moins de tours que l'événement
* n'en compte, après une hausse des tours (§ 9) — se dit hors du retenu,
* et les tours que le retenu porte restent. null quand le tour 1 n'a pas le
* retenu pour source, ou que la personne n'est, à un tour du retenu, ni
* assise ni en réserve — une exclue, absente de tout tour. * assise ni en réserve — une exclue, absente de tout tour.
* *
* @param {number} id * @param {number} id
* @param {number} tours * @param {number} tours
* @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour * @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null} * @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}|{tour: number, horsRetenu: true}>|null}
*/ */
export function itineraire(id, tours, etatDuTour) { export function itineraire(id, tours, etatDuTour) {
const etapes = []; const etapes = [];
for (let tour = 1; tour <= tours; tour += 1) { for (let tour = 1; tour <= tours; tour += 1) {
const etat = etatDuTour(tour); const etat = etatDuTour(tour);
if (etat.source !== 'retenu') return null; if (etat.source !== 'retenu') {
if (tour === 1) return null;
etapes.push({ tour, horsRetenu: true });
continue;
}
let etape = null; let etape = null;
for (const table of etat.tables) { for (const table of etat.tables) {
const chaise = table.chaises.find(({ occupant }) => occupant === id); const chaise = table.chaises.find(({ occupant }) => occupant === id);
@ -208,6 +215,18 @@ export function itineraire(id, tours, etatDuTour) {
return etapes; return etapes;
} }
/**
* Vrai quand Entrée valide la saisie : toujours, sauf Maj+Entrée dans une
* zone de texte — les notes —, qui y insère un saut de ligne.
*
* @param {{maj: boolean, multiligne: boolean}} touche maj : Maj tenue ;
* multiligne : la saisie est une zone de texte
* @returns {boolean}
*/
export function entreeValide({ maj, multiligne }) {
return !(maj && multiligne);
}
/** /**
* Les réservations d'une personne, pour la fiche et la fenêtre de * 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é * suppression : dans l'ordre de la charge, chacune avec le numéro affiché

View file

@ -14,6 +14,7 @@ import {
celluleSuivante, celluleSuivante,
cleDeColonne, cleDeColonne,
cleDeStatut, cleDeStatut,
entreeValide,
itineraire, itineraire,
lignesGrille, lignesGrille,
nomExport, nomExport,
@ -218,17 +219,36 @@ describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () =
assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]); assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]);
}); });
test('source reservations ou proposition, à un tour : null', () => { test('un tour que le retenu ne porte pas — tours haussés après la retenue — se dit hors du retenu ; les tours du retenu restent (§ 9)', () => {
const retenu = etat('retenu', true, [tableDe(4, [7])], []); const retenu = etat('retenu', true, [tableDe(4, [7])], []);
for (const source of ['reservations', 'proposition']) { for (const source of ['reservations', 'proposition']) {
const etats = new Map([ const etats = new Map([
[1, retenu], [1, retenu],
[2, etat(source, true, [tableDe(4, [7])], [])], [2, retenu],
[3, etat('retenu', true, [tableDe(4, [5])], [7])],
[4, etat(source, true, [tableDe(4, [7])], [])],
]); ]);
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source); assert.deepEqual(
itineraire(7, 4, (r) => etats.get(r)),
[
{ tour: 1, numero: 4, siege: 1 },
{ tour: 2, numero: 4, siege: 1 },
{ tour: 3, reserve: true },
{ tour: 4, horsRetenu: true },
],
source,
);
} }
}); });
test('sans retenu au tour 1 : null, quelle que soit la source des tours suivants', () => {
const etats = new Map([
[1, etat('reservations', true, [tableDe(4, [7])], [])],
[2, etat('retenu', true, [tableDe(4, [7])], [])],
]);
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null);
});
test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => { 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])]]); const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]);
assert.equal(itineraire(7, 1, (r) => etats.get(r)), null); assert.equal(itineraire(7, 1, (r) => etats.get(r)), null);
@ -303,3 +323,18 @@ describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
assert.throws(() => cleDeStatut('exclu'), RangeError); assert.throws(() => cleDeStatut('exclu'), RangeError);
}); });
}); });
describe('entreeValide : Entrée valide, Maj+Entrée garde le saut de ligne des notes (§ 10.2, § 10.3)', () => {
test('Entrée seule valide, dans un champ d’une ligne comme dans une zone de texte', () => {
assert.equal(entreeValide({ maj: false, multiligne: false }), true);
assert.equal(entreeValide({ maj: false, multiligne: true }), true);
});
test('Maj+Entrée ne valide pas une zone de texte : elle y insère un saut de ligne', () => {
assert.equal(entreeValide({ maj: true, multiligne: true }), false);
});
test('Maj+Entrée valide un champ d’une ligne, qui n’a pas de saut de ligne', () => {
assert.equal(entreeValide({ maj: true, multiligne: false }), true);
});
});

View file

@ -10,10 +10,22 @@
// les zooms et l'ajustement par Plan.svelte, qui tient la vue : // les zooms et l'ajustement par Plan.svelte, qui tient la vue :
// zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit // zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit
// son état par aria-pressed, que la forme du bouton redit. // son état par aria-pressed, que la forme du bouton redit.
//
// evidence : la personne mise en évidence, {id, nom} — nom null pour une
// personne que la charge n'a plus —, ou null. La barre la nomme et offre
// d'ôter la mise en évidence, que la personne soit assise au tour montré
// ou non ; le menu de la personne la pose (MenuPersonne). Une personne que
// la charge n'a plus se dit telle, sans nom.
//
// source : ce que la barre dit du placement montré, que rend sourceMontree
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
// et, quand une proposition est demandée, offre de ne plus la montrer
// (ui.montrerProposition(null)), sous le libellé revenir. Ni l'un ni
// l'autre n'écrit l'événement.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { toursOfferts } from './vue_plan.js'; import { toursOfferts } from './vue_plan.js';
let { tour, tours, zoomer, ajuster } = $props(); let { tour, tours, zoomer, ajuster, evidence = null, source } = $props();
const t = getContext('t'); const t = getContext('t');
const ui = getContext('ui'); const ui = getContext('ui');
@ -22,6 +34,12 @@
</script> </script>
<div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}> <div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}>
<p class="source" data-source>{t(source.libelle.cle, source.libelle.details)}</p>
{#if source.revenir !== null}
<button type="button" data-commande="quitterProposition" onclick={() => ui.montrerProposition(null)}
>{t(source.revenir)}</button
>
{/if}
<label class="tour"> <label class="tour">
<span>{t('plan.vue.tour')}</span> <span>{t('plan.vue.tour')}</span>
<select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}> <select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}>
@ -39,6 +57,14 @@
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()} <button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}</button >{t('plan.vue.pleinEcran')}</button
> >
{#if evidence !== null}
<p class="evidence" data-evidence={evidence.id}>
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{:else}<span
>{t('plan.evidence.absente')}</span
>{/if}
<button type="button" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}>{t('plan.evidence.oter')}</button>
</p>
{/if}
</div> </div>
<style> <style>
@ -49,6 +75,18 @@
gap: var(--espace-2); gap: var(--espace-2);
} }
.source {
margin: 0;
font-weight: 700;
}
.evidence {
display: flex;
align-items: center;
gap: var(--espace-1);
margin: 0;
}
.tour { .tour {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -15,6 +15,7 @@
// (§ 14.8). // (§ 14.8).
import { afterEach, beforeAll, describe, expect, test } from 'vitest'; import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte'; import { flushSync, mount, unmount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import '../jetons.css'; import '../jetons.css';
import { creerFormat } from '../../application/format.js'; import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
@ -31,11 +32,12 @@ import { rechercher } from '../../moteur/recherche.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { versFichier } from '../../stockage/placements.js'; import { versFichier } from '../../stockage/placements.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js'; import { composantes, couleursPeintes, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw'; import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import BandeauPlan from './BandeauPlan.svelte'; import BandeauPlan from './BandeauPlan.svelte';
import EchellePlan from './EchellePlan.svelte'; import EchellePlan from './EchellePlan.svelte';
import { creerMesureur } from './mesure_texte.js';
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js'; import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte'; import PlanDessin from './PlanDessin.svelte';
@ -71,8 +73,10 @@ afterEach(demonterTout);
/** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la /** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la
* fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est * fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est
* en display: none, et conteneur.style.display = '' le montre. */ * en display: none, et conteneur.style.display = '' le montre. props
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) { * remplace { modele } : un accesseur qui lit un état réactif change le
* modèle du composant monté. */
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false, props = { modele } } = {}) {
const conteneur = document.createElement('div'); const conteneur = document.createElement('div');
conteneur.dataset.surface = ''; conteneur.dataset.surface = '';
if (cache) conteneur.style.display = 'none'; if (cache) conteneur.style.display = 'none';
@ -87,7 +91,7 @@ function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}
groupe.setAttribute('data-vue', ''); groupe.setAttribute('data-vue', '');
groupe.setAttribute('transform', transformDe(vue)); groupe.setAttribute('transform', transformDe(vue));
svg.append(groupe); svg.append(groupe);
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE }); const composant = mount(PlanDessin, { target: groupe, props, context: CONTEXTE });
flushSync(); flushSync();
montes.push({ composant, conteneur }); montes.push({ composant, conteneur });
const boite = svg.getBoundingClientRect(); const boite = svg.getBoundingClientRect();
@ -483,6 +487,113 @@ describe('le plan monté caché (point d\u{2019}attention 1)', () => {
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true); expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
} }
}); });
test('montré, caché, son modèle changé pendant qu\u{2019}il est caché, puis remontré : chaque ligne tient, chaque nom long s\u{2019}abrège sur la marque', async () => {
const charge = chargeDeTables([4, 8]);
avecProposition(charge);
const reactif = new SvelteMap([['modele', modeleDe(charge, { proposition: 1 })]]);
const { svg, conteneur } = monterPlan(null, { k: 1, tx: 300, ty: 350 }, {
props: {
get modele() {
return reactif.get('modele');
},
},
});
await deuxImages();
flushSync();
expect(lignesDe(svg, 1).length).toBe(4);
conteneur.style.display = 'none';
await deuxImages();
flushSync();
// Un autre modèle, posé pendant que le plan est caché : quatre-vingts
// « W » pour chaque nom.
const longs = structuredClone(charge);
for (const p of longs.participants) {
p.prenom = null;
p.nom = 'W'.repeat(80);
}
reactif.set('modele', modeleDe(longs, { proposition: 1 }));
flushSync();
await deuxImages();
conteneur.style.display = '';
await deuxImages();
flushSync();
const lignes = tous(svg, '[data-liste] > tspan');
expect(lignes.length).toBe(12);
for (const ligne of lignes) {
expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const nom = ligne.querySelector('[data-partie=nom]').textContent;
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
}
});
test('creerMesureur : la largeur nulle d\u{2019}un texte non rendu ne se retient pas ; rendu, la chaîne se mesure', () => {
const conteneur = document.createElement('div');
conteneur.style.display = 'none';
document.body.prepend(conteneur);
try {
const svg = document.createElementNS(SVG, 'svg');
const texte = document.createElementNS(SVG, 'text');
texte.setAttribute('font-size', '18');
svg.append(texte);
conteneur.append(svg);
const mesurer = creerMesureur(texte);
expect(mesurer('Ombrelle')).toBe(0);
conteneur.style.display = '';
expect(mesurer('Ombrelle')).toBeGreaterThan(0);
expect(mesurer('')).toBe(0);
} finally {
conteneur.remove();
}
});
});
describe('un nom porteur de balisage, des homonymes (point d\u{2019}attention 3)', () => {
test('le balisage reste du texte, dans un seul nœud ; deux « Iris Ombrelle » ont chacune leur ligne et leur numéro', () => {
const BALISAGES = ['<tspan font-size="200">X</tspan>', '</text><circle r="999"/>'];
const charge = chargeDeTables([8, 4]);
charge.participants[1].prenom = 'Iris';
charge.participants[1].nom = 'Ombrelle';
charge.participants[2].prenom = null;
charge.participants[2].nom = BALISAGES[0];
charge.participants[3].prenom = null;
charge.participants[3].nom = BALISAGES[1];
// Une proposition qui assied les huit premiers à la table 1.
avecProposition(charge, {
id: 1,
graine: 1,
arret: 1,
historique: 1,
produitVersion: 'epreuve',
siegesAttribues: false,
tables: [1, 2],
capacites: [8, 4],
tours: 1,
participants: charge.participants.map(({ id }) => id),
placement: [{ sieges: [[1, 2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12]], reserve: [] }],
});
const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 });
const liste = svg.querySelector('[data-liste="1"]');
const lignes = lignesDe(svg, 1);
expect(lignes.length).toBe(8);
// Chaque ligne d'un occupant sans réservation ni titre : sa tête et son
// nom, deux tspan ; rien d'autre sous la liste.
expect(liste.querySelectorAll('*').length).toBe(8 * 3);
expect([...liste.querySelectorAll('*')].every((e) => e.localName === 'tspan')).toBe(true);
expect(svg.querySelectorAll('circle[r="999"], [font-size="200"]').length).toBe(0);
const noms = tous(liste, '[data-partie=nom]');
expect(noms.length).toBe(8);
for (const nom of noms) expect(nom.childElementCount).toBe(0);
for (const balisage of BALISAGES) {
const portes = noms.filter(({ textContent }) => textContent === balisage || (textContent.endsWith(MARQUE) && balisage.startsWith(textContent.slice(0, -MARQUE.length))));
expect(portes.length, balisage).toBe(1);
}
const homonymes = noms.filter(({ textContent }) => textContent === 'Iris Ombrelle');
expect(homonymes.length).toBe(2);
const numeros = homonymes.map((nom) => nom.previousElementSibling.textContent);
expect(numeros.every((tete) => /^\d+\. $/.test(tete))).toBe(true);
expect(new Set(numeros).size).toBe(2);
});
}); });
describe('le signal de conflit (§ 7.5, § 13.3)', () => { describe('le signal de conflit (§ 7.5, § 13.3)', () => {
@ -510,6 +621,11 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille)); expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille));
expect(visible(pastilles[0].querySelector('text'))).toBe(true); expect(visible(pastilles[0].querySelector('text'))).toBe(true);
expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]); expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]);
// La pastille se pose sur un socle du fond du plateau : la trame ne
// passe pas sous elle (§ 14.5), et reste peinte autour du socle.
expect(fondPeintSous(pastilles[0].querySelector('[data-marque=pastille]'))).toEqual([jeton('--couleur-table-fond')]);
const socle = trames[0].closest('[data-table]').querySelector('[data-socle]');
expect(Number(socle.getAttribute('r'))).toBeLessThan(Number(enConflit.getAttribute('r')));
expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none'); expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none');
demonterTout(); demonterTout();
@ -637,8 +753,43 @@ describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => {
}); });
describe('dessin et désignation (§ 7.3, § 13.5)', () => { describe('dessin et désignation (§ 7.3, § 13.5)', () => {
test('au seuil k = 24/pas de chaque table, la cible d’un siège a la taille du pas : à pas/2 − 1 px de sa chaise, vers sa voisine, le DOM et designer désignent la chaise ; à pas/2 + 1 px, ils désignent la même place', () => {
const charge = chargeDeTables([3, 8, 8, 12], { pas: 800, formes: ['ronde', 'ronde', 'carree', 'carree'] });
let points = 0;
for (const id of [1, 2, 3, 4]) {
const geometrie = modeleDe(charge, { k: 1 }).tables[id - 1].geometrie;
const k = (24 / geometrie.pas) * (1 + 1e-9);
const vue = { k, tx: innerWidth / 2 - k * geometrie.centre.x, ty: innerHeight / 2 };
const modele = modeleDe(charge, { k, listes: false });
expect(modele.tables[id - 1].regime).toBe('sieges');
const geometries = modele.tables.map(({ geometrie: g }) => g);
const { cadre, ecran } = monterPlan(modele, vue);
const { chaises } = geometries[id - 1];
chaises.forEach((chaise, i) => {
const voisine = chaises[(i + 1) % chaises.length];
const c = ecran(chaise.x, chaise.y);
const v = ecran(voisine.x, voisine.y);
const d = Math.hypot(v.x - c.x, v.y - c.y);
for (const [ecart, attendu] of [[-1, `${id}:${chaise.siege}`], [1, undefined]]) {
const portee = (geometrie.pas / 2) * k + ecart;
const p = { x: c.x + ((v.x - c.x) * portee) / d, y: c.y + ((v.y - c.y) * portee) / d };
const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null;
const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, k);
const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null;
expect(parDom, `table ${id}, siège ${chaise.siege}, écart ${ecart}`).toBe(parGeometrie);
if (attendu !== undefined) expect(parDom, `table ${id}, siège ${chaise.siege}`).toBe(attendu);
points += 1;
}
});
demonterTout();
}
expect(points).toBe(2 * (3 + 8 + 8 + 12));
});
test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => { test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => {
const charge = chargeDeTables([8, 8], { pas: 200 }); // La table 2 si près que la liste de la table 1 passe sur ses chaises de
// gauche.
const charge = chargeDeTables([8, 8], { pas: 280 });
const vue = { k: 1.5, tx: 300, ty: 350 }; const vue = { k: 1.5, tx: 300, ty: 350 };
const modele = modeleDe(charge, { k: vue.k }); const modele = modeleDe(charge, { k: vue.k });
const { svg, cadre, ecran } = monterPlan(modele, vue); const { svg, cadre, ecran } = monterPlan(modele, vue);

View file

@ -10,7 +10,8 @@
// écrit son attribut transform à chaque image, directement, et le montre // écrit son attribut transform à chaque image, directement, et le montre
// par la propriété fantome : le nom de la personne, dans son propre nœud, // par la propriété fantome : le nom de la personne, dans son propre nœud,
// puis le libellé de ce que le geste modifie — la clé que cleFantome // puis le libellé de ce que le geste modifie — la clé que cleFantome
// (gestes.js) tire de la cible de l'état des places. Caché, il ne porte // (gestes.js) tire de la cible de l'état des places et de la portée de la
// réservation qui tient la personne. Caché, il ne porte
// rien ; montré ou non, il ne reçoit pas le pointeur. // rien ; montré ou non, il ne reçoit pas le pointeur.
// //
// fantome : {nom, cle} | null ; element : le <g>, lié pour Plan.svelte. // fantome : {nom, cle} | null ; element : le <g>, lié pour Plan.svelte.

View file

@ -36,7 +36,7 @@ import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js'; import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js'; import { VERSION } from '../../version.genere.js';
import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?raw'; import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?raw';
import { fixerCadre, glisser, pointer, touche } from '../../../test/navigateur/entrees.js'; import { fixerCadre, glisser, molette, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js'; import { monterApp } from '../../../test/navigateur/montage.js';
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js'; import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
@ -731,6 +731,39 @@ describe('les gestes d’une personne (§ 7.1, § 8.2, § 5.8)', () => {
}); });
}); });
describe('un refus posé sur une place ne couvre pas la cible suivante (§ 5.9, § 7.3)', () => {
test('un appui sur une chaise que couvre le texte du refus atteint le plan : le refus se ferme, la chaise se sélectionne', async () => {
const m = await ouvrir(huitRetenue());
m.ui.montrerProposition(1);
flushSync();
await images(2);
await glisser(pointChaise(m, 3, 1), pointChaise(m, 7, 1));
const place = dans(m, '[data-siege="7:1"]');
await attendre(() => expect(decrit(place)).not.toBeNull());
const refus = decrit(place);
const boite = refus.getBoundingClientRect();
// Les chaises dont le centre tombe dans la boîte du refus, hors de ses
// boutons.
const couvertes = tous(m, '[data-siege]')
.map((chaise) => {
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
return { chaise, point: pointChaise(m, table, siege) };
})
.filter(({ point: { x, y } }) => x > boite.left + 2 && x < boite.right - 2 && y > boite.top + 2 && y < boite.bottom - 2)
.filter(({ point: { x, y } }) => document.elementFromPoint(x, y)?.closest('button') === null);
expect(couvertes.length).toBeGreaterThan(0);
const { chaise, point } = couvertes[0];
const avant = fichiers(m);
await pointer('presser', point.x, point.y);
await pointer('relacher', point.x, point.y);
await images(2);
expect(decrit(place)).toBeNull();
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
expect(m.ui.etat().selection).toEqual({ table, siege });
await rienEcrit(m, avant);
});
});
// --- Glisser une table (§ 7.1, § 9) --------------------------------------------- // --- Glisser une table (§ 7.1, § 9) ---------------------------------------------
describe('glisser une table (§ 7.1, § 9)', () => { describe('glisser une table (§ 7.1, § 9)', () => {
@ -1009,6 +1042,54 @@ describe('un clic n’est pas un glisser, la lecture, l’inactivité (§ 8.4)',
}); });
}); });
describe('regarder pendant un geste et regarder sans appuyer (§ 7.2, § 7.3, § 8.4)', () => {
test('la molette pendant le glissement d’une personne ne change pas la vue : la commande nomme la chaise sous le pointeur au relâcher', async () => {
const m = await ouvrir(huitRetenue());
m.ui.montrerTour(2);
flushSync();
await avecVue(m, { k: 0.5, tx: 150, ty: 60 });
const commandes = releverCommandes(m);
const r = fichiers(m).revision;
const depart = pointChaise(m, 4, 1);
await presserEtBouger(depart, decale(depart, 30, 0));
const vue = groupe(m).getAttribute('transform');
const cadre = cadreDe(m);
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -300);
await images(3);
expect(groupe(m).getAttribute('transform')).toBe(vue);
const vers = pointChaise(m, 7, 3);
await pointer('bouger', vers.x, vers.y);
await uneImage();
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(commandes).toEqual([{ nom: 'deplacerDansRetenu', args: { tour: 2, participant: 1, table: 7, siege: 3 } }]);
});
test('la molette, « Zoom + », « Ajuster » et le changement de tour ne relancent pas le compte d’inactivité : le retour en lecture tombe à son heure', async () => {
const m = await ouvrir(huitRetenue());
expect(m.seance.etat().mode).toBe('ecriture');
m.horloge.avancer(INACTIVITE_MS - 1000);
await images(2);
const transform = groupe(m).getAttribute('transform');
const cadre = cadreDe(m);
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -100);
await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBe(transform));
dans(m, '[data-commande="zoomer"]').click();
flushSync();
dans(m, '[data-commande="ajuster"]').click();
flushSync();
const select = dans(m, '[data-champ="tour"]');
select.value = '2';
select.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
expect(m.ui.etat().tour).toBe(2);
await images(2);
expect(m.seance.etat().mode).toBe('ecriture');
m.horloge.avancer(1001);
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
});
});
// L'écart d'une poignée tirée de trois places, au zoom relu. // L'écart d'une poignée tirée de trois places, au zoom relu.
const pasDeLecture = (m) => ((3 * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2; const pasDeLecture = (m) => ((3 * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2;

View file

@ -17,6 +17,8 @@
// réservations « tour » à une table qu'une seule « tous les tours » // réservations « tour » à une table qu'une seule « tous les tours »
// remplacerait. « Convertir » l'exécute ; « Ignorer » la retire de la // remplacerait. « Convertir » l'exécute ; « Ignorer » la retire de la
// liste jusqu'au prochain montage, sans rien écrire. // liste jusqu'au prochain montage, sans rien écrire.
// Chaque ligne de titre a pour clé son rang : deux titres égaux à une
// même place restent deux lignes.
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont // Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
// des données, chacun dans son nœud. Échap, sur le panneau ou l'un de ses // des données, chacun dans son nœud. Échap, sur le panneau ou l'un de ses
// contrôles, oublie le choix en attente. Le panneau lit 'seance' et 'ui' // contrôles, oublie le choix en attente. Le panneau lit 'seance' et 'ui'
@ -74,7 +76,7 @@
{#if charge !== null} {#if charge !== null}
<h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4> <h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4>
<ul data-liste-travail="titres" aria-labelledby="{uid}-titres"> <ul data-liste-travail="titres" aria-labelledby="{uid}-titres">
{#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)} {#each titres as titre, rang (rang)}
<li> <li>
<button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)} <button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)}
><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee" ><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee"

View file

@ -19,14 +19,15 @@
// n'est pas assise (executerSurPlace), sinon à côté de la commande. // n'est pas assise (executerSurPlace), sinon à côté de la commande.
// //
// participant : l'identifiant de la personne ; le menu porte data-menu. // participant : l'identifiant de la personne ; le menu porte data-menu.
// etatTour : l'état des places du tour affiché, que SectionPlan calcule et
// que le panneau de la place passe, ou null sans événement.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { etatPlaces } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.js'; import { commandeDeDepot } from './depot.js';
import { actionsPersonne, executerSurPlace } from './modele.js'; import { actionsPersonne, executerSurPlace } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { participant } = $props(); let { participant, etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -35,8 +36,8 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant)); const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant));
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(fiche === undefined ? null : etatTour);
const etat = $derived(fiche === undefined ? null : etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
// La place que la personne occupe au tour montré, et sa chaise, ou null. // La place que la personne occupe au tour montré, et sa chaise, ou null.
const assise = $derived.by(() => { const assise = $derived.by(() => {
if (etat === null) return null; if (etat === null) return null;
@ -51,7 +52,10 @@
? null ? null
: commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }), : commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }),
); );
const actions = $derived(fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait })); const enEvidence = $derived($ui.miseEnEvidence === participant);
const actions = $derived(
fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait, enEvidence }),
);
const dependances = { seance, ui }; const dependances = { seance, ui };
// Où poser un refus : sa place, sinon la place sélectionnée. // Où poser un refus : sa place, sinon la place sélectionnée.
@ -87,6 +91,8 @@
deplacerReservation, deplacerReservation,
exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }), exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }),
reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }), reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }),
mettreEnEvidence: () => ui.mettreEnEvidence(participant),
oterEvidence: () => ui.mettreEnEvidence(null),
ouvrirFiche: () => ui.ouvrirFiche(participant), ouvrirFiche: () => ui.ouvrirFiche(participant),
}; };
</script> </script>

View file

@ -25,7 +25,7 @@
</pattern> </pattern>
<pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)"> <pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)">
<rect class="fond-table" width="12" height="12" /> <rect class="fond-table" width="12" height="12" />
<line class="trait-trame" x1="6" y1="0" x2="6" y2="12" /> <line class="trait-conflit" x1="6" y1="0" x2="6" y2="12" />
</pattern> </pattern>
</defs> </defs>
@ -43,7 +43,7 @@
fill: var(--couleur-table-fond); fill: var(--couleur-table-fond);
} }
.trait-trame { .trait-conflit {
stroke: var(--couleur-conflit); stroke: var(--couleur-conflit);
stroke-width: 3; stroke-width: 3;
} }

View file

@ -21,15 +21,19 @@
// — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue. // — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue.
// //
// Échap referme le panneau et rend le focus à la racine du plan. Le // Échap referme le panneau et rend le focus à la racine du plan. Le
// panneau lit 'seance' et 'ui' au contexte, sans propriété. // panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
// du tour affiché, que SectionPlan calcule, ou null sans événement, et le
// passe au menu.
import { getContext, untrack } from 'svelte'; import { getContext, untrack } from 'svelte';
import { etatPlaces, libellePropose } from '../../application/places.js'; import { libellePropose } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.js'; import { commandeDeDepot } from './depot.js';
import { libellePlace } from './gestes.js'; import { libellePlace } from './gestes.js';
import MenuPersonne from './MenuPersonne.svelte'; import MenuPersonne from './MenuPersonne.svelte';
import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js'; import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -39,8 +43,8 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null); const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(place === null ? null : etatTour);
const etat = $derived(place === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table)); const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table));
const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null)); const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null));
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
@ -199,7 +203,7 @@
{/if} {/if}
</div> </div>
{#if menuDe !== null} {#if menuDe !== null}
<MenuPersonne participant={menuDe} /> <MenuPersonne participant={menuDe} {etatTour} />
{/if} {/if}
</section> </section>
{/if} {/if}

View file

@ -22,14 +22,17 @@
// (executerSurPlace), LIBELLE_VIDE à côté du champ. // (executerSurPlace), LIBELLE_VIDE à côté du champ.
// //
// Échap referme le panneau et rend le focus à la racine du plan. Le // Échap referme le panneau et rend le focus à la racine du plan. Le
// panneau lit 'seance' et 'ui' au contexte, sans propriété. // panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
// du tour affiché, que SectionPlan calcule, ou null sans événement.
import { getContext, tick, untrack } from 'svelte'; import { getContext, tick, untrack } from 'svelte';
import { comptesTitres } from '../../application/listes_travail.js'; import { comptesTitres } from '../../application/listes_travail.js';
import { etatPlaces, libellePropose } from '../../application/places.js'; import { libellePropose } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { libellePlace } from './gestes.js'; import { libellePlace } from './gestes.js';
import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js'; import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -39,8 +42,7 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null); const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(table === null ? null : etatTour);
const etat = $derived(table === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table)); const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table));
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table)); const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table));

View file

@ -23,9 +23,12 @@
// Le projet navigateur emploie expect, et test et describe de Vitest // Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8). // (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte'; import { flushSync, mount, unmount } from 'svelte';
import { get, writable } from 'svelte/store';
import { appliquerCommande } from '../../application/commandes.js'; import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces } from '../../application/places.js'; import { etatPlaces } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js'; import { constante } from '../../geometrie/constantes.js';
import { surLeSol } from '../../geometrie/designation.js'; import { surLeSol } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js'; import { geometrieTable } from '../../geometrie/tables.js';
@ -39,6 +42,8 @@ import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?
import TEXTE_CONFLIT from '../../../test/fixtures/plan/conflit_table_un.gtt.json?raw'; import TEXTE_CONFLIT from '../../../test/fixtures/plan/conflit_table_un.gtt.json?raw';
import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/navigateur/entrees.js'; import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js'; import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import ListesTravail from './ListesTravail.svelte';
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js'; import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
const BASE = 'soiree'; const BASE = 'soiree';
@ -261,16 +266,24 @@ async function cliquerPoint({ x, y }) {
await images(2); await images(2);
} }
// Un clic réel au centre d'une chaise. Quand un avertissement paru au-dessus // Un clic réel au centre d'une chaise. Quand elle est hors du cadre du
// du plan l'a poussé sous le bas de la fenêtre, la page défile d'abord // plan — les annonces du cadre de mode, avertissements compris, prennent
// jusqu'à la chaise. // leur hauteur à la surface —, la vue se déplace d'abord, par le cadrage de
// la séance, pour poser la chaise au centre du cadre, au même k.
async function cliquerChaise(m, table, siege) { async function cliquerChaise(m, table, siege) {
const atteinte = () => { const atteinte = () => {
const { x, y } = pointChaise(m, table, siege); const { x, y } = pointChaise(m, table, siege);
return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false; return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false;
}; };
if (!atteinte()) { if (!atteinte()) {
chaiseEl(m, table, siege).scrollIntoView({ block: 'center', inline: 'center' }); const vue = vueLue(m);
const cadre = cadreDe(m);
const { x, y } = pointChaise(m, table, siege);
await avecVue(m, {
k: vue.k,
tx: vue.tx + cadre.gauche + cadre.largeur / 2 - x,
ty: vue.ty + cadre.haut + cadre.hauteur / 2 - y,
});
await attendre(() => expect(atteinte()).toBe(true)); await attendre(() => expect(atteinte()).toBe(true));
} }
await cliquerPoint(pointChaise(m, table, siege)); await cliquerPoint(pointChaise(m, table, siege));
@ -284,6 +297,34 @@ async function presserBouton(element) {
await cliquerPoint({ x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 }); await cliquerPoint({ x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 });
} }
// Presse un bouton par le vrai pointeur, et dit s'il a reçu l'appui : un
// bouton que le pointeur traverse laisse l'appui à ce qui est dessous.
async function presseRecue(element) {
let recu = false;
const noter = () => {
recu = true;
};
element.addEventListener('pointerdown', noter);
try {
await presserBouton(element);
} finally {
element.removeEventListener('pointerdown', noter);
}
return recu;
}
// Presse au point depuis et avance jusqu'au point vers sans relâcher : le
// fantôme reste montré.
async function presserEtBouger(depuis, vers, { pas = 6 } = {}) {
await pointer('presser', depuis.x, depuis.y);
for (let i = 1; i <= pas; i += 1) {
const t = i / pas;
await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t);
await images(1);
}
flushSync();
}
// Clique un bouton par son clic HTML, puis laisse la séance écrire. // Clique un bouton par son clic HTML, puis laisse la séance écrire.
async function cliquer(element) { async function cliquer(element) {
expect(element, 'bouton présent').not.toBeNull(); expect(element, 'bouton présent').not.toBeNull();
@ -465,7 +506,10 @@ describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => {
const tables = charge(m).tables; const tables = charge(m).tables;
const premiere = tables[0]; const premiere = tables[0];
expect(actif(m)).toBe(`table-${premiere.id}`); expect(actif(m)).toBe(`table-${premiere.id}`);
const voisine = tables.find(({ position }) => position.x === premiere.position.x + 300 && position.y === premiere.position.y); // La voisine de droite : la plus proche à droite, sur la même rangée.
const voisine = tables
.filter(({ position }) => position.y === premiere.position.y && position.x > premiere.position.x)
.reduce((plus, table) => (plus === undefined || table.position.x < plus.position.x ? table : plus), undefined);
expect(voisine).toBeDefined(); expect(voisine).toBeDefined();
await touche('ArrowRight'); await touche('ArrowRight');
flushSync(); flushSync();
@ -877,6 +921,163 @@ describe('le menu fait ce que fait le geste (§ 7.1, § 10.2)', () => {
}); });
}); });
// --- Deux titres identiques à une même place (§ 5.8) -----------------------------
describe('les listes de travail tiennent deux titres identiques à une même place (§ 5.8)', () => {
test('deux titres « animateur » au siège 2 de la table 1, rendus par titresNonPourvus : deux lignes, aucune erreur de rendu', () => {
// Le panneau est monté seul, sur une séance d'épreuve qui porte la
// charge telle quelle : une charge que l'analyse admettrait ou non, le
// rendu ne doit pas tomber sur elle.
const charge = sansProposition();
charge.titres = [
{ table: 1, siege: 2, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
const seance = writable({ charge });
const etatUi = writable({ choix: null, selection: null });
const ui = { subscribe: etatUi.subscribe, etat: () => get(etatUi), selectionner: () => {}, choisir: () => {} };
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const conteneur = document.createElement('div');
document.body.prepend(conteneur);
const contexte = new Map([['t', t], ['seance', seance], ['ui', ui]]);
let composant = null;
try {
composant = mount(ListesTravail, { target: conteneur, context: contexte });
flushSync();
const lignes = [...conteneur.querySelectorAll('[data-liste-travail="titres"] [data-titre="1:2"]')];
expect(lignes).toHaveLength(2);
expect(lignes.map(texteDe)).toEqual(['table 1 : titre animateur non pourvu', 'table 1 : titre animateur non pourvu']);
} finally {
if (composant !== null) unmount(composant);
conteneur.remove();
}
});
});
// --- Le plan dit le placement qu'il montre (§ 2.1 étape 11, § 5.8) ----------------
describe('le plan dit le placement qu’il montre, et revient d’une proposition (§ 2.1 étape 11, § 5.8)', () => {
const source = (m) => texteDe(dans(m, '[data-source]'));
const reserve = (m) => [...dans(m, '[data-reserve]').querySelectorAll('[data-participant]')].map((e) => Number(e.dataset.participant));
test('avant la retenue : « Voir » une proposition, puis revenir aux réservations depuis le plan ; la réserve liste de nouveau les non-réservés', async () => {
const m = await ouvrir(huitAvantRetenue());
expect(source(m)).toBe('Réservations');
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
const nonReserves = reserve(m);
expect(nonReserves).toHaveLength(charge(m).participants.length);
m.ui.montrerProposition(1);
flushSync();
await images(2);
expect(source(m)).toBe('Proposition n° 1 : un geste y pose une réservation');
expect(reserve(m)).not.toEqual(nonReserves);
const quitter = dans(m, '[data-commande="quitterProposition"]');
expect(texteDe(quitter)).toBe('Revenir aux réservations');
await presserBouton(quitter);
flushSync();
await images(2);
expect(m.ui.etat().proposition).toBeNull();
expect(source(m)).toBe('Réservations');
expect(reserve(m)).toEqual(nonReserves);
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
});
test('après la retenue : la proposition montrée se dit en vue seule, et la commande revient au placement retenu', async () => {
const m = await ouvrir(huitRetenue());
expect(source(m)).toBe('Placement retenu');
m.ui.montrerProposition(1);
flushSync();
await images(2);
expect(source(m)).toBe('Proposition n° 1, vue seule');
const quitter = dans(m, '[data-commande="quitterProposition"]');
expect(texteDe(quitter)).toBe('Revenir au placement retenu');
await presserBouton(quitter);
flushSync();
await images(2);
expect(m.ui.etat().proposition).toBeNull();
expect(source(m)).toBe('Placement retenu');
});
});
// --- Un geste à un tour ne change pas les autres tours (§ 7.1, § 5.8) -----------
describe('une réservation « tous les tours » ne se retire pas par un geste (§ 7.1)', () => {
test('sol ou « Retirer du tour » : refus sur la place, rien d’écrit ; le remède la retire ; « ce tour » se retire par le sol', async () => {
const base = executer(
executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }),
'reserver',
{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 },
);
const m = await ouvrir(base);
expect(etatMontre(m).cible).toBe('reservations');
const place = () => chaiseEl(m, 3, 1);
const refusSur = () => decrit(place())?.querySelector('[data-refus="RESERVATION_TOUS_LES_TOURS"]') ?? null;
let avant = fichiers(m);
await glisser(pointChaise(m, 3, 1), pointSol(m));
await attendre(() => expect(refusSur()).not.toBeNull());
expect(texteDe(decrit(place()))).toContain('tous les tours');
expect(texteDe(decrit(place()))).toContain(`Table ${charge(m).tables.find(({ id }) => id === 3).numero}, siège 1`);
await rienEcrit(m, avant);
expect(charge(m).reservations).toEqual(base.reservations);
// Le message laisse passer le pointeur, ses boutons le prennent : « Fermer »,
// pressé par le vrai pointeur, reçoit l'appui et retire la pose.
const fermer = decrit(place()).querySelector('button.fermer');
expect(fermer).not.toBeNull();
expect(await presseRecue(fermer)).toBe(true);
await attendre(() => expect(refusSur()).toBeNull());
expect(m.ui.etat().pose).toBeNull();
await rienEcrit(m, avant);
await activerSiegeAuClavier(m, 3, 1);
await presserBouton(dans(m, `[data-menu="${IRIS}"] [data-commande="retirerDuTour"]`));
await attendre(() => expect(refusSur()).not.toBeNull());
await rienEcrit(m, avant);
// Le remède, pressé par le vrai pointeur : il reçoit l'appui, et sa
// commande écrit une entrée.
const remede = refusSur().querySelector('[data-commande="retirerReservation"]');
expect(remede).not.toBeNull();
const r = fichiers(m).revision;
expect(await presseRecue(remede)).toBe(true);
await uneEntree(m, r);
expect(charge(m).reservations).toEqual([{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }]);
avant = fichiers(m);
await glisser(pointChaise(m, 4, 2), pointSol(m));
await uneEntree(m, avant.revision);
expect(charge(m).reservations).toEqual([]);
});
test('une personne réservée glissée vers une chaise libre : le fantôme dit qu’elle déplace sa réservation, et sa portée ; « tous les tours » se déplace à chaque tour', async () => {
const base = executer(
executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }),
'reserver',
{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 },
);
const m = await ouvrir(base);
expect(etatMontre(m).cible).toBe('reservations');
const etatDuFantome = () => texteDe(dans(m, '[data-fantome] [data-partie="etat"]'));
let r = fichiers(m).revision;
let vers = pointChaise(m, 4, 3);
await presserEtBouger(pointChaise(m, 3, 1), vers);
expect(etatDuFantome()).toBe('déplace sa réservation, à tous les tours');
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(charge(m).reservations).toContainEqual({ participant: IRIS, table: 4, siege: 3, portee: 'tous', tour: null });
r = fichiers(m).revision;
vers = pointChaise(m, 4, 4);
await presserEtBouger(pointChaise(m, 4, 2), vers);
expect(etatDuFantome()).toBe('déplace sa réservation de ce tour');
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(charge(m).reservations).toContainEqual({ participant: THEO, table: 4, siege: 4, portee: 'tour', tour: 1 });
});
});
// --- Réserver, titrer, désigner (§ 4, § 2.1 étape 6, § 5.8) --------------------- // --- Réserver, titrer, désigner (§ 4, § 2.1 étape 6, § 5.8) ---------------------
describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)', () => { describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)', () => {
@ -964,15 +1165,16 @@ describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)
expect(apres.titres).toHaveLength(origine.titres.length + 1); expect(apres.titres).toHaveLength(origine.titres.length + 1);
expect(apres.retenu).toEqual(origine.retenu); expect(apres.retenu).toEqual(origine.retenu);
expect(apres.propositions).toEqual(origine.propositions); expect(apres.propositions).toEqual(origine.propositions);
expect(m.seance.etat().avertissements).toContainEqual({ code: 'RESERVATION_NON_HONOREE', details: { participant: IRIS, table: 3, tour: 1 } }); expect(m.seance.etat().avertissements).toContainEqual({
code: 'RESERVATION_NON_HONOREE',
details: { participant: IRIS, table: 3, tour: 1, personne: 'Iris Ombrelle', numero: 3 },
});
await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull()); await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull());
}); });
// L'avertissement nomme Iris et la table 3 : son texte vient du libellé // L'avertissement nomme Iris et la table 3 : ses détails portent le nom
// d'A2 et du composant des avertissements d'I4, que le correctif // affiché de la personne et le numéro de la table (avertissementsApres).
// rapports/I11-avertissement-nomme.diff complète. L'épreuve échoue tant test('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3', async () => {
// qu'il n'est pas appliqué ; appliqué, test.fails la signale à retourner.
test.fails('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3 (attend le correctif d’A2 et d’I4)', async () => {
const base = chargeDe({ const base = chargeDe({
id: 'evt-apres-generation', id: 'evt-apres-generation',
places: [4, 4, 4], places: [4, 4, 4],
@ -1385,3 +1587,97 @@ describe('les listes de travail mènent à la place ou à la personne (§ 5.8)',
expect(m.ui.etat().choix).toBeNull(); expect(m.ui.etat().choix).toBeNull();
}); });
}); });
// --- La mise en évidence d'une personne (§ 7.1, § 8.2, § 8.4) ------------------------
describe('la mise en évidence d’une personne : posée et ôtée par un geste, en lecture, sans rien écrire (§ 7.1, § 8.4)', () => {
// La chaise qui porte la marque de mise en évidence, ou null ; la marque
// se voit au style calculé.
const marquees = (m) => tous(m, '[data-marque="miseEnEvidence"]');
const chaiseMarquee = (m) => {
const [marque, ...autres] = marquees(m);
expect(autres).toEqual([]);
if (marque === undefined) return null;
expect(marque.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
return marque.closest('[data-siege]').dataset.siege;
};
// La place qu'occupe une personne au tour montré, « table:siège ».
const placeDe = (m, personne) => {
for (const table of etatMontre(m).tables) {
const chaise = table.chaises.find(({ occupant }) => occupant === personne);
if (chaise !== undefined) return `${table.id}:${chaise.siege}`;
}
return null;
};
test('par le menu de la personne : sa chaise marquée, et elle seule, à chaque tour montré ; ôtée par le menu, puis posée et ôtée par la barre de vue', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
expect(m.seance.etat().mode).toBe('lecture');
const avant = fichiers(m);
await cliquerChaise(m, 2, 1);
const personne = etatMontre(m).tables[1].chaises[0].occupant;
expect(personne).not.toBeNull();
expect(chaiseMarquee(m)).toBeNull();
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
expect(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)).toBeNull();
// La barre de vue nomme la personne mise en évidence.
const barre = dans(m, '[data-evidence]');
expect(barre).not.toBeNull();
const { nom, prenom } = charge(m).participants.find(({ id }) => id === personne);
expect(texteDe(barre)).toContain(prenom === null ? nom : `${prenom} ${nom}`);
expect(Number(barre.dataset.evidence)).toBe(personne);
for (const tour of [2, 3, 1]) {
m.ui.montrerTour(tour);
flushSync();
await images(2);
expect(chaiseMarquee(m), `tour ${tour}`).toBe(placeDe(m, personne));
}
await cliquerChaise(m, 2, 1);
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="oterEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
expect(dans(m, '[data-evidence]')).toBeNull();
expect(m.ui.etat().miseEnEvidence).toBeNull();
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
await cliquer(dans(m, '[data-evidence] [data-commande="oterEvidence"]'));
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
expect(fichiers(m)).toEqual(avant);
expect(m.seance.etat().mode).toBe('lecture');
});
test('une personne mise en évidence puis supprimée : la barre dit qu’elle n’est plus dans la liste, et offre toujours d’ôter la mise en évidence', async () => {
const m = await ouvrir(huitRetenue());
const personne = etatMontre(m).tables[1].chaises[0].occupant;
m.ui.mettreEnEvidence(personne);
flushSync();
await m.seance.executer('supprimerParticipant', { id: personne });
flushSync();
await images(2);
const barre = dans(m, '[data-evidence]');
expect(barre).not.toBeNull();
expect(texteDe(barre)).toContain('une personne qui n’est plus dans la liste');
await cliquer(barre.querySelector('[data-commande="oterEvidence"]'));
expect(dans(m, '[data-evidence]')).toBeNull();
});
test('depuis sa ligne de la grille des participants : « Voir sur le plan » ouvre le plan, sa chaise marquée ; rien d’écrit', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
const avant = fichiers(m);
const personne = etatMontre(m).tables[2].chaises[1].occupant;
m.ui.allerA('participants');
flushSync();
const voir = await attendre(() => {
const bouton = dans(m, `[data-participant="${personne}"] [data-commande="voirSurLePlan"]`);
expect(bouton).not.toBeNull();
return bouton;
});
await cliquer(voir);
await attendre(() => expect(m.ui.etat().section).toBe('plan'));
await attendre(() => expect(tous(m, '[data-table]').length).toBe(8));
await attendre(() => expect(chaiseMarquee(m)).toBe('3:2'));
expect(m.ui.etat().miseEnEvidence).toBe(personne);
expect(fichiers(m)).toEqual(avant);
});
});

View file

@ -33,6 +33,8 @@
// //
// diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou // diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou
// null — le bandeau ne porte alors que la phrase des conflits (§ 7.5). // null — le bandeau ne porte alors que la phrase des conflits (§ 7.5).
// etatTour : l'état des places du tour affiché (EtatPlaces, A5), que
// SectionPlan calcule pour le plan et les panneaux.
// //
// Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul // Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul
// écouteur de chaque type, sur la racine : pointerdown identifie la source // écouteur de chaque type, sur la racine : pointerdown identifie la source
@ -42,9 +44,10 @@
// aucune mise en page ; pointerup achève, par au plus une commande de la // aucune mise en page ; pointerup achève, par au plus une commande de la
// séance ; pointercancel, Échap — écouté sur document, en phase de // séance ; pointercancel, Échap — écouté sur document, en phase de
// capture, le temps du geste — et le passage en lecture (surAnnulation) // capture, le temps du geste — et le passage en lecture (surAnnulation)
// restaurent. Pendant le glissement, le composant écrit l'attribut // restaurent. Pendant un geste, la molette ne zoome pas : le geste se
// transform directement — groupe de la vue, groupe d'une table et ses // juge sur la vue de l'appui. Pendant le glissement, le composant écrit
// frères, fantôme — et ne confie le résultat au modèle qu'au relâcher : // l'attribut transform directement — groupe de la vue, groupe d'une table
// et ses frères, fantôme — et ne confie le résultat au modèle qu'au relâcher :
// aucun élément capturant n'est remplacé. La poignée passe son nombre au // aucun élément capturant n'est remplacé. La poignée passe son nombre au
// modèle de dessin (option poignee), sans rien écrire. Un refus — du // modèle de dessin (option poignee), sans rien écrire. Un refus — du
// dépôt, du contrôle de la poignée, de la commande — se pose sur la place // dépôt, du contrôle de la poignée, de la commande — se pose sur la place
@ -86,13 +89,12 @@
// suivant. // suivant.
import { getContext, tick, untrack } from 'svelte'; import { getContext, tick, untrack } from 'svelte';
import { controlerCapacite } from '../../application/capacite.js'; import { controlerCapacite } from '../../application/capacite.js';
import { etatPlaces } from '../../application/places.js';
import { constante } from '../../geometrie/constantes.js'; import { constante } from '../../geometrie/constantes.js';
import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
import BandeauPlan from './BandeauPlan.svelte'; import BandeauPlan from './BandeauPlan.svelte';
import BarreVue from './BarreVue.svelte'; import BarreVue from './BarreVue.svelte';
import { toucheDuPlan, vueQuiMontre } from './clavier.js'; import { toucheDuPlan, vueQuiMontre } from './clavier.js';
import { deposer } from './depot.js'; import { deposer, reservationTenue } from './depot.js';
import EchellePlan from './EchellePlan.svelte'; import EchellePlan from './EchellePlan.svelte';
import Fantome from './Fantome.svelte'; import Fantome from './Fantome.svelte';
import { REPOS, cleFantome, geste, memoriser } from './gestes.js'; import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
@ -104,14 +106,14 @@
cadreDepuisRect, cadreDepuisRect,
englobantDuTour, englobantDuTour,
nomDeCharge, nomDeCharge,
tourAffiche, sourceMontree,
vueApresCadre, vueApresCadre,
vueInitiale, vueInitiale,
zoomerBouton, zoomerBouton,
zoomerMolette, zoomerMolette,
} from './vue_plan.js'; } from './vue_plan.js';
let { diagnostic = null } = $props(); let { diagnostic = null, etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -148,12 +150,17 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const tours = $derived(charge.evenement.tours); const tours = $derived(charge.evenement.tours);
const tour = $derived(tourAffiche($ui.tour, tours)); const etat = $derived(etatTour);
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etat.tour);
const englobantPlan = $derived(englobantDuTour(etat)); const englobantPlan = $derived(englobantDuTour(etat));
const nomDe = $derived(nomDeCharge(charge)); const nomDe = $derived(nomDeCharge(charge));
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const pose = $derived($ui.pose); const pose = $derived($ui.pose);
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
// null pour une personne que la charge n'a plus ; null sans mise en évidence.
// Le placement que le plan montre, que la barre de vue nomme.
const source = $derived(sourceMontree(etat, $ui.proposition, charge.retenu !== null));
const evidence = $derived($ui.miseEnEvidence === null ? null : { id: $ui.miseEnEvidence, nom: nomDe($ui.miseEnEvidence) ?? null });
// L'élément actif que le dessin marque : sous un focus venu du clavier. // L'élément actif que le dessin marque : sous un focus venu du clavier.
const actifMontre = $derived(aLeFocus && auClavier ? actif : null); const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
const modele = $derived( const modele = $derived(
@ -378,6 +385,12 @@
// retirerait le transform d'un second glissement de la même table. // retirerait le transform d'un second glissement de la même table.
let commandeEnCours = false; let commandeEnCours = false;
// Vrai de l'appui d'un geste à son relâcher ou à son annulation : la
// machine juge le geste entier sur la vue relevée à l'appui, et la
// molette ne zoome pas pendant ce temps — le point du relâcher se
// convertirait dans un autre repère que celui où le fantôme le montre.
let gesteEnCours = false;
// Confie la commande d'un geste à la séance ; un refus se pose sur la // Confie la commande d'un geste à la séance ; un refus se pose sur la
// place. La cible apres se restaure une fois la charge rendue : la table // place. La cible apres se restaure une fois la charge rendue : la table
// se dessine alors à sa nouvelle position, ou revient. // se dessine alors à sa nouvelle position, ou revient.
@ -421,6 +434,7 @@
}); });
const surMolette = (evenement) => { const surMolette = (evenement) => {
evenement.preventDefault(); evenement.preventDefault();
if (gesteEnCours) return;
poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement)); poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement));
}; };
observateur.observe(element); observateur.observe(element);
@ -456,6 +470,7 @@
function appliquer({ etat: suivant, effets }) { function appliquer({ etat: suivant, effets }) {
const pointeur = etatGeste.pointeur; const pointeur = etatGeste.pointeur;
etatGeste = suivant; etatGeste = suivant;
gesteEnCours = suivant.phase !== 'repos';
// Un appui devenu glissement — au-delà du seuil, que la machine juge — // Un appui devenu glissement — au-delà du seuil, que la machine juge —
// n'est plus une pression longue. // n'est plus une pression longue.
if (suivant.phase === 'glisse') oublierPression(); if (suivant.phase === 'glisse') oublierPression();
@ -480,7 +495,10 @@
else for (const cible of elementsDeTable(effet.cible.table)) cible.setAttribute('transform', effet.transform); else for (const cible of elementsDeTable(effet.cible.table)) cible.setAttribute('transform', effet.transform);
break; break;
case 'montrerFantome': case 'montrerFantome':
fantome = { nom: nomDe(effet.participant), cle: cleFantome(etat.cible) }; fantome = {
nom: nomDe(effet.participant),
cle: cleFantome(etat.cible, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null),
};
break; break;
case 'montrerPlaces': case 'montrerPlaces':
poignee = { table: effet.table, places: effet.places }; poignee = { table: effet.table, places: effet.places };
@ -678,6 +696,7 @@
oublierImage(); oublierImage();
document.removeEventListener('keydown', surTouche, true); document.removeEventListener('keydown', surTouche, true);
if (etatGeste.phase !== 'repos') seance.finirGeste(); if (etatGeste.phase !== 'repos') seance.finirGeste();
gesteEnCours = false;
}; };
}); });
@ -693,7 +712,7 @@
</script> </script>
<div class="plan"> <div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} /> <BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} {source} />
<div class="surface" data-surface bind:this={surface}> <div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} /> <BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<!-- Le plan est un composant interactif à deux dimensions, que le <!-- Le plan est un composant interactif à deux dimensions, que le

View file

@ -11,6 +11,11 @@
// chacun avec la commande de son remède, puis chaque avertissement. La // chacun avec la commande de son remède, puis chaque avertissement. La
// place le désigne par aria-describedby, vers id. // place le désigne par aria-describedby, vers id.
// //
// Posé sur le plan, le message couvre d'autres places : seuls ses
// contrôles reçoivent le pointeur. Un appui sur le texte va au plan
// dessous, dont l'appui retire la pose et saisit ce qui est visé — le
// refus se ferme au geste suivant sans lui prendre sa cible.
//
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y // pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
// en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un // en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un
// remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus // remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus
@ -42,6 +47,7 @@
.refus-place { .refus-place {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
pointer-events: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -57,6 +63,10 @@
font-size: var(--taille-texte); font-size: var(--taille-texte);
} }
.refus-place :global(button) {
pointer-events: auto;
}
p { p {
margin: 0; margin: 0;
} }

View file

@ -11,10 +11,12 @@
// d'une liste vers le plan : la place se choisit ensuite sur le plan. Un // d'une liste vers le plan : la place se choisit ensuite sur le plan. Un
// changement de tour change la liste et oublie le choix (etat_ui.js) ; // changement de tour change la liste et oublie le choix (etat_ui.js) ;
// Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi. // Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi.
// Le panneau lit 'seance' et 'ui' au contexte, sans propriété. // Le panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des
// places du tour affiché, que SectionPlan calcule, ou null sans événement.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { etatPlaces } from '../../application/places.js'; import { nomDeCharge } from './vue_plan.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -22,8 +24,8 @@
const uid = $props.id(); const uid = $props.id();
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
const reserve = $derived(charge === null ? [] : etatPlaces(charge, { tour, proposition: $ui.proposition }).reserve); const reserve = $derived(etatTour === null ? [] : etatTour.reserve);
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const choix = $derived($ui.choix); const choix = $derived($ui.choix);
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id; const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;

View file

@ -6,32 +6,44 @@
// sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact, // sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact,
// dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ; // dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ;
// les listes de travail, la réserve, le panneau d'une place et celui // les listes de travail, la réserve, le panneau d'une place et celui
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte : la // d'une table. Les panneaux lisent 'seance' et 'ui' au contexte.
// section ne leur passe rien. Le plan se remonte pour chaque événement : //
// un autre événement repart de sa propre vue, mémorisée ou ajustée. // L'état des places du tour affiché (etatPlaces, A5) se calcule ici, une
// fois par changement de la charge, du tour affiché ou de la proposition
// montrée — chacun un $derived, qu'un autre changement de l'état de
// navigation ne relance pas —, et se passe au plan, à la réserve et aux
// panneaux de la place et de la table (etatTour) : aucun ne le recalcule.
// Le plan se remonte pour chaque événement : un autre événement repart de
// sa propre vue, mémorisée ou ajustée.
// //
// En plein écran, le titre et les panneaux s'effacent : le plan prend la // En plein écran, le titre et les panneaux s'effacent : le plan prend la
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le // surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4). // cadre l'entoure toujours (§ 8.4).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js'; import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { etatPlaces } from '../../application/places.js';
import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte';
import ListesTravail from './ListesTravail.svelte'; import ListesTravail from './ListesTravail.svelte';
import PanneauPlace from './PanneauPlace.svelte'; import PanneauPlace from './PanneauPlace.svelte';
import PanneauTable from './PanneauTable.svelte'; import PanneauTable from './PanneauTable.svelte';
import Plan from './Plan.svelte'; import Plan from './Plan.svelte';
import Reserve from './Reserve.svelte'; import Reserve from './Reserve.svelte';
import { tourAffiche } from './vue_plan.js';
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const ui = getContext('ui'); const ui = getContext('ui');
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id); const charge = $derived($seance.charge);
const evenement = $derived(charge === null ? null : charge.evenement.id);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
const proposition = $derived($ui.proposition);
const etatTour = $derived(charge === null ? null : etatPlaces(charge, { tour, proposition }));
// Le diagnostic de la charge, que Plan donne au modèle de dessin : quand // Le diagnostic de la charge, que Plan donne au modèle de dessin : quand
// toutes les tables sont en conflit, le bandeau du plan en énonce les // toutes les tables sont en conflit, le bandeau du plan en énonce les
// planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et // planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et
// le bandeau ne porte que la phrase. // le bandeau ne porte que la phrase.
const diagnostic = $derived($seance.charge === null ? null : diagnostiquerCharge($seance.charge).diagnostic); const diagnostic = $derived(charge === null ? null : diagnostiquerCharge(charge).diagnostic);
</script> </script>
<section data-section="plan" class="section-plan"> <section data-section="plan" class="section-plan">
@ -41,7 +53,7 @@
<div class="corps"> <div class="corps">
{#if evenement !== null} {#if evenement !== null}
{#key evenement} {#key evenement}
<Plan {diagnostic} /> <Plan {diagnostic} {etatTour} />
{/key} {/key}
{/if} {/if}
{#if !$ui.pleinEcran} {#if !$ui.pleinEcran}
@ -50,9 +62,9 @@
<Diagnostic compact /> <Diagnostic compact />
</div> </div>
<ListesTravail /> <ListesTravail />
<Reserve /> <Reserve {etatTour} />
<PanneauPlace /> <PanneauPlace {etatTour} />
<PanneauTable /> <PanneauTable {etatTour} />
</aside> </aside>
{/if} {/if}
</div> </div>

View file

@ -13,13 +13,20 @@
// En régime table, les chaises ne se dessinent pas : l'emprise entière se // En régime table, les chaises ne se dessinent pas : l'emprise entière se
// peint au fond de la chaise et devient la seule cible, sous le plateau. // peint au fond de la chaise et devient la seule cible, sous le plateau.
// En régime sièges, chaque chaise est un groupe [data-siege="table:siège"] // En régime sièges, chaque chaise est un groupe [data-siege="table:siège"]
// dont seul le disque reçoit le pointeur : ses marques, comme la pastille, // dont la cible reçoit le pointeur : un disque sans peinture, de rayon
// ont pointer-events: none, et la cible d'une chaise est le disque même que // rayonCible — max(RAYON_CHAISE, pas / 2), le disque même que la
// la désignation lit (designation.js). // désignation lit (designation.js) —, dernier enfant du groupe, au-dessus
// de la chaise et de ses marques ; les marques, comme la pastille, ont
// pointer-events: none. La cible ne porte aucune marque et ne peint rien :
// le premier disque du groupe reste la chaise.
// //
// Une table en conflit, quand le plan signale, porte un contour épaissi // Une table en conflit, quand le plan signale, porte un contour épaissi
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa // (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
// pastille porte le nombre de violations, signalée ou non (§ 7.5). // pastille porte le nombre de violations, signalée ou non (§ 7.5). Sur la
// trame, un socle [data-socle] du fond du plateau, centré sous la
// pastille, la sépare des traits de la trame : la pastille se mesure
// contre le fond du plateau (§ 14.5), et la trame reste peinte autour.
// Le socle précède la marque de gel, qui se peint sur lui.
// //
// Le groupe de la table porte id="table-<id>", celui d'une chaise // Le groupe de la table porte id="table-<id>", celui d'une chaise
// id="siege-<table>-<siège>" : la racine du plan, seul arrêt de tabulation, // id="siege-<table>-<siège>" : la racine du plan, seul arrêt de tabulation,
@ -39,6 +46,7 @@
ECART_FOCUS, ECART_FOCUS,
ECART_MISE_EN_EVIDENCE, ECART_MISE_EN_EVIDENCE,
RAYON_PASTILLE, RAYON_PASTILLE,
RAYON_SOCLE_PASTILLE,
TAILLE_TEXTE_PASTILLE_CM, TAILLE_TEXTE_PASTILLE_CM,
TRACE_GEL, TRACE_GEL,
formesTable, formesTable,
@ -87,6 +95,7 @@
{:else} {:else}
<rect class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} /> <rect class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
{/if} {/if}
<circle class="socle" data-socle cx={formes.pastille.x} cy={formes.pastille.y} r={RAYON_SOCLE_PASTILLE} />
{/if} {/if}
{#if table.gelee} {#if table.gelee}
<path class="gel" data-marque="gel" d={TRACE_GEL} transform="translate({formes.gel.x},{formes.gel.y})" /> <path class="gel" data-marque="gel" d={TRACE_GEL} transform="translate({formes.gel.x},{formes.gel.y})" />
@ -133,6 +142,7 @@
{#if chaise.focus} {#if chaise.focus}
<rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} /> <rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} />
{/if} {/if}
<circle class="cible" cx={chaise.x} cy={chaise.y} r={table.geometrie.rayonCible} />
</g> </g>
{/each} {/each}
</g> </g>
@ -180,11 +190,25 @@
pointer-events: none; pointer-events: none;
} }
.socle {
fill: var(--couleur-table-fond);
stroke: none;
pointer-events: none;
}
.gel { .gel {
fill: var(--couleur-gel); fill: var(--couleur-gel);
pointer-events: none; pointer-events: none;
} }
/* La cible d'un siège : aucune peinture, le pointeur la reçoit tout de
même. */
.cible {
fill: none;
stroke: none;
pointer-events: all;
}
.chaise { .chaise {
fill: var(--couleur-chaise-fond); fill: var(--couleur-chaise-fond);
stroke: var(--couleur-chaise-trait); stroke: var(--couleur-chaise-trait);

View file

@ -569,7 +569,7 @@ describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () =
}); });
describe('le cadrage (§ 8.5, § 13.3)', () => { describe('le cadrage (§ 8.5, § 13.3)', () => {
test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en lecture, puis sur un plan bloqué', async () => { test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en écriture, en lecture, puis sur un plan bloqué', async () => {
const m = await petite(); const m = await petite();
const verifier = async (gestes) => { const verifier = async (gestes) => {
const avant = fichiers(m); const avant = fichiers(m);
@ -590,15 +590,24 @@ describe('le cadrage (§ 8.5, § 13.3)', () => {
const lue = vueLue(m); const lue = vueLue(m);
expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue); expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue);
}; };
expect(m.seance.etat().mode).toBe('lecture'); const zooms = async () => {
await verifier(async () => {
await cran(m, -100); await cran(m, -100);
await cran(m, -100); await cran(m, -100);
cliquer(m, 'zoomer'); cliquer(m, 'zoomer');
cliquer(m, 'dezoomer'); cliquer(m, 'dezoomer');
cliquer(m, 'ajuster'); cliquer(m, 'ajuster');
await cran(m, 100); await cran(m, 100);
}); };
// Le cas de base : l'écriture, sur un événement que rien ne bloque.
await m.seance.passerEnEcriture();
flushSync();
expect(m.seance.etat().mode).toBe('ecriture');
expect(m.seance.etat().evenement.etat).not.toBe('bloque');
await verifier(zooms);
await m.seance.passerEnLecture();
flushSync();
expect(m.seance.etat().mode).toBe('lecture');
await verifier(zooms);
await m.seance.passerEnEcriture(); await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' }); await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync(); flushSync();

View file

@ -9,6 +9,16 @@
// le geste modifie, et la commande juge la pose et la capacité. // le geste modifie, et la commande juge la pose et la capacité.
import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js'; import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js';
/**
* Codes d'un refus que le dépôt lève lui-même, leur texte dans
* src/application/libelles/places.js :
* RESERVATION_TOUS_LES_TOURS {participant, table, remede} : sous la cible
* 'reservations', une personne que sa réservation « tous les tours » tient
* à sa chaise, déposée hors des tables ; remède { commande:
* 'retirerReservation', arguments: {participant, table, portee: 'tous',
* tour: null} }.
*/
// La chaise d'une table de l'état des places, ou undefined. // La chaise d'une table de l'état des places, ou undefined.
function chaiseDe(etatPlaces, { table, siege }) { function chaiseDe(etatPlaces, { table, siege }) {
return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1]; return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1];
@ -23,15 +33,38 @@ function premierLibre(etatPlaces, table, libre) {
return chaise === undefined ? null : chaise.siege; return chaise === undefined ? null : chaise.siege;
} }
/**
* La réservation qui tient une personne à sa chaise d'origine, telle que
* l'état des places la porte — {participant, portee, tour} —, sinon null :
* pas de chaise d'origine, une chaise que rien ne tient, ou que la
* réservation d'une autre tient. Le dépôt et le libellé du fantôme la lisent
* ici tous deux.
*
* @param {Object} etatPlaces EtatPlaces du tour affiché
* @param {number} personne
* @param {{table: number, siege: number}|null} depuis
* @returns {{participant: number, portee: 'tous'|'tour', tour: number|null}|null}
*/
export function reservationTenue(etatPlaces, personne, depuis) {
const reservation = depuis === null ? null : (chaiseDe(etatPlaces, depuis)?.reservation ?? null);
return reservation?.participant === personne ? reservation : null;
}
/** /**
* La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat, * La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat,
* « Source ») : * « Source ») :
* - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte * - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte
* pas — : une personne que sa chaise d'origine tient réservée déplace sa * pas — : une personne que sa chaise d'origine tient réservée déplace sa
* réservation (deplacerReservation), qui garde portée et tour ; une * réservation (deplacerReservation), qui garde portée et tour — le
* fantôme le dit pendant le geste, « tous les tours » compris ; une
* autre reçoit une réservation de portée « tour » au tour affiché * autre reçoit une réservation de portée « tour » au tour affiché
* (reserver) ; sur le sol, la réservation glissée part * (reserver) ; sur le sol, la réservation « ce tour » glissée part
* (retirerReservation), et rien pour une personne non réservée. Un siège * (retirerReservation), et rien pour une personne non réservée. Une
* réservation « tous les tours » ne part pas : un geste fait à un tour ne
* change pas les autres tours en silence, et une réservation se défait
* par sa propre commande (§ 7.1) ; le dépôt rend le refus
* RESERVATION_TOUS_LES_TOURS {participant, table}, dont le remède est ce
* retrait, de portée « tous ». Un siège
* qu'occupe une personne non réservée de la proposition montrée vaut un * qu'occupe une personne non réservée de la proposition montrée vaut un
* siège libre : la proposition reste identique, et dérive ; * siège libre : la proposition reste identique, et dérive ;
* - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une * - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une
@ -69,11 +102,12 @@ export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, sur
return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } }; return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } };
} }
const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis); const tenue = reservationTenue(etatPlaces, personne, depuis);
const tenue = origine?.reservation?.participant === personne ? origine.reservation : null;
if (cible === null) { if (cible === null) {
if (tenue === null) return null; if (tenue === null) return null;
return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } }; const retrait = { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
if (tenue.portee !== 'tous') return retrait;
return { refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: personne, table: depuis.table, remede: retrait } } };
} }
const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null); const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null);
if (tenue !== null) { if (tenue !== null) {

View file

@ -14,7 +14,7 @@ import { appliquerCommande } from '../../application/commandes.js';
import { etatPlaces } from '../../application/places.js'; import { etatPlaces } from '../../application/places.js';
import { geometrieTable } from '../../geometrie/tables.js'; import { geometrieTable } from '../../geometrie/tables.js';
import { creerCharge } from '../../stockage/document.js'; import { creerCharge } from '../../stockage/document.js';
import { commandeDeDepot, deposer } from './depot.js'; import { commandeDeDepot, deposer, reservationTenue } from './depot.js';
const NOMS = [ const NOMS = [
['Ombrelle', 'Iris'], ['Ombrelle', 'Iris'],
@ -114,6 +114,39 @@ describe('commandeDeDepot sous la cible « reservations » : avant la retenue (
}); });
}); });
test('reservationTenue : la réservation qui tient la personne à sa chaise d’origine, sinon null', () => {
// Le fantôme et le dépôt la lisent de la même fonction : le libellé du
// geste dit la portée que deplacerReservation garde.
const tous = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
const etat = etatPlaces(tous, { tour: 2, proposition: 1 });
assert.deepEqual(reservationTenue(etat, 1, { table: 1, siege: 1 }), { participant: 1, portee: 'tous', tour: null });
assert.equal(reservationTenue(etat, 2, { table: 1, siege: 1 }), null);
assert.equal(reservationTenue(etat, 1, { table: 2, siege: 1 }), null);
assert.equal(reservationTenue(etat, 1, null), null);
const tour = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
assert.deepEqual(reservationTenue(etatPlaces(tour, { tour: 1, proposition: 1 }), 1, { table: 1, siege: 1 }), {
participant: 1,
portee: 'tour',
tour: 1,
});
});
test('sur le sol, une réservation « tous les tours » ne se retire pas : { refus } RESERVATION_TOUS_LES_TOURS, son remède retirerReservation de portée « tous », que la charge exécute', () => {
// Un geste fait à un tour ne change jamais en silence les autres tours :
// la réservation se défait par sa propre commande (§ 7.1).
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
for (const tour of [1, 2]) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
assert.equal(etat.cible, 'reservations');
assert.deepEqual(depot(etat, { table: 1, siege: 1 }, null, true), {
refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: 1, table: 1, remede } },
});
}
const apres = executer(charge, remede.commande, remede.arguments);
assert.deepEqual(apres.reservations, []);
});
test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => { test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => {
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 }); const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
const etat = etatPlaces(charge, { tour: 1, proposition: 1 }); const etat = etatPlaces(charge, { tour: 1, proposition: 1 });

View file

@ -232,17 +232,34 @@ const CLES_FANTOME = new Map([
[null, 'plan.fantome.vueSeule'], [null, 'plan.fantome.vueSeule'],
]); ]);
// Sous la cible 'reservations', la clé d'une personne que sa réservation
// tient à sa chaise, par portée : son dépôt sur une chaise est
// deplacerReservation, qui garde la portée.
const CLES_RESERVATION_TENUE = new Map([
['tour', 'plan.fantome.reservationTour'],
['tous', 'plan.fantome.reservationTous'],
]);
/** /**
* La clé du libellé que le fantôme porte pendant le glissement d'une * La clé du libellé que le fantôme porte pendant le glissement d'une
* personne (§ 5.8) : « pose une réservation » sous la cible 'reservations', * personne (§ 5.8, § 7.1) : sous la cible 'reservations', « pose une
* « déplace dans le placement retenu » sous 'retenu', la vue seule sous une * réservation » pour une personne que rien ne tient, sinon le déplacement
* cible null. Une autre valeur lève : elle ne prend aucun libellé par défaut. * de sa réservation et sa portée — « tous les tours » s'y dit ; « déplace
* dans le placement retenu » sous 'retenu' ; la vue seule sous une cible
* null. Une cible ou une portée hors du contrat lève : elle ne prend aucun
* libellé par défaut.
* *
* @param {'reservations'|'retenu'|null} cible EtatPlaces.cible * @param {'reservations'|'retenu'|null} cible EtatPlaces.cible
* @param {'tous'|'tour'|null} [portee] la portée de la réservation qui tient
* la personne à sa chaise d'origine (reservationTenue de depot.js), ou null
* @returns {string} * @returns {string}
*/ */
export function cleFantome(cible) { export function cleFantome(cible, portee = null) {
if (!CLES_FANTOME.has(cible)) throw new TypeError(`cible du fantôme hors du contrat : ${JSON.stringify(cible)}`); if (!CLES_FANTOME.has(cible)) throw new TypeError(`cible du fantôme hors du contrat : ${JSON.stringify(cible)}`);
if (portee !== null && !CLES_RESERVATION_TENUE.has(portee)) {
throw new TypeError(`portée du fantôme hors du contrat : ${JSON.stringify(portee)}`);
}
if (cible === 'reservations' && portee !== null) return CLES_RESERVATION_TENUE.get(portee);
return CLES_FANTOME.get(cible); return CLES_FANTOME.get(cible);
} }

View file

@ -331,6 +331,23 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
for (const cible of ['reservations', 'retenu', null]) assert.equal(LIBELLES.has(cleFantome(cible)), true); for (const cible of ['reservations', 'retenu', null]) assert.equal(LIBELLES.has(cleFantome(cible)), true);
}); });
test('cleFantome : sous les réservations, une personne réservée déplace sa réservation, et le libellé en dit la portée', () => {
// Le dépôt d'une personne réservée exécute deplacerReservation, qui garde
// la portée : « tous les tours » se dit pendant le geste (§ 5.8, § 7.1).
assert.equal(cleFantome('reservations', null), 'plan.fantome.reservations');
assert.equal(cleFantome('reservations', 'tour'), 'plan.fantome.reservationTour');
assert.equal(cleFantome('reservations', 'tous'), 'plan.fantome.reservationTous');
for (const portee of [null, 'tour', 'tous']) {
assert.equal(cleFantome('retenu', portee), 'plan.fantome.retenu');
assert.equal(cleFantome(null, portee), 'plan.fantome.vueSeule');
assert.equal(LIBELLES.has(cleFantome('reservations', portee)), true);
}
assert.match(LIBELLES.get(cleFantome('reservations', 'tous')), /tous les tours/);
for (const portee of ['Tous', 'tours', '', 0]) {
assert.throws(() => cleFantome('reservations', portee), { name: 'TypeError', message: /^portée du fantôme hors du contrat/ });
}
});
test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => { test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => {
for (const cible of ['reservation', 'Retenu', undefined, '', 0]) { for (const cible of ['reservation', 'Retenu', undefined, '', 0]) {
assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ }); assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ });

View file

@ -19,6 +19,11 @@ export const LARGEUR_ECHELLE_PX = 120;
/** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel /** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel
* d'une table de 2, la plus petite. */ * d'une table de 2, la plus petite. */
export const RAYON_PASTILLE = 15; export const RAYON_PASTILLE = 15;
/** Rayon du socle de la pastille, en cm de dessin : un disque du fond du
* plateau, posé sur la trame d'une table signalée, sous la pastille, qui
* l'en sépare (§ 14.5). Plus petit que le plateau le plus petit, de rayon
* PLANCHER_DIAMETRE / 2 : la trame reste peinte autour. */
export const RAYON_SOCLE_PASTILLE = 20;
/** Corps du chiffre de la pastille, en cm de dessin. */ /** Corps du chiffre de la pastille, en cm de dessin. */
export const TAILLE_TEXTE_PASTILLE_CM = 18; export const TAILLE_TEXTE_PASTILLE_CM = 18;
@ -468,15 +473,16 @@ export function actionsPlace({ reservation, titre }, choix) {
* montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un * montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un
* refus (retrait non null) ; réserver ici quand elle est assise sur une * refus (retrait non null) ; réserver ici quand elle est assise sur une
* place qu'aucune réservation ne tient ; déplacer sa réservation quand la * place qu'aucune réservation ne tient ; déplacer sa réservation quand la
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la * sienne tient sa chaise ; exclure, ou réintégrer une exclue ; la mettre en
* fiche. * évidence, ou ôter sa mise en évidence quand elle l'est ; ouvrir la fiche.
* *
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne * @param {{exclu: boolean, chaise: Object|null, retrait: Object|null, enEvidence: boolean}} personne
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin * chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin ;
* enEvidence, vrai quand elle est la personne mise en évidence (ui.miseEnEvidence)
* @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci, * @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci,
* deplacerReservation, exclure, reintegrer, ouvrirFiche * deplacerReservation, exclure, reintegrer, mettreEnEvidence, oterEvidence, ouvrirFiche
*/ */
export function actionsPersonne({ exclu, chaise, retrait }) { export function actionsPersonne({ exclu, chaise, retrait, enEvidence }) {
const assise = chaise !== null; const assise = chaise !== null;
const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant; const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
return [ return [
@ -487,6 +493,8 @@ export function actionsPersonne({ exclu, chaise, retrait }) {
['deplacerReservation', tenue], ['deplacerReservation', tenue],
['exclure', !exclu], ['exclure', !exclu],
['reintegrer', exclu], ['reintegrer', exclu],
['mettreEnEvidence', !enEvidence],
['oterEvidence', enEvidence],
['ouvrirFiche', true], ['ouvrirFiche', true],
] ]
.filter(([, offerte]) => offerte) .filter(([, offerte]) => offerte)

View file

@ -16,7 +16,7 @@ import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js'; import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js'; import { constante } from '../../geometrie/constantes.js';
import { regimeCibles } from '../../geometrie/designation.js'; import { regimeCibles } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js'; import { PLANCHER_DIAMETRE, RAYON_POIGNEE, geometrieTable } from '../../geometrie/tables.js';
import { indexerPlan } from '../../moteur/configuration.js'; import { indexerPlan } from '../../moteur/configuration.js';
import { conflitsParTable } from '../../moteur/conflits.js'; import { conflitsParTable } from '../../moteur/conflits.js';
import { diagnostiquer } from '../../moteur/diagnostic.js'; import { diagnostiquer } from '../../moteur/diagnostic.js';
@ -26,7 +26,13 @@ import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage
import { planDepuisFichier, versFichier } from '../../stockage/placements.js'; import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import { import {
ECART_ANNEAU,
ECART_DESIGNATION,
ECART_FOCUS,
ECART_MISE_EN_EVIDENCE,
LARGEUR_ECHELLE_PX, LARGEUR_ECHELLE_PX,
RAYON_PASTILLE,
RAYON_SOCLE_PASTILLE,
actionsPersonne, actionsPersonne,
actionsPlace, actionsPlace,
ajusterLigne, ajusterLigne,
@ -708,6 +714,36 @@ describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1,
}); });
}); });
describe('les marques autour d\u{2019}une table laissent la liste de noms à découvert (§ 7.4, § 14.5)', () => {
// Le trait du carré du focus fait 3 px à l'écran (TablePlan) : sa moitié
// extérieure couvre au plus 1,5 px, soit 1,5 / k cm, et les listes ne se
// rendent qu'à k ≥ K_LISTES.
const DEMI_TRAIT_FOCUS_CM = 1.5 / K_LISTES;
test('le focus, de la table ou de sa chaise la plus à droite, et la poignée finissent avant le bloc de noms', () => {
for (const forme of ['ronde', 'carree']) {
for (const places of [2, 3, 5, 8, 12, 16]) {
const g = geometrieTable({ id: 1, numero: 1, forme, position: { x: 40, y: -70 } }, places);
const droite = Math.max(...g.chaises.map((chaise) => chaise.x + chaise.rayon));
const bords = {
'focus de la table': g.centre.x + g.rayonEmprise + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM,
'focus de la chaise': droite + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM,
poignée: g.poignee.x + RAYON_POIGNEE,
};
for (const [quoi, bord] of Object.entries(bords)) {
assert.ok(bord < g.origineListe.x, `${forme} de ${places} : ${quoi} jusqu'à ${bord}, liste dès ${g.origineListe.x}`);
}
}
}
});
test('les autres marques d\u{2019}une chaise restent sous le focus ; le socle de la pastille, entre elle et le plus petit plateau', () => {
for (const ecart of [ECART_ANNEAU, ECART_MISE_EN_EVIDENCE, ECART_DESIGNATION]) assert.ok(ecart < ECART_FOCUS, String(ecart));
assert.ok(RAYON_PASTILLE < RAYON_SOCLE_PASTILLE);
assert.ok(RAYON_SOCLE_PASTILLE < PLANCHER_DIAMETRE / 2);
});
});
describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => { describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => {
test('chaque partie présente, nommée ; les absentes omises', () => { test('chaque partie présente, nommée ; les absentes omises', () => {
const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null }; const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null };
@ -929,25 +965,42 @@ describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => {
const assise = { siege: 2, occupant: 4, reservation: null, titre: null }; const assise = { siege: 2, occupant: 4, reservation: null, titre: null };
const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } }; const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } };
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => { test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, mettre en évidence, ouvrir la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [ assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait, enEvidence: false }), [
'deplacer', 'deplacer',
'echanger', 'echanger',
'retirerDuTour', 'retirerDuTour',
'reserverIci', 'reserverIci',
'exclure', 'exclure',
'mettreEnEvidence',
'ouvrirFiche', 'ouvrirFiche',
]); ]);
}); });
test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => { test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => {
const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } }; const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } };
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null, enEvidence: false }), [
'deplacer',
'echanger',
'deplacerReservation',
'exclure',
'mettreEnEvidence',
'ouvrirFiche',
]);
}); });
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => { test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, la mise en évidence et la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'exclure', 'mettreEnEvidence', 'ouvrirFiche']);
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'reintegrer', 'mettreEnEvidence', 'ouvrirFiche']);
});
test('mise en évidence (§ 8.4) : la personne mise en évidence offre de l\u{2019}ôter, à la même place, et rien d\u{2019}autre ne change', () => {
for (const [chaise, sonRetrait] of [[assise, retrait], [null, null]]) {
const sans = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: false });
const avec = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: true });
assert.equal(sans.filter((action) => action === 'mettreEnEvidence').length, 1);
assert.deepEqual(avec, sans.map((action) => (action === 'mettreEnEvidence' ? 'oterEvidence' : action)));
}
}); });
}); });
@ -1005,11 +1058,11 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } }; const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } };
const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]); const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
const menu = new Set([ const menu = new Set([
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }), ...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {}, enEvidence: false }),
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }), ...actionsPersonne({ exclu: true, chaise: tenue, retrait: {}, enEvidence: true }),
]); ]);
assert.equal(actions.size, 7); assert.equal(actions.size, 7);
assert.equal(menu.size, 8); assert.equal(menu.size, 10);
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action); for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action); for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string'); for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');

View file

@ -127,3 +127,36 @@ export function nomDeCharge(charge) {
const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)])); const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)]));
return (id) => noms.get(id); return (id) => noms.get(id);
} }
/**
* Ce que la barre de vue dit du placement que le plan montre (§ 2.1, étape
* 11 ; § 5.8), lu sur l'état des places du tour : le placement retenu, les
* seules réservations — ou un retenu qui ne se lit pas, sous lequel le plan
* montre les réservations —, une proposition dont les gestes posent des
* réservations (avant la retenue), ou une proposition en vue seule (après).
* Quand une proposition est demandée (ui.proposition), la barre offre de ne
* plus la montrer : revenir au retenu quand il existe, aux réservations
* sinon ; une proposition demandée qu'un tour ne porte pas laisse le plan
* sur sa source réelle, et la commande reste offerte.
*
* @param {{source: string, proposition: number|null, cible: string|null, refus: Object|null}} etat
* EtatPlaces du tour affiché
* @param {number|null} demandee la proposition demandée au plan
* @param {boolean} retenu la charge porte un placement retenu
* @returns {{libelle: {cle: string, details: Object}, revenir: string|null}}
* revenir : la clé du libellé de la commande, null sans proposition demandée
* @throws {TypeError} pour une source hors du contrat
*/
export function sourceMontree(etat, demandee, retenu) {
const revenir = demandee === null ? null : retenu ? 'plan.source.revenirRetenu' : 'plan.source.revenirReservations';
const avec = (cle, details = {}) => ({ libelle: { cle, details }, revenir });
if (etat.source === 'retenu') return avec('plan.source.retenu');
if (etat.source === 'proposition') {
const cle = etat.cible === 'reservations' ? 'plan.source.propositionReservations' : 'plan.source.propositionVueSeule';
return avec(cle, { proposition: etat.proposition });
}
if (etat.source === 'reservations') {
return avec(etat.refus?.code === 'RETENU_FAUTIF' ? 'plan.source.retenuFautif' : 'plan.source.reservations');
}
throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`);
}

View file

@ -27,6 +27,7 @@ import {
cadreDepuisRect, cadreDepuisRect,
englobantDuTour, englobantDuTour,
nomDeCharge, nomDeCharge,
sourceMontree,
tourAffiche, tourAffiche,
toursOfferts, toursOfferts,
vueApresCadre, vueApresCadre,
@ -249,3 +250,47 @@ describe('l’englobant d’un tour, le tour montré, les noms', () => {
assert.equal(nomDe(2), 'Grisaille'); assert.equal(nomDe(2), 'Grisaille');
}); });
}); });
describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2.1 étape 11, § 5.8)', () => {
// Les champs d'EtatPlaces que la barre lit : source, proposition, cible, refus.
const etat = (source, { proposition = null, cible = null, refus = null } = {}) => ({ source, proposition, cible, refus });
test('le retenu, les réservations, un retenu illisible : un libellé chacun, aucune commande sans proposition demandée', () => {
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), null, true), {
libelle: { cle: 'plan.source.retenu', details: {} },
revenir: null,
});
assert.deepEqual(sourceMontree(etat('reservations', { cible: 'reservations' }), null, false), {
libelle: { cle: 'plan.source.reservations', details: {} },
revenir: null,
});
const fautif = etat('reservations', { refus: { code: 'RETENU_FAUTIF', details: {} } });
assert.deepEqual(sourceMontree(fautif, null, true).libelle, { cle: 'plan.source.retenuFautif', details: {} });
});
test('une proposition montrée avant la retenue : ses gestes posent des réservations ; la commande revient aux réservations', () => {
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 3, cible: 'reservations' }), 3, false), {
libelle: { cle: 'plan.source.propositionReservations', details: { proposition: 3 } },
revenir: 'plan.source.revenirReservations',
});
});
test('une proposition montrée après la retenue : vue seule ; la commande revient au retenu', () => {
const refus = { code: 'PROPOSITION_MONTREE', details: { proposition: 2, remede: null } };
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 2, refus }), 2, true), {
libelle: { cle: 'plan.source.propositionVueSeule', details: { proposition: 2 } },
revenir: 'plan.source.revenirRetenu',
});
});
test('une proposition demandée qu’un tour ne porte pas : le plan dit sa source réelle, et la commande reste offerte', () => {
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), 2, true), {
libelle: { cle: 'plan.source.retenu', details: {} },
revenir: 'plan.source.revenirRetenu',
});
});
test('une source hors du contrat lève', () => {
assert.throws(() => sourceMontree(etat('autre'), null, false), TypeError);
});
});

View file

@ -7,7 +7,17 @@
// libellé de confirmation, un premier clic arme la commande — son libellé // libellé de confirmation, un premier clic arme la commande — son libellé
// devient celui de confirmer — et seul un second exécute ; armée, elle se // devient celui de confirmer — et seul un second exécute ; armée, elle se
// désarme au terme de DELAI_CONFIRMATION_MS, par l'horloge du contexte. // désarme au terme de DELAI_CONFIRMATION_MS, par l'horloge du contexte.
// La machine est celle de commande.js. // La machine est celle de commande.js : le clic répété d'une série
// — detail supérieur à 1 — est sans effet (§ 8.5). Le clavier n'en porte
// pas : un appui d'Entrée ou d'Espace que la répétition automatique émet
// — keydown dont repeat est vrai — voit son action par défaut empêchée, et
// n'active pas le bouton. Une touche tenue n'active la commande qu'une
// fois, et ne confirme jamais celle qu'elle vient d'armer.
//
// Pendant qu'une exécution qui rend une promesse s'achève, la commande est
// occupée : un clic ne fait rien, et le bouton porte aria-busy et
// aria-disabled, dessiné d'un filet tireté. Une exécution sans promesse
// rend la commande aussitôt.
// //
// Un refus — une erreur qui porte un code — s'affiche à côté du bouton, // Un refus — une erreur qui porte un code — s'affiche à côté du bouton,
// avec son remède, et le bouton reste actif : jamais un bouton grisé muet. // avec son remède, et le bouton reste actif : jamais un bouton grisé muet.
@ -27,6 +37,7 @@
const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) }); const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) });
let armee = $state(false); let armee = $state(false);
let enCours = $state(false);
let refus = $state(null); let refus = $state(null);
let minuterie = null; let minuterie = null;
@ -45,9 +56,10 @@
armee = machine.armee; armee = machine.armee;
} }
async function cliquer() { async function cliquer(evenement) {
const { action } = machine.cliquer(); const { action } = machine.cliquer({ repete: evenement.detail > 1 });
armee = machine.armee; armee = machine.armee;
if (action === 'aucune') return;
if (action === 'armer') { if (action === 'armer') {
minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS); minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS);
return; return;
@ -55,18 +67,36 @@
desarmer(); desarmer();
refus = null; refus = null;
try { try {
await executer(); const rendu = executer();
if (typeof rendu?.then === 'function') {
enCours = true;
await rendu;
}
} catch (erreur) { } catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur; if (typeof erreur?.code !== 'string') throw erreur;
refus = { code: erreur.code, details: erreur.details ?? {} }; refus = { code: erreur.code, details: erreur.details ?? {} };
} finally {
machine.terminer();
enCours = false;
} }
} }
function surTouche(evenement) {
if (evenement.repeat && (evenement.key === 'Enter' || evenement.key === ' ')) evenement.preventDefault();
}
onDestroy(desarmer); onDestroy(desarmer);
</script> </script>
<div class="commande"> <div class="commande">
<button type="button" data-commande={designation} onclick={cliquer}>{libelle}</button> <button
type="button"
data-commande={designation}
aria-busy={enCours ? 'true' : undefined}
aria-disabled={enCours ? 'true' : undefined}
onkeydown={surTouche}
onclick={cliquer}>{libelle}</button
>
{#if refus !== null} {#if refus !== null}
<Refus {refus} lever={() => (refus = null)} {gestes} /> <Refus {refus} lever={() => (refus = null)} {gestes} />
{/if} {/if}
@ -91,6 +121,12 @@
cursor: pointer; cursor: pointer;
} }
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
button:focus-visible { button:focus-visible {
outline: 2px solid var(--couleur-focus); outline: 2px solid var(--couleur-focus);
outline-offset: 2px; outline-offset: 2px;

View file

@ -8,7 +8,19 @@
// confirmer s'arme au premier clic — son libellé devient celui de la // confirmer s'arme au premier clic — son libellé devient celui de la
// confirmation — et n'exécute qu'au second ; le composant la désarme au // confirmation — et n'exécute qu'au second ; le composant la désarme au
// terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin // terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin
// n'exécute une commande à confirmer en un clic. // n'exécute une commande à confirmer en un clic (§ 8.5).
//
// Le clic répété d'une série — celui dont le compte de clics, detail,
// dépasse 1 — est sans effet sur toute commande : il n'exécute, n'arme ni ne
// confirme rien. Le second clic d'un double-clic tombe souvent sur une autre
// commande que le premier, celle qu'a montée l'exécution à la même place
// — « Revenir en lecture » à la place de « Modifier » —, et il n'est jamais
// une seconde exécution voulue : « Défaire » double-cliqué ne rend qu'un
// geste. Armée, une commande le reste ; un clic simple la confirme.
//
// Une commande n'est pas ré-entrante : de l'exécution lancée à terminer(),
// que le composant appelle quand l'action rend la main, tout clic est sans
// effet. Un second clic pendant une écriture lente ne relance rien.
// //
// Un remède (contrat « Refus et avertissements ») est null, une commande // Un remède (contrat « Refus et avertissements ») est null, une commande
// exécutable telle quelle, { commande, arguments }, ou un geste, // exécutable telle quelle, { commande, arguments }, ou un geste,
@ -58,24 +70,37 @@ export const SECTIONS_REMEDE = Object.freeze([
* *
* @param {{confirmer?: string|null}} [options] la clé du libellé de * @param {{confirmer?: string|null}} [options] la clé du libellé de
* confirmation ; null pour une commande qui exécute au premier clic * confirmation ; null pour une commande qui exécute au premier clic
* @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}} * @returns {{readonly armee: boolean, readonly enCours: boolean,
* cliquer arme une commande à confirmer désarmée, et exécute sinon, en la * cliquer: (clic?: {repete?: boolean}) => ({action: 'armer'}|{action: 'executer'}|{action: 'aucune'}),
* désarmant ; expirer la désarme. * terminer: () => void, expirer: () => void}}
* cliquer ne fait rien pendant une exécution, ni pour le clic répété
* d'une série (repete vrai) ; sinon il arme une commande à confirmer
* désarmée, et exécute, en la désarmant et en la marquant en cours ;
* terminer la rend aux clics ; expirer la désarme.
*/ */
export function creerMachineCommande({ confirmer = null } = {}) { export function creerMachineCommande({ confirmer = null } = {}) {
let armee = false; let armee = false;
let enCours = false;
return { return {
get armee() { get armee() {
return armee; return armee;
}, },
cliquer() { get enCours() {
return enCours;
},
cliquer({ repete = false } = {}) {
if (enCours || repete) return { action: 'aucune' };
if (confirmer !== null && !armee) { if (confirmer !== null && !armee) {
armee = true; armee = true;
return { action: 'armer' }; return { action: 'armer' };
} }
armee = false; armee = false;
enCours = true;
return { action: 'executer' }; return { action: 'executer' };
}, },
terminer() {
enCours = false;
},
expirer() { expirer() {
armee = false; armee = false;
}, },

View file

@ -65,10 +65,12 @@ describe('commande : la machine', () => {
assert.equal(DELAI_CONFIRMATION_MS, 10_000); assert.equal(DELAI_CONFIRMATION_MS, 10_000);
}); });
test('sans confirmer, chaque clic exécute, et la commande ne s’arme jamais', () => { test('sans confirmer, chaque clic exécute une fois l’exécution précédente terminée, et la commande ne s’arme jamais', () => {
const commande = creerMachineCommande(); const commande = creerMachineCommande();
assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.deepEqual(commande.cliquer(), { action: 'executer' });
commande.terminer();
assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.deepEqual(commande.cliquer(), { action: 'executer' });
commande.terminer();
assert.equal(commande.armee, false); assert.equal(commande.armee, false);
}); });
@ -79,9 +81,55 @@ describe('commande : la machine', () => {
assert.equal(commande.armee, true); assert.equal(commande.armee, true);
assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.deepEqual(commande.cliquer(), { action: 'executer' });
assert.equal(commande.armee, false); assert.equal(commande.armee, false);
commande.terminer();
assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau'); assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau');
}); });
test('pendant l’exécution, un clic ne fait rien : ni seconde exécution, ni armement ; terminer() la rend', () => {
const simple = creerMachineCommande();
assert.deepEqual(simple.cliquer(), { action: 'executer' });
assert.equal(simple.enCours, true);
assert.deepEqual(simple.cliquer(), { action: 'aucune' });
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' });
simple.terminer();
assert.equal(simple.enCours, false);
assert.deepEqual(simple.cliquer(), { action: 'executer' });
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
confirmee.cliquer();
assert.deepEqual(confirmee.cliquer(), { action: 'executer' });
assert.deepEqual(confirmee.cliquer(), { action: 'aucune' }, 'occupée, le clic ne la réarme pas');
assert.equal(confirmee.armee, false);
confirmee.terminer();
assert.deepEqual(confirmee.cliquer(), { action: 'armer' });
});
test('armée, le clic répété d’un double-clic ne confirme pas : elle reste armée, et un clic simple ensuite exécute (§ 8.5)', () => {
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'armer' });
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' });
assert.equal(commande.armee, true);
assert.equal(commande.enCours, false);
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' }, 'un triple clic non plus');
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'executer' });
assert.equal(commande.armee, false);
});
test('le clic répété d’une série n’exécute jamais, avec ou sans confirmer : il ne fait rien, et le clic simple suivant exécute', () => {
const simple = creerMachineCommande();
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'reçu sans le premier clic de sa série');
assert.equal(simple.enCours, false);
assert.deepEqual(simple.cliquer({ repete: false }), { action: 'executer' });
simple.terminer();
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le second clic d’un double-clic');
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le troisième');
assert.deepEqual(simple.cliquer(), { action: 'executer' });
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
assert.deepEqual(confirmee.cliquer({ repete: true }), { action: 'aucune' }, 'désarmée, le clic répété ne l’arme pas');
assert.equal(confirmee.armee, false);
});
test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => { test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => {
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE }); const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
commande.cliquer(); commande.cliquer();

View file

@ -12,6 +12,7 @@
// Le projet navigateur emploie expect, et test et describe de Vitest // Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8). // (§ 14.8).
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, tick, unmount } from 'svelte'; import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css'; import '../jetons.css';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
@ -132,6 +133,41 @@ async function cliquerAilleurs() {
await tick(); await tick();
} }
// Un double-clic réel au centre de l'élément : deux appuis, le second
// annoncé par Chromium comme le deuxième clic d'une série (clickCount 2),
// si bien que son événement click porte detail 2. Les coordonnées du cadre
// de l'épreuve passent dans la fenêtre de la page qui le porte, d'un cadre
// à son parent, comme dans test/navigateur/entrees.js.
async function doubleCliquer(element) {
let { x, y } = centre(element);
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const cadre = fenetre.frameElement;
const boite = cadre.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(cadre);
const echelle = cadre.offsetWidth > 0 ? boite.width / cadre.offsetWidth : 1;
x = boite.left + (cadre.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (cadre.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
for (const clickCount of [1, 2]) {
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount });
}
await tick();
}
// Une touche Entrée tenue : un premier appui, puis repetitions appuis que
// Chromium marque répétés (autoRepeat, d'où repeat vrai sur keydown), comme
// la répétition automatique du clavier, et un seul relâcher. Les événements
// vont à l'élément qui a le focus.
async function tenirEntree(repetitions) {
const entree = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
const appui = { type: 'keyDown', ...entree, text: '\r', unmodifiedText: '\r' };
await cdp().send('Input.dispatchKeyEvent', appui);
for (let n = 0; n < repetitions; n += 1) await cdp().send('Input.dispatchKeyEvent', { ...appui, autoRepeat: true });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...entree });
await tick();
}
async function taper(texte) { async function taper(texte) {
for (const caractere of texte) await touche(caractere); for (const caractere of texte) await touche(caractere);
await tick(); await tick();
@ -539,6 +575,39 @@ describe('le remède « Débloquer » (§ 8.5)', () => {
expect(montage.seance.etat().evenement.etat).toBe('bloque'); expect(montage.seance.etat().evenement.etat).toBe('bloque');
}); });
test('un double-clic arme la commande sans débloquer : elle reste armée, rien d’écrit ; un clic simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const clics = [];
bouton.addEventListener('click', (evenement) => clics.push(evenement.detail));
await doubleCliquer(bouton);
expect(clics).toEqual([1, 2]);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
test('Entrée tenue arme la commande sans débloquer : les appuis répétés du clavier ne confirment pas, rien d’écrit ; un appui simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const appuis = [];
bouton.addEventListener('keydown', (evenement) => appuis.push(evenement.repeat));
bouton.focus();
await tenirEntree(3);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(appuis).toEqual([false, true, true, true]);
await tenirEntree(0);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
// Le refus d'un plan bloqué posé contre le champ par une frappe. // Le refus d'un plan bloqué posé contre le champ par une frappe.
async function refusAuPremierCaractere2(montage) { async function refusAuPremierCaractere2(montage) {
const { cible, entree } = monterChamp(montage); const { cible, entree } = monterChamp(montage);
@ -824,6 +893,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const message = fenetre.querySelector('[data-refus]'); const message = fenetre.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(visible(message)).toBe(true); expect(visible(message)).toBe(true);
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull(); for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(montage.seance.etat().charge.participants).toHaveLength(1); expect(montage.seance.etat().charge.participants).toHaveLength(1);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
@ -836,10 +906,32 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
// entre elles : la seconde Échap ferme le <dialog> sans que cancel puisse // entre elles : la seconde Échap ferme le <dialog> sans que cancel puisse
// l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt, // l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt,
// modale et toujours occupée ; la fin de l'action seule décide. // modale et toujours occupée ; la fin de l'action seule décide.
async function echapEnRafale(fois) { //
// L'événement close part dans une tâche à lui, après la touche : la rafale
// compte les fermetures — l'attribut open retiré, que MutationObserver
// signale dès la touche traitée — et les événements close reçus, et attend
// que chaque fermeture ait eu le sien. Au moins une Échap ferme la fenêtre.
async function echapEnRafale(fenetre, fois) {
let fermetures = 0;
let evenements = 0;
const observateur = new MutationObserver((releves) => {
fermetures += releves.filter(({ oldValue }) => oldValue !== null).length;
});
const surClose = () => {
evenements += 1;
};
observateur.observe(fenetre, { attributes: true, attributeFilter: ['open'], attributeOldValue: true });
fenetre.addEventListener('close', surClose);
try {
for (let i = 0; i < fois; i += 1) await touche('Escape'); for (let i = 0; i < fois; i += 1) await touche('Escape');
// L'événement close part dans une tâche à lui, après la touche. await vi.waitFor(() => {
await new Promise((fin) => setTimeout(fin, 0)); expect(fermetures).toBeGreaterThan(0);
expect(evenements).toBe(fermetures);
});
} finally {
observateur.disconnect();
fenetre.removeEventListener('close', surClose);
}
await tick(); await tick();
} }
@ -848,6 +940,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.open).toBe(true); expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true); expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.getAttribute('aria-busy')).toBe('true'); expect(fenetre.getAttribute('aria-busy')).toBe('true');
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBe('true'); for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(montage.seance.etat().charge.participants).toHaveLength(1); expect(montage.seance.etat().charge.participants).toHaveLength(1);
} }
@ -857,10 +950,10 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const montage = await preparer(); const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage); const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fois); await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels); attendreOccupee(montage, fenetre, appels);
// Une seconde rafale, la fenêtre déjà rouverte une fois. // Une seconde rafale, la fenêtre déjà rouverte une fois.
await echapEnRafale(fois); await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels); attendreOccupee(montage, fenetre, appels);
attente.resoudre(); attente.resoudre();
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
@ -874,7 +967,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const montage = await preparer(); const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage); const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fois); await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels); attendreOccupee(montage, fenetre, appels);
await montage.seance.passerEnLecture(); await montage.seance.passerEnLecture();
attente.resoudre(); attente.resoudre();
@ -883,6 +976,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.matches(':modal')).toBe(true); expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE'); expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(fenetre.getAttribute('aria-busy')).toBeNull(); expect(fenetre.getAttribute('aria-busy')).toBeNull();
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull(); for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(appels).toEqual(['confirmer']); expect(appels).toEqual(['confirmer']);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
@ -957,6 +1051,52 @@ describe('commande (§ 2.1)', () => {
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2)); await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2));
}); });
test('pendant son exécution, un second clic réel ne relance rien : le bouton se dit occupé ; finie, un clic l’exécute de nouveau', async () => {
const montage = await preparer();
let appels = 0;
let liberer;
const cible = monter(
Commande,
{
cle: 'communs.remede.ajouterParticipant',
executer: () => {
appels += 1;
return new Promise((resoudre) => {
liberer = resoudre;
});
},
},
montage.contexte,
);
const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]');
const styleRepos = getComputedStyle(bouton).borderTopStyle;
await cliquer(bouton);
expect(appels).toBe(1);
expect(bouton.getAttribute('aria-busy')).toBe('true');
expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos);
await cliquer(bouton);
await doubleCliquer(bouton);
expect(appels).toBe(1);
liberer();
await vi.waitFor(() => expect(bouton.getAttribute('aria-busy')).toBeNull());
expect(bouton.getAttribute('aria-disabled')).toBeNull();
await cliquer(bouton);
expect(appels).toBe(2);
liberer();
});
test('une exécution sans promesse rend aussitôt la commande : deux clics de suite, deux exécutions', async () => {
const montage = await preparer();
let appels = 0;
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => void (appels += 1) }, montage.contexte);
const bouton = cible.querySelector('button');
bouton.click();
bouton.click();
expect(appels).toBe(2);
expect(bouton.getAttribute('aria-busy')).toBeNull();
});
test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => { test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => {
const montage = await preparer(); const montage = await preparer();
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte); const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte);

View file

@ -242,6 +242,42 @@ describe('une table créée (§ 4, § 6.1)', () => {
}); });
}); });
describe('un geste en cours ne se relance pas (§ 2.1)', () => {
test('« Créer une table » cliquée deux fois pendant l’écriture d’un support lent : une table, une entrée', async () => {
const m = await preparer(chargeDe({ n: 2 }));
const r0 = revision(m);
// Le système simulé retient ses écritures jusqu'à liberer() : le geste
// reste en cours d'écriture. Le premier geste crée le journal, par une
// écriture entière ; les suivants y ajoutent une ligne.
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
for (const primitive of ['ajouterLigne', 'ecrireAtomique']) {
const ecrire = m.fs[primitive];
m.fs[primitive] = async (...parametres) => {
await porte;
return ecrire.apply(m.fs, parametres);
};
}
const bouton = commande(section(m), 'creerTableRonde');
bouton.scrollIntoView({ block: 'center' });
const boite = bouton.getBoundingClientRect();
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
for (let clic = 0; clic < 2; clic += 1) {
await pointer('presser', x, y);
await pointer('relacher', x, y);
flushSync();
}
expect(bouton.getAttribute('aria-busy')).toBe('true');
liberer();
await attendre(m);
expect(charge(m).tables).toHaveLength(1);
expect(revision(m)).toBe(r0 + 1);
expect(bouton.getAttribute('aria-busy')).toBeNull();
});
});
describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => { describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => {
test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => { test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 })); const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 }));
@ -354,6 +390,12 @@ describe('changer le défaut (§ 6.1, § 6.2, § 19.3 06)', () => {
expect(fenetre()).toBeNull(); expect(fenetre()).toBeNull();
const refus = enonce(m).querySelector('[data-refus-realigner]'); const refus = enonce(m).querySelector('[data-refus-realigner]');
expect(refus).not.toBeNull(); expect(refus).not.toBeNull();
// L'en-tête est celui du réalignement refusé, qui laisse « Appliquer »
// offert, et non celui d'un défaut que rien n'applique.
const tete = refus.querySelector('[data-phrase]');
expect(tete?.textContent).toBe(t('tables.defaut.realignementRefuse', { avant: 8, apres: 10 }));
expect(visible(tete)).toBe(true);
expect(refus.textContent).not.toContain(t('tables.defaut.refusee', { avant: 8, apres: 10 }));
expect(refus.textContent).toContain('Table 2'); expect(refus.textContent).toContain('Table 2');
expect(refus.textContent).toContain('11 réservations'); expect(refus.textContent).toContain('11 réservations');
expect(ecritures(m)).toBe(avant); expect(ecritures(m)).toBe(avant);

File diff suppressed because it is too large Load diff

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