[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La liste des participants dans un vrai moteur de rendu (§ 4, § 4.2, § 4.4,
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
// collante, la grille au clavier, le tri, le filtre et le compte,
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
[ADD] participants: group the list by columns, with counts and collapse
The participants list can now be grouped by one or more columns, nested
level by level: appartenance (by its normalised key, labelled with its
first spelling), status, exclusion, or any text column. Each group header
shows its value, its count and a keyboard-operable collapse button, with
Collapse all and Expand all. The filter applies first, groups follow the
current sort, arrow keys skip headers and collapsed rows, and an edit
moves its row to its new group. Grouping is session state only and never
writes the event. Checked: 2811 node, 565 browser tests, index alone.
--- FR ---
[ADD] participants : grouper la liste par colonnes, compte et repli
La liste des participants se groupe par une ou plusieurs colonnes,
imbriquées niveau par niveau : appartenance (par sa clé normalisée, sous sa
première orthographe), statut, exclusion, ou toute colonne de texte.
Chaque en-tête de groupe montre sa valeur, son compte et un bouton de
repli au clavier, avec Tout replier et Tout déplier. Le filtre s'applique
d'abord, les groupes suivent le tri, les flèches sautent les en-têtes et
les lignes repliées, et une saisie déplace sa ligne dans son nouveau
groupe. État de séance seul, aucune écriture. Vérifié : 2811 node, 565.
Assisted-by: Claude Opus 5.5
2026-10-08 03:49:36 -04:00
// et l'itinéraire, l'export, le collage, et la liste groupée par une
// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés. Chaque épreuve monte App par
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
// gestes passent par des entrées que Chromium traite comme réelles
// (test/navigateur/entrees.js). Les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach , beforeEach , describe , expect , test , vi } from 'vitest' ;
import { cdp } from 'vitest/browser' ;
import { flushSync , tick } from 'svelte' ;
import { bilanChangementDefaut } from '../../application/bilans.js' ;
import { TABLES _APPLICATION } from '../../application/libelles.js' ;
import { creerTraduction } from '../../application/traduction.js' ;
[ADD] participants: group the list by columns, with counts and collapse
The participants list can now be grouped by one or more columns, nested
level by level: appartenance (by its normalised key, labelled with its
first spelling), status, exclusion, or any text column. Each group header
shows its value, its count and a keyboard-operable collapse button, with
Collapse all and Expand all. The filter applies first, groups follow the
current sort, arrow keys skip headers and collapsed rows, and an edit
moves its row to its new group. Grouping is session state only and never
writes the event. Checked: 2811 node, 565 browser tests, index alone.
--- FR ---
[ADD] participants : grouper la liste par colonnes, compte et repli
La liste des participants se groupe par une ou plusieurs colonnes,
imbriquées niveau par niveau : appartenance (par sa clé normalisée, sous sa
première orthographe), statut, exclusion, ou toute colonne de texte.
Chaque en-tête de groupe montre sa valeur, son compte et un bouton de
repli au clavier, avec Tout replier et Tout déplier. Le filtre s'applique
d'abord, les groupes suivent le tri, les flèches sautent les en-têtes et
les lignes repliées, et une saisie déplace sa ligne dans son nouveau
groupe. État de séance seul, aucune écriture. Vérifié : 2811 node, 565.
Assisted-by: Claude Opus 5.5
2026-10-08 03:49:36 -04:00
import { apercevoir , cleNormalisee , decoder , exporterParticipants } from '../../application/csv.js' ;
import { statuts } from '../../application/places.js' ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
import { STATUT , nombrePlacesManquantes , normaliser } from '../../moteur/configuration.js' ;
import { configurationDepuisCharge } from '../../stockage/document.js' ;
import { lireJournal } from '../../stockage/journal.js' ;
import { SUFFIXES } from '../../stockage/noms.js' ;
import { formeNommee } from '../../stockage/placements.js' ;
import { monterApp } from '../../../test/navigateur/montage.js' ;
import { pointer , touche } from '../../../test/navigateur/entrees.js' ;
import { visible } from '../../../test/navigateur/rendu.js' ;
import CAS _LIMITES from '../../../exemples/participants_cas_limites.csv?url' ;
import { TABLES _INTERFACE } from '../libelles.js' ;
[ADD] participants: group the list by columns, with counts and collapse
The participants list can now be grouped by one or more columns, nested
level by level: appartenance (by its normalised key, labelled with its
first spelling), status, exclusion, or any text column. Each group header
shows its value, its count and a keyboard-operable collapse button, with
Collapse all and Expand all. The filter applies first, groups follow the
current sort, arrow keys skip headers and collapsed rows, and an edit
moves its row to its new group. Grouping is session state only and never
writes the event. Checked: 2811 node, 565 browser tests, index alone.
--- FR ---
[ADD] participants : grouper la liste par colonnes, compte et repli
La liste des participants se groupe par une ou plusieurs colonnes,
imbriquées niveau par niveau : appartenance (par sa clé normalisée, sous sa
première orthographe), statut, exclusion, ou toute colonne de texte.
Chaque en-tête de groupe montre sa valeur, son compte et un bouton de
repli au clavier, avec Tout replier et Tout déplier. Le filtre s'applique
d'abord, les groupes suivent le tri, les flèches sautent les en-têtes et
les lignes repliées, et une saisie déplace sa ligne dans son nouveau
groupe. État de séance seul, aucune écriture. Vérifié : 2811 node, 565.
Assisted-by: Claude Opus 5.5
2026-10-08 03:49:36 -04:00
import { COLONNES , GROUPEMENTS , cheminDe , cleDeGroupement , cleDeStatut , groupesGrille , lignesGrille , lignesVisibles } from './grille.js' ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
const { t } = creerTraduction ( [ ... TABLES _APPLICATION , ... TABLES _INTERFACE ] ) ;
const EVENEMENT = 'Soirée des Merles' ;
const CLUB = 'Club des Merles' ;
// Des noms inventés, hors du réservoir du générateur.
const NOMS = [
'Quenouillet' ,
'Brindelle' ,
'Calvaille' ,
'Doucemer' ,
'Esquirol' ,
'Faverolle' ,
'Glanduc' ,
'Houblonnet' ,
'Isardin' ,
'Joncaille' ,
'Kerbouet' ,
'Lisonnet' ,
'Moucheron' ,
'Nivelette' ,
'Orbelin' ,
'Pistachon' ,
'Rouvrelle' ,
] ;
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
// n'a rien reçu pendant l'épreuve.
let montes = [ ] ;
let erreursAvant = 0 ;
beforeEach ( ( ) => {
erreursAvant = globalThis . _ _gttErreurs ? . length ? ? 0 ;
} ) ;
afterEach ( ( ) => {
for ( const m of montes . reverse ( ) ) m . demonter ( ) ;
montes = [ ] ;
vi . restoreAllMocks ( ) ;
const recues = globalThis . _ _gttErreurs ? . slice ( erreursAvant ) ? ? [ ] ;
expect ( recues , 'erreurs reçues par le collecteur' ) . toEqual ( [ ] ) ;
} ) ;
async function rendu ( ) {
flushSync ( ) ;
await tick ( ) ;
}
// Attend que la file de la séance ait passé ce que le geste y a posé, par
// une lecture admise dans tout mode qui n'écrit rien, puis le rendu.
async function attendreSeance ( m ) {
await m . seance . formeNommee ( null ) . catch ( ( ) => undefined ) ;
await rendu ( ) ;
await rendu ( ) ;
}
// Un CSV d'épreuve, séparé par « ; » : nom, prénom, appartenance, exclu,
// titre pressenti, notes.
const csv = ( personnes ) =>
[
'nom;prenom;appartenance;exclu;titre_pressenti;notes' ,
... personnes . map ( ( p ) => [ p . nom , p . prenom ? ? '' , p . appartenance ? ? '' , p . exclu ? 'oui' : '' , p . titre ? ? '' , p . notes ? ? '' ] . join ( ';' ) ) ,
] . join ( '\n' ) ;
// Importe un texte par la commande, comme le ferait l'aperçu (I6).
const importer = ( m , texte ) => m . seance . executer ( 'importerParticipants' , { apercu : apercevoir ( texte ) , mode : 'ajouter' } ) ;
// Les octets d'un CSV livré, lus par son adresse.
async function octetsLivres ( url ) {
const reponse = await fetch ( url ) ;
return new Uint8Array ( await reponse . arrayBuffer ( ) ) ;
}
/ * *
* Un montage , un événement créé — sieges places par défaut , tours tours ,
* tables tables rondes , personnes importées — , en écriture , la section des
* participants ouverte .
* /
async function evenementOuvert ( { sieges = 8 , tours = 2 , tables = 0 , personnes = [ ] } = { } ) {
const m = await monterApp ( ) ;
montes . push ( m ) ;
await m . seance . creer ( { nom : EVENEMENT , date : null , siegesParDefaut : sieges , tours } ) ;
await m . seance . passerEnEcriture ( ) ;
for ( let i = 0 ; i < tables ; i += 1 ) await m . seance . executer ( 'creerTable' , { forme : 'ronde' } ) ;
if ( personnes . length > 0 ) await importer ( m , csv ( personnes ) ) ;
m . ui . allerA ( 'participants' ) ;
await rendu ( ) ;
return m ;
}
const base = ( m ) => m . seance . etat ( ) . evenement . base ;
const texteJournal = ( m ) => m . fs . contenu ( 'documents' , ` ${ base ( m ) } ${ SUFFIXES . journal } ` ) ;
const entrees = ( m ) => lireJournal ( texteJournal ( m ) ) . entrees ;
const texteEtat = ( m ) => m . fs . contenu ( 'documents' , ` ${ base ( m ) } ${ SUFFIXES . etat } ` ) ;
const participants = ( m ) => m . seance . etat ( ) . charge . participants ;
const idDe = ( m , nom ) => participants ( m ) . find ( ( p ) => p . nom === nom ) . id ;
const section = ( m ) => m . racine . querySelector ( '[data-section="participants"]' ) ;
const formulaire = ( m ) => section ( m ) . querySelector ( 'form' ) ;
const champ = ( m , cle ) => formulaire ( m ) . querySelector ( ` [data-champ=" ${ cle } "] ` ) ;
const grille = ( m ) => section ( m ) . querySelector ( '[role="grid"]' ) ;
const lignes = ( m ) => [ ... grille ( m ) . querySelectorAll ( '[data-participant]' ) ] ;
const idsAffiches = ( m ) => lignes ( m ) . map ( ( ligne ) => Number ( ligne . dataset . participant ) ) ;
const ligne = ( m , id ) => grille ( m ) . querySelector ( ` [data-participant=" ${ id } "] ` ) ;
const cellule = ( m , id , colonne ) => ligne ( m , id ) . querySelector ( ` [data-champ=" ${ colonne } "] ` ) ;
const commandeDeLigne = ( m , id , nom ) => ligne ( m , id ) . querySelector ( ` [data-commande=" ${ nom } "] ` ) ;
const fiche = ( m ) => section ( m ) . querySelector ( '[data-fiche]' ) ;
const centre = ( element ) => {
const b = element . getBoundingClientRect ( ) ;
return { x : b . left + b . width / 2 , y : b . top + b . height / 2 } ;
} ;
// Un clic réel au centre de l'élément, amené d'abord dans la fenêtre.
async function cliquer ( element ) {
element . scrollIntoView ( { block : 'center' } ) ;
const { x , y } = centre ( element ) ;
await pointer ( 'presser' , x , y ) ;
await pointer ( 'relacher' , x , y ) ;
await rendu ( ) ;
}
// Remplace le texte d'un champ par une saisie réelle : le champ prend le
// focus, son texte est sélectionné, puis remplacé par l'insertion du texte,
// ou effacé.
async function saisir ( entree , texte ) {
entree . focus ( ) ;
entree . select ( ) ;
if ( texte === '' ) await touche ( 'Backspace' ) ;
else await cdp ( ) . send ( 'Input.insertText' , { text : texte } ) ;
await rendu ( ) ;
}
// Ajoute une personne par le formulaire : chaque champ donné saisi, puis
// Entrée dans le champ du nom.
async function ajouter ( m , champs ) {
for ( const [ cle , valeur ] of Object . entries ( champs ) ) await saisir ( champ ( m , cle ) , valeur ) ;
champ ( m , 'nom' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
}
// Maj+Tab réel : touche() ne porte pas de modificateur ; 8 est le masque de
// Maj dans Input.dispatchKeyEvent.
async function majTab ( ) {
const commun = { key : 'Tab' , code : 'Tab' , windowsVirtualKeyCode : 9 , nativeVirtualKeyCode : 9 , modifiers : 8 } ;
await cdp ( ) . send ( 'Input.dispatchKeyEvent' , { type : 'rawKeyDown' , ... commun } ) ;
await cdp ( ) . send ( 'Input.dispatchKeyEvent' , { type : 'keyUp' , ... commun } ) ;
}
[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
// 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 ( ) ;
}
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
// La valeur de la fiche que nomme l'étiquette de clé donnée.
function valeurDeFiche ( m , cle ) {
const etiquette = [ ... fiche ( m ) . querySelectorAll ( 'dt' ) ] . find ( ( dt ) => dt . textContent === t ( cle ) ) ;
expect ( etiquette , cle ) . toBeDefined ( ) ;
return etiquette . nextElementSibling ;
}
// Le tableau de la fiche que titre la légende de clé donnée.
const tableauDeFiche = ( m , cle ) => [ ... fiche ( m ) . querySelectorAll ( 'table' ) ] . find ( ( tableau ) => tableau . caption ? . textContent === t ( cle ) ) ;
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
function ecart ( a , b ) {
const ba = a . getBoundingClientRect ( ) ;
const bb = b . getBoundingClientRect ( ) ;
const dx = Math . max ( 0 , ba . left - bb . right , bb . left - ba . right ) ;
const dy = Math . max ( 0 , ba . top - bb . bottom , bb . top - ba . bottom ) ;
return Math . max ( dx , dy ) ;
}
[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
// 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 ( ) ;
}
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
const aucuneFenetre = ( ) => expect ( document . querySelectorAll ( 'dialog[open], [role=dialog], [role=alertdialog]' ) . length ) . toBe ( 0 ) ;
describe ( 'la saisie (§ 4, § 10.2)' , ( ) => {
test ( 'seul le nom : « Pervenche » enregistre une personne aux autres champs null, en une entrée ; un nom tapé en NFD est enregistré en NFC' , async ( ) => {
const m = await evenementOuvert ( ) ;
const avant = entrees ( m ) . length ;
await ajouter ( m , { nom : 'Pervenche' } ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
const [ { id , ... champs } ] = participants ( m ) ;
expect ( Number . isInteger ( id ) ) . toBe ( true ) ;
expect ( champs ) . toEqual ( {
nom : 'Pervenche' ,
prenom : null ,
appartenance : null ,
courriel : null ,
titrePressenti : null ,
notes : null ,
exclu : false ,
} ) ;
expect ( champ ( m , 'nom' ) . value ) . toBe ( '' ) ;
const decompose = 'Ombre\u{0301}lle' ;
expect ( decompose . length ) . toBe ( 9 ) ;
await ajouter ( m , { nom : decompose } ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 2 ) ;
expect ( participants ( m ) . at ( - 1 ) . nom ) . toBe ( 'Ombr\u{00E9}lle' ) ;
} ) ;
[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 ( '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' ] ,
] ) ;
} ) ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
test ( 'nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture' , async ( ) => {
const m = await evenementOuvert ( ) ;
const trace = m . trace . length ;
const etat = texteEtat ( m ) ;
await ajouter ( m , { nom : ' ' } ) ;
const entree = champ ( m , 'nom' ) ;
expect ( entree . getAttribute ( 'aria-invalid' ) ) . toBe ( 'true' ) ;
const message = document . getElementById ( entree . getAttribute ( 'aria-describedby' ) ) ;
expect ( message ) . not . toBeNull ( ) ;
expect ( message . textContent ) . toContain ( t ( 'NOM_REQUIS' , { } ) ) ;
expect ( visible ( message ) ) . toBe ( true ) ;
expect ( ecart ( entree , message ) ) . toBeLessThanOrEqual ( 16 ) ;
aucuneFenetre ( ) ;
expect ( m . trace . length ) . toBe ( trace ) ;
expect ( texteEtat ( m ) ) . toBe ( etat ) ;
expect ( participants ( m ) ) . toHaveLength ( 0 ) ;
} ) ;
test ( 'appartenance collante : trois personnes portent « Club des Merles » ; le champ vidé, la quatrième n’ en porte aucune' , async ( ) => {
const m = await evenementOuvert ( ) ;
await ajouter ( m , { nom : 'Ombrelle' , prenom : 'Iris' , appartenance : CLUB } ) ;
expect ( champ ( m , 'appartenance' ) . value ) . toBe ( CLUB ) ;
expect ( champ ( m , 'prenom' ) . value ) . toBe ( '' ) ;
await ajouter ( m , { nom : 'Pervenche' } ) ;
await ajouter ( m , { nom : 'Lacasse' } ) ;
expect ( participants ( m ) . map ( ( p ) => [ p . nom , p . prenom , p . appartenance ] ) ) . toEqual ( [
[ 'Ombrelle' , 'Iris' , CLUB ] ,
[ 'Pervenche' , null , CLUB ] ,
[ 'Lacasse' , null , CLUB ] ,
] ) ;
await saisir ( champ ( m , 'appartenance' ) , '' ) ;
await ajouter ( m , { nom : 'Grisaille' } ) ;
expect ( participants ( m ) . at ( - 1 ) . appartenance ) . toBeNull ( ) ;
} ) ;
[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 ( '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 ( '' ) ;
} ) ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
test ( 'une appartenance qui ne diffère d’ une existante que par la casse et les blancs est signalée sur le champ, l’ orthographe existante à reprendre ; rien ne fusionne seul' , async ( ) => {
const m = await evenementOuvert ( { personnes : [ { nom : 'Ombrelle' , prenom : 'Iris' , appartenance : CLUB } ] } ) ;
const trace = m . trace . length ;
await saisir ( champ ( m , 'appartenance' ) , 'club des merles' ) ;
const signal = formulaire ( m ) . querySelector ( '[data-avertissement]' ) ;
expect ( signal ) . not . toBeNull ( ) ;
expect ( visible ( signal ) ) . toBe ( true ) ;
expect ( signal . textContent ) . toContain ( CLUB ) ;
expect ( champ ( m , 'appartenance' ) . getAttribute ( 'aria-describedby' ) ) . toContain ( signal . id ) ;
expect ( champ ( m , 'appartenance' ) . value ) . toBe ( 'club des merles' ) ;
await cliquer ( signal . querySelector ( '[data-commande="reprendreOrthographe"]' ) ) ;
expect ( champ ( m , 'appartenance' ) . value ) . toBe ( CLUB ) ;
expect ( formulaire ( m ) . querySelector ( '[data-avertissement]' ) ) . toBeNull ( ) ;
expect ( m . trace . length ) . toBe ( trace ) ;
} ) ;
} ) ;
describe ( 'la grille (§ 10.2, § 19.3)' , ( ) => {
const TROIS = [
{ nom : 'Ombrelle' , prenom : 'Iris' , appartenance : CLUB } ,
{ nom : 'Pervenche' , prenom : 'Théo' , appartenance : 'CLUB DES MERLES' } ,
{ nom : 'Lacasse' , prenom : 'Ondine' } ,
{ nom : 'Grisaille' } ,
] ;
test ( 'au clavier seul : le courriel d’ Ondine Lacasse changé en une entrée ; Échap rend la valeur, valider sans changement n’ écrit rien' , async ( ) => {
const m = await evenementOuvert ( { personnes : TROIS } ) ;
const ondine = idDe ( m , 'Lacasse' ) ;
const rang = idsAffiches ( m ) . indexOf ( ondine ) ;
const colonne = COLONNES . indexOf ( 'courriel' ) ;
expect ( rang ) . toBeGreaterThan ( 0 ) ;
expect ( colonne ) . toBeGreaterThan ( 0 ) ;
const premiere = grille ( m ) . querySelector ( '[tabindex="0"]' ) ;
expect ( premiere ) . toBe ( cellule ( m , idsAffiches ( m ) [ 0 ] , COLONNES [ 0 ] ) ) ;
premiere . focus ( ) ;
for ( let i = 0 ; i < rang ; i += 1 ) await touche ( 'ArrowDown' ) ;
for ( let i = 0 ; i < colonne ; i += 1 ) await touche ( 'ArrowRight' ) ;
await rendu ( ) ;
const cible = cellule ( m , ondine , 'courriel' ) ;
expect ( document . activeElement ) . toBe ( cible ) ;
const avant = entrees ( m ) . length ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
for ( const caractere of 'ondine@exemple.test' ) await touche ( caractere ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( entrees ( m ) . at ( - 1 ) . libelle ) . toBe ( 'Modifié Ondine Lacasse : courriel' ) ;
expect ( participants ( m ) . find ( ( p ) => p . id === ondine ) . courriel ) . toBe ( 'ondine@exemple.test' ) ;
expect ( cellule ( m , ondine , 'courriel' ) . textContent . trim ( ) ) . toBe ( 'ondine@exemple.test' ) ;
// Échap en cours d'édition : rien ne s'écrit, la valeur d'origine revient.
const trace = m . trace . length ;
cellule ( m , ondine , 'courriel' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
for ( const caractere of 'zz' ) await touche ( caractere ) ;
await touche ( 'Escape' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( cellule ( m , ondine , 'courriel' ) . querySelector ( 'input' ) ) . toBeNull ( ) ;
expect ( cellule ( m , ondine , 'courriel' ) . textContent . trim ( ) ) . toBe ( 'ondine@exemple.test' ) ;
// Valider sans changement : aucune commande ne part, donc aucun refus
// SANS_EFFET ne se pose sur la cellule, et rien ne s'écrit.
const executer = vi . spyOn ( m . seance , 'executer' ) ;
cellule ( m , ondine , 'courriel' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( executer ) . not . toHaveBeenCalled ( ) ;
expect ( cellule ( m , ondine , 'courriel' ) . querySelector ( '[data-refus]' ) ) . toBeNull ( ) ;
expect ( cellule ( m , ondine , 'courriel' ) . querySelector ( '[aria-invalid]' ) ) . toBeNull ( ) ;
expect ( cellule ( m , ondine , 'courriel' ) . querySelector ( 'input' ) ) . toBeNull ( ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( m . trace . length ) . toBe ( trace ) ;
} ) ;
test ( 'Tab après une saisie qui déplace la ligne au tri mène à la cellule suivante de la même personne' , async ( ) => {
const m = await evenementOuvert ( { personnes : TROIS } ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
const premier = idsAffiches ( m ) [ 0 ] ;
expect ( participants ( m ) . find ( ( p ) => p . id === premier ) . nom ) . toBe ( 'Grisaille' ) ;
cellule ( m , premier , 'nom' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , premier , 'nom' ) . querySelector ( 'input' ) , 'Zed' ) ;
await touche ( 'Tab' ) ;
await attendreSeance ( m ) ;
expect ( participants ( m ) . find ( ( p ) => p . id === premier ) . nom ) . toBe ( 'Zed' ) ;
expect ( idsAffiches ( m ) . at ( - 1 ) ) . toBe ( premier ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , premier , COLONNES [ 1 ] ) ) ;
} ) ;
test ( 'Tab après une saisie qui retire la ligne du filtre mène à la première cellule de la personne qui la suivait' , async ( ) => {
const m = await evenementOuvert ( { personnes : TROIS } ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'merles' ) ;
const [ premier , second ] = idsAffiches ( m ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 2 ) ;
cellule ( m , premier , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , premier , 'appartenance' ) . querySelector ( 'input' ) , 'Cercle des Pinsons' ) ;
await touche ( 'Tab' ) ;
await attendreSeance ( m ) ;
expect ( idsAffiches ( m ) ) . toEqual ( [ second ] ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , second , COLONNES [ 0 ] ) ) ;
} ) ;
test ( 'Maj+Tab après une saisie qui retire la ligne du filtre mène à la dernière cellule de la personne qui la précédait' , async ( ) => {
const m = await evenementOuvert ( { personnes : TROIS } ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'merles' ) ;
const [ premier , second ] = idsAffiches ( m ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 2 ) ;
cellule ( m , second , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , second , 'appartenance' ) . querySelector ( 'input' ) , 'Cercle des Pinsons' ) ;
await majTab ( ) ;
await attendreSeance ( m ) ;
expect ( idsAffiches ( m ) ) . toEqual ( [ premier ] ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , premier , COLONNES . at ( - 1 ) ) ) ;
} ) ;
test ( 'une note de deux lignes, retouchée dans la grille, garde son saut de ligne' , async ( ) => {
const m = await evenementOuvert ( ) ;
await m . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Lacasse' , prenom : 'Ondine' , notes : 'arrive tard\nrepart tôt' } } ) ;
await rendu ( ) ;
const id = idDe ( m , 'Lacasse' ) ;
const avant = entrees ( m ) . length ;
cellule ( m , id , 'notes' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
for ( const caractere of ' x' ) await touche ( caractere ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( participants ( m ) . find ( ( p ) => p . id === id ) . notes ) . toBe ( 'arrive tard\nrepart tôt x' ) ;
} ) ;
[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 ( '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' ) ;
} ) ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
test ( 'trier par nom, croissant puis décroissant, filtrer « merles » : l’ ordre et les lignes suivent ; rien ne s’ écrit, en écriture comme en lecture' , async ( ) => {
const m = await evenementOuvert ( { personnes : TROIS } ) ;
const parNom = ( sens ) =>
[ ... participants ( m ) ]
. sort ( ( a , b ) => ( a . nom < b . nom ? - 1 : a . nom > b . nom ? 1 : a . id - b . id ) * ( sens === 'croissant' ? 1 : - 1 ) )
. map ( ( p ) => p . id ) ;
const merles = participants ( m )
. filter ( ( p ) => p . appartenance !== null )
. map ( ( p ) => p . id ) ;
expect ( merles ) . toHaveLength ( 2 ) ;
for ( const mode of [ 'ecriture' , 'lecture' ] ) {
if ( mode === 'lecture' ) {
await m . seance . passerEnLecture ( ) ;
await rendu ( ) ;
}
const journal = texteJournal ( m ) ;
const etat = texteEtat ( m ) ;
const trace = m . trace . length ;
const trier = section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ;
await cliquer ( trier ) ;
expect ( idsAffiches ( m ) , mode ) . toEqual ( parNom ( 'croissant' ) ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
expect ( idsAffiches ( m ) , mode ) . toEqual ( parNom ( 'decroissant' ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'merles' ) ;
expect ( [ ... idsAffiches ( m ) ] . sort ( ( a , b ) => a - b ) , mode ) . toEqual ( merles ) ;
expect ( section ( m ) . querySelector ( 'output' ) . textContent ) . toBe ( t ( 'participants.grille.compte' , { total : 4 , affiches : 2 } ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , '' ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 4 ) ;
await attendreSeance ( m ) ;
expect ( texteJournal ( m ) , mode ) . toBe ( journal ) ;
expect ( texteEtat ( m ) , mode ) . toBe ( etat ) ;
expect ( m . trace . length , mode ) . toBe ( trace ) ;
}
} ) ;
test ( 'après l’ import des cas limites, le compte affiché égale le nombre de lignes importées ; une personne exclue reste visible' , async ( ) => {
const m = await evenementOuvert ( ) ;
const octets = await octetsLivres ( CAS _LIMITES ) ;
const { resume } = await m . seance . executer ( 'importerParticipants' , {
apercu : apercevoir ( decoder ( octets ) . texte ) ,
mode : 'ajouter' ,
} ) ;
await rendu ( ) ;
expect ( resume . ajoutes ) . toBeGreaterThan ( 0 ) ;
expect ( participants ( m ) ) . toHaveLength ( resume . ajoutes ) ;
expect ( lignes ( m ) ) . toHaveLength ( resume . ajoutes ) ;
expect ( section ( m ) . querySelector ( 'output' ) . textContent ) . toBe (
t ( 'participants.grille.compte' , { total : resume . ajoutes , affiches : resume . ajoutes } ) ,
) ;
const exclue = participants ( m ) . find ( ( p ) => p . exclu ) ;
expect ( exclue ) . toBeDefined ( ) ;
expect ( cellule ( m , exclue . id , 'exclu' ) . textContent . trim ( ) ) . toBe ( t ( 'participants.grille.exclue' ) ) ;
} ) ;
test ( 'exclusion : douze personnes, en exclure une → douze lignes, la sienne porte « exclue » en texte ; le bilan compte onze participants' , async ( ) => {
const m = await evenementOuvert ( { personnes : NOMS . slice ( 0 , 12 ) . map ( ( nom ) => ( { nom } ) ) } ) ;
const id = idDe ( m , NOMS [ 3 ] ) ;
const avant = entrees ( m ) . length ;
await cliquer ( commandeDeLigne ( m , id , 'exclureParticipant' ) ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( lignes ( m ) ) . toHaveLength ( 12 ) ;
expect ( cellule ( m , id , 'exclu' ) . textContent . trim ( ) ) . toBe ( t ( 'participants.grille.exclue' ) ) ;
const autres = participants ( m ) . filter ( ( p ) => p . id !== id ) ;
expect ( autres ) . toHaveLength ( 11 ) ;
// La marque est un texte, distinct de celui d'une personne présente.
for ( const p of autres ) expect ( cellule ( m , p . id , 'exclu' ) . textContent . trim ( ) ) . toBe ( t ( 'participants.grille.presente' ) ) ;
expect ( t ( 'participants.grille.presente' ) ) . not . toBe ( t ( 'participants.grille.exclue' ) ) ;
expect ( bilanChangementDefaut ( m . seance . etat ( ) . charge , 8 ) . participants ) . toBe ( 11 ) ;
} ) ;
test ( 'notes jamais interprétées : « <b>x</b> & =1+1 » s’ affiche tel quel dans la grille et sur la fiche, sans élément <b>' , async ( ) => {
const m = await evenementOuvert ( ) ;
const notes = '<b>x</b> & =1+1' ;
await m . seance . executer ( 'ajouterParticipant' , { champs : { nom : 'Ombrelle' , notes } } ) ;
await rendu ( ) ;
const id = idDe ( m , 'Ombrelle' ) ;
expect ( cellule ( m , id , 'notes' ) . textContent . trim ( ) ) . toBe ( notes ) ;
m . ui . ouvrirFiche ( id ) ;
await rendu ( ) ;
expect ( valeurDeFiche ( m , 'participants.colonne.notes' ) . textContent ) . toBe ( notes ) ;
expect ( section ( m ) . querySelectorAll ( 'b' ) ) . toHaveLength ( 0 ) ;
} ) ;
test ( 'titre pressenti : visible dans la grille et sur la fiche' , async ( ) => {
const m = await evenementOuvert ( { personnes : [ { nom : 'Pervenche' , prenom : 'Théo' , titre : 'hôtesse' } ] } ) ;
const id = idDe ( m , 'Pervenche' ) ;
expect ( cellule ( m , id , 'titrePressenti' ) . textContent . trim ( ) ) . toBe ( 'hôtesse' ) ;
cellule ( m , id , 'titrePressenti' ) . scrollIntoView ( { block : 'center' , inline : 'center' } ) ;
expect ( visible ( cellule ( m , id , 'titrePressenti' ) ) ) . toBe ( true ) ;
m . ui . ouvrirFiche ( id ) ;
await rendu ( ) ;
const valeur = valeurDeFiche ( m , 'participants.colonne.titrePressenti' ) ;
expect ( valeur . textContent ) . toBe ( 'hôtesse' ) ;
expect ( visible ( valeur ) ) . toBe ( true ) ;
} ) ;
test ( 'supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien' , async ( ) => {
const m = await evenementOuvert ( { tables : 1 , personnes : [ { nom : 'Ombrelle' } , { nom : 'Pervenche' } ] } ) ;
const iris = idDe ( m , 'Ombrelle' ) ;
const theo = idDe ( m , 'Pervenche' ) ;
const table = m . seance . etat ( ) . charge . tables [ 0 ] ;
await m . seance . executer ( 'reserver' , { participant : theo , table : table . id , siege : 2 , portee : 'tous' , tour : null } ) ;
await rendu ( ) ;
const avant = entrees ( m ) . length ;
await cliquer ( commandeDeLigne ( m , iris , 'supprimerParticipant' ) ) ;
await attendreSeance ( m ) ;
aucuneFenetre ( ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( participants ( m ) . map ( ( p ) => p . id ) ) . toEqual ( [ theo ] ) ;
await cliquer ( commandeDeLigne ( m , theo , 'supprimerParticipant' ) ) ;
const fenetre = document . querySelector ( 'dialog[open]' ) ;
expect ( fenetre ) . not . toBeNull ( ) ;
const elements = [ ... fenetre . querySelectorAll ( 'li' ) ] . map ( ( li ) => li . textContent ) ;
expect ( elements ) . toEqual ( [
t ( 'participants.suppression.reservation' , { numero : table . numero , siege : 2 , portee : 'tous' , tour : null } ) ,
] ) ;
await cliquer ( fenetre . querySelector ( '[data-commande="renoncer"]' ) ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( participants ( m ) . map ( ( p ) => p . id ) ) . toEqual ( [ theo ] ) ;
await cliquer ( commandeDeLigne ( m , theo , 'supprimerParticipant' ) ) ;
await cliquer ( document . querySelector ( 'dialog[open] [data-commande="confirmer"]' ) ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 2 ) ;
expect ( participants ( m ) ) . toEqual ( [ ] ) ;
} ) ;
} ) ;
describe ( 'le manque de places (§ 5.9, § 4.4)' , ( ) => {
test ( 'deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1' , async ( ) => {
const m = await evenementOuvert ( { tables : 2 , personnes : NOMS . slice ( 0 , 16 ) . map ( ( nom ) => ( { nom } ) ) } ) ;
const executer = vi . spyOn ( m . seance , 'executer' ) ;
const avant = entrees ( m ) . length ;
await ajouter ( m , { nom : NOMS [ 16 ] } ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( participants ( m ) ) . toHaveLength ( 17 ) ;
expect ( executer ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( executer . mock . calls [ 0 ] [ 0 ] ) . toBe ( 'ajouterParticipant' ) ;
const rendus = await executer . mock . results [ 0 ] . value ;
const attendu = nombrePlacesManquantes ( normaliser ( configurationDepuisCharge ( m . seance . etat ( ) . charge ) ) ) ;
expect ( attendu ) . toBe ( 1 ) ;
const manque = rendus . avertissements . find ( ( { code } ) => code === 'PLACES_MANQUANTES' ) ;
expect ( manque ? . details . placesManquantes ) . toBe ( attendu ) ;
const etat = m . seance . etat ( ) . avertissements . find ( ( { code } ) => code === 'PLACES_MANQUANTES' ) ;
expect ( etat ? . details . placesManquantes ) . toBe ( attendu ) ;
} ) ;
test ( 'deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1' , async ( ) => {
const personnes = NOMS . slice ( 0 , 17 ) . map ( ( nom , rang ) => ( { nom , exclu : rang === 5 } ) ) ;
const m = await evenementOuvert ( { tables : 2 , personnes } ) ;
const id = idDe ( m , NOMS [ 5 ] ) ;
const executer = vi . spyOn ( m . seance , 'executer' ) ;
const avant = entrees ( m ) . length ;
await cliquer ( commandeDeLigne ( m , id , 'reintegrerParticipant' ) ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( participants ( m ) . find ( ( p ) => p . id === id ) . exclu ) . toBe ( false ) ;
expect ( executer ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( executer . mock . calls [ 0 ] ) . toEqual ( [ 'reintegrerParticipant' , { id } ] ) ;
const rendus = await executer . mock . results [ 0 ] . value ;
const attendu = nombrePlacesManquantes ( normaliser ( configurationDepuisCharge ( m . seance . etat ( ) . charge ) ) ) ;
expect ( attendu ) . toBe ( 1 ) ;
expect ( rendus . avertissements . find ( ( { code } ) => code === 'PLACES_MANQUANTES' ) ? . details . placesManquantes ) . toBe ( attendu ) ;
expect ( m . seance . etat ( ) . avertissements . find ( ( { code } ) => code === 'PLACES_MANQUANTES' ) ? . details . placesManquantes ) . toBe (
attendu ,
) ;
} ) ;
} ) ;
describe ( 'la fiche (§ 4.2, § 7.4)' , ( ) => {
test ( 'statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut' , async ( ) => {
const m = await evenementOuvert ( {
sieges : 4 ,
tours : 3 ,
tables : 2 ,
personnes : [
{ nom : 'Ombrelle' , prenom : 'Iris' } ,
{ nom : 'Pervenche' , prenom : 'Théo' } ,
{ nom : 'Lacasse' , prenom : 'Ondine' } ,
{ nom : 'Grisaille' } ,
] ,
} ) ;
const [ t1 , t2 ] = m . seance . etat ( ) . charge . tables . map ( ( { id } ) => id ) ;
const [ a , b , c , d ] = [ 'Ombrelle' , 'Pervenche' , 'Lacasse' , 'Grisaille' ] . map ( ( nom ) => idDe ( m , nom ) ) ;
const reserver = ( participant , table , portee , tour = null ) =>
m . seance . executer ( 'reserver' , { participant , table , siege : null , portee , tour } ) ;
await reserver ( a , t1 , 'tous' ) ;
for ( const tour of [ 1 , 2 , 3 ] ) await reserver ( b , t1 , 'tour' , tour ) ;
await reserver ( c , t1 , 'tour' , 1 ) ;
await reserver ( c , t2 , 'tour' , 2 ) ;
await rendu ( ) ;
const instance = normaliser ( configurationDepuisCharge ( m . seance . etat ( ) . charge ) ) ;
const NOMS _STATUTS = new Map ( [
[ STATUT . ANCRE , 'ancre' ] ,
[ STATUT . PARTIELLEMENT _FIXE , 'partiel' ] ,
[ STATUT . MOBILE , 'mobile' ] ,
] ) ;
const attendus = [
[ a , 'ancre' ] ,
[ b , 'ancre' ] ,
[ c , 'partiel' ] ,
[ d , 'mobile' ] ,
] ;
for ( const [ id , statut ] of attendus ) {
expect ( NOMS _STATUTS . get ( instance . statut [ instance . indexDe . get ( id ) ] ) ) . toBe ( statut ) ;
m . ui . ouvrirFiche ( id ) ;
await rendu ( ) ;
expect ( fiche ( m ) . dataset . fiche ) . toBe ( String ( id ) ) ;
expect ( valeurDeFiche ( m , 'participants.fiche.statut' ) . textContent ) . toBe ( t ( cleDeStatut ( statut ) ) ) ;
expect ( fiche ( m ) . querySelectorAll ( 'input, select, textarea' ) ) . toHaveLength ( 0 ) ;
}
} ) ;
test ( 'ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée' , async ( ) => {
const m = await evenementOuvert ( { sieges : 4 , tours : 3 , tables : 2 , personnes : NOMS . slice ( 0 , 7 ) . map ( ( nom ) => ( { nom } ) ) } ) ;
await m . seance . executer ( 'modifierReglage' , { cle : 'attribuerSieges' , valeur : true } ) ;
await m . app . generation ( ) . generer ( { graine : 1 , nombre : 1 , arret : 50 , historique : 1000 } ) ;
const [ proposition ] = m . seance . etat ( ) . charge . propositions ;
await m . seance . executer ( 'retenirProposition' , { id : proposition . id } ) ;
// Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire.
await m . seance . executer ( 'retirerDuTour' , { tour : 2 , participant : idDe ( m , NOMS [ 2 ] ) } ) ;
await rendu ( ) ;
const charge = m . seance . etat ( ) . charge ;
const forme = formeNommee ( charge . retenu , charge ) ;
expect ( forme . some ( ( ligneForme ) => ligneForme . reserve ) ) . toBe ( true ) ;
const id = idDe ( m , NOMS [ 2 ] ) ;
await cliquer ( commandeDeLigne ( m , id , 'fiche' ) ) ;
expect ( m . ui . etat ( ) . fiche ) . toBe ( id ) ;
expect ( fiche ( m ) . dataset . fiche ) . toBe ( String ( id ) ) ;
expect ( visible ( fiche ( m ) ) ) . toBe ( true ) ;
await cliquer ( fiche ( m ) . querySelector ( '[data-commande="fermerFiche"]' ) ) ;
expect ( fiche ( m ) ) . toBeNull ( ) ;
expect ( m . ui . etat ( ) . section ) . toBe ( 'participants' ) ;
// Chaque personne : l'itinéraire de la fiche, ligne par ligne.
expect ( charge . participants ) . toHaveLength ( 7 ) ;
for ( const { id : personne } of charge . participants ) {
m . ui . ouvrirFiche ( personne ) ;
await rendu ( ) ;
expect ( visible ( fiche ( m ) ) ) . toBe ( true ) ;
const tableau = tableauDeFiche ( m , 'participants.fiche.itineraire' ) ;
expect ( tableau ) . toBeDefined ( ) ;
const lus = [ ... tableau . tBodies [ 0 ] . rows ] . map ( ( rangee ) => [ ... rangee . cells ] . map ( ( c ) => c . textContent . trim ( ) ) ) ;
const attendus = forme
. filter ( ( l ) => l . participant === personne )
. map ( ( l ) => ( l . reserve ? [ String ( l . tour ) , t ( 'participants.fiche.enReserve' ) ] : [ String ( l . tour ) , String ( l . numero ) , String ( l . siege ) ] ) ) ;
expect ( attendus ) . toHaveLength ( 3 ) ;
expect ( lus ) . toEqual ( attendus ) ;
}
} ) ;
[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 ( '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' ) ] ] ) ;
}
} ) ;
[ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.
--- FR ---
[ADD] interface : liste, événement, participants, import, tables, plan
Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.
Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00
test ( 'un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement' , async ( ) => {
const long = 'Ombrelle' . repeat ( 25 ) ;
expect ( long ) . toHaveLength ( 200 ) ;
const m = await evenementOuvert ( ) ;
await m . seance . executer ( 'ajouterParticipant' , { champs : { nom : long } } ) ;
const id = idDe ( m , long ) ;
m . ui . ouvrirFiche ( id ) ;
await rendu ( ) ;
const porteur = fiche ( m ) ;
const valeur = valeurDeFiche ( m , 'participants.colonne.nom' ) ;
expect ( valeur . textContent ) . toBe ( long ) ;
expect ( porteur . textContent ) . not . toContain ( '\u{2026}' ) ;
expect ( porteur . scrollWidth ) . toBeLessThanOrEqual ( porteur . clientWidth ) ;
expect ( valeur . scrollWidth ) . toBeLessThanOrEqual ( valeur . clientWidth ) ;
const interligne = Number . parseFloat ( getComputedStyle ( valeur ) . lineHeight ) ;
expect ( interligne ) . toBeGreaterThan ( 0 ) ;
expect ( valeur . getBoundingClientRect ( ) . height ) . toBeGreaterThanOrEqual ( 2 * interligne ) ;
} ) ;
} ) ;
describe ( 'export et collage (§ 10.2)' , ( ) => {
test ( '« Exporter la liste » remet les octets d’ exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris' , async ( ) => {
const m = await evenementOuvert ( ) ;
const octets = await octetsLivres ( CAS _LIMITES ) ;
await m . seance . executer ( 'importerParticipants' , { apercu : apercevoir ( decoder ( octets ) . texte ) , mode : 'ajouter' } ) ;
await rendu ( ) ;
const releves = [ ] ;
m . fs . enregistrerSous = async ( nomPropose , contenu ) => {
releves . push ( { nomPropose , octets : contenu } ) ;
return nomPropose ;
} ;
const avant = participants ( m ) ;
expect ( avant . some ( ( p ) => p . exclu ) ) . toBe ( true ) ;
const trace = m . trace . length ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="exporter"]' ) ) ;
await attendreSeance ( m ) ;
expect ( releves ) . toHaveLength ( 1 ) ;
expect ( releves [ 0 ] . nomPropose ) . toBe ( ` ${ base ( m ) } -participants.csv ` ) ;
expect ( [ ... releves [ 0 ] . octets ] ) . toEqual ( [ ... exporterParticipants ( avant ) ] ) ;
expect ( m . trace . length ) . toBe ( trace ) ;
await m . seance . fermer ( ) ;
await m . seance . creer ( { nom : 'Soirée des Grisailles' , date : null , siegesParDefaut : 8 , tours : 2 } ) ;
await m . seance . passerEnEcriture ( ) ;
await m . seance . executer ( 'importerParticipants' , { apercu : apercevoir ( decoder ( releves [ 0 ] . octets ) . texte ) , mode : 'ajouter' } ) ;
const sansId = ( liste ) => liste . map ( ( { id , ... champs } ) => champs ) ;
expect ( avant . length ) . toBeGreaterThan ( 0 ) ;
expect ( sansId ( participants ( m ) ) ) . toEqual ( sansId ( avant ) ) ;
} ) ;
test ( 'un collage sur la grille ouvre l’ aperçu d’ import : importEnAttente porte le texte intact, origine collage, et la section import ; zéro écriture' , async ( ) => {
const m = await evenementOuvert ( { personnes : [ { nom : 'Ombrelle' } ] } ) ;
const etats = [ ] ;
const desabonner = m . ui . subscribe ( ( etat ) => etats . push ( etat ) ) ;
const trace = m . trace . length ;
const texte = 'nom;prénom\r\nPervenche;Théo\n"Lacasse; Ondine";\u{00A0}x\t' ;
grille ( m ) . querySelector ( '[tabindex="0"]' ) . focus ( ) ;
const presse = new DataTransfer ( ) ;
presse . setData ( 'text/plain' , texte ) ;
grille ( m ) . querySelector ( '[tabindex="0"]' ) . dispatchEvent (
new ClipboardEvent ( 'paste' , { clipboardData : presse , bubbles : true , cancelable : true } ) ,
) ;
await rendu ( ) ;
desabonner ( ) ;
expect ( etats . some ( ( etat ) => etat . importEnAttente ? . texte === texte && etat . importEnAttente . origine === 'collage' && etat . section === 'import' ) ) . toBe (
true ,
) ;
expect ( m . ui . etat ( ) . section ) . toBe ( 'import' ) ;
await attendreSeance ( m ) ;
expect ( m . trace . length ) . toBe ( trace ) ;
} ) ;
} ) ;
[ADD] participants: group the list by columns, with counts and collapse
The participants list can now be grouped by one or more columns, nested
level by level: appartenance (by its normalised key, labelled with its
first spelling), status, exclusion, or any text column. Each group header
shows its value, its count and a keyboard-operable collapse button, with
Collapse all and Expand all. The filter applies first, groups follow the
current sort, arrow keys skip headers and collapsed rows, and an edit
moves its row to its new group. Grouping is session state only and never
writes the event. Checked: 2811 node, 565 browser tests, index alone.
--- FR ---
[ADD] participants : grouper la liste par colonnes, compte et repli
La liste des participants se groupe par une ou plusieurs colonnes,
imbriquées niveau par niveau : appartenance (par sa clé normalisée, sous sa
première orthographe), statut, exclusion, ou toute colonne de texte.
Chaque en-tête de groupe montre sa valeur, son compte et un bouton de
repli au clavier, avec Tout replier et Tout déplier. Le filtre s'applique
d'abord, les groupes suivent le tri, les flèches sautent les en-têtes et
les lignes repliées, et une saisie déplace sa ligne dans son nouveau
groupe. État de séance seul, aucune écriture. Vérifié : 2811 node, 565.
Assisted-by: Claude Opus 5.5
2026-10-08 03:49:36 -04:00
describe ( 'la liste groupée par une colonne ou plusieurs' , ( ) => {
// Cinq personnes : deux du Club des Merles sous deux orthographes, deux
// du Cercle des Pinsons dont une exclue, une sans appartenance.
const CINQ = [
{ nom : 'Ombrelle' , prenom : 'Iris' , appartenance : CLUB , titre : 'hôtesse' } ,
{ nom : 'Pervenche' , prenom : 'Théo' , appartenance : 'CLUB DES MERLES' } ,
{ nom : 'Lacasse' , prenom : 'Ondine' } ,
{ nom : 'Grisaille' , appartenance : 'Cercle des Pinsons' , exclu : true } ,
{ nom : 'Brindelle' , appartenance : 'Cercle des Pinsons' } ,
] ;
const PINSONS = 'texte:cercle des pinsons' ;
const MERLES = 'texte:club des merles' ;
// Le sélecteur du niveau donné, 1 pour le plus large, et tous ceux
// affichés.
const selecteur = ( m , niveau = 1 ) => section ( m ) . querySelector ( ` [data-champ="grouper"][data-niveau=" ${ niveau } "] ` ) ;
const selecteurs = ( m ) => [ ... section ( m ) . querySelectorAll ( '[data-champ="grouper"]' ) ] ;
// Les en-têtes de groupe affichés, de tout niveau, dans l'ordre du rendu ;
// l'en-tête d'un groupe porte sa clé, chaque ligne celle du plus étroit
// groupe qui la compte.
const groupes = ( m ) => [ ... grille ( m ) . querySelectorAll ( '[data-groupe]' ) ] ;
const groupe = ( m , cle ) => grille ( m ) . querySelector ( ` [data-groupe=" ${ cle } "] ` ) ;
const bascule = ( m , cle ) => groupe ( m , cle ) . querySelector ( '[data-commande="basculerGroupe"]' ) ;
const idsDuGroupe = ( m , cle ) =>
lignes ( m )
. filter ( ( { dataset : { groupeLigne } } ) => groupeLigne === cle || groupeLigne . startsWith ( ` ${ cle } / ` ) )
. map ( ( l ) => Number ( l . dataset . participant ) ) ;
const ouverts = ( m ) => groupes ( m ) . map ( ( g ) => bascule ( m , g . dataset . groupe ) . getAttribute ( 'aria-expanded' ) ) ;
// Tous les groupes d'un arbre de groupesGrille, chacun avant ses
// sous-groupes : l'ordre des en-têtes déplié.
const aplatir = ( arbre ) => arbre . flatMap ( ( g ) => [ g , ... ( g . sousGroupes === null ? [ ] : aplatir ( g . sousGroupes ) ) ] ) ;
// Ce que montre l'en-tête d'un groupe : son libellé et son compte.
const entete = ( m , cle ) => ( {
libelle : groupe ( m , cle ) . querySelector ( '[data-groupe-libelle]' ) . textContent ,
compte : groupe ( m , cle ) . querySelector ( '[data-groupe-compte]' ) . textContent ,
} ) ;
// Le libellé et le compte qu'un groupe de groupesGrille doit afficher.
const enteteAttendu = ( { libelle , cleLibelle , compte } ) => ( {
libelle : libelle ? ? t ( cleLibelle ) ,
compte : t ( 'participants.groupe.compte' , { compte } ) ,
} ) ;
// Choisit la colonne d'un niveau du groupement au clavier seul : le
// sélecteur de ce niveau prend le focus, puis les flèches le mènent à
// l'option voulue.
async function grouperPar ( m , colonne , niveau = 1 ) {
const choix = selecteur ( m , niveau ) ;
const valeurs = [ ... choix . options ] . map ( ( option ) => option . value ) ;
const cible = valeurs . indexOf ( colonne ) ;
expect ( cible , colonne ) . toBeGreaterThanOrEqual ( 0 ) ;
choix . focus ( ) ;
for ( let essai = 0 ; essai < valeurs . length && choix . selectedIndex !== cible ; essai += 1 ) {
await touche ( choix . selectedIndex < cible ? 'ArrowDown' : 'ArrowUp' ) ;
await rendu ( ) ;
}
expect ( choix . value ) . toBe ( colonne ) ;
await rendu ( ) ;
}
// Le groupement par appartenance puis par statut, Ombrelle ancrée par une
// réservation : les autres présentes sont mobiles.
async function appartenancePuisStatut ( ) {
const m = await evenementOuvert ( { tables : 1 , personnes : CINQ } ) ;
const charge = m . seance . etat ( ) . charge ;
await m . seance . executer ( 'reserver' , { participant : idDe ( m , 'Ombrelle' ) , table : charge . tables [ 0 ] . id , siege : null , portee : 'tous' , tour : null } ) ;
await attendreSeance ( m ) ;
await grouperPar ( m , 'appartenance' , 1 ) ;
await grouperPar ( m , 'statut' , 2 ) ;
return m ;
}
// Le journal, l'état et la trace d'écriture, à comparer après des gestes
// de regard.
const empreinte = ( m ) => ( { journal : texteJournal ( m ) , etat : texteEtat ( m ) , trace : m . trace . length } ) ;
test ( 'le sélecteur offre « Aucun » puis chaque colonne groupable ; sans groupement, aucun en-tête de groupe' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
const options = [ ... selecteur ( m ) . options ] ;
expect ( options . map ( ( o ) => o . value ) ) . toEqual ( [ '' , ... GROUPEMENTS ] ) ;
expect ( options . map ( ( o ) => o . textContent ) ) . toEqual ( [ t ( 'participants.groupe.aucun' ) , ... GROUPEMENTS . map ( ( c ) => t ( cleDeGroupement ( c ) ) ) ] ) ;
expect ( selecteur ( m ) . value ) . toBe ( '' ) ;
expect ( selecteurs ( m ) ) . toHaveLength ( 1 ) ;
expect ( groupes ( m ) ) . toHaveLength ( 0 ) ;
expect ( lignes ( m ) ) . toHaveLength ( 5 ) ;
expect ( section ( m ) . querySelector ( '[data-commande="toutReplier"]' ) ) . toBeNull ( ) ;
} ) ;
test ( 'chaque colonne choisie ouvre le niveau suivant, aux colonnes pas encore prises ; « Aucun » retire un niveau et les suivants' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' , 1 ) ;
expect ( selecteurs ( m ) ) . toHaveLength ( 2 ) ;
expect ( selecteur ( m , 2 ) . value ) . toBe ( '' ) ;
const deuxieme = [ ... selecteur ( m , 2 ) . options ] ;
expect ( deuxieme . map ( ( o ) => o . value ) ) . toEqual ( [ '' , ... GROUPEMENTS . filter ( ( c ) => c !== 'appartenance' ) ] ) ;
expect ( deuxieme [ 0 ] . textContent ) . toBe ( t ( 'participants.groupe.aucunSousGroupe' ) ) ;
// Chaque sélecteur a son étiquette, distincte des autres.
const etiquettes = selecteurs ( m ) . map ( ( choix ) => choix . labels [ 0 ] ? . textContent ) ;
expect ( etiquettes ) . toEqual ( [ t ( 'participants.groupe.grouperPar' ) , t ( 'participants.groupe.puisPar' , { niveau : 2 } ) ] ) ;
await grouperPar ( m , 'statut' , 2 ) ;
expect ( selecteurs ( m ) . map ( ( choix ) => choix . value ) ) . toEqual ( [ 'appartenance' , 'statut' , '' ] ) ;
expect ( [ ... selecteur ( m , 3 ) . options ] . map ( ( o ) => o . value ) ) . toEqual ( [ '' , ... GROUPEMENTS . filter ( ( c ) => c !== 'appartenance' && c !== 'statut' ) ] ) ;
expect ( new Set ( groupes ( m ) . map ( ( g ) => g . dataset . niveau ) ) ) . toEqual ( new Set ( [ '1' , '2' ] ) ) ;
// La colonne d'un niveau prise plus haut quitte le niveau plus bas.
await grouperPar ( m , 'statut' , 1 ) ;
expect ( selecteurs ( m ) . map ( ( choix ) => choix . value ) ) . toEqual ( [ 'statut' , '' ] ) ;
await grouperPar ( m , 'nom' , 2 ) ;
await grouperPar ( m , 'prenom' , 3 ) ;
expect ( selecteurs ( m ) . map ( ( choix ) => choix . value ) ) . toEqual ( [ 'statut' , 'nom' , 'prenom' , '' ] ) ;
await grouperPar ( m , '' , 2 ) ;
expect ( selecteurs ( m ) . map ( ( choix ) => choix . value ) ) . toEqual ( [ 'statut' , '' ] ) ;
expect ( new Set ( groupes ( m ) . map ( ( g ) => g . dataset . niveau ) ) ) . toEqual ( new Set ( [ '1' ] ) ) ;
await grouperPar ( m , '' , 1 ) ;
expect ( selecteurs ( m ) . map ( ( choix ) => choix . value ) ) . toEqual ( [ '' ] ) ;
expect ( groupes ( m ) ) . toHaveLength ( 0 ) ;
expect ( lignes ( m ) ) . toHaveLength ( 5 ) ;
// Toutes les colonnes prises : aucun sélecteur de plus.
for ( const [ rang , colonne ] of GROUPEMENTS . entries ( ) ) await grouperPar ( m , colonne , rang + 1 ) ;
expect ( selecteurs ( m ) ) . toHaveLength ( GROUPEMENTS . length ) ;
} ) ;
test ( 'par appartenance : un en-tête par groupe, son libellé et son compte, ses lignes dessous ; puis chaque colonne groupable suit groupesGrille ; rien ne s’ écrit' , async ( ) => {
const m = await evenementOuvert ( { tables : 1 , personnes : CINQ } ) ;
const charge = m . seance . etat ( ) . charge ;
await m . seance . executer ( 'reserver' , { participant : idDe ( m , 'Ombrelle' ) , table : charge . tables [ 0 ] . id , siege : null , portee : 'tous' , tour : null } ) ;
await attendreSeance ( m ) ;
const avant = empreinte ( m ) ;
await grouperPar ( m , 'appartenance' ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ PINSONS , MERLES , 'vide' ] ) ;
expect ( entete ( m , PINSONS ) ) . toEqual ( { libelle : 'Cercle des Pinsons' , compte : t ( 'participants.groupe.compte' , { compte : 2 } ) } ) ;
expect ( entete ( m , MERLES ) ) . toEqual ( { libelle : CLUB , compte : t ( 'participants.groupe.compte' , { compte : 2 } ) } ) ;
expect ( entete ( m , 'vide' ) ) . toEqual ( { libelle : t ( 'participants.groupe.vide' ) , compte : t ( 'participants.groupe.compte' , { compte : 1 } ) } ) ;
expect ( idsDuGroupe ( m , PINSONS ) ) . toEqual ( [ idDe ( m , 'Grisaille' ) , idDe ( m , 'Brindelle' ) ] ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ idDe ( m , 'Ombrelle' ) , idDe ( m , 'Pervenche' ) ] ) ;
expect ( idsDuGroupe ( m , 'vide' ) ) . toEqual ( [ idDe ( m , 'Lacasse' ) ] ) ;
// L'en-tête précède les lignes de son groupe, dans le rendu.
const premiere = ligne ( m , idDe ( m , 'Grisaille' ) ) . getBoundingClientRect ( ) ;
expect ( bascule ( m , PINSONS ) . getBoundingClientRect ( ) . bottom ) . toBeLessThanOrEqual ( premiere . top + 1 ) ;
await grouperPar ( m , 'statut' ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ 'statut:ancre' , 'statut:mobile' , 'statut:exclue' ] ) ;
expect ( entete ( m , 'statut:ancre' ) . libelle ) . toBe ( t ( 'participants.statut.ancre' ) ) ;
expect ( idsDuGroupe ( m , 'statut:ancre' ) ) . toEqual ( [ idDe ( m , 'Ombrelle' ) ] ) ;
expect ( idsDuGroupe ( m , 'statut:exclue' ) ) . toEqual ( [ idDe ( m , 'Grisaille' ) ] ) ;
expect ( GROUPEMENTS . length ) . toBeGreaterThan ( 0 ) ;
const lignesAttendues = lignesGrille ( participants ( m ) , { tri : null , filtre : '' } , cleNormalisee ) ;
for ( const colonne of GROUPEMENTS ) {
await grouperPar ( m , colonne ) ;
const attendus = groupesGrille ( lignesAttendues , [ colonne ] , { tri : null , cleNormalisee , statuts : statuts ( m . seance . etat ( ) . charge ) . parId } ) ;
expect ( attendus . length , colonne ) . toBeGreaterThan ( 0 ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) , colonne ) . toEqual ( attendus . map ( ( { cle } ) => cle ) ) ;
for ( const attendu of attendus ) {
expect ( entete ( m , attendu . cle ) , ` ${ colonne } ${ attendu . cle } ` ) . toEqual ( enteteAttendu ( attendu ) ) ;
expect ( idsDuGroupe ( m , attendu . cle ) , ` ${ colonne } ${ attendu . cle } ` ) . toEqual ( attendu . lignes . map ( ( { id } ) => id ) ) ;
expect ( bascule ( m , attendu . cle ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
}
}
await grouperPar ( m , '' ) ;
expect ( groupes ( m ) ) . toHaveLength ( 0 ) ;
expect ( lignes ( m ) ) . toHaveLength ( 5 ) ;
await attendreSeance ( m ) ;
expect ( empreinte ( m ) ) . toEqual ( avant ) ;
} ) ;
test ( 'replier masque exactement les lignes du groupe, déplier les rend ; Tout replier, Tout déplier ; l’ état se lit en texte et dans aria-expanded ; rien ne s’ écrit, en écriture comme en lecture' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
for ( const mode of [ 'ecriture' , 'lecture' ] ) {
if ( mode === 'lecture' ) {
await m . seance . passerEnLecture ( ) ;
await rendu ( ) ;
}
const avant = empreinte ( m ) ;
await grouperPar ( m , 'appartenance' ) ;
const tous = idsAffiches ( m ) ;
expect ( tous , mode ) . toHaveLength ( 5 ) ;
const merles = idsDuGroupe ( m , MERLES ) ;
expect ( merles , mode ) . toHaveLength ( 2 ) ;
const texteOuvert = bascule ( m , MERLES ) . querySelector ( '[data-groupe-etat]' ) . textContent ;
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( bascule ( m , MERLES ) . getAttribute ( 'aria-expanded' ) , mode ) . toBe ( 'false' ) ;
expect ( idsDuGroupe ( m , MERLES ) , mode ) . toEqual ( [ ] ) ;
expect ( idsAffiches ( m ) , mode ) . toEqual ( tous . filter ( ( id ) => ! merles . includes ( id ) ) ) ;
expect ( entete ( m , MERLES ) . compte , mode ) . toBe ( t ( 'participants.groupe.compte' , { compte : 2 } ) ) ;
// Replié et ouvert se distinguent par le texte, pas seulement par la couleur.
expect ( bascule ( m , MERLES ) . querySelector ( '[data-groupe-etat]' ) . textContent , mode ) . not . toBe ( texteOuvert ) ;
expect ( bascule ( m , PINSONS ) . getAttribute ( 'aria-expanded' ) , mode ) . toBe ( 'true' ) ;
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( bascule ( m , MERLES ) . getAttribute ( 'aria-expanded' ) , mode ) . toBe ( 'true' ) ;
expect ( idsAffiches ( m ) , mode ) . toEqual ( tous ) ;
// Au clavier : Entrée sur le bouton replie.
bascule ( m , PINSONS ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
expect ( bascule ( m , PINSONS ) . getAttribute ( 'aria-expanded' ) , mode ) . toBe ( 'false' ) ;
expect ( idsDuGroupe ( m , PINSONS ) , mode ) . toEqual ( [ ] ) ;
await touche ( ' ' ) ;
await rendu ( ) ;
expect ( bascule ( m , PINSONS ) . getAttribute ( 'aria-expanded' ) , mode ) . toBe ( 'true' ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="toutReplier"]' ) ) ;
expect ( lignes ( m ) , mode ) . toHaveLength ( 0 ) ;
expect ( groupes ( m ) . map ( ( g ) => bascule ( m , g . dataset . groupe ) . getAttribute ( 'aria-expanded' ) ) , mode ) . toEqual ( [ 'false' , 'false' , 'false' ] ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="toutDeplier"]' ) ) ;
expect ( idsAffiches ( m ) , mode ) . toEqual ( tous ) ;
expect ( groupes ( m ) . map ( ( g ) => bascule ( m , g . dataset . groupe ) . getAttribute ( 'aria-expanded' ) ) , mode ) . toEqual ( [ 'true' , 'true' , 'true' ] ) ;
await attendreSeance ( m ) ;
expect ( empreinte ( m ) , mode ) . toEqual ( avant ) ;
}
} ) ;
test ( 'le filtre passe d’ abord : un groupe sans ligne disparaît, chaque compte est celui des lignes montrées ; trié par la colonne groupée, les groupes suivent le sens' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'pinsons' ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ PINSONS ] ) ;
expect ( entete ( m , PINSONS ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 2 } ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'ondine' ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ 'vide' ] ) ;
expect ( entete ( m , 'vide' ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 1 } ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , 'iris' ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ MERLES ] ) ;
expect ( entete ( m , MERLES ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 1 } ) ) ;
await saisir ( section ( m ) . querySelector ( '[data-champ="filtre"]' ) , '' ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="appartenance"]' ) ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="appartenance"]' ) ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ MERLES , PINSONS , 'vide' ] ) ;
// Trié par nom décroissant : groupes croissants, lignes au tri dans chacun.
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="trier"][data-cible="nom"]' ) ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( [ PINSONS , MERLES , 'vide' ] ) ;
expect ( idsDuGroupe ( m , PINSONS ) ) . toEqual ( [ idDe ( m , 'Grisaille' ) , idDe ( m , 'Brindelle' ) ] ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ idDe ( m , 'Pervenche' ) , idDe ( m , 'Ombrelle' ) ] ) ;
} ) ;
test ( 'au clavier, les flèches et Tab passent d’ une ligne visible à la suivante, par-dessus les en-têtes et les groupes repliés' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' ) ;
await cliquer ( bascule ( m , MERLES ) ) ;
const [ grisaille , brindelle , lacasse ] = [ 'Grisaille' , 'Brindelle' , 'Lacasse' ] . map ( ( nom ) => idDe ( m , nom ) ) ;
expect ( idsAffiches ( m ) ) . toEqual ( [ grisaille , brindelle , lacasse ] ) ;
const premiere = grille ( m ) . querySelector ( 'td[tabindex="0"]' ) ;
expect ( premiere ) . toBe ( cellule ( m , grisaille , COLONNES [ 0 ] ) ) ;
premiere . focus ( ) ;
await touche ( 'ArrowDown' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , brindelle , COLONNES [ 0 ] ) ) ;
await touche ( 'ArrowDown' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , lacasse , COLONNES [ 0 ] ) ) ;
await touche ( 'ArrowDown' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , lacasse , COLONNES [ 0 ] ) ) ;
await touche ( 'ArrowUp' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , brindelle , COLONNES [ 0 ] ) ) ;
// Tab depuis la dernière cellule de Brindelle mène à la première de Lacasse.
cellule ( m , brindelle , COLONNES . at ( - 1 ) ) . focus ( ) ;
await touche ( 'Tab' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , lacasse , COLONNES [ 0 ] ) ) ;
// Une seule cellule de la grille dans l'ordre de tabulation.
expect ( grille ( m ) . querySelectorAll ( 'td[tabindex="0"]' ) ) . toHaveLength ( 1 ) ;
} ) ;
test ( 'une saisie qui change la valeur groupée mène la ligne à son nouveau groupe, la cellule garde le focus ; un groupe replié qui la reçoit se déplie ; une entrée par saisie' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' ) ;
const lacasse = idDe ( m , 'Lacasse' ) ;
const brindelle = idDe ( m , 'Brindelle' ) ;
const avant = entrees ( m ) . length ;
cellule ( m , lacasse , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , lacasse , 'appartenance' ) . querySelector ( 'input' ) , 'Cercle des Pinsons' ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( idsDuGroupe ( m , PINSONS ) ) . toContain ( lacasse ) ;
expect ( groupe ( m , 'vide' ) ) . toBeNull ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , lacasse , 'appartenance' ) ) ;
expect ( entete ( m , PINSONS ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 3 } ) ) ;
// Vers un groupe replié : il se déplie, et la cellule garde le focus.
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ ] ) ;
cellule ( m , brindelle , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , brindelle , 'appartenance' ) . querySelector ( 'input' ) , CLUB ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 2 ) ;
expect ( bascule ( m , MERLES ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toContain ( brindelle ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , brindelle , 'appartenance' ) ) ;
} ) ;
test ( 'changer de groupement déplie tout : d’ une colonne à une autre, ou à l’ ajout d’ un niveau' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' ) ;
await cliquer ( bascule ( m , 'vide' ) ) ;
expect ( bascule ( m , 'vide' ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 4 ) ;
// Le prénom a lui aussi un groupe « vide ».
await grouperPar ( m , 'prenom' ) ;
expect ( groupe ( m , 'vide' ) ) . not . toBeNull ( ) ;
expect ( ouverts ( m ) . length ) . toBeGreaterThan ( 0 ) ;
expect ( ouverts ( m ) . every ( ( etat ) => etat === 'true' ) ) . toBe ( true ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 5 ) ;
await cliquer ( bascule ( m , 'vide' ) ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 3 ) ;
await grouperPar ( m , 'statut' , 2 ) ;
expect ( ouverts ( m ) . every ( ( etat ) => etat === 'true' ) ) . toBe ( true ) ;
expect ( idsAffiches ( m ) ) . toHaveLength ( 5 ) ;
} ) ;
test ( 'validée par la sortie du champ, le focus posé ailleurs y reste ; le groupe replié qui reçoit la ligne le reste' , async ( ) => {
const m = await evenementOuvert ( { personnes : CINQ } ) ;
await grouperPar ( m , 'appartenance' ) ;
const lacasse = idDe ( m , 'Lacasse' ) ;
const ombrelle = idDe ( m , 'Ombrelle' ) ;
const filtre = section ( m ) . querySelector ( '[data-champ="filtre"]' ) ;
const avant = entrees ( m ) . length ;
cellule ( m , lacasse , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , lacasse , 'appartenance' ) . querySelector ( 'input' ) , 'Cercle des Pinsons' ) ;
await cliquer ( filtre ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( idsDuGroupe ( m , PINSONS ) ) . toContain ( lacasse ) ;
expect ( document . activeElement ) . toBe ( filtre ) ;
// Le groupe qui reçoit la ligne, replié avant la saisie, le reste.
await cliquer ( bascule ( m , PINSONS ) ) ;
cellule ( m , ombrelle , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , ombrelle , 'appartenance' ) . querySelector ( 'input' ) , 'Cercle des Pinsons' ) ;
await cliquer ( filtre ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 2 ) ;
expect ( bascule ( m , PINSONS ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' ) ;
expect ( entete ( m , PINSONS ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 4 } ) ) ;
expect ( idsAffiches ( m ) ) . not . toContain ( ombrelle ) ;
expect ( document . activeElement ) . toBe ( filtre ) ;
} ) ;
test ( 'appartenance puis statut : en-têtes imbriqués, comptes, repli du sous-groupe seul ou du parent entier ; rien ne s’ écrit' , async ( ) => {
const m = await appartenancePuisStatut ( ) ;
const avant = empreinte ( m ) ;
const [ ombrelle , pervenche ] = [ 'Ombrelle' , 'Pervenche' ] . map ( ( nom ) => idDe ( m , nom ) ) ;
const attendus = groupesGrille ( lignesGrille ( participants ( m ) , { tri : null , filtre : '' } , cleNormalisee ) , [ 'appartenance' , 'statut' ] , {
tri : null ,
cleNormalisee ,
statuts : statuts ( m . seance . etat ( ) . charge ) . parId ,
} ) ;
const tous = aplatir ( attendus ) ;
expect ( tous . some ( ( { niveau } ) => niveau === 2 ) ) . toBe ( true ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( tous . map ( ( { cle } ) => cle ) ) ;
expect ( groupes ( m ) . map ( ( g ) => Number ( g . dataset . niveau ) ) ) . toEqual ( tous . map ( ( { niveau } ) => niveau ) ) ;
for ( const attendu of tous ) {
expect ( entete ( m , attendu . cle ) , attendu . cle ) . toEqual ( enteteAttendu ( attendu ) ) ;
expect ( idsDuGroupe ( m , attendu . cle ) , attendu . cle ) . toEqual ( lignesVisibles ( [ attendu ] , new Set ( ) ) . map ( ( { id } ) => id ) ) ;
}
expect ( idsAffiches ( m ) ) . toEqual ( lignesVisibles ( attendus , new Set ( ) ) . map ( ( { id } ) => id ) ) ;
expect ( entete ( m , MERLES ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 2 } ) ) ;
expect ( idsDuGroupe ( m , ` ${ MERLES } /statut:ancre ` ) ) . toEqual ( [ ombrelle ] ) ;
expect ( idsDuGroupe ( m , ` ${ MERLES } /statut:mobile ` ) ) . toEqual ( [ pervenche ] ) ;
// Un en-tête de sous-groupe est en retrait de celui de son parent.
expect ( bascule ( m , ` ${ MERLES } /statut:ancre ` ) . getBoundingClientRect ( ) . left ) . toBeGreaterThan ( bascule ( m , MERLES ) . getBoundingClientRect ( ) . left ) ;
await cliquer ( bascule ( m , ` ${ MERLES } /statut:mobile ` ) ) ;
expect ( bascule ( m , ` ${ MERLES } /statut:mobile ` ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ ombrelle ] ) ;
expect ( entete ( m , MERLES ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 2 } ) ) ;
expect ( entete ( m , ` ${ MERLES } /statut:mobile ` ) . compte ) . toBe ( t ( 'participants.groupe.compte' , { compte : 1 } ) ) ;
expect ( bascule ( m , MERLES ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( groupe ( m , ` ${ MERLES } /statut:ancre ` ) ) . toBeNull ( ) ;
expect ( groupe ( m , ` ${ MERLES } /statut:mobile ` ) ) . toBeNull ( ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ ] ) ;
expect ( idsAffiches ( m ) ) . toEqual ( lignesVisibles ( attendus , new Set ( [ MERLES ] ) ) . map ( ( { id } ) => id ) ) ;
// Déplié, le parent rend ses sous-groupes dans l'état où il les a laissés.
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( bascule ( m , ` ${ MERLES } /statut:mobile ` ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' ) ;
expect ( bascule ( m , ` ${ MERLES } /statut:ancre ` ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
// Tout replier replie le premier niveau ; Tout déplier déplie tous les niveaux.
await cliquer ( section ( m ) . querySelector ( '[data-commande="toutReplier"]' ) ) ;
expect ( lignes ( m ) ) . toHaveLength ( 0 ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( attendus . map ( ( { cle } ) => cle ) ) ;
expect ( ouverts ( m ) . every ( ( etat ) => etat === 'false' ) ) . toBe ( true ) ;
// Un parent déplié après Tout replier montre ses sous-groupes dépliés.
await cliquer ( bascule ( m , MERLES ) ) ;
expect ( ouverts ( m ) . filter ( ( etat ) => etat === 'true' ) ) . toHaveLength ( 3 ) ;
expect ( idsDuGroupe ( m , MERLES ) ) . toEqual ( [ ombrelle , pervenche ] ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="toutDeplier"]' ) ) ;
expect ( groupes ( m ) . map ( ( g ) => g . dataset . groupe ) ) . toEqual ( tous . map ( ( { cle } ) => cle ) ) ;
expect ( ouverts ( m ) . every ( ( etat ) => etat === 'true' ) ) . toBe ( true ) ;
expect ( idsAffiches ( m ) ) . toEqual ( lignesVisibles ( attendus , new Set ( ) ) . map ( ( { id } ) => id ) ) ;
await attendreSeance ( m ) ;
expect ( empreinte ( m ) ) . toEqual ( avant ) ;
} ) ;
test ( 'imbriqués : les flèches sautent tout en-tête ; une ligne menée sous un parent et un sous-groupe repliés les déplie' , async ( ) => {
const m = await appartenancePuisStatut ( ) ;
const [ ombrelle , pervenche , lacasse , grisaille , brindelle ] = [ 'Ombrelle' , 'Pervenche' , 'Lacasse' , 'Grisaille' , 'Brindelle' ] . map ( ( nom ) => idDe ( m , nom ) ) ;
await cliquer ( bascule ( m , ` ${ MERLES } /statut:ancre ` ) ) ;
expect ( idsAffiches ( m ) ) . toEqual ( [ brindelle , grisaille , pervenche , lacasse ] ) ;
cellule ( m , grisaille , COLONNES [ 0 ] ) . focus ( ) ;
await touche ( 'ArrowDown' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , pervenche , COLONNES [ 0 ] ) ) ;
await touche ( 'ArrowUp' ) ;
await rendu ( ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , grisaille , COLONNES [ 0 ] ) ) ;
await cliquer ( section ( m ) . querySelector ( '[data-commande="toutDeplier"]' ) ) ;
expect ( idsAffiches ( m ) ) . toContain ( ombrelle ) ;
// Lacasse rejoint les Merles, mobile comme Pervenche : le sous-groupe
// et son parent, tous deux repliés, se déplient.
const destination = ` ${ MERLES } /statut:mobile ` ;
expect ( cheminDe ( groupesGrille ( lignesGrille ( participants ( m ) , { tri : null , filtre : '' } , cleNormalisee ) , [ 'appartenance' , 'statut' ] , {
tri : null ,
cleNormalisee ,
statuts : statuts ( m . seance . etat ( ) . charge ) . parId ,
} ) , pervenche ) ) . toEqual ( [ MERLES , destination ] ) ;
await cliquer ( bascule ( m , destination ) ) ;
await cliquer ( bascule ( m , MERLES ) ) ;
const avant = entrees ( m ) . length ;
cellule ( m , lacasse , 'appartenance' ) . focus ( ) ;
await touche ( 'Enter' ) ;
await rendu ( ) ;
await saisir ( cellule ( m , lacasse , 'appartenance' ) . querySelector ( 'input' ) , CLUB ) ;
await touche ( 'Enter' ) ;
await attendreSeance ( m ) ;
expect ( entrees ( m ) ) . toHaveLength ( avant + 1 ) ;
expect ( bascule ( m , MERLES ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
expect ( bascule ( m , destination ) . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
expect ( idsDuGroupe ( m , destination ) ) . toEqual ( [ pervenche , lacasse ] ) ;
expect ( document . activeElement ) . toBe ( cellule ( m , lacasse , 'appartenance' ) ) ;
} ) ;
} ) ;