gestion_table_tournante_libre/src/interface/primitives/primitives.navigateur.test.js
Mathieu Benoit 3028fca2ed [FIX] interface: ghost labels, keyboard, valid HTML, closed data-* list
The drag ghost names what releasing at the point changes, floor included.
Escape on a seat returns to its table, a refused Tab stays put, the legend
toggle exposes aria-expanded, and the header markup is valid HTML. Every
data-* attribute the interface sets belongs to one closed list that a guard
reads. The tree guards follow helpers a test imports, and the reporter says
the whole series stays watched under coverage on a small machine.
Checked: every new test red first; 3001 node, 635 browser and 63 long tests
green from the index alone.

--- FR ---

[FIX] interface : fantôme nommé, clavier, HTML valide, data-* fermés

Le fantôme du glisser nomme ce que relâcher au point modifie, sol compris.
Échap sur un siège remonte à sa table, un Tab refusé reste en place, la
bascule de la légende expose aria-expanded, et la tête est du HTML valide.
Chaque attribut data-* que l'interface pose appartient à une liste fermée
qu'une garde lit. Les gardes de l'arbre suivent les aides qu'une épreuve
importe, et le rapporteur dit la série entière surveillée sous couverture.
Vérifié : chaque épreuve rouge d'abord ; 3001 node, 635 navigateur et 63
longues vertes depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 04:02:03 -04:00

1187 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des primitives dans un vrai moteur de rendu (§ 2.1, § 5.9, § 8.2,
// § 8.4, § 8.5) : le champ, le refus, la fenêtre à confirmer et la commande,
// montés chacun par mount avec les contextes que pose App — 'seance', 't',
// 'horloge' —, sur une séance créée sur le système de fichiers simulé et
// l'horloge pilotée : rien n'attend le temps réel, et chaque écriture se
// compte. Les gestes passent par des entrées que Chromium traite comme
// réelles (test/navigateur/entrees.js). Les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { INACTIVITE_MS, creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { serialiserCharge } from '../../stockage/canonique.js';
import { lireJournal, reconstruire } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { visible } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Champ from './Champ.svelte';
import Commande from './Commande.svelte';
import Confirmation from './Confirmation.svelte';
import Infobulle from './Infobulle.svelte';
import { DELAI_CONFIRMATION_MS } from './commande.js';
import { MARGE_INFOBULLE_PX, placerInfobulle } from './infobulle.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Les deux issues d'une fenêtre à confirmer, par leurs désignations.
const ISSUES = '[data-commande="renoncer"], [data-commande="confirmer"]';
const EVENEMENT = 'Soirée des Merles';
const NOM = 'Ombrelle';
// Les composants montés par l'épreuve courante, et leurs conteneurs.
const montes = [];
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
vi.restoreAllMocks();
});
// Une séance sur le système simulé et l'horloge pilotée, un événement créé,
// Iris Ombrelle ajoutée (participant 1) ; puis bloque : plan bloqué, et la
// séance revient seule en lecture ; mode 'lecture' : retour en lecture.
async function preparer({ mode = 'ecriture', bloque = false } = {}) {
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
const { documents } = await fs.racines();
const horloge = creerHorlogePilotee();
let tirages = 0;
const seance = creerSeance({
fs,
racine: documents,
horloge,
identifiant: () => {
tirages += 1;
return `epreuve-${tirages}`;
},
produitVersion: VERSION.affichee,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
await seance.creer({ nom: EVENEMENT, date: null, siegesParDefaut: 6, tours: 2 });
await seance.passerEnEcriture();
await seance.executer('ajouterParticipant', { champs: { nom: NOM, prenom: 'Iris' } });
if (bloque) await seance.executer('changerEtat', { etat: 'bloque' });
if (mode === 'lecture') await seance.passerEnLecture();
const base = seance.etat().evenement.base;
const contexte = new Map([
['seance', seance],
['t', t],
['horloge', horloge],
]);
return { fs, horloge, seance, base, contexte };
}
// Monte un composant dans un conteneur neuf, au début du corps.
function monter(Composant, props, contexte) {
const cible = document.createElement('div');
document.body.prepend(cible);
const composant = mount(Composant, { target: cible, props, context: contexte });
flushSync();
montes.push({ composant, cible });
return cible;
}
// Le champ du nom d'Iris : lire refuse un texte blanc, NOM_REQUIS ; executer
// passe par la séance, et chaque promesse rendue se garde dans attentes.
function monterChamp(montage) {
const attentes = [];
const props = {
cle: 'nom',
etiquette: 'communs.commande.modifier',
valeur: NOM,
lire: (texte) => (texte.trim() === '' ? { refus: { code: 'NOM_REQUIS', details: { remede: null } } } : { valeur: texte.trim() }),
executer: (valeur) => {
const promesse = montage.seance.executer('modifierParticipant', { id: 1, champs: { nom: valeur } });
attentes.push(promesse);
return promesse;
},
};
const cible = monter(Champ, props, montage.contexte);
return { cible, entree: cible.querySelector('input[data-champ="nom"]'), attentes };
}
const centre = (element) => {
const b = element.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// Un clic réel au centre de l'élément.
async function cliquer(element) {
const { x, y } = centre(element);
await pointer('presser', x, y);
await pointer('relacher', x, y);
await tick();
}
// Un clic réel dans le bas de la fenêtre, où rien ne se monte : le champ qui
// a le focus le perd.
async function cliquerAilleurs() {
await pointer('presser', 4, innerHeight - 4);
await pointer('relacher', 4, innerHeight - 4);
await tick();
}
// Un double-clic réel au centre de l'élément : deux appuis, le second
// annoncé par Chromium comme le deuxième clic d'une série (clickCount 2),
// si bien que son événement click porte detail 2.
async function doubleCliquer(element) {
const { x, y } = centre(element);
for (const clickCount of [1, 2]) {
await pointer('presser', x, y, { clickCount });
await pointer('relacher', x, y, { clickCount });
}
await tick();
}
// Une touche Entrée tenue : un premier appui, puis repetitions appuis que
// Chromium marque répétés (autoRepeat, d'où repeat vrai sur keydown), comme
// la répétition automatique du clavier, et un seul relâcher. Les événements
// vont à l'élément qui a le focus.
async function tenirEntree(repetitions) {
const entree = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
const appui = { type: 'keyDown', ...entree, text: '\r', unmodifiedText: '\r' };
await cdp().send('Input.dispatchKeyEvent', appui);
for (let n = 0; n < repetitions; n += 1) await cdp().send('Input.dispatchKeyEvent', { ...appui, autoRepeat: true });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...entree });
await tick();
}
async function taper(texte) {
for (const caractere of texte) await touche(caractere);
await tick();
}
// Les lignes du journal de l'événement.
function lignesJournal(montage) {
return montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`).split('\n').filter((l) => l !== '');
}
// Attend que la file de la séance ait passé tout ce qui s'y trouve — le
// retour automatique qu'une minuterie échue vient d'y poser compris — par
// une lecture admise dans tout mode, qui n'écrit rien et dont le rendu,
// réussi ou refusé, n'importe pas.
async function viderFile(montage) {
await montage.seance.formeNommee(null).catch(() => undefined);
}
const verrouPresent = (montage) => montage.fs.contenu('documents', `${montage.base}${SUFFIXES.verrou}`) !== null;
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
function ecart(a, b) {
const ba = a.getBoundingClientRect();
const bb = b.getBoundingClientRect();
const dx = Math.max(0, ba.left - bb.right, bb.left - ba.right);
const dy = Math.max(0, ba.top - bb.bottom, bb.top - ba.bottom);
return Math.max(dx, dy);
}
// Aucune fenêtre ni boîte de dialogue dans le document.
function aucuneFenetre() {
expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0);
}
describe('champ : la fin du geste (§ 2.1, § 8.2)', () => {
test("taper sans quitter le champ n'écrit rien, même 30 min plus tard ; le quitter écrit une ligne, et la dernière entrée reconstruit la charge écrite", async () => {
const montage = await preparer();
const { entree, attentes } = monterChamp(montage);
const ecritures = montage.fs.compteurs.ecritures;
const lignes = lignesJournal(montage).length;
const revision = montage.seance.etat().evenement.revision;
await cliquer(entree);
await taper(' Lacasse');
montage.horloge.avancer(30 * 60 * 1000);
await tick();
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(entree.value).toBe(`${NOM} Lacasse`);
expect(attentes).toHaveLength(0);
await cliquerAilleurs();
expect(attentes).toHaveLength(1);
await Promise.all(attentes);
expect(lignesJournal(montage)).toHaveLength(lignes + 1);
const etat = montage.seance.etat();
expect(etat.evenement.revision).toBe(revision + 1);
expect(etat.charge.participants[0].nom).toBe(`${NOM} Lacasse`);
const journal = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`));
const derniere = journal.entrees.at(-1);
expect(derniere.revision).toBe(revision + 1);
expect(serialiserCharge(reconstruire(journal, derniere.revision))).toBe(serialiserCharge(etat.charge));
});
test('une saisie que lire refuse affiche son refus contre le champ, garde le texte et n’écrit rien ; Échap rend la valeur enregistrée', async () => {
const montage = await preparer();
const { cible, entree, attentes } = monterChamp(montage);
const ecritures = montage.fs.compteurs.ecritures;
await cliquer(entree);
for (let i = 0; i < NOM.length; i += 1) await touche('Backspace');
await cliquerAilleurs();
const message = cible.querySelector('[data-refus]');
expect(message).not.toBeNull();
expect(message.querySelector('p').textContent).toBe(t('NOM_REQUIS', { remede: null }));
expect(entree.getAttribute('aria-invalid')).toBe('true');
expect(entree.getAttribute('aria-describedby')).toBe(message.id);
expect(message.id).not.toBe('');
expect(entree.value).toBe('');
expect(attentes).toHaveLength(0);
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
await cliquer(entree);
await touche('Escape');
await tick();
expect(entree.value).toBe(NOM);
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
});
});
describe('champ : le refus de l’exécution (§ 2.1, § 5.9)', () => {
test('executer refusé avec un code : le refus se pose contre le champ, la saisie gardée, rien d’écrit ; le refus levé par son remède, le champ rend la valeur enregistrée, et une saisie nouvelle s’écrit', async () => {
const montage = await preparer();
const { cible, entree, attentes } = monterChamp(montage);
const lignes = lignesJournal(montage).length;
await cliquer(entree);
await taper(' Lacasse');
// Le retour en lecture part avant la sortie du champ : la séance passe ses
// appels dans l'ordre, et modifierParticipant arrive en lecture.
const retour = montage.seance.passerEnLecture();
entree.blur();
expect(attentes).toHaveLength(1);
await retour;
const ecritures = montage.fs.compteurs.ecritures;
await expect(attentes[0]).rejects.toMatchObject({ code: 'MODE_LECTURE' });
await tick();
const message = cible.querySelector('[data-refus]');
expect(message).not.toBeNull();
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } }));
expect(visible(message)).toBe(true);
expect(ecart(message, entree)).toBeLessThanOrEqual(8);
expect(entree.getAttribute('aria-invalid')).toBe('true');
expect(entree.getAttribute('aria-describedby')).toBe(message.id);
expect(message.id).not.toBe('');
expect(entree.value).toBe(`${NOM} Lacasse`);
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(lignesJournal(montage)).toHaveLength(lignes);
expect(montage.seance.etat().charge.participants[0].nom).toBe(NOM);
aucuneFenetre();
await cliquer(message.querySelector('button[data-commande="modifier"]'));
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture'));
await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull());
// Le refus levé, la saisie refusée ne reste pas affichée comme si elle
// avait été appliquée : le champ rend la valeur enregistrée.
expect(entree.value).toBe(NOM);
expect(entree.getAttribute('aria-invalid')).toBeNull();
await cliquer(entree);
await touche('Enter');
await tick();
expect(attentes).toHaveLength(1);
await taper(' Lacasse');
await touche('Enter');
await tick();
expect(attentes).toHaveLength(2);
await attentes[1];
expect(lignesJournal(montage)).toHaveLength(lignes + 1);
expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`);
});
test('executer refusé par un code de la séance : la séance passée en écriture par un autre chemin que le remède, le refus quitte le champ, qui rend la valeur enregistrée et le compte d’inactivité', async () => {
const montage = await preparer();
const { cible, entree, attentes } = monterChamp(montage);
await cliquer(entree);
await taper(' Lacasse');
const retour = montage.seance.passerEnLecture();
entree.blur();
await retour;
await expect(attentes[0]).rejects.toMatchObject({ code: 'MODE_LECTURE' });
await tick();
expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('MODE_LECTURE');
await montage.seance.passerEnEcriture();
flushSync();
expect(montage.seance.etat().refus).toBeNull();
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
expect(entree.getAttribute('aria-describedby')).toBeNull();
expect(entree.value).toBe(NOM);
// La saisie rendue ne tient plus de geste : l'inactivité ramène la
// séance en lecture.
const lignes = lignesJournal(montage).length;
montage.horloge.avancer(INACTIVITE_MS + 1);
await viderFile(montage);
expect(montage.seance.etat().mode).toBe('lecture');
// Entrée ne relance rien : aucune saisie n'est plus en cours.
await cliquer(entree);
await touche('Enter');
await tick();
expect(attentes).toHaveLength(1);
expect(lignesJournal(montage)).toHaveLength(lignes);
expect(montage.seance.etat().charge.participants[0].nom).toBe(NOM);
});
test('executer refusé par un code de la séance que l’état ne tient déjà plus : le refus ne se pose pas, et le champ rend la valeur enregistrée', async () => {
const montage = await preparer();
const appels = [];
// Un refus MODE_LECTURE qui arrive la séance en écriture : il a été levé
// avant d'arriver.
const cible = monter(
Champ,
{
cle: 'nom',
etiquette: 'communs.commande.modifier',
valeur: NOM,
lire: (texte) => ({ valeur: texte.trim() }),
executer: (valeur) => {
appels.push(valeur);
return Promise.reject(Object.assign(new Error('refus d’épreuve'), { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } }));
},
},
montage.contexte,
);
const entree = cible.querySelector('input[data-champ="nom"]');
await cliquer(entree);
await taper(' Lacasse');
await cliquerAilleurs();
expect(appels).toEqual([`${NOM} Lacasse`]);
await vi.waitFor(() => expect(entree.value).toBe(NOM));
flushSync();
expect(montage.seance.etat().refus).toBeNull();
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
montage.horloge.avancer(INACTIVITE_MS + 1);
await viderFile(montage);
expect(montage.seance.etat().mode).toBe('lecture');
});
test('une autre cause remplace le refus de l’exécution : la saisie refusée reste affichée, marquée, tant qu’un refus de la séance la couvre', async () => {
const montage = await preparer();
const { cible, entree, attentes } = monterChamp(montage);
await cliquer(entree);
await taper(' Lacasse');
// Le support rompt pendant la saisie : sa validation est refusée
// SUPPORT_ROMPU, puis le retour en lecture remplace cette cause.
montage.fs.pannes.ecriture('documents');
await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' });
await cliquerAilleurs();
await expect(attentes[0]).rejects.toMatchObject({ code: 'SUPPORT_ROMPU' });
await tick();
expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('SUPPORT_ROMPU');
expect(entree.value).toBe(`${NOM} Lacasse`);
await montage.seance.passerEnLecture();
flushSync();
const message = cible.querySelector('[data-refus]');
expect(message?.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(entree.value).toBe(`${NOM} Lacasse`);
expect(entree.getAttribute('aria-invalid')).toBe('true');
expect(entree.getAttribute('aria-describedby')).toBe(message.id);
});
test('le remède d’un refus de la séance ne retire que lui : le refus de lire qu’il couvrait reparaît, la saisie fautive toujours là', async () => {
const montage = await preparer();
const { cible, entree } = monterChamp(montage);
await cliquer(entree);
for (let i = 0; i < NOM.length; i += 1) await touche('Backspace');
await cliquerAilleurs();
expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('NOM_REQUIS');
await montage.seance.passerEnLecture();
await cliquer(entree);
await taper('z');
const message = cible.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
await cliquer(message.querySelector('button[data-commande="modifier"]'));
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture'));
await tick();
flushSync();
expect(entree.value).toBe('');
const reste = cible.querySelector('[data-refus]');
expect(reste?.getAttribute('data-refus')).toBe('NOM_REQUIS');
expect(entree.getAttribute('aria-invalid')).toBe('true');
expect(entree.getAttribute('aria-describedby')).toBe(reste.id);
});
});
describe('champ : le refus là où il a lieu (§ 2.1, § 5.9)', () => {
// Tape un caractère dans le champ et rend le message posé contre lui ;
// vérifie le texte revenu, le message visible et touchant le champ, son
// texte et son remède, l'absence de toute fenêtre, et les attributs du
// champ.
async function refusAuPremierCaractere(montage, remede) {
const alerte = vi.spyOn(window, 'alert');
const confirmation = vi.spyOn(window, 'confirm');
const { cible, entree, attentes } = monterChamp(montage);
const ecritures = montage.fs.compteurs.ecritures;
await cliquer(entree);
await taper('z');
expect(entree.value).toBe(NOM);
const message = cible.querySelector('[data-refus]');
expect(message).not.toBeNull();
expect(visible(message)).toBe(true);
expect(ecart(message, entree)).toBeLessThanOrEqual(8);
const { refus } = montage.seance.etat();
expect(refus).not.toBeNull();
expect(message.getAttribute('data-refus')).toBe(refus.code);
expect(message.querySelector('p').textContent).toBe(t(refus.code, refus.details));
expect(message.textContent).toContain(remede);
expect(message.querySelector('button').textContent.trim()).toBe(remede);
aucuneFenetre();
expect(alerte).not.toHaveBeenCalled();
expect(confirmation).not.toHaveBeenCalled();
expect(entree.getAttribute('aria-invalid')).toBe('true');
expect(entree.getAttribute('aria-describedby')).toBe(message.id);
expect(message.id).not.toBe('');
expect(attentes).toHaveLength(0);
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
return { cible, entree, message };
}
test('en lecture, la première frappe est refusée aussitôt : MODE_LECTURE, et « Modifier » contre le champ', async () => {
const montage = await preparer({ mode: 'lecture' });
const { refus } = montage.seance.etat();
expect(refus.code).toBe('MODE_LECTURE');
await refusAuPremierCaractere(montage, 'Modifier');
});
test('plan bloqué : LECTURE_SEULE BLOQUE, et « Débloquer »', async () => {
const montage = await preparer({ bloque: true });
expect(montage.seance.etat().refus).toMatchObject({ code: 'LECTURE_SEULE', details: { raison: 'BLOQUE' } });
await refusAuPremierCaractere(montage, 'Débloquer');
});
test('support rompu : SUPPORT_ROMPU, et « Choisir un dossier… » quand le support rompu est celui des Documents', async () => {
const montage = await preparer();
montage.fs.pannes.ecriture('documents');
await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' });
expect(montage.seance.etat().refus.code).toBe('SUPPORT_ROMPU');
await refusAuPremierCaractere(montage, 'Choisir un dossier\u{2026}');
});
test('le remède « Modifier » passe en écriture, retire le refus, et la frappe suivante se saisit', async () => {
const montage = await preparer({ mode: 'lecture' });
const { cible, entree, message } = await refusAuPremierCaractere(montage, 'Modifier');
await cliquer(message.querySelector('button'));
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture'));
await tick();
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
await cliquer(entree);
await taper('x');
expect(entree.value).toBe(`${NOM}x`);
});
test('le refus de la séance suit la séance : passée en écriture par un autre chemin que son remède, le champ ne garde ni le refus ni aria-invalid', async () => {
const montage = await preparer({ mode: 'lecture' });
const { cible, entree } = await refusAuPremierCaractere(montage, 'Modifier');
await montage.seance.passerEnEcriture();
flushSync();
expect(montage.seance.etat().refus).toBeNull();
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
expect(entree.getAttribute('aria-describedby')).toBeNull();
// De nouveau en lecture : rien ne se pose avant une frappe.
await montage.seance.passerEnLecture();
flushSync();
expect(cible.querySelector('[data-refus]')).toBeNull();
await montage.seance.passerEnEcriture();
flushSync();
expect(cible.querySelector('[data-refus]')).toBeNull();
await cliquer(entree);
await taper('x');
expect(entree.value).toBe(`${NOM}x`);
});
test('une autre cause remplace le refus posé : support rompu, puis retour en lecture → MODE_LECTURE et « Modifier » contre le champ', async () => {
const montage = await preparer();
montage.fs.pannes.ecriture('documents');
await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' });
const { cible, entree } = await refusAuPremierCaractere(montage, 'Choisir un dossier\u{2026}');
await montage.seance.passerEnLecture();
flushSync();
expect(montage.seance.etat().refus.code).toBe('MODE_LECTURE');
const message = cible.querySelector('[data-refus]');
expect(message).not.toBeNull();
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } }));
expect(message.querySelector('button').textContent.trim()).toBe('Modifier');
expect(entree.getAttribute('aria-describedby')).toBe(message.id);
});
test('plan bloqué, débloqué hors du remède : le refus LECTURE_SEULE quitte le champ', async () => {
const montage = await preparer({ bloque: true });
const { cible, entree } = await refusAuPremierCaractere(montage, 'Débloquer');
await montage.seance.debloquer();
flushSync();
expect(montage.seance.etat().refus).toBeNull();
expect(cible.querySelector('[data-refus]')).toBeNull();
expect(entree.getAttribute('aria-invalid')).toBeNull();
});
});
describe('le remède « Débloquer » (§ 8.5)', () => {
test('un clic arme la commande, sans écrire ; un second débloque, une entrée', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
expect(bouton.getAttribute('data-commande')).toBe('debloquer');
const ecritures = montage.fs.compteurs.ecritures;
const entrees = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`)).entrees.length;
await cliquer(bouton);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
aucuneFenetre();
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
const journal = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`));
expect(journal.entrees.length).toBe(entrees + 1);
expect(montage.seance.etat().mode).toBe('ecriture');
});
test('armée, le délai de l’horloge la désarme : elle redit « Débloquer », et le clic suivant n’écrit rien', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
await cliquer(bouton);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
montage.horloge.avancer(DELAI_CONFIRMATION_MS - 1);
flushSync();
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
montage.horloge.avancer(1);
flushSync();
expect(bouton.textContent.trim()).toBe('Débloquer');
await cliquer(bouton);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
});
test('un double-clic arme la commande sans débloquer : elle reste armée, rien d’écrit ; un clic simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const clics = [];
bouton.addEventListener('click', (evenement) => clics.push(evenement.detail));
await doubleCliquer(bouton);
expect(clics).toEqual([1, 2]);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
test('Entrée tenue arme la commande sans débloquer : les appuis répétés du clavier ne confirment pas, rien d’écrit ; un appui simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const appuis = [];
bouton.addEventListener('keydown', (evenement) => appuis.push(evenement.repeat));
bouton.focus();
await tenirEntree(3);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(appuis).toEqual([false, true, true, true]);
await tenirEntree(0);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
// Le refus d'un plan bloqué posé contre le champ par une frappe.
async function refusAuPremierCaractere2(montage) {
const { cible, entree } = monterChamp(montage);
await cliquer(entree);
await taper('z');
const message = cible.querySelector('[data-refus]');
expect(message).not.toBeNull();
return { cible, entree, message };
}
});
describe('la saisie suspend l’inactivité (§ 8.4)', () => {
test('une saisie non validée tient la séance en écriture au-delà de l’inactivité ; validée, une entrée, puis le retour en lecture rend le verrou', async () => {
const montage = await preparer();
const { entree, attentes } = monterChamp(montage);
const lignes = lignesJournal(montage).length;
await cliquer(entree);
await taper('ine');
montage.horloge.avancer(INACTIVITE_MS + 1000);
await tick();
expect(montage.seance.etat().mode).toBe('ecriture');
expect(entree.value).toBe(`${NOM}ine`);
expect(verrouPresent(montage)).toBe(true);
await touche('Enter');
await tick();
expect(attentes).toHaveLength(1);
await Promise.all(attentes);
expect(lignesJournal(montage)).toHaveLength(lignes + 1);
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
await vi.waitFor(() => expect(verrouPresent(montage)).toBe(false));
});
test('une saisie en cours, la séance renvoyée en lecture puis repassée en écriture sans quitter le champ : la saisie suspend de nouveau l’inactivité', async () => {
const montage = await preparer();
const { entree, attentes } = monterChamp(montage);
await cliquer(entree);
await taper('ine');
await montage.seance.passerEnLecture();
await montage.seance.passerEnEcriture();
expect(document.activeElement).toBe(entree);
montage.horloge.avancer(INACTIVITE_MS + 1);
await viderFile(montage);
expect(montage.seance.etat().mode).toBe('ecriture');
await touche('Enter');
await tick();
await Promise.all(attentes);
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
});
describe('saisie, puis « Revenir en lecture » (§ 8.2, § 8.4)', () => {
test('par des entrées réelles : la saisie s’écrit, puis la séance revient en lecture et rend le verrou', async () => {
const montage = await preparer();
const commande = monter(
Commande,
{ cle: 'communs.commande.lecture', executer: () => montage.seance.passerEnLecture() },
montage.contexte,
);
const { entree, attentes } = monterChamp(montage);
const lignes = lignesJournal(montage).length;
await cliquer(entree);
await taper(' Lacasse');
const bouton = commande.querySelector('button[data-commande="lecture"]');
expect(bouton).not.toBeNull();
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
await Promise.all(attentes);
expect(attentes).toHaveLength(1);
expect(lignesJournal(montage)).toHaveLength(lignes + 1);
expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`);
expect(verrouPresent(montage)).toBe(false);
});
test('la sortie du champ lance l’exécution dans le même tour : un retour en lecture appelé aussitôt après attend son écriture', async () => {
const montage = await preparer();
const { entree, attentes } = monterChamp(montage);
const lignes = lignesJournal(montage).length;
await cliquer(entree);
await taper(' Lacasse');
entree.blur();
const retour = montage.seance.passerEnLecture();
expect(attentes).toHaveLength(1);
await Promise.all([...attentes, retour]);
expect(lignesJournal(montage)).toHaveLength(lignes + 1);
expect(montage.seance.etat().mode).toBe('lecture');
expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`);
});
});
describe('fenêtre à confirmer (§ 2.1)', () => {
const ELEMENTS = ['Iris Ombrelle', 'Théo Pervenche', 'Ondine Lacasse'];
function monterConfirmation(montage) {
const appels = [];
const cible = monter(
Confirmation,
{
titre: 'communs.remede.supprimerParticipant',
elements: ELEMENTS,
surConfirmer: () => {
appels.push('confirmer');
return montage.seance.executer('supprimerParticipant', { id: 1 });
},
surAnnuler: () => appels.push('annuler'),
},
montage.contexte,
);
return { cible, appels, fenetre: cible.querySelector('dialog') };
}
test('une fenêtre modale, ouverte, qui nomme chaque élément détruit, chacun dans son nœud, Annuler par défaut', async () => {
const montage = await preparer();
const { fenetre } = monterConfirmation(montage);
expect(fenetre).not.toBeNull();
expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true);
const items = [...fenetre.querySelectorAll('li')];
expect(items).toHaveLength(ELEMENTS.length);
expect(items.map((li) => li.textContent)).toEqual(ELEMENTS);
for (const item of items) expect(visible(item)).toBe(true);
expect(document.activeElement.getAttribute('data-commande')).toBe('renoncer');
expect(document.activeElement.textContent.trim()).toBe('Annuler');
});
test('Annuler → surAnnuler, zéro écriture ; Échap de même', async () => {
const montage = await preparer();
const ecritures = montage.fs.compteurs.ecritures;
const premiere = monterConfirmation(montage);
await cliquer(premiere.fenetre.querySelector('[data-commande="renoncer"]'));
expect(premiere.appels).toEqual(['annuler']);
expect(premiere.fenetre.open).toBe(false);
const seconde = monterConfirmation(montage);
await touche('Escape');
await tick();
expect(seconde.appels).toEqual(['annuler']);
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
});
test('Confirmer → surConfirmer ; abouti, la fenêtre se ferme', async () => {
const montage = await preparer();
const { fenetre, appels } = monterConfirmation(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
expect(appels).toEqual(['confirmer']);
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
await vi.waitFor(() => expect(fenetre.open).toBe(false));
});
test('ouverte, elle suspend l’inactivité (§ 8.4) : au-delà d’INACTIVITE_MS, toujours en écriture, et Confirmer aboutit ; fermée, le compte reprend', async () => {
const montage = await preparer();
const { fenetre } = monterConfirmation(montage);
montage.horloge.avancer(INACTIVITE_MS + 1);
await tick();
expect(montage.seance.etat().mode).toBe('ecriture');
expect(verrouPresent(montage)).toBe(true);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
await vi.waitFor(() => expect(fenetre.open).toBe(false));
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
test('Annuler rend le compte d’inactivité', async () => {
const montage = await preparer();
const { fenetre } = monterConfirmation(montage);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
test('Confirmer refusé : le refus se pose dans la fenêtre, qui reste ouverte, rien d’écrit ; son remède levé, Confirmer aboutit et la ferme', async () => {
const montage = await preparer();
const { fenetre, appels } = monterConfirmation(montage);
await montage.seance.passerEnLecture();
const lignes = lignesJournal(montage).length;
const ecritures = montage.fs.compteurs.ecritures;
const confirmer = fenetre.querySelector('[data-commande="confirmer"]');
await cliquer(confirmer);
expect(appels).toEqual(['confirmer']);
await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull());
const message = fenetre.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } }));
expect(visible(message)).toBe(true);
expect(fenetre.open).toBe(true);
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(lignesJournal(montage)).toHaveLength(lignes);
expect(montage.seance.etat().charge.participants).toHaveLength(1);
await cliquer(message.querySelector('button[data-commande="modifier"]'));
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture'));
await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).toBeNull());
// Le retour explicite en lecture a remis à zéro le compte des gestes de
// la séance : la fenêtre, toujours ouverte, le rouvre au passage en
// écriture.
montage.horloge.avancer(INACTIVITE_MS + 1);
await viderFile(montage);
expect(montage.seance.etat().mode).toBe('ecriture');
expect(fenetre.open).toBe(true);
await cliquer(confirmer);
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
await vi.waitFor(() => expect(fenetre.open).toBe(false));
});
// Une promesse que l'épreuve résout ou rejette à la main : l'action
// confirmée qui dure, celle d'un disque lent.
function differe() {
let resoudre;
let rejeter;
const promesse = new Promise((oui, non) => {
resoudre = oui;
rejeter = non;
});
return { promesse, resoudre, rejeter };
}
// La fenêtre dont surConfirmer attend attente avant d'exécuter la
// suppression d'Iris par la séance.
function monterConfirmationDifferee(montage) {
const appels = [];
const attente = differe();
const cible = monter(
Confirmation,
{
titre: 'communs.remede.supprimerParticipant',
elements: ELEMENTS,
surConfirmer: () => {
appels.push('confirmer');
return attente.promesse.then(() => montage.seance.executer('supprimerParticipant', { id: 1 }));
},
surAnnuler: () => appels.push('annuler'),
},
montage.contexte,
);
return { cible, appels, attente, fenetre: cible.querySelector('dialog') };
}
test('occupée pendant l’action confirmée : Annuler, Échap et un second Confirmer ne font rien, les boutons se montrent désactivés et l’attente se dit ; seule la fin de l’action ferme la fenêtre', async () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
const renoncer = fenetre.querySelector('[data-commande="renoncer"]');
const confirmer = fenetre.querySelector('[data-commande="confirmer"]');
const styleRepos = getComputedStyle(confirmer).borderTopStyle;
await cliquer(confirmer);
expect(appels).toEqual(['confirmer']);
for (const bouton of [renoncer, confirmer]) {
expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(bouton).borderTopStyle).toBe('dashed');
expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos);
}
const annonce = [...fenetre.querySelectorAll('[role=status]')].find(
(element) => element.textContent.trim() === t('communs.confirmation.enCours'),
);
expect(annonce).toBeDefined();
expect(visible(annonce)).toBe(true);
await touche('Escape');
await tick();
await cliquer(renoncer);
await cliquer(confirmer);
expect(appels).toEqual(['confirmer']);
expect(fenetre.open).toBe(true);
expect(montage.seance.etat().charge.participants).toHaveLength(1);
attente.resoudre();
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
await vi.waitFor(() => expect(fenetre.open).toBe(false));
expect(appels).toEqual(['confirmer']);
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
test('occupée, Échap ne ferme rien : l’action refusée avec un code, son refus se pose dans la fenêtre restée ouverte, qui redevient active', async () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
const confirmer = fenetre.querySelector('[data-commande="confirmer"]');
await cliquer(confirmer);
await touche('Escape');
await tick();
await montage.seance.passerEnLecture();
attente.resoudre();
await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull());
expect(appels).toEqual(['confirmer']);
expect(fenetre.open).toBe(true);
const message = fenetre.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(visible(message)).toBe(true);
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(montage.seance.etat().charge.participants).toHaveLength(1);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
expect(appels).toEqual(['confirmer', 'annuler']);
expect(fenetre.open).toBe(false);
});
// La fenêtre occupée tient à toute touche. Chromium n'annule pas deux
// fois de suite une demande de fermeture sans activation de l'utilisateur
// entre elles : la seconde Échap ferme le <dialog> sans que cancel puisse
// l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt,
// modale et toujours occupée ; la fin de l'action seule décide.
//
// L'événement close part dans une tâche à lui, après la touche : la rafale
// compte les fermetures — l'attribut open retiré, que MutationObserver
// signale dès la touche traitée — et les événements close reçus, et attend
// que chaque fermeture ait eu le sien. Au moins une Échap ferme la fenêtre.
async function echapEnRafale(fenetre, fois) {
let fermetures = 0;
let evenements = 0;
const observateur = new MutationObserver((releves) => {
fermetures += releves.filter(({ oldValue }) => oldValue !== null).length;
});
const surClose = () => {
evenements += 1;
};
observateur.observe(fenetre, { attributes: true, attributeFilter: ['open'], attributeOldValue: true });
fenetre.addEventListener('close', surClose);
try {
for (let i = 0; i < fois; i += 1) await touche('Escape');
await vi.waitFor(() => {
expect(fermetures).toBeGreaterThan(0);
expect(evenements).toBe(fermetures);
});
} finally {
observateur.disconnect();
fenetre.removeEventListener('close', surClose);
}
await tick();
}
function attendreOccupee(montage, fenetre, appels) {
expect(appels).toEqual(['confirmer']);
expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.getAttribute('aria-busy')).toBe('true');
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(montage.seance.etat().charge.participants).toHaveLength(1);
}
for (const fois of [2, 3]) {
test(`occupée, ${fois} Échap de suite ne la ferment pas : rouverte modale et occupée, sans surAnnuler ; la fin de l’action la ferme`, async () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
// Une seconde rafale, la fenêtre déjà rouverte une fois.
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
attente.resoudre();
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
await vi.waitFor(() => expect(fenetre.open).toBe(false));
expect(appels).toEqual(['confirmer']);
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
test(`occupée, ${fois} Échap de suite, puis l’action refusée avec un code : le refus se pose dans la fenêtre rouverte, qui redevient active`, async () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
await montage.seance.passerEnLecture();
attente.resoudre();
await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull());
expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(fenetre.getAttribute('aria-busy')).toBeNull();
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(appels).toEqual(['confirmer']);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
expect(appels).toEqual(['confirmer', 'annuler']);
expect(fenetre.open).toBe(false);
});
}
test('fermée par le navigateur hors de l’attente, sans passer par Annuler : c’est une annulation, qui rend le compte d’inactivité', async () => {
const montage = await preparer();
const { fenetre, appels } = monterConfirmation(montage);
fenetre.close();
await vi.waitFor(() => expect(appels).toEqual(['annuler']));
expect(fenetre.open).toBe(false);
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
});
test('une erreur sans code ferme la fenêtre, rend le compte d’inactivité, et remonte', async () => {
const montage = await preparer();
const remontees = [];
const recueillir = (evenement) => {
evenement.preventDefault();
remontees.push(evenement.reason);
};
window.addEventListener('unhandledrejection', recueillir);
try {
const { fenetre, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
const panne = new Error('panne d’épreuve');
attente.rejeter(panne);
await vi.waitFor(() => expect(fenetre.open).toBe(false));
await vi.waitFor(() => expect(remontees).toContain(panne));
expect(fenetre.querySelector('[data-refus]')).toBeNull();
montage.horloge.avancer(INACTIVITE_MS);
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture'));
} finally {
window.removeEventListener('unhandledrejection', recueillir);
}
});
});
describe('commande (§ 2.1)', () => {
test('refusée, elle affiche son refus à côté d’elle, avec son remède, et reste active : jamais un bouton grisé muet', async () => {
const montage = await preparer({ mode: 'lecture' });
const ecritures = montage.fs.compteurs.ecritures;
const cible = monter(
Commande,
{
cle: 'communs.remede.ajouterParticipant',
executer: () => montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }),
},
montage.contexte,
);
const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]');
expect(bouton).not.toBeNull();
expect(bouton.disabled).toBe(false);
await cliquer(bouton);
await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).not.toBeNull());
const message = cible.querySelector('[data-refus]');
expect(visible(message)).toBe(true);
expect(ecart(message, bouton)).toBeLessThanOrEqual(8);
expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } }));
expect(bouton.disabled).toBe(false);
expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
aucuneFenetre();
await cliquer(message.querySelector('button[data-commande="modifier"]'));
await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture'));
await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull());
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2));
});
test('pendant son exécution, un second clic réel ne relance rien : le bouton se dit occupé ; finie, un clic l’exécute de nouveau', async () => {
const montage = await preparer();
let appels = 0;
let liberer;
const cible = monter(
Commande,
{
cle: 'communs.remede.ajouterParticipant',
executer: () => {
appels += 1;
return new Promise((resoudre) => {
liberer = resoudre;
});
},
},
montage.contexte,
);
const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]');
const styleRepos = getComputedStyle(bouton).borderTopStyle;
await cliquer(bouton);
expect(appels).toBe(1);
expect(bouton.getAttribute('aria-busy')).toBe('true');
expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos);
await cliquer(bouton);
await doubleCliquer(bouton);
expect(appels).toBe(1);
liberer();
await vi.waitFor(() => expect(bouton.getAttribute('aria-busy')).toBeNull());
expect(bouton.getAttribute('aria-disabled')).toBeNull();
await cliquer(bouton);
expect(appels).toBe(2);
liberer();
});
test('une exécution sans promesse rend aussitôt la commande : deux click() synthétiques, chacun hors d’une série de clics, deux exécutions', async () => {
const montage = await preparer();
let appels = 0;
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => void (appels += 1) }, montage.contexte);
const bouton = cible.querySelector('button');
bouton.click();
bouton.click();
expect(appels).toBe(2);
expect(bouton.getAttribute('aria-busy')).toBeNull();
});
test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => {
const montage = await preparer();
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte);
const bouton = cible.querySelector('button');
expect(bouton.textContent.trim()).toBe('Revenir en lecture');
expect(bouton.getAttribute('data-commande')).toBe('lecture');
expect(bouton.getAttribute('type')).toBe('button');
});
});
describe('infobulle (§ 12.7, § 14.5)', () => {
// Un terme posé en position fixe au point (gauche, haut) de la fenêtre,
// et l'infobulle ouverte sur lui ; fermer est compté.
function ouvrirSur({ gauche, haut, texte = 'terme' }) {
const ancre = document.createElement('span');
ancre.textContent = texte;
Object.assign(ancre.style, { position: 'fixed', left: `${gauche}px`, top: `${haut}px` });
document.body.append(ancre);
const fermetures = [];
const cible = monter(
Infobulle,
{ id: 'infobulle-epreuve', ouverte: { ancre, cle: 'propositions.explication.rencontresTous', details: {} }, fermer: () => fermetures.push(1) },
new Map([['t', t]]),
);
const bulle = cible.querySelector('[role="tooltip"]');
return { ancre, bulle, fermetures, retirer: () => ancre.remove() };
}
// La place que placerInfobulle donne à l'infobulle, sur les rectangles du rendu.
function placeAttendue(ancre, bulle) {
const a = ancre.getBoundingClientRect();
const b = bulle.getBoundingClientRect();
const fenetre = document.documentElement;
return placerInfobulle(
{ gauche: a.left, haut: a.top, droite: a.right, bas: a.bottom },
{ largeur: b.width, hauteur: b.height },
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight },
);
}
// La place écrite dans le style, que le navigateur sérialise au millième
// de px : elle se compare à la place attendue à ce grain.
const placeLue = (bulle) => ({ gauche: parseFloat(bulle.style.left), haut: parseFloat(bulle.style.top) });
function exigerPlace(bulle, attendue) {
const lue = placeLue(bulle);
expect(lue.gauche).toBeCloseTo(attendue.gauche, 2);
expect(lue.haut).toBeCloseTo(attendue.haut, 2);
}
test('un terme au coin bas droit de la fenêtre : l’infobulle reste dans le cadre, à la marge de chaque bord', async () => {
const { ancre, bulle, retirer } = ouvrirSur({ gauche: innerWidth - 20, haut: innerHeight - 20 });
try {
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
const boite = bulle.getBoundingClientRect();
const fenetre = document.documentElement;
expect(boite.right).toBeLessThanOrEqual(fenetre.clientWidth - MARGE_INFOBULLE_PX + 0.5);
expect(boite.left).toBeGreaterThanOrEqual(MARGE_INFOBULLE_PX - 0.5);
expect(boite.bottom).toBeLessThanOrEqual(fenetre.clientHeight - MARGE_INFOBULLE_PX + 0.5);
expect(boite.top).toBeGreaterThanOrEqual(MARGE_INFOBULLE_PX - 0.5);
// Au-dessus du terme, qu'elle ne couvre pas.
expect(boite.bottom).toBeLessThanOrEqual(ancre.getBoundingClientRect().top);
} finally {
retirer();
}
});
test('la fenêtre redimensionnée : l’infobulle se replace sur les rectangles relus', async () => {
const { ancre, bulle, retirer } = ouvrirSur({ gauche: 40, haut: 40 });
try {
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
exigerPlace(bulle, placeAttendue(ancre, bulle));
// Le terme suit le bas de la fenêtre ; seul resize le dit à l'infobulle.
Object.assign(ancre.style, { top: `${innerHeight - 30}px` });
const avant = placeLue(bulle);
window.dispatchEvent(new Event('resize'));
flushSync();
const attendue = placeAttendue(ancre, bulle);
expect(Math.abs(attendue.haut - avant.haut)).toBeGreaterThan(1);
exigerPlace(bulle, attendue);
} finally {
retirer();
}
});
test('l’ancre retirée du document : le prochain replacement ferme l’infobulle, une fois', async () => {
const { bulle, fermetures, retirer } = ouvrirSur({ gauche: 40, haut: 40 });
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
expect(fermetures).toEqual([]);
retirer();
window.dispatchEvent(new Event('resize'));
flushSync();
expect(fermetures).toEqual([1]);
});
});