[ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so these behaviours live once. A field validates on leaving it and shows a refused value as refused, restoring the stored one when a session refusal clears. A refusal is shown where the gesture happened, with its remedy as an executable command. The confirmation window only opens for a gesture that destroys data, and stays busy — no key closes it — until the confirmed action ends. Checked: 2152 node and 163 browser tests. --- FR --- [ADD] interface : primitives — champ, refus, confirmation, commande Chaque écran du plan saisit, refuse et confirme de la même façon : ces comportements n'existent qu'une fois. Un champ se valide à sa sortie et montre une valeur refusée comme refusée, rendant la valeur enregistrée quand un refus de séance s'efface. Un refus se pose là où le geste a eu lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre que pour un geste qui détruit des données, et reste occupée — aucune touche ne la ferme — jusqu'à la fin de l'action confirmée. Vérifié : 2152 node et 163 navigateur. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
cc6b9fe996
commit
876ea0f2ce
12 changed files with 2603 additions and 1 deletions
|
|
@ -3,4 +3,85 @@
|
||||||
|
|
||||||
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
|
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
|
||||||
// refus (§ 14.6). Chaque clé commence par « communs. ».
|
// 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.<commande> pour chaque commande
|
||||||
|
// du registre (src/application/commandes.js), communs.remede.<geste> pour un
|
||||||
|
// geste du contrat, suivi de .<variante> 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'],
|
||||||
|
]);
|
||||||
|
|
|
||||||
228
src/interface/primitives/Champ.svelte
Normal file
228
src/interface/primitives/Champ.svelte
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Un champ de saisie (§ 2, § 8.2, § 8.4) : son étiquette, la clé etiquette
|
||||||
|
// traduite ; data-champ porte cle. La machine est celle de champ.js : la
|
||||||
|
// saisie se valide à la sortie du champ ou par Entrée, jamais par un
|
||||||
|
// minuteur, et Échap rend la valeur enregistrée. executer(valeur) part
|
||||||
|
// dans le même tour que la sortie du champ : la séance passe ses appels
|
||||||
|
// dans l'ordre, et un retour en lecture cliqué ensuite attend son écriture.
|
||||||
|
//
|
||||||
|
// Une saisie non validée est un geste ouvert, que tient geste.js : il
|
||||||
|
// suspend le compte d'inactivité de la séance, et se recompte quand la
|
||||||
|
// séance, renvoyée en lecture pendant la saisie, repasse en écriture.
|
||||||
|
//
|
||||||
|
// Le refus courant de la séance, etat().refus, refuse la première frappe
|
||||||
|
// aussitôt : le texte revient à la valeur enregistrée, et le refus, avec
|
||||||
|
// son remède, se pose contre le champ. Le champ lit ce refus et ne refait
|
||||||
|
// pas le contrôle. Un refus de l'exécution dont le code nomme un état de
|
||||||
|
// la séance (estRefusSeance, champ.js) se range avec lui. Posé, un refus
|
||||||
|
// de la séance suit la séance par subscribe (refusSeanceCourant) : levé
|
||||||
|
// par quelque chemin que ce soit, il quitte le champ, et une autre cause
|
||||||
|
// le remplace. La saisie qu'une exécution refusée par un tel code a
|
||||||
|
// laissée dans le champ reste affichée, marquée, tant qu'un refus de la
|
||||||
|
// séance la couvre ; ce refus levé, le champ rend la valeur enregistrée :
|
||||||
|
// il n'affiche jamais, sans marque, une saisie que rien n'a appliquée.
|
||||||
|
// Un refus de lire, ou de l'exécution sur un autre code, est
|
||||||
|
// local : il se pose de même, la saisie gardée, et le refus de la séance
|
||||||
|
// passe devant lui tant qu'il dure. Le remède abouti ne retire que le
|
||||||
|
// refus qu'il levait, celui qui s'affichait à son départ. Le champ porte
|
||||||
|
// aria-invalid et aria-describedby vers le message. La sortie du champ ne
|
||||||
|
// retire pas un refus : son remède, qu'un clic atteint, resterait sinon
|
||||||
|
// hors de portée.
|
||||||
|
import { getContext, onDestroy, untrack } from 'svelte';
|
||||||
|
import { creerMachineChamp, estRefusSeance, refusSeanceCourant } from './champ.js';
|
||||||
|
import { creerGeste } from './geste.js';
|
||||||
|
import Refus from './Refus.svelte';
|
||||||
|
|
||||||
|
let { cle, etiquette, valeur, lire, executer, details = {}, gestes = {} } = $props();
|
||||||
|
|
||||||
|
const t = getContext('t');
|
||||||
|
const seance = getContext('seance') ?? null;
|
||||||
|
const uid = $props.id();
|
||||||
|
const idEntree = `${uid}-entree`;
|
||||||
|
const idRefus = `${uid}-refus`;
|
||||||
|
|
||||||
|
// Le texte d'une valeur enregistrée : vide pour null.
|
||||||
|
const texteDe = (v) => (v === null || v === undefined ? '' : String(v));
|
||||||
|
|
||||||
|
const machine = creerMachineChamp({ stockee: untrack(() => texteDe(valeur)), lire: (texte) => lire(texte) });
|
||||||
|
let saisie = $state(machine.saisie);
|
||||||
|
// Le refus de la séance, posé par une frappe ou par l'exécution, et le
|
||||||
|
// refus local, de lire ou de l'exécution ; le premier s'affiche avant le
|
||||||
|
// second. Bruts : un refus se compare par identité, celle de l'objet que
|
||||||
|
// Refus reçoit et rend à lever.
|
||||||
|
let refusSeance = $state.raw(null);
|
||||||
|
let refusLocal = $state.raw(null);
|
||||||
|
const refus = $derived(refusSeance ?? refusLocal);
|
||||||
|
const geste = seance === null ? null : creerGeste(seance);
|
||||||
|
// Vrai tant que la saisie affichée est celle qu'une exécution, refusée
|
||||||
|
// par un code de la séance, n'a pas appliquée.
|
||||||
|
let saisieRefusee = false;
|
||||||
|
|
||||||
|
// Pose le refus de la séance : tout changement passe par ici. Levé alors
|
||||||
|
// qu'il couvrait une saisie refusée, celle-ci cède la place à la valeur
|
||||||
|
// enregistrée, et le geste qu'elle tenait se rend.
|
||||||
|
function poserRefusSeance(nouveau) {
|
||||||
|
refusSeance = nouveau;
|
||||||
|
if (!saisieRefusee || refusSeance !== null) return;
|
||||||
|
saisieRefusee = false;
|
||||||
|
machine.abandonner();
|
||||||
|
saisie = machine.saisie;
|
||||||
|
refusLocal = null;
|
||||||
|
suivre();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus de la séance posé suit l'état : le même objet tant qu'il tient
|
||||||
|
// — sa commande garde son état, une confirmation armée —, null quand il
|
||||||
|
// est levé, la cause courante quand une autre l'a remplacé.
|
||||||
|
const desabonner =
|
||||||
|
seance?.subscribe((etat) => {
|
||||||
|
if (refusSeance === null) return;
|
||||||
|
poserRefusSeance(refusSeanceCourant(refusSeance, etat));
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
// Le remède abouti d'un refus rend celui qui s'affichait à son départ :
|
||||||
|
// il ne retire que lui. Déjà levé par la séance, il ne retire rien.
|
||||||
|
function lever(affiche) {
|
||||||
|
if (affiche === refusSeance) poserRefusSeance(null);
|
||||||
|
else if (affiche === refusLocal) refusLocal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une valeur enregistrée nouvelle, venue de la séance : la saisie la suit
|
||||||
|
// hors d'une saisie en cours.
|
||||||
|
$effect.pre(() => {
|
||||||
|
machine.recevoir(texteDe(valeur));
|
||||||
|
untrack(() => {
|
||||||
|
saisie = machine.saisie;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le geste de la séance suit la saisie en cours de la machine.
|
||||||
|
function suivre() {
|
||||||
|
geste?.tenir(machine.enCours);
|
||||||
|
}
|
||||||
|
|
||||||
|
function surSaisie(evenement) {
|
||||||
|
const entree = evenement.currentTarget;
|
||||||
|
const courant = seance?.etat().refus ?? null;
|
||||||
|
if (courant !== null) {
|
||||||
|
entree.value = machine.saisie;
|
||||||
|
poserRefusSeance(courant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
machine.saisir(entree.value);
|
||||||
|
saisie = machine.saisie;
|
||||||
|
saisieRefusee = false;
|
||||||
|
poserRefusSeance(null);
|
||||||
|
refusLocal = null;
|
||||||
|
suivre();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus d'une exécution ; une erreur sans code n'en est pas un et
|
||||||
|
// remonte. Un refus de la séance se confronte aussitôt à son état : levé
|
||||||
|
// avant d'arriver, il ne se pose pas, et la saisie refusée cède la place
|
||||||
|
// à la valeur enregistrée.
|
||||||
|
function echouer(erreur) {
|
||||||
|
if (typeof erreur?.code !== 'string') throw erreur;
|
||||||
|
const recu = { code: erreur.code, details: erreur.details ?? {} };
|
||||||
|
machine.echec(recu);
|
||||||
|
saisie = machine.saisie;
|
||||||
|
suivre();
|
||||||
|
if (seance !== null && estRefusSeance(recu)) {
|
||||||
|
saisieRefusee = true;
|
||||||
|
poserRefusSeance(refusSeanceCourant(recu, seance.etat()));
|
||||||
|
} else refusLocal = recu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valider() {
|
||||||
|
saisieRefusee = false;
|
||||||
|
const rendu = machine.valider();
|
||||||
|
saisie = machine.saisie;
|
||||||
|
suivre();
|
||||||
|
if (rendu.action === 'refus') {
|
||||||
|
refusLocal = rendu.refus;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rendu.action === 'aucune') return;
|
||||||
|
refusLocal = null;
|
||||||
|
let resultat;
|
||||||
|
try {
|
||||||
|
resultat = executer(rendu.valeur);
|
||||||
|
} catch (erreur) {
|
||||||
|
echouer(erreur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Promise.resolve(resultat).catch(echouer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abandonner(entree) {
|
||||||
|
machine.abandonner();
|
||||||
|
saisie = machine.saisie;
|
||||||
|
entree.value = machine.saisie;
|
||||||
|
saisieRefusee = false;
|
||||||
|
poserRefusSeance(null);
|
||||||
|
refusLocal = null;
|
||||||
|
suivre();
|
||||||
|
}
|
||||||
|
|
||||||
|
function surTouche(evenement) {
|
||||||
|
if (evenement.key === 'Escape') abandonner(evenement.currentTarget);
|
||||||
|
else if (evenement.key === 'Enter') {
|
||||||
|
evenement.preventDefault();
|
||||||
|
valider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
desabonner?.();
|
||||||
|
geste?.liberer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="champ">
|
||||||
|
<label for={idEntree}>{t(etiquette, details)}</label>
|
||||||
|
<input
|
||||||
|
id={idEntree}
|
||||||
|
type="text"
|
||||||
|
data-champ={cle}
|
||||||
|
value={saisie}
|
||||||
|
aria-invalid={refus === null ? undefined : 'true'}
|
||||||
|
aria-describedby={refus === null ? undefined : idRefus}
|
||||||
|
oninput={surSaisie}
|
||||||
|
onkeydown={surTouche}
|
||||||
|
onblur={valider}
|
||||||
|
/>
|
||||||
|
{#if refus !== null}
|
||||||
|
<Refus id={idRefus} {refus} {lever} {gestes} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--espace-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: var(--espace-1) var(--espace-2);
|
||||||
|
border: 1px solid var(--couleur-page-filet);
|
||||||
|
border-radius: var(--rayon-1);
|
||||||
|
background-color: var(--couleur-page-fond);
|
||||||
|
color: var(--couleur-page-texte);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--taille-texte);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid var(--couleur-focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[aria-invalid='true'] {
|
||||||
|
border-color: var(--couleur-refus);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
src/interface/primitives/Commande.svelte
Normal file
98
src/interface/primitives/Commande.svelte
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Une commande (§ 2.1, § 8.5) : un bouton dont le libellé est la clé cle,
|
||||||
|
// traduite, et qui appelle executer au clic. Avec confirmer, la clé du
|
||||||
|
// libellé de confirmation, un premier clic arme la commande — son libellé
|
||||||
|
// devient celui de confirmer — et seul un second exécute ; armée, elle se
|
||||||
|
// désarme au terme de DELAI_CONFIRMATION_MS, par l'horloge du contexte.
|
||||||
|
// La machine est celle de commande.js.
|
||||||
|
//
|
||||||
|
// Un refus — une erreur qui porte un code — s'affiche à côté du bouton,
|
||||||
|
// avec son remède, et le bouton reste actif : jamais un bouton grisé muet.
|
||||||
|
// Une erreur sans code n'est pas un refus et remonte.
|
||||||
|
//
|
||||||
|
// data-commande porte nom, ou à défaut le dernier segment de la clé.
|
||||||
|
// confirmer se lit au montage : une commande qui change de confirmation se
|
||||||
|
// remonte.
|
||||||
|
import { getContext, onDestroy, untrack } from 'svelte';
|
||||||
|
import { DELAI_CONFIRMATION_MS, creerMachineCommande } from './commande.js';
|
||||||
|
import Refus from './Refus.svelte';
|
||||||
|
|
||||||
|
let { cle, executer, confirmer = null, details = {}, nom = null, gestes = {} } = $props();
|
||||||
|
|
||||||
|
const t = getContext('t');
|
||||||
|
const horloge = getContext('horloge');
|
||||||
|
const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) });
|
||||||
|
|
||||||
|
let armee = $state(false);
|
||||||
|
let refus = $state(null);
|
||||||
|
let minuterie = null;
|
||||||
|
|
||||||
|
const libelle = $derived(armee ? t(confirmer) : t(cle, details));
|
||||||
|
const designation = $derived(nom ?? cle.slice(cle.lastIndexOf('.') + 1));
|
||||||
|
|
||||||
|
function desarmer() {
|
||||||
|
if (minuterie === null) return;
|
||||||
|
horloge.annuler(minuterie);
|
||||||
|
minuterie = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expirer() {
|
||||||
|
minuterie = null;
|
||||||
|
machine.expirer();
|
||||||
|
armee = machine.armee;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cliquer() {
|
||||||
|
const { action } = machine.cliquer();
|
||||||
|
armee = machine.armee;
|
||||||
|
if (action === 'armer') {
|
||||||
|
minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
desarmer();
|
||||||
|
refus = null;
|
||||||
|
try {
|
||||||
|
await executer();
|
||||||
|
} catch (erreur) {
|
||||||
|
if (typeof erreur?.code !== 'string') throw erreur;
|
||||||
|
refus = { code: erreur.code, details: erreur.details ?? {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(desarmer);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="commande">
|
||||||
|
<button type="button" data-commande={designation} onclick={cliquer}>{libelle}</button>
|
||||||
|
{#if refus !== null}
|
||||||
|
<Refus {refus} lever={() => (refus = null)} {gestes} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.commande {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--espace-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--espace-1) var(--espace-3);
|
||||||
|
border: 1px solid var(--couleur-page-filet);
|
||||||
|
border-radius: var(--rayon-1);
|
||||||
|
background-color: var(--couleur-page-fond);
|
||||||
|
color: var(--couleur-page-texte);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--taille-texte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--couleur-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
209
src/interface/primitives/Confirmation.svelte
Normal file
209
src/interface/primitives/Confirmation.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// La fenêtre à confirmer (§ 2.1) : la seule fenêtre du logiciel, réservée
|
||||||
|
// à la destruction de saisie. Un <dialog> modal, ouvert au montage, qui
|
||||||
|
// porte le titre — la clé titre traduite — et nomme chaque élément détruit,
|
||||||
|
// chacun dans son propre nœud de texte. Annuler a le focus par défaut ;
|
||||||
|
// Échap annule. Annuler ferme la fenêtre puis appelle surAnnuler.
|
||||||
|
// Confirmer appelle surConfirmer et attend sa promesse : aboutie, la
|
||||||
|
// fenêtre se ferme ; refusée — une erreur qui porte un code —, elle reste
|
||||||
|
// ouverte et le refus, avec son remède, s'y pose, là où le geste a eu
|
||||||
|
// lieu ; une erreur sans code ferme la fenêtre et remonte. Le parent la
|
||||||
|
// démonte.
|
||||||
|
//
|
||||||
|
// Pendant l'attente, la fenêtre est occupée : la destruction est déjà
|
||||||
|
// demandée, et rien ne la dit annulée pendant qu'elle s'achève. Annuler,
|
||||||
|
// Échap et un second Confirmer ne font rien ; les deux boutons portent
|
||||||
|
// aria-disabled, se dessinent d'un filet tireté, et une annonce dit
|
||||||
|
// l'attente. Seule la fin de l'action ferme la fenêtre ou la rend active.
|
||||||
|
// Aucune touche ne la ferme : le navigateur peut fermer un <dialog> sans
|
||||||
|
// que cancel l'en empêche — Chromium n'annule pas une seconde demande de
|
||||||
|
// fermeture sans activation de l'utilisateur entre les deux —, et la
|
||||||
|
// fenêtre fermée pendant l'attente se rouvre aussitôt par showModal,
|
||||||
|
// toujours occupée. Fermée hors de l'attente par un autre chemin que le
|
||||||
|
// sien, elle annule, comme Échap.
|
||||||
|
//
|
||||||
|
// Ouverte, la fenêtre tient un geste de la séance du contexte (geste.js),
|
||||||
|
// rendu à la fermeture ou au démontage : il suspend le compte
|
||||||
|
// d'inactivité, qui ne la ramène pas en lecture sous l'opérateur, et se
|
||||||
|
// recompte quand la séance, renvoyée en lecture, repasse en écriture.
|
||||||
|
import { getContext, onDestroy, onMount } from 'svelte';
|
||||||
|
import { creerGeste } from './geste.js';
|
||||||
|
import Refus from './Refus.svelte';
|
||||||
|
|
||||||
|
let { titre, elements, surConfirmer, surAnnuler, details = {} } = $props();
|
||||||
|
|
||||||
|
const t = getContext('t');
|
||||||
|
const seance = getContext('seance') ?? null;
|
||||||
|
const uid = $props.id();
|
||||||
|
let fenetre;
|
||||||
|
let renoncer;
|
||||||
|
let refus = $state(null);
|
||||||
|
// L'action confirmée en cours : la fenêtre est occupée.
|
||||||
|
let enAttente = $state(false);
|
||||||
|
// Vrai dès que fermer() a fermé la fenêtre : l'événement close qui suit
|
||||||
|
// est le sien.
|
||||||
|
let fermee = false;
|
||||||
|
const geste = seance === null ? null : creerGeste(seance);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fenetre.showModal();
|
||||||
|
renoncer.focus();
|
||||||
|
geste?.tenir(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fermer() {
|
||||||
|
fermee = true;
|
||||||
|
if (fenetre?.open) fenetre.close();
|
||||||
|
geste?.tenir(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function annuler() {
|
||||||
|
if (enAttente) return undefined;
|
||||||
|
fermer();
|
||||||
|
return surAnnuler();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmer() {
|
||||||
|
if (enAttente) return;
|
||||||
|
enAttente = true;
|
||||||
|
refus = null;
|
||||||
|
try {
|
||||||
|
await surConfirmer();
|
||||||
|
} catch (erreur) {
|
||||||
|
enAttente = false;
|
||||||
|
if (typeof erreur?.code !== 'string') {
|
||||||
|
fermer();
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
refus = { code: erreur.code, details: erreur.details ?? {} };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enAttente = false;
|
||||||
|
fermer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => geste?.liberer());
|
||||||
|
|
||||||
|
// Échap : l'annulation, par le même chemin qu'Annuler ; la fenêtre ne se
|
||||||
|
// ferme jamais d'elle-même.
|
||||||
|
function surCancel(evenement) {
|
||||||
|
evenement.preventDefault();
|
||||||
|
annuler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// La fenêtre fermée sans fermer() : occupée, elle se rouvre, modale ;
|
||||||
|
// sinon, c'est une annulation. Démontée, elle ne se rouvre pas.
|
||||||
|
function surClose() {
|
||||||
|
if (fermee) return;
|
||||||
|
if (enAttente) {
|
||||||
|
if (fenetre.isConnected) fenetre.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
annuler();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={fenetre}
|
||||||
|
aria-labelledby={`${uid}-titre`}
|
||||||
|
aria-busy={enAttente ? 'true' : undefined}
|
||||||
|
oncancel={surCancel}
|
||||||
|
onclose={surClose}
|
||||||
|
>
|
||||||
|
<h2 id={`${uid}-titre`}>{t(titre, details)}</h2>
|
||||||
|
<ul>
|
||||||
|
{#each elements as element, rang (rang)}
|
||||||
|
<li>{element}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if refus !== null}
|
||||||
|
<div class="refus">
|
||||||
|
<Refus {refus} lever={() => (refus = null)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if enAttente}
|
||||||
|
<p class="attente" role="status">{t('communs.confirmation.enCours')}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="issues">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-commande="renoncer"
|
||||||
|
aria-disabled={enAttente ? 'true' : undefined}
|
||||||
|
bind:this={renoncer}
|
||||||
|
onclick={annuler}
|
||||||
|
>
|
||||||
|
{t('communs.confirmation.annuler')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-commande="confirmer"
|
||||||
|
aria-disabled={enAttente ? 'true' : undefined}
|
||||||
|
onclick={confirmer}
|
||||||
|
>
|
||||||
|
{t('communs.confirmation.confirmer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
max-width: min(40rem, calc(100vw - 2 * var(--espace-4)));
|
||||||
|
padding: var(--espace-4);
|
||||||
|
border: 1px solid var(--couleur-page-filet);
|
||||||
|
border-radius: var(--rayon-2);
|
||||||
|
background-color: var(--couleur-page-fond);
|
||||||
|
color: var(--couleur-page-texte);
|
||||||
|
box-shadow: var(--ombre-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--espace-3);
|
||||||
|
font-size: var(--taille-titre);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0 0 var(--espace-4);
|
||||||
|
padding-left: var(--espace-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refus {
|
||||||
|
margin: 0 0 var(--espace-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attente {
|
||||||
|
margin: 0 0 var(--espace-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issues {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--espace-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--espace-1) var(--espace-3);
|
||||||
|
border: 1px solid var(--couleur-page-filet);
|
||||||
|
border-radius: var(--rayon-1);
|
||||||
|
background-color: var(--couleur-page-fond);
|
||||||
|
color: var(--couleur-page-texte);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[aria-disabled='true'] {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--couleur-page-texte-secondaire);
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--couleur-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
src/interface/primitives/Refus.svelte
Normal file
79
src/interface/primitives/Refus.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Un refus posé là où le geste a eu lieu (§ 2.1, § 5.9, § 14.6) : son
|
||||||
|
// texte, celui de t sur son code et ses détails, puis son remède en
|
||||||
|
// Commande, puis chacun de details.autres dans leur ordre, de même. Une
|
||||||
|
// région role="status", jamais une fenêtre.
|
||||||
|
//
|
||||||
|
// refus : {code, details}, le remède dans details.remede. Le remède
|
||||||
|
// s'exécute par actionRemede (commande.js), sur la séance et l'état de
|
||||||
|
// navigation du contexte, ou par gestes, ceux que l'écran fournit — le
|
||||||
|
// choix d'une place sur le plan. Un remède que rien ne sait exécuter ne
|
||||||
|
// pose aucune commande ; le texte du refus reste. Le remède abouti — tout
|
||||||
|
// rendu autre que null, qui dit une désignation annulée —, lever(refus),
|
||||||
|
// quand il est donné, reçoit le refus affiché au départ du remède, celui
|
||||||
|
// qu'il levait : le porteur ne retire que lui, même remplacé entre-temps.
|
||||||
|
// Un refus de details.autres rend à lever le refus qui le porte.
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { actionRemede } from './commande.js';
|
||||||
|
import Commande from './Commande.svelte';
|
||||||
|
import Refus from './Refus.svelte';
|
||||||
|
|
||||||
|
let { refus, lever = null, gestes = {}, id = undefined } = $props();
|
||||||
|
|
||||||
|
const t = getContext('t');
|
||||||
|
const seance = getContext('seance');
|
||||||
|
const ui = getContext('ui') ?? null;
|
||||||
|
|
||||||
|
const message = $derived(t(refus.code, refus.details));
|
||||||
|
const action = $derived(actionRemede(refus.details?.remede ?? null, { seance, ui, gestes }));
|
||||||
|
const autres = $derived(refus.details?.autres ?? []);
|
||||||
|
|
||||||
|
async function executerRemede() {
|
||||||
|
const affiche = refus;
|
||||||
|
const rendu = await action.executer();
|
||||||
|
if (rendu !== null) lever?.(affiche);
|
||||||
|
return rendu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le lever d'un refus de details.autres : celui du refus qui le porte.
|
||||||
|
const leverPorteur = $derived(lever === null ? null : () => lever(refus));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="refus" role="status" data-refus={refus.code} {id}>
|
||||||
|
<p>{message}</p>
|
||||||
|
{#if action !== null}
|
||||||
|
{#key action}
|
||||||
|
<Commande
|
||||||
|
cle={action.cle}
|
||||||
|
details={action.details}
|
||||||
|
nom={action.nom}
|
||||||
|
confirmer={action.confirmer}
|
||||||
|
executer={executerRemede}
|
||||||
|
{gestes}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#each autres as autre, rang (rang)}
|
||||||
|
<Refus refus={autre} lever={leverPorteur} {gestes} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.refus {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--espace-1);
|
||||||
|
padding: var(--espace-1) var(--espace-2);
|
||||||
|
border-left: 3px solid var(--couleur-refus);
|
||||||
|
color: var(--couleur-refus);
|
||||||
|
font-size: var(--taille-texte);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
src/interface/primitives/champ.js
Normal file
145
src/interface/primitives/champ.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
161
src/interface/primitives/champ.test.js
Normal file
161
src/interface/primitives/champ.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
src/interface/primitives/commande.js
Normal file
180
src/interface/primitives/commande.js
Normal file
|
|
@ -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.<commande>, ou
|
||||||
|
// communs.remede.<geste>, suivie de .<variante> 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<string, (remede: 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 };
|
||||||
|
}
|
||||||
277
src/interface/primitives/commande.test.js
Normal file
277
src/interface/primitives/commande.test.js
Normal file
|
|
@ -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.<commande> ou
|
||||||
|
// communs.remede.<geste>[.<variante>] : la garde des clés littérales ne la
|
||||||
|
// voit pas, et cette épreuve l'énumère sur COMMANDES et sur les gestes du
|
||||||
|
// contrat de données.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { describe, test } from '../../../test/lanceur.js';
|
||||||
|
import { COMMANDES } from '../../application/commandes.js';
|
||||||
|
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||||
|
import { creerTraduction } from '../../application/traduction.js';
|
||||||
|
import { TABLES_INTERFACE } from '../libelles.js';
|
||||||
|
import { TABLE as COMMUNS } from '../libelles/communs.js';
|
||||||
|
import {
|
||||||
|
CONFIRMATION_DEBLOCAGE,
|
||||||
|
DELAI_CONFIRMATION_MS,
|
||||||
|
GESTES,
|
||||||
|
SECTIONS_REMEDE,
|
||||||
|
SOURCES_SECOURS,
|
||||||
|
actionRemede,
|
||||||
|
creerMachineCommande,
|
||||||
|
libelleRemede,
|
||||||
|
} from './commande.js';
|
||||||
|
|
||||||
|
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||||
|
|
||||||
|
// Une séance d'épreuve : chaque appel se consigne, et rend ce que l'épreuve
|
||||||
|
// lui donne.
|
||||||
|
function seanceEnregistreuse() {
|
||||||
|
const appels = [];
|
||||||
|
const consigner = (nom) => (...args) => {
|
||||||
|
appels.push([nom, ...args]);
|
||||||
|
return Promise.resolve({ nom });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
appels,
|
||||||
|
etat: () => ({ evenement: { base: 'Soirée des Merles' } }),
|
||||||
|
executer: consigner('executer'),
|
||||||
|
passerEnEcriture: consigner('passerEnEcriture'),
|
||||||
|
debloquer: consigner('debloquer'),
|
||||||
|
ouvrirSecours: consigner('ouvrirSecours'),
|
||||||
|
ecrireAilleurs: consigner('ecrireAilleurs'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiEnregistreur() {
|
||||||
|
const appels = [];
|
||||||
|
return {
|
||||||
|
appels,
|
||||||
|
allerA: (section) => appels.push(['allerA', section]),
|
||||||
|
montrerTour: (tour) => appels.push(['montrerTour', tour]),
|
||||||
|
montrerProposition: (id) => appels.push(['montrerProposition', id]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RACINE = { id: 'documents', chemin: 'D:\\Exemple\\Documents' };
|
||||||
|
|
||||||
|
describe('commande : la machine', () => {
|
||||||
|
test('le délai de confirmation est de dix secondes', () => {
|
||||||
|
assert.equal(DELAI_CONFIRMATION_MS, 10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans confirmer, chaque clic exécute, et la commande ne s’arme jamais', () => {
|
||||||
|
const commande = creerMachineCommande();
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||||
|
assert.equal(commande.armee, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('avec confirmer : un clic arme, un second exécute et désarme', () => {
|
||||||
|
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||||
|
assert.equal(commande.armee, false);
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'armer' });
|
||||||
|
assert.equal(commande.armee, true);
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||||
|
assert.equal(commande.armee, false);
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => {
|
||||||
|
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||||
|
commande.cliquer();
|
||||||
|
commande.expirer();
|
||||||
|
assert.equal(commande.armee, false);
|
||||||
|
assert.deepEqual(commande.cliquer(), { action: 'armer' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commande : le libellé d’un remède', () => {
|
||||||
|
test('un remède de commande → communs.remede.<commande>, ses arguments pour détails', () => {
|
||||||
|
const remede = { commande: 'reintegrerParticipant', arguments: { id: 3 } };
|
||||||
|
assert.deepEqual(libelleRemede(remede), { cle: 'communs.remede.reintegrerParticipant', details: { id: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un geste à variantes → communs.remede.<geste>.<variante>', () => {
|
||||||
|
assert.equal(libelleRemede({ geste: 'ouvrirSecours', source: 'precedent' }).cle, 'communs.remede.ouvrirSecours.precedent');
|
||||||
|
assert.equal(libelleRemede({ geste: 'ouvrirSecours', source: 'journal' }).cle, 'communs.remede.ouvrirSecours.journal');
|
||||||
|
assert.equal(libelleRemede({ geste: 'ecrireAilleurs', racine: RACINE }).cle, 'communs.remede.ecrireAilleurs.documents');
|
||||||
|
assert.equal(libelleRemede({ geste: 'ecrireAilleurs', racine: null }).cle, 'communs.remede.ecrireAilleurs.choisi');
|
||||||
|
assert.equal(libelleRemede({ geste: 'ouvrirSection', section: 'tables' }).cle, 'communs.remede.ouvrirSection.tables');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un geste sans variante → communs.remede.<geste> ; afficherTour porte son tour', () => {
|
||||||
|
assert.deepEqual(libelleRemede({ geste: 'modifier' }), { cle: 'communs.remede.modifier', details: {} });
|
||||||
|
assert.deepEqual(libelleRemede({ geste: 'debloquer' }), { cle: 'communs.remede.debloquer', details: {} });
|
||||||
|
assert.deepEqual(libelleRemede({ geste: 'afficherTour', tour: 2, proposition: null }), {
|
||||||
|
cle: 'communs.remede.afficherTour',
|
||||||
|
details: { tour: 2 },
|
||||||
|
});
|
||||||
|
assert.equal(libelleRemede({ geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: {} } }).cle, 'communs.remede.choisirPlace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un geste, une source ou une section hors du contrat lève RangeError en les nommant', () => {
|
||||||
|
assert.throws(() => libelleRemede({ geste: 'voler' }), (e) => e instanceof RangeError && /voler/.test(e.message));
|
||||||
|
assert.throws(() => libelleRemede({ geste: 'ouvrirSecours', source: 'ailleurs' }), RangeError);
|
||||||
|
assert.throws(() => libelleRemede({ geste: 'ouvrirSection', section: 'accueil' }), RangeError);
|
||||||
|
assert.throws(() => libelleRemede({ commande: 'voler', arguments: {} }), RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => {
|
||||||
|
test('chaque commande de COMMANDES a son libellé de remède, une chaîne non vide', () => {
|
||||||
|
const noms = [...COMMANDES.keys()];
|
||||||
|
assert.ok(noms.length >= 20, `${noms.length} commandes`);
|
||||||
|
for (const nom of noms) {
|
||||||
|
const { cle, details } = libelleRemede({ commande: nom, arguments: {} });
|
||||||
|
const texte = t(cle, details);
|
||||||
|
assert.equal(typeof texte, 'string', nom);
|
||||||
|
assert.ok(texte.length > 0 && !texte.includes('undefined'), `${nom} : « ${texte} »`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque geste du contrat, chaque variante comprise, a son libellé', () => {
|
||||||
|
assert.deepEqual([...GESTES], [
|
||||||
|
'modifier', 'debloquer', 'afficherTour', 'ouvrirSection', 'choisirPlace', 'ouvrirSecours', 'ecrireAilleurs',
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...SOURCES_SECOURS], ['precedent', 'journal']);
|
||||||
|
assert.deepEqual([...SECTIONS_REMEDE], ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
|
||||||
|
const remedes = [
|
||||||
|
{ geste: 'modifier' },
|
||||||
|
{ geste: 'debloquer' },
|
||||||
|
{ geste: 'afficherTour', tour: 3, proposition: null },
|
||||||
|
...SECTIONS_REMEDE.map((section) => ({ geste: 'ouvrirSection', section })),
|
||||||
|
{ geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: {} } },
|
||||||
|
...SOURCES_SECOURS.map((source) => ({ geste: 'ouvrirSecours', source })),
|
||||||
|
{ geste: 'ecrireAilleurs', racine: RACINE },
|
||||||
|
{ geste: 'ecrireAilleurs', racine: null },
|
||||||
|
];
|
||||||
|
assert.equal(remedes.length, 15);
|
||||||
|
const textes = remedes.map((remede) => {
|
||||||
|
const { cle, details } = libelleRemede(remede);
|
||||||
|
return t(cle, details);
|
||||||
|
});
|
||||||
|
for (const texte of textes) assert.ok(texte.length > 0 && !texte.includes('undefined'), texte);
|
||||||
|
assert.equal(t('communs.remede.afficherTour', { tour: 3 }), 'Afficher le tour 3');
|
||||||
|
assert.equal(new Set(textes).size, textes.length, 'deux variantes ne se confondent pas');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reprendreVerrou, offert à côté de VERROU_PRIS, a sa clé', () => {
|
||||||
|
assert.equal(t('communs.remede.reprendreVerrou'), 'Reprendre le verrou');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aucune clé communs.remede.* hors de l’énumération : chacune est atteinte par une commande, un geste ou reprendreVerrou', () => {
|
||||||
|
const atteintes = new Set([
|
||||||
|
'communs.remede.reprendreVerrou',
|
||||||
|
...[...COMMANDES.keys()].map((nom) => libelleRemede({ commande: nom, arguments: {} }).cle),
|
||||||
|
libelleRemede({ geste: 'modifier' }).cle,
|
||||||
|
libelleRemede({ geste: 'debloquer' }).cle,
|
||||||
|
libelleRemede({ geste: 'afficherTour', tour: 1, proposition: null }).cle,
|
||||||
|
libelleRemede({ geste: 'choisirPlace' }).cle,
|
||||||
|
...SECTIONS_REMEDE.map((section) => libelleRemede({ geste: 'ouvrirSection', section }).cle),
|
||||||
|
...SOURCES_SECOURS.map((source) => libelleRemede({ geste: 'ouvrirSecours', source }).cle),
|
||||||
|
libelleRemede({ geste: 'ecrireAilleurs', racine: RACINE }).cle,
|
||||||
|
libelleRemede({ geste: 'ecrireAilleurs', racine: null }).cle,
|
||||||
|
]);
|
||||||
|
const posees = [...COMMUNS.keys()].filter((cle) => cle.startsWith('communs.remede.'));
|
||||||
|
assert.ok(posees.length > 0);
|
||||||
|
assert.deepEqual(posees.filter((cle) => !atteintes.has(cle)), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque geste, source et section qu’un remède de src/application écrit en littéral est du contrat', () => {
|
||||||
|
const dossier = new URL('../../application/', import.meta.url);
|
||||||
|
const sources = readdirSync(dossier).filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js'));
|
||||||
|
const releves = { geste: new Set(), source: new Set(), section: new Set() };
|
||||||
|
for (const nom of sources) {
|
||||||
|
const texte = readFileSync(new URL(nom, dossier), 'utf8');
|
||||||
|
// Le geste de tout remède ; la source d'un secours et la section ouverte
|
||||||
|
// dans leur remède seul — EtatPlaces porte aussi une source —, et la
|
||||||
|
// section que generation.js passe à son aide versSection.
|
||||||
|
for (const [, geste] of texte.matchAll(/\bgeste: '([A-Za-z]+)'/g)) releves.geste.add(geste);
|
||||||
|
for (const [, source] of texte.matchAll(/geste: 'ouvrirSecours', source: '([A-Za-z]+)'/g)) releves.source.add(source);
|
||||||
|
for (const [, , section] of texte.matchAll(/(geste: 'ouvrirSection', section: |versSection\()'([A-Za-z]+)'/g)) {
|
||||||
|
releves.section.add(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(releves.geste.size >= 5, [...releves.geste].join(', '));
|
||||||
|
assert.ok(releves.section.size > 0 && releves.source.size > 0);
|
||||||
|
assert.deepEqual([...releves.geste].filter((g) => !GESTES.includes(g)), []);
|
||||||
|
assert.deepEqual([...releves.source].filter((s) => !SOURCES_SECOURS.includes(s)), []);
|
||||||
|
assert.deepEqual([...releves.section].filter((s) => !SECTIONS_REMEDE.includes(s)), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commande : l’action d’un remède', () => {
|
||||||
|
test('un remède null n’offre aucune action', () => {
|
||||||
|
assert.equal(actionRemede(null, { seance: seanceEnregistreuse() }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un remède de commande exécute la commande telle quelle, par la séance', async () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
const action = actionRemede({ commande: 'retirerTitre', arguments: { table: 2, siege: 4 } }, { seance });
|
||||||
|
assert.equal(action.nom, 'retirerTitre');
|
||||||
|
assert.equal(action.cle, 'communs.remede.retirerTitre');
|
||||||
|
assert.equal(action.confirmer, null);
|
||||||
|
await action.executer();
|
||||||
|
assert.deepEqual(seance.appels, [['executer', 'retirerTitre', { table: 2, siege: 4 }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modifier → passerEnEcriture ; ouvrirSecours → la base ouverte et la source ; ecrireAilleurs → la racine du remède', async () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
await actionRemede({ geste: 'modifier' }, { seance }).executer();
|
||||||
|
await actionRemede({ geste: 'ouvrirSecours', source: 'journal' }, { seance }).executer();
|
||||||
|
await actionRemede({ geste: 'ecrireAilleurs', racine: RACINE }, { seance }).executer();
|
||||||
|
await actionRemede({ geste: 'ecrireAilleurs', racine: null }, { seance }).executer();
|
||||||
|
assert.deepEqual(seance.appels, [
|
||||||
|
['passerEnEcriture'],
|
||||||
|
['ouvrirSecours', 'Soirée des Merles', 'journal'],
|
||||||
|
['ecrireAilleurs', RACINE],
|
||||||
|
['ecrireAilleurs', null],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debloquer se confirme : son action porte la clé de confirmation, et appelle seance.debloquer', async () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
const action = actionRemede({ geste: 'debloquer' }, { seance });
|
||||||
|
assert.equal(action.confirmer, CONFIRMATION_DEBLOCAGE);
|
||||||
|
assert.equal(CONFIRMATION_DEBLOCAGE, 'communs.commande.confirmerDeblocage');
|
||||||
|
assert.equal(action.nom, 'debloquer');
|
||||||
|
await action.executer();
|
||||||
|
assert.deepEqual(seance.appels, [['debloquer']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debloquer offert comme commande se confirme aussi : aucun chemin ne débloque en un clic (§ 8.5)', async () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
const action = actionRemede({ commande: 'debloquer', arguments: {} }, { seance });
|
||||||
|
assert.equal(action.confirmer, CONFIRMATION_DEBLOCAGE);
|
||||||
|
assert.equal(action.nom, 'debloquer');
|
||||||
|
await action.executer();
|
||||||
|
assert.deepEqual(seance.appels, [['executer', 'debloquer', {}]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('afficherTour et ouvrirSection passent par l’état de navigation ; sans lui, aucune action', () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
const ui = uiEnregistreur();
|
||||||
|
actionRemede({ geste: 'afficherTour', tour: 2, proposition: null }, { seance, ui }).executer();
|
||||||
|
actionRemede({ geste: 'ouvrirSection', section: 'participants' }, { seance, ui }).executer();
|
||||||
|
assert.deepEqual(ui.appels, [['montrerProposition', null], ['montrerTour', 2], ['allerA', 'participants']]);
|
||||||
|
assert.equal(actionRemede({ geste: 'ouvrirSection', section: 'tables' }, { seance }), null);
|
||||||
|
assert.deepEqual(seance.appels, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choisirPlace, et tout geste que l’écran fournit, passent par gestes ; sans lui, choisirPlace n’offre aucune action', () => {
|
||||||
|
const seance = seanceEnregistreuse();
|
||||||
|
const recus = [];
|
||||||
|
const remede = { geste: 'choisirPlace', puis: { commande: 'deplacerReservation', arguments: { participant: 4 } } };
|
||||||
|
assert.equal(actionRemede(remede, { seance, ui: uiEnregistreur() }), null);
|
||||||
|
const action = actionRemede(remede, { seance, gestes: { choisirPlace: (r) => recus.push(r) } });
|
||||||
|
action.executer();
|
||||||
|
assert.deepEqual(recus, [remede]);
|
||||||
|
assert.equal(action.cle, 'communs.remede.choisirPlace');
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/interface/primitives/geste.js
Normal file
63
src/interface/primitives/geste.js
Normal file
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
113
src/interface/primitives/geste.test.js
Normal file
113
src/interface/primitives/geste.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
968
src/interface/primitives/primitives.navigateur.test.js
Normal file
968
src/interface/primitives/primitives.navigateur.test.js
Normal file
|
|
@ -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 <dialog> 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue