diff --git a/src/interface/libelles/communs.js b/src/interface/libelles/communs.js index 0cbcb6b..46c1ff6 100644 --- a/src/interface/libelles/communs.js +++ b/src/interface/libelles/communs.js @@ -3,4 +3,85 @@ // Les libellés communs aux écrans : commandes, confirmations, remèdes d'un // refus (§ 14.6). Chaque clé commence par « communs. ». -export const TABLE = new Map(); +// +// communs.commande.* nomme les commandes de mode et de secours, que la place +// fixe de l'événement et les écrans de la liste reprennent. communs.remede.* +// nomme le remède d'un refus : communs.remede. pour chaque commande +// du registre (src/application/commandes.js), communs.remede. pour un +// geste du contrat, suivi de . quand ses arguments changent son +// libellé (primitives/commande.js, libelleRemede). Un remède qui fait ce que +// fait une commande de mode en reprend le texte, par texte. + +// Le libellé d'une autre clé de cette table. +const meme = (cle) => (details, texte) => texte(cle); + +export const TABLE = new Map([ + // --- Les commandes de mode et de secours (§ 8.4, § 8.5, § 8.6, § 8.8) ----- + ['communs.commande.modifier', 'Modifier'], + ['communs.commande.debloquer', 'Débloquer'], + ['communs.commande.confirmerDeblocage', 'Confirmer le déblocage'], + ['communs.commande.lecture', 'Revenir en lecture'], + ['communs.commande.ecrireDocuments', 'Écrire dans les Documents'], + ['communs.commande.choisirDossier', 'Choisir un dossier\u{2026}'], + ['communs.commande.ouvrirPrecedent', 'Ouvrir la version précédente'], + ['communs.commande.ouvrirJournal', 'Ouvrir le dernier instant du journal'], + + // --- La fenêtre à confirmer (§ 2.1) ------------------------------------------- + ['communs.confirmation.confirmer', 'Confirmer'], + ['communs.confirmation.annuler', 'Annuler'], + ['communs.confirmation.enCours', 'Opération en cours\u{2026}'], + + // --- Les remèdes des gestes du contrat ------------------------------------ + ['communs.remede.modifier', meme('communs.commande.modifier')], + ['communs.remede.debloquer', meme('communs.commande.debloquer')], + ['communs.remede.reprendreVerrou', 'Reprendre le verrou'], + ['communs.remede.afficherTour', ({ tour }) => `Afficher le tour ${tour}`], + ['communs.remede.choisirPlace', 'Choisir une place'], + ['communs.remede.ouvrirSecours.precedent', meme('communs.commande.ouvrirPrecedent')], + ['communs.remede.ouvrirSecours.journal', meme('communs.commande.ouvrirJournal')], + ['communs.remede.ecrireAilleurs.documents', meme('communs.commande.ecrireDocuments')], + ['communs.remede.ecrireAilleurs.choisi', meme('communs.commande.choisirDossier')], + ['communs.remede.ouvrirSection.import', "Ouvrir l'import"], + ['communs.remede.ouvrirSection.participants', 'Ouvrir les participants'], + ['communs.remede.ouvrirSection.tables', 'Ouvrir les tables'], + ['communs.remede.ouvrirSection.plan', 'Ouvrir le plan'], + ['communs.remede.ouvrirSection.diagnostic', 'Ouvrir le diagnostic'], + ['communs.remede.ouvrirSection.generation', 'Ouvrir la génération'], + ['communs.remede.ouvrirSection.propositions', 'Ouvrir les propositions'], + + // --- Les remèdes de commande : un par commande du registre ------------------ + // debloquer, commande et geste, porte la clé des gestes, plus haut. + ['communs.remede.ajouterParticipant', 'Ajouter la personne'], + ['communs.remede.modifierParticipant', 'Modifier la personne'], + ['communs.remede.exclureParticipant', 'Exclure la personne'], + ['communs.remede.reintegrerParticipant', 'Réintégrer la personne'], + ['communs.remede.supprimerParticipant', 'Supprimer la personne'], + ['communs.remede.importerParticipants', 'Importer la liste'], + ['communs.remede.changerEtat', "Changer l'état du plan"], + ['communs.remede.enregistrerGeneration', 'Enregistrer la génération'], + ['communs.remede.retenirProposition', 'Retenir la proposition'], + ['communs.remede.effacerPropositions', 'Effacer les propositions'], + ['communs.remede.creerTable', 'Créer une table'], + ['communs.remede.supprimerTable', 'Supprimer la table'], + ['communs.remede.deplacerTable', 'Déplacer la table'], + // sieges : le nombre de places que le remède donne à la table, quand il le + // fixe (SURRESERVATION, § 5.9). + [ + 'communs.remede.changerPlacesTable', + ({ sieges }) => (Number.isInteger(sieges) ? `Passer la table à ${sieges} places` : 'Changer le nombre de places'), + ], + ['communs.remede.realignerTable', 'Réaligner la table'], + ['communs.remede.changerDefaut', 'Changer le nombre de places par défaut'], + ['communs.remede.changerTours', 'Changer le nombre de tours'], + ['communs.remede.modifierReglage', 'Modifier le réglage'], + ['communs.remede.reserver', 'Réserver la place'], + ['communs.remede.retirerReservation', 'Retirer la réservation'], + ['communs.remede.deplacerReservation', 'Déplacer la réservation'], + ['communs.remede.poserTitre', 'Poser le titre'], + ['communs.remede.retirerTitre', 'Retirer le titre'], + ['communs.remede.designer', 'Désigner la personne'], + ['communs.remede.convertirEnTous', 'Réserver pour tous les tours'], + ['communs.remede.deplacerDansRetenu', 'Déplacer dans le placement retenu'], + ['communs.remede.echangerDansRetenu', 'Échanger les deux places'], + ['communs.remede.retirerDuTour', 'Retirer du tour'], +]); diff --git a/src/interface/primitives/Champ.svelte b/src/interface/primitives/Champ.svelte new file mode 100644 index 0000000..321f9c1 --- /dev/null +++ b/src/interface/primitives/Champ.svelte @@ -0,0 +1,228 @@ + + + + +
+ + + {#if refus !== null} + + {/if} +
+ + diff --git a/src/interface/primitives/Commande.svelte b/src/interface/primitives/Commande.svelte new file mode 100644 index 0000000..0c86c5f --- /dev/null +++ b/src/interface/primitives/Commande.svelte @@ -0,0 +1,98 @@ + + + + +
+ + {#if refus !== null} + (refus = null)} {gestes} /> + {/if} +
+ + diff --git a/src/interface/primitives/Confirmation.svelte b/src/interface/primitives/Confirmation.svelte new file mode 100644 index 0000000..c1e1018 --- /dev/null +++ b/src/interface/primitives/Confirmation.svelte @@ -0,0 +1,209 @@ + + + + + +

{t(titre, details)}

+
    + {#each elements as element, rang (rang)} +
  • {element}
  • + {/each} +
+ {#if refus !== null} +
+ (refus = null)} /> +
+ {/if} + {#if enAttente} +

{t('communs.confirmation.enCours')}

+ {/if} +
+ + +
+
+ + diff --git a/src/interface/primitives/Refus.svelte b/src/interface/primitives/Refus.svelte new file mode 100644 index 0000000..af7c445 --- /dev/null +++ b/src/interface/primitives/Refus.svelte @@ -0,0 +1,79 @@ + + + + +
+

{message}

+ {#if action !== null} + {#key action} + + {/key} + {/if} + {#each autres as autre, rang (rang)} + + {/each} +
+ + diff --git a/src/interface/primitives/champ.js b/src/interface/primitives/champ.js new file mode 100644 index 0000000..9339750 --- /dev/null +++ b/src/interface/primitives/champ.js @@ -0,0 +1,145 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La machine d'un champ de saisie (§ 2, § 8.2), pure : Champ.svelte la +// câble. Elle tient le texte de la valeur enregistrée (stockee), le texte +// saisi (saisie), le drapeau d'une saisie non validée (enCours) et le refus +// de la dernière validation (refus). Le texte saisi ne devient une valeur +// qu'à la validation, que la sortie du champ déclenche : jamais pendant la +// frappe, jamais par un minuteur. +// +// lire(texte) rend { valeur } ou { refus: {code, details} } ; la machine ne +// connaît ni la séance ni la commande, qu'exécute le composant. + +/** + * Une machine de champ. + * + * @param {{stockee: string, lire: (texte: string) => ({valeur: unknown}|{refus: {code: string, details: Object}})}} options + * @returns {{ + * readonly stockee: string, readonly saisie: string, readonly enCours: boolean, + * readonly refus: null|{code: string, details: Object}, + * saisir: (texte: string) => void, + * valider: () => ({action: 'aucune'}|{action: 'refus', refus: Object}|{action: 'executer', valeur: unknown}), + * abandonner: () => void, echec: (refus: Object) => void, recevoir: (texte: string) => void, + * }} + * valider rend 'aucune' quand rien n'est saisi ou que la saisie se lit + * comme la valeur enregistrée — la saisie revient alors à son texte ; + * 'refus' quand lire refuse, la saisie gardée et toujours en cours ; + * 'executer' sinon : la saisie devient le texte enregistré, la validation + * suivante ne relance rien, et echec(refus) défait ce passage quand + * l'exécution est refusée. abandonner (Échap) rend la valeur enregistrée. + * recevoir pose une valeur enregistrée nouvelle, venue d'ailleurs : la + * saisie la suit hors d'une saisie en cours. + */ +export function creerMachineChamp({ stockee, lire }) { + let enregistree = stockee; + let saisie = stockee; + let enCours = false; + let refus = null; + // Le texte enregistré d'avant la dernière exécution, qu'echec restitue. + let avant = stockee; + + // La valeur que lire donne d'un texte, ou undefined quand il la refuse. + const valeurDe = (texte) => { + const lu = lire(texte); + return Object.hasOwn(lu, 'refus') ? undefined : lu.valeur; + }; + + function terminer() { + saisie = enregistree; + enCours = false; + refus = null; + } + + return { + get stockee() { + return enregistree; + }, + get saisie() { + return saisie; + }, + get enCours() { + return enCours; + }, + get refus() { + return refus; + }, + saisir(texte) { + saisie = texte; + enCours = true; + refus = null; + }, + valider() { + if (!enCours || saisie === enregistree) { + terminer(); + return { action: 'aucune' }; + } + const lu = lire(saisie); + if (Object.hasOwn(lu, 'refus')) { + refus = lu.refus; + return { action: 'refus', refus }; + } + const ancienne = valeurDe(enregistree); + if (ancienne !== undefined && Object.is(ancienne, lu.valeur)) { + terminer(); + return { action: 'aucune' }; + } + avant = enregistree; + enregistree = saisie; + enCours = false; + refus = null; + return { action: 'executer', valeur: lu.valeur }; + }, + abandonner() { + terminer(); + }, + echec(refusRecu) { + enregistree = avant; + enCours = true; + refus = refusRecu; + }, + recevoir(texte) { + enregistree = texte; + avant = texte; + if (!enCours) saisie = texte; + }, + }; +} + +// Les codes d'un refus de la séance : ceux de son état, qu'exigerEcriture +// calcule — LECTURE_SEULE, MODE_LECTURE, SUPPORT_ROMPU —, et VERROU_PRIS, le +// passage en écriture refusé. Les raisons de LECTURE_SEULE qu'etat.refus +// porte ; les autres, ENCODAGE et FORMAT_PLUS_RECENT, tiennent le temps de +// la lecture. +const CODES_SEANCE = new Set(['LECTURE_SEULE', 'MODE_LECTURE', 'SUPPORT_ROMPU', 'VERROU_PRIS']); +const RAISONS_ETAT = new Set(['BLOQUE', 'FORMAT']); + +/** Vrai pour un refus qui nomme un état de la séance et non la saisie. */ +export const estRefusSeance = (refus) => CODES_SEANCE.has(refus.code); + +/** Deux refus de même code et de mêmes détails sont le même. */ +export const memeRefus = (a, b) => a.code === b.code && JSON.stringify(a.details) === JSON.stringify(b.details); + +/** + * Le refus de la séance qu'un champ affiche, confronté à l'état courant + * (EtatSeance) : le refus lui-même, le même objet, tant que l'état le + * tient ; null quand l'état ne le tient plus ; la cause courante quand une + * autre l'a remplacé. VERROU_PRIS tient tant qu'etat.verrouAutre est + * relevé ; LECTURE_SEULE d'une raison qu'etat.refus ne porte pas, tant que + * la séance est en lecture ; les autres suivent etat.refus. Un refus local + * passe tel quel. + * + * @param {{code: string, details: Object}} refus + * @param {{mode: string, refus: null|{code: string, details: Object}, verrouAutre: Object|null}} etat + * @returns {null|{code: string, details: Object}} + */ +export function refusSeanceCourant(refus, etat) { + if (!estRefusSeance(refus)) return refus; + if (refus.code === 'VERROU_PRIS') return etat.verrouAutre === null ? null : refus; + if (refus.code === 'LECTURE_SEULE' && !RAISONS_ETAT.has(refus.details?.raison)) { + return etat.mode === 'lecture' ? refus : null; + } + const courant = etat.refus; + if (courant === null) return null; + return memeRefus(courant, refus) ? refus : courant; +} diff --git a/src/interface/primitives/champ.test.js b/src/interface/primitives/champ.test.js new file mode 100644 index 0000000..addfc2a --- /dev/null +++ b/src/interface/primitives/champ.test.js @@ -0,0 +1,161 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La machine d'un champ (§ 2, § 8.2) : la saisie se tient hors de la valeur +// enregistrée jusqu'à la validation, que la sortie du champ déclenche ; Échap +// rend la valeur enregistrée ; une saisie que lire refuse garde son texte et +// porte le refus. Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { creerMachineChamp, estRefusSeance, refusSeanceCourant } from './champ.js'; + +// Une lecture d'épreuve : un texte blanc est refusé LIBELLE_VIDE, tout autre +// se lit sans ses blancs de bord. +function lireLibelle(texte) { + const valeur = texte.trim(); + if (valeur === '') return { refus: { code: 'LIBELLE_VIDE', details: { remede: null } } }; + return { valeur }; +} + +// Une lecture d'entier : NON_ENTIER pour ce qui n'en est pas un. +function lireEntier(texte) { + if (!/^\s*\d+\s*$/.test(texte)) return { refus: { code: 'NON_ENTIER', details: { saisie: texte, remede: null } } }; + return { valeur: Number(texte) }; +} + +describe('champ : la machine', () => { + test("au départ, la saisie est la valeur enregistrée, rien n'est en cours", () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + assert.equal(champ.stockee, 'Grisaille'); + assert.equal(champ.saisie, 'Grisaille'); + assert.equal(champ.enCours, false); + assert.equal(champ.refus, null); + }); + + test('saisir puis valider sans changement → aucune', () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + champ.saisir('Grisaill'); + champ.saisir('Grisaille'); + assert.equal(champ.enCours, true); + assert.deepEqual(champ.valider(), { action: 'aucune' }); + assert.equal(champ.enCours, false); + assert.equal(champ.saisie, 'Grisaille'); + }); + + test('une saisie qui se lit comme la valeur enregistrée → aucune, et la saisie revient à la valeur enregistrée', () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + champ.saisir(' Grisaille '); + assert.deepEqual(champ.valider(), { action: 'aucune' }); + assert.equal(champ.saisie, 'Grisaille'); + assert.equal(champ.enCours, false); + }); + + test('valider sans avoir rien saisi → aucune', () => { + const champ = creerMachineChamp({ stockee: '4', lire: lireEntier }); + assert.deepEqual(champ.valider(), { action: 'aucune' }); + }); + + test('Échap (abandonner) → la valeur enregistrée, rien en cours, aucun refus', () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + champ.saisir(''); + assert.equal(champ.valider().action, 'refus'); + champ.abandonner(); + assert.equal(champ.saisie, 'Grisaille'); + assert.equal(champ.enCours, false); + assert.equal(champ.refus, null); + }); + + test('une saisie que lire refuse → refus, la saisie gardée et toujours en cours', () => { + const champ = creerMachineChamp({ stockee: '4', lire: lireEntier }); + champ.saisir('quatre'); + const rendu = champ.valider(); + assert.deepEqual(rendu, { action: 'refus', refus: { code: 'NON_ENTIER', details: { saisie: 'quatre', remede: null } } }); + assert.equal(champ.saisie, 'quatre'); + assert.equal(champ.enCours, true); + assert.deepEqual(champ.refus, rendu.refus); + champ.saisir('quatr'); + assert.equal(champ.refus, null, 'une frappe retire le refus'); + }); + + test('une saisie lue → executer avec la valeur lue ; la saisie devient la valeur enregistrée', () => { + const champ = creerMachineChamp({ stockee: '4', lire: lireEntier }); + champ.saisir(' 6'); + assert.deepEqual(champ.valider(), { action: 'executer', valeur: 6 }); + assert.equal(champ.enCours, false); + assert.equal(champ.stockee, ' 6'); + assert.deepEqual(champ.valider(), { action: 'aucune' }, 'une seconde validation, la sortie après Entrée, ne relance rien'); + }); + + test("echec : le refus de l'exécution rend la valeur enregistrée d'avant, garde la saisie, et la remet en cours", () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + champ.saisir('Club des Merles'); + assert.equal(champ.valider().action, 'executer'); + const refus = { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } }; + champ.echec(refus); + assert.equal(champ.stockee, 'Grisaille'); + assert.equal(champ.saisie, 'Club des Merles'); + assert.equal(champ.enCours, true); + assert.deepEqual(champ.refus, refus); + assert.deepEqual(champ.valider(), { action: 'executer', valeur: 'Club des Merles' }); + }); + + test('recevoir : une valeur enregistrée nouvelle se montre hors saisie, et attend la fin de la saisie sinon', () => { + const champ = creerMachineChamp({ stockee: 'Grisaille', lire: lireLibelle }); + champ.recevoir('Club des Merles'); + assert.equal(champ.saisie, 'Club des Merles'); + champ.saisir('Iris'); + champ.recevoir('Ondine Lacasse'); + assert.equal(champ.saisie, 'Iris'); + assert.equal(champ.stockee, 'Ondine Lacasse'); + champ.abandonner(); + assert.equal(champ.saisie, 'Ondine Lacasse'); + }); + + test('une valeur enregistrée que lire refuse se compare par son texte', () => { + const champ = creerMachineChamp({ stockee: '', lire: lireLibelle }); + champ.saisir(''); + assert.deepEqual(champ.valider(), { action: 'aucune' }); + champ.saisir('Iris'); + assert.deepEqual(champ.valider(), { action: 'executer', valeur: 'Iris' }); + }); +}); + +describe('champ : le refus de la séance confronté à son état', () => { + const MODE_LECTURE = { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } }; + const BLOQUE = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } }; + const etatDe = (champs) => ({ mode: 'lecture', refus: null, verrouAutre: null, ...champs }); + + test('les codes de la séance : MODE_LECTURE, LECTURE_SEULE, SUPPORT_ROMPU, VERROU_PRIS ; un refus de lire ou de commande est local', () => { + for (const code of ['MODE_LECTURE', 'LECTURE_SEULE', 'SUPPORT_ROMPU', 'VERROU_PRIS']) { + assert.equal(estRefusSeance({ code, details: {} }), true, code); + } + for (const code of ['NOM_REQUIS', 'NON_ENTIER', 'SANS_EFFET', 'AUCUN_EVENEMENT']) { + assert.equal(estRefusSeance({ code, details: {} }), false, code); + } + }); + + test("tenu tant qu'etat.refus le porte, le même objet rendu ; levé quand etat.refus est null ; remplacé par une autre cause", () => { + const pose = { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } }; + assert.equal(refusSeanceCourant(pose, etatDe({ refus: MODE_LECTURE })), pose); + assert.equal(refusSeanceCourant(pose, etatDe({ mode: 'ecriture' })), null); + assert.equal(refusSeanceCourant(pose, etatDe({ refus: BLOQUE })), BLOQUE); + }); + + test("VERROU_PRIS : tenu tant qu'un verrou d'une autre séance est relevé, levé sinon, jamais remplacé par MODE_LECTURE", () => { + const pris = { code: 'VERROU_PRIS', details: { seance: 'epreuve-2', depuis: null, vivant: null, remede: null } }; + const autre = { seance: 'epreuve-2', depuis: null, vivant: null }; + assert.equal(refusSeanceCourant(pris, etatDe({ refus: MODE_LECTURE, verrouAutre: autre })), pris); + assert.equal(refusSeanceCourant(pris, etatDe({ refus: MODE_LECTURE })), null); + }); + + test("LECTURE_SEULE d'une raison qu'etat.refus ne porte pas (ENCODAGE) : tenu en lecture, levé en écriture", () => { + const encodage = { code: 'LECTURE_SEULE', details: { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } } }; + assert.equal(refusSeanceCourant(encodage, etatDe({ refus: MODE_LECTURE })), encodage); + assert.equal(refusSeanceCourant(encodage, etatDe({ mode: 'ecriture' })), null); + }); + + test('un refus local passe tel quel', () => { + const local = { code: 'NOM_REQUIS', details: { remede: null } }; + assert.equal(refusSeanceCourant(local, etatDe({ mode: 'ecriture' })), local); + }); +}); diff --git a/src/interface/primitives/commande.js b/src/interface/primitives/commande.js new file mode 100644 index 0000000..fa333cf --- /dev/null +++ b/src/interface/primitives/commande.js @@ -0,0 +1,180 @@ +// © 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), purs : Commande.svelte et Refus.svelte les câblent. +// +// Une commande sans confirmation exécute à chaque clic. Une commande à +// confirmer s'arme au premier clic — son libellé devient celui de la +// confirmation — et n'exécute qu'au second ; le composant la désarme au +// terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin +// n'exécute une commande à confirmer en un clic. +// +// Un remède (contrat « Refus et avertissements ») est null, une commande +// exécutable telle quelle, { commande, arguments }, ou un geste, +// { geste, … }. Son libellé est la clé communs.remede., ou +// communs.remede., suivie de . quand le geste a plusieurs +// libellés selon ses arguments : la source d'ouvrirSecours, la racine +// d'ecrireAilleurs — documents quand elle est donnée, choisi quand +// l'opérateur la désigne —, la section d'ouvrirSection. Son action passe par +// la séance, par l'état de navigation pour ce qui ne fait que montrer, ou +// par un geste que l'écran fournit : choisirPlace se choisit sur le plan. + +import { COMMANDES } from '../../application/commandes.js'; + +/** Le délai au terme duquel une commande armée se désarme. */ +export const DELAI_CONFIRMATION_MS = 10_000; + +/** La clé du libellé que prend « Débloquer » une fois armée (§ 8.5). */ +export const CONFIRMATION_DEBLOCAGE = 'communs.commande.confirmerDeblocage'; + +/** Les gestes d'un remède, ceux du contrat de données. */ +export const GESTES = Object.freeze([ + 'modifier', + 'debloquer', + 'afficherTour', + 'ouvrirSection', + 'choisirPlace', + 'ouvrirSecours', + 'ecrireAilleurs', +]); + +/** Les sources d'un secours (seance.ouvrirSecours). */ +export const SOURCES_SECOURS = Object.freeze(['precedent', 'journal']); + +/** Les sections qu'ouvre un remède : celles de l'événement ouvert, dans l'ordre du § 2.1. */ +export const SECTIONS_REMEDE = Object.freeze([ + 'import', + 'participants', + 'tables', + 'plan', + 'diagnostic', + 'generation', + 'propositions', +]); + +/** + * Une machine de commande. + * + * @param {{confirmer?: string|null}} [options] la clé du libellé de + * confirmation ; null pour une commande qui exécute au premier clic + * @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}} + * cliquer arme une commande à confirmer désarmée, et exécute sinon, en la + * désarmant ; expirer la désarme. + */ +export function creerMachineCommande({ confirmer = null } = {}) { + let armee = false; + return { + get armee() { + return armee; + }, + cliquer() { + if (confirmer !== null && !armee) { + armee = true; + return { action: 'armer' }; + } + armee = false; + return { action: 'executer' }; + }, + expirer() { + armee = false; + }, + }; +} + +// Une valeur d'un remède qui doit appartenir à sa liste : RangeError qui la +// nomme sinon, plutôt qu'une clé de traduction que rien ne porte. +function exigerParmi(valeur, liste, quoi) { + if (!liste.includes(valeur)) throw new RangeError(`remède : ${quoi} inconnu ${JSON.stringify(valeur)}`); + return valeur; +} + +/** + * Le libellé d'un remède : sa clé dans la table communs et les détails que + * le texte reçoit — les arguments d'une commande, le tour d'afficherTour. + * + * @param {Object} remede non null + * @returns {{cle: string, details: Object}} + * @throws {RangeError} une commande, un geste, une source ou une section + * hors du registre ou du contrat + */ +export function libelleRemede(remede) { + if (Object.hasOwn(remede, 'commande')) { + if (!COMMANDES.has(remede.commande)) throw new RangeError(`remède : commande inconnue ${JSON.stringify(remede.commande)}`); + return { cle: `communs.remede.${remede.commande}`, details: remede.arguments ?? {} }; + } + const geste = exigerParmi(remede.geste, GESTES, 'geste'); + if (geste === 'ouvrirSecours') { + return { cle: `communs.remede.ouvrirSecours.${exigerParmi(remede.source, SOURCES_SECOURS, 'source')}`, details: {} }; + } + if (geste === 'ecrireAilleurs') { + return { cle: `communs.remede.ecrireAilleurs.${remede.racine ? 'documents' : 'choisi'}`, details: {} }; + } + if (geste === 'ouvrirSection') { + return { cle: `communs.remede.ouvrirSection.${exigerParmi(remede.section, SECTIONS_REMEDE, 'section')}`, details: {} }; + } + if (geste === 'afficherTour') return { cle: 'communs.remede.afficherTour', details: { tour: remede.tour } }; + return { cle: `communs.remede.${geste}`, details: {} }; +} + +// Ce que fait un geste du remède, par la séance ou par l'état de navigation ; +// null quand ce qui le ferait manque. +function executionDuGeste(remede, seance, ui) { + switch (remede.geste) { + case 'modifier': + return () => seance.passerEnEcriture(); + case 'debloquer': + return () => seance.debloquer(); + case 'ouvrirSecours': + return () => seance.ouvrirSecours(seance.etat().evenement.base, remede.source); + case 'ecrireAilleurs': + return () => seance.ecrireAilleurs(remede.racine ?? null); + case 'afficherTour': + if (ui === null) return null; + return () => { + ui.montrerProposition(remede.proposition ?? null); + ui.montrerTour(remede.tour); + }; + case 'ouvrirSection': + if (ui === null) return null; + return () => ui.allerA(remede.section); + default: + return null; + } +} + +/** + * L'action qu'offre un remède : de quoi poser une Commande à côté du refus. + * Un geste que gestes fournit passe par lui, avant tout autre chemin ; + * choisirPlace n'a pas d'autre chemin. Un remède de commande s'exécute par + * seance.executer, tel quel ; modifier, debloquer, ouvrirSecours et + * ecrireAilleurs par la méthode de la séance de même nom — passerEnEcriture + * pour modifier, la base de l'événement ouvert pour ouvrirSecours ; + * afficherTour et ouvrirSection par l'état de navigation, ui. + * + * @param {Object|null} remede + * @param {{seance: Object, ui?: Object|null, gestes?: Object unknown>}} dependances + * @returns {null|{nom: string, cle: string, details: Object, confirmer: string|null, executer: () => unknown}} + * null sans remède, ou quand rien ne sait l'exécuter ; nom : la commande + * ou le geste, que porte data-commande ; confirmer : la clé de la + * confirmation, pour debloquer seul, geste ou commande + */ +export function actionRemede(remede, { seance, ui = null, gestes = {} }) { + if (remede === null || remede === undefined) return null; + const { cle, details } = libelleRemede(remede); + const nom = remede.commande ?? remede.geste; + // debloquer se confirme, qu'il vienne en geste ou en commande : aucun + // chemin ne débloque en un clic (§ 8.5). + const confirmer = nom === 'debloquer' ? CONFIRMATION_DEBLOCAGE : null; + let executer; + if (remede.geste !== undefined && Object.hasOwn(gestes, remede.geste)) { + const fourni = gestes[remede.geste]; + executer = () => fourni(remede); + } else if (remede.commande !== undefined) { + executer = () => seance.executer(remede.commande, remede.arguments ?? {}); + } else { + executer = executionDuGeste(remede, seance, ui ?? null); + } + if (executer === null) return null; + return { nom, cle, details, confirmer, executer }; +} diff --git a/src/interface/primitives/commande.test.js b/src/interface/primitives/commande.test.js new file mode 100644 index 0000000..bf703d8 --- /dev/null +++ b/src/interface/primitives/commande.test.js @@ -0,0 +1,277 @@ +// © 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'); + }); +}); diff --git a/src/interface/primitives/geste.js b/src/interface/primitives/geste.js new file mode 100644 index 0000000..a278e23 --- /dev/null +++ b/src/interface/primitives/geste.js @@ -0,0 +1,63 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le geste qu'une primitive tient ouvert sur la séance (§ 8.4) : une saisie +// non validée, une fenêtre à confirmer ouverte. Un geste compté par la +// séance suspend son compte d'inactivité, qui ne la ramène pas en lecture +// sous l'opérateur. +// +// Le geste ne se compte qu'en écriture, seul mode où le compte d'inactivité +// court. Le retour en lecture, explicite ou à la fermeture de l'événement, +// remet le compte de la séance à zéro et l'annonce par surAnnulation : le +// geste n'est plus compté, et finirGeste n'est pas appelé pour lui — il +// retirerait le geste d'une autre primitive. Le retour automatique n'a lieu +// que sans geste compté. Le passage en écriture suivant, que subscribe +// publie, recompte le geste que la primitive tient encore. + +/** + * Le geste d'une primitive sur une séance. + * + * @param {{subscribe: Function, surAnnulation: Function, debuterGeste: Function, finirGeste: Function}} seance + * @returns {{tenir: (voulu: boolean) => void, liberer: () => void}} + * tenir(voulu) dit si la primitive tient le geste ouvert ; liberer, au + * démontage, finit le geste compté et désabonne. + */ +export function creerGeste(seance) { + // voulu : la primitive tient le geste ; compte : la séance le compte ; + // ecriture : le mode publié en dernier, ou annoncé quitté. + let voulu = false; + let compte = false; + let ecriture = false; + + function ajuster() { + if (voulu && ecriture && !compte) { + compte = true; + seance.debuterGeste(); + } else if (!voulu && compte) { + compte = false; + seance.finirGeste(); + } + } + + const desabonnerEtat = seance.subscribe((etat) => { + ecriture = etat.mode === 'ecriture'; + ajuster(); + }); + const desabonnerAnnulation = seance.surAnnulation(() => { + ecriture = false; + compte = false; + }); + + return { + tenir(nouveau) { + voulu = nouveau; + ajuster(); + }, + liberer() { + voulu = false; + ajuster(); + desabonnerEtat(); + desabonnerAnnulation(); + }, + }; +} diff --git a/src/interface/primitives/geste.test.js b/src/interface/primitives/geste.test.js new file mode 100644 index 0000000..77e6baa --- /dev/null +++ b/src/interface/primitives/geste.test.js @@ -0,0 +1,113 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le geste qu'une primitive tient ouvert sur la séance (§ 8.4), sur une +// séance factice qui compte ses gestes comme celle d'A1 : debuterGeste et +// finirGeste s'emboîtent, un fini de trop ne compte pas, et le retour +// explicite en lecture remet le compte à zéro avant d'annoncer l'annulation. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { creerGeste } from './geste.js'; + +function seanceFactice(mode = 'ecriture') { + const abonnes = new Set(); + const annulations = new Set(); + const seance = { + mode, + gestes: 0, + appels: [], + subscribe(fn) { + abonnes.add(fn); + fn({ mode: seance.mode }); + return () => abonnes.delete(fn); + }, + surAnnulation(fn) { + annulations.add(fn); + return () => annulations.delete(fn); + }, + debuterGeste() { + seance.gestes += 1; + seance.appels.push('debuter'); + }, + finirGeste() { + seance.gestes = Math.max(0, seance.gestes - 1); + seance.appels.push('finir'); + }, + passerEnLecture() { + seance.gestes = 0; + seance.mode = 'lecture'; + for (const fn of [...annulations]) fn(); + for (const fn of [...abonnes]) fn({ mode: seance.mode }); + }, + passerEnEcriture() { + seance.mode = 'ecriture'; + for (const fn of [...abonnes]) fn({ mode: seance.mode }); + }, + abonnes, + annulations, + }; + return seance; +} + +describe('geste : le geste tenu par une primitive', () => { + test('tenir en écriture ouvre un geste, une seule fois ; ne plus le tenir le finit', () => { + const seance = seanceFactice(); + const geste = creerGeste(seance); + geste.tenir(true); + geste.tenir(true); + assert.equal(seance.gestes, 1); + geste.tenir(false); + geste.tenir(false); + assert.equal(seance.gestes, 0); + assert.deepEqual(seance.appels, ['debuter', 'finir']); + }); + + test("en lecture, rien ne s'ouvre ; le passage en écriture ouvre le geste tenu", () => { + const seance = seanceFactice('lecture'); + const geste = creerGeste(seance); + geste.tenir(true); + assert.equal(seance.gestes, 0); + seance.passerEnEcriture(); + assert.equal(seance.gestes, 1); + }); + + test("le retour en lecture remet le compte à zéro : le geste n'est plus tenu, et le passage en écriture suivant le rouvre", () => { + const seance = seanceFactice(); + const geste = creerGeste(seance); + geste.tenir(true); + seance.passerEnLecture(); + assert.equal(seance.gestes, 0); + seance.passerEnEcriture(); + assert.equal(seance.gestes, 1, 'le geste se rouvre'); + seance.passerEnLecture(); + geste.tenir(false); + assert.deepEqual(seance.appels, ['debuter', 'debuter'], "aucun finir sur un compte que la séance a remis à zéro"); + }); + + test("un geste d'une autre primitive n'est pas fini par celle dont la séance a oublié le sien", () => { + const seance = seanceFactice(); + const premier = creerGeste(seance); + const second = creerGeste(seance); + premier.tenir(true); + seance.passerEnLecture(); + seance.passerEnEcriture(); + second.tenir(true); + assert.equal(seance.gestes, 2); + premier.tenir(false); + assert.equal(seance.gestes, 1, 'le geste du second reste compté'); + }); + + test('liberer finit le geste tenu et désabonne', () => { + const seance = seanceFactice(); + const geste = creerGeste(seance); + assert.equal(seance.abonnes.size, 1); + assert.equal(seance.annulations.size, 1); + geste.tenir(true); + geste.liberer(); + assert.equal(seance.gestes, 0); + assert.equal(seance.abonnes.size, 0); + assert.equal(seance.annulations.size, 0); + seance.passerEnEcriture(); + assert.equal(seance.gestes, 0); + }); +}); diff --git a/src/interface/primitives/primitives.navigateur.test.js b/src/interface/primitives/primitives.navigateur.test.js new file mode 100644 index 0000000..82f1891 --- /dev/null +++ b/src/interface/primitives/primitives.navigateur.test.js @@ -0,0 +1,968 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des primitives dans un vrai moteur de rendu (§ 2.1, § 5.9, § 8.2, +// § 8.4, § 8.5) : le champ, le refus, la fenêtre à confirmer et la commande, +// montés chacun par mount avec les contextes que pose App — 'seance', 't', +// 'horloge' —, sur une séance créée sur le système de fichiers simulé et +// l'horloge pilotée : rien n'attend le temps réel, et chaque écriture se +// compte. Les gestes passent par des entrées que Chromium traite comme +// réelles (test/navigateur/entrees.js). Les noms d'épreuve sont inventés. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { flushSync, mount, tick, unmount } from 'svelte'; +import '../jetons.css'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerHorlogePilotee } from '../../application/horloge.js'; +import { INACTIVITE_MS, creerSeance } from '../../application/seance.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { serialiserCharge } from '../../stockage/canonique.js'; +import { lireJournal, reconstruire } from '../../stockage/journal.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { VERSION } from '../../version.genere.js'; +import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; +import { pointer, touche } from '../../../test/navigateur/entrees.js'; +import { visible } from '../../../test/navigateur/rendu.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import Champ from './Champ.svelte'; +import Commande from './Commande.svelte'; +import Confirmation from './Confirmation.svelte'; +import { DELAI_CONFIRMATION_MS } from './commande.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const EVENEMENT = 'Soirée des Merles'; +const NOM = 'Ombrelle'; + +// Les composants montés par l'épreuve courante, et leurs conteneurs. +const montes = []; + +afterEach(() => { + for (const { composant, cible } of montes.splice(0)) { + unmount(composant); + cible.remove(); + } + vi.restoreAllMocks(); +}); + +// Une séance sur le système simulé et l'horloge pilotée, un événement créé, +// Iris Ombrelle ajoutée (participant 1) ; puis bloque : plan bloqué, et la +// séance revient seule en lecture ; mode 'lecture' : retour en lecture. +async function preparer({ mode = 'ecriture', bloque = false } = {}) { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + const { documents } = await fs.racines(); + const horloge = creerHorlogePilotee(); + let tirages = 0; + const seance = creerSeance({ + fs, + racine: documents, + horloge, + identifiant: () => { + tirages += 1; + return `epreuve-${tirages}`; + }, + produitVersion: VERSION.affichee, + planifier: horloge.planifier, + annuler: horloge.annuler, + }); + await seance.creer({ nom: EVENEMENT, date: null, siegesParDefaut: 6, tours: 2 }); + await seance.passerEnEcriture(); + await seance.executer('ajouterParticipant', { champs: { nom: NOM, prenom: 'Iris' } }); + if (bloque) await seance.executer('changerEtat', { etat: 'bloque' }); + if (mode === 'lecture') await seance.passerEnLecture(); + const base = seance.etat().evenement.base; + const contexte = new Map([ + ['seance', seance], + ['t', t], + ['horloge', horloge], + ]); + return { fs, horloge, seance, base, contexte }; +} + +// Monte un composant dans un conteneur neuf, au début du corps. +function monter(Composant, props, contexte) { + const cible = document.createElement('div'); + document.body.prepend(cible); + const composant = mount(Composant, { target: cible, props, context: contexte }); + flushSync(); + montes.push({ composant, cible }); + return cible; +} + +// Le champ du nom d'Iris : lire refuse un texte blanc, NOM_REQUIS ; executer +// passe par la séance, et chaque promesse rendue se garde dans attentes. +function monterChamp(montage) { + const attentes = []; + const props = { + cle: 'nom', + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: (texte) => (texte.trim() === '' ? { refus: { code: 'NOM_REQUIS', details: { remede: null } } } : { valeur: texte.trim() }), + executer: (valeur) => { + const promesse = montage.seance.executer('modifierParticipant', { id: 1, champs: { nom: valeur } }); + attentes.push(promesse); + return promesse; + }, + }; + const cible = monter(Champ, props, montage.contexte); + return { cible, entree: cible.querySelector('input[data-champ="nom"]'), attentes }; +} + +const centre = (element) => { + const b = element.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; +}; + +// Un clic réel au centre de l'élément. +async function cliquer(element) { + const { x, y } = centre(element); + await pointer('presser', x, y); + await pointer('relacher', x, y); + await tick(); +} + +// Un clic réel dans le bas de la fenêtre, où rien ne se monte : le champ qui +// a le focus le perd. +async function cliquerAilleurs() { + await pointer('presser', 4, innerHeight - 4); + await pointer('relacher', 4, innerHeight - 4); + await tick(); +} + +async function taper(texte) { + for (const caractere of texte) await touche(caractere); + await tick(); +} + +// Les lignes du journal de l'événement. +function lignesJournal(montage) { + return montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`).split('\n').filter((l) => l !== ''); +} + +// Attend que la file de la séance ait passé tout ce qui s'y trouve — le +// retour automatique qu'une minuterie échue vient d'y poser compris — par +// une lecture admise dans tout mode, qui n'écrit rien et dont le rendu, +// réussi ou refusé, n'importe pas. +async function viderFile(montage) { + await montage.seance.formeNommee(null).catch(() => undefined); +} + +const verrouPresent = (montage) => montage.fs.contenu('documents', `${montage.base}${SUFFIXES.verrou}`) !== null; + +// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent. +function ecart(a, b) { + const ba = a.getBoundingClientRect(); + const bb = b.getBoundingClientRect(); + const dx = Math.max(0, ba.left - bb.right, bb.left - ba.right); + const dy = Math.max(0, ba.top - bb.bottom, bb.top - ba.bottom); + return Math.max(dx, dy); +} + +// Aucune fenêtre ni boîte de dialogue dans le document. +function aucuneFenetre() { + expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0); +} + +describe('champ : la fin du geste (§ 2.1, § 8.2)', () => { + test("taper sans quitter le champ n'écrit rien, même 30 min plus tard ; le quitter écrit une ligne, et la dernière entrée reconstruit la charge écrite", async () => { + const montage = await preparer(); + const { entree, attentes } = monterChamp(montage); + const ecritures = montage.fs.compteurs.ecritures; + const lignes = lignesJournal(montage).length; + const revision = montage.seance.etat().evenement.revision; + + await cliquer(entree); + await taper(' Lacasse'); + montage.horloge.avancer(30 * 60 * 1000); + await tick(); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + expect(entree.value).toBe(`${NOM} Lacasse`); + expect(attentes).toHaveLength(0); + + await cliquerAilleurs(); + expect(attentes).toHaveLength(1); + await Promise.all(attentes); + expect(lignesJournal(montage)).toHaveLength(lignes + 1); + const etat = montage.seance.etat(); + expect(etat.evenement.revision).toBe(revision + 1); + expect(etat.charge.participants[0].nom).toBe(`${NOM} Lacasse`); + const journal = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`)); + const derniere = journal.entrees.at(-1); + expect(derniere.revision).toBe(revision + 1); + expect(serialiserCharge(reconstruire(journal, derniere.revision))).toBe(serialiserCharge(etat.charge)); + }); + + test('une saisie que lire refuse affiche son refus contre le champ, garde le texte et n’écrit rien ; Échap rend la valeur enregistrée', async () => { + const montage = await preparer(); + const { cible, entree, attentes } = monterChamp(montage); + const ecritures = montage.fs.compteurs.ecritures; + await cliquer(entree); + for (let i = 0; i < NOM.length; i += 1) await touche('Backspace'); + await cliquerAilleurs(); + const message = cible.querySelector('[data-refus]'); + expect(message).not.toBeNull(); + expect(message.querySelector('p').textContent).toBe(t('NOM_REQUIS', { remede: null })); + expect(entree.getAttribute('aria-invalid')).toBe('true'); + expect(entree.getAttribute('aria-describedby')).toBe(message.id); + expect(message.id).not.toBe(''); + expect(entree.value).toBe(''); + expect(attentes).toHaveLength(0); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + await cliquer(entree); + await touche('Escape'); + await tick(); + expect(entree.value).toBe(NOM); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + }); +}); + +describe('champ : le refus de l’exécution (§ 2.1, § 5.9)', () => { + test('executer refusé avec un code : le refus se pose contre le champ, la saisie gardée, rien d’écrit ; le refus levé par son remède, le champ rend la valeur enregistrée, et une saisie nouvelle s’écrit', async () => { + const montage = await preparer(); + const { cible, entree, attentes } = monterChamp(montage); + const lignes = lignesJournal(montage).length; + await cliquer(entree); + await taper(' Lacasse'); + // Le retour en lecture part avant la sortie du champ : la séance passe ses + // appels dans l'ordre, et modifierParticipant arrive en lecture. + const retour = montage.seance.passerEnLecture(); + entree.blur(); + expect(attentes).toHaveLength(1); + await retour; + const ecritures = montage.fs.compteurs.ecritures; + await expect(attentes[0]).rejects.toMatchObject({ code: 'MODE_LECTURE' }); + await tick(); + const message = cible.querySelector('[data-refus]'); + expect(message).not.toBeNull(); + expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } })); + expect(visible(message)).toBe(true); + expect(ecart(message, entree)).toBeLessThanOrEqual(8); + expect(entree.getAttribute('aria-invalid')).toBe('true'); + expect(entree.getAttribute('aria-describedby')).toBe(message.id); + expect(message.id).not.toBe(''); + expect(entree.value).toBe(`${NOM} Lacasse`); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + expect(lignesJournal(montage)).toHaveLength(lignes); + expect(montage.seance.etat().charge.participants[0].nom).toBe(NOM); + aucuneFenetre(); + + await cliquer(message.querySelector('button[data-commande="modifier"]')); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + // Le refus levé, la saisie refusée ne reste pas affichée comme si elle + // avait été appliquée : le champ rend la valeur enregistrée. + expect(entree.value).toBe(NOM); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + await cliquer(entree); + await touche('Enter'); + await tick(); + expect(attentes).toHaveLength(1); + await taper(' Lacasse'); + await touche('Enter'); + await tick(); + expect(attentes).toHaveLength(2); + await attentes[1]; + expect(lignesJournal(montage)).toHaveLength(lignes + 1); + expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`); + }); + + test('executer refusé par un code de la séance : la séance passée en écriture par un autre chemin que le remède, le refus quitte le champ, qui rend la valeur enregistrée et le compte d’inactivité', async () => { + const montage = await preparer(); + const { cible, entree, attentes } = monterChamp(montage); + await cliquer(entree); + await taper(' Lacasse'); + const retour = montage.seance.passerEnLecture(); + entree.blur(); + await retour; + await expect(attentes[0]).rejects.toMatchObject({ code: 'MODE_LECTURE' }); + await tick(); + expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('MODE_LECTURE'); + await montage.seance.passerEnEcriture(); + flushSync(); + expect(montage.seance.etat().refus).toBeNull(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + expect(entree.getAttribute('aria-describedby')).toBeNull(); + expect(entree.value).toBe(NOM); + // La saisie rendue ne tient plus de geste : l'inactivité ramène la + // séance en lecture. + const lignes = lignesJournal(montage).length; + montage.horloge.avancer(INACTIVITE_MS + 1); + await viderFile(montage); + expect(montage.seance.etat().mode).toBe('lecture'); + // Entrée ne relance rien : aucune saisie n'est plus en cours. + await cliquer(entree); + await touche('Enter'); + await tick(); + expect(attentes).toHaveLength(1); + expect(lignesJournal(montage)).toHaveLength(lignes); + expect(montage.seance.etat().charge.participants[0].nom).toBe(NOM); + }); + + test('executer refusé par un code de la séance que l’état ne tient déjà plus : le refus ne se pose pas, et le champ rend la valeur enregistrée', async () => { + const montage = await preparer(); + const appels = []; + // Un refus MODE_LECTURE qui arrive la séance en écriture : il a été levé + // avant d'arriver. + const cible = monter( + Champ, + { + cle: 'nom', + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: (texte) => ({ valeur: texte.trim() }), + executer: (valeur) => { + appels.push(valeur); + return Promise.reject(Object.assign(new Error('refus d’épreuve'), { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } })); + }, + }, + montage.contexte, + ); + const entree = cible.querySelector('input[data-champ="nom"]'); + await cliquer(entree); + await taper(' Lacasse'); + await cliquerAilleurs(); + expect(appels).toEqual([`${NOM} Lacasse`]); + await vi.waitFor(() => expect(entree.value).toBe(NOM)); + flushSync(); + expect(montage.seance.etat().refus).toBeNull(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + montage.horloge.avancer(INACTIVITE_MS + 1); + await viderFile(montage); + expect(montage.seance.etat().mode).toBe('lecture'); + }); + + test('une autre cause remplace le refus de l’exécution : la saisie refusée reste affichée, marquée, tant qu’un refus de la séance la couvre', async () => { + const montage = await preparer(); + const { cible, entree, attentes } = monterChamp(montage); + await cliquer(entree); + await taper(' Lacasse'); + // Le support rompt pendant la saisie : sa validation est refusée + // SUPPORT_ROMPU, puis le retour en lecture remplace cette cause. + montage.fs.pannes.ecriture('documents'); + await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' }); + await cliquerAilleurs(); + await expect(attentes[0]).rejects.toMatchObject({ code: 'SUPPORT_ROMPU' }); + await tick(); + expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('SUPPORT_ROMPU'); + expect(entree.value).toBe(`${NOM} Lacasse`); + await montage.seance.passerEnLecture(); + flushSync(); + const message = cible.querySelector('[data-refus]'); + expect(message?.getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(entree.value).toBe(`${NOM} Lacasse`); + expect(entree.getAttribute('aria-invalid')).toBe('true'); + expect(entree.getAttribute('aria-describedby')).toBe(message.id); + }); + + test('le remède d’un refus de la séance ne retire que lui : le refus de lire qu’il couvrait reparaît, la saisie fautive toujours là', async () => { + const montage = await preparer(); + const { cible, entree } = monterChamp(montage); + await cliquer(entree); + for (let i = 0; i < NOM.length; i += 1) await touche('Backspace'); + await cliquerAilleurs(); + expect(cible.querySelector('[data-refus]')?.getAttribute('data-refus')).toBe('NOM_REQUIS'); + await montage.seance.passerEnLecture(); + await cliquer(entree); + await taper('z'); + const message = cible.querySelector('[data-refus]'); + expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); + await cliquer(message.querySelector('button[data-commande="modifier"]')); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture')); + await tick(); + flushSync(); + expect(entree.value).toBe(''); + const reste = cible.querySelector('[data-refus]'); + expect(reste?.getAttribute('data-refus')).toBe('NOM_REQUIS'); + expect(entree.getAttribute('aria-invalid')).toBe('true'); + expect(entree.getAttribute('aria-describedby')).toBe(reste.id); + }); +}); + +describe('champ : le refus là où il a lieu (§ 2.1, § 5.9)', () => { + // Tape un caractère dans le champ et rend le message posé contre lui ; + // vérifie le texte revenu, le message visible et touchant le champ, son + // texte et son remède, l'absence de toute fenêtre, et les attributs du + // champ. + async function refusAuPremierCaractere(montage, remede) { + const alerte = vi.spyOn(window, 'alert'); + const confirmation = vi.spyOn(window, 'confirm'); + const { cible, entree, attentes } = monterChamp(montage); + const ecritures = montage.fs.compteurs.ecritures; + await cliquer(entree); + await taper('z'); + expect(entree.value).toBe(NOM); + const message = cible.querySelector('[data-refus]'); + expect(message).not.toBeNull(); + expect(visible(message)).toBe(true); + expect(ecart(message, entree)).toBeLessThanOrEqual(8); + const { refus } = montage.seance.etat(); + expect(refus).not.toBeNull(); + expect(message.getAttribute('data-refus')).toBe(refus.code); + expect(message.querySelector('p').textContent).toBe(t(refus.code, refus.details)); + expect(message.textContent).toContain(remede); + expect(message.querySelector('button').textContent.trim()).toBe(remede); + aucuneFenetre(); + expect(alerte).not.toHaveBeenCalled(); + expect(confirmation).not.toHaveBeenCalled(); + expect(entree.getAttribute('aria-invalid')).toBe('true'); + expect(entree.getAttribute('aria-describedby')).toBe(message.id); + expect(message.id).not.toBe(''); + expect(attentes).toHaveLength(0); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + return { cible, entree, message }; + } + + test('en lecture, la première frappe est refusée aussitôt : MODE_LECTURE, et « Modifier » contre le champ', async () => { + const montage = await preparer({ mode: 'lecture' }); + const { refus } = montage.seance.etat(); + expect(refus.code).toBe('MODE_LECTURE'); + await refusAuPremierCaractere(montage, 'Modifier'); + }); + + test('plan bloqué : LECTURE_SEULE BLOQUE, et « Débloquer »', async () => { + const montage = await preparer({ bloque: true }); + expect(montage.seance.etat().refus).toMatchObject({ code: 'LECTURE_SEULE', details: { raison: 'BLOQUE' } }); + await refusAuPremierCaractere(montage, 'Débloquer'); + }); + + test('support rompu : SUPPORT_ROMPU, et « Choisir un dossier… » quand le support rompu est celui des Documents', async () => { + const montage = await preparer(); + montage.fs.pannes.ecriture('documents'); + await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' }); + expect(montage.seance.etat().refus.code).toBe('SUPPORT_ROMPU'); + await refusAuPremierCaractere(montage, 'Choisir un dossier\u{2026}'); + }); + + test('le remède « Modifier » passe en écriture, retire le refus, et la frappe suivante se saisit', async () => { + const montage = await preparer({ mode: 'lecture' }); + const { cible, entree, message } = await refusAuPremierCaractere(montage, 'Modifier'); + await cliquer(message.querySelector('button')); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture')); + await tick(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + await cliquer(entree); + await taper('x'); + expect(entree.value).toBe(`${NOM}x`); + }); + + test('le refus de la séance suit la séance : passée en écriture par un autre chemin que son remède, le champ ne garde ni le refus ni aria-invalid', async () => { + const montage = await preparer({ mode: 'lecture' }); + const { cible, entree } = await refusAuPremierCaractere(montage, 'Modifier'); + await montage.seance.passerEnEcriture(); + flushSync(); + expect(montage.seance.etat().refus).toBeNull(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + expect(entree.getAttribute('aria-describedby')).toBeNull(); + // De nouveau en lecture : rien ne se pose avant une frappe. + await montage.seance.passerEnLecture(); + flushSync(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + await montage.seance.passerEnEcriture(); + flushSync(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + await cliquer(entree); + await taper('x'); + expect(entree.value).toBe(`${NOM}x`); + }); + + test('une autre cause remplace le refus posé : support rompu, puis retour en lecture → MODE_LECTURE et « Modifier » contre le champ', async () => { + const montage = await preparer(); + montage.fs.pannes.ecriture('documents'); + await expect(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } })).rejects.toMatchObject({ code: 'ECRITURE' }); + const { cible, entree } = await refusAuPremierCaractere(montage, 'Choisir un dossier\u{2026}'); + await montage.seance.passerEnLecture(); + flushSync(); + expect(montage.seance.etat().refus.code).toBe('MODE_LECTURE'); + const message = cible.querySelector('[data-refus]'); + expect(message).not.toBeNull(); + expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } })); + expect(message.querySelector('button').textContent.trim()).toBe('Modifier'); + expect(entree.getAttribute('aria-describedby')).toBe(message.id); + }); + + test('plan bloqué, débloqué hors du remède : le refus LECTURE_SEULE quitte le champ', async () => { + const montage = await preparer({ bloque: true }); + const { cible, entree } = await refusAuPremierCaractere(montage, 'Débloquer'); + await montage.seance.debloquer(); + flushSync(); + expect(montage.seance.etat().refus).toBeNull(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(entree.getAttribute('aria-invalid')).toBeNull(); + }); +}); + +describe('le remède « Débloquer » (§ 8.5)', () => { + test('un clic arme la commande, sans écrire ; un second débloque, une entrée', async () => { + const montage = await preparer({ bloque: true }); + const { message } = await refusAuPremierCaractere2(montage); + const bouton = message.querySelector('button'); + expect(bouton.getAttribute('data-commande')).toBe('debloquer'); + const ecritures = montage.fs.compteurs.ecritures; + const entrees = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`)).entrees.length; + await cliquer(bouton); + expect(bouton.textContent.trim()).toBe('Confirmer le déblocage'); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + aucuneFenetre(); + await cliquer(bouton); + await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque')); + const journal = lireJournal(montage.fs.contenu('documents', `${montage.base}${SUFFIXES.journal}`)); + expect(journal.entrees.length).toBe(entrees + 1); + expect(montage.seance.etat().mode).toBe('ecriture'); + }); + + test('armée, le délai de l’horloge la désarme : elle redit « Débloquer », et le clic suivant n’écrit rien', async () => { + const montage = await preparer({ bloque: true }); + const { message } = await refusAuPremierCaractere2(montage); + const bouton = message.querySelector('button'); + const ecritures = montage.fs.compteurs.ecritures; + await cliquer(bouton); + expect(bouton.textContent.trim()).toBe('Confirmer le déblocage'); + montage.horloge.avancer(DELAI_CONFIRMATION_MS - 1); + flushSync(); + expect(bouton.textContent.trim()).toBe('Confirmer le déblocage'); + montage.horloge.avancer(1); + flushSync(); + expect(bouton.textContent.trim()).toBe('Débloquer'); + await cliquer(bouton); + expect(bouton.textContent.trim()).toBe('Confirmer le déblocage'); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + expect(montage.seance.etat().evenement.etat).toBe('bloque'); + }); + + // Le refus d'un plan bloqué posé contre le champ par une frappe. + async function refusAuPremierCaractere2(montage) { + const { cible, entree } = monterChamp(montage); + await cliquer(entree); + await taper('z'); + const message = cible.querySelector('[data-refus]'); + expect(message).not.toBeNull(); + return { cible, entree, message }; + } +}); + +describe('la saisie suspend l’inactivité (§ 8.4)', () => { + test('une saisie non validée tient la séance en écriture au-delà de l’inactivité ; validée, une entrée, puis le retour en lecture rend le verrou', async () => { + const montage = await preparer(); + const { entree, attentes } = monterChamp(montage); + const lignes = lignesJournal(montage).length; + await cliquer(entree); + await taper('ine'); + montage.horloge.avancer(INACTIVITE_MS + 1000); + await tick(); + expect(montage.seance.etat().mode).toBe('ecriture'); + expect(entree.value).toBe(`${NOM}ine`); + expect(verrouPresent(montage)).toBe(true); + await touche('Enter'); + await tick(); + expect(attentes).toHaveLength(1); + await Promise.all(attentes); + expect(lignesJournal(montage)).toHaveLength(lignes + 1); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + await vi.waitFor(() => expect(verrouPresent(montage)).toBe(false)); + }); + + test('une saisie en cours, la séance renvoyée en lecture puis repassée en écriture sans quitter le champ : la saisie suspend de nouveau l’inactivité', async () => { + const montage = await preparer(); + const { entree, attentes } = monterChamp(montage); + await cliquer(entree); + await taper('ine'); + await montage.seance.passerEnLecture(); + await montage.seance.passerEnEcriture(); + expect(document.activeElement).toBe(entree); + montage.horloge.avancer(INACTIVITE_MS + 1); + await viderFile(montage); + expect(montage.seance.etat().mode).toBe('ecriture'); + await touche('Enter'); + await tick(); + await Promise.all(attentes); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); +}); + +describe('saisie, puis « Revenir en lecture » (§ 8.2, § 8.4)', () => { + test('par des entrées réelles : la saisie s’écrit, puis la séance revient en lecture et rend le verrou', async () => { + const montage = await preparer(); + const commande = monter( + Commande, + { cle: 'communs.commande.lecture', executer: () => montage.seance.passerEnLecture() }, + montage.contexte, + ); + const { entree, attentes } = monterChamp(montage); + const lignes = lignesJournal(montage).length; + await cliquer(entree); + await taper(' Lacasse'); + const bouton = commande.querySelector('button[data-commande="lecture"]'); + expect(bouton).not.toBeNull(); + await cliquer(bouton); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + await Promise.all(attentes); + expect(attentes).toHaveLength(1); + expect(lignesJournal(montage)).toHaveLength(lignes + 1); + expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`); + expect(verrouPresent(montage)).toBe(false); + }); + + test('la sortie du champ lance l’exécution dans le même tour : un retour en lecture appelé aussitôt après attend son écriture', async () => { + const montage = await preparer(); + const { entree, attentes } = monterChamp(montage); + const lignes = lignesJournal(montage).length; + await cliquer(entree); + await taper(' Lacasse'); + entree.blur(); + const retour = montage.seance.passerEnLecture(); + expect(attentes).toHaveLength(1); + await Promise.all([...attentes, retour]); + expect(lignesJournal(montage)).toHaveLength(lignes + 1); + expect(montage.seance.etat().mode).toBe('lecture'); + expect(montage.seance.etat().charge.participants[0].nom).toBe(`${NOM} Lacasse`); + }); +}); + +describe('fenêtre à confirmer (§ 2.1)', () => { + const ELEMENTS = ['Iris Ombrelle', 'Théo Pervenche', 'Ondine Lacasse']; + + function monterConfirmation(montage) { + const appels = []; + const cible = monter( + Confirmation, + { + titre: 'communs.remede.supprimerParticipant', + elements: ELEMENTS, + surConfirmer: () => { + appels.push('confirmer'); + return montage.seance.executer('supprimerParticipant', { id: 1 }); + }, + surAnnuler: () => appels.push('annuler'), + }, + montage.contexte, + ); + return { cible, appels, fenetre: cible.querySelector('dialog') }; + } + + test('une fenêtre modale, ouverte, qui nomme chaque élément détruit, chacun dans son nœud, Annuler par défaut', async () => { + const montage = await preparer(); + const { fenetre } = monterConfirmation(montage); + expect(fenetre).not.toBeNull(); + expect(fenetre.open).toBe(true); + expect(fenetre.matches(':modal')).toBe(true); + const items = [...fenetre.querySelectorAll('li')]; + expect(items).toHaveLength(ELEMENTS.length); + expect(items.map((li) => li.textContent)).toEqual(ELEMENTS); + for (const item of items) expect(visible(item)).toBe(true); + expect(document.activeElement.getAttribute('data-commande')).toBe('renoncer'); + expect(document.activeElement.textContent.trim()).toBe('Annuler'); + }); + + test('Annuler → surAnnuler, zéro écriture ; Échap de même', async () => { + const montage = await preparer(); + const ecritures = montage.fs.compteurs.ecritures; + const premiere = monterConfirmation(montage); + await cliquer(premiere.fenetre.querySelector('[data-commande="renoncer"]')); + expect(premiere.appels).toEqual(['annuler']); + expect(premiere.fenetre.open).toBe(false); + const seconde = monterConfirmation(montage); + await touche('Escape'); + await tick(); + expect(seconde.appels).toEqual(['annuler']); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + }); + + test('Confirmer → surConfirmer ; abouti, la fenêtre se ferme', async () => { + const montage = await preparer(); + const { fenetre, appels } = monterConfirmation(montage); + await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); + expect(appels).toEqual(['confirmer']); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + }); + + test('ouverte, elle suspend l’inactivité (§ 8.4) : au-delà d’INACTIVITE_MS, toujours en écriture, et Confirmer aboutit ; fermée, le compte reprend', async () => { + const montage = await preparer(); + const { fenetre } = monterConfirmation(montage); + montage.horloge.avancer(INACTIVITE_MS + 1); + await tick(); + expect(montage.seance.etat().mode).toBe('ecriture'); + expect(verrouPresent(montage)).toBe(true); + await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); + + test('Annuler rend le compte d’inactivité', async () => { + const montage = await preparer(); + const { fenetre } = monterConfirmation(montage); + await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); + + test('Confirmer refusé : le refus se pose dans la fenêtre, qui reste ouverte, rien d’écrit ; son remède levé, Confirmer aboutit et la ferme', async () => { + const montage = await preparer(); + const { fenetre, appels } = monterConfirmation(montage); + await montage.seance.passerEnLecture(); + const lignes = lignesJournal(montage).length; + const ecritures = montage.fs.compteurs.ecritures; + const confirmer = fenetre.querySelector('[data-commande="confirmer"]'); + await cliquer(confirmer); + expect(appels).toEqual(['confirmer']); + await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull()); + const message = fenetre.querySelector('[data-refus]'); + expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } })); + expect(visible(message)).toBe(true); + expect(fenetre.open).toBe(true); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + expect(lignesJournal(montage)).toHaveLength(lignes); + expect(montage.seance.etat().charge.participants).toHaveLength(1); + await cliquer(message.querySelector('button[data-commande="modifier"]')); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture')); + await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).toBeNull()); + // Le retour explicite en lecture a remis à zéro le compte des gestes de + // la séance : la fenêtre, toujours ouverte, le rouvre au passage en + // écriture. + montage.horloge.avancer(INACTIVITE_MS + 1); + await viderFile(montage); + expect(montage.seance.etat().mode).toBe('ecriture'); + expect(fenetre.open).toBe(true); + await cliquer(confirmer); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + }); + + // Une promesse que l'épreuve résout ou rejette à la main : l'action + // confirmée qui dure, celle d'un disque lent. + function differe() { + let resoudre; + let rejeter; + const promesse = new Promise((oui, non) => { + resoudre = oui; + rejeter = non; + }); + return { promesse, resoudre, rejeter }; + } + + // La fenêtre dont surConfirmer attend attente avant d'exécuter la + // suppression d'Iris par la séance. + function monterConfirmationDifferee(montage) { + const appels = []; + const attente = differe(); + const cible = monter( + Confirmation, + { + titre: 'communs.remede.supprimerParticipant', + elements: ELEMENTS, + surConfirmer: () => { + appels.push('confirmer'); + return attente.promesse.then(() => montage.seance.executer('supprimerParticipant', { id: 1 })); + }, + surAnnuler: () => appels.push('annuler'), + }, + montage.contexte, + ); + return { cible, appels, attente, fenetre: cible.querySelector('dialog') }; + } + + test('occupée pendant l’action confirmée : Annuler, Échap et un second Confirmer ne font rien, les boutons se montrent désactivés et l’attente se dit ; seule la fin de l’action ferme la fenêtre', async () => { + const montage = await preparer(); + const { fenetre, appels, attente } = monterConfirmationDifferee(montage); + const renoncer = fenetre.querySelector('[data-commande="renoncer"]'); + const confirmer = fenetre.querySelector('[data-commande="confirmer"]'); + const styleRepos = getComputedStyle(confirmer).borderTopStyle; + await cliquer(confirmer); + expect(appels).toEqual(['confirmer']); + for (const bouton of [renoncer, confirmer]) { + expect(bouton.getAttribute('aria-disabled')).toBe('true'); + expect(getComputedStyle(bouton).borderTopStyle).toBe('dashed'); + expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos); + } + const annonce = [...fenetre.querySelectorAll('[role=status]')].find( + (element) => element.textContent.trim() === t('communs.confirmation.enCours'), + ); + expect(annonce).toBeDefined(); + expect(visible(annonce)).toBe(true); + await touche('Escape'); + await tick(); + await cliquer(renoncer); + await cliquer(confirmer); + expect(appels).toEqual(['confirmer']); + expect(fenetre.open).toBe(true); + expect(montage.seance.etat().charge.participants).toHaveLength(1); + attente.resoudre(); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + expect(appels).toEqual(['confirmer']); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); + + test('occupée, Échap ne ferme rien : l’action refusée avec un code, son refus se pose dans la fenêtre restée ouverte, qui redevient active', async () => { + const montage = await preparer(); + const { fenetre, appels, attente } = monterConfirmationDifferee(montage); + const confirmer = fenetre.querySelector('[data-commande="confirmer"]'); + await cliquer(confirmer); + await touche('Escape'); + await tick(); + await montage.seance.passerEnLecture(); + attente.resoudre(); + await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull()); + expect(appels).toEqual(['confirmer']); + expect(fenetre.open).toBe(true); + const message = fenetre.querySelector('[data-refus]'); + expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(visible(message)).toBe(true); + for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull(); + expect(montage.seance.etat().charge.participants).toHaveLength(1); + await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); + expect(appels).toEqual(['confirmer', 'annuler']); + expect(fenetre.open).toBe(false); + }); + + // La fenêtre occupée tient à toute touche. Chromium n'annule pas deux + // fois de suite une demande de fermeture sans activation de l'utilisateur + // entre elles : la seconde Échap ferme le sans que cancel puisse + // l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt, + // modale et toujours occupée ; la fin de l'action seule décide. + async function echapEnRafale(fois) { + for (let i = 0; i < fois; i += 1) await touche('Escape'); + // L'événement close part dans une tâche à lui, après la touche. + await new Promise((fin) => setTimeout(fin, 0)); + await tick(); + } + + function attendreOccupee(montage, fenetre, appels) { + expect(appels).toEqual(['confirmer']); + expect(fenetre.open).toBe(true); + expect(fenetre.matches(':modal')).toBe(true); + expect(fenetre.getAttribute('aria-busy')).toBe('true'); + for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBe('true'); + expect(montage.seance.etat().charge.participants).toHaveLength(1); + } + + for (const fois of [2, 3]) { + test(`occupée, ${fois} Échap de suite ne la ferment pas : rouverte modale et occupée, sans surAnnuler ; la fin de l’action la ferme`, async () => { + const montage = await preparer(); + const { fenetre, appels, attente } = monterConfirmationDifferee(montage); + await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); + await echapEnRafale(fois); + attendreOccupee(montage, fenetre, appels); + // Une seconde rafale, la fenêtre déjà rouverte une fois. + await echapEnRafale(fois); + attendreOccupee(montage, fenetre, appels); + attente.resoudre(); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + expect(appels).toEqual(['confirmer']); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); + + test(`occupée, ${fois} Échap de suite, puis l’action refusée avec un code : le refus se pose dans la fenêtre rouverte, qui redevient active`, async () => { + const montage = await preparer(); + const { fenetre, appels, attente } = monterConfirmationDifferee(montage); + await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); + await echapEnRafale(fois); + attendreOccupee(montage, fenetre, appels); + await montage.seance.passerEnLecture(); + attente.resoudre(); + await vi.waitFor(() => expect(fenetre.querySelector('[data-refus]')).not.toBeNull()); + expect(fenetre.open).toBe(true); + expect(fenetre.matches(':modal')).toBe(true); + expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE'); + expect(fenetre.getAttribute('aria-busy')).toBeNull(); + for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull(); + expect(appels).toEqual(['confirmer']); + await cliquer(fenetre.querySelector('[data-commande="renoncer"]')); + expect(appels).toEqual(['confirmer', 'annuler']); + expect(fenetre.open).toBe(false); + }); + } + + test('fermée par le navigateur hors de l’attente, sans passer par Annuler : c’est une annulation, qui rend le compte d’inactivité', async () => { + const montage = await preparer(); + const { fenetre, appels } = monterConfirmation(montage); + fenetre.close(); + await vi.waitFor(() => expect(appels).toEqual(['annuler'])); + expect(fenetre.open).toBe(false); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + }); + + test('une erreur sans code ferme la fenêtre, rend le compte d’inactivité, et remonte', async () => { + const montage = await preparer(); + const remontees = []; + const recueillir = (evenement) => { + evenement.preventDefault(); + remontees.push(evenement.reason); + }; + window.addEventListener('unhandledrejection', recueillir); + try { + const { fenetre, attente } = monterConfirmationDifferee(montage); + await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); + const panne = new Error('panne d’épreuve'); + attente.rejeter(panne); + await vi.waitFor(() => expect(fenetre.open).toBe(false)); + await vi.waitFor(() => expect(remontees).toContain(panne)); + expect(fenetre.querySelector('[data-refus]')).toBeNull(); + montage.horloge.avancer(INACTIVITE_MS); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('lecture')); + } finally { + window.removeEventListener('unhandledrejection', recueillir); + } + }); +}); + +describe('commande (§ 2.1)', () => { + test('refusée, elle affiche son refus à côté d’elle, avec son remède, et reste active : jamais un bouton grisé muet', async () => { + const montage = await preparer({ mode: 'lecture' }); + const ecritures = montage.fs.compteurs.ecritures; + const cible = monter( + Commande, + { + cle: 'communs.remede.ajouterParticipant', + executer: () => montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), + }, + montage.contexte, + ); + const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]'); + expect(bouton).not.toBeNull(); + expect(bouton.disabled).toBe(false); + await cliquer(bouton); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).not.toBeNull()); + const message = cible.querySelector('[data-refus]'); + expect(visible(message)).toBe(true); + expect(ecart(message, bouton)).toBeLessThanOrEqual(8); + expect(message.querySelector('p').textContent).toBe(t('MODE_LECTURE', { remede: { geste: 'modifier' } })); + expect(bouton.disabled).toBe(false); + expect(bouton.getAttribute('aria-disabled')).toBeNull(); + expect(montage.fs.compteurs.ecritures).toBe(ecritures); + aucuneFenetre(); + await cliquer(message.querySelector('button[data-commande="modifier"]')); + await vi.waitFor(() => expect(montage.seance.etat().mode).toBe('ecriture')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + await cliquer(bouton); + await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2)); + }); + + test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => { + const montage = await preparer(); + const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte); + const bouton = cible.querySelector('button'); + expect(bouton.textContent.trim()).toBe('Revenir en lecture'); + expect(bouton.getAttribute('data-commande')).toBe('lecture'); + expect(bouton.getAttribute('type')).toBe('button'); + }); +});