The wheel no longer zooms during a gesture, so a drop lands under the ghost. A seat is a target the size of its pitch. A floor drop refuses to remove an every-tour reservation. A seat reservation the retained plan does not hold is flagged at the seat, with its swap as remedy. DERIVE names the person; capacities have an upper bound; Generate waits for a pending setting; a worker that fails to load shows a refusal; § 5.3 constraints get their switches. Commands are not re-entrant and a double click does not confirm. Checked: 2755 node, 550 browser tests, from the index alone. --- FR --- [FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme. Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse de retirer une réservation de tous les tours. Une réservation à un siège que le retenu ne tient pas est relevée au siège, l'échange en remède. La dérive nomme la personne ; les capacités ont une borne ; Générer attend un réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le § 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul. Assisted-by: Claude Opus 5.5
1193 lines
59 KiB
JavaScript
1193 lines
59 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// L'événement ouvert dans un vrai moteur de rendu (§ 2.1, § 8.3 à § 8.9,
|
||
// § 9, § 14.5) : aucune section n'en exige une autre ; la place fixe de
|
||
// « Modifier », de « Débloquer » et de « Revenir en lecture », plein écran
|
||
// compris ; le cadre de mode et son libellé ; l'inactivité ; l'état bloqué
|
||
// et son déblocage confirmé en place ; les états du plan ; défaire et
|
||
// refaire ; les annonces d'ouverture, du journal et de la dérive ; le verrou
|
||
// d'une autre séance et sa reprise ; le support rompu ; le format plus
|
||
// récent. Chaque épreuve monte App par monterApp, sur le système de fichiers
|
||
// simulé, au cadre de 1 366 × 700, et lit le rendu : la boîte, l'élément
|
||
// qu'atteint un point, le style calculé, le texte — jamais une classe.
|
||
//
|
||
// Les éléments s'atteignent par leurs attributs data-… : data-place-fixe, la
|
||
// place fixe du cadre ; data-etat, un état du plan de l'en-tête ;
|
||
// data-verrou, le verrou d'une autre séance ; data-support-rompu, le message
|
||
// du support rompu. Le texte attendu vient de la même traduction que celle
|
||
// de l'application ; les noms d'épreuve sont inventés.
|
||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||
import { cdp } from 'vitest/browser';
|
||
import { flushSync } from 'svelte';
|
||
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
|
||
import { INACTIVITE_MS } from '../../application/seance.js';
|
||
import { creerTraduction } from '../../application/traduction.js';
|
||
import { apercevoir } from '../../csv/apercu.js';
|
||
import { FLUX, creerPcg32 } from '../../demo/prng.js';
|
||
import { rechercher } from '../../moteur/recherche.js';
|
||
import { serialiser } from '../../stockage/canonique.js';
|
||
import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../stockage/document.js';
|
||
import { lireJournal } from '../../stockage/journal.js';
|
||
import { SUFFIXES } from '../../stockage/noms.js';
|
||
import { VERSION } from '../../version.genere.js';
|
||
import { fixerCadre, molette } from '../../../test/navigateur/entrees.js';
|
||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||
import { visible } from '../../../test/navigateur/rendu.js';
|
||
import { TABLES_INTERFACE } from '../libelles.js';
|
||
import { CONFIRMATION_DEBLOCAGE, DELAI_CONFIRMATION_MS } from '../primitives/commande.js';
|
||
import { SECTIONS } from './sections.js';
|
||
|
||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||
|
||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Merlette', 'Fauvelle'];
|
||
const EVENEMENT = { nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 };
|
||
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
|
||
|
||
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
|
||
// n'a rien reçu pendant l'épreuve.
|
||
let montes = [];
|
||
async function monter(options) {
|
||
const m = await monterApp(options);
|
||
montes.push(m);
|
||
return m;
|
||
}
|
||
beforeEach(async () => {
|
||
await fixerCadre(1366, 700);
|
||
});
|
||
afterEach(() => {
|
||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||
for (const m of montes.reverse()) m.demonter();
|
||
montes = [];
|
||
window.scrollTo(0, 0);
|
||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||
});
|
||
|
||
// --- Les lectures du rendu ----------------------------------------------------
|
||
|
||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
|
||
const cadre = (m) => dans(m, '[data-mode]');
|
||
const fixe = (m, nom) => dans(m, `[data-place-fixe] [data-commande="${nom}"]`);
|
||
const historique = (m, nom) => dans(m, `[data-mode] [data-historique] [data-commande="${nom}"]`);
|
||
const boutonEtat = (m, etat) => dans(m, `[data-etat="${etat}"] [data-commande="etat"]`);
|
||
const libelleCadre = (m) => dans(m, '[data-mode-libelle="cadre"]');
|
||
const libelleBandeau = (m) => dans(m, '[data-mode-libelle="bandeau"]');
|
||
const avertissement = (m, code) => dans(m, `[data-avertissement="${code}"]`);
|
||
const ecran = (m) => dans(m, '[data-ecran]');
|
||
|
||
// Le coin haut gauche de la boîte d'un élément.
|
||
const coin = (element) => {
|
||
const { left, top } = element.getBoundingClientRect();
|
||
return [left, top];
|
||
};
|
||
|
||
// L'élément qu'atteint le centre de la boîte d'un élément.
|
||
const auCentre = (element) => {
|
||
const { left, top, width, height } = element.getBoundingClientRect();
|
||
return document.elementFromPoint(left + width / 2, top + height / 2);
|
||
};
|
||
|
||
// Un appui réel annoncé par Chromium comme le rang-ième clic d'une série
|
||
// (clickCount) : son événement click porte detail rang. cible est un élément,
|
||
// visé en son centre, ou le point que rend l'appui précédent de la série. Les
|
||
// coordonnées du cadre de l'épreuve passent dans la fenêtre de la page qui le
|
||
// porte, d'un cadre à son parent, comme dans test/navigateur/entrees.js.
|
||
async function cliquerEnSerie(cible, rang) {
|
||
let { x, y } = cible;
|
||
if (cible instanceof Element) {
|
||
const { left, top, width, height } = cible.getBoundingClientRect();
|
||
[x, y] = [left + width / 2, top + height / 2];
|
||
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
|
||
const porteur = fenetre.frameElement;
|
||
const boite = porteur.getBoundingClientRect();
|
||
const style = fenetre.parent.getComputedStyle(porteur);
|
||
const echelle = porteur.offsetWidth > 0 ? boite.width / porteur.offsetWidth : 1;
|
||
x = boite.left + (porteur.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
|
||
y = boite.top + (porteur.clientTop + parseFloat(style.paddingTop) + y) * echelle;
|
||
}
|
||
}
|
||
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: rang });
|
||
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: rang });
|
||
flushSync();
|
||
return { x, y };
|
||
}
|
||
|
||
// Attend que la file de la séance ait passé ce qui s'y trouve, par une
|
||
// lecture admise dans tout mode, qui n'écrit rien.
|
||
async function viderFile(m) {
|
||
await m.seance.formeNommee(null).catch(() => undefined);
|
||
flushSync();
|
||
}
|
||
|
||
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
|
||
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
|
||
|
||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||
async function attendre(assertion) {
|
||
for (let essai = 0; ; essai += 1) {
|
||
flushSync();
|
||
try {
|
||
return assertion();
|
||
} catch (erreur) {
|
||
if (essai >= 200) throw erreur;
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Les événements d'épreuve -----------------------------------------------------
|
||
|
||
const baseDe = (m) => m.seance.etat().evenement.base;
|
||
const fichier = (m, suffixe, racine = 'documents') => m.fs.contenu(racine, `${baseDe(m)}${suffixe}`);
|
||
const journalDe = (m, racine = 'documents') => lireJournal(fichier(m, SUFFIXES.journal, racine));
|
||
|
||
// Un montage où l'événement neuf « Club des Merles », zéro participant et
|
||
// zéro table, est ouvert en lecture.
|
||
async function neuf(options = {}) {
|
||
const m = await monter(options);
|
||
await m.seance.creer(EVENEMENT);
|
||
flushSync();
|
||
return m;
|
||
}
|
||
|
||
// L'événement ouvert en écriture, garni par la séance des six participants
|
||
// et de trois tables de trois places.
|
||
async function garnir(m) {
|
||
await m.seance.passerEnEcriture();
|
||
for (const nom of NOMS) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
|
||
flushSync();
|
||
}
|
||
|
||
// Enregistre deux propositions du moteur, puis, quand retenir le veut, retient
|
||
// la première.
|
||
async function generer(m, { retenir = true } = {}) {
|
||
const charge = m.seance.etat().charge;
|
||
const configuration = configurationDepuisCharge(charge);
|
||
const propositions = rechercher(configuration, { graine: 7, arret: 40, nombre: 2 });
|
||
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
|
||
if (retenir) await m.seance.executer('retenirProposition', { id: m.seance.etat().charge.propositions[0].id });
|
||
flushSync();
|
||
}
|
||
|
||
// Réécrit l'état d'un événement fermé : change(charge) le modifie en place,
|
||
// et l'état se sérialise à sa révision.
|
||
function reecrireEtat(m, base, change) {
|
||
const chemin = `${base}${SUFFIXES.etat}`;
|
||
const { entete, charge } = analyser(m.fs.contenu('documents', chemin));
|
||
change(charge);
|
||
m.fs.deposer('documents', chemin, serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion }));
|
||
}
|
||
|
||
// --- Les épreuves -----------------------------------------------------------------
|
||
|
||
describe('aucun assistant (§ 2.1)', () => {
|
||
test('un événement neuf : chaque section s’ouvre depuis la navigation, dans un ordre tiré puis renversé, et rend son titre ; aucune n’est grisée, aucun « Suivant »', async () => {
|
||
const m = await neuf();
|
||
const { charge } = m.seance.etat();
|
||
expect([charge.participants.length, charge.tables.length]).toEqual([0, 0]);
|
||
const ordre = creerPcg32(20261006, FLUX.DEMO).melanger([...SECTIONS]);
|
||
expect(ordre.length).toBe(7);
|
||
for (const nom of [...ordre, ...[...ordre].reverse()]) {
|
||
const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`);
|
||
expect(visible(bouton), nom).toBe(true);
|
||
bouton.click();
|
||
flushSync();
|
||
expect(m.ui.etat().section).toBe(nom);
|
||
const section = dans(m, `[data-section="${nom}"]`);
|
||
expect(section, nom).not.toBeNull();
|
||
expect(section.textContent).toContain(t(`coquille.section.${nom}`));
|
||
}
|
||
const boutons = tous(m, '[data-ecran="evenement"] button');
|
||
expect(boutons.length).toBeGreaterThan(0);
|
||
for (const bouton of boutons) {
|
||
expect(bouton.disabled).toBe(false);
|
||
expect(bouton.getAttribute('aria-disabled')).toBeNull();
|
||
}
|
||
expect(ecran(m).textContent).not.toMatch(/suivant/i);
|
||
});
|
||
});
|
||
|
||
describe('la place fixe (§ 8.4, § 8.5)', () => {
|
||
test('« Modifier » aux mêmes coordonnées sur les sept sections ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => {
|
||
const m = await neuf();
|
||
const coins = [];
|
||
for (const nom of SECTIONS) {
|
||
m.ui.allerA(nom);
|
||
flushSync();
|
||
expect(visible(fixe(m, 'modifier')), nom).toBe(true);
|
||
coins.push(coin(fixe(m, 'modifier')));
|
||
}
|
||
expect(coins.length).toBe(7);
|
||
for (const lu of coins) expect(lu).toEqual(coins[0]);
|
||
// Un refus posé à côté d'un état, au-dessus de la surface, ne déplace
|
||
// pas la place fixe, et ne la couvre pas.
|
||
boutonEtat(m, 'bloque').click();
|
||
await attendre(() => expect(dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]')).not.toBeNull());
|
||
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
|
||
expect(visible(fixe(m, 'modifier'))).toBe(true);
|
||
m.ui.allerA('plan');
|
||
m.ui.basculerPleinEcran();
|
||
flushSync();
|
||
expect(visible(fixe(m, 'modifier'))).toBe(true);
|
||
expect(fixe(m, 'modifier').contains(auCentre(fixe(m, 'modifier')))).toBe(true);
|
||
expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]);
|
||
expect(dans(m, '[data-commande="section"]')).toBeNull();
|
||
m.ui.basculerPleinEcran();
|
||
flushSync();
|
||
await m.seance.passerEnEcriture();
|
||
flushSync();
|
||
expect(fixe(m, 'modifier')).toBeNull();
|
||
expect(fixe(m, 'lecture').textContent).toBe(t('communs.commande.lecture'));
|
||
expect(coin(fixe(m, 'lecture'))).toEqual(coins[0]);
|
||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||
flushSync();
|
||
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
|
||
expect(visible(fixe(m, 'debloquer'))).toBe(true);
|
||
});
|
||
|
||
test('un double-clic sur « Modifier » passe en écriture et y reste : le second clic de la série tombe sur « Revenir en lecture », venu à sa place, qui ne l’exécute pas', async () => {
|
||
const m = await neuf();
|
||
const avant = m.trace.length;
|
||
const clics = [];
|
||
m.racine.addEventListener('click', (evenement) => clics.push([evenement.target.dataset.commande, evenement.detail]), true);
|
||
const point = await cliquerEnSerie(fixe(m, 'modifier'), 1);
|
||
await attendre(() => expect(fixe(m, 'lecture')).not.toBeNull());
|
||
await viderFile(m);
|
||
await cliquerEnSerie(point, 2);
|
||
await viderFile(m);
|
||
expect(clics).toEqual([
|
||
['modifier', 1],
|
||
['lecture', 2],
|
||
]);
|
||
expect(m.seance.etat().mode).toBe('ecriture');
|
||
expect(fixe(m, 'lecture')).not.toBeNull();
|
||
const verrous = m.trace.slice(avant).filter(([primitive]) => primitive === 'verrouiller' || primitive === 'deverrouiller');
|
||
expect(verrous.map(([primitive]) => primitive)).toEqual(['verrouiller']);
|
||
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
|
||
test('deux cents caractères sans espace : la page ne s’élargit pas, et « Modifier » garde sa place', async () => {
|
||
const m = await neuf();
|
||
const place = coin(fixe(m, 'modifier'));
|
||
await m.seance.fermer();
|
||
for (const motif of ['W', 'WW W', '\u{C6}\u{C6}\u{C6}', '\u{D4}jgq']) {
|
||
const nom = motif.repeat(Math.ceil(200 / motif.length)).slice(0, 200);
|
||
expect(nom.length).toBe(200);
|
||
await m.seance.creer({ ...EVENEMENT, nom });
|
||
flushSync();
|
||
const titre = dans(m, '[data-ecran="evenement"] h1');
|
||
expect(titre.textContent).toBe(nom);
|
||
expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth);
|
||
expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth);
|
||
// Ni le titre ni la tête qui le porte ne débordent : la tête défile en
|
||
// hauteur, et un nom qui ne passerait pas à la ligne s'étendrait en
|
||
// largeur sous elle sans élargir la page.
|
||
const tete = titre.closest('header').parentElement;
|
||
expect([titre.scrollWidth, tete.scrollWidth], motif).toEqual([titre.clientWidth, tete.clientWidth]);
|
||
expect(coin(fixe(m, 'modifier')), motif).toEqual(place);
|
||
expect(visible(fixe(m, 'modifier')), motif).toBe(true);
|
||
await m.seance.fermer();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('passer en écriture (§ 8.4, § 19.3 01b)', () => {
|
||
test('avant « Modifier », « Bloqué » est refusé à côté de l’état avec « Modifier », sans rien écrire ; après, il écrit une entrée, un jalon, et la séance revient en lecture', async () => {
|
||
const m = await neuf();
|
||
const avant = m.trace.length;
|
||
boutonEtat(m, 'bloque').click();
|
||
const refus = await attendre(() => {
|
||
const element = dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(refus.textContent).toContain(t('MODE_LECTURE', {}));
|
||
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||
expect(visible(refus)).toBe(true);
|
||
expect(m.trace.length).toBe(avant);
|
||
fixe(m, 'modifier').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
|
||
// Sur un brouillon neuf, seul « Bloqué » pose un état : les autres sont
|
||
// refusés à côté d'eux, sans rien écrire.
|
||
const ecrites = m.trace.length;
|
||
for (const [etat, code] of [
|
||
['brouillon', 'SANS_EFFET'],
|
||
['propose', 'AUCUNE_PROPOSITION'],
|
||
['retenu', 'AUCUN_RETENU'],
|
||
]) {
|
||
boutonEtat(m, etat).click();
|
||
await attendre(() => expect(refusPres(boutonEtat(m, etat), code)).not.toBeNull());
|
||
}
|
||
expect(m.trace.length).toBe(ecrites);
|
||
const { entrees, jalons } = journalDe(m);
|
||
boutonEtat(m, 'bloque').click();
|
||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('bloque'));
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||
const apres = journalDe(m);
|
||
expect(apres.entrees.length).toBe(entrees.length + 1);
|
||
expect(apres.jalons.length).toBe(jalons.length + 1);
|
||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||
expect(fixe(m, 'debloquer')).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('le cadre de mode (§ 8.4, § 14.5)', () => {
|
||
test('il contient la surface, sa bordure n’est pas nulle, son libellé se voit ; texte et trait diffèrent en lecture, en écriture et bloqué ; même boîte après défilement', async () => {
|
||
const m = await neuf();
|
||
const releves = [];
|
||
const relever = () => {
|
||
flushSync();
|
||
const boite = cadre(m).getBoundingClientRect();
|
||
const surface = dans(m, '[data-section]').getBoundingClientRect();
|
||
expect(boite.left).toBeLessThanOrEqual(surface.left);
|
||
expect(boite.top).toBeLessThanOrEqual(surface.top);
|
||
expect(boite.right).toBeGreaterThanOrEqual(surface.right);
|
||
expect(boite.bottom).toBeGreaterThanOrEqual(surface.bottom);
|
||
const style = getComputedStyle(cadre(m));
|
||
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0);
|
||
expect(style.borderTopStyle).not.toBe('none');
|
||
const libelle = libelleCadre(m);
|
||
expect(libelle.checkVisibility()).toBe(true);
|
||
expect(visible(libelle)).toBe(true);
|
||
expect(libelle.contains(auCentre(libelle))).toBe(true);
|
||
expect(libelle.textContent).toBe(libelleBandeau(m).textContent);
|
||
releves.push({ texte: libelle.textContent, trait: style.borderTopStyle });
|
||
};
|
||
relever();
|
||
await m.seance.passerEnEcriture();
|
||
relever();
|
||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||
relever();
|
||
expect(releves.map(({ texte }) => texte)).toEqual([
|
||
t('coquille.mode.lecture'),
|
||
t('coquille.mode.ecriture'),
|
||
t('coquille.mode.bloque'),
|
||
]);
|
||
expect(new Set(releves.map(({ trait }) => trait)).size).toBe(3);
|
||
// Une surface plus haute que la fenêtre : la page et la surface
|
||
// défilées jusqu'en bas, le libellé garde sa boîte.
|
||
const avant = libelleCadre(m).getBoundingClientRect().toJSON();
|
||
const haut = document.createElement('div');
|
||
haut.style.height = '3000px';
|
||
dans(m, '[data-section]').append(haut);
|
||
window.scrollTo(0, document.documentElement.scrollHeight);
|
||
for (let porteur = dans(m, '[data-section]'); porteur !== null; porteur = porteur.parentElement) {
|
||
porteur.scrollTop = porteur.scrollHeight;
|
||
}
|
||
expect(libelleCadre(m).getBoundingClientRect().toJSON()).toEqual(avant);
|
||
expect(visible(libelleCadre(m))).toBe(true);
|
||
expect(visible(fixe(m, 'debloquer'))).toBe(true);
|
||
haut.remove();
|
||
});
|
||
});
|
||
|
||
describe('l’inactivité (§ 8.4, § 19.4)', () => {
|
||
for (const [ouvrir, fermer] of [
|
||
['debuterGeste', 'finirGeste'],
|
||
['debuterCalcul', 'finirCalcul'],
|
||
]) {
|
||
test(`${ouvrir} ouvert, onze minutes ne ramènent pas en lecture ; ${fermer}, dix minutes y ramènent : verrou rendu, cadre, bandeau et avertissement le disent`, async () => {
|
||
const m = await neuf();
|
||
fixe(m, 'modifier').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
m.seance[ouvrir]();
|
||
m.horloge.avancer(11 * 60 * 1000);
|
||
flushSync();
|
||
expect(m.seance.etat().mode).toBe('ecriture');
|
||
m.seance[fermer]();
|
||
m.horloge.avancer(INACTIVITE_MS);
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||
expect(libelleCadre(m).textContent).toBe(t('coquille.mode.lecture'));
|
||
expect(libelleBandeau(m).textContent).toBe(t('coquille.mode.lecture'));
|
||
const annonce = avertissement(m, 'RETOUR_AUTOMATIQUE');
|
||
expect(annonce.textContent).toContain(t('RETOUR_AUTOMATIQUE', {}));
|
||
expect(visible(annonce)).toBe(true);
|
||
expect(fixe(m, 'modifier')).not.toBeNull();
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('bloqué (§ 8.5, § 9)', () => {
|
||
test('« Bloquer » rend le verrou ; une autre séance le lit bloqué ; ni « Modifier », ni « Retenu », ni « Défaire » ne débloquent ; « Débloquer » se confirme en place, puis écrit', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
boutonEtat(m, 'bloque').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||
expect(fichier(m, SUFFIXES.verrou)).toBeNull();
|
||
expect(fixe(m, 'debloquer')).not.toBeNull();
|
||
const base = baseDe(m);
|
||
const autre = await monter({ systeme: { disque: m.disque } });
|
||
await autre.seance.ouvrir(base);
|
||
flushSync();
|
||
expect(autre.seance.etat().evenement.etat).toBe('bloque');
|
||
expect(fixe(autre, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||
expect(fixe(autre, 'modifier')).toBeNull();
|
||
|
||
const avant = m.trace.length;
|
||
expect(fixe(m, 'modifier')).toBeNull();
|
||
boutonEtat(m, 'retenu').click();
|
||
await attendre(() => expect(refusPres(boutonEtat(m, 'retenu'), 'LECTURE_SEULE')).not.toBeNull());
|
||
historique(m, 'defaire').click();
|
||
const refusDefaire = await attendre(() => {
|
||
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(refusDefaire.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
|
||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||
expect(m.trace.length).toBe(avant);
|
||
|
||
// Un clic arme, sans rien écrire ; l'horloge le désarme au terme du délai.
|
||
fixe(m, 'debloquer').click();
|
||
flushSync();
|
||
expect(fixe(m, 'debloquer').textContent).toBe(t(CONFIRMATION_DEBLOCAGE));
|
||
m.horloge.avancer(DELAI_CONFIRMATION_MS);
|
||
flushSync();
|
||
expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer'));
|
||
fixe(m, 'debloquer').click();
|
||
flushSync();
|
||
expect(m.trace.length).toBe(avant);
|
||
expect(m.seance.etat().evenement.etat).toBe('bloque');
|
||
const { entrees, jalons } = journalDe(m);
|
||
fixe(m, 'debloquer').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
const { charge } = m.seance.etat();
|
||
expect(charge.evenement.etat).toBe(etatDeduit(charge));
|
||
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
|
||
expect(journalDe(m).jalons.length).toBe(jalons.length + 1);
|
||
expect(fixe(m, 'lecture')).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('les états du plan (§ 9)', () => {
|
||
test('avec des propositions et un retenu, « Brouillon » puis « Retenu » : rien d’autre ne change, deux entrées, deux jalons, libellés exacts', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
const avant = m.seance.etat().charge;
|
||
expect(avant.evenement.etat).toBe('retenu');
|
||
const { entrees, jalons } = journalDe(m);
|
||
boutonEtat(m, 'brouillon').click();
|
||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('brouillon'));
|
||
boutonEtat(m, 'retenu').click();
|
||
await attendre(() => expect(m.seance.etat().evenement.etat).toBe('retenu'));
|
||
const apres = m.seance.etat().charge;
|
||
expect(apres).toEqual(avant);
|
||
const journal = journalDe(m);
|
||
expect(journal.entrees.slice(entrees.length).map((entree) => entree.libelle)).toEqual([
|
||
libelle('ENTREE_CHANGER_ETAT', { etat: 'brouillon' }),
|
||
libelle('ENTREE_CHANGER_ETAT', { etat: 'retenu' }),
|
||
]);
|
||
expect(journal.jalons.length).toBe(jalons.length + 2);
|
||
expect(journal.jalons.slice(-2).map(({ nom }) => nom)).toEqual([
|
||
libelle('JALON_ETAT', { etat: 'brouillon' }),
|
||
libelle('JALON_ETAT', { etat: 'retenu' }),
|
||
]);
|
||
});
|
||
|
||
test('« Retenu » sans retenu : refusé à côté de l’état, avec la section des propositions pour remède', async () => {
|
||
const m = await neuf();
|
||
fixe(m, 'modifier').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
const avant = m.trace.length;
|
||
boutonEtat(m, 'retenu').click();
|
||
const refus = await attendre(() => {
|
||
const element = refusPres(boutonEtat(m, 'retenu'), 'AUCUN_RETENU');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(visible(refus)).toBe(true);
|
||
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
|
||
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.propositions'));
|
||
remede.click();
|
||
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
|
||
expect(m.trace.length).toBe(avant);
|
||
});
|
||
});
|
||
|
||
describe('défaire, refaire (§ 8.3)', () => {
|
||
const nombre = (m) => m.seance.etat().charge.participants.length;
|
||
|
||
test('trois gestes, « Défaire » rend le deuxième et offre « Refaire » ; « Refaire » rend le troisième ; un geste après « Défaire » retire « Refaire »', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||
flushSync();
|
||
expect(historique(m, 'refaire')).toBeNull();
|
||
historique(m, 'defaire').click();
|
||
await attendre(() => expect(nombre(m)).toBe(2));
|
||
expect(historique(m, 'refaire').textContent).toBe(t('evenement.commande.refaire'));
|
||
historique(m, 'refaire').click();
|
||
await attendre(() => expect(nombre(m)).toBe(3));
|
||
historique(m, 'defaire').click();
|
||
await attendre(() => expect(nombre(m)).toBe(2));
|
||
expect(historique(m, 'refaire')).not.toBeNull();
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
|
||
await attendre(() => expect(historique(m, 'refaire')).toBeNull());
|
||
});
|
||
|
||
test('un double-clic sur « Défaire » rend un seul geste : le second clic de la série, reçu une fois le premier retour écrit, n’en défait pas un autre', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||
flushSync();
|
||
const { entrees } = journalDe(m);
|
||
const point = await cliquerEnSerie(historique(m, 'defaire'), 1);
|
||
await attendre(() => expect(nombre(m)).toBe(2));
|
||
await viderFile(m);
|
||
expect(historique(m, 'defaire').getAttribute('aria-busy')).toBeNull();
|
||
await cliquerEnSerie(point, 2);
|
||
await viderFile(m);
|
||
expect(nombre(m)).toBe(2);
|
||
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
|
||
// Un clic simple ensuite défait de nouveau.
|
||
await cliquerEnSerie(historique(m, 'defaire'), 1);
|
||
await attendre(() => expect(nombre(m)).toBe(1));
|
||
});
|
||
|
||
test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
for (const nom of NOMS.slice(0, 4)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||
expect(m.seance.etat().evenement.revision).toBe(5);
|
||
await m.seance.revenir(2);
|
||
flushSync();
|
||
expect(nombre(m)).toBe(1);
|
||
historique(m, 'defaire').click();
|
||
await attendre(() => expect(nombre(m)).toBe(0));
|
||
});
|
||
|
||
test('en lecture, « Défaire » est refusé à côté de lui avec « Modifier » ; bloqué, avec « Débloquer » ; rien ne s’écrit', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
|
||
await m.seance.passerEnLecture();
|
||
flushSync();
|
||
const avant = m.trace.length;
|
||
historique(m, 'defaire').click();
|
||
const enLecture = await attendre(() => {
|
||
const element = refusPres(historique(m, 'defaire'), 'MODE_LECTURE');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(enLecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||
expect(m.trace.length).toBe(avant);
|
||
expect(nombre(m)).toBe(1);
|
||
await m.seance.passerEnEcriture();
|
||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||
flushSync();
|
||
const bloque = m.trace.length;
|
||
historique(m, 'defaire').click();
|
||
const refus = await attendre(() => {
|
||
const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
expect(refus.querySelector('[data-commande="debloquer"]')).not.toBeNull();
|
||
expect(m.trace.length).toBe(bloque);
|
||
});
|
||
});
|
||
|
||
describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
|
||
test('une proposition de longueur fausse et une périmée : l’écartée nomme son numéro et sa faute ; la dérive nomme la périmée et sa raison, et la charge la garde', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m, { retenir: false });
|
||
const base = baseDe(m);
|
||
const [premiere, seconde] = m.seance.etat().charge.propositions.map(({ id }) => id);
|
||
await m.seance.fermer();
|
||
reecrireEtat(m, base, (charge) => {
|
||
charge.propositions[0].placement.pop();
|
||
charge.participants[0].exclu = true;
|
||
});
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
const etat = m.seance.etat();
|
||
expect(etat.fautives.length).toBe(1);
|
||
const [fautive] = etat.fautives;
|
||
expect(fautive.id).toBe(premiere);
|
||
const ecartee = avertissement(m, 'PROPOSITIONS_FAUTIVES');
|
||
expect(visible(ecartee)).toBe(true);
|
||
expect(ecartee.textContent).toContain(t('PROPOSITIONS_FAUTIVES', { ids: [fautive.id] }));
|
||
expect(fautive.fautes.length).toBeGreaterThan(0);
|
||
for (const faute of fautive.fautes) expect(ecartee.textContent).toContain(t(faute.code, faute));
|
||
expect(ecartee.querySelector('[data-fautive]').textContent).toContain(String(fautive.id));
|
||
const derive = avertissement(m, 'DERIVE');
|
||
expect(derive.textContent).toContain(t('DERIVE', { ids: [seconde], retenu: false }));
|
||
const [{ raisons }] = etat.examen.derives;
|
||
expect(raisons.length).toBeGreaterThan(0);
|
||
for (const raison of raisons) expect(derive.textContent).toContain(t(raison.code, raison));
|
||
expect(etat.charge.propositions.map(({ id }) => id)).toEqual([seconde]);
|
||
derive.querySelector('[data-commande="ouvrirSection"]').click();
|
||
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
|
||
});
|
||
|
||
test('en plein écran, le remède d’une dérive mène à sa section, et la navigation des sections y revient : le plein écran ne vaut que pour le plan', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||
m.ui.allerA('plan');
|
||
flushSync();
|
||
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
|
||
dans(m, '[data-commande="pleinEcran"]').click();
|
||
flushSync();
|
||
expect(tous(m, '[data-commande="section"]')).toHaveLength(0);
|
||
const remede = await attendre(() => {
|
||
const trouve = dans(m, '[data-annonces] [data-avertissement="DERIVE_NOUVELLE"] [data-commande="ouvrirSection"]');
|
||
expect(trouve).not.toBeNull();
|
||
return trouve;
|
||
});
|
||
remede.click();
|
||
await attendre(() => expect(m.ui.etat().section).not.toBe('plan'));
|
||
flushSync();
|
||
const navigation = tous(m, '[data-commande="section"]');
|
||
expect(navigation).toHaveLength(SECTIONS.length);
|
||
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
|
||
navigation.find((bouton) => bouton.dataset.cible === 'tables').click();
|
||
flushSync();
|
||
expect(dans(m, '[data-section]').dataset.section).toBe('tables');
|
||
m.ui.allerA('plan');
|
||
flushSync();
|
||
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
|
||
});
|
||
|
||
test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
const base = baseDe(m);
|
||
await m.seance.fermer();
|
||
reecrireEtat(m, base, (charge) => {
|
||
charge.retenu.placement.pop();
|
||
});
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
const premier = dans(m, '[data-avertissement]');
|
||
expect(premier.dataset.avertissement).toBe('RETENU_FAUTIF');
|
||
expect(premier.textContent).toContain(t('RETENU_FAUTIF', {}));
|
||
const remede = premier.querySelector('[data-commande="ouvrirSecours"]');
|
||
expect(remede.textContent).toBe(t('communs.remede.ouvrirSecours.precedent'));
|
||
const avant = m.trace.length;
|
||
remede.click();
|
||
await attendre(() => expect(m.seance.etat().examen.retenu.fautes).toEqual([]));
|
||
expect(m.seance.etat().mode).toBe('lecture');
|
||
expect(m.seance.etat().charge.retenu).toBeNull();
|
||
expect(avertissement(m, 'RETENU_FAUTIF')).toBeNull();
|
||
expect(m.trace.length).toBe(avant);
|
||
});
|
||
});
|
||
|
||
describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', () => {
|
||
// Ferme l'événement garni et généré de base, l'abîme, puis le rouvre : une
|
||
// proposition de longueur fausse, une périmée, et une entrée du journal
|
||
// abîmée lui donnent trois avertissements à l'ouverture, au moins.
|
||
async function rouvrirAbime(m, base) {
|
||
await m.seance.fermer();
|
||
reecrireEtat(m, base, (charge) => {
|
||
charge.propositions[0].placement.pop();
|
||
charge.participants[0].exclu = true;
|
||
});
|
||
const chemin = `${base}${SUFFIXES.journal}`;
|
||
const lignes = m.fs.contenu('documents', chemin).split('\n');
|
||
const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 4);
|
||
expect(rang).toBeGreaterThan(0);
|
||
lignes[rang] = '{"type":"entree", abîmée';
|
||
m.fs.deposer('documents', chemin, lignes.join('\n'));
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
}
|
||
|
||
test('à 1 366 × 700, trois avertissements et plus : la page ne défile pas, le plan garde sa hauteur ; les avertissements défilent à la molette, sans rien déplacer', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m, { retenir: false });
|
||
const base = baseDe(m);
|
||
// La hauteur du dessin du plan sans aucune annonce : la mesure de
|
||
// référence de ce que les avertissements peuvent lui prendre.
|
||
m.ui.allerA('plan');
|
||
flushSync();
|
||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
|
||
expect(hauteurSeule).toBeGreaterThan(0);
|
||
await rouvrirAbime(m, base);
|
||
expect(m.ui.etat().section).toBe('plan');
|
||
const montres = tous(m, '[data-avertissement]');
|
||
expect(montres.length).toBeGreaterThanOrEqual(3);
|
||
const boite = (element) => element.getBoundingClientRect().toJSON();
|
||
// La page ne défile pas : le cadre tient dans la fenêtre, et la section
|
||
// du plan dans le cadre, sans que la surface qui la porte ait à défiler.
|
||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
|
||
const section = dans(m, '[data-section]');
|
||
const porteur = section.parentElement;
|
||
expect(section.getBoundingClientRect().bottom).toBeLessThanOrEqual(porteur.getBoundingClientRect().bottom);
|
||
expect(porteur.scrollHeight).toBeLessThanOrEqual(porteur.clientHeight);
|
||
expect(visible(dans(m, '[data-plan]'))).toBe(true);
|
||
// Le plan reste la surface de travail : les annonces ne prennent pas
|
||
// plus du quart de la hauteur de son dessin.
|
||
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule * 0.75);
|
||
// Les avertissements débordent leur zone, qui défile seule, à la
|
||
// molette, comme sous la main de l'opérateur : le dernier, amené dans la
|
||
// zone, se voit, et ni la page, ni la place fixe, ni la section n'ont
|
||
// bougé.
|
||
const zone = montres[0].closest('[data-annonces]');
|
||
expect(zone).not.toBeNull();
|
||
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
|
||
expect(zone.scrollHeight).toBeGreaterThan(zone.clientHeight);
|
||
expect(['auto', 'scroll']).toContain(getComputedStyle(zone).overflowY);
|
||
expect(visible(montres[0])).toBe(true);
|
||
const place = coin(fixe(m, 'modifier'));
|
||
const avant = boite(section);
|
||
const surZone = zone.getBoundingClientRect();
|
||
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
|
||
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
|
||
expect(zone.scrollTop).toBeGreaterThan(0);
|
||
expect(window.scrollY).toBe(0);
|
||
expect(coin(fixe(m, 'modifier'))).toEqual(place);
|
||
expect(boite(section)).toEqual(avant);
|
||
// Sur chaque section, la page reste sans défilement, et la zone garde
|
||
// sa boîte.
|
||
const zoneAvant = boite(zone);
|
||
for (const nom of SECTIONS) {
|
||
m.ui.allerA(nom);
|
||
flushSync();
|
||
expect(document.documentElement.scrollHeight, nom).toBeLessThanOrEqual(innerHeight);
|
||
expect(boite(dans(m, '[data-annonces]')), nom).toEqual(zoneAvant);
|
||
}
|
||
});
|
||
|
||
test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan garde toute sa hauteur, et ses chaises au zoom ajusté', async () => {
|
||
const m = await monter();
|
||
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
|
||
await m.seance.passerEnEcriture();
|
||
for (let n = 0; n < 18; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `${NOMS[n % NOMS.length]} ${n + 1}` } });
|
||
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
|
||
await generer(m);
|
||
m.ui.allerA('plan');
|
||
flushSync();
|
||
const ajuster = async () => {
|
||
dans(m, '[data-commande="ajuster"]').click();
|
||
flushSync();
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
flushSync();
|
||
};
|
||
await ajuster();
|
||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
|
||
expect(hauteurSeule).toBeGreaterThan(0);
|
||
// Trois gestes admis sur le retenu, chacun une dérive annoncée.
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||
await m.seance.executer('creerTable', { forme: 'ronde' });
|
||
await m.seance.executer('changerTours', { tours: 4 });
|
||
flushSync();
|
||
await ajuster();
|
||
const montres = tous(m, '[data-annonces] [data-avertissement]');
|
||
expect(montres.length).toBeGreaterThan(0);
|
||
// La zone se voit, et le premier avertissement s'ouvre dans elle.
|
||
const zone = dans(m, '[data-annonces]');
|
||
expect(visible(zone)).toBe(true);
|
||
const [boiteZone, premier] = [zone.getBoundingClientRect(), montres[0].getBoundingClientRect()];
|
||
expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top);
|
||
expect(premier.top).toBeLessThan(boiteZone.bottom - 16);
|
||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - 1);
|
||
const tables = tous(m, '[data-plan] [data-table]');
|
||
expect(tables.length).toBe(4);
|
||
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);
|
||
// La navigation et la place fixe restent atteintes.
|
||
const navigation = tous(m, '[data-commande="section"]');
|
||
expect(navigation).toHaveLength(SECTIONS.length);
|
||
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
|
||
expect(fixe(m, 'lecture').contains(auCentre(fixe(m, 'lecture')))).toBe(true);
|
||
});
|
||
|
||
test('à 1 366 × 700, un support rompu et trois avertissements et plus : chaque issue du support rompu se voit sans défiler, la page ne défile pas, et les avertissements défilent sous lui', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m, { retenir: false });
|
||
await rouvrirAbime(m, baseDe(m));
|
||
expect(tous(m, '[data-avertissement]').length).toBeGreaterThanOrEqual(3);
|
||
fixe(m, 'modifier').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
m.fs.pannes.ecriture('documents');
|
||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
|
||
flushSync();
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
expect(m.seance.etat().supportRompu).not.toBeNull();
|
||
const montres = tous(m, '[data-avertissement]');
|
||
expect(montres.length).toBeGreaterThanOrEqual(3);
|
||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
|
||
// Les issues du support rompu, seule sortie de l'opérateur, se voient
|
||
// sans que rien ne défile.
|
||
const message = dans(m, '[data-support-rompu]');
|
||
for (const nom of ['choisirDossier', 'abandonner']) {
|
||
const issue = message.querySelector(`[data-commande="${nom}"]`);
|
||
expect(issue, nom).not.toBeNull();
|
||
expect(visible(issue), nom).toBe(true);
|
||
}
|
||
expect(visible(fixe(m, 'lecture'))).toBe(true);
|
||
expect(visible(dans(m, '[data-plan]'))).toBe(true);
|
||
// Les avertissements restent dans la zone, qui défile seule : le
|
||
// dernier, amené à la molette, se voit, et la page n'a pas bougé.
|
||
const zone = message.closest('[data-annonces]');
|
||
expect(zone).not.toBeNull();
|
||
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
|
||
const surZone = zone.getBoundingClientRect();
|
||
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
|
||
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
|
||
expect(window.scrollY).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('le journal (§ 8.6, § 8.2)', () => {
|
||
test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
for (let n = 2; n <= 100; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `Ombrelle ${n}` } });
|
||
const base = baseDe(m);
|
||
const journal = journalDe(m);
|
||
expect(journal.entrees.length).toBe(100);
|
||
expect(journal.jalons.length).toBe(1);
|
||
await m.seance.fermer();
|
||
const chemin = `${base}${SUFFIXES.journal}`;
|
||
const lignes = m.fs.contenu('documents', chemin).split('\n');
|
||
const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 37);
|
||
expect(rang).toBeGreaterThan(0);
|
||
lignes[rang] = '{"type":"entree", abîmée';
|
||
m.fs.deposer('documents', chemin, lignes.join('\n'));
|
||
const avant = m.trace.length;
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
const ecartees = avertissement(m, 'JOURNAL_ECARTEES');
|
||
expect(ecartees.textContent).toContain(t('JOURNAL_ECARTEES', { n: 64 }));
|
||
expect(ecartees.textContent).toContain('64');
|
||
expect(visible(ecartees)).toBe(true);
|
||
expect(m.trace.length).toBe(avant);
|
||
});
|
||
|
||
test('un journal en avance d’une entrée : la charge du geste est celle qui s’ouvre, annoncée, sans rien écrire', async () => {
|
||
const m = await neuf();
|
||
await m.seance.passerEnEcriture();
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } });
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } });
|
||
const base = baseDe(m);
|
||
await m.seance.fermer();
|
||
m.fs.deposer('documents', `${base}${SUFFIXES.etat}`, m.fs.contenu('documents', `${base}${SUFFIXES.precedent}`));
|
||
const avant = m.trace.length;
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
expect(m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(NOMS.slice(0, 2));
|
||
expect(m.seance.etat().evenement.revision).toBe(3);
|
||
expect(avertissement(m, 'JOURNAL_REPRIS').textContent).toContain(t('JOURNAL_REPRIS', {}));
|
||
expect(m.trace.length).toBe(avant);
|
||
});
|
||
});
|
||
|
||
describe('la dérive (§ 9)', () => {
|
||
test('retenu, puis exclure une personne : l’avertissement nomme le retenu et la raison, et le geste suivant reste permis', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
const exclue = m.seance.etat().charge.participants[0].id;
|
||
await m.seance.executer('exclureParticipant', { id: exclue });
|
||
flushSync();
|
||
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
|
||
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
|
||
const element = avertissement(m, 'DERIVE_NOUVELLE');
|
||
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
|
||
// La raison telle que la séance la rend, détails compris : son texte
|
||
// est celui que l'avertissement affiche.
|
||
const { raisons } = nouvelle.details.touchees.find(({ id }) => id === 'retenu');
|
||
const exclusion = raisons.find(({ code }) => code === 'PARTICIPANT_EXCLU');
|
||
expect(exclusion.participant).toBe(exclue);
|
||
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', exclusion));
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
|
||
});
|
||
|
||
test('retenu : supprimer une table, réduire une capacité, ajouter une personne, chacun accepté, l’avertissement nommant les propositions touchées ; leur nombre inchangé ; un import, IMPORT_EN_RETENU', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
const propositions = m.seance.etat().charge.propositions.length;
|
||
const [premiere, seconde] = m.seance.etat().charge.tables;
|
||
const gestes = [
|
||
['supprimerTable', { id: premiere.id }],
|
||
['changerPlacesTable', { id: seconde.id, sieges: 2, origine: 'champ' }],
|
||
['ajouterParticipant', { champs: { nom: 'Escarbille' } }],
|
||
];
|
||
for (const [nom, args] of gestes) {
|
||
await m.seance.executer(nom, args);
|
||
flushSync();
|
||
const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE');
|
||
expect(nouvelle, nom).toBeDefined();
|
||
expect(nouvelle.details.touchees.length, nom).toBeGreaterThan(0);
|
||
expect(avertissement(m, 'DERIVE_NOUVELLE').textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
|
||
expect(m.seance.etat().charge.propositions.length).toBe(propositions);
|
||
}
|
||
const apercu = apercevoir('nom\nTilleraie\n', { participants: m.seance.etat().charge.participants });
|
||
await m.seance.executer('importerParticipants', { apercu, mode: 'ajouter' });
|
||
flushSync();
|
||
expect(avertissement(m, 'IMPORT_EN_RETENU').textContent).toContain(t('IMPORT_EN_RETENU', {}));
|
||
});
|
||
});
|
||
|
||
describe('la tête reste en place quand les annonces vont et viennent (§ 2.1, § 8.4)', () => {
|
||
test('une dérive affichée, l’écriture refusée : « Bloqué » cliqué reste le même élément, et porte à côté de lui le refus ECRITURE', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||
flushSync();
|
||
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
|
||
m.fs.pannes.ecriture('documents');
|
||
const avant = boutonEtat(m, 'bloque');
|
||
avant.click();
|
||
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
|
||
await viderFile(m);
|
||
// Le support rompu fait passer les annonces sous la tête : le bouton du
|
||
// geste reste celui qu'a reçu le clic, et le refus se pose à côté de lui.
|
||
expect(boutonEtat(m, 'bloque')).toBe(avant);
|
||
expect(avant.isConnected).toBe(true);
|
||
await attendre(() => expect(refusPres(avant, 'ECRITURE')).not.toBeNull());
|
||
expect(visible(refusPres(avant, 'ECRITURE'))).toBe(true);
|
||
});
|
||
|
||
test('le premier avertissement venu, le focus du clavier reste sur le bouton de la tête qui le portait', async () => {
|
||
const m = await neuf();
|
||
await garnir(m);
|
||
await generer(m);
|
||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||
const bouton = dans(m, '[data-commande="section"][data-cible="plan"]');
|
||
bouton.focus();
|
||
expect(document.activeElement).toBe(bouton);
|
||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||
flushSync();
|
||
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
|
||
expect(dans(m, '[data-commande="section"][data-cible="plan"]')).toBe(bouton);
|
||
expect(document.activeElement).toBe(bouton);
|
||
});
|
||
});
|
||
|
||
describe('le verrou d’une autre séance (§ 8.8)', () => {
|
||
test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => {
|
||
const premier = await neuf();
|
||
await premier.seance.passerEnEcriture();
|
||
const base = baseDe(premier);
|
||
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
|
||
const second = await monter({ systeme: { disque: premier.disque } });
|
||
// Les deux montages s'empilent dans la page : le premier se cache, et
|
||
// le second occupe la fenêtre.
|
||
premier.racine.hidden = true;
|
||
await second.seance.ouvrir(base);
|
||
flushSync();
|
||
expect(second.seance.etat().mode).toBe('lecture');
|
||
expect(dans(second, '[data-refus]')).toBeNull();
|
||
fixe(second, 'modifier').click();
|
||
const refus = await attendre(() => {
|
||
const element = refusPres(fixe(second, 'modifier'), 'VERROU_PRIS');
|
||
expect(element).not.toBeNull();
|
||
return element;
|
||
});
|
||
const pris = second.seance.etat().verrouAutre;
|
||
expect(pris).toEqual({ seance: tenu.seance, depuis: tenu.depuis, vivant: true });
|
||
expect(refus.textContent).toContain(t('VERROU_PRIS', { ...pris, remede: null }));
|
||
const verrou = dans(second, '[data-verrou]');
|
||
expect(visible(verrou)).toBe(true);
|
||
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
|
||
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
|
||
expect(verrou.querySelector('[data-commande="reprendreVerrou"]')).toBeNull();
|
||
premier.fs.pannes.couperApres(0);
|
||
fixe(second, 'modifier').click();
|
||
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
|
||
const repris = avertissement(second, 'VERROU_REPRIS');
|
||
expect(repris.textContent).toContain(t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }));
|
||
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
|
||
expect(dans(second, '[data-verrou]')).toBeNull();
|
||
});
|
||
|
||
test('le verrou d’un autre poste : la reprise s’offre à côté du refus, se confirme en place en nommant la séance et l’instant, puis le reprend', async () => {
|
||
const premier = await neuf();
|
||
await premier.seance.passerEnEcriture();
|
||
const base = baseDe(premier);
|
||
const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou));
|
||
const second = await monter({ systeme: { disque: premier.disque, hote: 'poste-voisin' } });
|
||
premier.racine.hidden = true;
|
||
await second.seance.ouvrir(base);
|
||
flushSync();
|
||
fixe(second, 'modifier').click();
|
||
await attendre(() => expect(refusPres(fixe(second, 'modifier'), 'VERROU_PRIS')).not.toBeNull());
|
||
expect(second.seance.etat().verrouAutre.vivant).toBeNull();
|
||
const verrou = dans(second, '[data-verrou]');
|
||
expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance);
|
||
expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis);
|
||
const reprendre = () => dans(second, '[data-verrou] [data-commande="reprendreVerrou"]');
|
||
expect(reprendre().textContent).toBe(t('communs.remede.reprendreVerrou'));
|
||
expect(visible(reprendre())).toBe(true);
|
||
const avant = second.trace.length;
|
||
reprendre().click();
|
||
flushSync();
|
||
expect(reprendre().textContent).toBe(t('evenement.verrou.confirmerReprise'));
|
||
expect(verrou.contains(reprendre())).toBe(true);
|
||
expect(second.trace.length).toBe(avant);
|
||
expect(second.seance.etat().mode).toBe('lecture');
|
||
reprendre().click();
|
||
await attendre(() => expect(second.seance.etat().mode).toBe('ecriture'));
|
||
expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id);
|
||
expect(avertissement(second, 'VERROU_REPRIS').textContent).toContain(
|
||
t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }),
|
||
);
|
||
expect(dans(second, '[data-verrou]')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('le support rompu (§ 8.6, § 8.2)', () => {
|
||
const message = (m) => dans(m, '[data-support-rompu]');
|
||
const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]');
|
||
|
||
// Le message offre exactement les issues noms, et chacune se voit au cadre
|
||
// de l'épreuve : c'est la seule sortie de l'opérateur d'un support rompu.
|
||
function issuesVisibles(m, noms) {
|
||
const offertes = [...message(m).querySelectorAll('[data-commande]')].map((issue) => issue.dataset.commande);
|
||
expect(offertes).toEqual(noms);
|
||
for (const nom of noms) expect(visible(message(m).querySelector(`[data-commande="${nom}"]`)), nom).toBe(true);
|
||
}
|
||
|
||
// L'événement ouvert en écriture, puis le support de racine refuse
|
||
// l'écriture d'un geste.
|
||
async function rompre(m, racine) {
|
||
fixe(m, 'modifier').click();
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||
m.fs.pannes.ecriture(racine);
|
||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
|
||
flushSync();
|
||
}
|
||
|
||
test('publié : le message nomme le fichier, le dossier et la rupture ; « Liste » est refusée ; « Écrire dans les Documents » y écrit, et la séance s’y poursuit', async () => {
|
||
const m = await neuf({ systeme: { executable: 'E:\\soirees' } });
|
||
expect(m.seance.etat().racine.id).toBe('portable');
|
||
const base = baseDe(m);
|
||
await rompre(m, 'portable');
|
||
const rompu = m.seance.etat().supportRompu;
|
||
expect(rompu.ailleurs).toEqual({ id: 'documents', chemin: DOCUMENTS });
|
||
expect(visible(message(m))).toBe(true);
|
||
const { ailleurs, ...details } = rompu;
|
||
expect(message(m).textContent).toContain(t('SUPPORT_ROMPU', details));
|
||
expect(message(m).textContent).toContain(rompu.chemin);
|
||
expect(message(m).textContent).toContain(rompu.dossier);
|
||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } })).rejects.toMatchObject({
|
||
code: 'SUPPORT_ROMPU',
|
||
});
|
||
// Le refus posé dans le bandeau ne déplace pas la place fixe, et ne la
|
||
// couvre pas.
|
||
const place = coin(fixe(m, 'lecture'));
|
||
commandeListe(m).click();
|
||
await attendre(() => expect(dans(m, '[data-refus="liste"] [data-refus="SUPPORT_ROMPU_NON_SAUVE"]')).not.toBeNull());
|
||
expect(coin(fixe(m, 'lecture'))).toEqual(place);
|
||
expect(visible(fixe(m, 'lecture'))).toBe(true);
|
||
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
|
||
expect(m.seance.etat().evenement.base).toBe(base);
|
||
issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']);
|
||
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
|
||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||
const nouvelle = baseDe(m);
|
||
expect(m.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).not.toBeNull();
|
||
const entrees = journalDe(m, 'documents').entrees.length;
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[2] } });
|
||
expect(journalDe(m, 'documents').entrees.length).toBe(entrees + 1);
|
||
expect(message(m)).toBeNull();
|
||
commandeListe(m).click();
|
||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||
expect(m.seance.etat().racine.id).toBe('documents');
|
||
await attendre(() => expect(dans(m, `[data-evenement="${nouvelle}"]`)).not.toBeNull());
|
||
const dossier = dans(m, '[data-dossier-travail]');
|
||
expect(dossier.textContent).toBe(DOCUMENTS);
|
||
expect(visible(dossier)).toBe(true);
|
||
});
|
||
|
||
test('non publié : le message n’offre que « Choisir un dossier… » ; le dialogue annulé ne change rien', async () => {
|
||
const m = await neuf();
|
||
await rompre(m, 'documents');
|
||
expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
|
||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
|
||
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
|
||
choisir.click();
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
|
||
expect(visible(message(m))).toBe(true);
|
||
});
|
||
|
||
test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => {
|
||
const m = await neuf();
|
||
await rompre(m, 'documents');
|
||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||
const avant = m.trace.length;
|
||
const abandonner = () => message(m).querySelector('[data-commande="abandonner"]');
|
||
expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner'));
|
||
abandonner().click();
|
||
flushSync();
|
||
expect(abandonner().textContent).toBe(t('evenement.supportRompu.confirmerAbandon'));
|
||
expect(m.seance.etat().evenement).not.toBeNull();
|
||
abandonner().click();
|
||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||
expect(m.seance.etat().evenement).toBeNull();
|
||
expect(m.trace.slice(avant).filter(([primitive]) => primitive !== 'deverrouiller')).toEqual([]);
|
||
});
|
||
|
||
test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => {
|
||
const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } });
|
||
await rompre(m, 'documents');
|
||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||
message(m).querySelector('[data-commande="choisirDossier"]').click();
|
||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||
expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' });
|
||
const base = baseDe(m);
|
||
expect(m.fs.contenu('choisi-1', `${base}${SUFFIXES.etat}`)).not.toBeNull();
|
||
const entrees = journalDe(m, 'choisi-1').entrees.length;
|
||
await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } });
|
||
expect(journalDe(m, 'choisi-1').entrees.length).toBe(entrees + 1);
|
||
commandeListe(m).click();
|
||
await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil'));
|
||
await attendre(() => expect(dans(m, `[data-evenement="${base}"]`)).not.toBeNull());
|
||
});
|
||
});
|
||
|
||
describe('le format plus récent (§ 8.8)', () => {
|
||
test('« Modifier » est remplacé par l’explication, et aucune commande de l’événement ne passe en écriture', async () => {
|
||
const m = await neuf();
|
||
const base = baseDe(m);
|
||
await m.seance.fermer();
|
||
const chemin = `${base}${SUFFIXES.etat}`;
|
||
const texte = m.fs.contenu('documents', chemin);
|
||
m.fs.deposer('documents', chemin, texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`));
|
||
await m.seance.ouvrir(base);
|
||
flushSync();
|
||
expect(fixe(m, 'modifier')).toBeNull();
|
||
const explication = dans(m, '[data-place-fixe] [data-refus="LECTURE_SEULE"]');
|
||
expect(explication.textContent).toContain(t('LECTURE_SEULE', { raison: 'FORMAT', remede: null }));
|
||
expect(visible(explication)).toBe(true);
|
||
const avant = m.trace.length;
|
||
const boutons = tous(m, '[data-ecran="evenement"] button').filter((bouton) => bouton.dataset.commande !== 'section');
|
||
expect(boutons.length).toBeGreaterThan(0);
|
||
for (const bouton of boutons) {
|
||
if (!bouton.isConnected) continue;
|
||
bouton.click();
|
||
bouton.click();
|
||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
}
|
||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||
expect(m.trace.length).toBe(avant);
|
||
});
|
||
});
|