gestion_table_tournante_libre/src/interface/primitives/commande.test.js
Mathieu Benoit 876ea0f2ce [ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.

--- FR ---

[ADD] interface : primitives — champ, refus, confirmation, commande

Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00

277 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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');
});
});