[ADD] interface: generation screen — settings, progress, worker, cancel

The operator launches a generation without the window freezing. The
screen sets the number of propositions, the seed, the stop count and the
history, refuses an out-of-range setting where it was typed, and asks
before a run estimated far beyond the default. The search runs in a worker
that terminate() cancels with no partial result, and the progress shows a
count, never a promised duration. The last duration lives for the session
only.
Checked: 2374 node and 391 browser tests.

--- FR ---

[ADD] interface : écran de génération — réglages, avancement, annulation

L'opérateur lance une génération sans figer la fenêtre. L'écran règle le
nombre de propositions, la graine, l'arrêt et l'historique, refuse un
réglage hors de son domaine là où il a été saisi, et demande avant un
calcul estimé bien au-delà du défaut. La recherche tourne dans un
travailleur que terminate() annule sans résultat partiel, et l'avancement
montre un compte, jamais une durée promise. La dernière durée ne vit que
le temps de la séance.
Vérifié : 2374 node et 391 navigateur.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 09:10:12 -04:00
parent 682e69876b
commit d19394957f
9 changed files with 1378 additions and 12 deletions

View file

@ -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());
},
};
},
};
}

View file

@ -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);
});
});

View file

@ -0,0 +1,61 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'avancement d'une génération en cours (§ 5.10) : les mouvements évalués
// sur le total, en mots et en barre — une barre de progression accessible,
// dont aria-valuenow porte les mouvements évalués —, et « Annuler la
// génération », qui appelle annuler. fait et total viennent du contrôleur
// de génération ; total est le produit du nombre de propositions et du
// compte d'arrêt, jamais nul pendant un calcul.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
let { fait, total, annuler } = $props();
const t = getContext('t');
const largeur = $derived(total > 0 ? `${(fait / total) * 100}%` : '0%');
</script>
<div class="avancement" data-avancement>
<p>{t('generation.avancement.texte', { fait, total })}</p>
<div
class="barre"
role="progressbar"
aria-label={t('generation.avancement.barre')}
aria-valuemin="0"
aria-valuemax={total}
aria-valuenow={fait}
>
<div class="plein" style:width={largeur}></div>
</div>
<Commande cle="generation.commande.annuler" nom="annuler" executer={annuler} />
</div>
<style>
.avancement {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
p {
margin: 0;
}
.barre {
box-sizing: border-box;
width: min(30rem, 100%);
height: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
}
.plein {
height: 100%;
background-color: var(--couleur-page-texte);
}
</style>

View file

@ -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));
});
});

View file

@ -0,0 +1,128 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les réglages d'une génération (§ 5.7, § 5.10) : la graine, puis le
// nombre de propositions, le compte d'arrêt et l'historique, chacun sous
// son libellé, qui porte son unité ; puis la durée observée du dernier
// calcul de la séance, et ce qu'elle laisse attendre des réglages courants.
// Aucun champ ne prend une durée : la recherche s'arrête sur un compte.
//
// Les trois réglages de la charge (generation.nombre, generation.arret,
// generation.historique) sont des Champ : une saisie se lit par la
// validation du contrôleur de génération, la même que celle de
// « Générer », et s'écrit à la sortie du champ par modifierReglage — une
// entrée par changement ; en lecture, la séance refuse la première frappe.
//
// La graine n'est pas au fichier : c'est une saisie de la commande, vivante
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire.
import { getContext } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
let { charge, mesure, estimation, graine = $bindable(), refusGraine = null, saisirGraine } = $props();
const t = getContext('t');
const seance = getContext('seance');
const generation = getContext('generation');
const uid = $props.id();
const idGraine = `${uid}-graine`;
const idRefusGraine = `${uid}-graine-refus`;
// Les réglages de la charge, dans l'ordre de l'écran.
const REGLAGES = ['nombre', 'arret', 'historique'];
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
// à défaut le premier, sinon la valeur lue.
const lecteur = (nom) => (texte) => {
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), [nom]: texte });
const propre = refus.find(({ champ }) => champ === nom) ?? refus[0];
if (propre !== undefined) return { refus: { code: propre.code, details: propre.details } };
return { valeur: reglages[nom] };
};
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
</script>
<div class="reglages" data-reglages>
<div class="champs">
<div class="champ">
<label for={idGraine}>{t('generation.reglage.graine')}</label>
<input
id={idGraine}
type="text"
data-champ="graine"
bind:value={graine}
aria-invalid={refusGraine === null ? undefined : 'true'}
aria-describedby={refusGraine === null ? undefined : idRefusGraine}
oninput={() => saisirGraine()}
/>
{#if refusGraine !== null}
<Refus id={idRefusGraine} refus={refusGraine} />
{/if}
</div>
{#each REGLAGES as nom (nom)}
<Champ
cle={`generation.${nom}`}
etiquette={`generation.reglage.${nom}`}
valeur={charge.reglages.generation[nom]}
lire={lecteur(nom)}
executer={ecrire(nom)}
/>
{/each}
</div>
{#if mesure === null}
<p data-duree>{t('generation.duree.inconnue')}</p>
{:else}
<p data-duree>{t('generation.duree.observee', mesure)}</p>
<p data-estimation>{t('generation.duree.estimee', { secondes: estimation.secondes })}</p>
{/if}
</div>
<style>
.reglages {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
.champs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-4);
}
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
p {
margin: 0;
}
</style>

View file

@ -2,12 +2,172 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de la génération sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
// La section de la génération (§ 2.1 étape 9, § 5.7, § 5.9, § 5.10,
// § 15.4) : les réglages, « Générer », la question d'une génération longue,
// l'avancement d'un calcul et l'issue du dernier. Tout passe par le
// contrôleur de génération du contexte, qui seul contrôle, lance, mesure et
// enregistre ; la section ne refait aucun de ses contrôles.
//
// « Générer » lit la graine saisie et les réglages de la charge par
// controleur.valider : un refus de la graine se pose contre elle, et rien
// ne part. Un refus du contrôleur — l'état de la séance, ce qui doit avoir
// eu lieu, les places manquantes — se pose à côté de « Générer », avec son
// remède ; aucune fenêtre ne s'ouvre. Une génération longue
// (CONFIRMATION_REQUISE) pose en place la question qui nomme l'ordre de
// grandeur attendu : « Générer quand même » la relance confirmée, « Ne pas
// générer » la retire sans rien écrire. La question tombe à toute autre
// 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.
//
// 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,
// et le contrôleur refuse un second lancement. Une annulation n'est pas un
// refus : l'état prêt revient, et l'issue le dit. Le contrôleur vit plus
// longtemps que la section : remontée pendant un calcul, elle en retrouve
// l'avancement par subscribe.
import { getContext, onDestroy, untrack } from 'svelte';
import { ErreurCommande } from '../../application/erreurs.js';
import { ErreurAnnulee } from '../../application/generation.js';
import Commande from '../primitives/Commande.svelte';
import Avancement from './Avancement.svelte';
import Reglages from './Reglages.svelte';
const t = getContext('t');
const seance = getContext('seance');
const generation = getContext('generation');
// La graine saisie, et son refus ; la question d'une génération longue,
// { estimation } ; l'issue de la dernière génération lancée d'ici,
// { ajoutees, identiques }.
let graine = $state(String(generation.grainePropose()));
let refusGraine = $state.raw(null);
let question = $state.raw(null);
let issue = $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.
let enCoursAvant = false;
const desabonner = generation.subscribe((etat) => {
if (etat.enCours && !enCoursAvant) {
graine = String(generation.grainePropose());
refusGraine = null;
question = null;
issue = null;
}
enCoursAvant = etat.enCours;
});
onDestroy(desabonner);
const charge = $derived($seance.charge);
// La question tombe quand la charge change.
$effect.pre(() => {
void charge;
untrack(() => {
question = null;
});
});
// 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.
const mesure = $derived.by(() => {
void $generation;
return generation.derniereGeneration();
});
const estimation = $derived(
mesure === null || charge === null ? null : generation.estimer(charge.reglages.generation),
);
function saisirGraine() {
refusGraine = null;
question = null;
}
// Lance une génération sur la graine saisie et les réglages de la charge ;
// confirmee : la réponse à la question d'une génération longue.
async function lancer(confirmee) {
refusGraine = null;
issue = null;
if (!confirmee) question = null;
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
const deGraine = refus.find(({ champ }) => champ === 'graine');
if (deGraine !== undefined) {
refusGraine = { code: deGraine.code, details: deGraine.details };
question = null;
return;
}
if (reglages === null) throw new ErreurCommande(refus[0].code, refus[0].details);
try {
const rendu = await generation.generer(reglages, { confirmee });
issue = { ajoutees: rendu.ajoutees.length, identiques: rendu.identiques };
} catch (erreur) {
if (erreur instanceof ErreurAnnulee) return;
if (erreur?.code === 'CONFIRMATION_REQUISE') {
question = { estimation: erreur.details.estimation };
return;
}
throw erreur;
}
}
</script>
<section data-section="generation">
<section data-section="generation" aria-busy={$generation.enCours ? 'true' : 'false'}>
<h2>{t('coquille.section.generation')}</h2>
{#if charge !== null}
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} />
<div class="lancer">
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
</div>
{#if question !== null}
<div class="question" data-question="longue" role="status">
<p>{t('generation.longue.question', question)}</p>
<div class="issues">
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
</div>
</div>
{/if}
{#if $generation.enCours}
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
{:else if issue !== null}
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
{:else if $generation.annulee === 'commande'}
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
{:else if $generation.annulee === 'seance'}
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
{/if}
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
.question {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border-left: 3px solid var(--couleur-avis);
}
.issues {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
p {
margin: 0;
}
</style>

View file

@ -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);

View file

@ -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é."],
]);

View file

@ -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',