[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
|
||||
// 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