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
768 lines
36 KiB
JavaScript
768 lines
36 KiB
JavaScript
// © 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));
|
||
});
|
||
});
|