gestion_table_tournante_libre/src/application/executeur_travailleur.test.js
Mathieu Benoit ff31a6e1ca [ADD] engine for quality: A_max, facilitator diversity, run trace, pairs
The quality page needs figures the engine did not yet produce. It now
bounds each person's distinct memberships (A_max) and checks it with
the indicator guard; reads facilitator diversity over the evening and
per round; records the search's moves and record points; lists repeated
and imposed pairs; and lays out charts through one geometry module with
two bench thresholds. Chart colour tokens carry their own validation.
Default plans still equal the reference fingerprints; nothing new is
written to the file yet.
Checked: node 4768, navigateur 967, node-long 128; new modules at 100 %.

--- FR ---

[ADD] moteur de la qualité : A_max, diversité, course, paires

La page de qualité demande des chiffres que le moteur ne produisait pas.
Il borne désormais les appartenances distinctes de chacun (A_max) et la
garde des indicateurs la vérifie ; il lit la diversité des animateurs sur
la soirée et par tour ; il relève les mouvements et les points de la
recherche ; il liste les paires répétées et imposées ; un seul module de
géométrie dispose les graphiques, avec deux seuils au banc. Les jetons
des graphiques portent leur propre validation. Les plans par défaut
égalent les empreintes ; rien de neuf ne s'écrit encore au fichier.
Vérifié : node 4768, navigateur 967, longues 128 ; nouveaux modules à 100 %.

Assisted-by: Claude Opus 5.5
2026-10-12 04:29:27 -04:00

437 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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, creerExecuteurDirect } 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("la course de chaque proposition traverse le groupe telle quelle, sans date : l'exécuteur direct rend la même, quel que soit l'ordre d'achèvement", async () => {
const configuration = configurationDe('petite-conflit');
const reglages = { graine: 23, nombre: 4, arret: 3_000, historique: 100 };
const direct = await creerExecuteurDirect().lancer(configuration, reglages).resultat;
assert.ok(direct.every(({ evalues, releves }) => evalues === 3_000 && releves.length === 4));
for (const choisir of [() => 0, (n) => n - 1]) {
const { fabrique, servir } = fabriqueCalculante();
const execution = creerExecuteurTravailleur(fabrique).lancer(configuration, reglages, { coeurs: 3 });
servir(choisir);
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'resolue');
assert.deepStrictEqual(lue.valeur, direct);
for (const proposition of lue.valeur) {
assert.deepEqual(Object.keys(proposition), ['id', 'graine', 'arret', 'historique', 'plan', 'evalues', 'releves']);
}
}
});
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);
});
});