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)', () => {