gestion_table_tournante_libre/src/interface/primitives/primitives.navigateur.test.js

1188 lines
54 KiB
JavaScript
Raw Normal View History

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