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