gestion_table_tournante_libre/src/interface/primitives/commande.test.js

278 lines
14 KiB
JavaScript
Raw Normal View History

// © 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.<commande> ou
// communs.remede.<geste>[.<variante>] : 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.<commande>, 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.<geste>.<variante>', () => {
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.<geste> ; 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');
});
});