Twelve components still compared key names themselves, so the shortcut table could drift from what they actually did; a guard listed them as pending. Each now reads its effect from RACCOURCIS through effetDe, the pending list and its guard are removed, and the generated shortcut table is unchanged. The keys the old guard pinned without a behaviour test (Escape on the plan root with no active element, ArrowLeft in the grid, and those of the participant and table lists) each gain one. Checked: 3852 node, 766 browser, 101 long tests, from the index alone. --- FR --- [REF] interface : chaque touche lue par la table des raccourcis Douze composants comparaient encore eux-mêmes les noms de touches : la table des raccourcis pouvait s'écarter de ce qu'ils faisaient, et une garde les tenait en attente. Chacun lit désormais son effet dans RACCOURCIS par effetDe ; la liste d'attente et sa garde disparaissent, la table engendrée ne change pas. Les touches que l'ancienne garde figeait sans épreuve de comportement (Échap sur le plan sans élément actif, flèche gauche dans la grille, listes des personnes et des tables) en reçoivent chacune une. Vérifié : 3852 node, 766 navigateur, 101 longues, depuis l'index seul. Assisted-by: Claude Opus 5.5
1706 lines
84 KiB
JavaScript
1706 lines
84 KiB
JavaScript
// © 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 : « <b>x</b> & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément <b>', async () => {
|
||
const m = await evenementOuvert();
|
||
const notes = '<b>x</b> & =1+1';
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', notes } });
|
||
await rendu();
|
||
const id = idDe(m, 'Ombrelle');
|
||
expect(cellule(m, id, 'notes').textContent.trim()).toBe(notes);
|
||
m.ui.ouvrirFiche(id);
|
||
await rendu();
|
||
expect(valeurDeFiche(m, 'participants.colonne.notes').textContent).toBe(notes);
|
||
expect(section(m).querySelectorAll('b')).toHaveLength(0);
|
||
});
|
||
|
||
test('titre pressenti : visible dans la grille et sur la fiche', async () => {
|
||
const m = await evenementOuvert({ personnes: [{ nom: 'Pervenche', prenom: 'Théo', titre: 'hôtesse' }] });
|
||
const id = idDe(m, 'Pervenche');
|
||
expect(cellule(m, id, 'titrePressenti').textContent.trim()).toBe('hôtesse');
|
||
cellule(m, id, 'titrePressenti').scrollIntoView({ block: 'center', inline: 'center' });
|
||
expect(visible(cellule(m, id, 'titrePressenti'))).toBe(true);
|
||
m.ui.ouvrirFiche(id);
|
||
await rendu();
|
||
const valeur = valeurDeFiche(m, 'participants.colonne.titrePressenti');
|
||
expect(valeur.textContent).toBe('hôtesse');
|
||
expect(visible(valeur)).toBe(true);
|
||
});
|
||
|
||
test('supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien', async () => {
|
||
const m = await evenementOuvert({ tables: 1, personnes: [{ nom: 'Ombrelle' }, { nom: 'Pervenche' }] });
|
||
const iris = idDe(m, 'Ombrelle');
|
||
const theo = idDe(m, 'Pervenche');
|
||
const table = m.seance.etat().charge.tables[0];
|
||
await m.seance.executer('reserver', { participant: theo, table: table.id, siege: 2, portee: 'tous', tour: null });
|
||
await rendu();
|
||
const avant = entrees(m).length;
|
||
|
||
await cliquer(commandeDeLigne(m, iris, 'supprimerParticipant'));
|
||
await attendreSeance(m);
|
||
aucuneFenetre();
|
||
expect(entrees(m)).toHaveLength(avant + 1);
|
||
expect(participants(m).map((p) => p.id)).toEqual([theo]);
|
||
|
||
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
|
||
const fenetre = document.querySelector('dialog[open]');
|
||
expect(fenetre).not.toBeNull();
|
||
const elements = [...fenetre.querySelectorAll('li')].map((li) => li.textContent);
|
||
expect(elements).toEqual([
|
||
t('participants.suppression.reservation', { numero: table.numero, siege: 2, portee: 'tous', tour: null }),
|
||
]);
|
||
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
|
||
await attendreSeance(m);
|
||
expect(entrees(m)).toHaveLength(avant + 1);
|
||
expect(participants(m).map((p) => p.id)).toEqual([theo]);
|
||
|
||
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
|
||
await cliquer(document.querySelector('dialog[open] [data-commande="confirmer"]'));
|
||
await attendreSeance(m);
|
||
expect(entrees(m)).toHaveLength(avant + 2);
|
||
expect(participants(m)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('le manque de places (§ 5.9, § 4.4)', () => {
|
||
test('deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
|
||
const m = await evenementOuvert({ tables: 2, personnes: NOMS.slice(0, 16).map((nom) => ({ nom })) });
|
||
const executer = vi.spyOn(m.seance, 'executer');
|
||
const avant = entrees(m).length;
|
||
await ajouter(m, { nom: NOMS[16] });
|
||
expect(entrees(m)).toHaveLength(avant + 1);
|
||
expect(participants(m)).toHaveLength(17);
|
||
expect(executer).toHaveBeenCalledTimes(1);
|
||
expect(executer.mock.calls[0][0]).toBe('ajouterParticipant');
|
||
const rendus = await executer.mock.results[0].value;
|
||
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
|
||
expect(attendu).toBe(1);
|
||
const manque = rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
|
||
expect(manque?.details.placesManquantes).toBe(attendu);
|
||
const etat = m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
|
||
expect(etat?.details.placesManquantes).toBe(attendu);
|
||
});
|
||
|
||
test('deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
|
||
const personnes = NOMS.slice(0, 17).map((nom, rang) => ({ nom, exclu: rang === 5 }));
|
||
const m = await evenementOuvert({ tables: 2, personnes });
|
||
const id = idDe(m, NOMS[5]);
|
||
const executer = vi.spyOn(m.seance, 'executer');
|
||
const avant = entrees(m).length;
|
||
await cliquer(commandeDeLigne(m, id, 'reintegrerParticipant'));
|
||
await attendreSeance(m);
|
||
expect(entrees(m)).toHaveLength(avant + 1);
|
||
expect(participants(m).find((p) => p.id === id).exclu).toBe(false);
|
||
expect(executer).toHaveBeenCalledTimes(1);
|
||
expect(executer.mock.calls[0]).toEqual(['reintegrerParticipant', { id }]);
|
||
const rendus = await executer.mock.results[0].value;
|
||
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
|
||
expect(attendu).toBe(1);
|
||
expect(rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(attendu);
|
||
expect(m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(
|
||
attendu,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('la fiche (§ 4.2, § 7.4)', () => {
|
||
test('statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut', async () => {
|
||
const m = await evenementOuvert({
|
||
sieges: 4,
|
||
tours: 3,
|
||
tables: 2,
|
||
personnes: [
|
||
{ nom: 'Ombrelle', prenom: 'Iris' },
|
||
{ nom: 'Pervenche', prenom: 'Théo' },
|
||
{ nom: 'Lacasse', prenom: 'Ondine' },
|
||
{ nom: 'Grisaille' },
|
||
],
|
||
});
|
||
const [t1, t2] = m.seance.etat().charge.tables.map(({ id }) => id);
|
||
const [a, b, c, d] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille'].map((nom) => idDe(m, nom));
|
||
const reserver = (participant, table, portee, tour = null) =>
|
||
m.seance.executer('reserver', { participant, table, siege: null, portee, tour });
|
||
await reserver(a, t1, 'tous');
|
||
for (const tour of [1, 2, 3]) await reserver(b, t1, 'tour', tour);
|
||
await reserver(c, t1, 'tour', 1);
|
||
await reserver(c, t2, 'tour', 2);
|
||
await rendu();
|
||
|
||
const instance = normaliser(configurationDepuisCharge(m.seance.etat().charge));
|
||
const NOMS_STATUTS = new Map([
|
||
[STATUT.ANCRE, 'ancre'],
|
||
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
|
||
[STATUT.MOBILE, 'mobile'],
|
||
]);
|
||
const attendus = [
|
||
[a, 'ancre'],
|
||
[b, 'ancre'],
|
||
[c, 'partiel'],
|
||
[d, 'mobile'],
|
||
];
|
||
for (const [id, statut] of attendus) {
|
||
expect(NOMS_STATUTS.get(instance.statut[instance.indexDe.get(id)])).toBe(statut);
|
||
m.ui.ouvrirFiche(id);
|
||
await rendu();
|
||
expect(fiche(m).dataset.fiche).toBe(String(id));
|
||
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
|
||
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
|
||
}
|
||
});
|
||
|
||
test('ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée', async () => {
|
||
const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: NOMS.slice(0, 7).map((nom) => ({ nom })) });
|
||
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
|
||
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
|
||
const [proposition] = m.seance.etat().charge.propositions;
|
||
await m.seance.executer('retenirProposition', { id: proposition.id });
|
||
// Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire.
|
||
await m.seance.executer('retirerDuTour', { tour: 2, participant: idDe(m, NOMS[2]) });
|
||
await rendu();
|
||
const charge = m.seance.etat().charge;
|
||
const forme = formeNommee(charge.retenu, charge);
|
||
expect(forme.some((ligneForme) => ligneForme.reserve)).toBe(true);
|
||
|
||
const id = idDe(m, NOMS[2]);
|
||
await cliquer(commandeDeLigne(m, id, 'fiche'));
|
||
expect(m.ui.etat().fiche).toBe(id);
|
||
expect(fiche(m).dataset.fiche).toBe(String(id));
|
||
expect(visible(fiche(m))).toBe(true);
|
||
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
|
||
expect(fiche(m)).toBeNull();
|
||
expect(m.ui.etat().section).toBe('participants');
|
||
|
||
// Chaque personne : l'itinéraire de la fiche, ligne par ligne.
|
||
expect(charge.participants).toHaveLength(7);
|
||
for (const { id: personne } of charge.participants) {
|
||
m.ui.ouvrirFiche(personne);
|
||
await rendu();
|
||
expect(visible(fiche(m))).toBe(true);
|
||
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
|
||
expect(tableau).toBeDefined();
|
||
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
|
||
const attendus = forme
|
||
.filter((l) => l.participant === personne)
|
||
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
|
||
expect(attendus).toHaveLength(3);
|
||
expect(lus).toEqual(attendus);
|
||
}
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
});
|