[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:
Mathieu Benoit 2026-10-07 07:21:20 -04:00
parent cc6b9fe996
commit 876ea0f2ce
12 changed files with 2603 additions and 1 deletions

View file

@ -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'],
]);

View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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);
});
});

View 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 };
}

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

View 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();
},
};
}

View 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);
});
});

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