[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des primitives dans un vrai moteur de rendu (§ 2.1, § 5.9, § 8.2,
// § 8.4, § 8.5) : le champ, le refus, la fenêtre à confirmer et la commande,
// montés chacun par mount avec les contextes que pose App — 'seance', 't',
// 'horloge' —, sur une séance créée sur le système de fichiers simulé et
// l'horloge pilotée : rien n'attend le temps réel, et chaque écriture se
// compte. Les gestes passent par des entrées que Chromium traite comme
// réelles (test/navigateur/entrees.js). Les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach , describe , expect , test , vi } from 'vitest' ;
[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
import { cdp } from 'vitest/browser' ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
import { flushSync , mount , tick , unmount } from 'svelte' ;
import '../jetons.css' ;
import { TABLES _APPLICATION } from '../../application/libelles.js' ;
import { creerHorlogePilotee } from '../../application/horloge.js' ;
import { INACTIVITE _MS , creerSeance } from '../../application/seance.js' ;
import { creerTraduction } from '../../application/traduction.js' ;
import { serialiserCharge } from '../../stockage/canonique.js' ;
import { lireJournal , reconstruire } from '../../stockage/journal.js' ;
import { SUFFIXES } from '../../stockage/noms.js' ;
import { VERSION } from '../../version.genere.js' ;
import { creerFichiersSimules } from '../../../test/fichiers_simules.js' ;
import { pointer , touche } from '../../../test/navigateur/entrees.js' ;
import { visible } from '../../../test/navigateur/rendu.js' ;
import { TABLES _INTERFACE } from '../libelles.js' ;
import Champ from './Champ.svelte' ;
import Commande from './Commande.svelte' ;
import Confirmation from './Confirmation.svelte' ;
import { DELAI _CONFIRMATION _MS } from './commande.js' ;
const { t } = creerTraduction ( [ ... TABLES _APPLICATION , ... TABLES _INTERFACE ] ) ;
const EVENEMENT = 'Soirée des Merles' ;
const NOM = 'Ombrelle' ;
// Les composants montés par l'épreuve courante, et leurs conteneurs.
const montes = [ ] ;
afterEach ( ( ) => {
for ( const { composant , cible } of montes . splice ( 0 ) ) {
unmount ( composant ) ;
cible . remove ( ) ;
}
vi . restoreAllMocks ( ) ;
} ) ;
// Une séance sur le système simulé et l'horloge pilotée, un événement créé,
// Iris Ombrelle ajoutée (participant 1) ; puis bloque : plan bloqué, et la
// séance revient seule en lecture ; mode 'lecture' : retour en lecture.
async function preparer ( { mode = 'ecriture' , bloque = false } = { } ) {
const fs = creerFichiersSimules ( ) ;
fs . deposerDossier ( 'documents' ) ;
const { documents } = await fs . racines ( ) ;
const horloge = creerHorlogePilotee ( ) ;
let tirages = 0 ;
const seance = creerSeance ( {
fs ,
racine : documents ,
horloge ,
identifiant : ( ) => {
tirages += 1 ;
return ` epreuve- ${ tirages } ` ;
} ,
produitVersion : VERSION . affichee ,
planifier : horloge . planifier ,
annuler : horloge . annuler ,
} ) ;
await seance . creer ( { nom : EVENEMENT , date : null , siegesParDefaut : 6 , tours : 2 } ) ;
await seance . passerEnEcriture ( ) ;
await seance . executer ( 'ajouterParticipant' , { champs : { nom : NOM , prenom : 'Iris' } } ) ;
if ( bloque ) await seance . executer ( 'changerEtat' , { etat : 'bloque' } ) ;
if ( mode === 'lecture' ) await seance . passerEnLecture ( ) ;
const base = seance . etat ( ) . evenement . base ;
const contexte = new Map ( [
[ 'seance' , seance ] ,
[ 't' , t ] ,
[ 'horloge' , horloge ] ,
] ) ;
return { fs , horloge , seance , base , contexte } ;
}
// Monte un composant dans un conteneur neuf, au début du corps.
function monter ( Composant , props , contexte ) {
const cible = document . createElement ( 'div' ) ;
document . body . prepend ( cible ) ;
const composant = mount ( Composant , { target : cible , props , context : contexte } ) ;
flushSync ( ) ;
montes . push ( { composant , cible } ) ;
return cible ;
}
// Le champ du nom d'Iris : lire refuse un texte blanc, NOM_REQUIS ; executer
// passe par la séance, et chaque promesse rendue se garde dans attentes.
function monterChamp ( montage ) {
const attentes = [ ] ;
const props = {
cle : 'nom' ,
etiquette : 'communs.commande.modifier' ,
valeur : NOM ,
lire : ( texte ) => ( texte . trim ( ) === '' ? { refus : { code : 'NOM_REQUIS' , details : { remede : null } } } : { valeur : texte . trim ( ) } ) ,
executer : ( valeur ) => {
const promesse = montage . seance . executer ( 'modifierParticipant' , { id : 1 , champs : { nom : valeur } } ) ;
attentes . push ( promesse ) ;
return promesse ;
} ,
} ;
const cible = monter ( Champ , props , montage . contexte ) ;
return { cible , entree : cible . querySelector ( 'input[data-champ="nom"]' ) , attentes } ;
}
const centre = ( element ) => {
const b = element . getBoundingClientRect ( ) ;
return { x : b . left + b . width / 2 , y : b . top + b . height / 2 } ;
} ;
// Un clic réel au centre de l'élément.
async function cliquer ( element ) {
const { x , y } = centre ( element ) ;
await pointer ( 'presser' , x , y ) ;
await pointer ( 'relacher' , x , y ) ;
await tick ( ) ;
}
// Un clic réel dans le bas de la fenêtre, où rien ne se monte : le champ qui
// a le focus le perd.
async function cliquerAilleurs ( ) {
await pointer ( 'presser' , 4 , innerHeight - 4 ) ;
await pointer ( 'relacher' , 4 , innerHeight - 4 ) ;
await tick ( ) ;
}
[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
// 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 ( ) ;
}
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
async function taper ( texte ) {
for ( const caractere of texte ) await touche ( caractere ) ;
await tick ( ) ;
}
// Les lignes du journal de l'événement.
function lignesJournal ( montage ) {
return montage . fs . contenu ( 'documents' , ` ${ montage . base } ${ SUFFIXES . journal } ` ) . split ( '\n' ) . filter ( ( l ) => l !== '' ) ;
}
// Attend que la file de la séance ait passé tout ce qui s'y trouve — le
// retour automatique qu'une minuterie échue vient d'y poser compris — par
// une lecture admise dans tout mode, qui n'écrit rien et dont le rendu,
// réussi ou refusé, n'importe pas.
async function viderFile ( montage ) {
await montage . seance . formeNommee ( null ) . catch ( ( ) => undefined ) ;
}
const verrouPresent = ( montage ) => montage . fs . contenu ( 'documents' , ` ${ montage . base } ${ SUFFIXES . verrou } ` ) !== null ;
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
function ecart ( a , b ) {
const ba = a . getBoundingClientRect ( ) ;
const bb = b . getBoundingClientRect ( ) ;
const dx = Math . max ( 0 , ba . left - bb . right , bb . left - ba . right ) ;
const dy = Math . max ( 0 , ba . top - bb . bottom , bb . top - ba . bottom ) ;
return Math . max ( dx , dy ) ;
}
// Aucune fenêtre ni boîte de dialogue dans le document.
function aucuneFenetre ( ) {
expect ( document . querySelectorAll ( '[role=dialog], [role=alertdialog], dialog[open]' ) . length ) . toBe ( 0 ) ;
}
describe ( 'champ : la fin du geste (§ 2.1, § 8.2)' , ( ) => {
test ( "taper sans quitter le champ n'écrit rien, même 30 min plus tard ; le quitter écrit une ligne, et la dernière entrée reconstruit la charge écrite" , async ( ) => {
const montage = await preparer ( ) ;
const { entree , attentes } = monterChamp ( montage ) ;
const ecritures = montage . fs . compteurs . ecritures ;
const lignes = lignesJournal ( montage ) . length ;
const revision = montage . seance . etat ( ) . evenement . revision ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
montage . horloge . avancer ( 30 * 60 * 1000 ) ;
await tick ( ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } Lacasse ` ) ;
expect ( attentes ) . toHaveLength ( 0 ) ;
await cliquerAilleurs ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
await Promise . all ( attentes ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes + 1 ) ;
const etat = montage . seance . etat ( ) ;
expect ( etat . evenement . revision ) . toBe ( revision + 1 ) ;
expect ( etat . charge . participants [ 0 ] . nom ) . toBe ( ` ${ NOM } Lacasse ` ) ;
const journal = lireJournal ( montage . fs . contenu ( 'documents' , ` ${ montage . base } ${ SUFFIXES . journal } ` ) ) ;
const derniere = journal . entrees . at ( - 1 ) ;
expect ( derniere . revision ) . toBe ( revision + 1 ) ;
expect ( serialiserCharge ( reconstruire ( journal , derniere . revision ) ) ) . toBe ( serialiserCharge ( etat . charge ) ) ;
} ) ;
test ( 'une saisie que lire refuse affiche son refus contre le champ, garde le texte et n’ écrit rien ; Échap rend la valeur enregistrée' , async ( ) => {
const montage = await preparer ( ) ;
const { cible , entree , attentes } = monterChamp ( montage ) ;
const ecritures = montage . fs . compteurs . ecritures ;
await cliquer ( entree ) ;
for ( let i = 0 ; i < NOM . length ; i += 1 ) await touche ( 'Backspace' ) ;
await cliquerAilleurs ( ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ) . not . toBeNull ( ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( 'NOM_REQUIS' , { remede : null } ) ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( message . id ) ;
expect ( message . id ) . not . toBe ( '' ) ;
expect ( entree . value ) . toBe ( '' ) ;
expect ( attentes ) . toHaveLength ( 0 ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
await cliquer ( entree ) ;
await touche ( 'Escape' ) ;
await tick ( ) ;
expect ( entree . value ) . toBe ( NOM ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
} ) ;
} ) ;
describe ( 'champ : le refus de l’ exécution (§ 2.1, § 5.9)' , ( ) => {
test ( 'executer refusé avec un code : le refus se pose contre le champ, la saisie gardée, rien d’ écrit ; le refus levé par son remède, le champ rend la valeur enregistrée, et une saisie nouvelle s’ écrit' , async ( ) => {
const montage = await preparer ( ) ;
const { cible , entree , attentes } = monterChamp ( montage ) ;
const lignes = lignesJournal ( montage ) . length ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
// Le retour en lecture part avant la sortie du champ : la séance passe ses
// appels dans l'ordre, et modifierParticipant arrive en lecture.
const retour = montage . seance . passerEnLecture ( ) ;
entree . blur ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
await retour ;
const ecritures = montage . fs . compteurs . ecritures ;
await expect ( attentes [ 0 ] ) . rejects . toMatchObject ( { code : 'MODE_LECTURE' } ) ;
await tick ( ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ) . not . toBeNull ( ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( 'MODE_LECTURE' , { remede : { geste : 'modifier' } } ) ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
expect ( ecart ( message , entree ) ) . toBeLessThanOrEqual ( 8 ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( message . id ) ;
expect ( message . id ) . not . toBe ( '' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } Lacasse ` ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes ) ;
expect ( montage . seance . etat ( ) . charge . participants [ 0 ] . nom ) . toBe ( NOM ) ;
aucuneFenetre ( ) ;
await cliquer ( message . querySelector ( 'button[data-commande="modifier"]' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ) ;
await vi . waitFor ( ( ) => expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ) ;
// Le refus levé, la saisie refusée ne reste pas affichée comme si elle
// avait été appliquée : le champ rend la valeur enregistrée.
expect ( entree . value ) . toBe ( NOM ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
await cliquer ( entree ) ;
await touche ( 'Enter' ) ;
await tick ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
await taper ( ' Lacasse' ) ;
await touche ( 'Enter' ) ;
await tick ( ) ;
expect ( attentes ) . toHaveLength ( 2 ) ;
await attentes [ 1 ] ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes + 1 ) ;
expect ( montage . seance . etat ( ) . charge . participants [ 0 ] . nom ) . toBe ( ` ${ NOM } Lacasse ` ) ;
} ) ;
test ( 'executer refusé par un code de la séance : la séance passée en écriture par un autre chemin que le remède, le refus quitte le champ, qui rend la valeur enregistrée et le compte d’ inactivité' , async ( ) => {
const montage = await preparer ( ) ;
const { cible , entree , attentes } = monterChamp ( montage ) ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
const retour = montage . seance . passerEnLecture ( ) ;
entree . blur ( ) ;
await retour ;
await expect ( attentes [ 0 ] ) . rejects . toMatchObject ( { code : 'MODE_LECTURE' } ) ;
await tick ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ? . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
await montage . seance . passerEnEcriture ( ) ;
flushSync ( ) ;
expect ( montage . seance . etat ( ) . refus ) . toBeNull ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBeNull ( ) ;
expect ( entree . value ) . toBe ( NOM ) ;
// La saisie rendue ne tient plus de geste : l'inactivité ramène la
// séance en lecture.
const lignes = lignesJournal ( montage ) . length ;
montage . horloge . avancer ( INACTIVITE _MS + 1 ) ;
await viderFile ( montage ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ;
// Entrée ne relance rien : aucune saisie n'est plus en cours.
await cliquer ( entree ) ;
await touche ( 'Enter' ) ;
await tick ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes ) ;
expect ( montage . seance . etat ( ) . charge . participants [ 0 ] . nom ) . toBe ( NOM ) ;
} ) ;
test ( 'executer refusé par un code de la séance que l’ état ne tient déjà plus : le refus ne se pose pas, et le champ rend la valeur enregistrée' , async ( ) => {
const montage = await preparer ( ) ;
const appels = [ ] ;
// Un refus MODE_LECTURE qui arrive la séance en écriture : il a été levé
// avant d'arriver.
const cible = monter (
Champ ,
{
cle : 'nom' ,
etiquette : 'communs.commande.modifier' ,
valeur : NOM ,
lire : ( texte ) => ( { valeur : texte . trim ( ) } ) ,
executer : ( valeur ) => {
appels . push ( valeur ) ;
return Promise . reject ( Object . assign ( new Error ( 'refus d’ épreuve' ) , { code : 'MODE_LECTURE' , details : { remede : { geste : 'modifier' } } } ) ) ;
} ,
} ,
montage . contexte ,
) ;
const entree = cible . querySelector ( 'input[data-champ="nom"]' ) ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
await cliquerAilleurs ( ) ;
expect ( appels ) . toEqual ( [ ` ${ NOM } Lacasse ` ] ) ;
await vi . waitFor ( ( ) => expect ( entree . value ) . toBe ( NOM ) ) ;
flushSync ( ) ;
expect ( montage . seance . etat ( ) . refus ) . toBeNull ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
montage . horloge . avancer ( INACTIVITE _MS + 1 ) ;
await viderFile ( montage ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ;
} ) ;
test ( 'une autre cause remplace le refus de l’ exécution : la saisie refusée reste affichée, marquée, tant qu’ un refus de la séance la couvre' , async ( ) => {
const montage = await preparer ( ) ;
const { cible , entree , attentes } = monterChamp ( montage ) ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
// Le support rompt pendant la saisie : sa validation est refusée
// SUPPORT_ROMPU, puis le retour en lecture remplace cette cause.
montage . fs . pannes . ecriture ( 'documents' ) ;
await expect ( montage . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Pervenche' } } ) ) . rejects . toMatchObject ( { code : 'ECRITURE' } ) ;
await cliquerAilleurs ( ) ;
await expect ( attentes [ 0 ] ) . rejects . toMatchObject ( { code : 'SUPPORT_ROMPU' } ) ;
await tick ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ? . getAttribute ( 'data-refus' ) ) . toBe ( 'SUPPORT_ROMPU' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } Lacasse ` ) ;
await montage . seance . passerEnLecture ( ) ;
flushSync ( ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ? . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } Lacasse ` ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( message . id ) ;
} ) ;
test ( 'le remède d’ un refus de la séance ne retire que lui : le refus de lire qu’ il couvrait reparaît, la saisie fautive toujours là' , async ( ) => {
const montage = await preparer ( ) ;
const { cible , entree } = monterChamp ( montage ) ;
await cliquer ( entree ) ;
for ( let i = 0 ; i < NOM . length ; i += 1 ) await touche ( 'Backspace' ) ;
await cliquerAilleurs ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ? . getAttribute ( 'data-refus' ) ) . toBe ( 'NOM_REQUIS' ) ;
await montage . seance . passerEnLecture ( ) ;
await cliquer ( entree ) ;
await taper ( 'z' ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
await cliquer ( message . querySelector ( 'button[data-commande="modifier"]' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ) ;
await tick ( ) ;
flushSync ( ) ;
expect ( entree . value ) . toBe ( '' ) ;
const reste = cible . querySelector ( '[data-refus]' ) ;
expect ( reste ? . getAttribute ( 'data-refus' ) ) . toBe ( 'NOM_REQUIS' ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( reste . id ) ;
} ) ;
} ) ;
describe ( 'champ : le refus là où il a lieu (§ 2.1, § 5.9)' , ( ) => {
// Tape un caractère dans le champ et rend le message posé contre lui ;
// vérifie le texte revenu, le message visible et touchant le champ, son
// texte et son remède, l'absence de toute fenêtre, et les attributs du
// champ.
async function refusAuPremierCaractere ( montage , remede ) {
const alerte = vi . spyOn ( window , 'alert' ) ;
const confirmation = vi . spyOn ( window , 'confirm' ) ;
const { cible , entree , attentes } = monterChamp ( montage ) ;
const ecritures = montage . fs . compteurs . ecritures ;
await cliquer ( entree ) ;
await taper ( 'z' ) ;
expect ( entree . value ) . toBe ( NOM ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ) . not . toBeNull ( ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
expect ( ecart ( message , entree ) ) . toBeLessThanOrEqual ( 8 ) ;
const { refus } = montage . seance . etat ( ) ;
expect ( refus ) . not . toBeNull ( ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( refus . code ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( refus . code , refus . details ) ) ;
expect ( message . textContent ) . toContain ( remede ) ;
expect ( message . querySelector ( 'button' ) . textContent . trim ( ) ) . toBe ( remede ) ;
aucuneFenetre ( ) ;
expect ( alerte ) . not . toHaveBeenCalled ( ) ;
expect ( confirmation ) . not . toHaveBeenCalled ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( message . id ) ;
expect ( message . id ) . not . toBe ( '' ) ;
expect ( attentes ) . toHaveLength ( 0 ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
return { cible , entree , message } ;
}
test ( 'en lecture, la première frappe est refusée aussitôt : MODE_LECTURE, et « Modifier » contre le champ' , async ( ) => {
const montage = await preparer ( { mode : 'lecture' } ) ;
const { refus } = montage . seance . etat ( ) ;
expect ( refus . code ) . toBe ( 'MODE_LECTURE' ) ;
await refusAuPremierCaractere ( montage , 'Modifier' ) ;
} ) ;
test ( 'plan bloqué : LECTURE_SEULE BLOQUE, et « Débloquer »' , async ( ) => {
const montage = await preparer ( { bloque : true } ) ;
expect ( montage . seance . etat ( ) . refus ) . toMatchObject ( { code : 'LECTURE_SEULE' , details : { raison : 'BLOQUE' } } ) ;
await refusAuPremierCaractere ( montage , 'Débloquer' ) ;
} ) ;
test ( 'support rompu : SUPPORT_ROMPU, et « Choisir un dossier… » quand le support rompu est celui des Documents' , async ( ) => {
const montage = await preparer ( ) ;
montage . fs . pannes . ecriture ( 'documents' ) ;
await expect ( montage . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Pervenche' } } ) ) . rejects . toMatchObject ( { code : 'ECRITURE' } ) ;
expect ( montage . seance . etat ( ) . refus . code ) . toBe ( 'SUPPORT_ROMPU' ) ;
await refusAuPremierCaractere ( montage , 'Choisir un dossier\u{2026}' ) ;
} ) ;
test ( 'le remède « Modifier » passe en écriture, retire le refus, et la frappe suivante se saisit' , async ( ) => {
const montage = await preparer ( { mode : 'lecture' } ) ;
const { cible , entree , message } = await refusAuPremierCaractere ( montage , 'Modifier' ) ;
await cliquer ( message . querySelector ( 'button' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ) ;
await tick ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
await cliquer ( entree ) ;
await taper ( 'x' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } x ` ) ;
} ) ;
test ( 'le refus de la séance suit la séance : passée en écriture par un autre chemin que son remède, le champ ne garde ni le refus ni aria-invalid' , async ( ) => {
const montage = await preparer ( { mode : 'lecture' } ) ;
const { cible , entree } = await refusAuPremierCaractere ( montage , 'Modifier' ) ;
await montage . seance . passerEnEcriture ( ) ;
flushSync ( ) ;
expect ( montage . seance . etat ( ) . refus ) . toBeNull ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBeNull ( ) ;
// De nouveau en lecture : rien ne se pose avant une frappe.
await montage . seance . passerEnLecture ( ) ;
flushSync ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
await montage . seance . passerEnEcriture ( ) ;
flushSync ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
await cliquer ( entree ) ;
await taper ( 'x' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } x ` ) ;
} ) ;
test ( 'une autre cause remplace le refus posé : support rompu, puis retour en lecture → MODE_LECTURE et « Modifier » contre le champ' , async ( ) => {
const montage = await preparer ( ) ;
montage . fs . pannes . ecriture ( 'documents' ) ;
await expect ( montage . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Pervenche' } } ) ) . rejects . toMatchObject ( { code : 'ECRITURE' } ) ;
const { cible , entree } = await refusAuPremierCaractere ( montage , 'Choisir un dossier\u{2026}' ) ;
await montage . seance . passerEnLecture ( ) ;
flushSync ( ) ;
expect ( montage . seance . etat ( ) . refus . code ) . toBe ( 'MODE_LECTURE' ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ) . not . toBeNull ( ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( 'MODE_LECTURE' , { remede : { geste : 'modifier' } } ) ) ;
expect ( message . querySelector ( 'button' ) . textContent . trim ( ) ) . toBe ( 'Modifier' ) ;
expect ( entree . getAttribute ( 'aria-describedby' ) ) . toBe ( message . id ) ;
} ) ;
test ( 'plan bloqué, débloqué hors du remède : le refus LECTURE_SEULE quitte le champ' , async ( ) => {
const montage = await preparer ( { bloque : true } ) ;
const { cible , entree } = await refusAuPremierCaractere ( montage , 'Débloquer' ) ;
await montage . seance . debloquer ( ) ;
flushSync ( ) ;
expect ( montage . seance . etat ( ) . refus ) . toBeNull ( ) ;
expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBeNull ( ) ;
} ) ;
} ) ;
describe ( 'le remède « Débloquer » (§ 8.5)' , ( ) => {
test ( 'un clic arme la commande, sans écrire ; un second débloque, une entrée' , async ( ) => {
const montage = await preparer ( { bloque : true } ) ;
const { message } = await refusAuPremierCaractere2 ( montage ) ;
const bouton = message . querySelector ( 'button' ) ;
expect ( bouton . getAttribute ( 'data-commande' ) ) . toBe ( 'debloquer' ) ;
const ecritures = montage . fs . compteurs . ecritures ;
const entrees = lireJournal ( montage . fs . contenu ( 'documents' , ` ${ montage . base } ${ SUFFIXES . journal } ` ) ) . entrees . length ;
await cliquer ( bouton ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Confirmer le déblocage' ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
aucuneFenetre ( ) ;
await cliquer ( bouton ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . evenement . etat ) . not . toBe ( 'bloque' ) ) ;
const journal = lireJournal ( montage . fs . contenu ( 'documents' , ` ${ montage . base } ${ SUFFIXES . journal } ` ) ) ;
expect ( journal . entrees . length ) . toBe ( entrees + 1 ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ;
} ) ;
test ( 'armée, le délai de l’ horloge la désarme : elle redit « Débloquer », et le clic suivant n’ écrit rien' , async ( ) => {
const montage = await preparer ( { bloque : true } ) ;
const { message } = await refusAuPremierCaractere2 ( montage ) ;
const bouton = message . querySelector ( 'button' ) ;
const ecritures = montage . fs . compteurs . ecritures ;
await cliquer ( bouton ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Confirmer le déblocage' ) ;
montage . horloge . avancer ( DELAI _CONFIRMATION _MS - 1 ) ;
flushSync ( ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Confirmer le déblocage' ) ;
montage . horloge . avancer ( 1 ) ;
flushSync ( ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Débloquer' ) ;
await cliquer ( bouton ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Confirmer le déblocage' ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
expect ( montage . seance . etat ( ) . evenement . etat ) . toBe ( 'bloque' ) ;
} ) ;
[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
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' ) ) ;
} ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
// Le refus d'un plan bloqué posé contre le champ par une frappe.
async function refusAuPremierCaractere2 ( montage ) {
const { cible , entree } = monterChamp ( montage ) ;
await cliquer ( entree ) ;
await taper ( 'z' ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( message ) . not . toBeNull ( ) ;
return { cible , entree , message } ;
}
} ) ;
describe ( 'la saisie suspend l’ inactivité (§ 8.4)' , ( ) => {
test ( 'une saisie non validée tient la séance en écriture au-delà de l’ inactivité ; validée, une entrée, puis le retour en lecture rend le verrou' , async ( ) => {
const montage = await preparer ( ) ;
const { entree , attentes } = monterChamp ( montage ) ;
const lignes = lignesJournal ( montage ) . length ;
await cliquer ( entree ) ;
await taper ( 'ine' ) ;
montage . horloge . avancer ( INACTIVITE _MS + 1000 ) ;
await tick ( ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ;
expect ( entree . value ) . toBe ( ` ${ NOM } ine ` ) ;
expect ( verrouPresent ( montage ) ) . toBe ( true ) ;
await touche ( 'Enter' ) ;
await tick ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
await Promise . all ( attentes ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes + 1 ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
await vi . waitFor ( ( ) => expect ( verrouPresent ( montage ) ) . toBe ( false ) ) ;
} ) ;
test ( 'une saisie en cours, la séance renvoyée en lecture puis repassée en écriture sans quitter le champ : la saisie suspend de nouveau l’ inactivité' , async ( ) => {
const montage = await preparer ( ) ;
const { entree , attentes } = monterChamp ( montage ) ;
await cliquer ( entree ) ;
await taper ( 'ine' ) ;
await montage . seance . passerEnLecture ( ) ;
await montage . seance . passerEnEcriture ( ) ;
expect ( document . activeElement ) . toBe ( entree ) ;
montage . horloge . avancer ( INACTIVITE _MS + 1 ) ;
await viderFile ( montage ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ;
await touche ( 'Enter' ) ;
await tick ( ) ;
await Promise . all ( attentes ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
} ) ;
describe ( 'saisie, puis « Revenir en lecture » (§ 8.2, § 8.4)' , ( ) => {
test ( 'par des entrées réelles : la saisie s’ écrit, puis la séance revient en lecture et rend le verrou' , async ( ) => {
const montage = await preparer ( ) ;
const commande = monter (
Commande ,
{ cle : 'communs.commande.lecture' , executer : ( ) => montage . seance . passerEnLecture ( ) } ,
montage . contexte ,
) ;
const { entree , attentes } = monterChamp ( montage ) ;
const lignes = lignesJournal ( montage ) . length ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
const bouton = commande . querySelector ( 'button[data-commande="lecture"]' ) ;
expect ( bouton ) . not . toBeNull ( ) ;
await cliquer ( bouton ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
await Promise . all ( attentes ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes + 1 ) ;
expect ( montage . seance . etat ( ) . charge . participants [ 0 ] . nom ) . toBe ( ` ${ NOM } Lacasse ` ) ;
expect ( verrouPresent ( montage ) ) . toBe ( false ) ;
} ) ;
test ( 'la sortie du champ lance l’ exécution dans le même tour : un retour en lecture appelé aussitôt après attend son écriture' , async ( ) => {
const montage = await preparer ( ) ;
const { entree , attentes } = monterChamp ( montage ) ;
const lignes = lignesJournal ( montage ) . length ;
await cliquer ( entree ) ;
await taper ( ' Lacasse' ) ;
entree . blur ( ) ;
const retour = montage . seance . passerEnLecture ( ) ;
expect ( attentes ) . toHaveLength ( 1 ) ;
await Promise . all ( [ ... attentes , retour ] ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes + 1 ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ;
expect ( montage . seance . etat ( ) . charge . participants [ 0 ] . nom ) . toBe ( ` ${ NOM } Lacasse ` ) ;
} ) ;
} ) ;
describe ( 'fenêtre à confirmer (§ 2.1)' , ( ) => {
const ELEMENTS = [ 'Iris Ombrelle' , 'Théo Pervenche' , 'Ondine Lacasse' ] ;
function monterConfirmation ( montage ) {
const appels = [ ] ;
const cible = monter (
Confirmation ,
{
titre : 'communs.remede.supprimerParticipant' ,
elements : ELEMENTS ,
surConfirmer : ( ) => {
appels . push ( 'confirmer' ) ;
return montage . seance . executer ( 'supprimerParticipant' , { id : 1 } ) ;
} ,
surAnnuler : ( ) => appels . push ( 'annuler' ) ,
} ,
montage . contexte ,
) ;
return { cible , appels , fenetre : cible . querySelector ( 'dialog' ) } ;
}
test ( 'une fenêtre modale, ouverte, qui nomme chaque élément détruit, chacun dans son nœud, Annuler par défaut' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre } = monterConfirmation ( montage ) ;
expect ( fenetre ) . not . toBeNull ( ) ;
expect ( fenetre . open ) . toBe ( true ) ;
expect ( fenetre . matches ( ':modal' ) ) . toBe ( true ) ;
const items = [ ... fenetre . querySelectorAll ( 'li' ) ] ;
expect ( items ) . toHaveLength ( ELEMENTS . length ) ;
expect ( items . map ( ( li ) => li . textContent ) ) . toEqual ( ELEMENTS ) ;
for ( const item of items ) expect ( visible ( item ) ) . toBe ( true ) ;
expect ( document . activeElement . getAttribute ( 'data-commande' ) ) . toBe ( 'renoncer' ) ;
expect ( document . activeElement . textContent . trim ( ) ) . toBe ( 'Annuler' ) ;
} ) ;
test ( 'Annuler → surAnnuler, zéro écriture ; Échap de même' , async ( ) => {
const montage = await preparer ( ) ;
const ecritures = montage . fs . compteurs . ecritures ;
const premiere = monterConfirmation ( montage ) ;
await cliquer ( premiere . fenetre . querySelector ( '[data-commande="renoncer"]' ) ) ;
expect ( premiere . appels ) . toEqual ( [ 'annuler' ] ) ;
expect ( premiere . fenetre . open ) . toBe ( false ) ;
const seconde = monterConfirmation ( montage ) ;
await touche ( 'Escape' ) ;
await tick ( ) ;
expect ( seconde . appels ) . toEqual ( [ 'annuler' ] ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
} ) ;
test ( 'Confirmer → surConfirmer ; abouti, la fenêtre se ferme' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels } = monterConfirmation ( montage ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="confirmer"]' ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 0 ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
} ) ;
test ( 'ouverte, elle suspend l’ inactivité (§ 8.4) : au-delà d’ INACTIVITE_MS, toujours en écriture, et Confirmer aboutit ; fermée, le compte reprend' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre } = monterConfirmation ( montage ) ;
montage . horloge . avancer ( INACTIVITE _MS + 1 ) ;
await tick ( ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ;
expect ( verrouPresent ( montage ) ) . toBe ( true ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="confirmer"]' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 0 ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
test ( 'Annuler rend le compte d’ inactivité' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre } = monterConfirmation ( montage ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="renoncer"]' ) ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
test ( 'Confirmer refusé : le refus se pose dans la fenêtre, qui reste ouverte, rien d’ écrit ; son remède levé, Confirmer aboutit et la ferme' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels } = monterConfirmation ( montage ) ;
await montage . seance . passerEnLecture ( ) ;
const lignes = lignesJournal ( montage ) . length ;
const ecritures = montage . fs . compteurs . ecritures ;
const confirmer = fenetre . querySelector ( '[data-commande="confirmer"]' ) ;
await cliquer ( confirmer ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
await vi . waitFor ( ( ) => expect ( fenetre . querySelector ( '[data-refus]' ) ) . not . toBeNull ( ) ) ;
const message = fenetre . querySelector ( '[data-refus]' ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( 'MODE_LECTURE' , { remede : { geste : 'modifier' } } ) ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
expect ( fenetre . open ) . toBe ( true ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
expect ( lignesJournal ( montage ) ) . toHaveLength ( lignes ) ;
expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 1 ) ;
await cliquer ( message . querySelector ( 'button[data-commande="modifier"]' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ) ;
// Le retour explicite en lecture a remis à zéro le compte des gestes de
// la séance : la fenêtre, toujours ouverte, le rouvre au passage en
// écriture.
montage . horloge . avancer ( INACTIVITE _MS + 1 ) ;
await viderFile ( montage ) ;
expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ;
expect ( fenetre . open ) . toBe ( true ) ;
await cliquer ( confirmer ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 0 ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
} ) ;
// Une promesse que l'épreuve résout ou rejette à la main : l'action
// confirmée qui dure, celle d'un disque lent.
function differe ( ) {
let resoudre ;
let rejeter ;
const promesse = new Promise ( ( oui , non ) => {
resoudre = oui ;
rejeter = non ;
} ) ;
return { promesse , resoudre , rejeter } ;
}
// La fenêtre dont surConfirmer attend attente avant d'exécuter la
// suppression d'Iris par la séance.
function monterConfirmationDifferee ( montage ) {
const appels = [ ] ;
const attente = differe ( ) ;
const cible = monter (
Confirmation ,
{
titre : 'communs.remede.supprimerParticipant' ,
elements : ELEMENTS ,
surConfirmer : ( ) => {
appels . push ( 'confirmer' ) ;
return attente . promesse . then ( ( ) => montage . seance . executer ( 'supprimerParticipant' , { id : 1 } ) ) ;
} ,
surAnnuler : ( ) => appels . push ( 'annuler' ) ,
} ,
montage . contexte ,
) ;
return { cible , appels , attente , fenetre : cible . querySelector ( 'dialog' ) } ;
}
test ( 'occupée pendant l’ action confirmée : Annuler, Échap et un second Confirmer ne font rien, les boutons se montrent désactivés et l’ attente se dit ; seule la fin de l’ action ferme la fenêtre' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels , attente } = monterConfirmationDifferee ( montage ) ;
const renoncer = fenetre . querySelector ( '[data-commande="renoncer"]' ) ;
const confirmer = fenetre . querySelector ( '[data-commande="confirmer"]' ) ;
const styleRepos = getComputedStyle ( confirmer ) . borderTopStyle ;
await cliquer ( confirmer ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
for ( const bouton of [ renoncer , confirmer ] ) {
expect ( bouton . getAttribute ( 'aria-disabled' ) ) . toBe ( 'true' ) ;
expect ( getComputedStyle ( bouton ) . borderTopStyle ) . toBe ( 'dashed' ) ;
expect ( getComputedStyle ( bouton ) . borderTopStyle ) . not . toBe ( styleRepos ) ;
}
const annonce = [ ... fenetre . querySelectorAll ( '[role=status]' ) ] . find (
( element ) => element . textContent . trim ( ) === t ( 'communs.confirmation.enCours' ) ,
) ;
expect ( annonce ) . toBeDefined ( ) ;
expect ( visible ( annonce ) ) . toBe ( true ) ;
await touche ( 'Escape' ) ;
await tick ( ) ;
await cliquer ( renoncer ) ;
await cliquer ( confirmer ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
expect ( fenetre . open ) . toBe ( true ) ;
expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 1 ) ;
attente . resoudre ( ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 0 ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
test ( 'occupée, Échap ne ferme rien : l’ action refusée avec un code, son refus se pose dans la fenêtre restée ouverte, qui redevient active' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels , attente } = monterConfirmationDifferee ( montage ) ;
const confirmer = fenetre . querySelector ( '[data-commande="confirmer"]' ) ;
await cliquer ( confirmer ) ;
await touche ( 'Escape' ) ;
await tick ( ) ;
await montage . seance . passerEnLecture ( ) ;
attente . resoudre ( ) ;
await vi . waitFor ( ( ) => expect ( fenetre . querySelector ( '[data-refus]' ) ) . not . toBeNull ( ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
expect ( fenetre . open ) . toBe ( true ) ;
const message = fenetre . querySelector ( '[data-refus]' ) ;
expect ( message . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
[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
expect ( fenetre . querySelectorAll ( '.issues button' ) ) . toHaveLength ( 2 ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
for ( const bouton of fenetre . querySelectorAll ( '.issues button' ) ) expect ( bouton . getAttribute ( 'aria-disabled' ) ) . toBeNull ( ) ;
expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 1 ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="renoncer"]' ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' , 'annuler' ] ) ;
expect ( fenetre . open ) . toBe ( false ) ;
} ) ;
// La fenêtre occupée tient à toute touche. Chromium n'annule pas deux
// fois de suite une demande de fermeture sans activation de l'utilisateur
// 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,
// modale et toujours occupée ; la fin de l'action seule décide.
[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
//
// 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' ) ;
await vi . waitFor ( ( ) => {
expect ( fermetures ) . toBeGreaterThan ( 0 ) ;
expect ( evenements ) . toBe ( fermetures ) ;
} ) ;
} finally {
observateur . disconnect ( ) ;
fenetre . removeEventListener ( 'close' , surClose ) ;
}
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
await tick ( ) ;
}
function attendreOccupee ( montage , fenetre , appels ) {
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
expect ( fenetre . open ) . toBe ( true ) ;
expect ( fenetre . matches ( ':modal' ) ) . toBe ( true ) ;
expect ( fenetre . getAttribute ( 'aria-busy' ) ) . toBe ( 'true' ) ;
[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
expect ( fenetre . querySelectorAll ( '.issues button' ) ) . toHaveLength ( 2 ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
for ( const bouton of fenetre . querySelectorAll ( '.issues button' ) ) expect ( bouton . getAttribute ( 'aria-disabled' ) ) . toBe ( 'true' ) ;
expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 1 ) ;
}
for ( const fois of [ 2 , 3 ] ) {
test ( ` occupée, ${ fois } Échap de suite ne la ferment pas : rouverte modale et occupée, sans surAnnuler ; la fin de l’ action la ferme ` , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels , attente } = monterConfirmationDifferee ( montage ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="confirmer"]' ) ) ;
[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
await echapEnRafale ( fenetre , fois ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
attendreOccupee ( montage , fenetre , appels ) ;
// Une seconde rafale, la fenêtre déjà rouverte une fois.
[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
await echapEnRafale ( fenetre , fois ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
attendreOccupee ( montage , fenetre , appels ) ;
attente . resoudre ( ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 0 ) ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
test ( ` occupée, ${ fois } Échap de suite, puis l’ action refusée avec un code : le refus se pose dans la fenêtre rouverte, qui redevient active ` , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels , attente } = monterConfirmationDifferee ( montage ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="confirmer"]' ) ) ;
[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
await echapEnRafale ( fenetre , fois ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
attendreOccupee ( montage , fenetre , appels ) ;
await montage . seance . passerEnLecture ( ) ;
attente . resoudre ( ) ;
await vi . waitFor ( ( ) => expect ( fenetre . querySelector ( '[data-refus]' ) ) . not . toBeNull ( ) ) ;
expect ( fenetre . open ) . toBe ( true ) ;
expect ( fenetre . matches ( ':modal' ) ) . toBe ( true ) ;
expect ( fenetre . querySelector ( '[data-refus]' ) . getAttribute ( 'data-refus' ) ) . toBe ( 'MODE_LECTURE' ) ;
expect ( fenetre . getAttribute ( 'aria-busy' ) ) . toBeNull ( ) ;
[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
expect ( fenetre . querySelectorAll ( '.issues button' ) ) . toHaveLength ( 2 ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
for ( const bouton of fenetre . querySelectorAll ( '.issues button' ) ) expect ( bouton . getAttribute ( 'aria-disabled' ) ) . toBeNull ( ) ;
expect ( appels ) . toEqual ( [ 'confirmer' ] ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="renoncer"]' ) ) ;
expect ( appels ) . toEqual ( [ 'confirmer' , 'annuler' ] ) ;
expect ( fenetre . open ) . toBe ( false ) ;
} ) ;
}
test ( 'fermée par le navigateur hors de l’ attente, sans passer par Annuler : c’ est une annulation, qui rend le compte d’ inactivité' , async ( ) => {
const montage = await preparer ( ) ;
const { fenetre , appels } = monterConfirmation ( montage ) ;
fenetre . close ( ) ;
await vi . waitFor ( ( ) => expect ( appels ) . toEqual ( [ 'annuler' ] ) ) ;
expect ( fenetre . open ) . toBe ( false ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} ) ;
test ( 'une erreur sans code ferme la fenêtre, rend le compte d’ inactivité, et remonte' , async ( ) => {
const montage = await preparer ( ) ;
const remontees = [ ] ;
const recueillir = ( evenement ) => {
evenement . preventDefault ( ) ;
remontees . push ( evenement . reason ) ;
} ;
window . addEventListener ( 'unhandledrejection' , recueillir ) ;
try {
const { fenetre , attente } = monterConfirmationDifferee ( montage ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="confirmer"]' ) ) ;
const panne = new Error ( 'panne d’ épreuve' ) ;
attente . rejeter ( panne ) ;
await vi . waitFor ( ( ) => expect ( fenetre . open ) . toBe ( false ) ) ;
await vi . waitFor ( ( ) => expect ( remontees ) . toContain ( panne ) ) ;
expect ( fenetre . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
montage . horloge . avancer ( INACTIVITE _MS ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'lecture' ) ) ;
} finally {
window . removeEventListener ( 'unhandledrejection' , recueillir ) ;
}
} ) ;
} ) ;
describe ( 'commande (§ 2.1)' , ( ) => {
test ( 'refusée, elle affiche son refus à côté d’ elle, avec son remède, et reste active : jamais un bouton grisé muet' , async ( ) => {
const montage = await preparer ( { mode : 'lecture' } ) ;
const ecritures = montage . fs . compteurs . ecritures ;
const cible = monter (
Commande ,
{
cle : 'communs.remede.ajouterParticipant' ,
executer : ( ) => montage . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Pervenche' } } ) ,
} ,
montage . contexte ,
) ;
const bouton = cible . querySelector ( 'button[data-commande="ajouterParticipant"]' ) ;
expect ( bouton ) . not . toBeNull ( ) ;
expect ( bouton . disabled ) . toBe ( false ) ;
await cliquer ( bouton ) ;
await vi . waitFor ( ( ) => expect ( cible . querySelector ( '[data-refus]' ) ) . not . toBeNull ( ) ) ;
const message = cible . querySelector ( '[data-refus]' ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
expect ( ecart ( message , bouton ) ) . toBeLessThanOrEqual ( 8 ) ;
expect ( message . querySelector ( 'p' ) . textContent ) . toBe ( t ( 'MODE_LECTURE' , { remede : { geste : 'modifier' } } ) ) ;
expect ( bouton . disabled ) . toBe ( false ) ;
expect ( bouton . getAttribute ( 'aria-disabled' ) ) . toBeNull ( ) ;
expect ( montage . fs . compteurs . ecritures ) . toBe ( ecritures ) ;
aucuneFenetre ( ) ;
await cliquer ( message . querySelector ( 'button[data-commande="modifier"]' ) ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . mode ) . toBe ( 'ecriture' ) ) ;
await vi . waitFor ( ( ) => expect ( cible . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ) ;
await cliquer ( bouton ) ;
await vi . waitFor ( ( ) => expect ( montage . seance . etat ( ) . charge . participants ) . toHaveLength ( 2 ) ) ;
} ) ;
[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
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 ( ) ;
} ) ;
[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.
--- FR ---
[ADD] interface : primitives — champ, refus, confirmation, commande
Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00
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 cible = monter ( Commande , { cle : 'communs.commande.lecture' , executer : ( ) => undefined } , montage . contexte ) ;
const bouton = cible . querySelector ( 'button' ) ;
expect ( bouton . textContent . trim ( ) ) . toBe ( 'Revenir en lecture' ) ;
expect ( bouton . getAttribute ( 'data-commande' ) ) . toBe ( 'lecture' ) ;
expect ( bouton . getAttribute ( 'type' ) ) . toBe ( 'button' ) ;
} ) ;
} ) ;