gestion_table_tournante_libre/src/interface/generation/Generation.navigateur.test.js

769 lines
36 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La section de la génération dans un vrai moteur de rendu (§ 5.7, § 5.9,
// § 5.10, § 2.1 étape 9 et « ce qui doit avoir eu lieu », § 15.4, § 8.4,
// § 9) : ses réglages, leur libellé et leur unité ; la graine, saisie de la
// commande ; le travailleur, qui rend ce que rend rechercher ; l'application
// vivante pendant le calcul ; l'annulation ; les refus posés à côté de
// « Générer » avec leur remède ; la durée observée ; la génération longue,
// annoncée en place.
//
// Chaque épreuve monte App par monterApp, sur le système de fichiers
// simulé, et nomme son exécuteur : le travailleur — inclus en ligne comme
// src/main.js l'inclut — pour un calcul hors du fil ; un exécuteur que
// l'épreuve résout à la main quand l'horloge pilotée doit avancer pendant
// le calcul ; le direct ailleurs, compté. L'épreuve lit le rendu par les
// attributs data-… du contrat, et ce que la séance a écrit : la révision, le
// journal, l'état sur le disque, le relevé des écritures. Le texte attendu
// vient de la même traduction que celle de l'application ; les noms
// d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { creerExecuteurTravailleur } from '../../application/executeur_travailleur.js';
import { ErreurAnnulee, creerExecuteurDirect } from '../../application/generation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Travailleur from './travailleur.js?worker&inline';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Les champs de la section, dans l'ordre de l'écran.
const CHAMPS = ['graine', 'generation.nombre', 'generation.arret', 'generation.historique'];
// 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 exécuteurs ---------------------------------------------------------------
// L'exécuteur du travailleur, celui de src/main.js ; travailleurs compte les
// travailleurs créés.
function executeurTravailleur() {
const suivi = { travailleurs: 0 };
const executeur = creerExecuteurTravailleur(() => {
suivi.travailleurs += 1;
return new Travailleur();
});
return { executeur, suivi };
}
// Un exécuteur dont chaque lancer est relevé : configuration, reglages, et
// ce que rend son résultat. lancements en garde la liste.
function releve(base) {
const lancements = [];
const executeur = {
lancer(configuration, reglages) {
const execution = base.lancer(configuration, reglages);
const lancement = { configuration, reglages, propositions: null };
lancements.push(lancement);
execution.resultat.then(
(propositions) => {
lancement.propositions = propositions;
},
() => undefined,
);
return execution;
},
};
return { executeur, lancements };
}
// Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé,
// avec resoudre(propositions), avancer(fait, total) et annulee.
function executeurAMain() {
const executions = [];
const executeur = {
lancer(configuration, reglages) {
const abonnes = new Set();
let resoudre;
let rejeter;
const resultat = new Promise((ok, ko) => {
resoudre = ok;
rejeter = ko;
});
const execution = {
configuration,
reglages,
resoudre,
annulee: false,
avancer: (fait, total) => {
for (const fn of [...abonnes]) fn(fait, total);
},
};
executions.push(execution);
return {
surAvancement(fn) {
abonnes.add(fn);
return () => abonnes.delete(fn);
},
resultat,
annuler() {
execution.annulee = true;
rejeter(new ErreurAnnulee());
},
};
},
};
return { executeur, executions };
}
// --- Les lectures du rendu ----------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="generation"]');
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
const bascule = (m, cle) => section(m).querySelector(`input[type="checkbox"][data-champ="${cle}"]`);
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
const fenetre = () => document.querySelector('dialog[open]');
const duree = (m) => section(m).querySelector('[data-duree]');
const question = (m) => section(m).querySelector('[data-question="longue"]');
const barre = (m) => section(m)?.querySelector('[data-avancement] [role="progressbar"]') ?? null;
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
const versSection = (m, cible) => dans(m, `[data-commande="section"][data-cible="${cible}"]`);
// La signature de ce que le plan montre : la tête de chaque ligne des
// listes — son rang et ce qui l'occupe, sans le nom, que la largeur mesurée
// abrège —, puis les marques de chaque chaise.
const montre = (m) => ({
listes: tous(m, '[data-liste]').map((liste) =>
[...liste.querySelectorAll('[data-partie="tete"]')].map((tete) => tete.textContent),
),
marques: tous(m, '[data-siege]').map((chaise) => [
chaise.getAttribute('data-siege'),
[...chaise.querySelectorAll('[data-marque]')].map((marque) => marque.getAttribute('data-marque')),
]),
});
// Choisit le tour r dans le sélecteur de la barre de vue du plan.
function choisirTour(m, r) {
const select = dans(m, '[data-section="plan"] [data-champ="tour"]');
expect(select, 'sélecteur de tour').not.toBeNull();
select.value = String(r);
select.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
// Cadre le plan sur ses tables, puis le rapproche, un zoom après l'autre,
// jusqu'à ce que les listes de noms et les chaises paraissent : elles ne se
// rendent qu'au-delà d'un zoom. Partie du même cadrage, la vue atteinte est
// la même à chaque appel.
async function cadrerPourLesListes(m) {
const commande = (nom) => dans(m, `[data-section="plan"] [data-commande="${nom}"]`);
commande('ajuster').click();
flushSync();
await images(2);
for (let cran = 0; tous(m, '[data-liste]').length === 0 || tous(m, '[data-siege]').length === 0; cran += 1) {
expect(cran, 'zooms avant les listes et les chaises').toBeLessThan(30);
commande('zoomer').click();
flushSync();
await images(2);
}
}
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
const controleur = (m) => m.app.generation();
const baseDe = (m) => m.seance.etat().evenement.base;
const etatSurDisque = (m) => m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.etat}`);
const journalDe = (m) => lireJournal(m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.journal}`));
// Attend que l'assertion passe, le rendu de Svelte à jour, image après image ;
// images borne l'attente.
async function attendre(assertion, images = 1200) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= images) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// Laisse passer n images.
async function images(n) {
for (let i = 0; i < n; i += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
// 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, puis le rendu.
async function passer(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await images(1);
flushSync();
}
// Un clic réel au centre de l'élément, puis la file de la séance passée.
async function cliquer(m, element) {
expect(element, 'élément à cliquer').not.toBeNull();
element.scrollIntoView({ block: 'center' });
const { left, top, width, height } = element.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await passer(m);
}
// Saisit texte dans le champ, la valeur courante remplacée, puis le quitte
// par Tab, ou le valide par Entrée.
async function saisir(m, entree, texte, { fin = 'Tab' } = {}) {
await cliquer(m, entree);
entree.select();
for (const caractere of texte) await touche(caractere);
if (fin !== null) await touche(fin);
await passer(m);
}
// Vrai tant que le contrôleur publie une génération en cours.
function enCours(m) {
let lu;
controleur(m).subscribe((etat) => {
lu = etat;
})();
return lu.enCours;
}
// --- Les événements d'épreuve -----------------------------------------------------
// Un montage sur l'exécuteur donné, où la démonstration cle est chargée,
// en écriture sauf ecriture faux, la section de la génération ouverte.
async function demonstration(cle, { executeur, ecriture = true } = {}) {
const m = await monter({ executeur });
await m.facade.chargerDemonstration(cle);
if (ecriture) await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
return m;
}
// Les réglages de génération de la charge changés par la séance.
async function regler(m, reglages) {
for (const [cle, valeur] of Object.entries(reglages)) {
await m.seance.executer('modifierReglage', { cle: `generation.${cle}`, valeur });
}
flushSync();
}
// --- Les épreuves -----------------------------------------------------------------
describe('les réglages (§ 5.7, § 5.10)', () => {
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
expect(entrees.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of CHAMPS) {
const cleLibelle = `generation.reglage.${cle.replace('generation.', '')}`;
expect(etiquette(m, cle).textContent).toBe(t(cleLibelle));
}
expect(t('generation.reglage.arret')).toContain('mouvements évalués par proposition');
expect(t('generation.reglage.nombre')).toContain('propositions');
expect(t('generation.reglage.historique')).toContain('mouvements');
expect(t('generation.reglage.graine')).toContain('entier');
expect(champ(m, 'generation.historique').value).toBe('1000');
expect(champ(m, 'generation.nombre').value).toBe('5');
expect(champ(m, 'generation.arret').value).toBe('200000');
for (const entree of entrees) {
expect(etiquette(m, entree.dataset.champ).textContent).not.toMatch(/durée|seconde|minute/i);
}
});
test('exécuteur direct : changer chaque réglage et quitter le champ — une entrée, ce réglage de la charge changé, les deux autres non', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
// Chaque réglage de la charge, et une valeur neuve, distincte de celle
// de la démonstration.
const saisies = { nombre: 3, arret: 40000, historique: 500 };
const noms = Object.keys(saisies);
expect(noms.length).toBe(3);
for (const nom of noms) {
const avant = { ...charge(m).reglages.generation };
expect(avant[nom], nom).not.toBe(saisies[nom]);
const r0 = revision(m);
const entrees = journalDe(m).entrees.length;
await saisir(m, champ(m, `generation.${nom}`), String(saisies[nom]));
expect(revision(m), nom).toBe(r0 + 1);
expect(journalDe(m).entrees.length, nom).toBe(entrees + 1);
expect(charge(m).reglages.generation, nom).toEqual({ ...avant, [nom]: saisies[nom] });
expect(champ(m, `generation.${nom}`).value, nom).toBe(String(saisies[nom]));
}
});
test('exécuteur direct : en lecture, la frappe dans un réglage est refusée avec « Modifier » ; la graine se saisit sans rien écrire', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
const ecritures = m.trace.length;
const arret = champ(m, 'generation.arret');
await saisir(m, arret, '9', { fin: null });
expect(arret.value).toBe('200000');
const refus = refusPres(arret, 'MODE_LECTURE');
expect(refus).not.toBeNull();
expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
await touche('Escape');
await saisir(m, champ(m, 'graine'), '77');
expect(champ(m, 'graine').value).toBe('77');
expect(champ(m, 'graine').parentElement.querySelector('[data-refus]')).toBeNull();
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
});
test('exécuteur direct, relevé : la graine vaut 1 à l’ouverture, 2 après une génération lancée à 1', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
await regler(m, { nombre: 2, arret: 2000 });
expect(champ(m, 'graine').value).toBe('1');
await cliquer(m, generer(m));
await attendre(() => expect(charge(m).propositions.length).toBe(2));
expect(lancements.map(({ reglages }) => reglages.graine)).toEqual([1]);
await attendre(() => expect(champ(m, 'graine').value).toBe('2'));
});
test('exécuteur direct, relevé : graine -1, 1,5 ou 2^32 — refus sur le champ, rien n’est lancé ni écrit', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
const saisies = ['-1', '1,5', String(2 ** 32)];
expect(saisies.length).toBe(3);
for (const saisie of saisies) {
const attendu = controleur(m).valider({ ...controleur(m).reglagesProposes(), graine: saisie }).refus;
expect(attendu.map(({ champ: nom }) => nom)).toEqual(['graine']);
await saisir(m, champ(m, 'graine'), saisie);
await cliquer(m, generer(m));
const graine = champ(m, 'graine');
const refus = refusPres(graine, attendu[0].code);
expect(refus, saisie).not.toBeNull();
expect(refus.querySelector('p').textContent).toBe(t(attendu[0].code, attendu[0].details));
expect(graine.getAttribute('aria-invalid')).toBe('true');
expect(generer(m).parentElement.querySelector('[data-refus]')).toBeNull();
}
expect(lancements).toEqual([]);
expect(charge(m).propositions).toEqual([]);
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
});
});
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
// Les cinq bascules, dans l'ordre du § 5.3.
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
const NOMS_DU_SPEC = [
'Séparer les appartenances',
'Nouveaux voisins à chaque tour',
'Nouvelle table à chaque tour',
'Varier les appartenances rencontrées',
'Attribuer les sièges',
];
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual(BASCULES);
BASCULES.forEach((cle, rang) => {
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
});
for (const cle of BASCULES) {
const avant = structuredClone(charge(m).reglages);
const r0 = revision(m);
const entrees = journalDe(m).entrees.length;
await cliquer(m, bascule(m, cle));
expect(revision(m), cle).toBe(r0 + 1);
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
}
// La barre d'espace bascule aussi, par le même geste.
const cle = BASCULES[0];
const avant = charge(m).reglages[cle];
bascule(m, cle).focus();
await touche(' ');
await passer(m);
expect(charge(m).reglages[cle]).toBe(!avant);
expect(bascule(m, cle).checked).toBe(!avant);
});
test('direct : bascule refusée en lecture (« Modifier ») et sur un plan bloqué (« Débloquer »), rien d’écrit', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
const cle = 'attribuerSieges';
const valeur = charge(m).reglages[cle];
const ecritures = m.trace.length;
await cliquer(m, bascule(m, cle));
const lecture = refusPres(bascule(m, cle), 'MODE_LECTURE');
expect(lecture).not.toBeNull();
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(bascule(m, cle).checked).toBe(valeur);
expect(charge(m).reglages[cle]).toBe(valeur);
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, bascule(m, 'nouvelleTable'));
const bloque = refusPres(bascule(m, 'nouvelleTable'), 'LECTURE_SEULE');
expect(bloque).not.toBeNull();
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(bascule(m, 'nouvelleTable').checked).toBe(charge(m).reglages.nouvelleTable);
expect(m.trace.length).toBe(ecrituresBloque);
expect(fenetre()).toBeNull();
});
});
describe('« Générer » derrière la file de la séance (§ 5.10, § 8.2)', () => {
test('relevé, disque lent : « Générer » pressé en quittant le compte d’arrêt part après son écriture, sur l’arrêt saisi', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
// Le disque tient chaque écriture jusqu'à ce que l'épreuve la libère.
const tenues = [];
for (const nom of ['ecrireAtomique', 'ajouterLigne']) {
const primitive = m.fs[nom];
m.fs[nom] = (...args) =>
new Promise((resoudre, rejeter) => {
tenues.push(() => Promise.resolve(primitive.apply(m.fs, args)).then(resoudre, rejeter));
});
}
const arret = champ(m, 'generation.arret');
await cliquer(m, arret);
arret.select();
for (const caractere of '1000') await touche(caractere);
const bouton = generer(m);
bouton.scrollIntoView({ block: 'center' });
const { left, top, width, height } = bouton.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await images(5);
expect(tenues.length, 'une écriture tenue').toBeGreaterThan(0);
expect(lancements, 'rien ne part avant l’écriture du réglage').toEqual([]);
for (let image = 0; lancements.length === 0; image += 1) {
expect(image, 'images avant le lancement').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
expect(lancements[0].reglages.arret).toBe(1000);
expect(charge(m).reglages.generation.arret).toBe(1000);
// Les écritures de l'enregistrement passent, et l'issue s'affiche.
for (let image = 0; dans(m, '[data-issue]') === null; image += 1) {
expect(image, 'images avant l’issue').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
});
});
describe('le travailleur qui ne démarre pas (§ 5.9, § 5.10)', () => {
test('travailleur qui ne se charge pas : TRAVAILLEUR_EN_ECHEC près de « Générer », rien d’écrit', async () => {
const adresse = URL.createObjectURL(new Blob(['ceci ( ne se compile pas'], { type: 'text/javascript' }));
const suivi = { travailleurs: 0 };
const executeur = creerExecuteurTravailleur(() => {
suivi.travailleurs += 1;
return new Worker(adresse);
});
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
await cliquer(m, generer(m));
await attendre(() => expect(refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC')).not.toBeNull());
const refus = refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC');
expect(refus.querySelector('p').textContent).toBe(t('TRAVAILLEUR_EN_ECHEC', { remede: null }));
expect(suivi.travailleurs).toBe(1);
expect(enCours(m)).toBe(false);
expect(section(m).getAttribute('aria-busy')).toBe('false');
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
URL.revokeObjectURL(adresse);
});
});
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
const { executeur: travailleur, suivi } = executeurTravailleur();
const { executeur, lancements } = releve(travailleur);
const m = await demonstration('petite', { executeur });
await regler(m, { nombre: 3, arret: 20000 });
const configuration = configurationDepuisCharge(charge(m));
await saisir(m, champ(m, 'graine'), '4242');
await cliquer(m, generer(m));
await attendre(() => expect(lancements[0]?.propositions ?? null).not.toBeNull());
expect(suivi.travailleurs).toBe(1);
const reglages = { graine: 4242, nombre: 3, arret: 20000, historique: 1000 };
expect(lancements[0].reglages).toEqual(reglages);
const attendues = rechercher(configuration, reglages);
expect(attendues.length).toBe(3);
expect(lancements[0].propositions).toEqual(attendues);
await attendre(() => expect(charge(m).propositions.length).toBe(3));
expect(charge(m).propositions.map(({ graine }) => graine)).toEqual(attendues.map(({ graine }) => graine));
}, 60_000);
test('exécuteur du travailleur : grande démonstration, compte élevé — pendant le calcul, la navigation et le changement du tour affiché mettent le DOM à jour ; l’avancement croît, aria-busy suit le calcul', async () => {
const { executeur } = executeurTravailleur();
const m = await demonstration('grande', { executeur });
await regler(m, { nombre: 4, arret: 400000 });
// Une réservation du seul tour 1, à un siège : une marque et une ligne
// qui ne paraissent qu'à ce tour. Le plan de chaque tour est relevé
// avant le calcul.
const { participants, reservations } = charge(m);
const libre = participants.find(({ id }) => !reservations.some(({ participant }) => participant === id));
await m.seance.executer('reserver', { participant: libre.id, table: 1, siege: 2, portee: 'tour', tour: 1 });
await cliquer(m, versSection(m, 'plan'));
await cadrerPourLesListes(m);
choisirTour(m, 1);
await images(2);
const tour1 = montre(m);
choisirTour(m, 2);
await images(2);
const tour2 = montre(m);
expect(tour1.listes.length).toBeGreaterThan(0);
expect(tour1.marques.length).toBeGreaterThan(0);
expect(tour2.listes).not.toEqual(tour1.listes);
choisirTour(m, 1);
await cliquer(m, versSection(m, 'generation'));
expect(section(m).getAttribute('aria-busy')).toBe('false');
await cliquer(m, generer(m));
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
expect(section(m).getAttribute('aria-busy')).toBe('true');
const valeurs = [Number(barre(m).getAttribute('aria-valuenow'))];
// Un geste de navigation pendant le calcul : le DOM change avant la fin.
await cliquer(m, versSection(m, 'participants'));
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
expect(section(m)).toBeNull();
expect(enCours(m)).toBe(true);
// Le plan pendant le calcul : il montre le tour 1, puis, le tour
// changé, les listes et les marques du tour 2, le calcul toujours en
// cours.
await cliquer(m, versSection(m, 'plan'));
await cadrerPourLesListes(m);
await attendre(() => expect(montre(m)).toEqual(tour1));
choisirTour(m, 2);
await attendre(() => expect(montre(m)).toEqual(tour2));
expect(m.ui.etat().tour).toBe(2);
expect(enCours(m)).toBe(true);
await cliquer(m, versSection(m, 'generation'));
expect(section(m).getAttribute('aria-busy')).toBe('true');
while (enCours(m)) {
const lue = barre(m)?.getAttribute('aria-valuenow');
if (lue !== undefined && Number(lue) !== valeurs.at(-1)) valeurs.push(Number(lue));
await images(1);
}
expect(valeurs.length).toBeGreaterThanOrEqual(2);
for (let i = 1; i < valeurs.length; i += 1) expect(valeurs[i]).toBeGreaterThan(valeurs[i - 1]);
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
expect(barre(m)).toBeNull();
await attendre(() => expect(charge(m).propositions.length).toBeGreaterThan(0));
}, 120_000);
test('exécuteur du travailleur : annuler est immédiat ; plus aucun avancement ; l’état prêt revient ; propositions, journal et état sur le disque inchangés', async () => {
const { executeur } = executeurTravailleur();
const m = await demonstration('grande', { executeur });
await regler(m, { nombre: 4, arret: 400000 });
const avant = { propositions: charge(m).propositions, journal: journalDe(m), etat: etatSurDisque(m) };
await cliquer(m, generer(m));
await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
const recus = [];
const desabonner = controleur(m).subscribe(({ fait }) => recus.push(fait));
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
expect(enCours(m)).toBe(false);
expect(section(m).getAttribute('aria-busy')).toBe('false');
expect(barre(m)).toBeNull();
expect(generer(m)).not.toBeNull();
const apresAnnulation = recus.length;
await images(30);
expect(recus.length).toBe(apresAnnulation);
desabonner();
expect(charge(m).propositions).toEqual(avant.propositions);
expect(journalDe(m)).toEqual(avant.journal);
expect(etatSurDisque(m)).toBe(avant.etat);
expect(fenetre()).toBeNull();
}, 60_000);
});
describe('les refus à la commande (§ 5.9, § 15.4, § 9)', () => {
test('exécuteur direct, relevé : 13 participants, 4 tables de 3 — PLACES_MANQUANTES {placesManquantes: 1} près de « Générer », remède vers les tables ; rien de lancé ni d’écrit', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
flushSync();
expect(charge(m).participants.length).toBe(13);
expect(charge(m).tables.length).toBe(4);
const r0 = revision(m);
const ecritures = m.trace.length;
await cliquer(m, generer(m));
const refus = refusPres(generer(m), 'PLACES_MANQUANTES');
expect(refus).not.toBeNull();
const message = refus.querySelector('p').textContent;
expect(message).toBe(t('PLACES_MANQUANTES', { placesManquantes: 1 }));
expect(message).toContain('1 place');
const remede = refus.querySelector('[data-commande="ouvrirSection"]');
expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.tables'));
expect(fenetre()).toBeNull();
await cliquer(m, remede);
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
expect(lancements).toEqual([]);
expect(charge(m).propositions).toEqual([]);
expect(revision(m)).toBe(r0);
expect(m.trace.length).toBe(ecritures);
});
test('exécuteur direct, relevé : un événement neuf en écriture — AUCUN_PARTICIPANT puis, des participants sans table, AUCUNE_TABLE ; chaque remède ouvre sa section', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await monter({ executeur });
await m.seance.creer({ nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 });
await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
let ecritures = m.trace.length;
await cliquer(m, generer(m));
const sansPersonne = refusPres(generer(m), 'AUCUN_PARTICIPANT');
expect(sansPersonne).not.toBeNull();
expect(sansPersonne.querySelector('p').textContent).toBe(t('AUCUN_PARTICIPANT', {}));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await cliquer(m, sansPersonne.querySelector('[data-commande="ouvrirSection"]'));
expect(m.ui.etat().section).toBe('participants');
expect(dans(m, '[data-section="participants"]')).not.toBeNull();
for (const nom of ['Pervenche', 'Lacasse']) await m.seance.executer('ajouterParticipant', { champs: { nom } });
m.ui.allerA('generation');
flushSync();
ecritures = m.trace.length;
await cliquer(m, generer(m));
const sansTable = refusPres(generer(m), 'AUCUNE_TABLE');
expect(sansTable).not.toBeNull();
expect(sansTable.querySelector('p').textContent).toBe(t('AUCUNE_TABLE', {}));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await cliquer(m, sansTable.querySelector('[data-commande="ouvrirSection"]'));
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
expect(lancements).toEqual([]);
});
test('exécuteur direct, relevé : en lecture, « Générer » refusé à côté de lui avec « Modifier » ; sur un plan bloqué, avec « Débloquer » ; aucune fenêtre', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur, ecriture: false });
const ecritures = m.trace.length;
await cliquer(m, generer(m));
const lecture = refusPres(generer(m), 'MODE_LECTURE');
expect(lecture).not.toBeNull();
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
const configuration = configurationDepuisCharge(charge(m));
const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 2000 });
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
await m.seance.executer('retenirProposition', { id: charge(m).propositions[0].id });
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, generer(m));
const bloque = refusPres(generer(m), 'LECTURE_SEULE');
expect(bloque).not.toBeNull();
expect(bloque.querySelector('p').textContent).toBe(t('LECTURE_SEULE', m.seance.etat().refus.details));
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(fenetre()).toBeNull();
expect(m.trace.length).toBe(ecrituresBloque);
expect(lancements).toEqual([]);
});
});
describe('la durée du calcul (§ 5.10, § 19.4)', () => {
test('exécuteur résolu à la main : sans mesure, le panneau le dit sans écrire 0 ; 1 800 ms d’horloge pilotée pour 5 × 200 000 mouvements, durée et réglages affichés', async () => {
const { executeur, executions } = executeurAMain();
const m = await demonstration('petite', { executeur });
expect(duree(m).textContent).toBe(t('generation.duree.inconnue'));
expect(duree(m).textContent).not.toMatch(/\b0\b/);
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
expect(executions[0].reglages).toEqual({ graine: 1, nombre: 5, arret: 200000, historique: 1000 });
expect(section(m).getAttribute('aria-busy')).toBe('true');
m.horloge.avancer(1800);
const propositions = rechercher(executions[0].configuration, { graine: 1, nombre: 5, arret: 2000 });
executions[0].resoudre(propositions);
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
await attendre(() => expect(charge(m).propositions.length).toBe(5));
const attendu = t('generation.duree.observee', { dureeMs: 1800, nombre: 5, arret: 200000 });
expect(duree(m).textContent).toBe(attendu);
expect(attendu).toContain('1,8 s');
expect(attendu).toContain('5 \u{00D7} 200\u{202F}000');
});
test('exécuteur résolu à la main : 10^6 mouvements en 2 s ; 6 × 10^8 annoncés en place, « environ vingt minutes à ce réglage » ; renoncer n’écrit rien, confirmer lance ; sous le seuil, sans question', async () => {
const { executeur, executions } = executeurAMain();
const m = await demonstration('petite', { executeur });
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
m.horloge.avancer(2000);
executions[0].resoudre([]);
await attendre(() => expect(enCours(m)).toBe(false));
expect(controleur(m).derniereGeneration()).toMatchObject({ dureeMs: 2000, mouvements: 1_000_000 });
await saisir(m, champ(m, 'generation.arret'), '120000000');
expect(charge(m).reglages.generation).toMatchObject({ nombre: 5, arret: 120_000_000 });
const journal = journalDe(m);
const ecritures = m.trace.length;
await cliquer(m, generer(m));
expect(executions.length).toBe(1);
expect(question(m)).not.toBeNull();
expect(question(m).textContent).toContain('environ vingt minutes à ce réglage');
expect(fenetre()).toBeNull();
await cliquer(m, question(m).querySelector('[data-commande="renoncerGeneration"]'));
expect(question(m)).toBeNull();
expect(executions.length).toBe(1);
expect(journalDe(m)).toEqual(journal);
expect(m.trace.length).toBe(ecritures);
await cliquer(m, generer(m));
await cliquer(m, question(m).querySelector('[data-commande="confirmerGeneration"]'));
expect(executions.length).toBe(2);
expect(executions[1].reglages).toMatchObject({ nombre: 5, arret: 120_000_000 });
expect(question(m)).toBeNull();
expect(section(m).getAttribute('aria-busy')).toBe('true');
await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
expect(executions[1].annulee).toBe(true);
await attendre(() => expect(enCours(m)).toBe(false));
await regler(m, { arret: 200000 });
await cliquer(m, generer(m));
expect(question(m)).toBeNull();
expect(executions.length).toBe(3);
executions[2].resoudre([]);
await attendre(() => expect(enCours(m)).toBe(false));
});
});