// © 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');
});
});
});