The drag ghost names what releasing at the point changes, floor included. Escape on a seat returns to its table, a refused Tab stays put, the legend toggle exposes aria-expanded, and the header markup is valid HTML. Every data-* attribute the interface sets belongs to one closed list that a guard reads. The tree guards follow helpers a test imports, and the reporter says the whole series stays watched under coverage on a small machine. Checked: every new test red first; 3001 node, 635 browser and 63 long tests green from the index alone. --- FR --- [FIX] interface : fantôme nommé, clavier, HTML valide, data-* fermés Le fantôme du glisser nomme ce que relâcher au point modifie, sol compris. Échap sur un siège remonte à sa table, un Tab refusé reste en place, la bascule de la légende expose aria-expanded, et la tête est du HTML valide. Chaque attribut data-* que l'interface pose appartient à une liste fermée qu'une garde lit. Les gardes de l'arbre suivent les aides qu'une épreuve importe, et le rapporteur dit la série entière surveillée sous couverture. Vérifié : chaque épreuve rouge d'abord ; 3001 node, 635 navigateur et 63 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5
419 lines
19 KiB
JavaScript
419 lines
19 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Épreuves de l'exécuteur du travailleur (§ 5.7, § 5.10) : le groupe de
|
||
// travailleurs de creerExecuteurTravailleur, sur une fabrique de
|
||
// travailleurs factices. Un travailleur factice garde chaque message reçu et
|
||
// compte ses terminate() ; l'épreuve lui fait poster les messages du contrat
|
||
// par son onmessage, comme le ferait le vrai travailleur. Les travailleurs
|
||
// calculants, eux, répondent par brancherTravailleur, le vrai côté
|
||
// travailleur, dans l'ordre que l'épreuve choisit : la valeur attendue est
|
||
// alors celle de rechercher, sollicité une seconde fois.
|
||
import assert from 'node:assert/strict';
|
||
import { describe, test } from '../../test/lanceur.js';
|
||
import { texteLivre } from '../demo/livrees.js';
|
||
import { rechercher } from '../moteur/recherche.js';
|
||
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
|
||
import { creerExecuteurTravailleur } from './executeur_travailleur.js';
|
||
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
|
||
import { brancherTravailleur } from './generation_travailleur.js';
|
||
|
||
const CONFIGURATION = Object.freeze({ marque: 'configuration d’épreuve', tours: 3 });
|
||
const REGLAGES = Object.freeze({ graine: 11, nombre: 2, arret: 40_000, historique: 1000 });
|
||
|
||
// Une proposition d'épreuve du rang donné, sans calcul.
|
||
const propositionDe = (rang) => ({ id: rang, graine: 100 + rang, arret: 40_000, historique: 1000, plan: [[[rang]]] });
|
||
|
||
// Une fabrique de travailleurs factices, et les travailleurs qu'elle a
|
||
// rendus, dans l'ordre. Chacun : recus, les messages reçus, clonés ;
|
||
// terminaisons, le compte de terminate() ; poster(data), un message du
|
||
// travailleur, remis à son onmessage courant ; rang(), celui du dernier
|
||
// proposer reçu.
|
||
function fabriqueFactice() {
|
||
const travailleurs = [];
|
||
const fabrique = () => {
|
||
const travailleur = {
|
||
recus: [],
|
||
terminaisons: 0,
|
||
onmessage: null,
|
||
postMessage(message) {
|
||
this.recus.push(structuredClone(message));
|
||
},
|
||
terminate() {
|
||
this.terminaisons += 1;
|
||
},
|
||
poster(data) {
|
||
this.onmessage?.({ data });
|
||
},
|
||
rang() {
|
||
return this.recus.at(-1)?.rang ?? null;
|
||
},
|
||
// Poste la proposition du rang en cours.
|
||
rendre() {
|
||
this.poster({ type: 'proposition', proposition: propositionDe(this.rang()) });
|
||
},
|
||
};
|
||
travailleurs.push(travailleur);
|
||
return travailleur;
|
||
};
|
||
return { fabrique, travailleurs };
|
||
}
|
||
|
||
// Une fabrique de travailleurs calculants : chaque proposer reçu attend que
|
||
// l'épreuve le serve ; servir(choisir) traite les demandes en attente une à
|
||
// une, choisir désignant chaque fois l'indice de la suivante parmi elles, et
|
||
// remet au travailleur ce que brancherTravailleur a posté. Un travailleur
|
||
// terminé ne reçoit plus rien.
|
||
function fabriqueCalculante() {
|
||
const enAttente = [];
|
||
let crees = 0;
|
||
const fabrique = () => {
|
||
crees += 1;
|
||
const travailleur = {
|
||
onmessage: null,
|
||
termine: false,
|
||
postMessage(message) {
|
||
enAttente.push({ travailleur, message: structuredClone(message) });
|
||
},
|
||
terminate() {
|
||
travailleur.termine = true;
|
||
},
|
||
};
|
||
return travailleur;
|
||
};
|
||
function servir(choisir) {
|
||
while (enAttente.length > 0) {
|
||
const [{ travailleur, message }] = enAttente.splice(choisir(enAttente.length), 1);
|
||
if (travailleur.termine) continue;
|
||
const postes = [];
|
||
const portee = { onmessage: null, postMessage: (m) => postes.push(structuredClone(m)) };
|
||
brancherTravailleur(portee);
|
||
portee.onmessage({ data: message });
|
||
for (const data of postes) if (!travailleur.termine) travailleur.onmessage?.({ data });
|
||
}
|
||
}
|
||
return { fabrique, servir, crees: () => crees };
|
||
}
|
||
|
||
// L'issue d'une promesse, sans attendre plus d'un tour de la file des
|
||
// microtâches : { etat: 'attente' }, { etat: 'resolue', valeur } ou
|
||
// { etat: 'rejetee', erreur }.
|
||
async function issue(promesse) {
|
||
let lue = { etat: 'attente' };
|
||
promesse.then(
|
||
(valeur) => {
|
||
lue = { etat: 'resolue', valeur };
|
||
},
|
||
(erreur) => {
|
||
lue = { etat: 'rejetee', erreur };
|
||
},
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
return lue;
|
||
}
|
||
|
||
const configurationDe = (cle) => configurationDepuisCharge(analyser(texteLivre(cle)).charge);
|
||
|
||
describe('exécuteur du travailleur : le groupe (§ 5.7, § 5.10)', () => {
|
||
test('coeursDisponibles : un entier d’au moins 1 tel quel, toute autre valeur 1 ; 1 sans option', () => {
|
||
const { fabrique } = fabriqueFactice();
|
||
const cas = [
|
||
[8, 8],
|
||
[1, 1],
|
||
[undefined, 1],
|
||
[0, 1],
|
||
[2.5, 1],
|
||
['4', 1],
|
||
[null, 1],
|
||
];
|
||
assert.equal(cas.length, 7);
|
||
for (const [donnee, attendue] of cas) {
|
||
assert.equal(creerExecuteurTravailleur(fabrique, { coeursDisponibles: donnee }).coeursDisponibles, attendue, String(donnee));
|
||
}
|
||
assert.equal(creerExecuteurTravailleur(fabrique).coeursDisponibles, 1);
|
||
});
|
||
|
||
test('min(nombre, coeurs) travailleurs, nés au lancer ; le travailleur k reçoit d’abord le rang k ; sans option, un seul', () => {
|
||
const cas = [
|
||
{ coeurs: 3, nombre: 5, attendus: 3 },
|
||
{ coeurs: 8, nombre: 3, attendus: 3 },
|
||
{ coeurs: 1, nombre: 5, attendus: 1 },
|
||
{ coeurs: undefined, nombre: 4, attendus: 1 },
|
||
];
|
||
assert.equal(cas.length, 4);
|
||
for (const { coeurs, nombre, attendus } of cas) {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const executeur = creerExecuteurTravailleur(fabrique, { coeursDisponibles: 16 });
|
||
assert.equal(travailleurs.length, 0, 'aucun travailleur avant le lancer');
|
||
const reglages = { ...REGLAGES, nombre };
|
||
const execution = executeur.lancer(CONFIGURATION, reglages, coeurs === undefined ? undefined : { coeurs });
|
||
execution.resultat.catch(() => undefined);
|
||
assert.equal(travailleurs.length, attendus, JSON.stringify({ coeurs, nombre }));
|
||
assert.equal(execution.coeurs, attendus);
|
||
assert.equal(new Set(travailleurs).size, attendus);
|
||
travailleurs.forEach((travailleur, k) => {
|
||
assert.deepEqual(travailleur.recus, [{ type: 'proposer', configuration: CONFIGURATION, reglages, rang: k + 1 }]);
|
||
assert.equal(travailleur.terminaisons, 0);
|
||
});
|
||
execution.annuler();
|
||
}
|
||
});
|
||
|
||
test('le rang suivant va au premier travailleur libéré ; celui qui n’a plus rien à faire est terminé ; le résultat suit les identifiants, non l’ordre d’achèvement', async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const reglages = { ...REGLAGES, nombre: 5 };
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, reglages, { coeurs: 2 });
|
||
const [premier, second] = travailleurs;
|
||
second.rendre(); // rang 2
|
||
assert.equal(second.rang(), 3);
|
||
second.rendre(); // rang 3
|
||
assert.equal(second.rang(), 4);
|
||
premier.rendre(); // rang 1
|
||
assert.equal(premier.rang(), 5);
|
||
premier.rendre(); // rang 5 : plus rien pour le premier
|
||
assert.equal(premier.terminaisons, 1);
|
||
assert.equal(second.terminaisons, 0);
|
||
assert.equal((await issue(execution.resultat)).etat, 'attente');
|
||
second.rendre(); // rang 4
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'resolue');
|
||
assert.deepEqual(lue.valeur, [1, 2, 3, 4, 5].map(propositionDe));
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1],
|
||
);
|
||
assert.deepEqual(
|
||
travailleurs.map(({ recus }) => recus.map(({ rang }) => rang)),
|
||
[
|
||
[1, 5],
|
||
[2, 3, 4],
|
||
],
|
||
);
|
||
execution.annuler();
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1],
|
||
'annuler après le résultat ne termine rien de plus',
|
||
);
|
||
});
|
||
|
||
test('travailleurs calculants : pour 1, 2 et 3 cœurs et 5 propositions, servis dans trois ordres, le résultat sérialisé égale celui de rechercher, octet pour octet', async () => {
|
||
const configuration = configurationDe('petite-conflit');
|
||
const reglages = { graine: 77, nombre: 5, arret: 3_000, historique: 1000 };
|
||
const attendu = JSON.stringify(rechercher(configuration, reglages));
|
||
const ordres = {
|
||
fifo: () => 0,
|
||
lifo: (n) => n - 1,
|
||
milieu: (n) => Math.floor(n / 2),
|
||
};
|
||
const coeurs = [1, 2, 3];
|
||
let comparaisons = 0;
|
||
for (const w of coeurs) {
|
||
for (const [nom, choisir] of Object.entries(ordres)) {
|
||
const { fabrique, servir, crees } = fabriqueCalculante();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(configuration, reglages, { coeurs: w });
|
||
assert.equal(crees(), w);
|
||
servir(choisir);
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'resolue', `${w} cœurs, ${nom}`);
|
||
assert.equal(JSON.stringify(lue.valeur), attendu, `${w} cœurs, ${nom}`);
|
||
comparaisons += 1;
|
||
}
|
||
}
|
||
assert.equal(comparaisons, 9);
|
||
});
|
||
|
||
test('l’avancement est la somme des avancements de chaque proposition, sur nombre × arret ; une proposition rendue compte pour son arret entier', () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const reglages = { ...REGLAGES, nombre: 3, arret: 100 };
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, reglages, { coeurs: 2 });
|
||
execution.resultat.catch(() => undefined);
|
||
const recus = [];
|
||
execution.surAvancement((fait, total) => recus.push([fait, total]));
|
||
const [premier, second] = travailleurs;
|
||
premier.poster({ type: 'avancement', fait: 40, total: 100 });
|
||
second.poster({ type: 'avancement', fait: 30, total: 100 });
|
||
premier.poster({ type: 'proposition', proposition: propositionDe(1) });
|
||
premier.poster({ type: 'avancement', fait: 50, total: 100 });
|
||
second.poster({ type: 'avancement', fait: 90, total: 100 });
|
||
assert.deepEqual(recus, [
|
||
[40, 300],
|
||
[70, 300],
|
||
[130, 300],
|
||
[180, 300],
|
||
[240, 300],
|
||
]);
|
||
execution.annuler();
|
||
});
|
||
|
||
test('l’avancement ne décroît jamais : un avancement plus petit, ou égal, que le dernier de sa proposition ne publie rien', () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const reglages = { ...REGLAGES, nombre: 2, arret: 100 };
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, reglages, { coeurs: 2 });
|
||
execution.resultat.catch(() => undefined);
|
||
const recus = [];
|
||
execution.surAvancement((fait, total) => recus.push([fait, total]));
|
||
const [premier, second] = travailleurs;
|
||
premier.poster({ type: 'avancement', fait: 60, total: 100 });
|
||
premier.poster({ type: 'avancement', fait: 20, total: 100 });
|
||
second.poster({ type: 'avancement', fait: 50, total: 100 });
|
||
second.poster({ type: 'avancement', fait: 50, total: 100 });
|
||
premier.poster({ type: 'proposition', proposition: propositionDe(1) });
|
||
second.poster({ type: 'avancement', fait: 40, total: 100 });
|
||
assert.deepEqual(recus, [
|
||
[60, 200],
|
||
[110, 200],
|
||
[150, 200],
|
||
]);
|
||
for (let i = 1; i < recus.length; i += 1) assert.ok(recus[i][0] >= recus[i - 1][0], `avancement ${i}`);
|
||
execution.annuler();
|
||
});
|
||
|
||
test('un abonné retiré ne reçoit plus rien', () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||
execution.resultat.catch(() => undefined);
|
||
const recus = [];
|
||
const retires = [];
|
||
execution.surAvancement((fait, total) => recus.push([fait, total]));
|
||
const desabonner = execution.surAvancement((fait, total) => retires.push([fait, total]));
|
||
const [travailleur] = travailleurs;
|
||
travailleur.poster({ type: 'avancement', fait: 20_000, total: 40_000 });
|
||
desabonner();
|
||
travailleur.poster({ type: 'avancement', fait: 30_000, total: 40_000 });
|
||
assert.deepEqual(recus, [
|
||
[20_000, 80_000],
|
||
[30_000, 80_000],
|
||
]);
|
||
assert.deepEqual(retires, [[20_000, 80_000]]);
|
||
execution.annuler();
|
||
});
|
||
|
||
test("annuler : chaque travailleur terminé aussitôt, rejet par ErreurAnnulee ; rien de ce qu'un travailleur poste ensuite n'atteint plus rien", async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, { ...REGLAGES, nombre: 5 }, { coeurs: 3 });
|
||
const avancements = [];
|
||
execution.surAvancement((fait, total) => avancements.push([fait, total]));
|
||
// Les gestionnaires posés au lancement : un message déjà en file chez
|
||
// le navigateur au moment de l'annulation les atteindrait encore.
|
||
const gestionnaires = travailleurs.map(({ onmessage }) => onmessage);
|
||
travailleurs[1].poster({ type: 'avancement', fait: 10_000, total: 40_000 });
|
||
execution.annuler();
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1, 1],
|
||
'terminate() de chacun dans le même tour que annuler()',
|
||
);
|
||
for (const gestionnaire of gestionnaires) {
|
||
gestionnaire({ data: { type: 'avancement', fait: 40_000, total: 40_000 } });
|
||
gestionnaire({ data: { type: 'proposition', proposition: propositionDe(1) } });
|
||
}
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'rejetee');
|
||
assert.ok(lue.erreur instanceof ErreurAnnulee, String(lue.erreur));
|
||
assert.deepEqual(avancements, [[10_000, 200_000]]);
|
||
assert.deepEqual(
|
||
travailleurs.map(({ recus }) => recus.length),
|
||
[1, 1, 1],
|
||
'aucun rang donné après l’annulation',
|
||
);
|
||
execution.annuler();
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1, 1],
|
||
'une seconde annulation ne termine rien de plus',
|
||
);
|
||
});
|
||
|
||
test('erreur { code, details } d’un travailleur : rejet par une ErreurGeneration de ce code et de ces détails, chaque travailleur terminé, la suite ignorée', async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, { ...REGLAGES, nombre: 4 }, { coeurs: 3 });
|
||
const avancements = [];
|
||
execution.surAvancement((fait) => avancements.push(fait));
|
||
travailleurs[0].rendre();
|
||
travailleurs[1].poster({ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } });
|
||
travailleurs[2].rendre();
|
||
travailleurs[0].poster({ type: 'avancement', fait: 1, total: 2 });
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'rejetee');
|
||
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
|
||
assert.equal(lue.erreur.code, 'PLACES_MANQUANTES');
|
||
assert.deepEqual(lue.erreur.details, { placesManquantes: 1 });
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1, 1],
|
||
);
|
||
assert.deepEqual(avancements, [40_000]);
|
||
});
|
||
|
||
test('le canal d’erreur d’un travailleur — un script qui ne se charge ou ne se compile pas : rejet par ErreurGeneration TRAVAILLEUR_EN_ECHEC, remède null, sans le message ; l’événement est traité, chaque travailleur terminé', async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES, { coeurs: 2 });
|
||
assert.equal(travailleurs.length, 2);
|
||
assert.ok(travailleurs.every(({ onerror }) => typeof onerror === 'function'));
|
||
const evenement = {
|
||
message: 'faute d’épreuve',
|
||
traite: false,
|
||
preventDefault() {
|
||
this.traite = true;
|
||
},
|
||
};
|
||
travailleurs[1].onerror(evenement);
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'rejetee');
|
||
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
|
||
assert.equal(lue.erreur.code, 'TRAVAILLEUR_EN_ECHEC');
|
||
assert.deepEqual(lue.erreur.details, { remede: null });
|
||
assert.doesNotMatch(JSON.stringify(lue.erreur.details), /épreuve/);
|
||
assert.equal(evenement.traite, true);
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1],
|
||
);
|
||
// Un événement d'erreur sans preventDefault, comme le rendrait une portée factice, ne lève pas.
|
||
const seconde = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||
travailleurs[2].onerror({});
|
||
assert.equal((await issue(seconde.resultat)).erreur.code, 'TRAVAILLEUR_EN_ECHEC');
|
||
});
|
||
|
||
test('une faute pendant le calcul, postée { type: faute, message, pile } : rejet par une Error sans code, qui porte le message et la pile du travailleur ; aucun refus, chaque travailleur terminé, la suite ignorée', async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES, { coeurs: 2 });
|
||
const avancements = [];
|
||
execution.surAvancement((fait, total) => avancements.push([fait, total]));
|
||
const [travailleur, autre] = travailleurs;
|
||
travailleur.poster({ type: 'faute', message: 'TypeError: invariant d’épreuve rompu', pile: 'TypeError: invariant d’épreuve rompu\n at calcul (épreuve.js:1:1)' });
|
||
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
|
||
autre.rendre();
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'rejetee');
|
||
assert.ok(lue.erreur instanceof Error, String(lue.erreur));
|
||
assert.ok(!(lue.erreur instanceof ErreurGeneration), 'une faute n’est pas un refus');
|
||
assert.ok(!(lue.erreur instanceof ErreurAnnulee));
|
||
assert.equal(lue.erreur.code, undefined);
|
||
assert.match(lue.erreur.message, /TypeError: invariant d’épreuve rompu/);
|
||
assert.match(lue.erreur.stack, /at calcul \(épreuve\.js:1:1\)/);
|
||
assert.deepEqual(avancements, []);
|
||
assert.deepEqual(
|
||
travailleurs.map(({ terminaisons }) => terminaisons),
|
||
[1, 1],
|
||
);
|
||
});
|
||
|
||
test('un réglage hors de son domaine rejette REGLAGE_HORS_DOMAINE sans faire naître de travailleur ; un nombre de cœurs qui n’est pas un entier d’au moins 1 lève une RangeError', async () => {
|
||
const { fabrique, travailleurs } = fabriqueFactice();
|
||
const executeur = creerExecuteurTravailleur(fabrique, { coeursDisponibles: 4 });
|
||
const execution = executeur.lancer(CONFIGURATION, { ...REGLAGES, nombre: 0 }, { coeurs: 2 });
|
||
const lue = await issue(execution.resultat);
|
||
assert.equal(lue.etat, 'rejetee');
|
||
assert.equal(lue.erreur.code, 'REGLAGE_HORS_DOMAINE');
|
||
assert.deepEqual(lue.erreur.details, { reglage: 'nombre', remede: null });
|
||
assert.equal(execution.coeurs, 0);
|
||
assert.doesNotThrow(() => execution.annuler());
|
||
for (const coeurs of [0, 1.5, '2']) {
|
||
assert.throws(() => executeur.lancer(CONFIGURATION, REGLAGES, { coeurs }), RangeError, String(coeurs));
|
||
}
|
||
assert.equal(travailleurs.length, 0);
|
||
});
|
||
});
|