// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // La machine d'une commande et les remèdes d'un refus (§ 2.1, § 8.5, // § 14.6). Une commande à confirmer s'arme au premier clic et n'exécute // qu'au second ; le délai la désarme. Le libellé d'un remède est une clé // composée à l'exécution, communs.remede. ou // communs.remede.[.] : la garde des clés littérales ne la // voit pas, et cette épreuve l'énumère sur COMMANDES et sur les gestes du // contrat de données. import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { describe, test } from '../../../test/lanceur.js'; import { COMMANDES } from '../../application/commandes.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { creerTraduction } from '../../application/traduction.js'; import { TABLES_INTERFACE } from '../libelles.js'; import { TABLE as COMMUNS } from '../libelles/communs.js'; import { CONFIRMATION_DEBLOCAGE, DELAI_CONFIRMATION_MS, GESTES, SECTIONS_REMEDE, SOURCES_SECOURS, actionRemede, creerMachineCommande, libelleRemede, } from './commande.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); // Une séance d'épreuve : chaque appel se consigne, et rend ce que l'épreuve // lui donne. function seanceEnregistreuse() { const appels = []; const consigner = (nom) => (...args) => { appels.push([nom, ...args]); return Promise.resolve({ nom }); }; return { appels, etat: () => ({ evenement: { base: 'Soirée des Merles' } }), executer: consigner('executer'), passerEnEcriture: consigner('passerEnEcriture'), debloquer: consigner('debloquer'), ouvrirSecours: consigner('ouvrirSecours'), ecrireAilleurs: consigner('ecrireAilleurs'), }; } function uiEnregistreur() { const appels = []; return { appels, allerA: (section) => appels.push(['allerA', section]), montrerTour: (tour) => appels.push(['montrerTour', tour]), montrerProposition: (id) => appels.push(['montrerProposition', id]), }; } const RACINE = { id: 'documents', chemin: 'D:\\Exemple\\Documents' }; describe('commande : la machine', () => { test('le délai de confirmation est de dix secondes', () => { assert.equal(DELAI_CONFIRMATION_MS, 10_000); }); test('sans confirmer, chaque clic exécute, et la commande ne s’arme jamais', () => { const commande = creerMachineCommande(); assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.equal(commande.armee, false); }); test('avec confirmer : un clic arme, un second exécute et désarme', () => { const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE }); assert.equal(commande.armee, false); assert.deepEqual(commande.cliquer(), { action: 'armer' }); assert.equal(commande.armee, true); assert.deepEqual(commande.cliquer(), { action: 'executer' }); assert.equal(commande.armee, false); assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau'); }); test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => { const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE }); commande.cliquer(); commande.expirer(); assert.equal(commande.armee, false); assert.deepEqual(commande.cliquer(), { action: 'armer' }); }); }); describe('commande : le libellé d’un remède', () => { test('un remède de commande → communs.remede., ses arguments pour détails', () => { const remede = { commande: 'reintegrerParticipant', arguments: { id: 3 } }; assert.deepEqual(libelleRemede(remede), { cle: 'communs.remede.reintegrerParticipant', details: { id: 3 } }); }); test('un geste à variantes → communs.remede..', () => { assert.equal(libelleRemede({ geste: 'ouvrirSecours', source: 'precedent' }).cle, 'communs.remede.ouvrirSecours.precedent'); assert.equal(libelleRemede({ geste: 'ouvrirSecours', source: 'journal' }).cle, 'communs.remede.ouvrirSecours.journal'); assert.equal(libelleRemede({ geste: 'ecrireAilleurs', racine: RACINE }).cle, 'communs.remede.ecrireAilleurs.documents'); assert.equal(libelleRemede({ geste: 'ecrireAilleurs', racine: null }).cle, 'communs.remede.ecrireAilleurs.choisi'); assert.equal(libelleRemede({ geste: 'ouvrirSection', section: 'tables' }).cle, 'communs.remede.ouvrirSection.tables'); }); test('un geste sans variante → communs.remede. ; afficherTour porte son tour', () => { assert.deepEqual(libelleRemede({ geste: 'modifier' }), { cle: 'communs.remede.modifier', details: {} }); assert.deepEqual(libelleRemede({ geste: 'debloquer' }), { cle: 'communs.remede.debloquer', details: {} }); assert.deepEqual(libelleRemede({ geste: 'afficherTour', tour: 2, proposition: null }), { cle: 'communs.remede.afficherTour', details: { tour: 2 }, }); assert.equal(libelleRemede({ geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: {} } }).cle, 'communs.remede.choisirPlace'); }); test('un geste, une source ou une section hors du contrat lève RangeError en les nommant', () => { assert.throws(() => libelleRemede({ geste: 'voler' }), (e) => e instanceof RangeError && /voler/.test(e.message)); assert.throws(() => libelleRemede({ geste: 'ouvrirSecours', source: 'ailleurs' }), RangeError); assert.throws(() => libelleRemede({ geste: 'ouvrirSection', section: 'accueil' }), RangeError); assert.throws(() => libelleRemede({ commande: 'voler', arguments: {} }), RangeError); }); }); describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => { test('chaque commande de COMMANDES a son libellé de remède, une chaîne non vide', () => { const noms = [...COMMANDES.keys()]; assert.ok(noms.length >= 20, `${noms.length} commandes`); for (const nom of noms) { const { cle, details } = libelleRemede({ commande: nom, arguments: {} }); const texte = t(cle, details); assert.equal(typeof texte, 'string', nom); assert.ok(texte.length > 0 && !texte.includes('undefined'), `${nom} : « ${texte} »`); } }); test('chaque geste du contrat, chaque variante comprise, a son libellé', () => { assert.deepEqual([...GESTES], [ 'modifier', 'debloquer', 'afficherTour', 'ouvrirSection', 'choisirPlace', 'ouvrirSecours', 'ecrireAilleurs', ]); assert.deepEqual([...SOURCES_SECOURS], ['precedent', 'journal']); assert.deepEqual([...SECTIONS_REMEDE], ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']); const remedes = [ { geste: 'modifier' }, { geste: 'debloquer' }, { geste: 'afficherTour', tour: 3, proposition: null }, ...SECTIONS_REMEDE.map((section) => ({ geste: 'ouvrirSection', section })), { geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: {} } }, ...SOURCES_SECOURS.map((source) => ({ geste: 'ouvrirSecours', source })), { geste: 'ecrireAilleurs', racine: RACINE }, { geste: 'ecrireAilleurs', racine: null }, ]; assert.equal(remedes.length, 15); const textes = remedes.map((remede) => { const { cle, details } = libelleRemede(remede); return t(cle, details); }); for (const texte of textes) assert.ok(texte.length > 0 && !texte.includes('undefined'), texte); assert.equal(t('communs.remede.afficherTour', { tour: 3 }), 'Afficher le tour 3'); assert.equal(new Set(textes).size, textes.length, 'deux variantes ne se confondent pas'); }); test('reprendreVerrou, offert à côté de VERROU_PRIS, a sa clé', () => { assert.equal(t('communs.remede.reprendreVerrou'), 'Reprendre le verrou'); }); test('aucune clé communs.remede.* hors de l’énumération : chacune est atteinte par une commande, un geste ou reprendreVerrou', () => { const atteintes = new Set([ 'communs.remede.reprendreVerrou', ...[...COMMANDES.keys()].map((nom) => libelleRemede({ commande: nom, arguments: {} }).cle), libelleRemede({ geste: 'modifier' }).cle, libelleRemede({ geste: 'debloquer' }).cle, libelleRemede({ geste: 'afficherTour', tour: 1, proposition: null }).cle, libelleRemede({ geste: 'choisirPlace' }).cle, ...SECTIONS_REMEDE.map((section) => libelleRemede({ geste: 'ouvrirSection', section }).cle), ...SOURCES_SECOURS.map((source) => libelleRemede({ geste: 'ouvrirSecours', source }).cle), libelleRemede({ geste: 'ecrireAilleurs', racine: RACINE }).cle, libelleRemede({ geste: 'ecrireAilleurs', racine: null }).cle, ]); const posees = [...COMMUNS.keys()].filter((cle) => cle.startsWith('communs.remede.')); assert.ok(posees.length > 0); assert.deepEqual(posees.filter((cle) => !atteintes.has(cle)), []); }); test('chaque geste, source et section qu’un remède de src/application écrit en littéral est du contrat', () => { const dossier = new URL('../../application/', import.meta.url); const sources = readdirSync(dossier).filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js')); const releves = { geste: new Set(), source: new Set(), section: new Set() }; for (const nom of sources) { const texte = readFileSync(new URL(nom, dossier), 'utf8'); // Le geste de tout remède ; la source d'un secours et la section ouverte // dans leur remède seul — EtatPlaces porte aussi une source —, et la // section que generation.js passe à son aide versSection. for (const [, geste] of texte.matchAll(/\bgeste: '([A-Za-z]+)'/g)) releves.geste.add(geste); for (const [, source] of texte.matchAll(/geste: 'ouvrirSecours', source: '([A-Za-z]+)'/g)) releves.source.add(source); for (const [, , section] of texte.matchAll(/(geste: 'ouvrirSection', section: |versSection\()'([A-Za-z]+)'/g)) { releves.section.add(section); } } assert.ok(releves.geste.size >= 5, [...releves.geste].join(', ')); assert.ok(releves.section.size > 0 && releves.source.size > 0); assert.deepEqual([...releves.geste].filter((g) => !GESTES.includes(g)), []); assert.deepEqual([...releves.source].filter((s) => !SOURCES_SECOURS.includes(s)), []); assert.deepEqual([...releves.section].filter((s) => !SECTIONS_REMEDE.includes(s)), []); }); }); describe('commande : l’action d’un remède', () => { test('un remède null n’offre aucune action', () => { assert.equal(actionRemede(null, { seance: seanceEnregistreuse() }), null); }); test('un remède de commande exécute la commande telle quelle, par la séance', async () => { const seance = seanceEnregistreuse(); const action = actionRemede({ commande: 'retirerTitre', arguments: { table: 2, siege: 4 } }, { seance }); assert.equal(action.nom, 'retirerTitre'); assert.equal(action.cle, 'communs.remede.retirerTitre'); assert.equal(action.confirmer, null); await action.executer(); assert.deepEqual(seance.appels, [['executer', 'retirerTitre', { table: 2, siege: 4 }]]); }); test('modifier → passerEnEcriture ; ouvrirSecours → la base ouverte et la source ; ecrireAilleurs → la racine du remède', async () => { const seance = seanceEnregistreuse(); await actionRemede({ geste: 'modifier' }, { seance }).executer(); await actionRemede({ geste: 'ouvrirSecours', source: 'journal' }, { seance }).executer(); await actionRemede({ geste: 'ecrireAilleurs', racine: RACINE }, { seance }).executer(); await actionRemede({ geste: 'ecrireAilleurs', racine: null }, { seance }).executer(); assert.deepEqual(seance.appels, [ ['passerEnEcriture'], ['ouvrirSecours', 'Soirée des Merles', 'journal'], ['ecrireAilleurs', RACINE], ['ecrireAilleurs', null], ]); }); test('debloquer se confirme : son action porte la clé de confirmation, et appelle seance.debloquer', async () => { const seance = seanceEnregistreuse(); const action = actionRemede({ geste: 'debloquer' }, { seance }); assert.equal(action.confirmer, CONFIRMATION_DEBLOCAGE); assert.equal(CONFIRMATION_DEBLOCAGE, 'communs.commande.confirmerDeblocage'); assert.equal(action.nom, 'debloquer'); await action.executer(); assert.deepEqual(seance.appels, [['debloquer']]); }); test('debloquer offert comme commande se confirme aussi : aucun chemin ne débloque en un clic (§ 8.5)', async () => { const seance = seanceEnregistreuse(); const action = actionRemede({ commande: 'debloquer', arguments: {} }, { seance }); assert.equal(action.confirmer, CONFIRMATION_DEBLOCAGE); assert.equal(action.nom, 'debloquer'); await action.executer(); assert.deepEqual(seance.appels, [['executer', 'debloquer', {}]]); }); test('afficherTour et ouvrirSection passent par l’état de navigation ; sans lui, aucune action', () => { const seance = seanceEnregistreuse(); const ui = uiEnregistreur(); actionRemede({ geste: 'afficherTour', tour: 2, proposition: null }, { seance, ui }).executer(); actionRemede({ geste: 'ouvrirSection', section: 'participants' }, { seance, ui }).executer(); assert.deepEqual(ui.appels, [['montrerProposition', null], ['montrerTour', 2], ['allerA', 'participants']]); assert.equal(actionRemede({ geste: 'ouvrirSection', section: 'tables' }, { seance }), null); assert.deepEqual(seance.appels, []); }); test('choisirPlace, et tout geste que l’écran fournit, passent par gestes ; sans lui, choisirPlace n’offre aucune action', () => { const seance = seanceEnregistreuse(); const recus = []; const remede = { geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: { participant: 4 } } }; assert.equal(actionRemede(remede, { seance, ui: uiEnregistreur() }), null); const action = actionRemede(remede, { seance, gestes: { choisirPlace: (r) => recus.push(r) } }); action.executer(); assert.deepEqual(recus, [remede]); assert.equal(action.cle, 'communs.remede.choisirPlace'); }); });