diff --git a/src/application/executeur_travailleur.js b/src/application/executeur_travailleur.js
new file mode 100644
index 0000000..98bc9cb
--- /dev/null
+++ b/src/application/executeur_travailleur.js
@@ -0,0 +1,97 @@
+// © 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).
+//
+// → { 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
+//
+// Chaque lancer prend un travailleur neuf de la fabrique et le termine à
+// l'issue : résultat, erreur, faute du travailleur 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'exécuteur du travailleur.
+ *
+ * @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
+ * @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 du travailleur, son canal
+ * d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
+ * annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
+ * avancement. Après l'issue, annuler() ne fait rien.
+ */
+export function creerExecuteurTravailleur(fabrique) {
+ return {
+ lancer(configuration, reglages) {
+ const travailleur = fabrique();
+ const abonnes = new Set();
+ let termine = false;
+ let resoudre;
+ let rejeter;
+ const resultat = new Promise((ok, ko) => {
+ resoudre = ok;
+ rejeter = ko;
+ });
+
+ // 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;
+ travailleur.onmessage = null;
+ travailleur.onerror = null;
+ travailleur.terminate();
+ }
+
+ travailleur.onmessage = ({ data }) => {
+ if (termine) return;
+ if (data.type === 'avancement') {
+ for (const fn of [...abonnes]) fn(data.fait, data.total);
+ } else if (data.type === 'resultat') {
+ resoudre(data.propositions);
+ terminer();
+ } else if (data.type === 'erreur') {
+ rejeter(new ErreurGeneration(data.code, data.details));
+ terminer();
+ }
+ };
+ travailleur.onerror = (evenement) => {
+ if (termine) return;
+ rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
+ terminer();
+ };
+ travailleur.postMessage({ type: 'lancer', configuration, reglages });
+
+ return {
+ surAvancement(fn) {
+ abonnes.add(fn);
+ return () => {
+ abonnes.delete(fn);
+ };
+ },
+ resultat,
+ annuler() {
+ if (termine) return;
+ terminer();
+ rejeter(new ErreurAnnulee());
+ },
+ };
+ },
+ };
+}
diff --git a/src/application/executeur_travailleur.test.js b/src/application/executeur_travailleur.test.js
new file mode 100644
index 0000000..a2653cf
--- /dev/null
+++ b/src/application/executeur_travailleur.test.js
@@ -0,0 +1,174 @@
+// © 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.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../test/lanceur.js';
+import { creerExecuteurTravailleur } from './executeur_travailleur.js';
+import { ErreurAnnulee, ErreurGeneration } from './generation.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 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.
+function fabriqueFactice() {
+ const travailleurs = [];
+ const fabrique = () => {
+ const travailleur = {
+ recus: [],
+ terminaisons: 0,
+ onmessage: null,
+ postMessage(message) {
+ this.recus.push(structuredClone(message));
+ },
+ terminate() {
+ this.terminaisons += 1;
+ },
+ poster(data) {
+ this.onmessage?.({ data });
+ },
+ };
+ travailleurs.push(travailleur);
+ return travailleur;
+ };
+ return { fabrique, travailleurs };
+}
+
+// L'issue d'une promesse, sans attendre plus d'un tour de la file des
+// microtâches : { etat: 'attente' }, { etat: 'resolue', valeur } ou
+// { etat: 'rejetee', erreur }.
+async function issue(promesse) {
+ let lue = { etat: 'attente' };
+ promesse.then(
+ (valeur) => {
+ lue = { etat: 'resolue', valeur };
+ },
+ (erreur) => {
+ lue = { etat: 'rejetee', erreur };
+ },
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ return lue;
+}
+
+describe('exécuteur du travailleur (§ 5.10)', () => {
+ test('un travailleur neuf par lancer, dont le premier message est { type: lancer, configuration, reglages }', () => {
+ 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 } },
+ ]);
+ assert.deepEqual(
+ travailleurs.map(({ terminaisons }) => terminaisons),
+ [0, 0],
+ );
+ });
+
+ test('deux avancements parviennent à surAvancement dans leur ordre ; un abonné retiré ne reçoit plus rien', () => {
+ const { fabrique, travailleurs } = fabriqueFactice();
+ const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
+ execution.resultat.catch(() => undefined);
+ const recus = [];
+ const retires = [];
+ execution.surAvancement((fait, total) => recus.push([fait, total]));
+ const desabonner = execution.surAvancement((fait, total) => retires.push([fait, total]));
+ const [travailleur] = travailleurs;
+ travailleur.poster({ type: 'avancement', fait: 50_000, total: 80_000 });
+ desabonner();
+ travailleur.poster({ type: 'avancement', fait: 80_000, total: 80_000 });
+ assert.deepEqual(recus, [
+ [50_000, 80_000],
+ [80_000, 80_000],
+ ]);
+ assert.deepEqual(retires, [[50_000, 80_000]]);
+ });
+
+ test('resultat : la promesse se résout aux propositions reçues, puis le travailleur est terminé ; un message tardif ne change rien', async () => {
+ const { fabrique, travailleurs } = fabriqueFactice();
+ const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
+ 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 } });
+ 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);
+ });
+
+ test("annuler : terminate() aussitôt, rejet par ErreurAnnulee ; un avancement ou un résultat posté ensuite n'atteint plus rien", 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 : rejet par une erreur sans code, le travailleur terminé', async () => {
+ const { fabrique, travailleurs } = fabriqueFactice();
+ const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
+ const [travailleur] = travailleurs;
+ assert.equal(typeof travailleur.onerror, 'function');
+ travailleur.onerror({ message: 'faute d’épreuve' });
+ const lue = await issue(execution.resultat);
+ assert.equal(lue.etat, 'rejetee');
+ assert.ok(lue.erreur instanceof Error);
+ assert.equal(lue.erreur.code, undefined);
+ assert.match(lue.erreur.message, /faute d’épreuve/);
+ assert.equal(travailleur.terminaisons, 1);
+ });
+});
diff --git a/src/interface/generation/Avancement.svelte b/src/interface/generation/Avancement.svelte
new file mode 100644
index 0000000..1d107cc
--- /dev/null
+++ b/src/interface/generation/Avancement.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
{t('generation.avancement.texte', { fait, total })}
+
+
+
+
+
diff --git a/src/interface/generation/Generation.navigateur.test.js b/src/interface/generation/Generation.navigateur.test.js
new file mode 100644
index 0000000..232ce78
--- /dev/null
+++ b/src/interface/generation/Generation.navigateur.test.js
@@ -0,0 +1,636 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// La section de la génération dans un vrai moteur de rendu (§ 5.7, § 5.9,
+// § 5.10, § 2.1 étape 9 et « ce qui doit avoir eu lieu », § 15.4, § 8.4,
+// § 9) : ses réglages, leur libellé et leur unité ; la graine, saisie de la
+// commande ; le travailleur, qui rend ce que rend rechercher ; l'application
+// vivante pendant le calcul ; l'annulation ; les refus posés à côté de
+// « Générer » avec leur remède ; la durée observée ; la génération longue,
+// annoncée en place.
+//
+// Chaque épreuve monte App par monterApp, sur le système de fichiers
+// simulé, et nomme son exécuteur : le travailleur — inclus en ligne comme
+// src/main.js l'inclut — pour un calcul hors du fil ; un exécuteur que
+// l'épreuve résout à la main quand l'horloge pilotée doit avancer pendant
+// le calcul ; le direct ailleurs, compté. L'épreuve lit le rendu par les
+// attributs data-… du contrat, et ce que la séance a écrit : la révision, le
+// journal, l'état sur le disque, le relevé des écritures. Le texte attendu
+// vient de la même traduction que celle de l'application ; les noms
+// d'épreuve sont inventés.
+//
+// Le projet navigateur emploie expect, et test et describe de Vitest
+// (§ 14.8).
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+import { flushSync } from 'svelte';
+import { creerExecuteurTravailleur } from '../../application/executeur_travailleur.js';
+import { ErreurAnnulee, creerExecuteurDirect } from '../../application/generation.js';
+import { TABLES_APPLICATION } from '../../application/libelles.js';
+import { creerTraduction } from '../../application/traduction.js';
+import { rechercher } from '../../moteur/recherche.js';
+import { configurationDepuisCharge } from '../../stockage/document.js';
+import { lireJournal } from '../../stockage/journal.js';
+import { SUFFIXES } from '../../stockage/noms.js';
+import { VERSION } from '../../version.genere.js';
+import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
+import { monterApp } from '../../../test/navigateur/montage.js';
+import { TABLES_INTERFACE } from '../libelles.js';
+import Travailleur from './travailleur.js?worker&inline';
+
+const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
+
+// Les champs de la section, dans l'ordre de l'écran.
+const CHAMPS = ['graine', 'generation.nombre', 'generation.arret', 'generation.historique'];
+
+// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
+// n'a rien reçu pendant l'épreuve.
+let montes = [];
+async function monter(options) {
+ const m = await monterApp(options);
+ montes.push(m);
+ return m;
+}
+beforeEach(async () => {
+ await fixerCadre(1366, 700);
+});
+afterEach(() => {
+ const erreurs = montes.flatMap((m) => m.erreurs());
+ for (const m of montes.reverse()) m.demonter();
+ montes = [];
+ window.scrollTo(0, 0);
+ expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
+});
+
+// --- Les exécuteurs ---------------------------------------------------------------
+
+// L'exécuteur du travailleur, celui de src/main.js ; travailleurs compte les
+// travailleurs créés.
+function executeurTravailleur() {
+ const suivi = { travailleurs: 0 };
+ const executeur = creerExecuteurTravailleur(() => {
+ suivi.travailleurs += 1;
+ return new Travailleur();
+ });
+ return { executeur, suivi };
+}
+
+// Un exécuteur dont chaque lancer est relevé : configuration, reglages, et
+// ce que rend son résultat. lancements en garde la liste.
+function releve(base) {
+ const lancements = [];
+ const executeur = {
+ lancer(configuration, reglages) {
+ const execution = base.lancer(configuration, reglages);
+ const lancement = { configuration, reglages, propositions: null };
+ lancements.push(lancement);
+ execution.resultat.then(
+ (propositions) => {
+ lancement.propositions = propositions;
+ },
+ () => undefined,
+ );
+ return execution;
+ },
+ };
+ return { executeur, lancements };
+}
+
+// Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé,
+// avec resoudre(propositions), avancer(fait, total) et annulee.
+function executeurAMain() {
+ const executions = [];
+ const executeur = {
+ lancer(configuration, reglages) {
+ const abonnes = new Set();
+ let resoudre;
+ let rejeter;
+ const resultat = new Promise((ok, ko) => {
+ resoudre = ok;
+ rejeter = ko;
+ });
+ const execution = {
+ configuration,
+ reglages,
+ resoudre,
+ annulee: false,
+ avancer: (fait, total) => {
+ for (const fn of [...abonnes]) fn(fait, total);
+ },
+ };
+ executions.push(execution);
+ return {
+ surAvancement(fn) {
+ abonnes.add(fn);
+ return () => abonnes.delete(fn);
+ },
+ resultat,
+ annuler() {
+ execution.annulee = true;
+ rejeter(new ErreurAnnulee());
+ },
+ };
+ },
+ };
+ return { executeur, executions };
+}
+
+// --- Les lectures du rendu ----------------------------------------------------
+
+const dans = (m, selecteur) => m.racine.querySelector(selecteur);
+const section = (m) => dans(m, '[data-section="generation"]');
+const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
+const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
+const generer = (m) => section(m).querySelector('[data-commande="generer"]');
+const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
+const fenetre = () => document.querySelector('dialog[open]');
+const duree = (m) => section(m).querySelector('[data-duree]');
+const question = (m) => section(m).querySelector('[data-question="longue"]');
+const barre = (m) => section(m)?.querySelector('[data-avancement] [role="progressbar"]') ?? null;
+
+const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
+const versSection = (m, cible) => dans(m, `[data-commande="section"][data-cible="${cible}"]`);
+
+// La signature de ce que le plan montre : la tête de chaque ligne des
+// listes — son rang et ce qui l'occupe, sans le nom, que la largeur mesurée
+// abrège —, puis les marques de chaque chaise.
+const montre = (m) => ({
+ listes: tous(m, '[data-liste]').map((liste) =>
+ [...liste.querySelectorAll('[data-partie="tete"]')].map((tete) => tete.textContent),
+ ),
+ marques: tous(m, '[data-siege]').map((chaise) => [
+ chaise.getAttribute('data-siege'),
+ [...chaise.querySelectorAll('[data-marque]')].map((marque) => marque.getAttribute('data-marque')),
+ ]),
+});
+
+// Choisit le tour r dans le sélecteur de la barre de vue du plan.
+function choisirTour(m, r) {
+ const select = dans(m, '[data-section="plan"] [data-champ="tour"]');
+ expect(select, 'sélecteur de tour').not.toBeNull();
+ select.value = String(r);
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ flushSync();
+}
+
+// Cadre le plan sur ses tables, puis le rapproche, un zoom après l'autre,
+// jusqu'à ce que les listes de noms et les chaises paraissent : elles ne se
+// rendent qu'au-delà d'un zoom. Partie du même cadrage, la vue atteinte est
+// la même à chaque appel.
+async function cadrerPourLesListes(m) {
+ const commande = (nom) => dans(m, `[data-section="plan"] [data-commande="${nom}"]`);
+ commande('ajuster').click();
+ flushSync();
+ await images(2);
+ for (let cran = 0; tous(m, '[data-liste]').length === 0 || tous(m, '[data-siege]').length === 0; cran += 1) {
+ expect(cran, 'zooms avant les listes et les chaises').toBeLessThan(30);
+ commande('zoomer').click();
+ flushSync();
+ await images(2);
+ }
+}
+
+const charge = (m) => m.seance.etat().charge;
+const revision = (m) => m.seance.etat().evenement.revision;
+const controleur = (m) => m.app.generation();
+const baseDe = (m) => m.seance.etat().evenement.base;
+const etatSurDisque = (m) => m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.etat}`);
+const journalDe = (m) => lireJournal(m.fs.contenu('documents', `${baseDe(m)}${SUFFIXES.journal}`));
+
+// Attend que l'assertion passe, le rendu de Svelte à jour, image après image ;
+// images borne l'attente.
+async function attendre(assertion, images = 1200) {
+ for (let essai = 0; ; essai += 1) {
+ flushSync();
+ try {
+ return assertion();
+ } catch (erreur) {
+ if (essai >= images) throw erreur;
+ await new Promise((resoudre) => requestAnimationFrame(resoudre));
+ }
+ }
+}
+
+// Laisse passer n images.
+async function images(n) {
+ for (let i = 0; i < n; i += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre));
+}
+
+// Attend que la file de la séance ait passé ce qui s'y trouve, par une
+// lecture admise dans tout mode qui n'écrit rien, puis le rendu.
+async function passer(m) {
+ await m.seance.formeNommee(null).catch(() => undefined);
+ await images(1);
+ flushSync();
+}
+
+// Un clic réel au centre de l'élément, puis la file de la séance passée.
+async function cliquer(m, element) {
+ expect(element, 'élément à cliquer').not.toBeNull();
+ element.scrollIntoView({ block: 'center' });
+ const { left, top, width, height } = element.getBoundingClientRect();
+ await pointer('presser', left + width / 2, top + height / 2);
+ await pointer('relacher', left + width / 2, top + height / 2);
+ await passer(m);
+}
+
+// Saisit texte dans le champ, la valeur courante remplacée, puis le quitte
+// par Tab, ou le valide par Entrée.
+async function saisir(m, entree, texte, { fin = 'Tab' } = {}) {
+ await cliquer(m, entree);
+ entree.select();
+ for (const caractere of texte) await touche(caractere);
+ if (fin !== null) await touche(fin);
+ await passer(m);
+}
+
+// Vrai tant que le contrôleur publie une génération en cours.
+function enCours(m) {
+ let lu;
+ controleur(m).subscribe((etat) => {
+ lu = etat;
+ })();
+ return lu.enCours;
+}
+
+// --- Les événements d'épreuve -----------------------------------------------------
+
+// Un montage sur l'exécuteur donné, où la démonstration cle est chargée,
+// en écriture sauf ecriture faux, la section de la génération ouverte.
+async function demonstration(cle, { executeur, ecriture = true } = {}) {
+ const m = await monter({ executeur });
+ await m.facade.chargerDemonstration(cle);
+ if (ecriture) await m.seance.passerEnEcriture();
+ m.ui.allerA('generation');
+ flushSync();
+ return m;
+}
+
+// Les réglages de génération de la charge changés par la séance.
+async function regler(m, reglages) {
+ for (const [cle, valeur] of Object.entries(reglages)) {
+ await m.seance.executer('modifierReglage', { cle: `generation.${cle}`, valeur });
+ }
+ flushSync();
+}
+
+// --- Les épreuves -----------------------------------------------------------------
+
+describe('les réglages (§ 5.7, § 5.10)', () => {
+ test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
+ const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
+ const entrees = [...section(m).querySelectorAll('input')];
+ expect(entrees.length).toBe(4);
+ expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
+ for (const cle of CHAMPS) {
+ const cleLibelle = `generation.reglage.${cle.replace('generation.', '')}`;
+ expect(etiquette(m, cle).textContent).toBe(t(cleLibelle));
+ }
+ expect(t('generation.reglage.arret')).toContain('mouvements évalués par proposition');
+ expect(t('generation.reglage.nombre')).toContain('propositions');
+ expect(t('generation.reglage.historique')).toContain('mouvements');
+ expect(t('generation.reglage.graine')).toContain('entier');
+ expect(champ(m, 'generation.historique').value).toBe('1000');
+ expect(champ(m, 'generation.nombre').value).toBe('5');
+ expect(champ(m, 'generation.arret').value).toBe('200000');
+ for (const entree of entrees) {
+ expect(etiquette(m, entree.dataset.champ).textContent).not.toMatch(/durée|seconde|minute/i);
+ }
+ });
+
+ test('exécuteur direct : changer chaque réglage et quitter le champ — une entrée, ce réglage de la charge changé, les deux autres non', async () => {
+ const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
+ // Chaque réglage de la charge, et une valeur neuve, distincte de celle
+ // de la démonstration.
+ const saisies = { nombre: 3, arret: 40000, historique: 500 };
+ const noms = Object.keys(saisies);
+ expect(noms.length).toBe(3);
+ for (const nom of noms) {
+ const avant = { ...charge(m).reglages.generation };
+ expect(avant[nom], nom).not.toBe(saisies[nom]);
+ const r0 = revision(m);
+ const entrees = journalDe(m).entrees.length;
+ await saisir(m, champ(m, `generation.${nom}`), String(saisies[nom]));
+ expect(revision(m), nom).toBe(r0 + 1);
+ expect(journalDe(m).entrees.length, nom).toBe(entrees + 1);
+ expect(charge(m).reglages.generation, nom).toEqual({ ...avant, [nom]: saisies[nom] });
+ expect(champ(m, `generation.${nom}`).value, nom).toBe(String(saisies[nom]));
+ }
+ });
+
+ test('exécuteur direct : en lecture, la frappe dans un réglage est refusée avec « Modifier » ; la graine se saisit sans rien écrire', async () => {
+ const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
+ const ecritures = m.trace.length;
+ const arret = champ(m, 'generation.arret');
+ await saisir(m, arret, '9', { fin: null });
+ expect(arret.value).toBe('200000');
+ const refus = refusPres(arret, 'MODE_LECTURE');
+ expect(refus).not.toBeNull();
+ expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
+ await touche('Escape');
+ await saisir(m, champ(m, 'graine'), '77');
+ expect(champ(m, 'graine').value).toBe('77');
+ expect(champ(m, 'graine').parentElement.querySelector('[data-refus]')).toBeNull();
+ expect(m.trace.length).toBe(ecritures);
+ expect(fenetre()).toBeNull();
+ });
+
+ test('exécuteur direct, relevé : la graine vaut 1 à l’ouverture, 2 après une génération lancée à 1', async () => {
+ const { executeur, lancements } = releve(creerExecuteurDirect());
+ const m = await demonstration('petite', { executeur });
+ await regler(m, { nombre: 2, arret: 2000 });
+ expect(champ(m, 'graine').value).toBe('1');
+ await cliquer(m, generer(m));
+ await attendre(() => expect(charge(m).propositions.length).toBe(2));
+ expect(lancements.map(({ reglages }) => reglages.graine)).toEqual([1]);
+ await attendre(() => expect(champ(m, 'graine').value).toBe('2'));
+ });
+
+ test('exécuteur direct, relevé : graine -1, 1,5 ou 2^32 — refus sur le champ, rien n’est lancé ni écrit', async () => {
+ const { executeur, lancements } = releve(creerExecuteurDirect());
+ const m = await demonstration('petite', { executeur });
+ const ecritures = m.trace.length;
+ const saisies = ['-1', '1,5', String(2 ** 32)];
+ expect(saisies.length).toBe(3);
+ for (const saisie of saisies) {
+ const attendu = controleur(m).valider({ ...controleur(m).reglagesProposes(), graine: saisie }).refus;
+ expect(attendu.map(({ champ: nom }) => nom)).toEqual(['graine']);
+ await saisir(m, champ(m, 'graine'), saisie);
+ await cliquer(m, generer(m));
+ const graine = champ(m, 'graine');
+ const refus = refusPres(graine, attendu[0].code);
+ expect(refus, saisie).not.toBeNull();
+ expect(refus.querySelector('p').textContent).toBe(t(attendu[0].code, attendu[0].details));
+ expect(graine.getAttribute('aria-invalid')).toBe('true');
+ expect(generer(m).parentElement.querySelector('[data-refus]')).toBeNull();
+ }
+ expect(lancements).toEqual([]);
+ expect(charge(m).propositions).toEqual([]);
+ expect(m.trace.length).toBe(ecritures);
+ expect(fenetre()).toBeNull();
+ });
+});
+
+describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
+ test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
+ const { executeur: travailleur, suivi } = executeurTravailleur();
+ const { executeur, lancements } = releve(travailleur);
+ const m = await demonstration('petite', { executeur });
+ await regler(m, { nombre: 3, arret: 20000 });
+ const configuration = configurationDepuisCharge(charge(m));
+ await saisir(m, champ(m, 'graine'), '4242');
+ await cliquer(m, generer(m));
+ await attendre(() => expect(lancements[0]?.propositions ?? null).not.toBeNull());
+ expect(suivi.travailleurs).toBe(1);
+ const reglages = { graine: 4242, nombre: 3, arret: 20000, historique: 1000 };
+ expect(lancements[0].reglages).toEqual(reglages);
+ const attendues = rechercher(configuration, reglages);
+ expect(attendues.length).toBe(3);
+ expect(lancements[0].propositions).toEqual(attendues);
+ await attendre(() => expect(charge(m).propositions.length).toBe(3));
+ expect(charge(m).propositions.map(({ graine }) => graine)).toEqual(attendues.map(({ graine }) => graine));
+ }, 60_000);
+
+ test('exécuteur du travailleur : grande démonstration, compte élevé — pendant le calcul, la navigation et le changement du tour affiché mettent le DOM à jour ; l’avancement croît, aria-busy suit le calcul', async () => {
+ const { executeur } = executeurTravailleur();
+ const m = await demonstration('grande', { executeur });
+ await regler(m, { nombre: 4, arret: 400000 });
+ // Une réservation du seul tour 1, à un siège : une marque et une ligne
+ // qui ne paraissent qu'à ce tour. Le plan de chaque tour est relevé
+ // avant le calcul.
+ const { participants, reservations } = charge(m);
+ const libre = participants.find(({ id }) => !reservations.some(({ participant }) => participant === id));
+ await m.seance.executer('reserver', { participant: libre.id, table: 1, siege: 2, portee: 'tour', tour: 1 });
+ await cliquer(m, versSection(m, 'plan'));
+ await cadrerPourLesListes(m);
+ choisirTour(m, 1);
+ await images(2);
+ const tour1 = montre(m);
+ choisirTour(m, 2);
+ await images(2);
+ const tour2 = montre(m);
+ expect(tour1.listes.length).toBeGreaterThan(0);
+ expect(tour1.marques.length).toBeGreaterThan(0);
+ expect(tour2.listes).not.toEqual(tour1.listes);
+ choisirTour(m, 1);
+ await cliquer(m, versSection(m, 'generation'));
+
+ expect(section(m).getAttribute('aria-busy')).toBe('false');
+ await cliquer(m, generer(m));
+ await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
+ expect(section(m).getAttribute('aria-busy')).toBe('true');
+ const valeurs = [Number(barre(m).getAttribute('aria-valuenow'))];
+
+ // Un geste de navigation pendant le calcul : le DOM change avant la fin.
+ await cliquer(m, versSection(m, 'participants'));
+ expect(dans(m, '[data-section="participants"]')).not.toBeNull();
+ expect(section(m)).toBeNull();
+ expect(enCours(m)).toBe(true);
+
+ // Le plan pendant le calcul : il montre le tour 1, puis, le tour
+ // changé, les listes et les marques du tour 2, le calcul toujours en
+ // cours.
+ await cliquer(m, versSection(m, 'plan'));
+ await cadrerPourLesListes(m);
+ await attendre(() => expect(montre(m)).toEqual(tour1));
+ choisirTour(m, 2);
+ await attendre(() => expect(montre(m)).toEqual(tour2));
+ expect(m.ui.etat().tour).toBe(2);
+ expect(enCours(m)).toBe(true);
+
+ await cliquer(m, versSection(m, 'generation'));
+ expect(section(m).getAttribute('aria-busy')).toBe('true');
+
+ while (enCours(m)) {
+ const lue = barre(m)?.getAttribute('aria-valuenow');
+ if (lue !== undefined && Number(lue) !== valeurs.at(-1)) valeurs.push(Number(lue));
+ await images(1);
+ }
+ expect(valeurs.length).toBeGreaterThanOrEqual(2);
+ for (let i = 1; i < valeurs.length; i += 1) expect(valeurs[i]).toBeGreaterThan(valeurs[i - 1]);
+ await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
+ expect(barre(m)).toBeNull();
+ await attendre(() => expect(charge(m).propositions.length).toBeGreaterThan(0));
+ }, 120_000);
+
+ test('exécuteur du travailleur : annuler est immédiat ; plus aucun avancement ; l’état prêt revient ; propositions, journal et état sur le disque inchangés', async () => {
+ const { executeur } = executeurTravailleur();
+ const m = await demonstration('grande', { executeur });
+ await regler(m, { nombre: 4, arret: 400000 });
+ const avant = { propositions: charge(m).propositions, journal: journalDe(m), etat: etatSurDisque(m) };
+ await cliquer(m, generer(m));
+ await attendre(() => expect(Number(barre(m)?.getAttribute('aria-valuenow') ?? 0)).toBeGreaterThan(0));
+ const recus = [];
+ const desabonner = controleur(m).subscribe(({ fait }) => recus.push(fait));
+ await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
+ expect(enCours(m)).toBe(false);
+ expect(section(m).getAttribute('aria-busy')).toBe('false');
+ expect(barre(m)).toBeNull();
+ expect(generer(m)).not.toBeNull();
+ const apresAnnulation = recus.length;
+ await images(30);
+ expect(recus.length).toBe(apresAnnulation);
+ desabonner();
+ expect(charge(m).propositions).toEqual(avant.propositions);
+ expect(journalDe(m)).toEqual(avant.journal);
+ expect(etatSurDisque(m)).toBe(avant.etat);
+ expect(fenetre()).toBeNull();
+ }, 60_000);
+});
+
+describe('les refus à la commande (§ 5.9, § 15.4, § 9)', () => {
+ test('exécuteur direct, relevé : 13 participants, 4 tables de 3 — PLACES_MANQUANTES {placesManquantes: 1} près de « Générer », remède vers les tables ; rien de lancé ni d’écrit', async () => {
+ const { executeur, lancements } = releve(creerExecuteurDirect());
+ const m = await demonstration('petite', { executeur });
+ await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
+ flushSync();
+ expect(charge(m).participants.length).toBe(13);
+ expect(charge(m).tables.length).toBe(4);
+ const r0 = revision(m);
+ const ecritures = m.trace.length;
+ await cliquer(m, generer(m));
+ const refus = refusPres(generer(m), 'PLACES_MANQUANTES');
+ expect(refus).not.toBeNull();
+ const message = refus.querySelector('p').textContent;
+ expect(message).toBe(t('PLACES_MANQUANTES', { placesManquantes: 1 }));
+ expect(message).toContain('1 place');
+ const remede = refus.querySelector('[data-commande="ouvrirSection"]');
+ expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.tables'));
+ expect(fenetre()).toBeNull();
+ await cliquer(m, remede);
+ expect(m.ui.etat().section).toBe('tables');
+ expect(dans(m, '[data-section="tables"]')).not.toBeNull();
+ expect(lancements).toEqual([]);
+ expect(charge(m).propositions).toEqual([]);
+ expect(revision(m)).toBe(r0);
+ expect(m.trace.length).toBe(ecritures);
+ });
+
+ test('exécuteur direct, relevé : un événement neuf en écriture — AUCUN_PARTICIPANT puis, des participants sans table, AUCUNE_TABLE ; chaque remède ouvre sa section', async () => {
+ const { executeur, lancements } = releve(creerExecuteurDirect());
+ const m = await monter({ executeur });
+ await m.seance.creer({ nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 });
+ await m.seance.passerEnEcriture();
+ m.ui.allerA('generation');
+ flushSync();
+ let ecritures = m.trace.length;
+ await cliquer(m, generer(m));
+ const sansPersonne = refusPres(generer(m), 'AUCUN_PARTICIPANT');
+ expect(sansPersonne).not.toBeNull();
+ expect(sansPersonne.querySelector('p').textContent).toBe(t('AUCUN_PARTICIPANT', {}));
+ expect(fenetre()).toBeNull();
+ expect(m.trace.length).toBe(ecritures);
+ await cliquer(m, sansPersonne.querySelector('[data-commande="ouvrirSection"]'));
+ expect(m.ui.etat().section).toBe('participants');
+ expect(dans(m, '[data-section="participants"]')).not.toBeNull();
+
+ for (const nom of ['Pervenche', 'Lacasse']) await m.seance.executer('ajouterParticipant', { champs: { nom } });
+ m.ui.allerA('generation');
+ flushSync();
+ ecritures = m.trace.length;
+ await cliquer(m, generer(m));
+ const sansTable = refusPres(generer(m), 'AUCUNE_TABLE');
+ expect(sansTable).not.toBeNull();
+ expect(sansTable.querySelector('p').textContent).toBe(t('AUCUNE_TABLE', {}));
+ expect(fenetre()).toBeNull();
+ expect(m.trace.length).toBe(ecritures);
+ await cliquer(m, sansTable.querySelector('[data-commande="ouvrirSection"]'));
+ expect(m.ui.etat().section).toBe('tables');
+ expect(dans(m, '[data-section="tables"]')).not.toBeNull();
+ expect(lancements).toEqual([]);
+ });
+
+ test('exécuteur direct, relevé : en lecture, « Générer » refusé à côté de lui avec « Modifier » ; sur un plan bloqué, avec « Débloquer » ; aucune fenêtre', async () => {
+ const { executeur, lancements } = releve(creerExecuteurDirect());
+ const m = await demonstration('petite', { executeur, ecriture: false });
+ const ecritures = m.trace.length;
+ await cliquer(m, generer(m));
+ const lecture = refusPres(generer(m), 'MODE_LECTURE');
+ expect(lecture).not.toBeNull();
+ expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
+ expect(fenetre()).toBeNull();
+ expect(m.trace.length).toBe(ecritures);
+
+ await m.seance.passerEnEcriture();
+ const configuration = configurationDepuisCharge(charge(m));
+ const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 2000 });
+ await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
+ await m.seance.executer('retenirProposition', { id: charge(m).propositions[0].id });
+ await m.seance.executer('changerEtat', { etat: 'bloque' });
+ flushSync();
+ const ecrituresBloque = m.trace.length;
+ await cliquer(m, generer(m));
+ const bloque = refusPres(generer(m), 'LECTURE_SEULE');
+ expect(bloque).not.toBeNull();
+ expect(bloque.querySelector('p').textContent).toBe(t('LECTURE_SEULE', m.seance.etat().refus.details));
+ expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
+ expect(fenetre()).toBeNull();
+ expect(m.trace.length).toBe(ecrituresBloque);
+ expect(lancements).toEqual([]);
+ });
+});
+
+describe('la durée du calcul (§ 5.10, § 19.4)', () => {
+ test('exécuteur résolu à la main : sans mesure, le panneau le dit sans écrire 0 ; 1 800 ms d’horloge pilotée pour 5 × 200 000 mouvements, durée et réglages affichés', async () => {
+ const { executeur, executions } = executeurAMain();
+ const m = await demonstration('petite', { executeur });
+ expect(duree(m).textContent).toBe(t('generation.duree.inconnue'));
+ expect(duree(m).textContent).not.toMatch(/\b0\b/);
+ await cliquer(m, generer(m));
+ expect(executions.length).toBe(1);
+ expect(executions[0].reglages).toEqual({ graine: 1, nombre: 5, arret: 200000, historique: 1000 });
+ expect(section(m).getAttribute('aria-busy')).toBe('true');
+ m.horloge.avancer(1800);
+ const propositions = rechercher(executions[0].configuration, { graine: 1, nombre: 5, arret: 2000 });
+ executions[0].resoudre(propositions);
+ await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
+ await attendre(() => expect(charge(m).propositions.length).toBe(5));
+ const attendu = t('generation.duree.observee', { dureeMs: 1800, nombre: 5, arret: 200000 });
+ expect(duree(m).textContent).toBe(attendu);
+ expect(attendu).toContain('1,8 s');
+ expect(attendu).toContain('5 \u{00D7} 200\u{202F}000');
+ });
+
+ test('exécuteur résolu à la main : 10^6 mouvements en 2 s ; 6 × 10^8 annoncés en place, « environ vingt minutes à ce réglage » ; renoncer n’écrit rien, confirmer lance ; sous le seuil, sans question', async () => {
+ const { executeur, executions } = executeurAMain();
+ const m = await demonstration('petite', { executeur });
+ await cliquer(m, generer(m));
+ expect(executions.length).toBe(1);
+ m.horloge.avancer(2000);
+ executions[0].resoudre([]);
+ await attendre(() => expect(enCours(m)).toBe(false));
+ expect(controleur(m).derniereGeneration()).toMatchObject({ dureeMs: 2000, mouvements: 1_000_000 });
+
+ await saisir(m, champ(m, 'generation.arret'), '120000000');
+ expect(charge(m).reglages.generation).toMatchObject({ nombre: 5, arret: 120_000_000 });
+ const journal = journalDe(m);
+ const ecritures = m.trace.length;
+ await cliquer(m, generer(m));
+ expect(executions.length).toBe(1);
+ expect(question(m)).not.toBeNull();
+ expect(question(m).textContent).toContain('environ vingt minutes à ce réglage');
+ expect(fenetre()).toBeNull();
+
+ await cliquer(m, question(m).querySelector('[data-commande="renoncerGeneration"]'));
+ expect(question(m)).toBeNull();
+ expect(executions.length).toBe(1);
+ expect(journalDe(m)).toEqual(journal);
+ expect(m.trace.length).toBe(ecritures);
+
+ await cliquer(m, generer(m));
+ await cliquer(m, question(m).querySelector('[data-commande="confirmerGeneration"]'));
+ expect(executions.length).toBe(2);
+ expect(executions[1].reglages).toMatchObject({ nombre: 5, arret: 120_000_000 });
+ expect(question(m)).toBeNull();
+ expect(section(m).getAttribute('aria-busy')).toBe('true');
+ await cliquer(m, section(m).querySelector('[data-avancement] [data-commande="annuler"]'));
+ expect(executions[1].annulee).toBe(true);
+ await attendre(() => expect(enCours(m)).toBe(false));
+
+ await regler(m, { arret: 200000 });
+ await cliquer(m, generer(m));
+ expect(question(m)).toBeNull();
+ expect(executions.length).toBe(3);
+ executions[2].resoudre([]);
+ await attendre(() => expect(enCours(m)).toBe(false));
+ });
+});
diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte
new file mode 100644
index 0000000..02d1efa
--- /dev/null
+++ b/src/interface/generation/Reglages.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+ saisirGraine()}
+ />
+ {#if refusGraine !== null}
+
+ {/if}
+
+ {#each REGLAGES as nom (nom)}
+
+ {/each}
+
+ {#if mesure === null}
+
{t('generation.duree.inconnue')}
+ {:else}
+
{t('generation.duree.observee', mesure)}
+
{t('generation.duree.estimee', { secondes: estimation.secondes })}
+ {/if}
+
+
+
diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte
index a122498..52beb11 100644
--- a/src/interface/generation/SectionGeneration.svelte
+++ b/src/interface/generation/SectionGeneration.svelte
@@ -2,12 +2,172 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
{t('coquille.section.generation')}
+ {#if charge !== null}
+
+
+ lancer(false)} />
+
+ {#if question !== null}
+
+
{t('generation.longue.question', question)}
+
+ lancer(true)} />
+ (question = null)} />
+
+
+ {/if}
+ {#if $generation.enCours}
+ generation.annuler()} />
+ {:else if issue !== null}
+ {t('generation.issue.ajoutees', issue)}
+ {:else if $generation.annulee === 'commande'}
+ {t('generation.issue.annuleeCommande')}
+ {:else if $generation.annulee === 'seance'}
+ {t('generation.issue.annuleeSeance')}
+ {/if}
+ {/if}
+
+
diff --git a/src/interface/generation/travailleur.js b/src/interface/generation/travailleur.js
new file mode 100644
index 0000000..c6a7558
--- /dev/null
+++ b/src/interface/generation/travailleur.js
@@ -0,0 +1,12 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le point d'entrée du travailleur de génération (§ 5.10) : un module qui
+// s'exécute dans la portée du travailleur, self, et la branche sur la
+// recherche du moteur par brancherTravailleur. src/main.js seul l'importe,
+// par « ?worker&inline » : Vite l'inclut dans la page, et la coquille, qui
+// charge la page par file://, n'a aucun fichier de travailleur à charger.
+// L'exécuteur qui lui parle est celui d'executeur_travailleur.js.
+import { brancherTravailleur } from '../../application/generation_travailleur.js';
+
+brancherTravailleur(self);
diff --git a/src/interface/libelles/generation.js b/src/interface/libelles/generation.js
index 695edb8..f626e80 100644
--- a/src/interface/libelles/generation.js
+++ b/src/interface/libelles/generation.js
@@ -1,6 +1,97 @@
// © 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 et avancement (§ 14.6). Chaque clé
-// commence par « generation. ».
-export const TABLE = new Map();
+// 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
+// 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
+// creerFormat, sur la composition que la traduction passe à l'entrée ; les
+// détails reçus sont des nombres.
+import { creerFormat } from '../../application/format.js';
+import { compte } from '../../application/libelles/aides.js';
+
+// 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) {
+ const format = creerFormat(texte);
+ return `${format.duree(dureeMs)} pour ${format.nombre(nombre)} \u{00D7} ${format.nombre(arret)} mouvements`;
+}
+
+export const TABLE = new Map([
+ // --- Les réglages (§ 5.7, § 5.10) ---------------------------------------------
+ ['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'],
+ ['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'],
+ ['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
+ ['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
+
+ // --- 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.
+ [
+ 'generation.duree.estimee',
+ ({ secondes }, texte) => `À ces réglages, une génération prendrait ${creerFormat(texte).ordreDeGrandeur(secondes)}.`,
+ ],
+
+ // --- 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 } }.
+ [
+ 'generation.longue.question',
+ ({ estimation }, texte) => {
+ if (estimation.inconnue === true) {
+ return (
+ "Aucune durée n'est encore mesurée sur ce poste pendant cette séance, et ce réglage demande plus de dix " +
+ 'fois le réglage par défaut. La lancer quand même ?'
+ );
+ }
+ const format = creerFormat(texte);
+ 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}, ` +
+ `d'après le dernier calcul : ${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} ` +
+ 'mouvements. La lancer quand même ?'
+ );
+ },
+ ],
+ ['generation.longue.confirmer', 'Générer quand même'],
+ ['generation.longue.renoncer', 'Ne pas générer'],
+
+ // --- Les commandes ----------------------------------------------------------------
+ ['generation.commande.generer', 'Générer'],
+ ['generation.commande.annuler', 'Annuler la génération'],
+
+ // --- L'avancement (§ 5.10) --------------------------------------------------------
+ ['generation.avancement.barre', 'Avancement de la génération'],
+ [
+ 'generation.avancement.texte',
+ ({ fait, total }, texte) => {
+ const { nombre } = creerFormat(texte);
+ return `Génération en cours : ${nombre(fait)} mouvements évalués sur ${nombre(total)}.`;
+ },
+ ],
+
+ // --- L'issue d'une génération -------------------------------------------------------
+ // ajoutees : le nombre de propositions enregistrées ; identiques : celles
+ // que le plan d'une proposition présente a écartées.
+ [
+ 'generation.issue.ajoutees',
+ ({ ajoutees, identiques }) => {
+ const nouvelles =
+ ajoutees === 0 ? 'Aucune proposition nouvelle' : `${compte(ajoutees, 'proposition ajoutée', 'propositions ajoutées')}`;
+ const ecartees =
+ identiques === 0
+ ? ''
+ : ` ; ${compte(identiques, 'placement identique à une proposition présente, écarté', 'placements identiques à des propositions présentes, écartés')}`;
+ return `${nouvelles}${ecartees}.`;
+ },
+ ],
+ ['generation.issue.annuleeCommande', "Génération annulée : rien n'est enregistré."],
+ ['generation.issue.annuleeSeance', "Génération annulée par le retour en lecture : rien n'est enregistré."],
+]);
diff --git a/src/main.js b/src/main.js
index 77e4cb0..1a68661 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,16 +6,23 @@
// window.gtt — le système de fichiers du processus principal et la copie
// dans le presse-papiers ; sans lui, la page est la plateforme web, sur
// l'OPFS et le presse-papiers du navigateur. Il construit les dépendances
-// d'App — l'horloge du système, l'exécuteur direct de la génération, les
-// identifiants tirés par crypto.randomUUID, les démonstrations livrées
-// nommées par le catalogue — et monte App dans le corps du document. Le
-// thème de départ est laissé à App, qui lit la préférence du système.
+// d'App — l'horloge du système, l'exécuteur de la génération sur le
+// travailleur, les identifiants tirés par crypto.randomUUID, les
+// démonstrations livrées nommées par le catalogue — et monte App dans le
+// corps du document. Le thème de départ est laissé à App, qui lit la
+// préférence du système.
+//
+// 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).
import { mount } from 'svelte';
-import { creerExecuteurDirect } from './application/generation.js';
+import { creerExecuteurTravailleur } from './application/executeur_travailleur.js';
import { creerHorlogeSysteme } from './application/horloge.js';
import { CATALOGUE } from './demo/catalogue.js';
import { LIVREES } from './demo/livrees.js';
import App from './interface/App.svelte';
+import Travailleur from './interface/generation/travailleur.js?worker&inline';
import { FORMAT } from './stockage/document.js';
import { creerFichiersElectron } from './stockage/fichiers_electron.js';
import { creerFichiersWeb } from './stockage/fichiers_web.js';
@@ -42,7 +49,7 @@ mount(App, {
props: {
fs,
horloge: creerHorlogeSysteme(),
- executeur: creerExecuteurDirect(),
+ executeur: creerExecuteurTravailleur(() => new Travailleur()),
pressePapiers,
identifiant: () => crypto.randomUUID(),
langue: 'fr',