// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // La liste des participants dans un vrai moteur de rendu (§ 4, § 4.2, § 4.4, // § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance // collante, la grille au clavier, le tri, le filtre et le compte, // l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche // et l'itinéraire, l'export, le collage, et la liste groupée par une // colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés, et les // conflits du placement montré sous ses groupes. Chaque épreuve monte App par // monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque // écriture se relève (trace) et chaque entrée se compte dans le journal. Les // gestes passent par des entrées que Chromium traite comme réelles // (test/navigateur/entrees.js). Les noms d'épreuve sont inventés. // // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { cdp } from 'vitest/browser'; import { flushSync, tick } from 'svelte'; import { bilanChangementDefaut } from '../../application/bilans.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { creerTraduction } from '../../application/traduction.js'; import { apercevoir, cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js'; import { statuts } from '../../application/places.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js'; import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { serialiser } from '../../stockage/canonique.js'; import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { formeNommee } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { pointer, touche } from '../../../test/navigateur/entrees.js'; import { visible } from '../../../test/navigateur/rendu.js'; import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url'; import { TABLES_INTERFACE } from '../libelles.js'; import { COLONNES, GROUPEMENTS, cheminDe, cleDeGroupement, cleDeStatut, groupesGrille, lignesGrille, lignesVisibles } from './grille.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const EVENEMENT = 'Soirée des Merles'; const CLUB = 'Club des Merles'; // Des noms inventés, hors du réservoir du générateur. const NOMS = [ 'Quenouillet', 'Brindelle', 'Calvaille', 'Doucemer', 'Esquirol', 'Faverolle', 'Glanduc', 'Houblonnet', 'Isardin', 'Joncaille', 'Kerbouet', 'Lisonnet', 'Moucheron', 'Nivelette', 'Orbelin', 'Pistachon', 'Rouvrelle', ]; // Les montages de l'épreuve en cours, démontés après elle ; le collecteur // n'a rien reçu pendant l'épreuve. let montes = []; let erreursAvant = 0; beforeEach(() => { erreursAvant = globalThis.__gttErreurs?.length ?? 0; }); afterEach(() => { for (const m of montes.reverse()) m.demonter(); montes = []; vi.restoreAllMocks(); const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? []; expect(recues, 'erreurs reçues par le collecteur').toEqual([]); }); async function rendu() { flushSync(); await tick(); } // Attend que la file de la séance ait passé ce que le geste y a posé, par // une lecture admise dans tout mode qui n'écrit rien, puis le rendu. async function attendreSeance(m) { await m.seance.formeNommee(null).catch(() => undefined); await rendu(); await rendu(); } // Un CSV d'épreuve, séparé par « ; » : nom, prénom, appartenance, exclu, // titre pressenti, notes. const csv = (personnes) => [ 'nom;prenom;appartenance;exclu;titre_pressenti;notes', ...personnes.map((p) => [p.nom, p.prenom ?? '', p.appartenance ?? '', p.exclu ? 'oui' : '', p.titre ?? '', p.notes ?? ''].join(';')), ].join('\n'); // Importe un texte par la commande, comme le ferait l'aperçu. 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 }); } // Maj+Entrée réel, le saut de ligne d'une zone de texte ; 8 est le masque // de Maj. async function majEntree() { const commun = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, modifiers: 8 }; await cdp().send('Input.dispatchKeyEvent', { type: 'keyDown', ...commun, text: '\r', unmodifiedText: '\r' }); await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun }); await rendu(); } // La valeur de la fiche que nomme l'étiquette de clé donnée. 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); } // Retient les ajouts de ligne au journal du système simulé jusqu'à // liberer() : l'écriture d'un geste reste en cours, comme sur un support // lent, sans délai ni minuterie. L'application lit la primitive sur fs au // moment du geste. function retenirJournal(m) { const ajouterLigne = m.fs.ajouterLigne; let liberer; const porte = new Promise((resoudre) => { liberer = resoudre; }); m.fs.ajouterLigne = async (...parametres) => { await porte; return ajouterLigne.apply(m.fs, parametres); }; return () => liberer(); } const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0); describe('la saisie (§ 4, § 10.2)', () => { test('seul le nom : « Pervenche » enregistre une personne aux autres champs null, en une entrée ; un nom tapé en NFD est enregistré en NFC', async () => { const m = await evenementOuvert(); const avant = entrees(m).length; await ajouter(m, { nom: 'Pervenche' }); expect(entrees(m)).toHaveLength(avant + 1); const [{ id, ...champs }] = participants(m); expect(Number.isInteger(id)).toBe(true); expect(champs).toEqual({ nom: 'Pervenche', prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, }); expect(champ(m, 'nom').value).toBe(''); const decompose = 'Ombre\u{0301}lle'; expect(decompose.length).toBe(9); await ajouter(m, { nom: decompose }); expect(entrees(m)).toHaveLength(avant + 2); expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle'); }); test('une seconde Entrée pendant l’écriture n’ajoute rien ; ce qui est tapé pendant l’écriture reste, ce qui a été envoyé se vide (§ 10.2)', async () => { const m = await evenementOuvert(); const avant = entrees(m).length; const liberer = retenirJournal(m); const ajout = () => section(m).querySelector('form [data-commande="ajouter"]'); expect(ajout()).not.toBeNull(); const styleRepos = getComputedStyle(ajout()).borderTopStyle; expect(ajout().getAttribute('aria-busy')).toBeNull(); expect(ajout().getAttribute('aria-disabled')).toBeNull(); await saisir(champ(m, 'nom'), 'Ombrelle'); await saisir(champ(m, 'notes'), 'arrive tard'); champ(m, 'nom').focus(); await touche('Enter'); await rendu(); // L'écriture retenue : « Ajouter » se dit occupé, d'un filet tireté. expect(ajout().getAttribute('aria-busy')).toBe('true'); expect(ajout().getAttribute('aria-disabled')).toBe('true'); expect(getComputedStyle(ajout()).borderTopStyle).toBe('dashed'); expect(getComputedStyle(ajout()).borderTopStyle).not.toBe(styleRepos); await touche('Enter'); await rendu(); // La personne suivante se tape pendant l'écriture de la première. await saisir(champ(m, 'prenom'), 'Théo'); await saisir(champ(m, 'nom'), 'Pervenche'); liberer(); await vi.waitFor(() => expect(participants(m)).toHaveLength(1)); await attendreSeance(m); await rendu(); expect(ajout().getAttribute('aria-busy')).toBeNull(); expect(ajout().getAttribute('aria-disabled')).toBeNull(); expect(getComputedStyle(ajout()).borderTopStyle).toBe(styleRepos); expect(participants(m)).toHaveLength(1); expect(participants(m)[0]).toMatchObject({ nom: 'Ombrelle', prenom: null, notes: 'arrive tard' }); expect(entrees(m)).toHaveLength(avant + 1); expect(champ(m, 'nom').value).toBe('Pervenche'); expect(champ(m, 'prenom').value).toBe('Théo'); expect(champ(m, 'notes').value).toBe(''); // La saisie gardée s'envoie à son tour. champ(m, 'nom').focus(); await touche('Enter'); await attendreSeance(m); expect(participants(m).map((p) => [p.nom, p.prenom])).toEqual([ ['Ombrelle', null], ['Pervenche', 'Théo'], ]); }); test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => { const m = await evenementOuvert(); const trace = m.trace.length; const etat = texteEtat(m); await ajouter(m, { nom: ' ' }); const entree = champ(m, 'nom'); expect(entree.getAttribute('aria-invalid')).toBe('true'); const message = document.getElementById(entree.getAttribute('aria-describedby')); expect(message).not.toBeNull(); expect(message.textContent).toContain(t('NOM_REQUIS', {})); expect(visible(message)).toBe(true); expect(ecart(entree, message)).toBeLessThanOrEqual(16); aucuneFenetre(); expect(m.trace.length).toBe(trace); expect(texteEtat(m)).toBe(etat); expect(participants(m)).toHaveLength(0); }); test('appartenance collante : trois personnes portent « Club des Merles » ; le champ vidé, la quatrième n’en porte aucune', async () => { const m = await evenementOuvert(); await ajouter(m, { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }); expect(champ(m, 'appartenance').value).toBe(CLUB); expect(champ(m, 'prenom').value).toBe(''); await ajouter(m, { nom: 'Pervenche' }); await ajouter(m, { nom: 'Lacasse' }); expect(participants(m).map((p) => [p.nom, p.prenom, p.appartenance])).toEqual([ ['Ombrelle', 'Iris', CLUB], ['Pervenche', null, CLUB], ['Lacasse', null, CLUB], ]); await saisir(champ(m, 'appartenance'), ''); await ajouter(m, { nom: 'Grisaille' }); expect(participants(m).at(-1).appartenance).toBeNull(); }); test('l’appartenance collante survit à un aller-retour par le plan et par « À propos » ; un autre événement repart d’un champ vide', async () => { const m = await evenementOuvert(); await ajouter(m, { nom: 'Ombrelle', appartenance: CLUB }); m.ui.allerA('plan'); await rendu(); m.ui.allerA('participants'); await rendu(); expect(champ(m, 'appartenance').value).toBe(CLUB); m.ui.ouvrirApropos(); await rendu(); m.ui.fermerApropos(); await rendu(); expect(champ(m, 'appartenance').value).toBe(CLUB); await ajouter(m, { nom: 'Pervenche' }); expect(participants(m).at(-1).appartenance).toBe(CLUB); await m.seance.fermer(); await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 2 }); m.ui.allerA('participants'); await rendu(); expect(champ(m, 'appartenance').value).toBe(''); }); test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => { 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('Flèche gauche sur une cellule hors édition mène à la cellule précédente de la ligne, et s’arrête à la première', async () => { const m = await evenementOuvert({ personnes: TROIS }); const premier = idsAffiches(m)[0]; cellule(m, premier, COLONNES[1]).focus(); await touche('ArrowLeft'); await rendu(); expect(document.activeElement).toBe(cellule(m, premier, COLONNES[0])); await touche('ArrowLeft'); await rendu(); expect(document.activeElement).toBe(cellule(m, premier, COLONNES[0])); }); 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('Entrée sur une saisie qui retire la ligne du filtre : la même colonne de la personne qui la suivait prend le focus, non le document', 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); cellule(m, premier, 'appartenance').focus(); await touche('Enter'); await rendu(); await saisir(cellule(m, premier, 'appartenance').querySelector('input'), 'Cercle des Pinsons'); await touche('Enter'); await attendreSeance(m); expect(idsAffiches(m)).toEqual([second]); await vi.waitFor(() => expect(document.activeElement).toBe(cellule(m, second, 'appartenance'))); }); test('Tab sur une saisie refusée : une seule commande, l’édition et le focus restent dans le champ, le refus contre lui', async () => { const m = await evenementOuvert({ personnes: TROIS }); const premier = idsAffiches(m)[0]; cellule(m, premier, 'nom').focus(); await touche('Enter'); await rendu(); const champ = cellule(m, premier, 'nom').querySelector('input'); await saisir(champ, ' '); const executer = vi.spyOn(m.seance, 'executer'); const avant = entrees(m).length; await touche('Tab'); await attendreSeance(m); await rendu(); expect(executer).toHaveBeenCalledTimes(1); const refuse = cellule(m, premier, 'nom').querySelector('input'); expect(refuse).not.toBeNull(); expect(document.activeElement).toBe(refuse); expect(refuse.getAttribute('aria-invalid')).toBe('true'); expect(cellule(m, premier, 'nom').querySelector('[data-refus]')).not.toBeNull(); expect(entrees(m)).toHaveLength(avant); }); 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('hors édition, Maj+Tab mène de la première cellule d’une ligne à la dernière de la ligne d’avant, Tab l’y ramène', async () => { const m = await evenementOuvert({ personnes: TROIS }); const [premier, second] = idsAffiches(m); cellule(m, second, COLONNES[0]).focus(); await rendu(); await majTab(); await rendu(); expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1))); expect(cellule(m, premier, COLONNES.at(-1)).querySelector('input, textarea')).toBeNull(); await touche('Tab'); await rendu(); expect(document.activeElement).toBe(cellule(m, second, COLONNES[0])); }); test('hors édition, Échap efface le refus de la cellule : Entrée en lecture le pose sans ouvrir de champ, la cellule garde le focus', async () => { const m = await evenementOuvert({ personnes: TROIS }); await m.seance.passerEnLecture(); await rendu(); const cible = cellule(m, idsAffiches(m)[0], 'nom'); cible.focus(); await touche('Enter'); await rendu(); expect(cible.querySelector('input')).toBeNull(); expect(cible.querySelector('[data-refus]')).not.toBeNull(); expect(document.activeElement).toBe(cible); await touche('Escape'); await rendu(); expect(cible.querySelector('[data-refus]')).toBeNull(); expect(cible.hasAttribute('aria-describedby')).toBe(false); }); test('une note de deux lignes, retouchée dans la grille, garde son saut de ligne', async () => { const m = await evenementOuvert(); await m.seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine', notes: 'arrive tard\nrepart tôt' } }); await rendu(); const id = idDe(m, 'Lacasse'); const avant = entrees(m).length; cellule(m, id, 'notes').focus(); await touche('Enter'); await rendu(); for (const caractere of ' x') await touche(caractere); await touche('Enter'); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x'); }); test('une note saisie sur deux lignes : Maj+Entrée insère le saut de ligne, dans la grille comme au formulaire ; Entrée valide (§ 10.2, § 10.3)', async () => { const m = await evenementOuvert(); // Au formulaire : la note de deux lignes, puis Entrée dans les notes envoie. await saisir(champ(m, 'nom'), 'Lacasse'); champ(m, 'notes').focus(); for (const caractere of 'a') await touche(caractere); await majEntree(); for (const caractere of 'b') await touche(caractere); await touche('Enter'); await attendreSeance(m); expect(participants(m)).toHaveLength(1); expect(participants(m)[0].notes).toBe('a\nb'); // Dans la grille : la cellule des notes, éditée, reçoit une troisième ligne. const id = idDe(m, 'Lacasse'); const avant = entrees(m).length; cellule(m, id, 'notes').focus(); await touche('Enter'); await rendu(); await majEntree(); for (const caractere of 'c') await touche(caractere); await rendu(); expect(entrees(m)).toHaveLength(avant); await touche('Enter'); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m)[0].notes).toBe('a\nb\nc'); }); test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => { 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 : « x & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément ', async () => { const m = await evenementOuvert(); const notes = 'x & =1+1'; await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', notes } }); await rendu(); const id = idDe(m, 'Ombrelle'); expect(cellule(m, id, 'notes').textContent.trim()).toBe(notes); m.ui.ouvrirFiche(id); await rendu(); expect(valeurDeFiche(m, 'participants.colonne.notes').textContent).toBe(notes); expect(section(m).querySelectorAll('b')).toHaveLength(0); }); test('titre pressenti : visible dans la grille et sur la fiche', async () => { const m = await evenementOuvert({ personnes: [{ nom: 'Pervenche', prenom: 'Théo', titre: 'hôtesse' }] }); const id = idDe(m, 'Pervenche'); expect(cellule(m, id, 'titrePressenti').textContent.trim()).toBe('hôtesse'); cellule(m, id, 'titrePressenti').scrollIntoView({ block: 'center', inline: 'center' }); expect(visible(cellule(m, id, 'titrePressenti'))).toBe(true); m.ui.ouvrirFiche(id); await rendu(); const valeur = valeurDeFiche(m, 'participants.colonne.titrePressenti'); expect(valeur.textContent).toBe('hôtesse'); expect(visible(valeur)).toBe(true); }); test('supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien', async () => { const m = await evenementOuvert({ tables: 1, personnes: [{ nom: 'Ombrelle' }, { nom: 'Pervenche' }] }); const iris = idDe(m, 'Ombrelle'); const theo = idDe(m, 'Pervenche'); const table = m.seance.etat().charge.tables[0]; await m.seance.executer('reserver', { participant: theo, table: table.id, siege: 2, portee: 'tous', tour: null }); await rendu(); const avant = entrees(m).length; await cliquer(commandeDeLigne(m, iris, 'supprimerParticipant')); await attendreSeance(m); aucuneFenetre(); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m).map((p) => p.id)).toEqual([theo]); await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant')); const fenetre = document.querySelector('dialog[open]'); expect(fenetre).not.toBeNull(); const elements = [...fenetre.querySelectorAll('li')].map((li) => li.textContent); expect(elements).toEqual([ t('participants.suppression.reservation', { numero: table.numero, siege: 2, portee: 'tous', tour: null }), ]); await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m).map((p) => p.id)).toEqual([theo]); await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant')); await cliquer(document.querySelector('dialog[open] [data-commande="confirmer"]')); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 2); expect(participants(m)).toEqual([]); }); }); describe('le manque de places (§ 5.9, § 4.4)', () => { test('deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1', async () => { const m = await evenementOuvert({ tables: 2, personnes: NOMS.slice(0, 16).map((nom) => ({ nom })) }); const executer = vi.spyOn(m.seance, 'executer'); const avant = entrees(m).length; await ajouter(m, { nom: NOMS[16] }); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m)).toHaveLength(17); expect(executer).toHaveBeenCalledTimes(1); expect(executer.mock.calls[0][0]).toBe('ajouterParticipant'); const rendus = await executer.mock.results[0].value; const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge))); expect(attendu).toBe(1); const manque = rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES'); expect(manque?.details.placesManquantes).toBe(attendu); const etat = m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES'); expect(etat?.details.placesManquantes).toBe(attendu); }); test('deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1', async () => { const personnes = NOMS.slice(0, 17).map((nom, rang) => ({ nom, exclu: rang === 5 })); const m = await evenementOuvert({ tables: 2, personnes }); const id = idDe(m, NOMS[5]); const executer = vi.spyOn(m.seance, 'executer'); const avant = entrees(m).length; await cliquer(commandeDeLigne(m, id, 'reintegrerParticipant')); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m).find((p) => p.id === id).exclu).toBe(false); expect(executer).toHaveBeenCalledTimes(1); expect(executer.mock.calls[0]).toEqual(['reintegrerParticipant', { id }]); const rendus = await executer.mock.results[0].value; const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge))); expect(attendu).toBe(1); expect(rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(attendu); expect(m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe( attendu, ); }); }); describe('la fiche (§ 4.2, § 7.4)', () => { test('statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut', async () => { const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: [ { nom: 'Ombrelle', prenom: 'Iris' }, { nom: 'Pervenche', prenom: 'Théo' }, { nom: 'Lacasse', prenom: 'Ondine' }, { nom: 'Grisaille' }, ], }); const [t1, t2] = m.seance.etat().charge.tables.map(({ id }) => id); const [a, b, c, d] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille'].map((nom) => idDe(m, nom)); const reserver = (participant, table, portee, tour = null) => m.seance.executer('reserver', { participant, table, siege: null, portee, tour }); await reserver(a, t1, 'tous'); for (const tour of [1, 2, 3]) await reserver(b, t1, 'tour', tour); await reserver(c, t1, 'tour', 1); await reserver(c, t2, 'tour', 2); await rendu(); const instance = normaliser(configurationDepuisCharge(m.seance.etat().charge)); const NOMS_STATUTS = new Map([ [STATUT.ANCRE, 'ancre'], [STATUT.PARTIELLEMENT_FIXE, 'partiel'], [STATUT.MOBILE, 'mobile'], ]); const attendus = [ [a, 'ancre'], [b, 'ancre'], [c, 'partiel'], [d, 'mobile'], ]; for (const [id, statut] of attendus) { expect(NOMS_STATUTS.get(instance.statut[instance.indexDe.get(id)])).toBe(statut); m.ui.ouvrirFiche(id); await rendu(); expect(fiche(m).dataset.fiche).toBe(String(id)); expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut))); expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0); } }); test('ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée', async () => { const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: NOMS.slice(0, 7).map((nom) => ({ nom })) }); await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true }); await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 }); const [proposition] = m.seance.etat().charge.propositions; await m.seance.executer('retenirProposition', { id: proposition.id }); // Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire. await m.seance.executer('retirerDuTour', { tour: 2, participant: idDe(m, NOMS[2]) }); await rendu(); const charge = m.seance.etat().charge; const forme = formeNommee(charge.retenu, charge); expect(forme.some((ligneForme) => ligneForme.reserve)).toBe(true); const id = idDe(m, NOMS[2]); await cliquer(commandeDeLigne(m, id, 'fiche')); expect(m.ui.etat().fiche).toBe(id); expect(fiche(m).dataset.fiche).toBe(String(id)); expect(visible(fiche(m))).toBe(true); await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]')); expect(fiche(m)).toBeNull(); expect(m.ui.etat().section).toBe('participants'); // Chaque personne : l'itinéraire de la fiche, ligne par ligne. expect(charge.participants).toHaveLength(7); for (const { id: personne } of charge.participants) { m.ui.ouvrirFiche(personne); await rendu(); expect(visible(fiche(m))).toBe(true); const tableau = tableauDeFiche(m, 'participants.fiche.itineraire'); expect(tableau).toBeDefined(); const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim())); const attendus = forme .filter((l) => l.participant === personne) .map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)])); expect(attendus).toHaveLength(3); expect(lus).toEqual(attendus); } }); test('retenu à 3 tours, l’événement passé à 4 : la fiche garde les tours du retenu, et dit le quatrième hors du retenu (§ 9)', async () => { const m = await evenementOuvert({ sieges: 3, tours: 3, tables: 2, personnes: NOMS.slice(0, 6).map((nom) => ({ nom })) }); await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true }); await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 }); const [proposition] = m.seance.etat().charge.propositions; await m.seance.executer('retenirProposition', { id: proposition.id }); await m.seance.executer('changerTours', { tours: 4 }); await rendu(); const charge = m.seance.etat().charge; expect(charge.evenement.tours).toBe(4); expect(charge.retenu.placement).toHaveLength(3); const forme = formeNommee(charge.retenu, charge); expect(charge.participants).toHaveLength(6); for (const { id: personne } of charge.participants) { m.ui.ouvrirFiche(personne); await rendu(); expect(fiche(m).textContent).not.toContain(t('participants.fiche.aucunRetenu')); const tableau = tableauDeFiche(m, 'participants.fiche.itineraire'); expect(tableau).toBeDefined(); const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim())); const attendus = forme .filter((l) => l.participant === personne) .map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)])); expect(attendus).toHaveLength(3); expect(lus).toEqual([...attendus, ['4', t('participants.fiche.horsRetenu')]]); } }); test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => { 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); }); }); 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 }); // Deux orthographes du même groupe : la plus ancienne personne le nomme. await m.seance.executer('modifierParticipant', { id: idDe(m, 'Pervenche'), champs: { appartenance: 'CLUB DES MERLES' } }); await grouperPar(m, 'appartenance'); const libelleEntier = entete(m, MERLES).libelle; expect(participants(m).find(({ nom }) => nom === 'Pervenche').appartenance).toBe('CLUB DES MERLES'); expect(libelleEntier).toBe(CLUB); 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 })); // Le libellé ne suit pas le filtre : chacune des deux orthographes seule // montrée, le groupe garde celui de la liste entière. expect(entete(m, MERLES).libelle).toBe(libelleEntier); await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pervenche'); expect(entete(m, MERLES).libelle).toBe(libelleEntier); // Tout replier vise chaque groupe, même ceux que le filtre cache. await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pinsons'); await cliquer(section(m).querySelector('[data-commande="toutReplier"]')); await saisir(section(m).querySelector('[data-champ="filtre"]'), ''); expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']); expect(ouverts(m)).toEqual(['false', 'false', 'false']); expect(lignes(m)).toHaveLength(0); await cliquer(section(m).querySelector('[data-commande="toutDeplier"]')); 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]); // Seul l'en-tête d'un groupe du premier niveau couvre son corps // (rowgroup) ; celui d'un sous-groupe ne nomme que sa ligne (§ 14.5). for (const g of groupes(m)) { expect(g.querySelector('th').getAttribute('scope'), g.dataset.groupe).toBe(g.dataset.niveau === '1' ? 'rowgroup' : 'row'); } // 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')); }); test('groupée par exclusion, Entrée sur l’exclusion : la ligne change de groupe, garde le focus, déplie son groupe', async () => { const m = await evenementOuvert({ tables: 1, personnes: CINQ }); await grouperPar(m, 'exclu'); const lacasse = idDe(m, 'Lacasse'); const avant = entrees(m).length; cellule(m, lacasse, 'exclu').focus(); await touche('Enter'); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 1); expect(participants(m).find(({ id }) => id === lacasse).exclu).toBe(true); expect(idsDuGroupe(m, 'exclu:oui')).toContain(lacasse); expect(document.activeElement).toBe(cellule(m, lacasse, 'exclu')); // Vers le groupe des présentes, replié : il se déplie, la cellule garde // le focus. await cliquer(bascule(m, 'exclu:non')); expect(bascule(m, 'exclu:non').getAttribute('aria-expanded')).toBe('false'); cellule(m, lacasse, 'exclu').focus(); await touche('Enter'); await attendreSeance(m); expect(entrees(m)).toHaveLength(avant + 2); expect(participants(m).find(({ id }) => id === lacasse).exclu).toBe(false); expect(bascule(m, 'exclu:non').getAttribute('aria-expanded')).toBe('true'); expect(idsDuGroupe(m, 'exclu:non')).toContain(lacasse); expect(document.activeElement).toBe(cellule(m, lacasse, 'exclu')); }); describe('les conflits du placement montré (§ 7.5)', () => { // Deux tables de trois places, numérotées 4 (identifiant 1) et 1 // (identifiant 2), deux tours. 1 et 2 du Club des Merles, 3 et 4 du // Cercle des Pinsons, 5 et 6 sans appartenance, 7 des Pinsons, exclue. const SEPT = [ ['Ombrelle', 'Iris', CLUB], ['Fontaine', 'Noé', CLUB], ['Pervenche', 'Théo', 'Cercle des Pinsons'], ['Lacasse', 'Ondine', 'Cercle des Pinsons'], ['Grisaille', null, null], ['Bruyère', 'Anouk', null], ['Sarcelle', 'Capucine', 'Cercle des Pinsons'], ]; // Proposition 1 : au tour 1, 1 et 2, collègues, à la table 4 ; au tour // 2, 4 et 5 se revoient à la table 4, 2 et 3 à la table 1, 1 revient à // la table 4 et 6 à la table 1. Proposition 2 : au tour 2, 2 et 3 se // revoient à la table 4 et y reviennent, 4 et 5 de même à la table 1. const PLANS = [ [ [[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]], ], [ [[1, 2, 3], [4, 5, 6]], [[2, 3, 6], [1, 4, 5]], ], ]; const TIRET = '\u{2014}'; const planFichier = (tours) => ({ siegesAttribues: false, tables: [1, 2], capacites: [3, 3], tours: tours.length, participants: [1, 2, 3, 4, 5, 6], placement: tours.map((listes) => ({ sieges: structuredClone(listes), reserve: [] })), }); function chargeSept() { const charge = creerCharge({ id: 'evt-conflits', nom: EVENEMENT, siegesParDefaut: 3, tours: 2 }); charge.participants = SEPT.map(([nom, prenom, appartenance], rang) => ({ id: rang + 1, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: rang === 6, })); charge.tables = [ { id: 1, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, { id: 2, numero: 1, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } }, ]; charge.propositions = PLANS.map((tours, rang) => ({ id: rang + 1, graine: rang + 1, arret: 40, historique: 1000, produitVersion: VERSION.affichee, ...planFichier(tours), })); charge.prochainsIds = { participant: 8, table: 3, proposition: 3 }; charge.evenement.etat = 'propose'; return charge; } // L'événement de la charge, ouvert depuis le disque simulé, en écriture, // la section des participants ouverte. async function ouvrirSept() { const texte = serialiser(chargeSept(), { revision: 1, produitVersion: VERSION.affichee }); const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] }); montes.push(m); await m.seance.ouvrir('soiree'); await m.seance.passerEnEcriture(); m.ui.allerA('participants'); await rendu(); return m; } // Un geste de la ligne d'une proposition dans sa section, puis le retour // aux participants par la navigation des sections. async function gesteDeProposition(m, id, nom) { m.ui.allerA('propositions'); await rendu(); await cliquer(m.racine.querySelector(`[data-section="propositions"] [data-proposition="${id}"] [data-commande="${nom}"]`)); await attendreSeance(m); await cliquer(m.racine.querySelector('[data-commande="section"][data-cible="participants"]')); expect(section(m)).not.toBeNull(); } const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); const marqueDeGroupe = (m, cle) => groupe(m, cle).querySelector('[data-groupe-conflits]'); const conflitsDuGroupe = (m, cle) => [...grille(m).querySelectorAll(`[data-conflits="${cle}"] [data-conflit]`)].map(texteDe); const marqueDePersonne = (m, id) => ligne(m, id).querySelector('[data-conflit-personne]'); const toutesLesMarques = (m) => grille(m).querySelectorAll('[data-groupe-conflits], [data-conflits], [data-conflit], [data-conflit-personne]'); // Ce que montre la liste groupée par appartenance sous la proposition 1, // ou sous un retenu du même plan. const SOUS_LE_PLAN_1 = { [MERLES]: [ `Tour 1, table 4 : Iris Ombrelle et Noé Fontaine ${TIRET} même appartenance`, `Tour 2, table 4 : Iris Ombrelle ${TIRET} revient à la table 4 (déjà au tour 1)`, `Tour 2, table 1 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, ], [PINSONS]: [ `Tour 2, table 4 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, `Tour 2, table 1 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, ], vide: [ `Tour 2, table 4 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, `Tour 2, table 1 : Anouk Bruyère ${TIRET} revient à la table 1 (déjà au tour 1)`, ], }; async function attendreSousLePlan1(m) { for (const [cle, attendues] of Object.entries(SOUS_LE_PLAN_1)) { expect(texteDe(marqueDeGroupe(m, cle)), cle).toBe(t('participants.conflit.groupe', { nombre: attendues.length })); expect(conflitsDuGroupe(m, cle), cle).toEqual(attendues); } expect(texteDe(marqueDePersonne(m, 1))).toBe('en conflit : même appartenance, revient à une table'); expect(texteDe(marqueDePersonne(m, 3))).toBe('en conflit : se revoient'); expect(marqueDePersonne(m, 7)).toBeNull(); } test('voir la proposition 1 : marque et nombre par groupe, une ligne par conflit, dans chaque groupe touché ; rien ne s’écrit', async () => { const m = await ouvrirSept(); await gesteDeProposition(m, 1, 'voir'); expect(m.ui.etat().proposition).toBe(1); const avant = { journal: texteJournal(m), etat: texteEtat(m) }; await grouperPar(m, 'appartenance'); expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']); await attendreSousLePlan1(m); // La marque se lit en texte, dans une forme : un cadre de trait plein, // non une seule couleur. const marque = getComputedStyle(marqueDeGroupe(m, MERLES)); expect(marque.borderTopStyle).toBe('solid'); expect(Number.parseFloat(marque.borderTopWidth)).toBeGreaterThanOrEqual(2); // Les lignes de conflit d'un groupe précèdent ses lignes de personnes. const premiere = grille(m).querySelector(`[data-conflits="${MERLES}"]`); expect(premiere.compareDocumentPosition(ligne(m, 1)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(premiere.compareDocumentPosition(groupe(m, MERLES)) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); // Replié, le groupe garde sa marque et retire ses lignes de conflit. await cliquer(bascule(m, MERLES)); expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); expect(conflitsDuGroupe(m, MERLES)).toEqual([]); expect({ journal: texteJournal(m), etat: texteEtat(m) }).toEqual(avant); }); test('imbriqués : un en-tête compte son sous-arbre, les lignes vont au plus étroit groupe', async () => { const m = await ouvrirSept(); await gesteDeProposition(m, 1, 'voir'); await grouperPar(m, 'appartenance'); await grouperPar(m, 'prenom', 2); expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); expect(conflitsDuGroupe(m, MERLES)).toEqual([]); const [iris, noe] = ['texte:iris', 'texte:noe'].map((segment) => `${MERLES}/${segment}`); expect(texteDe(marqueDeGroupe(m, iris))).toBe(t('participants.conflit.groupe', { nombre: 2 })); expect(conflitsDuGroupe(m, iris)).toEqual([SOUS_LE_PLAN_1[MERLES][0], SOUS_LE_PLAN_1[MERLES][1]]); expect(conflitsDuGroupe(m, noe)).toEqual([SOUS_LE_PLAN_1[MERLES][0], SOUS_LE_PLAN_1[MERLES][2]]); // Capucine Sarcelle, exclue, n'est dans aucun conflit : son groupe n'a // pas de marque. expect(marqueDeGroupe(m, `${PINSONS}/texte:capucine`)).toBeNull(); }); test('retenir la 1 : mêmes marques, lues sur le retenu ; voir la 2 montre les siennes', async () => { const m = await ouvrirSept(); await gesteDeProposition(m, 1, 'retenir'); expect(m.seance.etat().charge.retenu?.proposition).toBe(1); expect(m.ui.etat().proposition).toBeNull(); await grouperPar(m, 'appartenance'); await attendreSousLePlan1(m); await gesteDeProposition(m, 2, 'voir'); expect(m.ui.etat().proposition).toBe(2); await grouperPar(m, 'appartenance'); expect(texteDe(marqueDeGroupe(m, PINSONS))).toBe(t('participants.conflit.groupe', { nombre: 4 })); expect(conflitsDuGroupe(m, PINSONS)).toEqual([ `Tour 2, table 4 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, `Tour 2, table 4 : Théo Pervenche ${TIRET} revient à la table 4 (déjà au tour 1)`, `Tour 2, table 1 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, `Tour 2, table 1 : Ondine Lacasse ${TIRET} revient à la table 1 (déjà au tour 1)`, ]); }); test('une règle désactivée retire ses conflits des groupes et des personnes', async () => { const m = await ouvrirSept(); await gesteDeProposition(m, 1, 'voir'); await grouperPar(m, 'appartenance'); expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); await m.seance.executer('modifierReglage', { cle: 'separerAppartenances', valeur: false }); await attendreSeance(m); expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 2 })); expect(conflitsDuGroupe(m, MERLES)).toEqual(SOUS_LE_PLAN_1[MERLES].slice(1)); expect(texteDe(marqueDePersonne(m, 1))).toBe('en conflit : revient à une table'); }); test('ni proposition montrée ni retenu : aucune marque ; sans groupement, rien ne change', async () => { const m = await ouvrirSept(); expect(m.ui.etat().proposition).toBeNull(); await grouperPar(m, 'appartenance'); expect(groupes(m)).toHaveLength(3); expect(toutesLesMarques(m)).toHaveLength(0); await gesteDeProposition(m, 1, 'voir'); expect(m.ui.etat().proposition).toBe(1); expect(selecteur(m).value).toBe(''); expect(lignes(m)).toHaveLength(7); expect(toutesLesMarques(m)).toHaveLength(0); expect(texteDe(cellule(m, 1, 'nom'))).toBe('Ombrelle'); }); }); });