gestion_table_tournante_libre/src/interface/evenement/modele.js
Mathieu Benoit 9d4f203ead [ADD] interface: single sources for settings, shortcuts and CSV columns
The generated documentation must list exactly what the screen shows. The
generation settings, the keyboard shortcuts and the CSV columns each live in
one table that both the interface and the documentation read; the target
field of the proposals section reads its entry too. A guard reports any key
named in the interface without its shortcut entry. The LISTE_SUR_VOISINE
warning now shows its remedy, moving the covered neighbour.
Checked: 3116 node and 643 browser tests green from the index alone.

--- FR ---

[ADD] interface : sources uniques des réglages, raccourcis, colonnes CSV

La documentation engendrée doit lister exactement ce que l'écran montre.
Les réglages de la génération, les raccourcis clavier et les colonnes du
CSV vivent chacun dans une table que lisent l'interface et la
documentation ; le champ de la cible des propositions lit aussi la sienne.
Une garde relève toute touche nommée dans l'interface sans son entrée de
raccourci. L'avertissement LISTE_SUR_VOISINE montre désormais son remède,
le déplacement de la voisine couverte.
Vérifié : 3116 node et 643 navigateur verts depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 04:51:45 -04:00

150 lines
6.9 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'événement ouvert, pur : ce que ses composants montrent de
// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre
// de mode et sa forme, la commande de la place fixe, les états du plan et
// leurs clés, la reprise de verrou que l'écran offre, et les avertissements
// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé
// que la séance ne décide déjà : le refus courant, le mode et l'état du plan
// se lisent dans EtatSeance, jamais se recalculent.
import { cleDeMode } from '../mode.js';
// La forme du cadre pour chaque clé du libellé de mode : un trait différent
// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5).
const FORMES = new Map([
['coquille.mode.lecture', 'tirets'],
['coquille.mode.ecriture', 'plein'],
['coquille.mode.bloque', 'double'],
]);
/**
* Libellé et forme du cadre de mode : cle, celle de cleDeMode (mode.js) — le
* libellé du bandeau — ; forme : lecture (tirets), écriture (trait plein),
* bloqué (trait double), quel que soit le mode d'un plan bloqué. null sans
* événement.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{cle: string, forme: 'tirets'|'plein'|'double'}}
*/
export function cadreDeMode(etatSeance) {
const cle = cleDeMode(etatSeance);
if (cle === null) return null;
return { cle, forme: FORMES.get(cle) };
}
/**
* La commande de la place fixe (§ 8.4, § 8.5, § 8.8) : 'format' quand le
* refus courant est celui d'un format plus récent — l'explication remplace
* alors toute commande, plan bloqué compris ; 'debloquer' sur un plan
* bloqué ; 'lecture' — « Revenir en lecture » — en écriture ; 'modifier'
* sinon. null sans événement.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|'format'|'debloquer'|'lecture'|'modifier'}
*/
export function commandeFixe(etatSeance) {
if (etatSeance.evenement === null) return null;
const { refus } = etatSeance;
if (refus?.code === 'LECTURE_SEULE' && refus.details?.raison === 'FORMAT') return 'format';
if (etatSeance.evenement.etat === 'bloque') return 'debloquer';
return etatSeance.mode === 'ecriture' ? 'lecture' : 'modifier';
}
/** Les états d'un plan (§ 9), dans l'ordre du spec. */
export const ETATS_PLAN = Object.freeze(['brouillon', 'propose', 'retenu', 'bloque']);
/**
* La clé du libellé d'un état du plan, evenement.etat.<etat>.
*
* @param {string} etat
* @returns {string}
* @throws {RangeError} hors d'ETATS_PLAN
*/
export function cleEtat(etat) {
if (!ETATS_PLAN.includes(etat)) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
return `evenement.etat.${etat}`;
}
/**
* Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a
* refusé le dernier passage en écriture quand sa vie ne se prouve pas
* (vivant null) — un autre poste, un verrou illisible. Un verrou vivant ne
* se reprend jamais, et celui d'une séance morte, la séance le reprend
* seule. null sinon.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{seance: string|null, depuis: string|null, vivant: null}}
*/
export function repriseOfferte(etatSeance) {
const { evenement, mode, verrouAutre } = etatSeance;
if (evenement === null || mode !== 'lecture' || verrouAutre === null || verrouAutre.vivant !== null) return null;
return verrouAutre;
}
// Le remède que l'écran joint à un avertissement : rouvrir la version
// précédente d'un retenu fautif ; ouvrir les propositions devant une dérive,
// et les tables devant des places manquantes. Les autres n'en ont pas.
const VERS_PROPOSITIONS = Object.freeze({ geste: 'ouvrirSection', section: 'propositions' });
const REMEDES = new Map([
['RETENU_FAUTIF', Object.freeze({ geste: 'ouvrirSecours', source: 'precedent' })],
['DERIVE', VERS_PROPOSITIONS],
['DERIVE_NOUVELLE', VERS_PROPOSITIONS],
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
]);
// Le remède d'un avertissement : celui qu'il porte en details.remede quand
// il en porte un — LISTE_SUR_VOISINE, le déplacement de la voisine que la
// commande a choisi —, sinon celui de REMEDES, sauf une réservation que le
// retenu ne tient pas à son siège (details.siege), que lève l'échange de sa
// personne avec l'occupant, ou, chaise vide, son déplacement au siège.
function remedeDe(code, details) {
if (details?.remede !== undefined && details.remede !== null) return details.remede;
if (code === 'RESERVATION_NON_HONOREE' && details.siege !== undefined) {
const { tour, participant, table, siege, occupant } = details;
return occupant === null
? { commande: 'deplacerDansRetenu', arguments: { tour, participant, table, siege } }
: { commande: 'echangerDansRetenu', arguments: { tour, a: participant, b: occupant } };
}
return REMEDES.get(code) ?? null;
}
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
// que l'examen ne porte pas n'apparaît pas.
function derivesNommees({ ids = [], retenu = false }, examen) {
if (examen === null || examen === undefined) return [];
const nommees = [];
if (retenu && examen.retenu.raisons.length > 0) nommees.push({ id: 'retenu', raisons: examen.retenu.raisons });
for (const id of ids) {
const derive = examen.derives.find((candidate) => candidate.id === id);
if (derive !== undefined) nommees.push({ id, raisons: derive.raisons });
}
return nommees;
}
/**
* Les avertissements à montrer, dans l'ordre d'EtatSeance.avertissements —
* celui de la séance : ceux de l'Ouvert courant, INVARIANT_VIOLE, ceux du
* dernier geste, RETOUR_AUTOMATIQUE —, sans en retirer ni en ajouter. Chacun
* garde son code et ses détails, et reçoit son remède : RETENU_FAUTIF →
* { geste: 'ouvrirSecours', source: 'precedent' } ; DERIVE et
* DERIVE_NOUVELLE → { geste: 'ouvrirSection', section: 'propositions' } ;
* PLACES_MANQUANTES → { geste: 'ouvrirSection', section: 'tables' } ; null
* pour les autres. PROPOSITIONS_FAUTIVES porte en plus fautives, celles
* d'EtatSeance.fautives ; DERIVE, derives, ce que l'examen dit de chaque
* placement qu'elle nomme ({id: number|'retenu', raisons}). Le texte vient
* de la traduction, sur le code.
*
* @param {Object} etatSeance EtatSeance
* @returns {Array<{code: string, details: Object, remede: Object|null, fautives?: Object[], derives?: Object[]}>}
*/
export function avertissementsOrdonnes(etatSeance) {
return etatSeance.avertissements.map(({ code, details }) => {
const rendu = { code, details, remede: remedeDe(code, details) };
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
return rendu;
});
}