diff --git a/spec.md b/spec.md index e7fb2cc..eb07b5f 100644 --- a/spec.md +++ b/spec.md @@ -767,8 +767,8 @@ d'écran (§ 19.4) tomberait avec. L'interdit de `performance.now` dans le moteu **Le compte est un réglage, et son plafond d'usage est de trente minutes.** Il est exposé dans les réglages de génération, avec son unité ; le logiciel affiche -à côté la **durée réellement observée du dernier calcul de la séance**, qui est -la seule façon d'étalonner un compte. Elle vaut pour la séance et ne se garde +à côté la **durée réellement observée du dernier calcul de la séance**, et les +cœurs qu'il a employés, qui est la seule façon d'étalonner un compte. Elle vaut pour la séance et ne se garde pas au-delà — rien ne survit à la séance hors du dossier de travail (§ 8.6) : au premier calcul d'un lancement, aucune mesure n'existe, et l'estimation se dit inconnue. Le réglage livré par défaut est celui qui, sur la machine de @@ -777,9 +777,11 @@ référence, reste bien en deçà de ce plafond. **Une génération longue s'annonce avant de partir.** À partir de la dernière durée mesurée, le logiciel énonce l'ordre de grandeur attendu — « environ vingt minutes à ce réglage » — et, **au-delà d'une minute estimée**, demande, par une -question posée en place, sans fenêtre. Sans mesure, il demande dès que les -mouvements demandés — propositions × compte d'arrêt — dépassent dix fois ceux du -réglage par défaut. Une recherche d'une demi-heure lancée par mégarde est une +question posée en place, sans fenêtre. L'estimation compte les mouvements qui se +suivent sur un même cœur — le compte d'arrêt pour chacune des ⌈propositions / +cœurs⌉ vagues de propositions calculées ensemble —, ceux de la dernière mesure +ramenés de même aux cœurs qu'elle a employés. Sans mesure, il demande dès que ces +mouvements dépassent dix fois ceux du réglage par défaut. Une recherche d'une demi-heure lancée par mégarde est une demi-heure de soirée perdue, et aucun réglage par défaut ne protège de cela. **Pendant la recherche, l'application reste vivante.** Le calcul ne tient pas le @@ -790,6 +792,23 @@ logiciel — ce qui perd exactement ce que la recherche avait trouvé. Le compte d'inactivité du § 8.4 est suspendu tant qu'un calcul tourne, donc le retour automatique en lecture ne tombe jamais au milieu d'une génération. +**Les propositions d'une génération se calculent en parallèle, sur les cœurs du +poste.** Chaque proposition ne dépend que de sa graine dérivée (§ 5.7) : un +groupe de fils d'exécution, hors de celui de l'interface, s'en partage les rangs, +chacun prenant le suivant dès qu'il a rendu le sien, et le résultat, rangé par +identifiant, est octet pour octet celui d'un calcul sur un seul fil, quel que +soit le nombre de cœurs. Ce nombre est un **réglage du poste, non de +l'événement** : une case « Cœurs automatiques », cochée par défaut, en emploie un +par processeur logique, hyperthreads compris ; décochée, elle laisse saisir un +« Nombre de cœurs », un entier de 1 au nombre de processeurs logiques, la valeur +automatique affichée à côté, et refuse toute autre saisie là où elle a eu lieu. +Le réglage vaut pour la séance du logiciel, comme la durée observée : il ne +s'écrit ni dans le fichier d'état ni dans le journal, et se règle en lecture +comme en écriture. Une génération n'emploie jamais plus de cœurs que de +propositions demandées. L'avancement reste une seule barre, la somme des +mouvements évalués de toutes les propositions ; l'annulation arrête tous les fils +à la fois, et l'échec de l'un les arrête tous, sans résultat partiel. + **Une commande annule une génération en cours, et elle écarte le résultat partiel.** L'annulation est immédiate — elle n'attend pas la fin d'une itération longue. Une proposition issue d'une recherche tronquée porterait la même graine @@ -3071,6 +3090,13 @@ d'inscriptibilité **en cours de séance**, et un support amovible. Sans elle, l la règle à cinq issues du § 8.6 reste le code le moins éprouvé du logiciel alors qu'elle décide de l'endroit où vit la soirée. +**Les fils de la génération sont un paramètre, comme le système de fichiers.** +L'exécuteur qui répartit les propositions entre ses fils (§ 5.10) reçoit de quoi +en faire naître et le nombre de processeurs logiques du poste ; seul le point +d'entrée de la page lit ce nombre de la plateforme. Les épreuves donnent le leur, +et des fils simulés qui s'achèvent dans l'ordre qu'elles choisissent : c'est +ainsi que s'éprouve un résultat indépendant de l'ordre d'achèvement (§ 5.7). + **La frontière des couches est gardée mécaniquement**, et pas seulement par discipline : un test du **graphe d'imports** refuse qu'un module de `src/moteur` ou de `src/geometrie` importe de `src/interface`, de `src/application`, de diff --git a/src/application/executeur_travailleur.js b/src/application/executeur_travailleur.js index 209f22e..d4840c1 100644 --- a/src/application/executeur_travailleur.js +++ b/src/application/executeur_travailleur.js @@ -1,57 +1,85 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// L'exécuteur de production de la génération (§ 5.10, § 13.4) : la -// recherche tourne dans un travailleur, hors du fil de l'interface, et -// l'exécuteur adapte son protocole — celui de generation_travailleur.js — -// au contrat Executeur du contrôleur de génération (generation.js). +// L'exécuteur de production de la génération (§ 5.7, § 5.10, § 13.4) : la +// recherche tourne dans un groupe de travailleurs, hors du fil de +// l'interface, et l'exécuteur adapte leur protocole — celui de +// generation_travailleur.js — au contrat Executeur du contrôleur de +// génération (generation.js). // -// → { type: 'lancer', configuration, reglages } le premier et seul message -// ← { type: 'avancement', fait, total } remis aux abonnés -// ← { type: 'resultat', propositions } résout le résultat -// ← { type: 'erreur', code, details } rejette par ErreurGeneration -// ← { type: 'faute', message, pile } rejette par une Error, une faute -// ✕ canal d'erreur du travailleur rejette par TRAVAILLEUR_EN_ECHEC +// Un lancer fait naître W = min(nombre, coeurs) travailleurs. Les rangs des +// propositions, 1 à nombre, se donnent dans l'ordre croissant : le +// travailleur k reçoit d'abord le rang k, puis chaque rang suivant va au +// premier travailleur qui rend sa proposition. Celui qui n'a plus de rang à +// recevoir est terminé aussitôt. Chaque proposition se calcule de sa graine +// dérivée, la même quel que soit le travailleur qui la calcule (proposerUne) : +// le résultat, rangé par identifiant, est octet pour octet celui de +// rechercher, quel que soit W et quel que soit l'ordre d'achèvement (§ 5.7). // -// Le travailleur poste ses fautes de calcul en message : son canal d'erreur +// → { type: 'proposer', configuration, reglages, rang } un rang à la fois +// ← { type: 'avancement', fait, total } fait dans la proposition en +// cours de ce travailleur +// ← { type: 'proposition', proposition } range la proposition, puis +// le rang suivant ou la fin +// ← { type: 'erreur', code, details } rejette par ErreurGeneration +// ← { type: 'faute', message, pile } rejette par une Error, une faute +// ✕ canal d'erreur d'un travailleur rejette par TRAVAILLEUR_EN_ECHEC +// +// L'avancement remis aux abonnés est la somme, sur toutes les propositions, +// des mouvements évalués — le dernier avancement reçu pour une proposition +// en cours, son arret entier pour une proposition rendue —, sur +// nombre × arret ; il ne décroît jamais. +// +// Un travailleur poste ses fautes de calcul en message : son canal d'erreur // ne porte que l'échec de son script, qui ne se charge ou ne se compile pas. // -// Chaque lancer prend un travailleur neuf de la fabrique et le termine à -// l'issue : résultat, erreur, faute, échec du script ou annulation. Terminé, -// le travailleur ne remet plus rien : un message déjà en file à cet instant -// ne parvient ni aux abonnés ni au résultat. L'annulation est terminate(), -// dans le même tour : immédiate, sans résultat partiel. La fabrique est un -// paramètre : src/main.js la construit sur le travailleur inclus dans la -// page, une épreuve sur un travailleur factice. Ce module ne touche aucune -// plateforme. -import { ErreurAnnulee, ErreurGeneration } from './generation.js'; +// L'issue — résultat, erreur, faute, échec d'un script ou annulation — +// termine tous les travailleurs du lancer, dans le même tour. Terminé, un +// travailleur ne remet plus rien : un message déjà en file à cet instant ne +// parvient ni aux abonnés ni au résultat. L'annulation est terminate() de +// chacun : immédiate, sans résultat partiel. La fabrique est un paramètre : +// src/main.js la construit sur le travailleur inclus dans la page, avec le +// nombre de processeurs logiques du poste ; une épreuve, sur des +// travailleurs factices. Ce module ne touche aucune plateforme. +import { ErreurAnnulee, ErreurGeneration, coeursDe, exigerReglages } from './generation.js'; /** - * L'exécuteur du travailleur. + * L'exécuteur du groupe de travailleurs. * * @param {() => {postMessage: (message: Object) => void, terminate: () => void, * onmessage: ((evenement: {data: Object}) => void)|null, * onerror?: ((evenement: {message?: string}) => void)|null}} fabrique * rend un travailleur neuf à chaque appel + * @param {{coeursDisponibles?: unknown}} [options] le nombre de processeurs + * logiques du poste, que l'exécuteur rend par coeursDe : 1 quand il n'est + * pas un entier d'au moins 1 * @returns {import('./generation.js').Executeur} - * lancer(configuration, reglages) rend une Execution : avancement → - * surAvancement ; resultat → la promesse résolue aux propositions reçues, - * puis terminate() ; erreur {code, details} → rejet par - * ErreurGeneration(code, details) ; faute {message, pile} → rejet par - * une Error sans code, dont le message porte celui du travailleur et la - * pile la sienne : elle remonte comme une faute, comme sous l'exécuteur - * direct ; canal d'erreur du travailleur — son script ne se charge ou ne + * coeursDisponibles ; lancer(configuration, reglages, { coeurs = 1 }) rend + * une Execution, coeurs étant le nombre de travailleurs qu'elle emploie. + * Un réglage hors de son domaine la rejette par REGLAGE_HORS_DOMAINE + * (exigerReglages), sans travailleur, coeurs valant 0 ; un nombre de + * cœurs qui n'est pas un entier d'au moins 1 lève une RangeError, une + * faute de l'appelant. avancement → surAvancement, la somme de l'en-tête ; + * la dernière proposition rangée → la promesse résolue aux propositions + * par identifiant croissant ; erreur {code, details} → rejet par + * ErreurGeneration(code, details) ; faute {message, pile} → rejet par une + * Error sans code, dont le message porte celui du travailleur et la pile + * la sienne : elle remonte comme une faute, comme sous l'exécuteur + * direct ; canal d'erreur d'un travailleur — son script ne se charge ou ne * se compile pas —, → rejet par ErreurGeneration('TRAVAILLEUR_EN_ECHEC', * { remede: null }), l'événement traité : aucun geste de la page ne * relance un travailleur que le navigateur refuse, et le texte du refus - * nomme sa cause (§ 5.9) ; - * annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun - * avancement. Après l'issue, annuler() ne fait rien. + * nomme sa cause (§ 5.9) ; annuler() → terminate() de chacun aussitôt, + * rejet par ErreurAnnulee, plus aucun avancement. Après l'issue, annuler() + * ne fait rien. */ -export function creerExecuteurTravailleur(fabrique) { +export function creerExecuteurTravailleur(fabrique, { coeursDisponibles } = {}) { return { - lancer(configuration, reglages) { - const travailleur = fabrique(); + coeursDisponibles: coeursDe(coeursDisponibles), + lancer(configuration, reglages, { coeurs = 1 } = {}) { + if (!Number.isInteger(coeurs) || coeurs < 1) { + throw new RangeError(`coeurs : entier d'au moins 1 attendu, reçu ${String(coeurs)}`); + } const abonnes = new Set(); let termine = false; let resoudre; @@ -60,59 +88,132 @@ export function creerExecuteurTravailleur(fabrique) { resoudre = ok; rejeter = ko; }); + const surAvancement = (fn) => { + abonnes.add(fn); + return () => { + abonnes.delete(fn); + }; + }; - // L'issue : le travailleur se termine, ses gestionnaires sont retirés, - // et rien de ce qu'il remettrait encore ne passe. - function terminer() { - termine = true; + try { + exigerReglages(reglages); + } catch (erreur) { + rejeter(erreur); + return { surAvancement, resultat, annuler() {}, coeurs: 0 }; + } + + const { nombre, arret } = reglages; + const total = nombre * arret; + // propositions[r − 1] : la proposition de rang r, rangée ; faits[r − 1] : + // les mouvements évalués de ce rang ; fait, leur somme. + const propositions = new Array(nombre); + const faits = new Array(nombre).fill(0); + let fait = 0; + let rangees = 0; + let prochain = 1; + const travailleurs = []; + + function publier() { + for (const fn of [...abonnes]) fn(fait, total); + } + + // Porte les mouvements évalués du rang à valeur, s'ils croissent, et + // publie la somme nouvelle. + function avancer(rang, valeur) { + if (valeur <= faits[rang - 1]) return; + fait += valeur - faits[rang - 1]; + faits[rang - 1] = valeur; + publier(); + } + + // Un travailleur arrêté : ses gestionnaires retirés, il ne remet plus rien. + function arreter(travailleur) { travailleur.onmessage = null; travailleur.onerror = null; travailleur.terminate(); } - travailleur.onmessage = ({ data }) => { - if (termine) return; + // L'issue : chaque travailleur encore en vie est arrêté. + function terminer() { + termine = true; + for (const suivi of travailleurs) { + if (suivi.vivant) { + suivi.vivant = false; + arreter(suivi.travailleur); + } + } + } + + function echouer(erreur) { + rejeter(erreur); + terminer(); + } + + // Donne au travailleur suivi le rang suivant, ou l'arrête quand il + // n'en reste plus. + function confier(suivi) { + if (prochain > nombre) { + suivi.vivant = false; + suivi.rang = null; + arreter(suivi.travailleur); + return; + } + suivi.rang = prochain; + prochain += 1; + suivi.travailleur.postMessage({ type: 'proposer', configuration, reglages, rang: suivi.rang }); + } + + function recevoir(suivi, data) { + if (termine || !suivi.vivant) return; if (data.type === 'avancement') { - for (const fn of [...abonnes]) fn(data.fait, data.total); - } else if (data.type === 'resultat') { - resoudre(data.propositions); - terminer(); + avancer(suivi.rang, data.fait); + } else if (data.type === 'proposition') { + const { rang } = suivi; + propositions[rang - 1] = data.proposition; + rangees += 1; + avancer(rang, arret); + if (rangees === nombre) { + resoudre(propositions); + terminer(); + } else { + confier(suivi); + } } else if (data.type === 'erreur') { - rejeter(new ErreurGeneration(data.code, data.details)); - terminer(); + echouer(new ErreurGeneration(data.code, data.details)); } else if (data.type === 'faute') { const faute = new Error(`travailleur de génération : ${data.message}`); faute.stack = `${faute.stack}\n${data.pile}`; - rejeter(faute); - terminer(); + echouer(faute); } - }; - // Le script du travailleur ne se charge ou ne se compile pas : le refus - // TRAVAILLEUR_EN_ECHEC, que la commande « Générer » pose à côté - // d'elle, jamais le message du moteur d'exécution. L'événement est - // traité : le navigateur ne le signale pas en plus comme une faute de - // la page. Une faute du calcul arrive en message, jamais ici. - travailleur.onerror = (evenement) => { - evenement?.preventDefault?.(); - if (termine) return; - rejeter(new ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null })); - terminer(); - }; - travailleur.postMessage({ type: 'lancer', configuration, reglages }); + } + + const paralleles = Math.min(nombre, coeurs); + for (let k = 0; k < paralleles; k += 1) { + const suivi = { travailleur: fabrique(), rang: null, vivant: true }; + travailleurs.push(suivi); + suivi.travailleur.onmessage = ({ data }) => recevoir(suivi, data); + // Le script du travailleur ne se charge ou ne se compile pas : le + // refus TRAVAILLEUR_EN_ECHEC, que la commande « Générer » pose à côté + // d'elle, jamais le message du moteur d'exécution. L'événement est + // traité : le navigateur ne le signale pas en plus comme une faute de + // la page. Une faute du calcul arrive en message, jamais ici. + suivi.travailleur.onerror = (evenement) => { + evenement?.preventDefault?.(); + if (termine || !suivi.vivant) return; + echouer(new ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null })); + }; + } + for (const suivi of travailleurs) confier(suivi); return { - surAvancement(fn) { - abonnes.add(fn); - return () => { - abonnes.delete(fn); - }; - }, + surAvancement, resultat, annuler() { if (termine) return; terminer(); rejeter(new ErreurAnnulee()); }, + coeurs: paralleles, }; }, }; diff --git a/src/application/executeur_travailleur.test.js b/src/application/executeur_travailleur.test.js index cd974b6..213d6e5 100644 --- a/src/application/executeur_travailleur.test.js +++ b/src/application/executeur_travailleur.test.js @@ -1,24 +1,34 @@ // © 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.10) : 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 de données par son onmessage, comme le ferait le -// vrai travailleur. Aucun calcul n'a lieu : les propositions et la -// configuration sont des objets d'épreuve. +// É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. +// travailleur, remis à son onmessage courant ; rang(), celui du dernier +// proposer reçu. function fabriqueFactice() { const travailleurs = []; const fabrique = () => { @@ -35,6 +45,13 @@ function fabriqueFactice() { 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; @@ -42,6 +59,42 @@ function fabriqueFactice() { 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 }. @@ -60,28 +113,141 @@ async function issue(promesse) { return lue; } -describe('exécuteur du travailleur (§ 5.10)', () => { - test('un travailleur neuf par lancer, dont le premier message est { type: lancer, configuration, reglages }', () => { +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 executeur = creerExecuteurTravailleur(fabrique); - assert.equal(travailleurs.length, 0, 'aucun travailleur avant le premier lancer'); - const premiere = executeur.lancer(CONFIGURATION, REGLAGES); - const seconde = executeur.lancer(CONFIGURATION, { ...REGLAGES, graine: 12 }); - premiere.resultat.catch(() => undefined); - seconde.resultat.catch(() => undefined); - assert.equal(travailleurs.length, 2); - assert.notEqual(travailleurs[0], travailleurs[1]); - assert.deepEqual(travailleurs[0].recus, [{ type: 'lancer', configuration: CONFIGURATION, reglages: REGLAGES }]); - assert.deepEqual(travailleurs[1].recus, [ - { type: 'lancer', configuration: CONFIGURATION, reglages: { ...REGLAGES, graine: 12 } }, - ]); + 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), - [0, 0], + [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('deux avancements parviennent à surAvancement dans leur ordre ; un abonné retiré ne reçoit plus rien', () => { + 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('un abonné retiré ne reçoit plus rien', () => { const { fabrique, travailleurs } = fabriqueFactice(); const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES); execution.resultat.catch(() => undefined); @@ -90,79 +256,79 @@ describe('exécuteur du travailleur (§ 5.10)', () => { 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: 50_000, total: 80_000 }); + travailleur.poster({ type: 'avancement', fait: 20_000, total: 40_000 }); desabonner(); - travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 }); + travailleur.poster({ type: 'avancement', fait: 30_000, total: 40_000 }); assert.deepEqual(recus, [ - [50_000, 80_000], - [80_000, 80_000], + [20_000, 80_000], + [30_000, 80_000], ]); - assert.deepEqual(retires, [[50_000, 80_000]]); + assert.deepEqual(retires, [[20_000, 80_000]]); + execution.annuler(); }); - test('resultat : la promesse se résout aux propositions reçues, puis le travailleur est terminé ; un message tardif ne change rien', async () => { + 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); + 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)); - const [travailleur] = travailleurs; - const propositions = [ - { id: 1, graine: 101, arret: 40_000, historique: 1000, plan: [[[1, 2]]] }, - { id: 2, graine: 102, arret: 40_000, historique: 1000, plan: [[[2, 1]]] }, - ]; - assert.equal((await issue(execution.resultat)).etat, 'attente'); - travailleur.poster({ type: 'resultat', propositions }); - const lue = await issue(execution.resultat); - assert.equal(lue.etat, 'resolue'); - assert.deepEqual(lue.valeur, propositions); - assert.equal(travailleur.terminaisons, 1); - travailleur.poster({ type: 'avancement', fait: 1, total: 2 }); - execution.annuler(); - assert.deepEqual(avancements, []); - assert.equal(travailleur.terminaisons, 1, 'annuler après le résultat ne termine pas une seconde fois'); - }); - - test('erreur { code, details } : rejet par une ErreurGeneration de ce code et de ces détails, le travailleur terminé', async () => { - const { fabrique, travailleurs } = fabriqueFactice(); - const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES); - const [travailleur] = travailleurs; - travailleur.poster({ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }); + 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.equal(travailleur.terminaisons, 1); + assert.deepEqual( + travailleurs.map(({ terminaisons }) => terminaisons), + [1, 1, 1], + ); + assert.deepEqual(avancements, [40_000]); }); - test("annuler : terminate() aussitôt, rejet par ErreurAnnulee ; un avancement ou un résultat posté ensuite n'atteint plus rien", async () => { + 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); - const avancements = []; - execution.surAvancement((fait, total) => avancements.push([fait, total])); - const [travailleur] = travailleurs; - // Le gestionnaire posé au lancement : un message déjà en file chez le - // navigateur au moment de l'annulation l'atteindrait encore. - const gestionnaire = travailleur.onmessage; - travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 }); - execution.annuler(); - assert.equal(travailleur.terminaisons, 1, 'terminate() dans le même tour que annuler()'); - gestionnaire({ data: { type: 'avancement', fait: 80_000, total: 80_000 } }); - gestionnaire({ data: { type: 'resultat', propositions: [] } }); - travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 }); - const lue = await issue(execution.resultat); - assert.equal(lue.etat, 'rejetee'); - assert.ok(lue.erreur instanceof ErreurAnnulee, String(lue.erreur)); - assert.deepEqual(avancements, [[50_000, 80_000]]); - execution.annuler(); - assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus'); - }); - - test('le canal d’erreur du 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é, le travailleur terminé', async () => { - const { fabrique, travailleurs } = fabriqueFactice(); - const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES); - const [travailleur] = travailleurs; - assert.equal(typeof travailleur.onerror, 'function'); + 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, @@ -170,7 +336,7 @@ describe('exécuteur du travailleur (§ 5.10)', () => { this.traite = true; }, }; - travailleur.onerror(evenement); + travailleurs[1].onerror(evenement); const lue = await issue(execution.resultat); assert.equal(lue.etat, 'rejetee'); assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur)); @@ -178,22 +344,25 @@ describe('exécuteur du travailleur (§ 5.10)', () => { assert.deepEqual(lue.erreur.details, { remede: null }); assert.doesNotMatch(JSON.stringify(lue.erreur.details), /épreuve/); assert.equal(evenement.traite, true); - assert.equal(travailleur.terminaisons, 1); + 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[1].onerror({}); + 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, le travailleur terminé, la suite ignorée', async () => { + 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); + const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES, { coeurs: 2 }); const avancements = []; execution.surAvancement((fait, total) => avancements.push([fait, total])); - const [travailleur] = travailleurs; + 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 }); - travailleur.poster({ type: 'resultat', propositions: [] }); + autre.rendre(); const lue = await issue(execution.resultat); assert.equal(lue.etat, 'rejetee'); assert.ok(lue.erreur instanceof Error, String(lue.erreur)); @@ -203,6 +372,25 @@ describe('exécuteur du travailleur (§ 5.10)', () => { 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.equal(travailleur.terminaisons, 1); + 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); }); }); diff --git a/src/application/generation.js b/src/application/generation.js index 98a77d3..1900fbf 100644 --- a/src/application/generation.js +++ b/src/application/generation.js @@ -13,6 +13,14 @@ // et s'annule par terminate(). Une erreur de la recherche devient la même // dans les deux, par versErreurGeneration. // +// Le contrôleur tient aussi le réglage des cœurs de calcul, un réglage du +// poste qui vaut pour la séance de l'application et ne s'écrit nulle part : +// automatique, la génération emploie autant de cœurs que l'exécuteur annonce +// de processeurs logiques (coeursDisponibles, que src/main.js lit de la +// plateforme) ; sinon le nombre saisi, de 1 à ces disponibles. Un lancement +// en emploie min(nombre de propositions, cœurs réglés), et l'estimation +// compte en vagues de propositions calculées ensemble. +// // Le contrôleur ne numérote rien et ne mesure aucun plan. Il refuse avant // tout lancement un réglage hors de son domaine (REGLAGE_HORS_DOMAINE), puis // ce que l'enregistrement refuserait — les refus de la séance, lus dans son @@ -104,6 +112,18 @@ export function exigerReglages(reglages) { } } +/** + * Le nombre de processeurs logiques d'un poste, tel que la plateforme le + * donne — hyperthreads compris —, rendu utilisable : un entier d'au moins 1 + * tel quel, 1 pour toute autre valeur, absente comprise. + * + * @param {unknown} valeur + * @returns {number} + */ +export function coeursDe(valeur) { + return Number.isInteger(valeur) && valeur >= 1 ? valeur : 1; +} + /** * L'erreur d'une recherche, la même dans les deux exécuteurs : ErreurAnnulee * telle quelle ; une ErreurConfiguration devient ErreurGeneration de son code @@ -127,10 +147,16 @@ export function versErreurGeneration(erreur) { * abonne fn aux avancements ; rend la fonction qui désabonne * @property {Promise} resultat * rejetée par ErreurAnnulee à l'annulation, par ErreurGeneration sur - * une erreur du moteur + * une erreur du moteur ; les propositions par identifiant croissant * @property {() => void} annuler sans résultat partiel + * @property {number} [coeurs] les cœurs que le calcul emploie ; 1 s'il + * ne le dit pas * - * @typedef {{lancer: (configuration: Object, reglages: Object) => Execution}} Executeur + * @typedef {Object} Executeur + * @property {(configuration: Object, reglages: Object, options?: {coeurs?: number}) => Execution} lancer + * options.coeurs : les cœurs que le calcul peut employer, 1 par défaut + * @property {number} [coeursDisponibles] les processeurs logiques du poste ; + * 1 s'il ne le dit pas */ /** @@ -139,12 +165,17 @@ export function versErreurGeneration(erreur) { * ou depuis un rappel d'avancement, fait lever ErreurAnnulee à la lecture * suivante du signal par le moteur : avant la proposition suivante, au plus * 1 000 mouvements plus tard. Chaque avancement est celui du moteur, toutes - * les 1 000 itérations. + * les 1 000 itérations. Il calcule une proposition après l'autre quel que + * soit options.coeurs, et chaque exécution dit n'employer qu'un cœur ; il + * annonce les cœurs qu'on lui donne, par coeursDe, pour qu'une épreuve règle + * ce que le contrôleur en lit. * + * @param {{coeursDisponibles?: unknown}} [options] * @returns {Executeur} */ -export function creerExecuteurDirect() { +export function creerExecuteurDirect({ coeursDisponibles } = {}) { return { + coeursDisponibles: coeursDe(coeursDisponibles), lancer(configuration, reglages) { const signal = { aborted: false }; const abonnes = new Set(); @@ -170,6 +201,7 @@ export function creerExecuteurDirect() { annuler() { signal.aborted = true; }, + coeurs: 1, }; }, }; @@ -189,23 +221,35 @@ function planRange(proposition, configuration, produitVersion) { * @param {Object} parametres * @param {Object} parametres.seance la séance d'A1 et A2 : etat, * subscribe, surAnnulation, executer, debuterCalcul, finirCalcul - * @param {Executeur} parametres.executeur + * @param {Executeur} parametres.executeur coeursDisponibles, qu'il annonce, + * borne le réglage des cœurs * @param {import('./horloge.js').Horloge} parametres.horloge instant() mesure * la durée d'un calcul * @param {string} parametres.produitVersion * @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider, - * estimer, derniereGeneration, generer, annuler, subscribe + * estimer, derniereGeneration, generer, annuler, coeurs, + * reglerCoeurs, subscribe */ export function creerControleurGeneration({ seance, executeur, horloge, produitVersion }) { // graine : la graine proposée ; base : celle de l'événement ouvert, null // sans événement ; mesure : le dernier calcul abouti, null avant lui ; // execution : celle en cours, null hors génération ; enCours vaut vrai du // lancement à la fin de l'enregistrement. + // coeurs : le réglage des cœurs de calcul, que l'observable porte — + // automatique, le nombre saisi (manuel), les disponibles, et nombre, les + // cœurs réglés. let graine = 1; let base = null; let mesure = null; let execution = null; - let observable = { enCours: false, fait: 0, total: 0, annulee: null }; + const disponibles = coeursDe(executeur.coeursDisponibles); + let reglageCoeurs = { automatique: true, manuel: disponibles }; + const coeurs = () => ({ + ...reglageCoeurs, + disponibles, + nombre: reglageCoeurs.automatique ? disponibles : reglageCoeurs.manuel, + }); + let observable = { enCours: false, fait: 0, total: 0, annulee: null, coeurs: coeurs() }; const abonnes = new Set(); function publier(changements) { @@ -232,22 +276,37 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV courante.annuler(); } + // Les cœurs qu'un lancement de nombre propositions emploie. + const coeursPour = (nombre) => Math.min(nombre, coeurs().nombre); + + // Les mouvements d'une génération qui se suivent sur un même cœur : arret + // pour chacune des ⌈nombre / cœurs⌉ vagues de propositions calculées + // ensemble. + const mouvementsParCoeur = (nombre, arret, parallele) => Math.ceil(nombre / parallele) * arret; + + // La durée d'une génération, proportionnelle aux mouvements qui se suivent + // sur un même cœur, ceux de la mesure ramenés aux cœurs qu'elle a employés. function estimer({ nombre, arret }) { - const mouvements = nombre * arret; if (mesure === null) return { inconnue: true }; - const secondes = (mouvements * mesure.dureeMs) / (mesure.mouvements * 1000); + const parallele = coeursPour(nombre); + const secondes = + (mouvementsParCoeur(nombre, arret, parallele) * mesure.dureeMs) / + (mouvementsParCoeur(mesure.nombre, mesure.arret, mesure.coeurs) * 1000); return { secondes, longue: secondes > SEUIL_GENERATION_LONGUE_S, auDelaDuPlafond: secondes > PLAFOND_USAGE_S, mesure: { ...mesure }, + coeurs: parallele, }; } - // Longue : au-delà du seuil estimé, ou, sans mesure, au-delà de dix fois - // le réglage par défaut. + // Longue : au-delà du seuil estimé, ou, sans mesure, quand les mouvements + // qui se suivent sur un même cœur dépassent dix fois le réglage par défaut. function estLongue(reglages, estimation) { - if (estimation.inconnue === true) return reglages.nombre * reglages.arret > MOUVEMENTS_SANS_MESURE; + if (estimation.inconnue === true) { + return mouvementsParCoeur(reglages.nombre, reglages.arret, coeursPour(reglages.nombre)) > MOUVEMENTS_SANS_MESURE; + } return estimation.longue; } @@ -290,13 +349,14 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV // résultat entre-temps. async function calculer(configuration, reglages) { const debut = horloge.instant(); - execution = executeur.lancer(configuration, reglages); + execution = executeur.lancer(configuration, reglages, { coeurs: coeursPour(reglages.nombre) }); + const employes = coeursDe(execution.coeurs); const desabonner = execution.surAvancement((fait, total) => publier({ fait, total })); const desannuler = seance.surAnnulation(() => annulerAvec('seance')); try { const propositions = await execution.resultat; if (observable.annulee !== null) throw new ErreurAnnulee(); - return { propositions, dureeMs: horloge.instant() - debut }; + return { propositions, dureeMs: horloge.instant() - debut, coeurs: employes }; } finally { desabonner(); desannuler(); @@ -349,13 +409,14 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV publier({ enCours: true, fait: 0, total: reglages.nombre * reglages.arret, annulee: null }); seance.debuterCalcul(); try { - const { propositions, dureeMs } = await calculer(configuration, reglages); + const { propositions, dureeMs, coeurs: employes } = await calculer(configuration, reglages); mesure = { dureeMs, mouvements: reglages.nombre * reglages.arret, participants: instance.N, nombre: reglages.nombre, arret: reglages.arret, + coeurs: employes, }; const { gardees, identiques } = trier(propositions, configuration, instance); if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs }; @@ -404,12 +465,19 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV return { reglages: refus.length === 0 ? reglages : null, refus }; }, /** - * L'estimation des réglages, proportionnelle aux mouvements de la - * dernière mesure : { inconnue: true } sans elle, sinon { secondes, - * longue, auDelaDuPlafond, mesure }. + * L'estimation des réglages, proportionnelle aux mouvements qui se + * suivent sur un même cœur — arret × ⌈nombre / cœurs⌉ —, ceux de la + * dernière mesure ramenés de même aux cœurs qu'elle a employés : + * { inconnue: true } sans elle, sinon { secondes, longue, + * auDelaDuPlafond, mesure, coeurs }, coeurs étant ceux que ces réglages + * emploieraient. */ estimer, - /** La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null avant lui. */ + /** + * La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null + * avant lui : { dureeMs, mouvements, participants, nombre, arret, + * coeurs }, coeurs étant ceux que l'exécution a employés. + */ derniereGeneration: () => (mesure === null ? null : { ...mesure }), /** * Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }. @@ -426,9 +494,41 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV /** Annule la génération en cours, sans résultat partiel ; annulee vaut 'commande'. */ annuler: () => annulerAvec('commande'), /** - * Le contrat des magasins de Svelte sur { enCours, fait, total, annulee } : - * annulee, la cause de la dernière annulation, 'commande' ou 'seance', - * null dès le lancement suivant. + * Le réglage des cœurs de calcul : { automatique, manuel, disponibles, + * nombre } — automatique vrai à la création ; manuel, le dernier nombre + * saisi, les disponibles avant toute saisie ; disponibles, ceux + * qu'annonce l'exécuteur, par coeursDe ; nombre, les cœurs réglés, + * disponibles en automatique, manuel sinon. + */ + coeurs, + /** + * Règle les cœurs de calcul, pour la séance de l'application : rien ne + * s'écrit, ni état, ni journal. saisie, quand elle est donnée, se lit + * par lireEntier (NON_ENTIER {saisie}), puis se borne de 1 aux + * disponibles (HORS_DOMAINE {min: 1, max}), remède null ; refusée, rien + * ne change, automatique compris. Rend { refus: null } ou { refus: + * {code, details} } ; un réglage accepté se publie aux abonnés. + */ + reglerCoeurs({ automatique, saisie } = {}) { + const suivant = { ...reglageCoeurs }; + if (saisie !== undefined) { + const lu = lireEntier(saisie); + if (lu.refus !== undefined) return { refus: { code: lu.refus.code, details: lu.refus.details } }; + if (lu.valeur < 1 || lu.valeur > disponibles) { + return { refus: { code: 'HORS_DOMAINE', details: { min: 1, max: disponibles, remede: null } } }; + } + suivant.manuel = lu.valeur; + } + if (automatique !== undefined) suivant.automatique = automatique === true; + reglageCoeurs = suivant; + publier({ coeurs: coeurs() }); + return { refus: null }; + }, + /** + * Le contrat des magasins de Svelte sur { enCours, fait, total, annulee, + * coeurs } : annulee, la cause de la dernière annulation, 'commande' ou + * 'seance', null dès le lancement suivant ; coeurs, le réglage des + * cœurs de calcul, celui que rend coeurs(). */ subscribe(fn) { abonnes.add(fn); diff --git a/src/application/generation.test.js b/src/application/generation.test.js index 0912ac5..3f9de85 100644 --- a/src/application/generation.test.js +++ b/src/application/generation.test.js @@ -24,15 +24,18 @@ import { SUFFIXES } from '../stockage/noms.js'; import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; import { ErreurCommande } from './erreurs.js'; +import { creerExecuteurTravailleur } from './executeur_travailleur.js'; import { ErreurAnnulee, ErreurGeneration, PLAFOND_USAGE_S, SEUIL_GENERATION_LONGUE_S, creerControleurGeneration, + coeursDe, creerExecuteurDirect, versErreurGeneration, } from './generation.js'; +import { brancherTravailleur } from './generation_travailleur.js'; import { creerHorlogePilotee } from './horloge.js'; import { libelle } from './libelles.js'; import { INACTIVITE_MS, creerSeance } from './seance.js'; @@ -85,14 +88,16 @@ function chargeDe({ n = 0, exclus = [], tables = 0, sieges = 3, tours = 2, nom = // --- Les exécuteurs d'épreuve --------------------------------------------------- -// Un exécuteur qui note chaque lancement avant de le confier à interne. +// Un exécuteur qui note chaque lancement, options comprises, avant de le +// confier à interne ; il porte les cœurs disponibles d'interne. function espionner(interne = creerExecuteurDirect()) { const lancements = []; return { lancements, - lancer(configuration, reglages) { - lancements.push({ configuration, reglages }); - return interne.lancer(configuration, reglages); + coeursDisponibles: interne.coeursDisponibles, + lancer(configuration, reglages, options) { + lancements.push({ configuration, reglages, options }); + return interne.lancer(configuration, reglages, options); }, }; } @@ -102,11 +107,14 @@ function espionner(interne = creerExecuteurDirect()) { // rejette ErreurAnnulee, comme le travailleur qu'on termine ; differer // vrai, elle ne fait que compter, et l'épreuve rejette quand elle veut, // comme l'exécuteur direct qui n'arrête qu'à la lecture suivante du signal. -function executeurManuel({ differer = false } = {}) { +// coeursDisponibles : ceux qu'il annonce ; chaque exécution emploie les +// cœurs que le lancer lui demande (options.coeurs). +function executeurManuel({ differer = false, coeursDisponibles } = {}) { const executions = []; return { executions, - lancer(configuration, reglages) { + coeursDisponibles, + lancer(configuration, reglages, options) { const abonnes = new Set(); let resoudre; let rejeter; @@ -117,6 +125,7 @@ function executeurManuel({ differer = false } = {}) { const execution = { configuration, reglages, + coeurs: options?.coeurs, annulations: 0, resultat, resoudre, @@ -226,6 +235,8 @@ const planRange = (proposition, configuration) => const ids = (charge) => charge.propositions.map(({ id }) => id); const COURT = { graine: 1, nombre: 1, arret: 50, historique: 1000 }; +// Le réglage des cœurs d'un contrôleur dont l'exécuteur n'en annonce pas. +const UN_COEUR = Object.freeze({ automatique: true, manuel: 1, disponibles: 1, nombre: 1 }); // --- valider (§ 5.7) ------------------------------------------------------------ @@ -807,7 +818,7 @@ describe("génération : l'annulation (§ 5.10, § 8.4)", () => { montage.controleur.subscribe((etatControleur) => { vu = etatControleur; }); - assert.deepEqual(vu, { enCours: false, fait: 0, total: COURT.nombre * COURT.arret, annulee: 'seance' }); + assert.deepEqual(vu, { enCours: false, fait: 0, total: COURT.nombre * COURT.arret, annulee: 'seance', coeurs: UN_COEUR }); assert.deepEqual(montage.journal(), journal); assert.equal(montage.etatDisque(), etat); }); @@ -849,17 +860,17 @@ describe("génération : l'annulation (§ 5.10, § 8.4)", () => { const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); const vus = []; montage.controleur.subscribe((etat) => vus.push(etat)); - assert.deepEqual(vus[0], { enCours: false, fait: 0, total: 0, annulee: null }); + assert.deepEqual(vus[0], { enCours: false, fait: 0, total: 0, annulee: null, coeurs: UN_COEUR }); const enCours = montage.controleur.generer(COURT); await Promise.resolve(); manuel.executions[0].avancer(20, 50); - assert.deepEqual(vus.at(-1), { enCours: true, fait: 20, total: 50, annulee: null }); + assert.deepEqual(vus.at(-1), { enCours: true, fait: 20, total: 50, annulee: null, coeurs: UN_COEUR }); montage.controleur.annuler(); await rejet(enCours); - assert.deepEqual(vus.at(-1), { enCours: false, fait: 20, total: 50, annulee: 'commande' }); + assert.deepEqual(vus.at(-1), { enCours: false, fait: 20, total: 50, annulee: 'commande', coeurs: UN_COEUR }); const suivante = montage.controleur.generer(COURT); await Promise.resolve(); - assert.deepEqual(vus.at(-1), { enCours: true, fait: 0, total: 50, annulee: null }); + assert.deepEqual(vus.at(-1), { enCours: true, fait: 0, total: 50, annulee: null, coeurs: UN_COEUR }); manuel.executions[1].resoudreParLeMoteur(); await suivante; assert.equal(vus.at(-1).enCours, false); @@ -971,6 +982,7 @@ describe('génération : la durée observée, en mémoire (§ 5.10)', () => { participants: 12, nombre: 5, arret: 200000, + coeurs: 1, }); const neuf = creerControleurGeneration({ seance: montage.seance, @@ -1009,12 +1021,14 @@ describe("génération : l'estimation et la génération longue (§ 5.10)", () = longue: true, auDelaDuPlafond: false, mesure, + coeurs: 1, }); assert.deepEqual(controleur.estimer({ graine: 1, nombre: 5, arret: 200000, historique: 1000 }), { secondes: 2, longue: false, auDelaDuPlafond: false, mesure, + coeurs: 1, }); assert.equal(controleur.estimer({ graine: 1, nombre: 5000, arret: 200000, historique: 1000 }).auDelaDuPlafond, true); assert.equal(controleur.estimer({ graine: 1, nombre: 150, arret: 200000, historique: 1000 }).longue, false); @@ -1050,6 +1064,268 @@ describe("génération : l'estimation et la génération longue (§ 5.10)", () = }); }); +// --- Les cœurs de calcul (§ 5.7, § 5.10) ------------------------------------------------- + +// Une fabrique de travailleurs qui calculent par brancherTravailleur, le +// vrai côté travailleur : la proposition de rang r rend sa réponse après +// (8 − r) tours de la file des microtâches, et les derniers rangs +// s'achèvent ainsi avant les premiers. +function travailleursCalculants() { + return () => { + const travailleur = { + onmessage: null, + termine: false, + postMessage(message) { + const copie = structuredClone(message); + let attente = 8 - copie.rang; + const tour = () => { + if (attente > 0) { + attente -= 1; + return Promise.resolve().then(tour); + } + if (travailleur.termine) return undefined; + const portee = { onmessage: null, postMessage: (data) => !travailleur.termine && travailleur.onmessage?.({ data: structuredClone(data) }) }; + brancherTravailleur(portee); + portee.onmessage({ data: copie }); + return undefined; + }; + Promise.resolve().then(tour); + }, + terminate() { + travailleur.termine = true; + }, + }; + return travailleur; + }; +} + +describe('génération : les cœurs de calcul, un réglage du poste pour la séance (§ 5.7, § 5.10)', () => { + test("automatiques à la création : autant que l'exécuteur en annonce ; 1 quand il n'en annonce pas", async () => { + const montage = await monter({ charges: [chargeDemo('petite')], executeur: creerExecuteurDirect({ coeursDisponibles: 6 }) }); + assert.deepEqual(montage.controleur.coeurs(), { automatique: true, manuel: 6, disponibles: 6, nombre: 6 }); + const sans = await monter({ charges: [chargeDemo('petite')], executeur: executeurManuel() }); + assert.deepEqual(sans.controleur.coeurs(), UN_COEUR); + }); + + test('reglerCoeurs : une saisie de 1 à disponibles, ou le refus de lireEntier ou HORS_DOMAINE {min: 1, max}, remède null, sans rien changer ; automatique rend les disponibles et garde le manuel', async () => { + const montage = await monter({ charges: [chargeDemo('petite')], executeur: creerExecuteurDirect({ coeursDisponibles: 6 }) }); + const { controleur } = montage; + const vus = []; + controleur.subscribe((etat) => vus.push(etat.coeurs)); + assert.deepEqual(controleur.reglerCoeurs({ automatique: false }), { refus: null }); + assert.deepEqual(controleur.coeurs(), { automatique: false, manuel: 6, disponibles: 6, nombre: 6 }); + assert.deepEqual(controleur.reglerCoeurs({ saisie: ' 3 ' }), { refus: null }); + assert.deepEqual(controleur.coeurs(), { automatique: false, manuel: 3, disponibles: 6, nombre: 3 }); + const refusees = [ + ['0', 'HORS_DOMAINE', { min: 1, max: 6, remede: null }], + ['7', 'HORS_DOMAINE', { min: 1, max: 6, remede: null }], + ['x', 'NON_ENTIER', { saisie: 'x', remede: null }], + ['1,5', 'NON_ENTIER', { saisie: '1,5', remede: null }], + ['-1', 'NON_ENTIER', { saisie: '-1', remede: null }], + ['', 'NON_ENTIER', { saisie: '', remede: null }], + ]; + assert.equal(refusees.length, 6); + for (const [saisie, code, details] of refusees) { + const { refus } = controleur.reglerCoeurs({ saisie, automatique: true }); + assert.deepEqual(refus, { code, details }, saisie); + assert.equal(typeof libelle(code, details), 'string'); + assert.deepEqual(controleur.coeurs(), { automatique: false, manuel: 3, disponibles: 6, nombre: 3 }, saisie); + } + assert.deepEqual(controleur.reglerCoeurs({ saisie: '6' }), { refus: null }); + assert.deepEqual(controleur.reglerCoeurs({ saisie: '1' }), { refus: null }); + assert.deepEqual(controleur.reglerCoeurs({ automatique: true }), { refus: null }); + assert.deepEqual(controleur.coeurs(), { automatique: true, manuel: 1, disponibles: 6, nombre: 6 }); + assert.deepEqual(vus, [ + { automatique: true, manuel: 6, disponibles: 6, nombre: 6 }, + { automatique: false, manuel: 6, disponibles: 6, nombre: 6 }, + { automatique: false, manuel: 3, disponibles: 6, nombre: 3 }, + { automatique: false, manuel: 6, disponibles: 6, nombre: 6 }, + { automatique: false, manuel: 1, disponibles: 6, nombre: 1 }, + { automatique: true, manuel: 1, disponibles: 6, nombre: 6 }, + ]); + }); + + test("régler les cœurs n'écrit rien : ni état, ni entrée du journal, ni révision, en écriture comme en lecture", async () => { + for (const ecriture of [true, false]) { + const montage = await monter({ charges: [chargeDemo('petite')], executeur: creerExecuteurDirect({ coeursDisponibles: 4 }), ecriture }); + const ecritures = montage.fs.compteurs.ecritures; + const journal = montage.journal(); + const etat = montage.etatDisque(); + const revision = montage.seance.etat().evenement.revision; + montage.controleur.reglerCoeurs({ automatique: false }); + assert.deepEqual(montage.controleur.reglerCoeurs({ saisie: '2' }), { refus: null }); + montage.controleur.reglerCoeurs({ saisie: '9' }); + montage.controleur.reglerCoeurs({ automatique: true }); + await montage.seance.attendre(); + assert.equal(montage.fs.compteurs.ecritures, ecritures, `écriture ${ecriture}`); + assert.deepEqual(montage.journal(), journal); + assert.equal(montage.etatDisque(), etat); + assert.equal(montage.seance.etat().evenement.revision, revision); + } + }); + + test('le réglage vaut pour la séance : il survit à la fermeture et à l’ouverture d’un autre événement', async () => { + const montage = await monter({ + charges: [chargeDemo('petite'), chargeDemo('petite-conflit', AUTRE)], + executeur: creerExecuteurDirect({ coeursDisponibles: 4 }), + }); + montage.controleur.reglerCoeurs({ automatique: false }); + montage.controleur.reglerCoeurs({ saisie: '2' }); + await montage.seance.fermer(); + await montage.seance.ouvrir(AUTRE); + assert.deepEqual(montage.controleur.coeurs(), { automatique: false, manuel: 2, disponibles: 4, nombre: 2 }); + }); + + test('generer confie à lancer coeurs = min(nombre de propositions, cœurs réglés)', async () => { + const montage = await monter({ charges: [chargeDemo('petite')], executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) }); + const { controleur, espion } = montage; + const cas = [ + { reglage: { automatique: true }, nombre: 3, attendus: 3 }, + { reglage: { automatique: true }, nombre: 6, attendus: 4 }, + { reglage: { automatique: false, saisie: '2' }, nombre: 5, attendus: 2 }, + { reglage: { automatique: false, saisie: '1' }, nombre: 2, attendus: 1 }, + ]; + assert.equal(cas.length, 4); + for (const [k, { reglage, nombre, attendus }] of cas.entries()) { + controleur.reglerCoeurs(reglage); + await controleur.generer({ graine: 10 + k, nombre, arret: 50, historique: 1000 }); + assert.deepEqual(espion.lancements.at(-1).options, { coeurs: attendus }, JSON.stringify(reglage)); + } + }); + + test("par le groupe de travailleurs, 1 cœur ou 3 : le même état écrit, octet pour octet, et les propositions de rechercher", async () => { + const etats = []; + for (const coeurs of [1, 3]) { + const executeur = creerExecuteurTravailleur(travailleursCalculants(), { coeursDisponibles: 3 }); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur }); + montage.controleur.reglerCoeurs({ automatique: false }); + montage.controleur.reglerCoeurs({ saisie: String(coeurs) }); + const reglages = { graine: 5, nombre: 5, arret: 2000, historique: 1000 }; + const bilan = await montage.controleur.generer(reglages); + assert.deepEqual(bilan.ajoutees, [1, 2, 3, 4, 5]); + assert.deepEqual(montage.espion.lancements.at(-1).options, { coeurs }); + const attendues = rechercher(configurationDepuisCharge(montage.charge()), reglages); + assert.deepEqual( + montage.charge().propositions.map(({ graine }) => graine), + attendues.map(({ graine }) => graine), + ); + assert.deepEqual(montage.charge().propositions.map(planDepuisFichier), attendues.map((p) => planRange(p, configurationDepuisCharge(montage.charge())))); + assert.equal(montage.controleur.derniereGeneration().coeurs, coeurs); + etats.push(montage.etatDisque()); + } + assert.equal(etats.length, 2); + assert.equal(etats[1], etats[0]); + }); + + test("la mesure porte les cœurs que l'exécution a employés ; 1 quand elle ne le dit pas", async () => { + const manuel = executeurManuel({ coeursDisponibles: 4 }); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 2000); + assert.equal(montage.controleur.derniereGeneration().coeurs, 4); + const muet = executeurTransforme((propositions) => propositions); + const autre = await monter({ charges: [chargeDemo('petite-conflit')], executeur: muet }); + await autre.controleur.generer(COURT); + assert.equal(autre.controleur.derniereGeneration().coeurs, 1); + }); + + test("l'estimation compte en vagues : coût d'une proposition × ⌈nombre / cœurs⌉, la mesure ramenée à ses propres vagues", async () => { + const manuel = executeurManuel({ coeursDisponibles: 8 }); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const { controleur } = montage; + controleur.reglerCoeurs({ automatique: false }); + controleur.reglerCoeurs({ saisie: '1' }); + // 5 × 200 000 mouvements en 2 s sur 1 cœur : 0,4 s par proposition. + await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 2000); + const sur1 = controleur.derniereGeneration(); + assert.equal(sur1.coeurs, 1); + controleur.reglerCoeurs({ saisie: '2' }); + const deux = controleur.estimer({ graine: 1, nombre: 5, arret: 200000, historique: 1000 }); + assert.equal(deux.coeurs, 2); + assert.ok(Math.abs(deux.secondes - 1.2) < 1e-9, String(deux.secondes)); + assert.deepEqual(deux.mesure, sur1); + assert.ok(Math.abs(controleur.estimer({ graine: 1, nombre: 4, arret: 200000 }).secondes - 0.8) < 1e-9); + controleur.reglerCoeurs({ automatique: true }); + const automatique = controleur.estimer({ graine: 1, nombre: 5, arret: 200000 }); + assert.equal(automatique.coeurs, 5); + assert.ok(Math.abs(automatique.secondes - 0.4) < 1e-9, String(automatique.secondes)); + // Longue au-delà de 60 s : 3 000 propositions, 1 200 s sur 1 cœur, 300 s + // sur 4, et 0,4 × ⌈3 000 / 8⌉ = 150 s sur 8. + controleur.reglerCoeurs({ automatique: false }); + controleur.reglerCoeurs({ saisie: '4' }); + const quatre = controleur.estimer({ graine: 1, nombre: 3000, arret: 200000 }); + assert.ok(Math.abs(quatre.secondes - 300) < 1e-9, String(quatre.secondes)); + assert.equal(quatre.longue, true); + controleur.reglerCoeurs({ automatique: true }); + assert.ok(Math.abs(controleur.estimer({ graine: 1, nombre: 3000, arret: 200000 }).secondes - 150) < 1e-9); + // Une mesure prise sur 5 cœurs, 5 propositions : une seule vague de + // 200 000 mouvements en 2 s ; sur 1 cœur, 5 vagues, 10 s. + await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 2000); + assert.equal(controleur.derniereGeneration().coeurs, 5); + controleur.reglerCoeurs({ automatique: false }); + controleur.reglerCoeurs({ saisie: '1' }); + assert.ok(Math.abs(controleur.estimer({ graine: 1, nombre: 5, arret: 200000 }).secondes - 10) < 1e-9); + }); + + test('sans mesure, la question part au-delà de 10 000 000 mouvements par cœur : ⌈nombre / cœurs⌉ × arret', async () => { + const manuel = executeurManuel({ coeursDisponibles: 2 }); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const { controleur, espion } = montage; + await refuseSansRien(montage, 'CONFIRMATION_REQUISE', { estimation: { inconnue: true }, remede: null }, { + graine: 1, + nombre: 10, + arret: 2000001, + historique: 1000, + }); + const juste = controleur.generer({ graine: 1, nombre: 10, arret: 2000000, historique: 1000 }); + await Promise.resolve(); + assert.equal(espion.lancements.length, 1); + controleur.annuler(); + await rejet(juste); + }); + + test('le texte de CONFIRMATION_REQUISE dit les cœurs de l’estimation et ceux de la mesure, seulement au-delà d’un', () => { + const mesure = { dureeMs: 2000, mouvements: 1000000, participants: 12, nombre: 5, arret: 200000, coeurs: 2 }; + const plusieurs = libelle('CONFIRMATION_REQUISE', { + estimation: { secondes: 1200, longue: true, auDelaDuPlafond: false, mesure, coeurs: 4 }, + remede: null, + }); + assert.match(plusieurs, /environ vingt minutes, sur 4 cœurs, d'après/); + assert.match(plusieurs, /12 participants, sur 2 cœurs/); + const un = libelle('CONFIRMATION_REQUISE', { + estimation: { secondes: 1200, longue: true, auDelaDuPlafond: false, mesure: { ...mesure, coeurs: 1 }, coeurs: 1 }, + remede: null, + }); + assert.doesNotMatch(un, /cœur/); + }); + + test("l'exécuteur direct annonce ses cœurs par coeursDe, calcule dans le fil quel que soit coeurs, et n'emploie qu'un cœur", async () => { + assert.equal(creerExecuteurDirect().coeursDisponibles, 1); + assert.equal(creerExecuteurDirect({ coeursDisponibles: 12 }).coeursDisponibles, 12); + assert.equal(creerExecuteurDirect({ coeursDisponibles: 0 }).coeursDisponibles, 1); + const configuration = configurationDepuisCharge(chargeDemo('petite-conflit')); + const reglages = { graine: 4, nombre: 3, arret: 2000 }; + const execution = creerExecuteurDirect({ coeursDisponibles: 4 }).lancer(configuration, reglages, { coeurs: 3 }); + assert.equal(execution.coeurs, 1); + assert.deepEqual(await execution.resultat, rechercher(configuration, reglages)); + }); + + test('coeursDe : un entier d’au moins 1 tel quel, toute autre valeur 1', () => { + const cas = [ + [4, 4], + [1, 1], + [0, 1], + [-2, 1], + [1.5, 1], + ['8', 1], + [undefined, 1], + [null, 1], + [Number.NaN, 1], + ]; + assert.equal(cas.length, 9); + for (const [valeur, attendue] of cas) assert.equal(coeursDe(valeur), attendue, String(valeur)); + }); +}); + // --- Une table, un tour (§ 15.4) --------------------------------------------------------- describe('génération : une table, un tour (§ 15.4)', () => { diff --git a/src/application/generation_travailleur.js b/src/application/generation_travailleur.js index b7d5c6e..d2421c2 100644 --- a/src/application/generation_travailleur.js +++ b/src/application/generation_travailleur.js @@ -1,15 +1,20 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Le côté travailleur de la génération (§ 5.10) : la recherche du moteur -// hors du fil de l'interface. Le travailleur reçoit un message, calcule, et -// poste ses avancements puis son résultat ou son erreur : +// Le côté travailleur de la génération (§ 5.7, § 5.10) : la recherche du +// moteur hors du fil de l'interface, une proposition par message. Un +// travailleur reçoit le rang d'une proposition, la calcule par proposerUne, +// et poste ses avancements puis la proposition ou son erreur ; il attend +// ensuite le rang suivant, que l'exécuteur lui donne ou non : // -// → { type: 'lancer', configuration, reglages } +// → { type: 'proposer', configuration, reglages, rang } // ← { type: 'avancement', fait, total } au plus une fois toutes les // CADENCE_AVANCEMENT progressions -// du moteur, fait croissant -// ← { type: 'resultat', propositions } celles de rechercher, telles quelles +// du moteur, fait croissant ; fait +// et total comptent dans la seule +// proposition, total valant arret +// ← { type: 'proposition', proposition } celle de rang rang de rechercher, +// telle quelle // ← { type: 'erreur', code, details } REGLAGE_HORS_DOMAINE {reglage} // d'exigerReglages, avant tout // calcul ; puis, par @@ -17,17 +22,20 @@ // les détails d'une // ErreurConfiguration // ← { type: 'faute', message, pile } une erreur que versErreurGeneration -// ne convertit pas : une faute de -// programmation, son texte et sa +// ne convertit pas — un rang hors +// de 1 à nombre compris : une faute +// de programmation, son texte et sa // pile, que l'exécuteur relève // comme une faute // -// L'annulation est terminate() du côté de l'interface : immédiate, sans -// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un -// autre type ne lance rien. Le gestionnaire ne lève jamais : le canal -// d'erreur du travailleur ne porte ainsi que l'échec de son script — qui ne -// se charge ou ne se compile pas —, et l'exécuteur ne confond pas les deux. -import { rechercher } from '../moteur/recherche.js'; +// Le travailleur ne garde rien d'un message à l'autre : chaque proposition +// se calcule à partir du seul message qui la demande. L'annulation est +// terminate() du côté de l'interface : immédiate, sans résultat partiel ; le +// travailleur ne lit aucun signal. Un message d'un autre type ne lance rien. +// Le gestionnaire ne lève jamais : le canal d'erreur du travailleur ne porte +// ainsi que l'échec de son script — qui ne se charge ou ne se compile pas —, +// et l'exécuteur ne confond pas les deux. +import { proposerUne } from '../moteur/recherche.js'; import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js'; /** Progressions du moteur — une toutes les 1 000 itérations — entre deux messages d'avancement. */ @@ -35,13 +43,13 @@ export const CADENCE_AVANCEMENT = 50; /** * Branche une portée de travailleur — { onmessage, postMessage } — sur - * rechercher, selon les messages de l'en-tête du module. + * proposerUne, selon les messages de l'en-tête du module. * * @param {{onmessage: ((evenement: {data: unknown}) => void)|null, postMessage: (message: Object) => void}} portee */ export function brancherTravailleur(portee) { portee.onmessage = ({ data }) => { - if (data === null || typeof data !== 'object' || data.type !== 'lancer') return; + if (data === null || typeof data !== 'object' || data.type !== 'proposer') return; let progressions = 0; const progression = (fait, total) => { progressions += 1; @@ -49,8 +57,8 @@ export function brancherTravailleur(portee) { }; try { exigerReglages(data.reglages); - const propositions = rechercher(data.configuration, data.reglages, { progression }); - portee.postMessage({ type: 'resultat', propositions }); + const proposition = proposerUne(data.configuration, data.reglages, data.rang, { progression }); + portee.postMessage({ type: 'proposition', proposition }); } catch (erreur) { const convertie = versErreurGeneration(erreur); if (convertie instanceof ErreurGeneration) { diff --git a/src/application/generation_travailleur.test.js b/src/application/generation_travailleur.test.js index a7e8cfe..843b009 100644 --- a/src/application/generation_travailleur.test.js +++ b/src/application/generation_travailleur.test.js @@ -1,9 +1,11 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Épreuves du travailleur de génération (§ 5.10) : brancherTravailleur sur -// une portée factice, qui garde chaque message posté. Les configurations -// viennent des démonstrations livrées et d'une charge aux noms inventés. +// Épreuves du travailleur de génération (§ 5.7, § 5.10) : brancherTravailleur +// sur une portée factice, qui garde chaque message posté. Le travailleur +// calcule une proposition par message, celle du rang demandé ; la valeur +// attendue est la proposition de même rang de rechercher. Les +// configurations viennent des démonstrations livrées. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { texteLivre } from '../demo/livrees.js'; @@ -25,41 +27,58 @@ function porteeFactice() { return { postes, envoyer: (data) => portee.onmessage({ data }) }; } -describe('travailleur de génération (§ 5.10)', () => { - test("après lancer, des avancements à fait croissant, au plus un tous les CADENCE_AVANCEMENT, puis le résultat de rechercher", () => { +describe('travailleur de génération (§ 5.7, § 5.10)', () => { + test("après proposer, des avancements de la seule proposition, au plus un tous les CADENCE_AVANCEMENT, puis la proposition de ce rang de rechercher", () => { assert.equal(CADENCE_AVANCEMENT, 50); const configuration = configurationDe('petite-conflit'); - const reglages = { graine: 9, nombre: 2, arret: 60000, historique: 1000 }; + const reglages = { graine: 9, nombre: 3, arret: 120000, historique: 1000 }; const { postes, envoyer } = porteeFactice(); - envoyer({ type: 'lancer', configuration, reglages }); + envoyer({ type: 'proposer', configuration, reglages, rang: 2 }); const avancements = postes.filter(({ type }) => type === 'avancement'); assert.ok(avancements.length > 0, 'aucun avancement'); - assert.ok(avancements.length <= Math.ceil((reglages.nombre * reglages.arret) / 1000 / CADENCE_AVANCEMENT)); + assert.ok(avancements.length <= Math.ceil(reglages.arret / 1000 / CADENCE_AVANCEMENT)); assert.deepEqual(avancements, [ { type: 'avancement', fait: 50000, total: 120000 }, { type: 'avancement', fait: 100000, total: 120000 }, ]); - assert.deepEqual(postes.at(-1), { type: 'resultat', propositions: rechercher(configuration, reglages) }); + const attendue = rechercher(configuration, reglages)[1]; + assert.equal(attendue.id, 2); + assert.deepEqual(postes.at(-1), { type: 'proposition', proposition: attendue }); assert.equal(postes.length, avancements.length + 1); }); + test('le même travailleur répond à chaque proposer, dans un ordre quelconque des rangs', () => { + const configuration = configurationDe('petite'); + const reglages = { graine: 31, nombre: 4, arret: 2000, historique: 1000 }; + const attendues = rechercher(configuration, reglages); + const { postes, envoyer } = porteeFactice(); + const rangs = [3, 1, 4]; + for (const rang of rangs) envoyer({ type: 'proposer', configuration, reglages, rang }); + const rendues = postes.filter(({ type }) => type === 'proposition').map(({ proposition }) => proposition); + assert.equal(rendues.length, rangs.length); + assert.deepEqual( + rendues, + rangs.map((rang) => attendues[rang - 1]), + ); + }); + test('des places manquantes postent erreur PLACES_MANQUANTES, les détails du moteur', () => { const configuration = configurationDe('petite'); configuration.tables = configuration.tables.slice(1); const { postes, envoyer } = porteeFactice(); - envoyer({ type: 'lancer', configuration, reglages: { graine: 1, nombre: 1, arret: 10 } }); + envoyer({ type: 'proposer', configuration, reglages: { graine: 1, nombre: 1, arret: 10 }, rang: 1 }); assert.deepEqual(postes, [{ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]); }); test('une graine de 2^32 poste erreur REGLAGE_HORS_DOMAINE, qui nomme le réglage, sans le message du moteur', () => { const { postes, envoyer } = porteeFactice(); - envoyer({ type: 'lancer', configuration: configurationDe('petite'), reglages: { graine: 2 ** 32, nombre: 1, arret: 10 } }); + envoyer({ type: 'proposer', configuration: configurationDe('petite'), reglages: { graine: 2 ** 32, nombre: 1, arret: 10 }, rang: 1 }); assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]); }); test('une faute que versErreurGeneration ne convertit pas poste { type: faute, message, pile }, sans lever : le canal d’erreur du travailleur reste au script qui ne se charge pas', () => { const { postes, envoyer } = porteeFactice(); - assert.doesNotThrow(() => envoyer({ type: 'lancer', configuration: null, reglages: { graine: 1, nombre: 1, arret: 10 } })); + assert.doesNotThrow(() => envoyer({ type: 'proposer', configuration: null, reglages: { graine: 1, nombre: 1, arret: 10 }, rang: 1 })); assert.equal(postes.length, 1); const [faute] = postes; assert.deepEqual(Object.keys(faute).sort(), ['message', 'pile', 'type']); @@ -69,9 +88,18 @@ describe('travailleur de génération (§ 5.10)', () => { assert.match(faute.pile, /TypeError/); }); + test('un rang hors de 1 à nombre est une faute de l’exécuteur, jamais un refus de réglage', () => { + const { postes, envoyer } = porteeFactice(); + envoyer({ type: 'proposer', configuration: configurationDe('petite'), reglages: { graine: 1, nombre: 2, arret: 10 }, rang: 3 }); + assert.equal(postes.length, 1); + assert.equal(postes[0].type, 'faute'); + assert.match(postes[0].message, /^RangeError: rang :/); + }); + test("un message d'un autre type ne lance rien", () => { const { postes, envoyer } = porteeFactice(); envoyer({ type: 'inconnu' }); + envoyer({ type: 'lancer', configuration: configurationDe('petite'), reglages: { graine: 1, nombre: 1, arret: 10 } }); envoyer(null); assert.deepEqual(postes, []); }); diff --git a/src/application/libelles/generation.js b/src/application/libelles/generation.js index 9c37f94..2477b04 100644 --- a/src/application/libelles/generation.js +++ b/src/application/libelles/generation.js @@ -17,8 +17,15 @@ const NOMS_DES_REGLAGES = new Map([ ['historique', "Historique d'acceptation"], ]); -// L'estimation d'une génération longue, en mots : l'ordre de grandeur et la -// mesure dont il vient, ou le seuil d'une génération sans mesure. +// Les cœurs d'un calcul, dits quand il en emploie plus d'un : « , sur +// 4 cœurs » ; rien pour un seul, ou quand le nombre n'est pas donné. +function surCoeurs(coeurs, texte) { + return typeof coeurs === 'number' && coeurs > 1 ? `, sur ${creerFormat(texte).nombre(coeurs)} cœurs` : ''; +} + +// L'estimation d'une génération longue, en mots : l'ordre de grandeur et les +// cœurs qu'elle emploierait, puis la mesure dont il vient ; ou le seuil d'une +// génération sans mesure. function phraseEstimation(estimation, texte) { if (estimation.inconnue === true) { return ( @@ -30,9 +37,10 @@ function phraseEstimation(estimation, texte) { const { mesure } = estimation; const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : ''; return ( - `Ce réglage prendrait ${format.ordreDeGrandeur(estimation.secondes)}${plafond}, d'après le dernier calcul, ` + + `Ce réglage prendrait ${format.ordreDeGrandeur(estimation.secondes)}${surCoeurs(estimation.coeurs, texte)}` + + `${plafond}, d'après le dernier calcul, ` + `${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} mouvements ` + - `sur ${compte(mesure.participants, 'participant', 'participants')}` + `sur ${compte(mesure.participants, 'participant', 'participants')}${surCoeurs(mesure.coeurs, texte)}` ); } diff --git a/src/interface/generation/Generation.navigateur.test.js b/src/interface/generation/Generation.navigateur.test.js index 936b57d..3eaf2b4 100644 --- a/src/interface/generation/Generation.navigateur.test.js +++ b/src/interface/generation/Generation.navigateur.test.js @@ -4,7 +4,9 @@ // 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 +// commande ; les cœurs de calcul, un réglage du poste qui n'écrit rien ; le +// groupe de travailleurs, qui rend ce que rend rechercher, sur un cœur comme +// sur deux ; 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. @@ -63,25 +65,30 @@ afterEach(() => { // --- Les exécuteurs --------------------------------------------------------------- -// L'exécuteur du travailleur, celui de src/main.js ; travailleurs compte les -// travailleurs créés. -function executeurTravailleur() { +// L'exécuteur du travailleur, celui de src/main.js, qui annonce +// coeursDisponibles ; travailleurs compte les travailleurs créés. +function executeurTravailleur({ coeursDisponibles } = {}) { const suivi = { travailleurs: 0 }; - const executeur = creerExecuteurTravailleur(() => { - suivi.travailleurs += 1; - return new Travailleur(); - }); + const executeur = creerExecuteurTravailleur( + () => { + suivi.travailleurs += 1; + return new Travailleur(); + }, + { coeursDisponibles }, + ); 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. +// Un exécuteur dont chaque lancer est relevé : configuration, reglages, +// options, et ce que rend son résultat. lancements en garde la liste. Il +// annonce les cœurs de base. function releve(base) { const lancements = []; const executeur = { - lancer(configuration, reglages) { - const execution = base.lancer(configuration, reglages); - const lancement = { configuration, reglages, propositions: null }; + coeursDisponibles: base.coeursDisponibles, + lancer(configuration, reglages, options) { + const execution = base.lancer(configuration, reglages, options); + const lancement = { configuration, reglages, options, propositions: null }; lancements.push(lancement); execution.resultat.then( (propositions) => { @@ -96,11 +103,14 @@ function releve(base) { } // Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé, -// avec resoudre(propositions), avancer(fait, total) et annulee. -function executeurAMain() { +// avec resoudre(propositions), avancer(fait, total) et annulee. Il annonce +// coeursDisponibles, et chaque exécution dit employer les cœurs que le +// lancer lui demande. +function executeurAMain({ coeursDisponibles } = {}) { const executions = []; const executeur = { - lancer(configuration, reglages) { + coeursDisponibles, + lancer(configuration, reglages, options) { const abonnes = new Set(); let resoudre; let rejeter; @@ -128,6 +138,7 @@ function executeurAMain() { execution.annulee = true; rejeter(new ErreurAnnulee()); }, + coeurs: options?.coeurs, }; }, }; @@ -147,6 +158,8 @@ 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 champCoeurs = (m) => section(m).querySelector('input[data-champ="coeurs"]'); +const disponiblesAffiches = (m) => section(m).querySelector('[data-coeurs-disponibles]'); const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)]; const versSection = (m, cible) => dans(m, `[data-commande="section"][data-cible="${cible}"]`); @@ -385,7 +398,7 @@ describe('les contraintes et l’attribution des sièges (§ 5.3)', () => { 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); + expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']); BASCULES.forEach((cle, rang) => { expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`)); expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]); @@ -522,8 +535,8 @@ describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => { 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(); + test('exécuteur du travailleur, deux cœurs : grande démonstration, compte élevé — pendant le calcul, la navigation et le changement du tour affiché mettent le DOM à jour ; une seule barre, dont l’avancement croît ; aria-busy suit le calcul', async () => { + const { executeur, suivi } = executeurTravailleur({ coeursDisponibles: 2 }); 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 @@ -579,13 +592,14 @@ describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => { } expect(valeurs.length).toBeGreaterThanOrEqual(2); for (let i = 1; i < valeurs.length; i += 1) expect(valeurs[i]).toBeGreaterThan(valeurs[i - 1]); + expect(suivi.travailleurs).toBe(2); 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(); + test('exécuteur du travailleur, deux cœurs : 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({ coeursDisponibles: 2 }); 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) }; @@ -609,6 +623,149 @@ describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => { }, 60_000); }); +describe('les cœurs de calcul, un réglage du poste (§ 5.7, § 5.10)', () => { + test('quatre cœurs annoncés : « Cœurs automatiques » cochée, aucun champ de nombre ; la valeur automatique et la note affichées', async () => { + const m = await demonstration('petite', { executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) }); + const automatique = bascule(m, 'coeursAutomatiques'); + expect(automatique).not.toBeNull(); + expect(automatique.checked).toBe(true); + expect(etiquette(m, 'coeursAutomatiques').textContent).toBe(t('generation.coeurs.automatique')); + expect(champCoeurs(m)).toBeNull(); + expect(disponiblesAffiches(m).textContent).toBe(t('generation.coeurs.disponibles', { disponibles: 4 })); + expect(disponiblesAffiches(m).textContent).toContain('4 cœurs'); + expect(section(m).querySelector('[data-coeurs]').textContent).toContain(t('generation.coeurs.note')); + expect(controleur(m).coeurs()).toEqual({ automatique: true, manuel: 4, disponibles: 4, nombre: 4 }); + }); + + test('décocher montre « Nombre de cœurs », pré-rempli ; hors de 1 à 4 ou non entier, refus contre le champ ; 2 accepté ; recocher le cache et garde 2 ; rien n’est écrit, aucune entrée du journal', async () => { + const m = await demonstration('petite', { executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) }); + const ecritures = m.trace.length; + const entrees = journalDe(m).entrees.length; + const r0 = revision(m); + await cliquer(m, bascule(m, 'coeursAutomatiques')); + expect(bascule(m, 'coeursAutomatiques').checked).toBe(false); + expect(controleur(m).coeurs().automatique).toBe(false); + expect(champCoeurs(m)).not.toBeNull(); + expect(champCoeurs(m).value).toBe('4'); + expect(etiquette(m, 'coeurs').textContent).toBe(t('generation.coeurs.nombre', { disponibles: 4 })); + expect(disponiblesAffiches(m).textContent).toBe(t('generation.coeurs.disponibles', { disponibles: 4 })); + const refusees = [ + ['0', 'HORS_DOMAINE', { min: 1, max: 4, remede: null }], + ['5', 'HORS_DOMAINE', { min: 1, max: 4, remede: null }], + ['x', 'NON_ENTIER', { saisie: 'x', remede: null }], + ]; + expect(refusees.length).toBe(3); + for (const [saisie, code, details] of refusees) { + await saisir(m, champCoeurs(m), saisie); + const refus = refusPres(champCoeurs(m), code); + expect(refus, saisie).not.toBeNull(); + expect(refus.querySelector('p').textContent).toBe(t(code, details)); + expect(champCoeurs(m).getAttribute('aria-invalid')).toBe('true'); + expect(champCoeurs(m).value).toBe(saisie); + expect(controleur(m).coeurs().nombre, saisie).toBe(4); + } + await saisir(m, champCoeurs(m), '2', { fin: 'Enter' }); + expect(champCoeurs(m).parentElement.querySelector('[data-refus]')).toBeNull(); + expect(champCoeurs(m).hasAttribute('aria-invalid')).toBe(false); + expect(controleur(m).coeurs()).toEqual({ automatique: false, manuel: 2, disponibles: 4, nombre: 2 }); + await cliquer(m, bascule(m, 'coeursAutomatiques')); + expect(champCoeurs(m)).toBeNull(); + expect(controleur(m).coeurs()).toEqual({ automatique: true, manuel: 2, disponibles: 4, nombre: 4 }); + await cliquer(m, bascule(m, 'coeursAutomatiques')); + expect(champCoeurs(m).value).toBe('2'); + expect(m.trace.length).toBe(ecritures); + expect(journalDe(m).entrees.length).toBe(entrees); + expect(revision(m)).toBe(r0); + expect(fenetre()).toBeNull(); + }); + + test('en lecture, le réglage se change sans refus ni écriture ; il survit au départ de la section', async () => { + const m = await demonstration('petite', { executeur: creerExecuteurDirect({ coeursDisponibles: 3 }), ecriture: false }); + const ecritures = m.trace.length; + await cliquer(m, bascule(m, 'coeursAutomatiques')); + await saisir(m, champCoeurs(m), '1'); + expect(section(m).querySelector('[data-coeurs] [data-refus]')).toBeNull(); + expect(controleur(m).coeurs().nombre).toBe(1); + await cliquer(m, versSection(m, 'participants')); + expect(section(m)).toBeNull(); + await cliquer(m, versSection(m, 'generation')); + expect(bascule(m, 'coeursAutomatiques').checked).toBe(false); + expect(champCoeurs(m).value).toBe('1'); + expect(m.trace.length).toBe(ecritures); + }); + + test('« Générer » quand le nombre de cœurs est refusé : rien ne part, le refus reste contre le champ ; corrigé, la génération part sur lui', async () => { + const { executeur, lancements } = releve(creerExecuteurDirect({ coeursDisponibles: 2 })); + const m = await demonstration('petite', { executeur }); + await regler(m, { nombre: 2, arret: 2000 }); + await cliquer(m, bascule(m, 'coeursAutomatiques')); + // Saisi puis quitté : le refus est posé, et « Générer » ne bouge plus + // sous le pointeur quand on le presse. + await saisir(m, champCoeurs(m), '3'); + expect(refusPres(champCoeurs(m), 'HORS_DOMAINE')).not.toBeNull(); + await cliquer(m, generer(m)); + await images(2); + expect(refusPres(champCoeurs(m), 'HORS_DOMAINE')).not.toBeNull(); + expect(lancements).toEqual([]); + expect(charge(m).propositions).toEqual([]); + await saisir(m, champCoeurs(m), '1'); + await cliquer(m, generer(m)); + await attendre(() => expect(charge(m).propositions.length).toBe(2)); + expect(lancements.map(({ options }) => options)).toEqual([{ coeurs: 1 }]); + }); + + test('vrais travailleurs : deux cœurs, puis un seul — deux travailleurs, puis un ; les mêmes propositions, celles de rechercher', async () => { + const reglages = { graine: 1, nombre: 3, arret: 20000, historique: 1000 }; + const propositions = []; + for (const coeurs of [2, 1]) { + const { executeur: travailleur, suivi } = executeurTravailleur({ coeursDisponibles: 2 }); + const { executeur, lancements } = releve(travailleur); + const m = await demonstration('petite', { executeur }); + await regler(m, { nombre: reglages.nombre, arret: reglages.arret }); + if (coeurs === 1) { + await cliquer(m, bascule(m, 'coeursAutomatiques')); + await saisir(m, champCoeurs(m), '1'); + } + await cliquer(m, generer(m)); + await attendre(() => expect(lancements[0]?.propositions ?? null).not.toBeNull()); + expect(lancements[0].options).toEqual({ coeurs }); + expect(suivi.travailleurs).toBe(coeurs); + expect(lancements[0].reglages).toEqual(reglages); + expect(lancements[0].propositions).toEqual(rechercher(lancements[0].configuration, reglages)); + await attendre(() => expect(charge(m).propositions.length).toBe(3)); + propositions.push(charge(m).propositions); + } + expect(propositions.length).toBe(2); + expect(JSON.stringify(propositions[1])).toBe(JSON.stringify(propositions[0])); + }, 60_000); + + test('la durée et l’estimation disent les cœurs : mesure sur 4 cœurs, puis l’estimation sur 1 cœur, recalculée sans relancer', async () => { + const { executeur, executions } = executeurAMain({ coeursDisponibles: 4 }); + 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)); + const mesure = controleur(m).derniereGeneration(); + expect(mesure).toMatchObject({ dureeMs: 2000, nombre: 5, coeurs: 4 }); + expect(duree(m).textContent).toBe(t('generation.duree.observee', mesure)); + expect(duree(m).textContent).toContain('sur 4 cœurs'); + const estimation = () => section(m).querySelector('[data-estimation]'); + const reglages = charge(m).reglages.generation; + expect(estimation().textContent).toBe(t('generation.duree.estimee', controleur(m).estimer(reglages))); + expect(controleur(m).estimer(reglages).coeurs).toBe(4); + await cliquer(m, bascule(m, 'coeursAutomatiques')); + await saisir(m, champCoeurs(m), '1'); + const surUn = controleur(m).estimer(reglages); + expect(surUn.coeurs).toBe(1); + expect(surUn.secondes).toBeCloseTo(5, 9); + await attendre(() => expect(estimation().textContent).toBe(t('generation.duree.estimee', surUn))); + expect(estimation().textContent).not.toContain('cœur'); + expect(executions.length).toBe(1); + }); +}); + 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()); diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte index b018f41..bbaa6fc 100644 --- a/src/interface/generation/Reglages.svelte +++ b/src/interface/generation/Reglages.svelte @@ -26,11 +26,30 @@ // écrite ou inchangée. Un refus de la séance — lecture, plan // bloqué, support rompu — se pose à côté de la case, avec son remède, et // rien ne s'écrit. - import { getContext } from 'svelte'; + // + // Les cœurs de calcul sont un réglage du poste, que le contrôleur de + // génération tient pour la séance du logiciel : ni le fichier ni le journal + // ne les portent, et ils se règlent en lecture comme en écriture. La case + // « Cœurs automatiques », cochée par défaut, emploie un cœur par + // processeur logique ; décochée, elle montre « Nombre de cœurs », pré-rempli + // du dernier nombre accepté. La saisie se valide à la sortie du champ ou + // par Entrée, par controleur.reglerCoeurs ; son refus se pose contre le + // champ, saisie gardée, et le parent le lit (refusCoeurs, lié) : un refus + // posé, « Générer » ne part pas. Échap rend le dernier nombre accepté. La + // valeur automatique du poste s'affiche à côté, dans les deux états. + import { getContext, untrack } from 'svelte'; import Champ from '../primitives/Champ.svelte'; import Refus from '../primitives/Refus.svelte'; - let { charge, mesure, estimation, graine = $bindable(), refusGraine = null, saisirGraine } = $props(); + let { + charge, + mesure, + estimation, + graine = $bindable(), + refusGraine = null, + saisirGraine, + refusCoeurs = $bindable(null), + } = $props(); const t = getContext('t'); const seance = getContext('seance'); @@ -38,6 +57,37 @@ const uid = $props.id(); const idGraine = `${uid}-graine`; const idRefusGraine = `${uid}-graine-refus`; + const idCoeursAutomatiques = `${uid}-coeurs-automatiques`; + const idCoeurs = `${uid}-coeurs`; + const idRefusCoeurs = `${uid}-coeurs-refus`; + + // Le réglage des cœurs, tel que le contrôleur le publie, et le texte du + // champ « Nombre de cœurs ». + const coeurs = $derived($generation.coeurs); + let saisieCoeurs = $state(untrack(() => String(generation.coeurs().manuel))); + + // Rend au champ le dernier nombre accepté, sans refus. + function rendreCoeurs() { + saisieCoeurs = String(generation.coeurs().manuel); + refusCoeurs = null; + } + + function basculerCoeurs(evenement) { + generation.reglerCoeurs({ automatique: evenement.currentTarget.checked }); + rendreCoeurs(); + } + + function validerCoeurs() { + refusCoeurs = generation.reglerCoeurs({ saisie: saisieCoeurs }).refus; + } + + function toucheCoeurs(evenement) { + if (evenement.key === 'Escape') rendreCoeurs(); + else if (evenement.key === 'Enter') { + evenement.preventDefault(); + validerCoeurs(); + } + } // Les réglages de la charge, dans l'ordre de l'écran. const REGLAGES = ['nombre', 'arret', 'historique']; @@ -130,11 +180,46 @@ {/each} +
+ {t('generation.coeurs.titre')} +
+ + +
+ {#if !coeurs.automatique} +
+ + (refusCoeurs = null)} + onkeydown={toucheCoeurs} + onblur={validerCoeurs} + /> + {#if refusCoeurs !== null} + + {/if} +
+ {/if} +

{t('generation.coeurs.disponibles', { disponibles: coeurs.disponibles })}

+

{t('generation.coeurs.note')}

+
{#if mesure === null}

{t('generation.duree.inconnue')}

{:else}

{t('generation.duree.observee', mesure)}

-

{t('generation.duree.estimee', { secondes: estimation.secondes })}

+

{t('generation.duree.estimee', estimation)}

{/if} @@ -178,6 +263,17 @@ border-color: var(--couleur-refus); } + .coeurs { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--espace-2); + margin: 0; + padding: var(--espace-2) var(--espace-3); + border: 1px solid var(--couleur-page-filet); + border-radius: var(--rayon-1); + } + .bascules { display: flex; flex-wrap: wrap; diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte index d5e07fd..d65b062 100644 --- a/src/interface/generation/SectionGeneration.svelte +++ b/src/interface/generation/SectionGeneration.svelte @@ -19,6 +19,9 @@ // frappe dans la graine, au clic de « Générer », et dès que la charge // change : elle ne vaut que pour les réglages qu'elle nomme. // + // Le nombre de cœurs refusé — refusCoeurs, que Reglages pose contre son + // champ — retient « Générer » : rien ne part tant qu'il est posé. + // // La graine est pré-remplie de controleur.grainePropose() au montage, puis, // à chaque lancement, de la graine suivante. Pendant le calcul, la section // porte aria-busy="true" et montre l'avancement ; « Générer » reste offert, @@ -44,6 +47,7 @@ let refusGraine = $state.raw(null); let question = $state.raw(null); let issue = $state.raw(null); + let refusCoeurs = $state.raw(null); // Un lancement — enCours qui passe à vrai — met dans le champ la graine // suivante et retire la question, le refus de la graine et l'issue d'avant. @@ -69,15 +73,17 @@ }); }); - // La mesure du dernier calcul de la séance, relue à chaque publication du - // contrôleur, et l'estimation des réglages de la charge qu'elle donne. + // La mesure du dernier calcul de la séance, et l'estimation des réglages + // de la charge qu'elle donne sur les cœurs réglés, relues à chaque + // publication du contrôleur — un changement des cœurs compris. const mesure = $derived.by(() => { void $generation; return generation.derniereGeneration(); }); - const estimation = $derived( - mesure === null || charge === null ? null : generation.estimer(charge.reglages.generation), - ); + const estimation = $derived.by(() => { + void $generation; + return mesure === null || charge === null ? null : generation.estimer(charge.reglages.generation); + }); function saisirGraine() { refusGraine = null; @@ -90,6 +96,7 @@ // trouve : un réglage quitté par le clic même s'écrit d'abord, et la // génération part sur lui, quelle que soit la lenteur du disque. async function lancer(confirmee) { + if (refusCoeurs !== null) return; refusGraine = null; issue = null; if (!confirmee) question = null; @@ -120,7 +127,7 @@

{t('coquille.section.generation')}

{#if charge !== null} - +
lancer(false)} />
diff --git a/src/interface/libelles/generation.js b/src/interface/libelles/generation.js index ccdb873..2597d2c 100644 --- a/src/interface/libelles/generation.js +++ b/src/interface/libelles/generation.js @@ -1,8 +1,9 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les libellés de la génération : réglages, durée observée, question d'une -// génération longue, avancement et issue (§ 5.7, § 5.10, § 14.6). Chaque +// Les libellés de la génération : réglages, cœurs de calcul, durée observée, +// question d'une génération longue, avancement et issue (§ 5.7, § 5.10, +// § 14.6). Chaque // clé commence par « generation. ». Le libellé d'un réglage porte son unité, // entre parenthèses : l'étiquette du champ la dit, et le nom accessible du // champ avec elle. Un nombre et une durée se mettent en forme par @@ -11,12 +12,21 @@ import { creerFormat } from '../../application/format.js'; import { compte } from '../../application/libelles/aides.js'; +// Les cœurs d'un calcul, dits quand il en emploie plus d'un : « , sur +// 4 cœurs » ; rien pour un seul, ou quand le nombre n'est pas donné. +function surCoeurs(coeurs, texte) { + return typeof coeurs === 'number' && coeurs > 1 ? `, sur ${creerFormat(texte).nombre(coeurs)} cœurs` : ''; +} + // Ce que vaut un calcul mesuré : sa durée, puis ses réglages, nombre de // propositions et compte d'arrêt, dont le produit est le nombre de -// mouvements évalués. -function calculMesure({ dureeMs, nombre, arret }, texte) { +// mouvements évalués, puis les cœurs qu'il a employés. +function calculMesure({ dureeMs, nombre, arret, coeurs }, texte) { const format = creerFormat(texte); - return `${format.duree(dureeMs)} pour ${format.nombre(nombre)} \u{00D7} ${format.nombre(arret)} mouvements`; + return ( + `${format.duree(dureeMs)} pour ${format.nombre(nombre)} \u{00D7} ${format.nombre(arret)} mouvements` + + surCoeurs(coeurs, texte) + ); } export const TABLE = new Map([ @@ -34,21 +44,44 @@ export const TABLE = new Map([ ['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'], ['generation.contrainte.attribuerSieges', 'Attribuer les sièges'], + // --- Les cœurs de calcul, un réglage du poste pour la séance (§ 5.10) -------- + // disponibles : les processeurs logiques du poste, hyperthreads compris. + ['generation.coeurs.titre', 'Cœurs de calcul'], + ['generation.coeurs.automatique', 'Cœurs automatiques (un par processeur logique du poste)'], + [ + 'generation.coeurs.nombre', + ({ disponibles }, texte) => `Nombre de cœurs (de 1 à ${creerFormat(texte).nombre(disponibles)})`, + ], + [ + 'generation.coeurs.disponibles', + ({ disponibles }, texte) => + `Valeur automatique sur ce poste : ${creerFormat(texte).nombre(disponibles)} ` + + `${disponibles <= 1 ? 'cœur' : 'cœurs'}, un par processeur logique, hyperthreads compris.`, + ], + [ + 'generation.coeurs.note', + "Une génération n'emploie jamais plus de cœurs que de propositions demandées. Ce réglage vaut pour cette " + + "séance du logiciel sur ce poste : il ne s'enregistre pas dans l'événement.", + ], + // --- La durée du dernier calcul de cette séance (§ 5.10) -------------------- [ 'generation.duree.inconnue', "Aucun calcul n'est encore mesuré sur ce poste pendant cette séance : la durée d'une génération est inconnue.", ], ['generation.duree.observee', (details, texte) => `Dernier calcul sur ce poste : ${calculMesure(details, texte)}.`], - // secondes : l'estimation des réglages courants, d'après le dernier calcul. + // secondes : l'estimation des réglages courants, d'après le dernier calcul ; + // coeurs : ceux qu'ils emploieraient. [ 'generation.duree.estimee', - ({ secondes }, texte) => `À ces réglages, une génération prendrait ${creerFormat(texte).ordreDeGrandeur(secondes)}.`, + ({ secondes, coeurs }, texte) => + `À ces réglages, une génération prendrait ${creerFormat(texte).ordreDeGrandeur(secondes)}${surCoeurs(coeurs, texte)}.`, ], // --- La génération longue, annoncée avant de partir (§ 5.10) ---------------- // estimation : celle de CONFIRMATION_REQUISE, { inconnue: true } sans - // mesure, sinon { secondes, auDelaDuPlafond, mesure: { dureeMs, mouvements } }. + // mesure, sinon { secondes, auDelaDuPlafond, mesure: { dureeMs, mouvements, + // coeurs }, coeurs }. [ 'generation.longue.question', ({ estimation }, texte) => { @@ -62,9 +95,10 @@ export const TABLE = new Map([ const { mesure } = estimation; const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : ''; return ( - `Cette génération prendrait ${format.ordreDeGrandeur(estimation.secondes)} à ce réglage${plafond}, ` + + `Cette génération prendrait ${format.ordreDeGrandeur(estimation.secondes)} à ce réglage` + + `${surCoeurs(estimation.coeurs, texte)}${plafond}, ` + `d'après le dernier calcul : ${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} ` + - 'mouvements. La lancer quand même ?' + `mouvements${surCoeurs(mesure.coeurs, texte)}. La lancer quand même ?` ); }, ], diff --git a/src/main.js b/src/main.js index 90fa897..9b413ea 100644 --- a/src/main.js +++ b/src/main.js @@ -14,8 +14,13 @@ // // Il est le seul module qui importe le travailleur de génération, par // « ?worker&inline » : Vite l'inclut dans la page, que la coquille charge -// par file://, sans fichier de travailleur à côté. Chaque génération en -// prend un neuf, que l'exécuteur termine à son issue (§ 5.10). +// par file://, sans fichier de travailleur à côté. Chaque génération fait +// naître un groupe de travailleurs neufs, un par cœur employé, que +// l'exécuteur termine à son issue (§ 5.10). Il est aussi le seul à lire le +// nombre de processeurs logiques du poste, navigator.hardwareConcurrency, +// hyperthreads compris : l'exécuteur l'annonce au contrôleur, qui en fait +// la valeur des cœurs automatiques ; une épreuve donne le sien à +// l'exécuteur qu'elle monte. import { mount } from 'svelte'; import { creerExecuteurTravailleur } from './application/executeur_travailleur.js'; import { creerHorlogeSysteme } from './application/horloge.js'; @@ -49,7 +54,9 @@ mount(App, { props: { fs, horloge: creerHorlogeSysteme(), - executeur: creerExecuteurTravailleur(() => new Travailleur()), + executeur: creerExecuteurTravailleur(() => new Travailleur(), { + coeursDisponibles: navigator.hardwareConcurrency, + }), pressePapiers, identifiant: () => crypto.randomUUID(), langue: 'fr', diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index b7e94d1..8d146b7 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -15,7 +15,9 @@ // l'historique, trois réglages qu'elle porte : la proposition 2 d'une demande // de 3 est celle d'une demande de 5, quel que soit l'ordre dans lequel les // propositions s'achèvent, et regenerer en rend le plan à partir de ces trois -// champs seuls (§ 8.9). Les deux passent par la même descente, proposer. +// champs seuls (§ 8.9). proposerUne calcule la proposition d'un seul rang, ce +// qui répartit une génération entre plusieurs fils. Les trois passent par la +// même descente, proposer. // // Sièges. Chaque tour offre S = Σ c_t sièges, rangés table par table dans // l'ordre des tables. Les personnes que instance.fixe impose à un tour @@ -627,6 +629,52 @@ export function rechercher(configuration, reglages, { signal, progression } = {} return propositions; } +/** + * La proposition de rang rang d'une génération (§ 5.7) : celle que + * rechercher(configuration, reglages) rend à l'indice rang − 1, octet pour + * octet. Sa graine dérivée est le rang-ième tirage de creerPcg32(graine, + * FLUX.GRAINES), et sa descente celle de rechercher : le résultat ne dépend + * ni de l'ordre dans lequel les rangs se calculent, ni du fil qui les + * calcule. Plusieurs fils calculent ainsi les propositions d'une même + * génération, chacun les siennes, et les rangent par identifiant. + * + * Toutes les 1 000 itérations, progression(fait, total) reçoit le rang du + * mouvement en cours dans cette seule proposition, total valant arret. Le + * signal se lit avant la descente et à chacun de ces instants : vrai, il + * fait lever ErreurAnnulee, et rien n'est rendu (§ 5.10). + * + * Lève ce que rechercher lève : la RangeError de verifierReglages, + * ErreurConfiguration de normaliser, PLACES_MANQUANTES ; et une RangeError + * qui nomme rang, champ reglage compris, quand rang n'est pas un entier de + * 1 à reglages.nombre. + * + * @param {import('./types.js').Configuration} configuration + * @param {ReglagesGeneration} reglages + * @param {number} rang 1 à reglages.nombre + * @param {{signal?: {aborted: boolean}, progression?: function(number, number): void}} [options] + * @returns {Proposition} + */ +export function proposerUne(configuration, reglages, rang, { signal, progression } = {}) { + verifierReglages(reglages); + const { graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT } = reglages; + exigerReglage('rang', rang, 1, nombre); + const instance = normaliser(configuration); + const structure = preparer(instance); + const graines = creerPcg32(graine, FLUX.GRAINES); + let graineDerivee = 0; + for (let tirage = 1; tirage <= rang; tirage += 1) graineDerivee = graines.suivant(); + lireSignal(signal); + const plan = proposer(instance, structure, graineDerivee, { + arret, + historique, + dejaFaits: 0, + total: arret, + signal, + progression, + }); + return { id: rang, graine: graineDerivee, arret, historique, plan }; +} + /** * Le contrôle des réglages que rechercher applique avant tout calcul : graine * de 0 à 2^32 − 1, puis arret, historique et nombre ≥ 1, un historique absent diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js index 866ff9f..e284548 100644 --- a/src/moteur/recherche.test.js +++ b/src/moteur/recherche.test.js @@ -40,6 +40,7 @@ import { creerEtat, defaire, proposerEtAppliquer, + proposerUne, rechercher, regenerer, scoreComplet, @@ -471,6 +472,108 @@ describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () }); }); +describe('proposerUne : une proposition par son rang (§ 5.7, § 5.10)', () => { + test('pour chaque rang de 1 à nombre, la proposition de ce rang de rechercher, octet pour octet', () => { + const cles = ['petite', 'petite-conflit']; + assert.equal(cles.length, 2); + for (const cle of cles) { + const reglages = { graine: 4_242, arret: 3_000, nombre: 5, historique: 200 }; + const attendues = rechercher(demo(cle), reglages); + assert.equal(attendues.length, 5); + for (let rang = 1; rang <= reglages.nombre; rang += 1) { + const une = proposerUne(demo(cle), reglages, rang); + assert.equal(JSON.stringify(une), JSON.stringify(attendues[rang - 1]), `${cle}, rang ${rang}`); + assert.deepStrictEqual(une, attendues[rang - 1]); + } + } + }); + + test("le rang dans un ordre quelconque, l'historique absent : la même proposition, à l'historique par défaut", () => { + const reglages = { graine: 9, arret: 1_500, nombre: 4 }; + const attendues = rechercher(demo('petite'), reglages); + const desordre = [3, 1, 4, 2].map((rang) => proposerUne(demo('petite'), reglages, rang)); + assert.deepStrictEqual( + [...desordre].sort((a, b) => a.id - b.id), + attendues, + ); + assert.deepEqual(desordre.map(({ historique }) => historique), [1_000, 1_000, 1_000, 1_000]); + }); + + test("l'avancement se compte dans la seule proposition : fait de 1 000 en 1 000, sur arret", () => { + const etapes = []; + proposerUne( + demo('petite-conflit'), + { graine: 3, arret: 2_500, nombre: 3 }, + 2, + { progression: (fait, total) => etapes.push([fait, total]) }, + ); + assert.deepEqual(etapes, [ + [1_000, 2_500], + [2_000, 2_500], + ]); + }); + + test('un signal levé avant le départ, ou à sa deuxième lecture, fait lever ErreurAnnulee sans rien rendre', () => { + assert.throws( + () => proposerUne(demo('petite'), { graine: 3, arret: 5_000, nombre: 2 }, 1, { signal: { aborted: true } }), + ErreurAnnulee, + ); + let lectures = 0; + const signal = { + get aborted() { + lectures += 1; + return lectures >= 2; + }, + }; + const etapes = []; + assert.throws( + () => + proposerUne( + demo('petite-conflit'), + { graine: 3, arret: 5_000, nombre: 2 }, + 2, + { signal, progression: (fait, total) => etapes.push([fait, total]) }, + ), + ErreurAnnulee, + ); + assert.equal(lectures, 2); + assert.deepEqual(etapes, [[1_000, 5_000]]); + }); + + test('un rang hors de 1 à nombre, ou non entier, lève une RangeError qui nomme rang ; un réglage hors de son domaine, celle de verifierReglages', () => { + const reglages = { graine: 1, arret: 10, nombre: 3 }; + const rangs = [0, 4, 1.5, '1', -1]; + assert.equal(rangs.length, 5); + for (const rang of rangs) { + assert.throws( + () => proposerUne(demo('petite'), reglages, rang), + (erreur) => erreur instanceof RangeError && erreur.reglage === 'rang' && erreur.message.startsWith('rang :'), + String(rang), + ); + } + assert.throws( + () => proposerUne(demo('petite'), { ...reglages, graine: 2 ** 32 }, 1), + (erreur) => erreur instanceof RangeError && erreur.reglage === 'graine', + ); + assert.throws( + () => proposerUne(demo('petite'), { ...reglages, nombre: 0 }, 1), + (erreur) => erreur instanceof RangeError && erreur.reglage === 'nombre', + ); + }); + + test('ne modifie pas la configuration reçue ; une salle trop petite lève PLACES_MANQUANTES comme rechercher', () => { + const configuration = salleReservee(); + proposerUne(configuration, { graine: 1, arret: 500, nombre: 2 }, 2); + assert.deepStrictEqual(configuration, salleReservee()); + const petite = demo('petite'); + petite.tables = petite.tables.slice(1); + assert.throws( + () => proposerUne(petite, { graine: 1, arret: 10, nombre: 1 }, 1), + (erreur) => erreur.code === 'PLACES_MANQUANTES' && erreur.details.placesManquantes === 3, + ); + }); +}); + describe('regenerer : une proposition depuis ses seuls champs (§ 5.7, § 8.9)', () => { test("pour chaque proposition d'une génération, regenerer rend le même plan à partir de ses trois champs, sans lire ni son plan ni son rang", () => { // arret 5 000 n'est pas un multiple de l'historique 37 : une descente