From cc6b9fe996993c10db7cceaf54405398a8b482a1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 06:09:45 -0400 Subject: [PATCH] [ADD] application: generation controller, worker, evaluation table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating must never block the screen or record a plan that breaks a rule. The controller refuses before launching, in a fixed order: an out-of-range setting named by the engine's own check, the session's refusals, then what makes the run useless. It runs the search in a worker or in the thread, records only propositions whose invariants hold, skips plans already present, and keeps the last duration for the session only. The evaluation table ranks propositions on the minimum of encounters and names the reservations a proposition does not honour. Checked: 2112 node and 60 node-long tests; coverage floors kept. --- FR --- [ADD] application : génération, travailleur, table d'évaluation Générer ne doit jamais bloquer l'écran ni enregistrer un plan qui enfreint une règle. Le contrôleur refuse avant tout lancement, dans un ordre fixe : un réglage hors de son domaine, nommé par la règle même du moteur, les refus de la séance, puis ce qui rend le calcul vain. Il lance la recherche dans un travailleur ou dans le fil, n'enregistre que les propositions dont les invariants tiennent, écarte les plans déjà présents, et garde la dernière durée pour la seule séance. La table d'évaluation classe les propositions sur le minimum de rencontres et nomme les réservations qu'une proposition ne tient pas. Vérifié : 2112 node et 60 node-long ; seuils de couverture tenus. Assisted-by: Claude Opus 5.5 --- src/application/evaluation.js | 140 +++ src/application/evaluation.test.js | 361 ++++++ src/application/generation.js | 441 +++++++ src/application/generation.test.js | 1062 +++++++++++++++++ src/application/generation_travailleur.js | 57 + .../generation_travailleur.test.js | 66 + src/application/libelles.test.js | 255 +++- src/application/libelles/generation.js | 76 +- src/application/libelles/moteur.js | 135 ++- src/moteur/recherche.js | 25 +- src/moteur/recherche.test.js | 28 + 11 files changed, 2632 insertions(+), 14 deletions(-) create mode 100644 src/application/evaluation.js create mode 100644 src/application/evaluation.test.js create mode 100644 src/application/generation.js create mode 100644 src/application/generation.test.js create mode 100644 src/application/generation_travailleur.js create mode 100644 src/application/generation_travailleur.test.js diff --git a/src/application/evaluation.js b/src/application/evaluation.js new file mode 100644 index 0000000..10312b0 --- /dev/null +++ b/src/application/evaluation.js @@ -0,0 +1,140 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La table d'évaluation (§ 5.4, § 5.5, § 5.7, § 12.6, § 13.2) : chaque +// proposition et le retenu, mesurés par la mesure qui fait foi, et le +// classement de celles qui se comparent. Le classement, les colonnes et le +// retenu la lisent ; ce module est le seul de l'application qui importe les +// mesures du moteur, et il n'en refait aucune. +// +// Un placement se compare quand il décrit la configuration courante : sans +// faute (fautes, S2) et sans dérive (derive, S2). Fautif ou en dérive, il +// n'est ni mesuré ni classé, et ses raisons disent pourquoi — la partition +// se fait avant classer, qui laisse passer deux populations de même taille. +// derive et planDepuisFichier, qui lèvent sur une faute, ne reçoivent que +// des placements sans faute. +// +// Les imposés viennent du diagnostic, les mêmes pour toute ligne ; les +// rencontres répétées imposées dépendent du plan, et chaque ligne les lit +// dans ses mesures, à part des choisies. +import { classer } from '../moteur/classement.js'; +import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { diagnostiquer } from '../moteur/diagnostic.js'; +import { mesurer } from '../moteur/indicateurs.js'; +import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js'; +import { plafondsRealises } from '../moteur/plafond.js'; +import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js'; +import { configurationDepuisCharge } from '../stockage/document.js'; +import { derive, fautes, planDepuisFichier } from '../stockage/placements.js'; + +/** + * @typedef {Object} LigneEvaluation + * @property {number} id celui de la proposition ; retenu.proposition pour le retenu + * @property {'comparable'|'derive'|'fautif'} statut + * @property {object[]} raisons fautif : ses fautes ; derive : ses raisons ; [] sinon + * @property {object[]} violations comparable : les réservations non honorées + * (verifierInvariants, RESERVATION) ; [] sinon + * @property {object|null} mesures mesurer, telles quelles + * @property {number[]|null} plafondsRealises + * @property {object|null} troisChiffres + * @property {number|null} ecartAuPlafondAPrioriMax le plus grand de + * ecartsAuPlafondAPriori ; null quand l'a priori est inconnu ou la + * population vide + * @property {boolean|null} minimumAtteint + * @property {object[]} refusDessin verifierIndicateurs, chaque violation + * complétée de participant, l'identifiant de son index ; [] + * + * @typedef {Object} TableEvaluation + * @property {number[]} population identifiants non exclus, croissants + * @property {LigneEvaluation[]} propositions comparables dans l'ordre du + * classement, puis les autres par identifiant + * @property {LigneEvaluation|null} retenu + * @property {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} classement + * classer, tel quel, sur les comparables + * @property {{retours: number, ancrages: number}} imposes + */ + +// Le plus grand élément d'une liste ; null pour une liste vide ou absente. +function plusGrand(valeurs) { + if (valeurs === null || valeurs.length === 0) return null; + let plus = valeurs[0]; + for (const valeur of valeurs) if (valeur > plus) plus = valeur; + return plus; +} + +// Une ligne qui ne se mesure pas. +const nonMesuree = (id, statut, raisons) => ({ + id, + statut, + raisons, + violations: [], + mesures: null, + plafondsRealises: null, + troisChiffres: null, + ecartAuPlafondAPrioriMax: null, + minimumAtteint: null, + refusDessin: [], +}); + +/** + * La table d'évaluation d'une charge (contrat de A6). Ne modifie pas la + * charge. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {{plafondsAPriori?: 'calcul'|null}} [options] + * 'calcul' : le plafond a priori par personne du diagnostic, une seule + * maximisation ; null : a priori inconnu (§ 17, point 4) + * @returns {TableEvaluation} + * @throws {ErreurConfiguration} la configuration de la charge ne se + * normalise pas : rien ne se mesure, et la génération refuse la même + * @throws {TypeError} une autre valeur de plafondsAPriori + */ +export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { + if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) { + throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`); + } + const configuration = configurationDepuisCharge(charge); + const diagnostic = diagnostiquer(configuration); + const instance = normaliser(configuration); + const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null; + + // La ligne d'un placement, proposition ou retenu, sous l'identifiant id. + function ligneDe(placement, id) { + const sesFautes = fautes(placement); + if (sesFautes.length > 0) return nonMesuree(id, 'fautif', sesFautes); + const raisons = derive(placement, charge); + if (raisons.length > 0) return nonMesuree(id, 'derive', raisons); + const plan = planDepuisFichier(placement); + const tableDe = indexerPlan(instance, plan); + const mesures = mesurer(instance, tableDe); + const realises = plafondsRealises(instance, tableDe); + return { + id, + statut: 'comparable', + raisons: [], + violations: verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION'), + mesures, + plafondsRealises: realises, + troisChiffres: troisChiffres(instance, mesures, aPriori, realises), + ecartAuPlafondAPrioriMax: plusGrand(ecartsAuPlafondAPriori(mesures, aPriori)), + minimumAtteint: minimumAtteint(mesures, aPriori), + refusDessin: verifierIndicateurs(mesures, aPriori, realises).map((violation) => ({ + ...violation, + participant: instance.ids[violation.index], + })), + }; + } + + const lignes = charge.propositions.map((proposition) => ligneDe(proposition, proposition.id)); + const comparables = lignes.filter(({ statut }) => statut === 'comparable'); + const classement = classer(comparables.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori }))); + const rangDe = new Map(classement.ordre.map((id, rang) => [id, rang])); + const autres = lignes.filter(({ statut }) => statut !== 'comparable').sort((a, b) => a.id - b.id); + return { + population: [...instance.ids], + propositions: [...comparables.sort((a, b) => rangDe.get(a.id) - rangDe.get(b.id)), ...autres], + retenu: charge.retenu === null ? null : ligneDe(charge.retenu, charge.retenu.proposition), + classement, + imposes: { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours }, + }; +} diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js new file mode 100644 index 0000000..a5d7b3f --- /dev/null +++ b/src/application/evaluation.test.js @@ -0,0 +1,361 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la table d'évaluation (§ 5.4, § 5.5, § 5.7, § 8.9, § 12.6, +// § 13.2, § 15.1, § 15.3, § 15.4, § 17) : la mesure qui fait foi, le +// classement, les placements en dérive ou fautifs, les imposés. Les charges +// partent des démonstrations livrées, et leurs propositions s'enregistrent +// par la commande ; les plans construits à la main sont écrits ici, sur la +// petite démonstration au conflit inévitable. La valeur attendue vient des +// fonctions du moteur, sollicitées une seconde fois (§ 19.1). +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { texteLivre } from '../demo/livrees.js'; +import { classer } from '../moteur/classement.js'; +import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { diagnostiquer } from '../moteur/diagnostic.js'; +import { ErreurConfiguration } from '../moteur/erreurs.js'; +import { mesurer } from '../moteur/indicateurs.js'; +import { ecartsAuPlafondAPriori } from '../moteur/manque.js'; +import { plafondsAPriori } from '../moteur/plafond.js'; +import { rechercher } from '../moteur/recherche.js'; +import { analyser, capacite, configurationDepuisCharge } from '../stockage/document.js'; +import { planDepuisFichier } from '../stockage/placements.js'; +import { VERSION } from '../version.genere.js'; +import { appliquerCommande } from './commandes.js'; +import { evaluer } from './evaluation.js'; + +const V1 = VERSION.affichee; + +const chargeDemo = (cle) => analyser(texteLivre(cle)).charge; + +// La charge, une génération du moteur de plus, enregistrée par la commande. +function generee(charge, reglages) { + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, reglages); + return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge; +} + +const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge; + +// Une proposition écrite à la main, sièges non attribués, sur les tables de +// la charge dans leur ordre ; ses listes et réserves telles que données. +function propositionDe(charge, id, tours, reserves = tours.map(() => [])) { + const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p); + return { + id, + graine: id, + arret: 40, + historique: 1000, + produitVersion: V1, + siegesAttribues: false, + tables: charge.tables.map(({ id: t }) => t), + capacites: charge.tables.map((table) => capacite(charge, table)), + tours: tours.length, + participants, + placement: tours.map((listes, rang) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: reserves[rang] })), + }; +} + +// Le retenu tiré d'une proposition. +function retenuDe(proposition) { + const { id, siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition); + return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }; +} + +// Gèle en profondeur : evaluer qui modifierait sa charge lèverait. +function figer(valeur) { + if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) { + Object.freeze(valeur); + for (const element of Object.values(valeur)) figer(element); + } + return valeur; +} + +// Chaque nombre atteint depuis la valeur, à toute profondeur. +function nombres(valeur, trouves = []) { + if (typeof valeur === 'number') trouves.push(valeur); + else if (valeur !== null && typeof valeur === 'object') for (const element of Object.values(valeur)) nombres(element, trouves); + return trouves; +} + +// Les plans écrits à la main sur la petite démonstration au conflit +// inévitable — appartenances de tailles 5, 4 et 3 —, nommés par leurs +// collisions (cumulées, distinctes). Les trois premiers ont le même plus +// grand écart au plafond a priori, 3 ; le quatrième garde ensemble, aux +// quatre tours, les personnes 1, 2 et 3, d'appartenances distinctes, et ses +// quatre collisions tombent sur quatre paires. +const PLANS_CONFLIT = [ + { + nom: '(4, 1)', + tours: [ + [[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]], + [[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]], + [[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]], + [[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]], + ], + collisions: [4, 1], + }, + { + nom: '(4, 2)', + tours: [ + [[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]], + [[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]], + [[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]], + [[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]], + ], + collisions: [4, 2], + }, + { + nom: '(6, 6)', + tours: [ + [[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]], + [[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]], + [[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]], + [[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]], + ], + collisions: [6, 6], + }, + { + nom: 'trio', + tours: [ + [[1, 2, 3], [6, 9, 10], [7, 11, 12], [4, 5, 8]], + [[1, 2, 3], [5, 6, 9], [7, 11, 12], [4, 8, 10]], + [[1, 2, 3], [7, 8, 9], [10, 11, 12], [4, 5, 6]], + [[1, 2, 3], [7, 8, 9], [6, 10, 11], [4, 5, 12]], + ], + collisions: [4, 4], + }, +]; + +describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => { + test('chaque ligne comparable porte mesurer, les plafonds, les trois chiffres ; le classement est celui de classer', () => { + const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 })); + const table = evaluer(charge); + const instance = normaliser(configurationDepuisCharge(charge)); + const aPriori = plafondsAPriori(instance); + assert.deepEqual(table.population, instance.ids); + assert.equal(table.propositions.length, 3); + const entrees = []; + for (const proposition of charge.propositions) { + const ligne = table.propositions.find(({ id }) => id === proposition.id); + const mesures = mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition))); + assert.equal(ligne.statut, 'comparable'); + assert.deepEqual(ligne.raisons, []); + assert.deepEqual(ligne.violations, []); + assert.deepEqual(ligne.refusDessin, []); + assert.deepEqual(ligne.mesures, mesures); + assert.equal(ligne.plafondsRealises.length, 12); + assert.equal(ligne.minimumAtteint, false); + assert.deepEqual(Object.keys(ligne.troisChiffres).sort(), ['ecartItineraireMax', 'effectifManque', 'manqueMax']); + assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecartsAuPlafondAPriori(mesures, aPriori))); + entrees.push({ id: proposition.id, mesures, plafondsAPriori: aPriori }); + } + assert.deepEqual(table.classement, classer(entrees)); + assert.deepEqual(table.propositions.map(({ id }) => id), table.classement.ordre); + assert.equal(table.retenu, null); + }); + + test("trois plans au même premier critère se rangent par l'excédent, puis les cumulées ; le trio vient en dernier", () => { + const charge = structuredClone(chargeDemo('petite-conflit')); + charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours)); + charge.prochainsIds.proposition = 5; + charge.evenement.etat = 'propose'; + const table = evaluer(figer(charge)); + const parId = new Map(table.propositions.map((ligne) => [ligne.id, ligne])); + PLANS_CONFLIT.forEach(({ nom, collisions }, rang) => { + const { mesures } = parId.get(rang + 1); + assert.deepEqual([mesures.collisionsCumulees, mesures.pairesDistinctes], collisions, nom); + }); + assert.deepEqual([1, 2, 3].map((id) => parId.get(id).ecartAuPlafondAPrioriMax), [3, 3, 3]); + assert.ok(parId.get(4).ecartAuPlafondAPrioriMax > 3); + assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]); + assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax'); + for (const ligne of table.propositions) { + const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDepuisCharge(charge)))); + assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecarts)); + } + }); + + test('evaluer ne modifie pas la charge, et deux appels rendent la même table', () => { + const charge = figer(generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 })); + assert.deepEqual(evaluer(charge), evaluer(charge)); + }); +}); + +describe('évaluation : une réservation que le plan ne tient pas (§ 5.8, § 12.6)', () => { + test("une réservation posée après la génération laisse la ligne comparable et classée, la violation RESERVATION nommée", () => { + const avant = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + const [proposition] = avant.propositions; + // La table de la personne 1 au premier tour, puis une autre où la réserver. + const listes = planDepuisFichier(proposition).tours[0]; + const sienne = avant.tables[listes.findIndex((liste) => liste.includes(1))].id; + const autre = avant.tables.find(({ id }) => id !== sienne).id; + const charge = executer(avant, 'reserver', { participant: 1, table: autre, siege: null, portee: 'tour', tour: 1 }); + const table = evaluer(figer(charge)); + const [ligne] = table.propositions; + assert.equal(ligne.id, proposition.id); + assert.equal(ligne.statut, 'comparable'); + assert.deepEqual(ligne.raisons, []); + assert.deepEqual(ligne.violations, [{ code: 'RESERVATION', participant: 1, table: autre, tour: 1 }]); + assert.notEqual(ligne.mesures, null); + assert.deepEqual(table.classement.ordre, [proposition.id]); + }); +}); + +describe('évaluation : les formes closes ne corrigent rien (§ 5.4)', () => { + test("un retenu qui laisse une personne en réserve à trois tours mesure moins de collisions que le plancher, et la table porte la mesure", () => { + const charge = structuredClone(chargeDemo('petite-conflit')); + // Le plan parfait de la petite démonstration, la personne 10 en réserve + // aux tours 2 à 4 : elle seule fait deux membres d'une même appartenance + // à une table, au premier tour. + const tours = [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], + [[6, 9, 12], [3, 8, 11], [2, 5], [1, 4, 7]], + [[4, 8], [2, 7, 12], [1, 6, 11], [3, 5, 9]], + [[5, 7, 11], [1, 9], [3, 4, 12], [2, 6, 8]], + ]; + const proposition = propositionDe(charge, 1, tours, [[], [10], [10], [10]]); + charge.propositions = [proposition]; + charge.retenu = retenuDe(proposition); + charge.prochainsIds.proposition = 2; + charge.evenement.etat = 'retenu'; + const table = evaluer(figer(charge)); + const plancher = diagnostiquer(configurationDepuisCharge(charge)).collisions.reduce((total, { plancherCumulees }) => total + plancherCumulees, 0); + assert.equal(plancher, 4); + assert.equal(table.retenu.statut, 'comparable'); + assert.equal(table.retenu.id, 1); + assert.equal(table.retenu.mesures.collisionsCumulees, 1); + assert.ok(table.retenu.mesures.collisionsCumulees < plancher); + }); +}); + +describe('évaluation : les populations différentes (§ 9, § 12.6)', () => { + test("générer, exclure une personne, générer encore : les premières sont en dérive, hors classement, leurs raisons nomment la personne", () => { + let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 }); + charge = executer(charge, 'exclureParticipant', { id: 4 }); + charge = generee(charge, { graine: 2, nombre: 2, arret: 200 }); + const table = evaluer(figer(charge)); + assert.deepEqual(table.population, [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12]); + assert.deepEqual(table.classement.ordre.slice().sort(), [3, 4]); + for (const id of [1, 2]) { + const ligne = table.propositions.find((candidate) => candidate.id === id); + assert.equal(ligne.statut, 'derive'); + assert.deepEqual(ligne.raisons, [{ code: 'PARTICIPANT_EXCLU', participant: 4 }]); + assert.deepEqual([ligne.mesures, ligne.plafondsRealises, ligne.troisChiffres, ligne.ecartAuPlafondAPrioriMax, ligne.minimumAtteint], [null, null, null, null, null]); + assert.deepEqual([ligne.violations, ligne.refusDessin], [[], []]); + } + assert.deepEqual(table.propositions.map(({ id }) => id), [...table.classement.ordre, 1, 2]); + }); + + test('une exclusion et un ajout, à effectif égal, mettent en dérive ; une table ajoutée aussi, TABLE_AJOUTEE', () => { + let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + charge = executer(charge, 'exclureParticipant', { id: 4 }); + charge = executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' } }); + const echange = evaluer(figer(charge)); + assert.equal(echange.population.length, 12); + assert.equal(echange.propositions[0].statut, 'derive'); + assert.deepEqual(echange.propositions[0].raisons.map(({ code }) => code), ['PARTICIPANT_EXCLU', 'PARTICIPANT_NON_PLACE']); + assert.deepEqual(echange.classement.ordre, []); + let ajout = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + ajout = executer(ajout, 'creerTable', { forme: 'ronde' }); + const table = evaluer(figer(ajout)); + assert.equal(table.propositions[0].statut, 'derive'); + assert.deepEqual(table.propositions[0].raisons, [{ code: 'TABLE_AJOUTEE', table: 5 }]); + }); +}); + +describe('évaluation : un placement fautif (§ 8.9, point 3)', () => { + test("un retenu dont une liste dépasse sa longueur déclarée est fautif, non mesuré, et les propositions se classent sans lui", () => { + const base = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300 }); + const attendue = evaluer(figer(structuredClone(base))); + const charge = structuredClone(base); + const retenu = retenuDe(charge.propositions[1]); + const [premiere, seconde] = retenu.placement[0].sieges; + premiere.push(seconde.shift()); + charge.retenu = retenu; + charge.evenement.etat = 'retenu'; + const table = evaluer(figer(charge)); + assert.equal(table.retenu.statut, 'fautif'); + assert.equal(table.retenu.id, charge.propositions[1].id); + assert.equal(table.retenu.mesures, null); + assert.ok(table.retenu.raisons.length > 0); + assert.ok(table.retenu.raisons.every(({ code }) => code === 'LONGUEUR'), JSON.stringify(table.retenu.raisons)); + assert.deepEqual(table.propositions, attendue.propositions); + assert.deepEqual(table.classement, attendue.classement); + }); +}); + +describe('évaluation : le plafond a priori inconnu (§ 17, point 4)', () => { + test("plafondsAPriori null : le premier critère est sauté et nommé ; écart d'itinéraire et certificat inconnus", () => { + const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 2, arret: 300 })); + const table = evaluer(charge, { plafondsAPriori: null }); + assert.equal(table.classement.critereSaute, 'ecartAuPlafondAPrioriMax'); + assert.equal(table.classement.criteresAppliques[0], 'excedentCollisions'); + for (const ligne of table.propositions) { + assert.equal(ligne.troisChiffres.ecartItineraireMax, null); + assert.equal(ligne.minimumAtteint, null); + assert.equal(ligne.ecartAuPlafondAPrioriMax, null); + } + assert.throws(() => evaluer(charge, { plafondsAPriori: [] }), TypeError); + }); +}); + +describe('évaluation : les imposés (§ 5.4, § 15.1)', () => { + test('la grande démonstration : 99 retours imposés, 33 ancrages qui en imposent, ceux de diagnostiquer', () => { + const charge = chargeDemo('grande'); + const table = evaluer(figer(charge)); + const diagnostic = diagnostiquer(configurationDepuisCharge(charge)); + assert.deepEqual(table.imposes, { retours: 99, ancrages: 33 }); + assert.deepEqual(table.imposes, { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours }); + }); + + test("deux personnes d'appartenances distinctes réservées ensemble : une rencontre répétée imposée par ligne, hors des choisies", () => { + const charge = structuredClone(chargeDemo('petite')); + charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const avec = generee(charge, { graine: 6, nombre: 3, arret: 500 }); + const table = evaluer(figer(avec)); + assert.equal(table.propositions.length, 3); + for (const ligne of table.propositions) { + assert.equal(ligne.mesures.rencontresRepetees.imposees, 1); + // Les paires réunies deux fois au moins, comptées sur le plan : celle + // des deux réservés n'est pas parmi les choisies. + const plan = planDepuisFichier(avec.propositions.find(({ id }) => id === ligne.id)); + const fois = new Map(); + for (const listes of plan.tours) { + for (const liste of listes) { + for (const a of liste) for (const b of liste) if (a < b) fois.set(`${a}-${b}`, (fois.get(`${a}-${b}`) ?? 0) + 1); + } + } + const repetees = [...fois.values()].filter((n) => n >= 2).length; + assert.equal(fois.get('1-2'), 4); + assert.equal(ligne.mesures.rencontresRepetees.choisies, repetees - 1); + } + }); +}); + +describe('évaluation : une table, un tour (§ 15.4)', () => { + test('six personnes à une table de 6, un tour : rien de répété, aucun NaN ni infini', () => { + const charge = structuredClone(chargeDemo('petite')); + charge.participants = charge.participants.slice(0, 6); + charge.tables = charge.tables.slice(0, 1).map((table) => ({ ...table, sieges: 6 })); + charge.evenement.tours = 1; + const table = evaluer(figer(generee(charge, { graine: 1, nombre: 1, arret: 10 }))); + const [ligne] = table.propositions; + assert.equal(ligne.statut, 'comparable'); + assert.deepEqual(ligne.mesures.rencontresRepetees, { choisies: 0, imposees: 0 }); + assert.equal(ligne.mesures.excedentCollisions, 0); + assert.equal(ligne.mesures.totalRetoursChoisis, 0); + const tous = nombres(table); + assert.ok(tous.length > 0); + assert.ok(tous.every(Number.isFinite), 'NaN ou infini dans la table'); + }); +}); + +describe("évaluation : une configuration qui ne se tient pas", () => { + test("evaluer lève l'ErreurConfiguration de normaliser", () => { + const charge = structuredClone(chargeDemo('petite')); + charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + assert.throws(() => evaluer(figer(charge)), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION'); + }); +}); diff --git a/src/application/generation.js b/src/application/generation.js new file mode 100644 index 0000000..98a77d3 --- /dev/null +++ b/src/application/generation.js @@ -0,0 +1,441 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La génération (§ 5.7, § 5.8, § 5.10) : les exécuteurs qui font tourner la +// recherche du moteur, et le contrôleur qui la lance depuis la séance. +// +// Un exécuteur rend, pour une configuration et des réglages, une exécution : +// son résultat, une promesse des propositions de rechercher ; ses +// avancements ; son annulation, qui rejette le résultat par ErreurAnnulee, +// sans résultat partiel. L'exécuteur direct calcule dans le fil, une +// microtâche après le lancement : il sert les épreuves et le premier +// montage de l'application ; celui du travailleur (I13) calcule hors du fil +// et s'annule par terminate(). Une erreur de la recherche devient la même +// dans les deux, par versErreurGeneration. +// +// Le contrôleur ne numérote rien et ne mesure aucun plan. Il refuse avant +// tout lancement un réglage hors de son domaine (REGLAGE_HORS_DOMAINE), puis +// ce que l'enregistrement refuserait — les refus de la séance, lus dans son +// état observable, dans l'ordre de son contrat —, puis +// ce qui rend la génération vaine ou impossible ; il garde la graine +// proposée et la durée du dernier calcul, en mémoire, pour la séance de +// l'application ; il vérifie les invariants de chaque proposition rendue, +// écarte celles dont le plan est déjà présent, et confie les autres à la +// commande enregistrerGeneration, une seule entrée du journal. Le nombre de +// propositions d'une génération est un réglage, jamais une constante d'ici. +// +// Le compte d'inactivité de la séance est suspendu pendant toute la +// génération (debuterCalcul), et repart à sa fin, quelle qu'elle soit +// (finirCalcul, dans un finally). Passer en lecture, ou fermer l'événement, +// annule la génération en cours (surAnnulation). +import { normaliser, nombrePlacesManquantes } from '../moteur/configuration.js'; +import { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js'; +import { rechercher, verifierReglages } from '../moteur/recherche.js'; +import { verifierInvariants } from '../moteur/verification.js'; +import { GENERATION_PAR_DEFAUT, configurationDepuisCharge } from '../stockage/document.js'; +import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js'; +import { lireEntier } from './capacite.js'; + +// src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur. +export { ErreurAnnulee } from '../moteur/erreurs.js'; + +/** Au-delà de cette durée estimée, en secondes, la génération demande confirmation (§ 5.10). */ +export const SEUIL_GENERATION_LONGUE_S = 60; + +/** Le plafond d'usage d'une génération, en secondes (§ 5.10) : un repère dit, jamais un refus. */ +export const PLAFOND_USAGE_S = 30 * 60; + +// Sans durée mesurée, une génération de plus de mouvements que ce compte — +// dix fois le réglage par défaut — demande confirmation. +const MOUVEMENTS_SANS_MESURE = 10 * GENERATION_PAR_DEFAUT.nombre * GENERATION_PAR_DEFAUT.arret; + +// La plus grande graine, et le modulo de la suivante. +const GRAINE_MAX = 2 ** 32 - 1; +const GRAINES = 2 ** 32; + +// Les champs des réglages, dans l'ordre où leurs refus se rendent, et le +// domaine de chacun ; max null : sans borne. +const CHAMPS = [ + ['graine', 0, GRAINE_MAX], + ['nombre', 1, null], + ['arret', 1, null], + ['historique', 1, null], +]; + +const versSection = (section) => ({ geste: 'ouvrirSection', section }); + +/** + * Refus ou échec d'une génération : son code et ses détails — ceux de la + * séance, du moteur ou du contrôleur, tels quels. Le texte affiché est celui + * de son code, nu (§ 14.6) ; le message, code et détails en JSON, sert aux + * traces. + */ +export class ErreurGeneration extends Error { + /** + * @param {string} code + * @param {Object} [details] + */ + constructor(code, details = {}) { + super(`${code} ${JSON.stringify(details)}`); + this.code = code; + this.details = details; + } +} +ErreurGeneration.prototype.name = 'ErreurGeneration'; + +/** + * Les réglages d'une génération, contrôlés par la règle de rechercher + * (verifierReglages) avant tout calcul. Lève, pour le premier réglage hors + * de son domaine, ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage, + * remede: null }), reglage étant son nom — graine, arret, historique ou + * nombre —, sans le message du moteur ; ne rend rien sinon. Toute autre + * erreur du contrôle est relevée telle quelle. + * + * @param {Object} reglages + */ +export function exigerReglages(reglages) { + try { + verifierReglages(reglages); + } catch (erreur) { + if (erreur instanceof RangeError && typeof erreur.reglage === 'string') { + throw new ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage: erreur.reglage, remede: null }); + } + throw erreur; + } +} + +/** + * L'erreur d'une recherche, la même dans les deux exécuteurs : ErreurAnnulee + * telle quelle ; une ErreurConfiguration devient ErreurGeneration de son code + * et de ses détails ; toute autre, RangeError comprise, est rendue telle + * quelle, pour être relevée. Les réglages sont contrôlés avant le calcul + * (exigerReglages) : une RangeError qui survient pendant lui vient d'ailleurs, + * d'un abonné aux avancements par exemple, et ne se dit pas comme un réglage. + * + * @param {unknown} erreur + * @returns {unknown} + */ +export function versErreurGeneration(erreur) { + if (erreur instanceof ErreurAnnulee) return erreur; + if (erreur instanceof ErreurConfiguration) return new ErreurGeneration(erreur.code, erreur.details); + return erreur; +} + +/** + * @typedef {Object} Execution + * @property {(fn: (fait: number, total: number) => void) => () => void} surAvancement + * abonne fn aux avancements ; rend la fonction qui désabonne + * @property {Promise} resultat + * rejetée par ErreurAnnulee à l'annulation, par ErreurGeneration sur + * une erreur du moteur + * @property {() => void} annuler sans résultat partiel + * + * @typedef {{lancer: (configuration: Object, reglages: Object) => Execution}} Executeur + */ + +/** + * L'exécuteur direct : rechercher dans le fil, une microtâche après lancer. + * Rien d'autre ne s'exécute pendant la recherche. annuler(), avant ce départ + * ou depuis un rappel d'avancement, fait lever ErreurAnnulee à la lecture + * suivante du signal par le moteur : avant la proposition suivante, au plus + * 1 000 mouvements plus tard. Chaque avancement est celui du moteur, toutes + * les 1 000 itérations. + * + * @returns {Executeur} + */ +export function creerExecuteurDirect() { + return { + lancer(configuration, reglages) { + const signal = { aborted: false }; + const abonnes = new Set(); + const progression = (fait, total) => { + for (const fn of [...abonnes]) fn(fait, total); + }; + const resultat = Promise.resolve().then(() => { + exigerReglages(reglages); + try { + return rechercher(configuration, reglages, { signal, progression }); + } catch (erreur) { + throw versErreurGeneration(erreur); + } + }); + return { + surAvancement(fn) { + abonnes.add(fn); + return () => { + abonnes.delete(fn); + }; + }, + resultat, + annuler() { + signal.aborted = true; + }, + }; + }, + }; +} + +// Le plan d'une proposition du moteur, rangé comme planDepuisFichier rend +// celui d'une proposition enregistrée : la même mise en forme, versFichier +// puis planDepuisFichier, pour que deux plans égaux s'écrivent pareil. +function planRange(proposition, configuration, produitVersion) { + const fichier = versFichier(proposition, configuration, { produitVersion, attribuerSieges: false, decalage: 0 }); + return JSON.stringify(planDepuisFichier(fichier)); +} + +/** + * Le contrôleur de génération d'une séance (§ 5.7, § 5.10). + * + * @param {Object} parametres + * @param {Object} parametres.seance la séance d'A1 et A2 : etat, + * subscribe, surAnnulation, executer, debuterCalcul, finirCalcul + * @param {Executeur} parametres.executeur + * @param {import('./horloge.js').Horloge} parametres.horloge instant() mesure + * la durée d'un calcul + * @param {string} parametres.produitVersion + * @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider, + * estimer, derniereGeneration, generer, annuler, subscribe + */ +export function creerControleurGeneration({ seance, executeur, horloge, produitVersion }) { + // graine : la graine proposée ; base : celle de l'événement ouvert, null + // sans événement ; mesure : le dernier calcul abouti, null avant lui ; + // execution : celle en cours, null hors génération ; enCours vaut vrai du + // lancement à la fin de l'enregistrement. + let graine = 1; + let base = null; + let mesure = null; + let execution = null; + let observable = { enCours: false, fait: 0, total: 0, annulee: null }; + const abonnes = new Set(); + + function publier(changements) { + observable = { ...observable, ...changements }; + for (const fn of [...abonnes]) fn(observable); + } + + // Une ouverture : l'événement passe de null, ou d'une autre base, à une + // base ; la graine proposée repart de 1. + seance.subscribe((etat) => { + const suivante = etat.evenement?.base ?? null; + if (suivante !== null && suivante !== base) graine = 1; + base = suivante; + }); + + // Les réglages de génération de la charge ouverte, ceux d'une charge + // neuve sans événement. + const reglagesDeLaCharge = () => seance.etat().charge?.reglages.generation ?? GENERATION_PAR_DEFAUT; + + function annulerAvec(cause) { + if (execution === null) return; + const courante = execution; + publier({ annulee: cause }); + courante.annuler(); + } + + function estimer({ nombre, arret }) { + const mouvements = nombre * arret; + if (mesure === null) return { inconnue: true }; + const secondes = (mouvements * mesure.dureeMs) / (mesure.mouvements * 1000); + return { + secondes, + longue: secondes > SEUIL_GENERATION_LONGUE_S, + auDelaDuPlafond: secondes > PLAFOND_USAGE_S, + mesure: { ...mesure }, + }; + } + + // Longue : au-delà du seuil estimé, ou, sans mesure, au-delà de dix fois + // le réglage par défaut. + function estLongue(reglages, estimation) { + if (estimation.inconnue === true) return reglages.nombre * reglages.arret > MOUVEMENTS_SANS_MESURE; + return estimation.longue; + } + + // Les refus d'avant lancement : les réglages d'abord, puis l'ordre du + // contrat ; rend la configuration et son instance quand la génération peut + // partir. + function exigerLancement(reglages, confirmee) { + exigerReglages(reglages); + const etat = seance.etat(); + if (etat.evenement === null) throw new ErreurGeneration('AUCUN_EVENEMENT', { remede: null }); + if (etat.refus !== null) throw new ErreurGeneration(etat.refus.code, etat.refus.details); + if (execution !== null || observable.enCours) throw new ErreurGeneration('GENERATION_EN_COURS', { remede: null }); + const { charge } = etat; + if (!charge.participants.some(({ exclu }) => !exclu)) { + throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') }); + } + if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') }); + const configuration = configurationDepuisCharge(charge); + let instance; + try { + instance = normaliser(configuration); + } catch (erreur) { + if (erreur instanceof ErreurConfiguration) throw new ErreurGeneration(erreur.code, { ...erreur.details, remede: null }); + throw erreur; + } + const placesManquantes = nombrePlacesManquantes(instance); + if (placesManquantes > 0) { + throw new ErreurGeneration('PLACES_MANQUANTES', { placesManquantes, remede: versSection('tables') }); + } + const estimation = estimer(reglages); + if (!confirmee && estLongue(reglages, estimation)) { + throw new ErreurGeneration('CONFIRMATION_REQUISE', { estimation, remede: null }); + } + return { configuration, instance }; + } + + // Le calcul confié à l'exécuteur : ses avancements publiés, son annulation + // par la séance suivie ; rend les propositions et la durée du calcul. Une + // annulation lève ErreurAnnulee, même quand l'exécuteur a rendu un + // résultat entre-temps. + async function calculer(configuration, reglages) { + const debut = horloge.instant(); + execution = executeur.lancer(configuration, reglages); + const desabonner = execution.surAvancement((fait, total) => publier({ fait, total })); + const desannuler = seance.surAnnulation(() => annulerAvec('seance')); + try { + const propositions = await execution.resultat; + if (observable.annulee !== null) throw new ErreurAnnulee(); + return { propositions, dureeMs: horloge.instant() - debut }; + } finally { + desabonner(); + desannuler(); + execution = null; + } + } + + // Les propositions à enregistrer, par identifiant du moteur croissant : + // chacune sans violation des invariants, sinon GENERATION_INVARIANT_VIOLE, + // rang compris — la place de la proposition dans la suite des graines du + // lancement, qui n'est l'identifiant d'aucune proposition enregistrée ; puis sans + // celles dont le plan est celui d'une proposition présente sans dérive, ou + // d'une proposition déjà gardée de ce lancement. + function trier(propositions, configuration, instance) { + const ordonnees = [...propositions].sort((a, b) => a.id - b.id); + for (const proposition of ordonnees) { + const violations = verifierInvariants(instance, proposition.plan); + if (violations.length > 0) { + throw new ErreurGeneration('GENERATION_INVARIANT_VIOLE', { rang: proposition.id, violations, remede: null }); + } + } + const charge = seance.etat().charge; + const presents = new Set( + charge.propositions.filter((p) => derive(p, charge).length === 0).map((p) => JSON.stringify(planDepuisFichier(p))), + ); + const gardees = []; + for (const proposition of ordonnees) { + const plan = planRange(proposition, configuration, produitVersion); + if (presents.has(plan)) continue; + presents.add(plan); + gardees.push(proposition); + } + return { gardees, identiques: ordonnees.length - gardees.length }; + } + + // Les identifiants que l'enregistrement a donnés : ceux des propositions + // de la charge qui n'étaient pas avant le geste, et qui portent la graine, + // l'arrêt et l'historique d'une proposition gardée ; croissants. + function ajouteesDans(charge, avant, gardees) { + const cles = new Set(gardees.map(({ graine: g, arret, historique }) => `${g}|${arret}|${historique}`)); + return charge.propositions + .filter(({ id, graine: g, arret, historique }) => !avant.has(id) && cles.has(`${g}|${arret}|${historique}`)) + .map(({ id }) => id) + .sort((a, b) => a - b); + } + + async function generer(reglages, { confirmee = false } = {}) { + const { configuration, instance } = exigerLancement(reglages, confirmee); + graine = (reglages.graine + 1) % GRAINES; + publier({ enCours: true, fait: 0, total: reglages.nombre * reglages.arret, annulee: null }); + seance.debuterCalcul(); + try { + const { propositions, dureeMs } = await calculer(configuration, reglages); + mesure = { + dureeMs, + mouvements: reglages.nombre * reglages.arret, + participants: instance.N, + nombre: reglages.nombre, + arret: reglages.arret, + }; + const { gardees, identiques } = trier(propositions, configuration, instance); + if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs }; + const avant = new Set(seance.etat().charge.propositions.map(({ id }) => id)); + await seance.executer('enregistrerGeneration', { propositions: gardees, configuration, produitVersion }); + return { ajoutees: ajouteesDans(seance.etat().charge, avant, gardees), identiques, dureeMs }; + } finally { + seance.finirCalcul(); + publier({ enCours: false }); + } + } + + return { + /** La graine proposée : 1 à chaque ouverture, puis la dernière lancée plus un, modulo 2^32. */ + grainePropose: () => graine, + /** { graine, nombre, arret, historique } : la graine proposée, les trois autres de la charge. */ + reglagesProposes() { + const { nombre, arret, historique } = reglagesDeLaCharge(); + return { graine, nombre, arret, historique }; + }, + /** + * Les saisies des quatre champs, chacune lue par lireEntier (NON_ENTIER + * {saisie}), puis bornée à son domaine (HORS_DOMAINE {min, max}, max null + * sans borne) ; un historique non saisi — absent, null ou blanc — vaut + * celui de la charge. Rend { reglages, refus } : reglages null dès + * qu'un champ est refusé, refus dans l'ordre des champs. + */ + valider(saisies) { + const reglages = {}; + const refus = []; + for (const [champ, min, max] of CHAMPS) { + const saisie = saisies[champ]; + if (champ === 'historique' && (saisie === undefined || saisie === null || (typeof saisie === 'string' && saisie.trim() === ''))) { + reglages.historique = reglagesDeLaCharge().historique; + continue; + } + const lu = lireEntier(saisie); + if (lu.refus !== undefined) { + refus.push({ champ, code: lu.refus.code, details: lu.refus.details }); + } else if (lu.valeur < min || (max !== null && lu.valeur > max)) { + refus.push({ champ, code: 'HORS_DOMAINE', details: { min, max, remede: null } }); + } else { + reglages[champ] = lu.valeur; + } + } + return { reglages: refus.length === 0 ? reglages : null, refus }; + }, + /** + * L'estimation des réglages, proportionnelle aux mouvements de la + * dernière mesure : { inconnue: true } sans elle, sinon { secondes, + * longue, auDelaDuPlafond, mesure }. + */ + estimer, + /** La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null avant lui. */ + derniereGeneration: () => (mesure === null ? null : { ...mesure }), + /** + * Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }. + * Refuse avant tout lancement, par ErreurGeneration, dans cet ordre : + * REGLAGE_HORS_DOMAINE {reglage} ; AUCUN_EVENEMENT ; le refus courant de + * la séance ; GENERATION_EN_COURS ; + * AUCUN_PARTICIPANT ; AUCUNE_TABLE ; le refus de normaliser ; + * PLACES_MANQUANTES ; CONFIRMATION_REQUISE {estimation}, que + * confirmee lève. Après la recherche : GENERATION_INVARIANT_VIOLE + * {rang, violations}, puis ce + * qu'executer lève, tel quel. Annulée : ErreurAnnulee. + */ + generer, + /** Annule la génération en cours, sans résultat partiel ; annulee vaut 'commande'. */ + annuler: () => annulerAvec('commande'), + /** + * Le contrat des magasins de Svelte sur { enCours, fait, total, annulee } : + * annulee, la cause de la dernière annulation, 'commande' ou 'seance', + * null dès le lancement suivant. + */ + subscribe(fn) { + abonnes.add(fn); + fn(observable); + return () => { + abonnes.delete(fn); + }; + }, + }; +} diff --git a/src/application/generation.test.js b/src/application/generation.test.js new file mode 100644 index 0000000..0912ac5 --- /dev/null +++ b/src/application/generation.test.js @@ -0,0 +1,1062 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la génération (§ 2.1, § 5.7, § 5.8, § 5.9, § 5.10, § 8.2, +// § 8.3, § 8.4, § 8.6, § 8.9, § 14.12, § 15.4) : le contrôleur sur une +// séance montée sur le système de fichiers d'épreuve, l'horloge pilotée et, +// sauf mention, l'exécuteur direct ; un exécuteur que l'épreuve résout à la +// main tient une génération ouverte. Les démonstrations livrées fournissent +// les configurations ; les personnes ajoutées portent des noms inventés, et +// la version vient du module engendré. +import assert from 'node:assert/strict'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { describe, test } from '../../test/lanceur.js'; +import { texteLivre } from '../demo/livrees.js'; +import { normaliser } from '../moteur/configuration.js'; +import { ErreurAnnulee as ErreurAnnuleeDuMoteur, ErreurConfiguration } from '../moteur/erreurs.js'; +import { rechercher, regenerer } from '../moteur/recherche.js'; +import { verifierInvariants } from '../moteur/verification.js'; +import { serialiser } from '../stockage/canonique.js'; +import { creer as creerDansLeDepot } from '../stockage/depot.js'; +import { FORMAT, analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js'; +import { lireJournal } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; +import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js'; +import { VERSION } from '../version.genere.js'; +import { ErreurCommande } from './erreurs.js'; +import { + ErreurAnnulee, + ErreurGeneration, + PLAFOND_USAGE_S, + SEUIL_GENERATION_LONGUE_S, + creerControleurGeneration, + creerExecuteurDirect, + versErreurGeneration, +} from './generation.js'; +import { creerHorlogePilotee } from './horloge.js'; +import { libelle } from './libelles.js'; +import { INACTIVITE_MS, creerSeance } from './seance.js'; + +const V1 = VERSION.affichee; +const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' }; +const NOM = 'Soirée des Merles'; +const AUTRE = 'Soirée des Pervenches'; +const MAX_GRAINE = 2 ** 32 - 1; + +const VERS_PARTICIPANTS = { geste: 'ouvrirSection', section: 'participants' }; +const VERS_TABLES = { geste: 'ouvrirSection', section: 'tables' }; + +// --- Les charges d'épreuve ---------------------------------------------------- + +// La charge d'une démonstration livrée, renommée : sa base devient nom. +function chargeDemo(cle, nom = NOM) { + const { charge } = analyser(texteLivre(cle)); + charge.evenement.nom = nom; + charge.evenement.id = `evt-${cle}-${nom.length}`; + return charge; +} + +const PRENOMS = ['Iris', 'Théo', 'Ondine', 'Anouk', 'Basile', 'Capucine', 'Gaspard', 'Hortense']; +const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Mélèze', 'Quenouille']; + +// Une charge de n personnes, dont les exclus, et des tables de sieges places. +function chargeDe({ n = 0, exclus = [], tables = 0, sieges = 3, tours = 2, nom = NOM } = {}) { + const charge = creerCharge({ id: `evt-${nom.length}-${n}`, nom, siegesParDefaut: sieges, tours }); + charge.participants = Array.from({ length: n }, (_, rang) => ({ + id: rang + 1, + nom: `${NOMS[rang % NOMS.length]}${rang >= NOMS.length ? ` ${rang}` : ''}`, + prenom: PRENOMS[rang % PRENOMS.length], + appartenance: rang % 2 === 0 ? 'Club des Merles' : 'Grisaille', + courriel: null, + titrePressenti: null, + notes: null, + exclu: exclus.includes(rang + 1), + })); + charge.tables = Array.from({ length: tables }, (_, rang) => ({ + id: rang + 1, + numero: rang + 1, + sieges: null, + forme: 'ronde', + position: { x: 300 * rang, y: 0 }, + })); + charge.prochainsIds = { participant: n + 1, table: tables + 1, proposition: 1 }; + return charge; +} + +// --- Les exécuteurs d'épreuve --------------------------------------------------- + +// Un exécuteur qui note chaque lancement avant de le confier à interne. +function espionner(interne = creerExecuteurDirect()) { + const lancements = []; + return { + lancements, + lancer(configuration, reglages) { + lancements.push({ configuration, reglages }); + return interne.lancer(configuration, reglages); + }, + }; +} + +// Un exécuteur que l'épreuve résout à la main : chaque exécution porte +// resoudre, rejeter, avancer, et compte ses annulations. Annulée, elle +// rejette ErreurAnnulee, comme le travailleur qu'on termine ; differer +// vrai, elle ne fait que compter, et l'épreuve rejette quand elle veut, +// comme l'exécuteur direct qui n'arrête qu'à la lecture suivante du signal. +function executeurManuel({ differer = false } = {}) { + const executions = []; + return { + executions, + 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, + annulations: 0, + resultat, + resoudre, + rejeter, + // Les propositions du moteur pour ce lancement, rendues telles quelles. + resoudreParLeMoteur: () => resoudre(rechercher(configuration, reglages)), + avancer: (fait, total) => { + for (const fn of [...abonnes]) fn(fait, total); + }, + surAvancement(fn) { + abonnes.add(fn); + return () => abonnes.delete(fn); + }, + annuler() { + execution.annulations += 1; + if (!differer) rejeter(new ErreurAnnulee()); + }, + }; + executions.push(execution); + return execution; + }, + }; +} + +// Un exécuteur qui calcule par le moteur, puis transforme la liste rendue. +function executeurTransforme(transformer) { + const direct = creerExecuteurDirect(); + return { + lancer(configuration, reglages) { + const execution = direct.lancer(configuration, reglages); + return { ...execution, resultat: execution.resultat.then((propositions) => transformer(propositions, configuration)) }; + }, + }; +} + +// --- Le montage --------------------------------------------------------------- + +async function deposer(fs, charge) { + await creerDansLeDepot(fs, DOC, charge, { + libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }), + nomJalon: libelle('JALON_CREATION'), + horodatage: '2031-05-17T07:00:00-04:00', + produitVersion: V1, + }); +} + +// Une séance sur les charges données, la première ouverte — en écriture +// sauf ecriture faux — et un contrôleur sur un exécuteur espionné. +async function monter({ charges, executeur = creerExecuteurDirect(), ecriture = true, fs = creerFichiersSimules(), disque = true } = {}) { + fs.deposerDossier('documents'); + if (disque) for (const charge of charges) await deposer(fs, charge); + const horloge = creerHorlogePilotee(); + let tirage = 0; + const seance = creerSeance({ + fs, + racine: DOC, + horloge, + identifiant: () => { + tirage += 1; + return `seance-${tirage}`; + }, + produitVersion: V1, + planifier: horloge.planifier, + annuler: horloge.annuler, + }); + const espion = espionner(executeur); + const controleur = creerControleurGeneration({ seance, executeur: espion, horloge, produitVersion: V1 }); + const base = charges[0].evenement.nom; + await seance.ouvrir(base); + if (ecriture) await seance.passerEnEcriture(); + const journal = () => lireJournal(fs.contenu('documents', `${base}${SUFFIXES.journal}`)); + const etatDisque = () => fs.contenu('documents', `${base}${SUFFIXES.etat}`); + return { fs, seance, horloge, controleur, espion, journal, etatDisque, charge: () => seance.etat().charge }; +} + +// Le refus attendu d'une promesse : une ErreurGeneration de ce code, aux +// détails donnés, dont le texte se compose. +async function refuse(promesse, code, details) { + let leve = null; + try { + await promesse; + } catch (erreur) { + leve = erreur; + } + assert.ok(leve instanceof ErreurGeneration, `ErreurGeneration ${code} attendue, reçu ${leve}`); + assert.equal(leve.code, code, leve.message); + if (details !== undefined) assert.deepEqual(leve.details, details); + assert.equal(typeof libelle(leve.code, leve.details), 'string'); + return leve; +} + +// La promesse rejette, et rend ce qu'elle lève. +async function rejet(promesse) { + try { + await promesse; + } catch (erreur) { + return erreur; + } + assert.fail('rejet attendu'); + return null; +} + +// Le plan d'une proposition du moteur, rangé comme planDepuisFichier le +// rend : la même mise en forme que celle d'une proposition enregistrée. +const planRange = (proposition, configuration) => + planDepuisFichier(versFichier(proposition, configuration, { produitVersion: V1, attribuerSieges: false, decalage: 0 })); + +const ids = (charge) => charge.propositions.map(({ id }) => id); +const COURT = { graine: 1, nombre: 1, arret: 50, historique: 1000 }; + +// --- valider (§ 5.7) ------------------------------------------------------------ + +describe('génération : la saisie des réglages (§ 5.7)', () => { + test('une graine négative, décimale ou en lettres refuse NON_ENTIER sur son champ', async () => { + const { controleur } = await monter({ charges: [chargeDemo('petite')] }); + for (const saisie of ['-1', '\u{2212}1', '1,5', 'x', '']) { + const { reglages, refus } = controleur.valider({ graine: saisie, nombre: '5', arret: '200000', historique: '1000' }); + assert.equal(reglages, null, saisie); + assert.deepEqual(refus, [{ champ: 'graine', code: 'NON_ENTIER', details: { saisie, remede: null } }], saisie); + assert.ok(libelle(refus[0].code, refus[0].details).length > 0); + } + }); + + test('une graine de 2^32 sort du domaine ; 0 et 2^32 - 1 sont admises', async () => { + const { controleur } = await monter({ charges: [chargeDemo('petite')] }); + const hors = controleur.valider({ graine: '4294967296', nombre: '5', arret: '200000', historique: '1000' }); + assert.deepEqual(hors.refus, [{ champ: 'graine', code: 'HORS_DOMAINE', details: { min: 0, max: MAX_GRAINE, remede: null } }]); + assert.match(libelle('HORS_DOMAINE', hors.refus[0].details), /4\u{202F}294\u{202F}967\u{202F}295/u); + for (const graine of ['0', '4294967295', ' 17 ']) { + const { reglages, refus } = controleur.valider({ graine, nombre: '5', arret: '200000', historique: '1000' }); + assert.deepEqual(refus, [], graine); + assert.deepEqual(reglages, { graine: Number(graine), nombre: 5, arret: 200000, historique: 1000 }); + } + }); + + test('nombre, arrêt et historique valent au moins 1 ; chaque refus se pose sur son champ, dans leur ordre', async () => { + const { controleur } = await monter({ charges: [chargeDemo('petite')] }); + const { reglages, refus } = controleur.valider({ graine: 'x', nombre: '0', arret: '0', historique: '0' }); + assert.equal(reglages, null); + const unAuMoins = { min: 1, max: null, remede: null }; + assert.deepEqual(refus, [ + { champ: 'graine', code: 'NON_ENTIER', details: { saisie: 'x', remede: null } }, + { champ: 'nombre', code: 'HORS_DOMAINE', details: unAuMoins }, + { champ: 'arret', code: 'HORS_DOMAINE', details: unAuMoins }, + { champ: 'historique', code: 'HORS_DOMAINE', details: unAuMoins }, + ]); + assert.doesNotMatch(libelle('HORS_DOMAINE', unAuMoins), /null|undefined/); + }); + + test("un historique non saisi vaut celui de la charge : 1 000 sur une charge neuve, 500 après modifierReglage ; les réglages proposés la reprennent", async () => { + const montage = await monter({ charges: [chargeDe({ n: 4, tables: 2, sieges: 2, tours: 2 })] }); + const { controleur, seance } = montage; + for (const historique of [undefined, null, '', ' ']) { + assert.equal(controleur.valider({ graine: '3', nombre: '2', arret: '40', historique }).reglages.historique, 1000); + } + assert.deepEqual(controleur.reglagesProposes(), { graine: 1, nombre: 5, arret: 200000, historique: 1000 }); + await seance.executer('modifierReglage', { cle: 'generation.historique', valeur: 500 }); + await seance.executer('modifierReglage', { cle: 'generation.nombre', valeur: 3 }); + await seance.executer('modifierReglage', { cle: 'generation.arret', valeur: 777 }); + assert.equal(controleur.valider({ graine: '3', nombre: '2', arret: '40' }).reglages.historique, 500); + assert.deepEqual(controleur.reglagesProposes(), { graine: 1, nombre: 3, arret: 777, historique: 500 }); + }); +}); + +// --- La graine proposée (§ 5.7) ------------------------------------------------- + +describe("génération : l'exécuteur direct refuse un réglage comme le travailleur", () => { + test('un réglage hors de son domaine rejette REGLAGE_HORS_DOMAINE, qui le nomme, sans le message du moteur', async () => { + const { charge } = analyser(texteLivre('petite')); + const configuration = configurationDepuisCharge(charge); + const { resultat } = creerExecuteurDirect().lancer(configuration, { graine: 2 ** 32, nombre: 1, arret: 10 }); + await refuse(resultat, 'REGLAGE_HORS_DOMAINE', { reglage: 'graine', remede: null }); + }); +}); + +describe('génération : la graine proposée (§ 5.7)', () => { + test("1 à l'ouverture, la dernière graine lancée plus un, 1 à l'ouverture d'un autre événement, 0 après 2^32 - 1", async () => { + const montage = await monter({ charges: [chargeDemo('petite'), chargeDemo('petite', AUTRE)] }); + const { controleur, seance } = montage; + assert.equal(controleur.grainePropose(), 1); + await controleur.generer({ ...COURT, graine: 7 }); + assert.equal(controleur.grainePropose(), 8); + assert.equal(controleur.reglagesProposes().graine, 8); + await seance.ouvrir(AUTRE); + assert.equal(controleur.grainePropose(), 1); + await seance.passerEnEcriture(); + await controleur.generer({ ...COURT, graine: MAX_GRAINE }); + assert.equal(controleur.grainePropose(), 0); + await seance.fermer(); + await seance.ouvrir(AUTRE); + assert.equal(controleur.grainePropose(), 1); + }); +}); + +// --- Les refus d'avant lancement (§ 2.1, § 5.9, § 8.4, § 8.6, § 15.4) ---------- + +// Le refus attendu de generer, sans écriture ni lancement. +async function refuseSansRien(montage, code, details, reglages = COURT) { + const avant = montage.fs.compteurs.ecritures; + const leve = await refuse(montage.controleur.generer(reglages), code, details); + assert.equal(montage.fs.compteurs.ecritures, avant, `${code} : une écriture`); + assert.equal(montage.espion.lancements.length, 0, `${code} : lancé`); + return leve; +} + +describe("génération : les refus d'avant lancement (§ 2.1, § 5.9, § 8.4, § 8.6, § 15.4)", () => { + test("un fichier d'un format plus récent refuse LECTURE_SEULE {raison: 'FORMAT'}", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + const texte = serialiser(chargeDemo('petite'), { revision: 1, produitVersion: V1 }); + fs.deposer('documents', `${NOM}${SUFFIXES.etat}`, texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`)); + const montage = await monter({ charges: [chargeDemo('petite')], fs, disque: false, ecriture: false }); + await refuseSansRien(montage, 'LECTURE_SEULE', { raison: 'FORMAT', remede: null }); + }); + + test("un plan bloqué refuse LECTURE_SEULE {raison: 'BLOQUE'}, jamais MODE_LECTURE", async () => { + const charge = chargeDemo('petite'); + charge.evenement.etat = 'bloque'; + const montage = await monter({ charges: [charge], ecriture: false }); + await refuseSansRien(montage, 'LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } }); + }); + + test('en lecture, MODE_LECTURE ; sans événement, AUCUN_EVENEMENT', async () => { + const montage = await monter({ charges: [chargeDemo('petite')], ecriture: false }); + await refuseSansRien(montage, 'MODE_LECTURE', { remede: { geste: 'modifier' } }); + await montage.seance.fermer(); + await refuseSansRien(montage, 'AUCUN_EVENEMENT', { remede: null }); + }); + + test('un support rompu refuse SUPPORT_ROMPU, remède compris', async () => { + const montage = await monter({ charges: [chargeDemo('petite')] }); + montage.fs.pannes.ecriture('documents'); + await assert.rejects(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } })); + assert.notEqual(montage.seance.etat().supportRompu, null); + const leve = await refuseSansRien(montage, 'SUPPORT_ROMPU'); + assert.deepEqual(leve.details, montage.seance.etat().refus.details); + assert.equal(leve.details.remede.geste, 'ecrireAilleurs'); + }); + + test('zéro participant non exclu, AUCUN_PARTICIPANT ; zéro table, AUCUNE_TABLE ; chacun nomme sa section', async () => { + const sansPersonne = await monter({ charges: [chargeDe({ n: 3, exclus: [1, 2, 3], tables: 2 })] }); + await refuseSansRien(sansPersonne, 'AUCUN_PARTICIPANT', { remede: VERS_PARTICIPANTS }); + const sansTable = await monter({ charges: [chargeDe({ n: 3, tables: 0 })] }); + await refuseSansRien(sansTable, 'AUCUNE_TABLE', { remede: VERS_TABLES }); + // Le moteur accepte N = 0 et rend des plans vides : le refus est celui du contrôleur. + const vide = configurationDepuisCharge(chargeDe({ n: 3, exclus: [1, 2, 3], tables: 2 })); + assert.deepEqual(rechercher(vide, COURT)[0].plan.tours, [[[], []], [[], []]]); + }); + + test('treize personnes pour quatre tables de 3 : PLACES_MANQUANTES {placesManquantes: 1}, vers les tables', async () => { + const montage = await monter({ charges: [chargeDe({ n: 13, tables: 4, sieges: 3 })] }); + await refuseSansRien(montage, 'PLACES_MANQUANTES', { placesManquantes: 1, remede: VERS_TABLES }); + }); + + test('une configuration que normaliser refuse rend son code et ses détails', async () => { + const charge = chargeDe({ n: 4, tables: 2, sieges: 2 }); + charge.reservations = [1, 2, 3].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const montage = await monter({ charges: [charge] }); + let attendu = null; + try { + normaliser(configurationDepuisCharge(charge)); + } catch (erreur) { + attendu = erreur; + } + assert.ok(attendu instanceof ErreurConfiguration); + await refuseSansRien(montage, attendu.code, { ...attendu.details, remede: null }); + }); + + test("plusieurs causes à la fois : le refus rendu est le premier dans l'ordre du contrat", async () => { + // Un plan bloqué sans aucune table : la séance d'abord. + const bloque = chargeDe({ n: 3, tables: 0 }); + bloque.evenement.etat = 'bloque'; + await refuseSansRien(await monter({ charges: [bloque], ecriture: false }), 'LECTURE_SEULE', { + raison: 'BLOQUE', + remede: { geste: 'debloquer' }, + }); + // En lecture, avec des places manquantes : la séance d'abord. + const lecture = await monter({ charges: [chargeDe({ n: 13, tables: 4, sieges: 3 })], ecriture: false }); + await refuseSansRien(lecture, 'MODE_LECTURE', { remede: { geste: 'modifier' } }); + // Personne à placer et aucune table : les participants d'abord. + const vide = await monter({ charges: [chargeDe({ n: 3, exclus: [1, 2, 3], tables: 0 })] }); + await refuseSansRien(vide, 'AUCUN_PARTICIPANT', { remede: VERS_PARTICIPANTS }); + // Une réservation de trop et des places manquantes : normaliser d'abord. + const surreservee = chargeDe({ n: 13, tables: 4, sieges: 3 }); + surreservee.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const leve = await refuseSansRien(await monter({ charges: [surreservee] }), 'SURRESERVATION'); + assert.equal(leve.details.remede, null); + // Des places manquantes et une génération longue non confirmée : le manque d'abord. + const longue = { graine: 1, nombre: 5, arret: 2000001, historique: 1000 }; + await refuseSansRien(await monter({ charges: [chargeDe({ n: 13, tables: 4, sieges: 3 })] }), 'PLACES_MANQUANTES', { + placesManquantes: 1, + remede: VERS_TABLES, + }, longue); + }); + + test('une génération en cours passe avant les refus de la charge, après ceux de la séance', async () => { + const manuel = executeurManuel({ differer: true }); + const montage = await monter({ charges: [chargeDe({ n: 3, tables: 2, sieges: 2 })], executeur: manuel }); + const premiere = montage.controleur.generer(COURT); + await Promise.resolve(); + for (const id of [1, 2, 3]) await montage.seance.executer('exclureParticipant', { id }); + await refuse(montage.controleur.generer(COURT), 'GENERATION_EN_COURS', { remede: null }); + // Fermée, la séance annule ; tant que l'exécution n'a pas rendu, l'absence d'événement l'emporte. + await montage.seance.fermer(); + assert.equal(manuel.executions[0].annulations, 1); + await refuse(montage.controleur.generer(COURT), 'AUCUN_EVENEMENT', { remede: null }); + assert.equal(montage.espion.lancements.length, 1); + manuel.executions[0].rejeter(new ErreurAnnulee()); + assert.ok((await rejet(premiere)) instanceof ErreurAnnulee); + }); + + test('un réglage hors de son domaine refuse REGLAGE_HORS_DOMAINE, qui le nomme, avant tout autre refus', async () => { + const montage = await monter({ charges: [chargeDemo('petite')] }); + const cas = [ + [{ ...COURT, graine: 2 ** 32 }, 'graine', /Graine/], + [{ ...COURT, arret: 1.5 }, 'arret', /Mouvements par proposition/], + [{ ...COURT, historique: 0 }, 'historique', /Historique d'acceptation/], + [{ ...COURT, nombre: 0 }, 'nombre', /Propositions par génération/], + ]; + for (const [reglages, reglage, nom] of cas) { + const leve = await refuseSansRien(montage, 'REGLAGE_HORS_DOMAINE', { reglage, remede: null }, reglages); + assert.match(libelle(leve.code, leve.details), nom); + } + assert.equal(montage.controleur.grainePropose(), 1); + // Un historique absent vaut celui de rechercher : la génération part. + const { graine, nombre, arret } = COURT; + assert.deepEqual((await montage.controleur.generer({ graine, nombre, arret })).ajoutees, [1]); + // En lecture, le réglage d'abord. + const lecture = await monter({ charges: [chargeDemo('petite')], ecriture: false }); + await refuseSansRien(lecture, 'REGLAGE_HORS_DOMAINE', { reglage: 'nombre', remede: null }, { ...COURT, nombre: 0 }); + }); + + test('une génération en cours sur un plan passé en lecture : MODE_LECTURE, la séance avant GENERATION_EN_COURS', async () => { + const manuel = executeurManuel({ differer: true }); + const montage = await monter({ charges: [chargeDemo('petite')], executeur: manuel }); + const premiere = montage.controleur.generer(COURT); + await Promise.resolve(); + await montage.seance.passerEnLecture(); + assert.equal(manuel.executions[0].annulations, 1); + await refuse(montage.controleur.generer(COURT), 'MODE_LECTURE', { remede: { geste: 'modifier' } }); + assert.equal(montage.espion.lancements.length, 1); + manuel.executions[0].rejeter(new ErreurAnnulee()); + assert.ok((await rejet(premiere)) instanceof ErreurAnnulee); + }); + + test('une seconde génération pendant la première refuse GENERATION_EN_COURS', async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite')], executeur: manuel }); + const premiere = montage.controleur.generer(COURT); + await Promise.resolve(); + assert.equal(montage.espion.lancements.length, 1); + await refuse(montage.controleur.generer(COURT), 'GENERATION_EN_COURS', { remede: null }); + assert.equal(montage.espion.lancements.length, 1); + manuel.executions[0].resoudreParLeMoteur(); + await premiere; + }); +}); + +// --- Un compte, jamais une durée (§ 5.10, § 14.7) ---------------------------------- + +describe('génération : un compte, jamais une durée (§ 5.10, § 14.7)', () => { + for (const pas of [1, 1000]) { + test(`l'horloge avancée de ${pas} ms à chaque avancement ne change aucun plan`, async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const reglages = { graine: 11, nombre: 2, arret: 5000, historique: 1000 }; + let avancements = 0; + let dernier = 0; + montage.controleur.subscribe(({ fait }) => { + if (fait > dernier) { + avancements += 1; + dernier = fait; + montage.horloge.avancer(pas); + } + }); + const bilan = await montage.controleur.generer(reglages); + assert.equal(avancements, 10); + assert.deepEqual(bilan.ajoutees, [1, 2]); + assert.equal(bilan.dureeMs, 10 * pas); + const configuration = configurationDepuisCharge(montage.charge()); + const attendues = rechercher(configuration, reglages); + assert.equal(attendues.length, 2); + montage.charge().propositions.forEach((proposition, rang) => { + assert.deepEqual(planDepuisFichier(proposition), planRange(attendues[rang], configuration)); + const { graine, arret, historique } = attendues[rang]; + assert.deepEqual([proposition.graine, proposition.arret, proposition.historique], [graine, arret, historique]); + }); + }); + } +}); + +// --- Les identifiants (§ 5.7, § 8.9, § 19.4) ------------------------------------------ + +describe('génération : les identifiants (§ 5.7, § 8.9)', () => { + const PREMIERE = { graine: 1, nombre: 3, arret: 200, historique: 1000 }; + const SECONDE = { graine: 2, nombre: 2, arret: 200, historique: 1000 }; + + test("les générations s'accumulent sous des identifiants qui suivent le compteur", async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const premiere = await montage.controleur.generer(PREMIERE); + assert.deepEqual([premiere.ajoutees, premiere.identiques], [[1, 2, 3], 0]); + assert.equal(montage.charge().prochainsIds.proposition, 4); + const avant = structuredClone(montage.charge().propositions); + const seconde = await montage.controleur.generer(SECONDE); + assert.deepEqual(seconde.ajoutees, [4, 5]); + assert.deepEqual(montage.charge().propositions.slice(0, 3), avant); + assert.deepEqual(ids(montage.charge()), [1, 2, 3, 4, 5]); + }); + + test("un exécuteur qui rend les propositions à rebours donne les mêmes identifiants et la même charge", async () => { + const droit = await monter({ charges: [chargeDemo('petite-conflit')] }); + await droit.controleur.generer(PREMIERE); + const rebours = await monter({ + charges: [chargeDemo('petite-conflit')], + executeur: executeurTransforme((propositions) => [...propositions].reverse()), + }); + const bilan = await rebours.controleur.generer(PREMIERE); + assert.deepEqual(bilan.ajoutees, [1, 2, 3]); + assert.deepEqual(rebours.charge().propositions, droit.charge().propositions); + }); + + test("relancer à l'identique n'ajoute rien et n'écrit rien ; un identifiant effacé ne revient jamais", async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const { controleur, seance, fs } = montage; + await controleur.generer(PREMIERE); + await controleur.generer(SECONDE); + const ecritures = fs.compteurs.ecritures; + const jalons = montage.journal().jalons.length; + const identique = await controleur.generer(PREMIERE); + assert.deepEqual([identique.ajoutees, identique.identiques], [[], 3]); + assert.equal(fs.compteurs.ecritures, ecritures); + assert.equal(montage.journal().jalons.length, jalons); + await seance.executer('retenirProposition', { id: 2 }); + await seance.executer('effacerPropositions', {}); + assert.deepEqual(ids(montage.charge()), [2]); + const relance = await controleur.generer(PREMIERE); + assert.deepEqual([relance.ajoutees, relance.identiques], [[6, 8], 1]); + assert.deepEqual(ids(montage.charge()), [2, 6, 8]); + assert.equal(montage.charge().prochainsIds.proposition, 9); + }); + + test("un compteur à 2^31 - 2 et une génération de 2 : COMPTEUR_SATURE de la commande, rien d'écrit, aucun jalon", async () => { + const charge = chargeDemo('petite-conflit'); + charge.prochainsIds.proposition = 2 ** 31 - 2; + const montage = await monter({ charges: [charge] }); + const ecritures = montage.fs.compteurs.ecritures; + const jalons = montage.journal().jalons.length; + const leve = await rejet(montage.controleur.generer({ ...SECONDE })); + assert.ok(leve instanceof ErreurCommande, String(leve)); + assert.equal(leve.code, 'COMPTEUR_SATURE'); + assert.equal(leve.details.compteur, 'proposition'); + assert.equal(montage.fs.compteurs.ecritures, ecritures); + assert.equal(montage.journal().jalons.length, jalons); + }); + + test("le double d'une proposition en dérive s'ajoute : seule une présente sans dérive rend un plan identique", async () => { + const montage = await monter({ charges: [chargeDe({ n: 6, tables: 1, sieges: 6, tours: 1 })] }); + const reglages = { graine: 1, nombre: 1, arret: 10, historique: 10 }; + assert.deepEqual((await montage.controleur.generer(reglages)).ajoutees, [1]); + await montage.seance.executer('changerPlacesTable', { id: 1, sieges: '7', origine: 'champ' }); + const [premiere] = montage.charge().propositions; + assert.deepEqual(derive(premiere, montage.charge()).map(({ code }) => code), ['CAPACITE_CHANGEE']); + const bilan = await montage.controleur.generer(reglages); + assert.deepEqual([bilan.ajoutees, bilan.identiques], [[2], 0]); + const [, seconde] = montage.charge().propositions; + assert.deepEqual(planDepuisFichier(seconde).tours, planDepuisFichier(premiere).tours); + assert.deepEqual(derive(seconde, montage.charge()), []); + }); + + test("deux propositions d'un même lancement au même plan n'en font qu'une", async () => { + const montage = await monter({ charges: [chargeDe({ n: 6, tables: 1, sieges: 6, tours: 1 })] }); + const bilan = await montage.controleur.generer({ graine: 5, nombre: 3, arret: 10, historique: 10 }); + assert.deepEqual([bilan.ajoutees, bilan.identiques], [[1], 2]); + }); +}); + +// --- Les champs d'une proposition (§ 5.7, § 8.9) -------------------------------------- + +describe("génération : les champs d'une proposition (§ 5.7, § 8.9)", () => { + test("chaque proposition porte la graine dérivée, l'arrêt et l'historique de rechercher, et se régénère", async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const reglages = { graine: 41, nombre: 3, arret: 300, historique: 50 }; + await montage.controleur.generer(reglages); + const charge = montage.charge(); + const configuration = configurationDepuisCharge(charge); + const moteur = rechercher(configuration, reglages); + assert.equal(charge.propositions.length, 3); + charge.propositions.forEach((proposition, rang) => { + assert.deepEqual( + [proposition.graine, proposition.arret, proposition.historique, proposition.produitVersion], + [moteur[rang].graine, 300, 50, V1], + ); + assert.deepEqual(planRange({ ...moteur[rang], plan: regenerer(configuration, proposition) }, configuration), planDepuisFichier(proposition)); + }); + }); +}); + +// --- Une entrée, le journal d'abord, les jalons (§ 8.2, § 8.3, § 8.8) --------------- + +describe("génération : une entrée, le journal d'abord, les jalons (§ 8.2, § 8.3, § 8.8)", () => { + test("une génération est une seule entrée ; sur un brouillon, deux jalons, sur un plan proposé, le premier seul", async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const { seance, controleur } = montage; + const executer = seance.executer; + const appels = []; + seance.executer = (nom, args) => { + appels.push(nom); + return executer(nom, args); + }; + const avant = montage.journal(); + await controleur.generer({ graine: 3, nombre: 2, arret: 100 }); + assert.deepEqual(appels, ['enregistrerGeneration']); + const apres = montage.journal(); + assert.equal(apres.entrees.length, avant.entrees.length + 1); + const revision = apres.entrees.at(-1).revision; + assert.equal(analyser(montage.etatDisque()).entete.revision, revision); + assert.deepEqual(apres.jalons.slice(avant.jalons.length).map(({ revision: r, nom }) => [r, nom]), [ + [revision - 1, libelle('JALON_AVANT_GENERATION')], + [revision, libelle('JALON_ETAT', { etat: 'propose' })], + ]); + await controleur.generer({ graine: 9, nombre: 1, arret: 100 }); + const encore = montage.journal(); + const derniere = encore.entrees.at(-1).revision; + assert.deepEqual(encore.jalons.slice(apres.jalons.length).map(({ revision: r, nom }) => [r, nom]), [ + [derniere - 1, libelle('JALON_AVANT_GENERATION')], + ]); + }); + + test("coupé après chaque écriture, le disque ne porte jamais un état en avance sur le journal", async () => { + const reglages = { graine: 3, nombre: 2, arret: 100, historique: 1000 }; + const temoin = await monter({ charges: [chargeDemo('petite-conflit')] }); + const avant = temoin.fs.compteurs.ecritures; + await temoin.controleur.generer(reglages); + const total = temoin.fs.compteurs.ecritures - avant; + assert.ok(total >= 3, `${total} écritures`); + let journalEnAvance = 0; + for (let k = 0; k <= total; k += 1) { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + montage.fs.pannes.couperApres(k); + try { + await montage.controleur.generer(reglages); + } catch { + // La coupure tue le processus au milieu de la génération. + } + const revisionEtat = analyser(montage.etatDisque()).entete.revision; + const revisionJournal = montage.journal().entrees.at(-1).revision; + assert.ok(revisionEtat <= revisionJournal, `coupure ${k} : état ${revisionEtat}, journal ${revisionJournal}`); + if (revisionJournal === revisionEtat + 1) journalEnAvance += 1; + } + assert.ok(journalEnAvance >= 1, 'aucune coupure ne laisse le journal une entrée en avance'); + }); + + test("annulée, une génération ne laisse ni entrée, ni jalon, ni écriture", async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const ecritures = montage.fs.compteurs.ecritures; + const journal = montage.journal(); + const enCours = montage.controleur.generer(COURT); + await Promise.resolve(); + montage.controleur.annuler(); + assert.ok((await rejet(enCours)) instanceof ErreurAnnulee); + assert.equal(montage.fs.compteurs.ecritures, ecritures); + assert.deepEqual(montage.journal(), journal); + }); +}); + +// --- Les invariants (§ 14.12) -------------------------------------------------------- + +describe('génération : les invariants (§ 14.12)', () => { + test("un exécuteur qui déplace un ancré fait refuser GENERATION_INVARIANT_VIOLE, qui nomme le rang et la violation, sans rien écrire", async () => { + const charge = chargeDemo('petite-conflit'); + charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }]; + // Au premier tour, la personne 1 change de liste avec un occupant d'une autre table. + const deplacer = (propositions) => + propositions.map((proposition) => { + const tours = structuredClone(proposition.plan.tours); + const [premiere, seconde] = tours[0]; + const rang = premiere.indexOf(1); + [premiere[rang], seconde[0]] = [seconde[0], premiere[rang]]; + return { ...proposition, plan: { ...proposition.plan, tours } }; + }); + const montage = await monter({ charges: [charge], executeur: executeurTransforme(deplacer) }); + const ecritures = montage.fs.compteurs.ecritures; + const leve = await refuse(montage.controleur.generer({ graine: 2, nombre: 2, arret: 100 }), 'GENERATION_INVARIANT_VIOLE'); + assert.deepEqual(Object.keys(leve.details), ['rang', 'violations', 'remede']); + assert.equal(leve.details.rang, 1); + assert.equal(leve.details.remede, null); + // Le texte dit le rang dans le lancement, ni un retenu, ni une dérive, + // ni une proposition enregistrée sous ce numéro. + const texte = libelle(leve.code, leve.details); + assert.match(texte, /rang 1\b/); + assert.match(texte, /la personne n° 1, réservée à la table d'identifiant 1, est placée ailleurs/); + assert.doesNotMatch(texte, /retenu|dérive|proposition n°/); + assert.deepEqual( + leve.details.violations.filter(({ code }) => code === 'RESERVATION'), + [{ code: 'RESERVATION', participant: 1, table: 1, tour: 1 }], + ); + assert.equal(montage.fs.compteurs.ecritures, ecritures); + assert.deepEqual(montage.charge().propositions, []); + }); + + test('le moteur réel sur la grande démonstration, 2 × 20 000 mouvements : aucune violation', async () => { + const montage = await monter({ charges: [chargeDemo('grande')] }); + const bilan = await montage.controleur.generer({ graine: 3, nombre: 2, arret: 20000 }); + assert.deepEqual(bilan.ajoutees, [1, 2]); + const charge = montage.charge(); + const instance = normaliser(configurationDepuisCharge(charge)); + assert.ok(instance.k > 0, 'aucun ancré'); + for (const proposition of charge.propositions) assert.deepEqual(verifierInvariants(instance, planDepuisFichier(proposition)), []); + }); +}); + +// --- Compléter un placement partiel (§ 5.8) ------------------------------------------- + +describe('génération : compléter un placement partiel (§ 5.8)', () => { + test('trois réservations, une « tous » et deux « tour », sont honorées, et personne n\'attend en réserve', async () => { + const charge = chargeDemo('petite'); + charge.reservations = [ + { participant: 1, table: 1, siege: null, portee: 'tous', tour: null }, + { participant: 2, table: 2, siege: null, portee: 'tour', tour: 1 }, + { participant: 3, table: 4, siege: null, portee: 'tour', tour: 3 }, + ]; + const montage = await monter({ charges: [charge] }); + await montage.controleur.generer({ graine: 8, nombre: 3, arret: 2000 }); + const apres = montage.charge(); + const instance = normaliser(configurationDepuisCharge(apres)); + assert.ok(apres.propositions.length > 0); + for (const proposition of apres.propositions) { + const plan = planDepuisFichier(proposition); + assert.deepEqual(verifierInvariants(instance, plan), []); + assert.deepEqual(plan.reserves, [[], [], [], []]); + const tableDe = (participant, tour) => plan.tables[plan.tours[tour - 1].findIndex((liste) => liste.includes(participant))]; + assert.deepEqual([1, 2, 3, 4].map((tour) => tableDe(1, tour)), [1, 1, 1, 1]); + assert.deepEqual([tableDe(2, 1), tableDe(3, 3)], [2, 4]); + } + }); +}); + +// --- L'inactivité (§ 5.10, § 8.4) --------------------------------------------------- + +describe("génération : l'inactivité se suspend pendant le calcul (§ 5.10, § 8.4)", () => { + for (const fin of ['resolue', 'annulee', 'rejetee']) { + test(`générer suspend le compte ; ${fin}, il repart`, async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const { seance, horloge, controleur } = montage; + const enCours = controleur.generer({ graine: 2, nombre: 1, arret: 100 }); + await Promise.resolve(); + assert.equal(seance.etat().calculEnCours, true); + horloge.avancer(INACTIVITE_MS + 1); + await seance.formeNommee(1).catch(() => null); + assert.equal(seance.mode, 'ecriture'); + const [execution] = manuel.executions; + if (fin === 'resolue') execution.resoudreParLeMoteur(); + if (fin === 'annulee') controleur.annuler(); + if (fin === 'rejetee') execution.rejeter(new ErreurGeneration('PLACES_MANQUANTES', { placesManquantes: 1 })); + try { + await enCours; + } catch { + // Annulée ou rejetée, la génération lève ; le compte repart quand même. + } + assert.equal(seance.etat().calculEnCours, false); + horloge.avancer(INACTIVITE_MS); + // Une lecture mise en file après le terme de la minuterie s'achève + // quand le retour en lecture est fait. + await seance.formeNommee(1).catch(() => null); + assert.equal(seance.mode, 'lecture'); + }); + } +}); + +// --- L'annulation (§ 5.10, § 8.4) ------------------------------------------------------ + +describe("génération : l'annulation (§ 5.10, § 8.4)", () => { + for (const geste of ['passerEnLecture', 'fermer']) { + test(`${geste} pendant une génération l'annule : 'seance', ni entrée, ni jalon, ni état écrit`, async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const journal = montage.journal(); + const etat = montage.etatDisque(); + const enCours = montage.controleur.generer(COURT); + await Promise.resolve(); + await montage.seance[geste](); + assert.equal(manuel.executions[0].annulations, 1); + const leve = await rejet(enCours); + assert.ok(leve instanceof ErreurAnnulee); + assert.ok(leve instanceof ErreurAnnuleeDuMoteur); + let vu = null; + montage.controleur.subscribe((etatControleur) => { + vu = etatControleur; + }); + assert.deepEqual(vu, { enCours: false, fait: 0, total: COURT.nombre * COURT.arret, annulee: 'seance' }); + assert.deepEqual(montage.journal(), journal); + assert.equal(montage.etatDisque(), etat); + }); + } + + test("annulée par la séance alors que l'exécution rend son résultat : ErreurAnnulee, ni écriture, ni jalon", async () => { + // L'exécution n'arrête rien d'elle-même : seule l'annulation notée par + // le contrôleur écarte le résultat rendu. Le passage en lecture, mis en + // file avant la résolution, annonce l'annulation avant que le calcul ne + // reprenne. + // Les écritures du seul retour en lecture, mesurées sans génération. + const temoin = await monter({ charges: [chargeDemo('petite-conflit')] }); + const avantTemoin = temoin.fs.compteurs.ecritures; + await temoin.seance.passerEnLecture(); + const ecrituresDuRetourEnLecture = temoin.fs.compteurs.ecritures - avantTemoin; + const manuel = executeurManuel({ differer: true }); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const ecritures = montage.fs.compteurs.ecritures; + const journal = montage.journal(); + const etat = montage.etatDisque(); + const enCours = montage.controleur.generer(COURT); + await Promise.resolve(); + const lecture = montage.seance.passerEnLecture(); + manuel.executions[0].resoudreParLeMoteur(); + const leve = await rejet(enCours); + await lecture; + assert.ok(leve instanceof ErreurAnnulee, String(leve)); + assert.equal(manuel.executions[0].annulations, 1); + assert.deepEqual(montage.charge().propositions, []); + assert.equal(montage.etatDisque(), etat); + assert.deepEqual(montage.journal(), journal); + assert.deepEqual(montage.journal().jalons, journal.jalons); + assert.equal(montage.fs.compteurs.ecritures - ecritures, ecrituresDuRetourEnLecture); + assert.equal(montage.controleur.derniereGeneration(), null); + }); + + test("controleur.annuler() dit 'commande' ; le lancement suivant remet annulee à null", async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const vus = []; + montage.controleur.subscribe((etat) => vus.push(etat)); + assert.deepEqual(vus[0], { enCours: false, fait: 0, total: 0, annulee: null }); + const enCours = montage.controleur.generer(COURT); + await Promise.resolve(); + manuel.executions[0].avancer(20, 50); + assert.deepEqual(vus.at(-1), { enCours: true, fait: 20, total: 50, annulee: null }); + montage.controleur.annuler(); + await rejet(enCours); + assert.deepEqual(vus.at(-1), { enCours: false, fait: 20, total: 50, annulee: 'commande' }); + const suivante = montage.controleur.generer(COURT); + await Promise.resolve(); + assert.deepEqual(vus.at(-1), { enCours: true, fait: 0, total: 50, annulee: null }); + manuel.executions[1].resoudreParLeMoteur(); + await suivante; + assert.equal(vus.at(-1).enCours, false); + }); +}); + +// --- L'exécuteur direct (§ 5.10) ----------------------------------------------------- + +describe("génération : l'exécuteur direct (§ 5.10)", () => { + const configuration = configurationDepuisCharge(chargeDemo('petite-conflit')); + + test('annuler aussitôt après lancer : ErreurAnnulee, aucun avancement', async () => { + const execution = creerExecuteurDirect().lancer(configuration, { graine: 1, nombre: 2, arret: 5000 }); + let avancements = 0; + execution.surAvancement(() => { + avancements += 1; + }); + execution.annuler(); + assert.ok((await rejet(execution.resultat)) instanceof ErreurAnnulee); + assert.equal(avancements, 0); + }); + + test("annuler depuis le premier avancement : ErreurAnnulee, aucun avancement de plus", async () => { + const execution = creerExecuteurDirect().lancer(configuration, { graine: 1, nombre: 2, arret: 5000 }); + const recus = []; + execution.surAvancement((fait, total) => { + recus.push([fait, total]); + execution.annuler(); + }); + assert.ok((await rejet(execution.resultat)) instanceof ErreurAnnulee); + assert.deepEqual(recus, [[1000, 10000]]); + }); + + test('sans annulation, le résultat est celui de rechercher, et chaque avancement compte les mouvements', async () => { + const reglages = { graine: 4, nombre: 2, arret: 3000 }; + const execution = creerExecuteurDirect().lancer(configuration, reglages); + const recus = []; + execution.surAvancement((fait) => recus.push(fait)); + assert.deepEqual(await execution.resultat, rechercher(configuration, reglages)); + assert.deepEqual(recus, [1000, 2000, 3000, 4000, 5000, 6000]); + }); + + test("une RangeError levée par un abonné remonte telle quelle, jamais comme un réglage", async () => { + const montage = await monter({ charges: [chargeDemo('petite-conflit')] }); + const faute = new RangeError('faute d\'un abonné'); + let levee = false; + montage.controleur.subscribe(({ fait }) => { + if (fait > 0 && !levee) { + levee = true; + throw faute; + } + }); + const ecritures = montage.fs.compteurs.ecritures; + const leve = await rejet(montage.controleur.generer({ graine: 1, nombre: 1, arret: 2000 })); + assert.equal(leve, faute); + assert.ok(!(leve instanceof ErreurGeneration)); + assert.equal(montage.fs.compteurs.ecritures, ecritures); + assert.deepEqual(montage.charge().propositions, []); + }); + + test('des places manquantes : ErreurGeneration PLACES_MANQUANTES, code et détails du moteur', async () => { + const manque = configurationDepuisCharge(chargeDe({ n: 13, tables: 4, sieges: 3 })); + const leve = await rejet(creerExecuteurDirect().lancer(manque, COURT).resultat); + assert.ok(leve instanceof ErreurGeneration); + assert.deepEqual([leve.code, leve.details], ['PLACES_MANQUANTES', { placesManquantes: 1 }]); + }); + + test("versErreurGeneration : l'annulation telle quelle, une erreur de configuration convertie, toute autre relevée, RangeError comprise", () => { + const annulee = new ErreurAnnuleeDuMoteur(); + assert.equal(versErreurGeneration(annulee), annulee); + const configurationFausse = new ErreurConfiguration('TOURS', { tours: 0 }); + const convertie = versErreurGeneration(configurationFausse); + assert.ok(convertie instanceof ErreurGeneration); + assert.deepEqual([convertie.code, convertie.details], ['TOURS', { tours: 0 }]); + let horsDomaine = null; + try { + rechercher(configuration, { graine: 2 ** 32, nombre: 1, arret: 1 }); + } catch (erreur) { + horsDomaine = erreur; + } + assert.ok(horsDomaine instanceof RangeError); + assert.equal(versErreurGeneration(horsDomaine), horsDomaine); + const autre = new TypeError('faute du code'); + assert.equal(versErreurGeneration(autre), autre); + }); +}); + +// --- La durée observée et l'estimation (§ 5.10) ------------------------------------------- + +// Une génération sur l'exécuteur manuel qui dure ms à l'horloge pilotée. +async function genererEn(montage, manuel, reglages, ms) { + const enCours = montage.controleur.generer(reglages, { confirmee: true }); + await Promise.resolve(); + montage.horloge.avancer(ms); + manuel.executions.at(-1).resoudre(rechercher(manuel.executions.at(-1).configuration, { ...reglages, arret: 50 })); + return enCours; +} + +describe('génération : la durée observée, en mémoire (§ 5.10)', () => { + test("1 800 ms pour 5 × 200 000 mouvements ; un contrôleur neuf ne sait rien", async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + assert.equal(montage.controleur.derniereGeneration(), null); + const bilan = await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 1800); + assert.equal(bilan.dureeMs, 1800); + assert.deepEqual(montage.controleur.derniereGeneration(), { + dureeMs: 1800, + mouvements: 1000000, + participants: 12, + nombre: 5, + arret: 200000, + }); + const neuf = creerControleurGeneration({ + seance: montage.seance, + executeur: creerExecuteurDirect(), + horloge: montage.horloge, + produitVersion: V1, + }); + assert.equal(neuf.derniereGeneration(), null); + assert.deepEqual(neuf.estimer({ graine: 1, nombre: 5, arret: 200000, historique: 1000 }), { inconnue: true }); + }); + + test("une génération annulée ou rejetée ne mesure rien", async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const enCours = montage.controleur.generer(COURT); + await Promise.resolve(); + montage.horloge.avancer(500); + montage.controleur.annuler(); + await rejet(enCours); + assert.equal(montage.controleur.derniereGeneration(), null); + }); +}); + +describe("génération : l'estimation et la génération longue (§ 5.10)", () => { + test('proportionnelle aux mouvements, longue au-delà du seuil, au-delà du plafond dite sans refus', async () => { + assert.equal(SEUIL_GENERATION_LONGUE_S, 60); + assert.equal(PLAFOND_USAGE_S, 1800); + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const { controleur } = montage; + assert.deepEqual(controleur.estimer({ graine: 1, nombre: 5, arret: 200000, historique: 1000 }), { inconnue: true }); + await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 2000); + const mesure = controleur.derniereGeneration(); + assert.deepEqual(controleur.estimer({ graine: 1, nombre: 3000, arret: 200000, historique: 1000 }), { + secondes: 1200, + longue: true, + auDelaDuPlafond: false, + mesure, + }); + assert.deepEqual(controleur.estimer({ graine: 1, nombre: 5, arret: 200000, historique: 1000 }), { + secondes: 2, + longue: false, + auDelaDuPlafond: false, + mesure, + }); + assert.equal(controleur.estimer({ graine: 1, nombre: 5000, arret: 200000, historique: 1000 }).auDelaDuPlafond, true); + assert.equal(controleur.estimer({ graine: 1, nombre: 150, arret: 200000, historique: 1000 }).longue, false); + assert.equal(controleur.estimer({ graine: 1, nombre: 151, arret: 200000, historique: 1000 }).longue, true); + }); + + test("une génération longue demande sa confirmation, puis part ; sans mesure, au-delà de 10 000 000 mouvements", async () => { + const manuel = executeurManuel(); + const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel }); + const { controleur, espion } = montage; + const sansMesure = { graine: 1, nombre: 5, arret: 2000001, historique: 1000 }; + const refus = await refuseSansRien(montage, 'CONFIRMATION_REQUISE', { estimation: { inconnue: true }, remede: null }, sansMesure); + assert.match(libelle(refus.code, refus.details), /confirm/i); + const juste = controleur.generer({ ...sansMesure, arret: 2000000 }); + await Promise.resolve(); + assert.equal(espion.lancements.length, 1); + controleur.annuler(); + await rejet(juste); + await genererEn(montage, manuel, { graine: 1, nombre: 5, arret: 200000, historique: 1000 }, 2000); + const longue = { graine: 1, nombre: 3000, arret: 200000, historique: 1000 }; + const lances = espion.lancements.length; + const ecritures = montage.fs.compteurs.ecritures; + const leve = await refuse(controleur.generer(longue), 'CONFIRMATION_REQUISE'); + assert.deepEqual(leve.details, { estimation: controleur.estimer(longue), remede: null }); + assert.match(libelle(leve.code, leve.details), /vingt minutes/); + assert.equal(espion.lancements.length, lances); + assert.equal(montage.fs.compteurs.ecritures, ecritures); + const confirmee = controleur.generer(longue, { confirmee: true }); + await Promise.resolve(); + assert.equal(espion.lancements.length, lances + 1); + controleur.annuler(); + await rejet(confirmee); + }); +}); + +// --- Une table, un tour (§ 15.4) --------------------------------------------------------- + +describe('génération : une table, un tour (§ 15.4)', () => { + test('une table de 6, six personnes, un tour : la génération aboutit', async () => { + const montage = await monter({ charges: [chargeDe({ n: 6, tables: 1, sieges: 6, tours: 1 })] }); + const bilan = await montage.controleur.generer({ graine: 1, nombre: 1, arret: 10 }); + assert.deepEqual(bilan.ajoutees, [1]); + assert.deepEqual(planDepuisFichier(montage.charge().propositions[0]).tours, [[[1, 2, 3, 4, 5, 6]]]); + }); +}); diff --git a/src/application/generation_travailleur.js b/src/application/generation_travailleur.js new file mode 100644 index 0000000..8781b0d --- /dev/null +++ b/src/application/generation_travailleur.js @@ -0,0 +1,57 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le côté travailleur de la génération (§ 5.10) : la recherche du moteur +// hors du fil de l'interface. Le travailleur reçoit un message, calcule, et +// poste ses avancements puis son résultat ou son erreur : +// +// → { type: 'lancer', configuration, reglages } +// ← { type: 'avancement', fait, total } au plus une fois toutes les +// CADENCE_AVANCEMENT progressions +// du moteur, fait croissant +// ← { type: 'resultat', propositions } celles de rechercher, telles quelles +// ← { type: 'erreur', code, details } REGLAGE_HORS_DOMAINE {reglage} +// d'exigerReglages, avant tout +// calcul ; puis, par +// versErreurGeneration, le code et +// les détails d'une +// ErreurConfiguration +// +// L'annulation est terminate() du côté de l'interface : immédiate, sans +// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un +// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit +// pas est relevée, et le travailleur la signale par son propre canal +// d'erreur. +import { rechercher } from '../moteur/recherche.js'; +import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js'; + +/** Progressions du moteur — une toutes les 1 000 itérations — entre deux messages d'avancement. */ +export const CADENCE_AVANCEMENT = 50; + +/** + * Branche une portée de travailleur — { onmessage, postMessage } — sur + * rechercher, selon les messages de l'en-tête du module. + * + * @param {{onmessage: ((evenement: {data: unknown}) => void)|null, postMessage: (message: Object) => void}} portee + */ +export function brancherTravailleur(portee) { + portee.onmessage = ({ data }) => { + if (data === null || typeof data !== 'object' || data.type !== 'lancer') return; + let progressions = 0; + const progression = (fait, total) => { + progressions += 1; + if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total }); + }; + let propositions; + try { + exigerReglages(data.reglages); + propositions = rechercher(data.configuration, data.reglages, { progression }); + } catch (erreur) { + const convertie = versErreurGeneration(erreur); + if (!(convertie instanceof ErreurGeneration)) throw convertie; + portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details }); + return; + } + portee.postMessage({ type: 'resultat', propositions }); + }; +} diff --git a/src/application/generation_travailleur.test.js b/src/application/generation_travailleur.test.js new file mode 100644 index 0000000..42ae551 --- /dev/null +++ b/src/application/generation_travailleur.test.js @@ -0,0 +1,66 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du travailleur de génération (§ 5.10) : brancherTravailleur sur +// une portée factice, qui garde chaque message posté. Les configurations +// viennent des démonstrations livrées et d'une charge aux noms inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { texteLivre } from '../demo/livrees.js'; +import { rechercher } from '../moteur/recherche.js'; +import { analyser, configurationDepuisCharge } from '../stockage/document.js'; +import { CADENCE_AVANCEMENT, brancherTravailleur } from './generation_travailleur.js'; + +const configurationDe = (cle) => configurationDepuisCharge(analyser(texteLivre(cle)).charge); + +// Une portée de travailleur factice : ce que le travailleur poste, et +// l'envoi d'un message au travailleur. +function porteeFactice() { + const postes = []; + const portee = { + onmessage: null, + postMessage: (message) => postes.push(structuredClone(message)), + }; + brancherTravailleur(portee); + return { postes, envoyer: (data) => portee.onmessage({ data }) }; +} + +describe('travailleur de génération (§ 5.10)', () => { + test("après lancer, des avancements à fait croissant, au plus un tous les CADENCE_AVANCEMENT, puis le résultat de rechercher", () => { + assert.equal(CADENCE_AVANCEMENT, 50); + const configuration = configurationDe('petite-conflit'); + const reglages = { graine: 9, nombre: 2, arret: 60000, historique: 1000 }; + const { postes, envoyer } = porteeFactice(); + envoyer({ type: 'lancer', configuration, reglages }); + const avancements = postes.filter(({ type }) => type === 'avancement'); + assert.ok(avancements.length > 0, 'aucun avancement'); + assert.ok(avancements.length <= Math.ceil((reglages.nombre * reglages.arret) / 1000 / CADENCE_AVANCEMENT)); + assert.deepEqual(avancements, [ + { type: 'avancement', fait: 50000, total: 120000 }, + { type: 'avancement', fait: 100000, total: 120000 }, + ]); + assert.deepEqual(postes.at(-1), { type: 'resultat', propositions: rechercher(configuration, reglages) }); + assert.equal(postes.length, avancements.length + 1); + }); + + test('des places manquantes postent erreur PLACES_MANQUANTES, les détails du moteur', () => { + const configuration = configurationDe('petite'); + configuration.tables = configuration.tables.slice(1); + const { postes, envoyer } = porteeFactice(); + envoyer({ type: 'lancer', configuration, reglages: { graine: 1, nombre: 1, arret: 10 } }); + assert.deepEqual(postes, [{ type: 'erreur', code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]); + }); + + test('une graine de 2^32 poste erreur REGLAGE_HORS_DOMAINE, qui nomme le réglage, sans le message du moteur', () => { + const { postes, envoyer } = porteeFactice(); + envoyer({ type: 'lancer', configuration: configurationDe('petite'), reglages: { graine: 2 ** 32, nombre: 1, arret: 10 } }); + assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]); + }); + + test("un message d'un autre type ne lance rien", () => { + const { postes, envoyer } = porteeFactice(); + envoyer({ type: 'inconnu' }); + envoyer(null); + assert.deepEqual(postes, []); + }); +}); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index c79cec0..b3ad8d2 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -16,11 +16,22 @@ // La table se partage en fichiers, un par domaine, sous libelles/ : chacun // exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique // des fichiers ; les tables, comme l'assemblage, sont hors du balayage. +// +// src/moteur se relève à part, épreuves exclues : chaque littéral en +// capitales de ses sources, hors commentaires, y est un code — celui d'une +// ErreurConfiguration ou d'une violation de ses vérificateurs —, que le +// moteur lève par une constante ou par une expression autant que par un +// appel. Une seule exception, nommée : les valeurs de domine dans manque.js, +// qui disent laquelle de deux propositions sert le mieux et ne s'affichent +// pas comme un code. import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, test } from '../../test/lanceur.js'; +import { CRITERES } from '../moteur/classement.js'; +import { TABLES_INTERFACE } from '../interface/libelles.js'; +import { creerTraduction } from './traduction.js'; import { CODES, TABLES_APPLICATION, libelle } from './libelles.js'; const RACINE = fileURLToPath(new URL('../..', import.meta.url)); @@ -90,8 +101,40 @@ function modulesDe(arbre) { .sort(); } +// --- Le relevé du moteur ----------------------------------------------------- + +const MOTEUR = 'src/moteur'; +// Les littéraux en capitales du moteur qui ne sont pas des codes, par module. +const PAS_DES_CODES = new Map([[join(MOTEUR, 'manque.js'), new Set(['A', 'B'])]]); +// Les modules du moteur qui portent des codes. +const MODULES_A_CODES = ['configuration.js', 'plafond.js', 'recherche.js', 'verification.js'].map((nom) => join(MOTEUR, nom)); +const LITTERAL_CODE = new RegExp(CODE, 'g'); + +/** Chaque littéral en capitales d'un texte source, hors commentaires. */ +function litterauxEnCapitales(texte) { + return [...sansCommentaires(texte).matchAll(LITTERAL_CODE)].map((trouve) => trouve[1]); +} + +// Relevé du moteur : chaque code de ses modules, hors exceptions nommées, +// le module qui émet chacun en premier, et les modules qui en portent. +function releverMoteur() { + const codes = new Set(); + const emetteurs = new Map(); + const porteurs = []; + for (const chemin of modulesDe(MOTEUR)) { + const exclus = PAS_DES_CODES.get(chemin) ?? new Set(); + const trouves = litterauxEnCapitales(readFileSync(join(RACINE, chemin), 'utf8')).filter((code) => !exclus.has(code)); + if (trouves.length > 0) porteurs.push(chemin); + for (const code of trouves) { + codes.add(code); + if (!emetteurs.has(code)) emetteurs.set(code, chemin); + } + } + return { codes, emetteurs, porteurs }; +} + // Relevé de l'arbre réel : par arbre, les codes émis et le fichier qui émet -// chacun en premier ; puis ceux des tables de contrat. +// chacun en premier ; puis ceux des tables de contrat ; puis ceux du moteur. function releverArbre() { const parArbre = new Map(); const emetteurs = new Map(); @@ -107,9 +150,15 @@ function releverArbre() { } const tables = new Set(TABLES.flatMap((chemin) => codesDesTables(readFileSync(join(RACINE, chemin), 'utf8')))); for (const code of tables) if (!emetteurs.has(code)) emetteurs.set(code, 'table de contrat'); + const moteur = releverMoteur(); + for (const [code, chemin] of moteur.emetteurs) if (!emetteurs.has(code)) emetteurs.set(code, chemin); + parArbre.set(MOTEUR, moteur.codes); return { parArbre, tables, emetteurs }; } +// Tous les codes relevés : tables de contrat, arbres balayés et moteur. +const tousReleves = ({ parArbre, tables }) => new Set([...tables, ...[...parArbre.values()].flatMap((codes) => [...codes])]); + describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => { test("le relevé lit le premier argument d'un appel, une clé code, un code de détail, et rien d'un commentaire", () => { const source = [ @@ -182,9 +231,10 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => { assert.ok(tables.has('VERROU_PRIS') && tables.has('IDENTIFIANT_HORS_COMPTEUR')); }); - test('chaque code que src/stockage, src/csv et src/application émettent a son texte', () => { - const { parArbre, tables, emetteurs } = releverArbre(); - const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]); + test('chaque code que src/stockage, src/csv, src/application et src/moteur émettent a son texte', () => { + const releve = releverArbre(); + const { emetteurs } = releve; + const releves = tousReleves(releve); assert.ok(releves.size > 0, 'balayage vide'); const connus = new Set(CODES); const sansTexte = [...releves].filter((code) => !connus.has(code)).sort(); @@ -196,8 +246,7 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => { }); test('chaque texte de la table a un émetteur : aucune entrée morte', () => { - const { parArbre, tables } = releverArbre(); - const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]); + const releves = tousReleves(releverArbre()); assert.ok(CODES.length > 0, 'table vide'); assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur'); }); @@ -334,6 +383,61 @@ const EXEMPLES = new Map([ // Moteur ['PLACES_MANQUANTES', { placesManquantes: 3 }], ['SURRESERVATION', { table: 2, tour: 1, reservees: 9, capacite: 8, remede: null }], + ['CAPACITE', { table: 2, capacite: 1 }], + ['TOURS', { tours: 0 }], + ['PARTICIPANT_DOUBLON', { participant: 4 }], + ['TABLE_DOUBLON', { table: 3 }], + ['RESERVATION_INCONNUE', { reservation: 0, participant: 42 }], + ['RESERVATION_TOUR', { reservation: 2, tour: 5 }], + ['RESERVATION_CONFLIT', { participant: 4, tour: 2, tables: [1, 3] }], + ['ENUMERATION_TROP_GRANDE', { participant: 7, tablesAdmissibles: 40, toursLibres: 9, limite: 1000000 }], + ['PLAN_TOURS', { attendu: 4, tours: 3, reserves: 3 }], + ['PLAN_TABLE_INCONNUE', { table: 9 }], + ['PLAN_TABLE_DOUBLON', { table: 2 }], + ['PLAN_TABLE_ABSENTE', { table: 5 }], + ['PLAN_LISTES', { tour: 2, listes: 3, tables: 4 }], + ['PLAN_INCONNU', { participant: 42, tour: 1 }], + ['PLAN_EXCLU_PLACE', { participant: 3, tour: 2 }], + ['PLAN_DOUBLE_PLACE', { participant: 5, tour: 3 }], + ['PLAN_NON_ASSIS', { participant: 6, tour: 4 }], + ['TABLE_INCONNUE', { table: 9, detail: 'inconnue' }], + ['DOUBLE_PLACE', { participant: 5, tour: 3 }], + ['EXCLU_PLACE', { participant: 3, tour: 2 }], + ['NON_ASSIS', { participant: 6, tour: 4 }], + ['RESERVATION', { participant: 4, table: 5, tour: 1 }], + ['PLAFOND_A_PRIORI', { index: 2, participant: 3 }], + ['ORDRE_PLAFONDS', { index: 2, participant: 3 }], + ['PLAFOND_REALISE', { index: 0, participant: 1 }], + ['DEPASSEMENT', { index: 4, participant: 9 }], + // Génération + ['GENERATION_EN_COURS', { remede: null }], + ['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }], + ['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }], + [ + 'CONFIRMATION_REQUISE', + { + estimation: { + secondes: 1200, + longue: true, + auDelaDuPlafond: false, + mesure: { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 }, + }, + remede: null, + }, + ], + ['HORS_DOMAINE', { min: 1, max: null, remede: null }], + ['REGLAGE_HORS_DOMAINE', { reglage: 'arret', remede: null }], + [ + 'GENERATION_INVARIANT_VIOLE', + { + rang: 2, + violations: [ + { code: 'RESERVATION', participant: 4, table: 5, tour: 1 }, + { code: 'CAPACITE', table: 5, tour: 2 }, + ], + remede: null, + }, + ], // Refus et avertissements des commandes ['NOM_REQUIS', { remede: null }], ['DATE_INVALIDE', { date: '2031-02-30', remede: null }], @@ -697,3 +801,142 @@ describe('libellés : les tables par domaine (§ 14.6)', () => { assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))); }); }); + +// --- Le moteur et la génération (A6) ------------------------------------------- + +describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7)', () => { + test('le relevé du moteur porte des codes, dans ses quatre modules à codes, et rien des valeurs de domine', () => { + const { codes, porteurs } = releverMoteur(); + assert.ok(codes.size >= 20, `${codes.size} codes relevés`); + assert.deepEqual(porteurs, MODULES_A_CODES); + assert.ok(!codes.has('A') && !codes.has('B')); + for (const code of ['PLAN_EXCLU_PLACE', 'PLAN_INCONNU', 'ENUMERATION_TROP_GRANDE', 'PLACES_MANQUANTES', 'RESERVATION']) { + assert.ok(codes.has(code), code); + } + assert.deepEqual(litterauxEnCapitales("// 'DANS_UN_COMMENTAIRE'\nconst x = a ? 'PREMIER' : 'SECOND';"), ['PREMIER', 'SECOND']); + }); + + test("chaque code du moteur a sa clé, une seule ; un code que seul le moteur lève l'a dans libelles/moteur.js, aux exceptions nommées près", async () => { + const { codes } = releverMoteur(); + const releve = releverArbre(); + const ailleurs = new Set([...releve.tables, ...ARBRES.flatMap((arbre) => [...releve.parArbre.get(arbre)])]); + const { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.js'); + const { TABLE: STOCKAGE_TABLE } = await import('./libelles/stockage.js'); + // INCONNU est déjà la clé de la table d'A1, une faute de placement du + // stockage aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION sont + // dans libelles/moteur.js depuis la séance (A2), et ne s'y redéfinissent pas. + const EXCEPTIONS = new Map([['INCONNU', STOCKAGE_TABLE]]); + let comptes = 0; + for (const code of codes) { + comptes += 1; + const porteuses = TABLES_APPLICATION.filter((table) => table.has(code)); + assert.equal(porteuses.length, 1, `${code} : ${porteuses.length} tables`); + if (EXCEPTIONS.has(code)) assert.equal(porteuses[0], EXCEPTIONS.get(code), code); + else if (!ailleurs.has(code)) assert.equal(porteuses[0], MOTEUR_TABLE, `${code} hors de libelles/moteur.js`); + } + assert.ok(comptes > 0); + for (const code of ['PLACES_MANQUANTES', 'SURRESERVATION']) assert.ok(MOTEUR_TABLE.has(code), code); + }); + + test('chaque critère de CRITERES a son libellé moteur.critere.', () => { + assert.ok(CRITERES.length > 0); + for (const critere of CRITERES) { + const texte = libelle(`moteur.critere.${critere}`); + assert.ok(texte.length > 0, critere); + for (const trace of TRACES) assert.doesNotMatch(texte, trace); + } + assert.equal(new Set(CRITERES.map((critere) => libelle(`moteur.critere.${critere}`))).size, CRITERES.length); + }); + + test("les tables de l'application et des écrans s'assemblent sans clé en double", () => { + assert.ok(TABLES_INTERFACE.length > 0); + assert.doesNotThrow(() => creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE])); + }); + + test('CAPACITE et TOURS, levés sous deux formes, ont un seul texte qui les distingue par leurs détails', () => { + const configuration = libelle('CAPACITE', { table: 2, capacite: 1 }); + const violation = libelle('CAPACITE', { table: 2, tour: 3 }); + assert.notEqual(configuration, violation); + assert.match(configuration, /\b1\b/); + assert.match(violation, /tour 3/); + const tours = libelle('TOURS', { tours: 0 }); + const toursViolation = libelle('TOURS', {}); + assert.notEqual(tours, toursViolation); + for (const texte of [configuration, violation, tours, toursViolation]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte); + }); + + test("une violation d'indicateur nomme la personne quand l'appelant la traduit, son rang sinon ; un détail de table inconnu lève", () => { + assert.match(libelle('DEPASSEMENT', { index: 4, participant: 9 }), /n° 9/); + assert.match(libelle('DEPASSEMENT', { index: 4 }), /rang 5/); + for (const detail of ['inconnue', 'doublon', 'absente']) assert.match(libelle('TABLE_INCONNUE', { table: 9, detail }), /9/); + assert.match(libelle('TABLE_INCONNUE', { tour: 2, detail: 'listes' }), /tour 2/); + assert.throws(() => libelle('TABLE_INCONNUE', { table: 9, detail: 'inventee' }), RangeError); + }); + + test('chaque refus de valider et de generer a son texte, dans la table de son domaine', async () => { + const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE])); + // Les refus que lèvent les épreuves de valider et de generer + // (generation.test.js), et la table qui porte chacun. Ceux de la séance, + // du stockage, du mobilier et du moteur sont relayés tels quels ; + // GENERATION_INVARIANT_VIOLE, propre à la génération, se distingue + // d'INVARIANT_VIOLE, la violation du retenu, que la séance porte. + const REFUS = [ + ['NON_ENTIER', 'mobilier'], + ['HORS_DOMAINE', 'generation'], + ['AUCUN_EVENEMENT', 'seance'], + ['LECTURE_SEULE', 'stockage'], + ['MODE_LECTURE', 'seance'], + ['SUPPORT_ROMPU', 'seance'], + ['GENERATION_EN_COURS', 'generation'], + ['AUCUN_PARTICIPANT', 'generation'], + ['AUCUNE_TABLE', 'generation'], + ['SURRESERVATION', 'moteur'], + ['PLACES_MANQUANTES', 'moteur'], + ['CONFIRMATION_REQUISE', 'generation'], + ['REGLAGE_HORS_DOMAINE', 'generation'], + ['GENERATION_INVARIANT_VIOLE', 'generation'], + ['COMPTEUR_SATURE', 'stockage'], + ]; + assert.equal(REFUS.length, 15); + for (const [code, table] of REFUS) { + assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`); + assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code); + } + }); + + test("la confirmation d'une génération longue dit l'ordre de grandeur, la mesure, le plafond ; sans mesure, le seuil", () => { + const mesure = { dureeMs: 2000, mouvements: 1000000, participants: 260, nombre: 5, arret: 200000 }; + const longue = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 1200, longue: true, auDelaDuPlafond: false, mesure }, remede: null }); + assert.match(longue, /environ vingt minutes/); + assert.match(longue, /260 participants/); + assert.doesNotMatch(longue, /trente minutes/); + const plafond = libelle('CONFIRMATION_REQUISE', { estimation: { secondes: 7200, longue: true, auDelaDuPlafond: true, mesure }, remede: null }); + assert.match(plafond, /trente minutes/); + const inconnue = libelle('CONFIRMATION_REQUISE', { estimation: { inconnue: true }, remede: null }); + assert.match(inconnue, /dix fois le réglage par défaut/); + for (const texte of [longue, plafond, inconnue]) for (const trace of TRACES) assert.doesNotMatch(texte, trace, texte); + }); + + test("une violation d'invariant rendue par la génération nomme son rang et chaque règle, sans retenu ni dérive", () => { + const details = { + rang: 2, + violations: [ + { code: 'RESERVATION', participant: 4, table: 5, tour: 1 }, + { code: 'CAPACITE', table: 5, tour: 2 }, + ], + remede: null, + }; + const texte = libelle('GENERATION_INVARIANT_VIOLE', details); + assert.match(texte, /rang 2\b/); + assert.match(texte, /2 règles/); + assert.ok(texte.includes(libelle('RESERVATION', details.violations[0])), texte); + assert.ok(texte.includes(libelle('CAPACITE', details.violations[1])), texte); + assert.doesNotMatch(texte, /retenu|dérive|proposition n°/); + assert.match(libelle('GENERATION_INVARIANT_VIOLE', { ...details, violations: details.violations.slice(0, 1) }), /1 règle\b/); + }); + + test('un domaine borné nomme ses deux bornes, un domaine ouvert son minimum', () => { + assert.match(libelle('HORS_DOMAINE', { min: 0, max: 4294967295, remede: null }), /0 et 4\u{202F}294\u{202F}967\u{202F}295/u); + assert.match(libelle('HORS_DOMAINE', { min: 1, max: null, remede: null }), /au moins 1\b/); + }); +}); diff --git a/src/application/libelles/generation.js b/src/application/libelles/generation.js index 0c17a47..78f4188 100644 --- a/src/application/libelles/generation.js +++ b/src/application/libelles/generation.js @@ -2,8 +2,39 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les textes des commandes de la génération et des propositions (§ 5.7, -// § 14.6) : refus et libellés figés. Cette table ne porte que des codes. -import { compte } from './aides.js'; +// § 14.6) : refus et libellés figés, puis les refus du contrôleur de +// génération et de la saisie de ses réglages (§ 5.9, § 5.10, § 15.4). Cette +// table ne porte que des codes. Un nombre et une durée se mettent en forme +// par creerFormat, sur la composition que la traduction passe à l'entrée. +import { creerFormat } from '../format.js'; +import { cite, compte, phraseDeRaison } from './aides.js'; + +// Le nom de chaque réglage de génération, celui que la saisie affiche. +const NOMS_DES_REGLAGES = new Map([ + ['graine', 'Graine'], + ['nombre', 'Propositions par génération'], + ['arret', 'Mouvements par proposition'], + ['historique', "Historique d'acceptation"], +]); + +// L'estimation d'une génération longue, en mots : l'ordre de grandeur et la +// mesure dont il vient, ou le seuil d'une génération sans mesure. +function phraseEstimation(estimation, texte) { + if (estimation.inconnue === true) { + return ( + "Aucune durée n'est encore mesurée sur ce poste dans cette séance, et ce réglage demande plus de " + + 'dix fois le réglage par défaut' + ); + } + const format = creerFormat(texte); + const { mesure } = estimation; + const plafond = estimation.auDelaDuPlafond ? ", au-delà du plafond d'usage de trente minutes" : ''; + return ( + `Ce réglage prendrait ${format.ordreDeGrandeur(estimation.secondes)}${plafond}, d'après le dernier calcul, ` + + `${format.duree(mesure.dureeMs)} pour ${format.nombre(mesure.mouvements)} mouvements ` + + `sur ${compte(mesure.participants, 'participant', 'participants')}` + ); +} export const TABLE = new Map([ // --- Les refus des propositions (§ 5.7) ------------------------------------ @@ -23,4 +54,45 @@ export const TABLE = new Map([ `Effacé ${compte(n, 'proposition', 'propositions')}` + `${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`, ], + + // --- Le contrôleur de génération (§ 5.9, § 5.10, § 15.4) ------------------- + ['GENERATION_EN_COURS', () => "Une génération est déjà en cours : l'annuler, ou attendre qu'elle s'achève."], + [ + 'AUCUN_PARTICIPANT', + () => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.", + ], + ['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."], + [ + 'CONFIRMATION_REQUISE', + ({ estimation }, texte) => + `${phraseEstimation(estimation, texte)}. Confirmer « Générer » lance quand même la génération.`, + ], + [ + 'REGLAGE_HORS_DOMAINE', + ({ reglage }) => + `Le réglage ${cite(NOMS_DES_REGLAGES.get(reglage) ?? String(reglage))} sort de son domaine : ` + + "la génération n'a pas eu lieu.", + ], + // rang : la place du placement dans la suite des graines du lancement, + // jamais un numéro de proposition, puisque rien n'est enregistré ; chaque + // violation se dit par le texte de son code (libelles/moteur.js). + [ + 'GENERATION_INVARIANT_VIOLE', + ({ rang, violations }, texte) => + `Le placement que la génération a rendu au rang ${rang} de ce lancement enfreint ` + + `${compte(violations.length, 'règle', 'règles')} ; rien n'est enregistré. ` + + violations.map((violation) => phraseDeRaison(violation.code, violation, texte)).join(' '), + ], + + // --- La saisie des réglages (§ 5.7) ----------------------------------------- + // max null : le domaine n'a pas de borne supérieure. + [ + 'HORS_DOMAINE', + ({ min, max }, texte) => { + const { nombre } = creerFormat(texte); + return max === null || max === undefined + ? `La valeur doit être d'au moins ${nombre(min)}.` + : `La valeur doit être comprise entre ${nombre(min)} et ${nombre(max)}.`; + }, + ], ]); diff --git a/src/application/libelles/moteur.js b/src/application/libelles/moteur.js index 892f42d..e619718 100644 --- a/src/application/libelles/moteur.js +++ b/src/application/libelles/moteur.js @@ -2,8 +2,33 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les -// libellés de ses critères (§ 5, § 14.6). -import { compte } from './aides.js'; +// libellés de ses critères (§ 5, § 14.6) : les codes d'une +// ErreurConfiguration, ceux des violations de verifierInvariants et de +// verifierIndicateurs, et moteur.critere. pour chaque nom de CRITERES. +// Un code garde un seul texte : CAPACITE et TOURS, que le moteur lève sous +// deux formes de détails — ErreurConfiguration et violation —, se +// distinguent par les détails qu'ils portent. Une réservation que le moteur +// désigne sans identifiant l'est par son rang à partir de 0, que le texte +// affiche à partir de 1 ; une personne qu'une violation d'indicateur désigne +// par son index se nomme par participant quand l'appelant l'a traduit, par +// son rang sinon. INCONNU, aux mêmes détails que la faute de placement du +// stockage, a son texte dans libelles/stockage.js. +import { capitale, compte, nomDans } from './aides.js'; + +// Les cas d'une table que le plan ne rattache pas à l'instance, par detail. +const CAS_DE_TABLE = new Map([ + ['inconnue', (table) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`], + ['doublon', (table) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`], + ['absente', (table) => `Le plan ne déclare pas la table d'identifiant ${table}.`], +]); + +// La personne d'une violation d'indicateur : son numéro, ou son rang dans +// la population quand l'appelant ne l'a pas traduit. +const personneDe = ({ index, participant }) => + participant === undefined || participant === null ? `la personne de rang ${index + 1}` : `la personne n° ${participant}`; + +// Un rang de réservation, compté à partir de 0 par le moteur. +const reservationDe = (rang) => `la réservation n° ${rang + 1}`; export const TABLE = new Map([ // Une salle trop petite : avertissement d'un geste qui fait croître le @@ -20,4 +45,110 @@ export const TABLE = new Map([ `La table d'identifiant ${table} reçoit ${compte(reservees, 'personne réservée', 'personnes réservées')} ` + `au tour ${tour}, pour ${compte(capacite, 'place', 'places')}.`, ], + + // --- La configuration (normaliser, § 4, § 5.2) ------------------------------ + // CAPACITE {table, capacite} : une capacité refusée ; {table, tour} : une + // liste du plan plus longue que sa table. + [ + 'CAPACITE', + ({ table, capacite, tour }) => + capacite === undefined + ? `Au tour ${tour}, la table d'identifiant ${table} reçoit plus de personnes qu'elle n'a de places.` + : `La table d'identifiant ${table} a une capacité de ${capacite} : une table compte au moins 2 places.`, + ], + // TOURS {tours} : un nombre de tours refusé ; {} : un plan qui n'a pas un + // tour par tour de l'événement. + [ + 'TOURS', + ({ tours }) => + tours === undefined + ? "Le plan ne porte pas autant de tours que l'événement." + : `Le nombre de tours, ${tours}, n'est pas un entier d'au moins 1.`, + ], + ['PARTICIPANT_DOUBLON', ({ participant }) => `La personne n° ${participant} figure plus d'une fois dans la liste.`], + ['TABLE_DOUBLON', ({ table }) => `La table d'identifiant ${table} figure plus d'une fois.`], + [ + 'RESERVATION_INCONNUE', + ({ reservation, participant, table }) => + participant === undefined + ? `${capitale(reservationDe(reservation))} désigne la table d'identifiant ${table}, que l'événement n'a pas.` + : `${capitale(reservationDe(reservation))} désigne la personne n° ${participant}, que la liste n'a pas.`, + ], + [ + 'RESERVATION_TOUR', + ({ reservation, tour }) => `${capitale(reservationDe(reservation))} désigne le tour ${tour}, que l'événement n'a pas.`, + ], + [ + 'RESERVATION_CONFLIT', + ({ participant, tour, tables }) => + `La personne n° ${participant} est réservée au tour ${tour} à deux tables, ` + + `d'identifiants ${tables[0]} et ${tables[1]}.`, + ], + [ + 'ENUMERATION_TROP_GRANDE', + ({ participant, limite }) => + `Le plafond a priori de la personne n° ${participant} demanderait d'énumérer plus de ${limite} itinéraires.`, + ], + + // --- Le plan confronté à la configuration (indexerPlan) ------------------------- + [ + 'PLAN_TOURS', + ({ attendu, tours, reserves }) => + `Le plan porte ${compte(tours, 'tour', 'tours')} et ${compte(reserves, 'réserve', 'réserves')}, ` + + `pour ${compte(attendu, 'tour', 'tours')} dans l'événement.`, + ], + ['PLAN_TABLE_INCONNUE', ({ table }) => `Le plan déclare la table d'identifiant ${table}, que l'événement n'a pas.`], + ['PLAN_TABLE_DOUBLON', ({ table }) => `Le plan déclare plus d'une fois la table d'identifiant ${table}.`], + ['PLAN_TABLE_ABSENTE', ({ table }) => `Le plan ne déclare pas la table d'identifiant ${table}.`], + [ + 'PLAN_LISTES', + ({ tour, listes, tables }) => + `Au tour ${tour}, le plan porte ${compte(listes, 'liste', 'listes')} pour ${compte(tables, 'table', 'tables')}.`, + ], + ['PLAN_INCONNU', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, que la liste n'a pas.`], + ['PLAN_EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place la personne n° ${participant}, qui est exclue.`], + ['PLAN_DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, le plan place plus d'une fois la personne n° ${participant}.`], + ['PLAN_NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, le plan ne place nulle part la personne n° ${participant}.`], + + // --- Les violations de verifierInvariants (§ 14.12) ----------------------------- + [ + 'TABLE_INCONNUE', + ({ table, tour, detail }) => + detail === 'listes' + ? `Au tour ${tour}, le plan n'a pas une liste par table déclarée.` + : nomDans(CAS_DE_TABLE, detail, 'cas de table')(table), + ], + ['DOUBLE_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`], + ['EXCLU_PLACE', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} est placée alors qu'elle est exclue.`], + ['NON_ASSIS', ({ participant, tour }) => `Au tour ${tour}, la personne n° ${participant} n'est assise à aucune table.`], + [ + 'RESERVATION', + ({ participant, table, tour }) => + `Au tour ${tour}, la personne n° ${participant}, réservée à la table d'identifiant ${table}, est placée ailleurs.`, + ], + + // --- Les violations de verifierIndicateurs (§ 12.3) ----------------------------- + [ + 'PLAFOND_A_PRIORI', + (details) => `Le plafond a priori de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`, + ], + [ + 'ORDRE_PLAFONDS', + (details) => `Le plafond réalisé de ${personneDe(details)} dépasse son plafond a priori : le calcul est faux.`, + ], + [ + 'PLAFOND_REALISE', + (details) => `Le plafond réalisé de ${personneDe(details)} dépasse le nombre des autres personnes : le calcul est faux.`, + ], + [ + 'DEPASSEMENT', + (details) => `Les rencontres de ${personneDe(details)} dépassent son plafond réalisé : le calcul est faux.`, + ], + + // --- Les critères du classement (§ 5.7) ----------------------------------------- + ['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'], + ['moteur.critere.excedentCollisions', 'excédent de collisions'], + ['moteur.critere.collisionsCumulees', 'collisions cumulées'], + ['moteur.critere.rencontresRepetees', 'rencontres répétées'], + ['moteur.critere.redondance', "redondance d'appartenance"], ]); diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index 1fad988..b7e94d1 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -163,10 +163,13 @@ const decrire = (valeur) => typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`; // Lève une RangeError qui nomme le réglage quand la valeur n'est pas un -// entier de min à max. +// entier de min à max : son message commence par le nom, et son champ +// reglage le porte. function exigerReglage(nom, valeur, min, max) { if (!Number.isInteger(valeur) || valeur < min || valeur > max) { - throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`); + const erreur = new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`); + erreur.reglage = nom; + throw erreur; } } @@ -601,9 +604,8 @@ function proposer(instance, structure, graineDerivee, options) { * @returns {Proposition[]} */ export function rechercher(configuration, reglages, { signal, progression } = {}) { + verifierReglages(reglages); const { graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT } = reglages; - exigerChampsProposition({ graine, arret, historique }); - exigerReglage('nombre', nombre, 1, Number.MAX_SAFE_INTEGER); const instance = normaliser(configuration); const structure = preparer(instance); const graines = creerPcg32(graine, FLUX.GRAINES); @@ -625,6 +627,21 @@ export function rechercher(configuration, reglages, { signal, progression } = {} return propositions; } +/** + * Le contrôle des réglages que rechercher applique avant tout calcul : graine + * de 0 à 2^32 − 1, puis arret, historique et nombre ≥ 1, un historique absent + * valant HISTORIQUE_PAR_DEFAUT. Lève, pour le premier réglage hors de son + * domaine dans cet ordre, une RangeError dont le message commence par son nom + * et dont le champ reglage le porte ; ne rend rien sinon. rechercher + * l'appelle : ce qu'il admet, rechercher l'admet. + * + * @param {ReglagesGeneration} reglages + */ +export function verifierReglages({ graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT }) { + exigerChampsProposition({ graine, arret, historique }); + exigerReglage('nombre', nombre, 1, Number.MAX_SAFE_INTEGER); +} + /** * Le plan d'une proposition, régénéré à partir de ses seuls champs (§ 5.7, * § 8.9) : la descente part de proposition.graine, la graine dérivée qu'elle diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js index 17ecbbc..866ff9f 100644 --- a/src/moteur/recherche.test.js +++ b/src/moteur/recherche.test.js @@ -44,6 +44,7 @@ import { regenerer, scoreComplet, scoreIncremental, + verifierReglages, } from './recherche.js'; import { verifierInvariants } from './verification.js'; @@ -441,6 +442,33 @@ describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () ); } }); + + test('verifierReglages : la règle de rechercher, la même RangeError, qui porte le nom du réglage', () => { + const cas = [ + [{ graine: 2 ** 32, arret: 10, nombre: 1 }, 'graine'], + [{ graine: 1, arret: 2.5, nombre: 1 }, 'arret'], + [{ graine: 1, arret: 10, nombre: 0 }, 'nombre'], + [{ graine: 1, arret: 10, nombre: 1, historique: 0 }, 'historique'], + [{ graine: -1, arret: 0, nombre: 0, historique: 0 }, 'graine'], + ]; + for (const [reglages, nom] of cas) { + let leve = null; + try { + verifierReglages(reglages); + } catch (erreur) { + leve = erreur; + } + assert.ok(leve instanceof RangeError, JSON.stringify(reglages)); + assert.equal(leve.reglage, nom); + assert.throws( + () => rechercher(demo('petite'), reglages), + (erreur) => erreur instanceof RangeError && erreur.message === leve.message && erreur.reglage === nom, + ); + } + // L'historique absent prend la valeur par défaut de rechercher. + assert.equal(verifierReglages({ graine: 0, arret: 1, nombre: 1 }), undefined); + assert.equal(verifierReglages({ graine: 2 ** 32 - 1, arret: 1, nombre: 1, historique: 1 }), undefined); + }); }); describe('regenerer : une proposition depuis ses seuls champs (§ 5.7, § 8.9)', () => {