[ADD] interface: single sources for settings, shortcuts and CSV columns

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

--- FR ---

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

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

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 04:51:45 -04:00
parent 0e9ff8c708
commit 9d4f203ead
17 changed files with 1234 additions and 66 deletions

View file

@ -9,20 +9,30 @@
import { cleEntete } from './normalisation.js';
/** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */
export const CHAMPS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
// Un champ et ses en-têtes, figés.
const champ = (nom, entetes) => Object.freeze([nom, Object.freeze(entetes)]);
// En-têtes de chaque champ tels que le § 10.1 les écrit : son nom, puis ses
// synonymes.
const ENTETES_DES_CHAMPS = [
['nom', ['nom']],
['prenom', ['prenom']],
['appartenance', ['appartenance', 'organisation', 'entreprise', 'équipe']],
['courriel', ['courriel']],
['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']],
['exclu', ['exclu']],
['notes', ['notes']],
];
/**
* La table des colonnes reconnues (§ 10.1, § 19.8), seule source des champs
* du CSV et de leurs synonymes : chaque champ, dans l'ordre de l'export, avec
* ses en-têtes tels que le § 10.1 les écrit — son nom, puis ses synonymes.
* CHAMPS et la reconnaissance en dérivent, et le tableau des colonnes de la
* documentation s'en engendre. Figée à toute profondeur.
*
* @type {ReadonlyArray<readonly [string, ReadonlyArray<string>]>}
*/
export const ENTETES_DES_CHAMPS = Object.freeze([
champ('nom', ['nom']),
champ('prenom', ['prenom']),
champ('appartenance', ['appartenance', 'organisation', 'entreprise', 'équipe']),
champ('courriel', ['courriel']),
champ('titre_pressenti', ['titre_pressenti', 'titre', 'rôle']),
champ('exclu', ['exclu']),
champ('notes', ['notes']),
]);
/** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */
export const CHAMPS = Object.freeze(ENTETES_DES_CHAMPS.map(([nom]) => nom));
// Champ de chaque clé d'en-tête, la clé tirée de l'écriture du § 10.1 par la
// même règle que celle d'un en-tête lu.

View file

@ -7,7 +7,7 @@
// colonnes revendiquent tant qu'un choix ne tranche pas.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CHAMPS, associer, reconnaitre } from './colonnes.js';
import { CHAMPS, ENTETES_DES_CHAMPS, associer, reconnaitre } from './colonnes.js';
describe('CHAMPS', () => {
test('les sept champs du § 10.1, dans leur ordre, figés', () => {
@ -16,6 +16,42 @@ describe('CHAMPS', () => {
});
});
describe('ENTETES_DES_CHAMPS : la table des colonnes reconnues, seule source (§ 10.1, § 19.8)', () => {
test('chaque champ de CHAMPS, dans son ordre, avec son nom en tête de ses en-têtes, puis ses synonymes', () => {
assert.deepEqual(
ENTETES_DES_CHAMPS.map(([champ, entetes]) => [champ, [...entetes]]),
[
['nom', ['nom']],
['prenom', ['prenom']],
['appartenance', ['appartenance', 'organisation', 'entreprise', 'équipe']],
['courriel', ['courriel']],
['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']],
['exclu', ['exclu']],
['notes', ['notes']],
],
);
assert.deepEqual(ENTETES_DES_CHAMPS.map(([champ]) => champ), [...CHAMPS]);
});
test('figée à toute profondeur : ni la table, ni une paire, ni une liste d’en-têtes ne change', () => {
assert.ok(ENTETES_DES_CHAMPS.length > 0);
assert.ok(Object.isFrozen(ENTETES_DES_CHAMPS));
for (const paire of ENTETES_DES_CHAMPS) {
assert.ok(Object.isFrozen(paire), paire[0]);
assert.ok(Object.isFrozen(paire[1]), paire[0]);
}
assert.throws(() => ENTETES_DES_CHAMPS.push(['ligne', ['ligne']]), TypeError);
assert.throws(() => ENTETES_DES_CHAMPS[2][1].push('club'), TypeError);
assert.equal(reconnaitre('club'), null);
});
test('chaque en-tête de la table est reconnu sous son champ : la reconnaissance la lit', () => {
const paires = ENTETES_DES_CHAMPS.flatMap(([champ, entetes]) => entetes.map((entete) => [entete, champ]));
assert.equal(paires.length, 12);
for (const [entete, champ] of paires) assert.equal(reconnaitre(entete), champ, entete);
});
});
describe('reconnaitre (§ 10.1)', () => {
test('chaque champ par son propre nom', () => {
assert.ok(CHAMPS.length > 0, 'aucun champ');

View file

@ -0,0 +1,104 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'avertissement LISTE_SUR_VOISINE dans un vrai moteur de rendu (§ 7.1,
// § 7.4, § 9) : une table agrandie dont la liste de noms passe sur sa
// voisine. Sur une charge construite — deux rondes de 8 posées par
// creerTable, la table 1 portée à 16 places par le champ de la section des
// tables —, l'avertissement paraît dans les annonces de l'événement, nomme
// la voisine par son numéro, et son remède déplace la voisine sur la case
// libre que la commande a choisie, par deplacerTable. App est montée par
// ouvrirEvenement (test/navigateur/ecrans.js), sur le système de fichiers
// simulé, au cadre de 1 366 × 700 ; le texte attendu vient de la même
// traduction que celle de l'application.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
import { attendre, cliquer, creerContexte, ouvrirEvenement } from '../../../test/navigateur/ecrans.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { avertissementsOrdonnes } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Images attendues au plus par étape : bien au-delà d'une écriture simulée
// et de son rendu, et sous le délai d'une épreuve, pour qu'une étape qui ne
// vient pas se nomme.
const IMAGES = 300;
// Deux rondes de 8, la table 1 en (0, 0), la table 2 en (813, 0) : la
// grille de creerTable les écarte de la largeur d'un bloc de noms à 8
// places, que la table 1 à 16 places dépasse.
function chargeVoisines() {
let charge = creerCharge({ id: 'epreuve-voisines', nom: 'Club des Merles', siegesParDefaut: 8, tours: 2 });
for (let i = 0; i < 2; i += 1) charge = appliquerCommande('creerTable', charge, { forme: 'ronde' }).charge;
return charge;
}
let contexte = null;
beforeEach(async () => {
await fixerCadre(1366, 700);
contexte = creerContexte();
});
afterEach(async () => {
const erreurs = contexte.erreurs();
await contexte.defaire();
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
describe('LISTE_SUR_VOISINE : la liste d’une table agrandie passe sur sa voisine (§ 7.1, Q17)', () => {
test('agrandie par son champ, la table 1 : l’avertissement paraît dans les annonces de l’événement, nomme la table 2, et son remède la déplace sur la case libre', async () => {
const charge = chargeVoisines();
expect(charge.tables.map(({ id, numero, position }) => [id, numero, position.x, position.y])).toEqual([
[1, 1, 0, 0],
[2, 2, 813, 0],
]);
// Ce que la commande dit du même geste : un avertissement, la table 1
// et sa voisine 2, et la case où le remède pose la voisine.
const attendu = appliquerCommande('changerPlacesTable', charge, { id: 1, sieges: 16, origine: 'champ' }).avertissements;
expect(attendu.map(({ code, details }) => [code, details.table, details.voisine, details.numeroVoisine])).toEqual([
['LISTE_SUR_VOISINE', 1, 2, 2],
]);
const [{ details }] = attendu;
expect(details.remede.commande).toBe('deplacerTable');
const { position } = details.remede.arguments;
expect(position).not.toEqual({ x: 813, y: 0 });
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'tables' });
const champ = await attendre('le champ des places de la table 1', () => m.racine.querySelector('input[data-champ="sieges-1"]'), IMAGES);
champ.focus();
champ.select();
for (const caractere of '16') await touche(caractere);
await touche('Enter');
await attendre('la table 1 à 16 places', () => m.seance.etat().charge.tables[0].sieges === 16, IMAGES);
const annonce = await attendre(
"l'avertissement dans les annonces de l'événement",
() => m.racine.querySelector('[data-annonces] [data-avertissement="LISTE_SUR_VOISINE"]'),
IMAGES,
);
// Les annonces montrent les avertissements de la séance, dans son ordre.
const montres = [...m.racine.querySelectorAll('[data-annonces] [data-avertissement]')].map((element) => element.dataset.avertissement);
expect(montres).toEqual(avertissementsOrdonnes(m.seance.etat()).map(({ code }) => code));
expect(montres).toContain('LISTE_SUR_VOISINE');
const texte = t('LISTE_SUR_VOISINE', details);
expect(annonce.querySelector('p').textContent).toBe(texte);
expect(texte).toContain('table 2');
const remede = await attendre('le remède de l’avertissement', () => annonce.querySelector('[data-commande="deplacerTable"]'), IMAGES);
expect(remede.textContent.trim()).toBe(t('communs.remede.deplacerTable', details.remede.arguments));
await cliquer(m, '[data-annonces] [data-avertissement="LISTE_SUR_VOISINE"] [data-commande="deplacerTable"]');
await attendre('la table 2 déplacée', () => m.seance.etat().charge.tables[1].position.x !== 813, IMAGES);
const apres = m.seance.etat().charge;
expect(apres.tables.map(({ id, position: { x, y } }) => [id, x, y])).toEqual([
[1, 0, 0],
[2, position.x, position.y],
]);
expect(apres.tables[0].sieges).toBe(16);
});
});

View file

@ -93,10 +93,13 @@ const REMEDES = new Map([
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
]);
// Le remède d'un avertissement : celui de REMEDES, sauf une réservation que
// le retenu ne tient pas à son siège (details.siege), que lève l'échange de
// sa personne avec l'occupant, ou, chaise vide, son déplacement au siège.
// Le remède d'un avertissement : celui qu'il porte en details.remede quand
// il en porte un — LISTE_SUR_VOISINE, le déplacement de la voisine que la
// commande a choisi —, sinon celui de REMEDES, sauf une réservation que le
// retenu ne tient pas à son siège (details.siege), que lève l'échange de sa
// personne avec l'occupant, ou, chaise vide, son déplacement au siège.
function remedeDe(code, details) {
if (details?.remede !== undefined && details.remede !== null) return details.remede;
if (code === 'RESERVATION_NON_HONOREE' && details.siege !== undefined) {
const { tour, participant, table, siege, occupant } = details;
return occupant === null

View file

@ -208,6 +208,24 @@ describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
assert.deepEqual(sansExamen.derives, []);
});
test('un avertissement qui porte son remède en details.remede le garde : LISTE_SUR_VOISINE déplace la voisine (§ 7.1)', () => {
const remede = { commande: 'deplacerTable', arguments: { id: 2, position: { x: 1626, y: 0 } } };
const details = { table: 1, voisine: 2, numero: 1, numeroVoisine: 2, remede };
const [rendu] = avertissementsOrdonnes(etat({ avertissements: [{ code: 'LISTE_SUR_VOISINE', details }] }));
assert.deepEqual(rendu, { code: 'LISTE_SUR_VOISINE', details, remede });
// Un remède null ou absent laisse celui de REMEDES, ou aucun.
const [derive, verrou] = avertissementsOrdonnes(
etat({
avertissements: [
{ code: 'DERIVE', details: { ids: [], retenu: false, remede: null } },
{ code: 'VERROU_REPRIS', details: { seance: 'b', depuis: null } },
],
}),
);
assert.deepEqual(derive.remede, REMEDES.get('DERIVE'));
assert.equal(verrou.remede, null);
});
test('chaque remède rendu se traduit : sa clé existe', () => {
const rendus = avertissementsOrdonnes(etat({ avertissements: CODES.map(avertissement) }));
const avecRemede = rendus.filter(({ remede }) => remede !== null);

View file

@ -0,0 +1,164 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les réglages de génération montrés depuis leur définition, dans un vrai
// moteur de rendu (§ 5.3, § 5.7, § 19.8) : Reglages.svelte, monté par mount
// avec les contextes que pose App — 't', 'seance', 'generation' —, sur une
// séance du système de fichiers simulé et l'horloge pilotée, montre chaque
// entrée de la section 'generation' de REGLAGES_GENERATION dans son ordre,
// sous son libellé, à la valeur de la charge, et aucune d'une autre section ;
// une définition passée par l'épreuve, une entrée ajoutée ou retirée, change
// l'écran de même. Le texte attendu vient de la
// même traduction que celle de l'application ; 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 } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import '../jetons.css';
import { creerControleurGeneration, creerExecuteurDirect } from '../../application/generation.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Reglages from './Reglages.svelte';
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
// Les entrées que l'écran de la génération montre : celles de sa section.
const DE_LA_GENERATION = reglagesDeSection(REGLAGES_GENERATION, 'generation');
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// 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();
}
});
// Une séance sur le système simulé et l'horloge pilotée, un événement créé,
// son contrôleur de génération ; Reglages monté sur la charge que rend
// avecCharge(charge de la séance), avec les props supplémentaires. Rend le
// conteneur.
async function monter({ avecCharge = (charge) => charge, props = {} } = {}) {
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: 'Soirée des Merles', date: null, siegesParDefaut: 6, tours: 2 });
const generation = creerControleurGeneration({
seance,
executeur: creerExecuteurDirect({ coeursDisponibles: 4 }),
horloge,
produitVersion: VERSION.affichee,
});
const cible = document.createElement('div');
document.body.append(cible);
const composant = mount(Reglages, {
target: cible,
props: {
charge: avecCharge(seance.etat().charge),
mesure: null,
estimation: null,
graine: '7',
refusGraine: null,
saisirGraine: () => {},
...props,
},
context: new Map([
['t', t],
['seance', seance],
['generation', generation],
]),
});
flushSync();
montes.push({ composant, cible });
return cible;
}
// Les désignations des champs montrés, dans l'ordre du document.
const designations = (cible) => [...cible.querySelectorAll('input[data-champ]')].map((entree) => entree.dataset.champ);
// L'entrée désignée par sa clé, et le texte de son libellé.
const entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`);
const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim();
describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
test('la définition livrée : la graine, chaque entrée de la génération dans son ordre, sous son libellé, à la valeur de la charge, puis les cœurs ; la cible des propositions n’y est pas', async () => {
const cible = await monter();
expect(DE_LA_GENERATION.length).toBeGreaterThan(0);
expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length);
expect(designations(cible)).toEqual(['graine', ...DE_LA_GENERATION.map(({ cle }) => cle), 'coeursAutomatiques']);
expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull();
for (const { cle, etiquette, genre, defaut } of DE_LA_GENERATION) {
expect(libelleDe(cible, cle), cle).toBe(t(etiquette));
// Une charge neuve porte les défauts de la définition.
if (genre === 'bascule') expect(entree(cible, cle).checked, cle).toBe(defaut);
else expect(entree(cible, cle).value, cle).toBe(String(defaut));
}
});
test('une entrée ajoutée à la définition paraît à l’écran, champ comme bascule, à sa place, sous son libellé, à la valeur de la charge', async () => {
const champ = Object.freeze({
nom: 'epreuve',
cle: 'generation.epreuve',
etiquette: 'generation.coeurs.titre',
unite: 'mouvements',
defaut: 1,
genre: 'champ',
section: 'generation',
});
const bascule = Object.freeze({
nom: 'epreuveBascule',
cle: 'epreuveBascule',
etiquette: 'generation.contraintes.titre',
unite: null,
defaut: false,
genre: 'bascule',
section: 'generation',
});
// Une entrée d'une autre section ne paraît pas sur cet écran.
const ailleurs = Object.freeze({ ...champ, nom: 'ailleurs', cle: 'ailleurs', section: 'propositions' });
const definition = [...DE_LA_GENERATION.slice(0, 1), champ, ailleurs, ...DE_LA_GENERATION.slice(1), bascule];
const cible = await monter({
avecCharge: (charge) => ({
...charge,
reglages: { ...charge.reglages, epreuveBascule: true, generation: { ...charge.reglages.generation, epreuve: 4321 } },
}),
props: { definition },
});
const cles = DE_LA_GENERATION.map(({ cle: c }) => c);
expect(designations(cible)).toEqual(['graine', cles[0], 'generation.epreuve', ...cles.slice(1), 'epreuveBascule', 'coeursAutomatiques']);
expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre'));
expect(entree(cible, 'generation.epreuve').value).toBe('4321');
expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre'));
expect(entree(cible, 'epreuveBascule').type).toBe('checkbox');
expect(entree(cible, 'epreuveBascule').checked).toBe(true);
});
test('une entrée retirée de la définition quitte l’écran : seuls ses champs et ses bascules paraissent', async () => {
const gardees = DE_LA_GENERATION.filter(({ nom }) => nom === 'arret' || nom === 'nouvelleTable');
expect(gardees).toHaveLength(2);
const cible = await monter({ props: { definition: gardees } });
expect(designations(cible)).toEqual(['graine', 'generation.arret', 'nouvelleTable', 'coeursAutomatiques']);
});
});

View file

@ -8,7 +8,15 @@
// calcul de la séance, et ce qu'elle laisse attendre des réglages courants.
// Aucun champ ne prend une durée : la recherche s'arrête sur un compte.
//
// Les trois réglages de la charge (generation.nombre, generation.arret,
// Les réglages de la charge sont ceux de definition, par défaut
// REGLAGES_GENERATION (reglages.js), la définition dont s'engendre aussi
// le tableau de la documentation : chaque entrée de la section
// 'generation' paraît, dans son ordre, champ ou bascule selon son genre,
// sous son libellé et désignée par sa clé (data-champ) ; une entrée d'une
// autre section appartient à un autre écran. Une épreuve passe une
// définition à elle.
//
// Les champs (generation.nombre, generation.arret,
// generation.historique) sont des Champ : une saisie se lit par la
// validation du contrôleur de génération, la même que celle de
// « Générer », et s'écrit à la sortie du champ par modifierReglage — une
@ -35,11 +43,14 @@
// du dernier nombre accepté. La saisie se valide à la sortie du champ ou
// par Entrée, par controleur.reglerCoeurs ; son refus se pose contre le
// champ, saisie gardée, et le parent le lit (refusCoeurs, lié) : un refus
// posé, « Générer » ne part pas. Échap rend le dernier nombre accepté. La
// valeur automatique du poste s'affiche à côté, dans les deux états.
// posé, « Générer » ne part pas. Échap rend le dernier nombre accepté ; les
// deux touches sont celles du contexte 'champ' de la table des raccourcis.
// La valeur automatique du poste s'affiche à côté, dans les deux états.
import { getContext, untrack } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
let {
charge,
@ -49,6 +60,7 @@
refusGraine = null,
saisirGraine,
refusCoeurs = $bindable(null),
definition = REGLAGES_GENERATION,
} = $props();
const t = getContext('t');
@ -82,15 +94,19 @@
}
function toucheCoeurs(evenement) {
if (evenement.key === 'Escape') rendreCoeurs();
else if (evenement.key === 'Enter') {
const effet = effetDe('champ', evenement.key);
if (effet === 'rendre') rendreCoeurs();
else if (effet === 'valider') {
evenement.preventDefault();
validerCoeurs();
}
}
// Les réglages de la charge, dans l'ordre de l'écran.
const REGLAGES = ['nombre', 'arret', 'historique'];
// Les champs et les bascules de la section de la génération, chacun dans
// l'ordre de la définition.
const deLaGeneration = $derived(reglagesDeSection(definition, 'generation'));
const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ'));
const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule'));
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
@ -102,17 +118,7 @@
return { valeur: reglages[nom] };
};
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
// Les bascules, dans l'ordre du § 5.3 : la clé du réglage et celle de son
// libellé.
const BASCULES = [
{ cle: 'separerAppartenances', etiquette: 'generation.contrainte.separerAppartenances' },
{ cle: 'nouveauxVoisins', etiquette: 'generation.contrainte.nouveauxVoisins' },
{ cle: 'nouvelleTable', etiquette: 'generation.contrainte.nouvelleTable' },
{ cle: 'varierAppartenances', etiquette: 'generation.contrainte.varierAppartenances' },
{ cle: 'attribuerSieges', etiquette: 'generation.contrainte.attribuerSieges' },
];
const ecrire = (cle) => (valeur) => seance.executer('modifierReglage', { cle, valeur });
// Le refus de la dernière bascule, { cle, refus }, posé à côté d'elle.
let refusBascule = $state.raw(null);
@ -121,16 +127,17 @@
// la charge ; à l'issue, la case montre celle de la charge, écrite ou
// inchangée. Annuler le clic ne suffirait pas : la séance peut avoir écrit
// avant que le navigateur rétablisse la case, à la fin de l'événement.
async function basculer(evenement, cle) {
async function basculer(evenement, entree) {
const caseACocher = evenement.currentTarget;
const { cle } = entree;
refusBascule = null;
try {
await seance.executer('modifierReglage', { cle, valeur: !charge.reglages[cle] });
await seance.executer('modifierReglage', { cle, valeur: !valeurDuReglage(charge.reglages, entree) });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusBascule = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
} finally {
caseACocher.checked = charge.reglages[cle];
caseACocher.checked = valeurDuReglage(charge.reglages, entree);
}
}
</script>
@ -152,26 +159,27 @@
<Refus id={idRefusGraine} refus={refusGraine} />
{/if}
</div>
{#each REGLAGES as nom (nom)}
{#each champs as entree (entree.cle)}
<Champ
cle={`generation.${nom}`}
etiquette={`generation.reglage.${nom}`}
valeur={charge.reglages.generation[nom]}
lire={lecteur(nom)}
executer={ecrire(nom)}
cle={entree.cle}
etiquette={entree.etiquette}
valeur={valeurDuReglage(charge.reglages, entree)}
lire={lecteur(entree.nom)}
executer={ecrire(entree.cle)}
/>
{/each}
</div>
<fieldset class="bascules">
<legend>{t('generation.contraintes.titre')}</legend>
{#each BASCULES as { cle, etiquette } (cle)}
{#each bascules as entree (entree.cle)}
{@const { cle, etiquette } = entree}
<div class="bascule">
<input
id={`${uid}-${cle}`}
type="checkbox"
data-champ={cle}
checked={charge.reglages[cle]}
onclick={(evenement) => basculer(evenement, cle)}
checked={valeurDuReglage(charge.reglages, entree)}
onclick={(evenement) => basculer(evenement, entree)}
/>
<label for={`${uid}-${cle}`}>{t(etiquette)}</label>
{#if refusBascule !== null && refusBascule.cle === cle}

View file

@ -0,0 +1,140 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La définition des réglages de la charge (§ 5.3, § 5.7, § 5.10, § 8.9,
// § 19.8) : la seule liste des réglages que porte le fichier. Chaque écran
// montre les entrées de sa section, dans leur ordre — Reglages.svelte celles
// de 'generation' —, et le tableau des réglages de la documentation
// s'engendre de toutes : un réglage ajouté ici paraît des deux côtés.
//
// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section } :
// nom son nom dans charge.reglages, ou dans charge.reglages.generation
// pour un champ de la génération ;
// cle son chemin dans charge.reglages, que la commande qui l'écrit
// reçoit et que le champ désigne (data-champ) :
// generation.<nom> pour un champ de la génération, <nom> sinon ;
// etiquette la clé du libellé, qui porte l'unité pour un champ ;
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null
// pour une bascule et pour la cible, une somme sans unité ;
// defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
// que la charge ne porte pas ;
// genre 'champ', une saisie entière, ou 'bascule', une case à cocher ;
// section la section de l'écran qui le montre : 'generation', écrit par
// modifierReglage, ou 'propositions', la cible de la somme de
// redondance, écrite par changerCibleRedondance.
//
// La graine, saisie de la commande, et les cœurs, réglage du poste, ne sont
// pas au fichier : Reglages.svelte les montre à part.
//
// Les défauts se lisent sur la charge d'un événement neuf, telle que la
// commande de création la pose (chargeNeuve) : creerCharge y copie
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe
// pas le stockage. La charge lue ici n'est écrite nulle part. La cible,
// facultative, n'y est pas : son défaut est CIBLE_REDONDANCE_PAR_DEFAUT.
import { chargeNeuve } from '../../application/commandes.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
/** @typedef {'champ'|'bascule'} GenreReglage */
/** @typedef {'generation'|'propositions'} SectionReglage */
/**
* @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null,
* defaut: number|boolean, genre: GenreReglage, section: SectionReglage}>} EntreeReglage
*/
const { reglages: DEFAUTS } = chargeNeuve(
{ nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT },
() => 'reglages',
).charge;
const champ = (nom, unite) =>
Object.freeze({
nom,
cle: `generation.${nom}`,
etiquette: `generation.reglage.${nom}`,
unite,
defaut: DEFAUTS.generation[nom],
genre: 'champ',
section: 'generation',
});
const bascule = (nom) =>
Object.freeze({
nom,
cle: nom,
etiquette: `generation.contrainte.${nom}`,
unite: null,
defaut: DEFAUTS[nom],
genre: 'bascule',
section: 'generation',
});
const CIBLE = Object.freeze({
nom: 'cibleRedondance',
cle: 'cibleRedondance',
etiquette: 'propositions.cible.etiquette',
unite: null,
defaut: CIBLE_REDONDANCE_PAR_DEFAUT,
genre: 'champ',
section: 'propositions',
});
/**
* Les réglages de la charge, dans l'ordre des écrans : les trois champs de
* la recherche (§ 5.7, § 5.10), puis les quatre contraintes et l'attribution
* des sièges, dans l'ordre du § 5.3 ; enfin la cible de la somme de
* redondance (§ 8.9), de la section des propositions.
*
* @type {ReadonlyArray<EntreeReglage>}
*/
export const REGLAGES_GENERATION = Object.freeze([
champ('nombre', 'propositions'),
champ('arret', 'mouvements'),
champ('historique', 'mouvements'),
bascule('separerAppartenances'),
bascule('nouveauxVoisins'),
bascule('nouvelleTable'),
bascule('varierAppartenances'),
bascule('attribuerSieges'),
CIBLE,
]);
/**
* Les entrées d'un genre, dans l'ordre de la définition.
*
* @param {ReadonlyArray<EntreeReglage>} definition
* @param {GenreReglage} genre
* @returns {EntreeReglage[]}
*/
export function reglagesDuGenre(definition, genre) {
return definition.filter((entree) => entree.genre === genre);
}
/**
* Les entrées d'une section, dans l'ordre de la définition.
*
* @param {ReadonlyArray<EntreeReglage>} definition
* @param {SectionReglage} section
* @returns {EntreeReglage[]}
*/
export function reglagesDeSection(definition, section) {
return definition.filter((entree) => entree.section === section);
}
/**
* La valeur qu'une charge porte pour une entrée, lue au chemin de sa clé :
* reglages.generation[nom] pour un champ de la génération, reglages[nom]
* sinon. Un réglage facultatif absent vaut le défaut de l'entrée.
*
* @param {Object} reglages charge.reglages
* @param {EntreeReglage} entree
* @returns {number|boolean}
* @throws {RangeError} pour un genre inconnu
*/
export function valeurDuReglage(reglages, entree) {
if (entree.genre !== 'champ' && entree.genre !== 'bascule') {
throw new RangeError(`genre de réglage inconnu : ${JSON.stringify(entree.genre)}`);
}
const valeur = entree.cle.split('.').reduce((objet, nom) => objet?.[nom], reglages);
return valeur ?? entree.defaut;
}

View file

@ -0,0 +1,182 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7,
// § 5.10, § 8.9, § 19.8) : sa forme, figée ; l'ordre des écrans ; les
// défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et
// CIBLE_REDONDANCE_PAR_DEFAUT compris ; chaque réglage du schéma du fichier,
// facultatif compris, et lui seul ; les clés que les commandes acceptent et
// les libellés qui existent ; la lecture d'une valeur dans une charge.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { appliquerCommande } from '../../application/commandes.js';
import { creerTraduction } from '../../application/traduction.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, GENERATION_PAR_DEFAUT, SCHEMA, creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Une charge neuve, telle que creerCharge la pose.
const chargeNeuveEpreuve = () => creerCharge({ id: 'epreuve', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
// La règle du champ cle d'une règle objet du schéma.
const champDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
// Les chemins des réglages que le schéma du fichier décrit, dans son ordre :
// <cle> pour une valeur à la racine des réglages, <objet>.<cle> pour une
// valeur d'un objet imbriqué ; un champ facultatif compte comme un autre.
function cheminsDuSchema() {
const chemins = [];
const parcourir = (regle, prefixe) => {
for (const [cle, sous] of regle.champs) {
if (sous.genre === 'objet') parcourir(sous, `${prefixe}${cle}.`);
else chemins.push(`${prefixe}${cle}`);
}
};
parcourir(champDe(champDe(SCHEMA, 'charge'), 'reglages'), '');
return chemins;
}
// La commande qui écrit un réglage de chaque section, et ses arguments.
const ECRITURES = new Map([
['generation', (entree, valeur) => ['modifierReglage', { cle: entree.cle, valeur }]],
['propositions', (entree, valeur) => ['changerCibleRedondance', { cible: String(valeur) }]],
]);
describe('REGLAGES_GENERATION : la définition que lisent les écrans et la documentation (§ 19.8)', () => {
test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section ; la définition comme chaque entrée sont figées', () => {
assert.ok(REGLAGES_GENERATION.length > 0, 'définition vide');
assert.ok(Object.isFrozen(REGLAGES_GENERATION));
for (const entree of REGLAGES_GENERATION) {
assert.deepEqual(Object.keys(entree), ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section']);
assert.ok(Object.isFrozen(entree), entree.nom);
}
assert.throws(() => REGLAGES_GENERATION.push({}), TypeError);
});
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, puis la cible de la section des propositions', () => {
assert.deepEqual(
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]),
[
['generation', 'champ', 'nombre'],
['generation', 'champ', 'arret'],
['generation', 'champ', 'historique'],
['generation', 'bascule', 'separerAppartenances'],
['generation', 'bascule', 'nouveauxVoisins'],
['generation', 'bascule', 'nouvelleTable'],
['generation', 'bascule', 'varierAppartenances'],
['generation', 'bascule', 'attribuerSieges'],
['propositions', 'champ', 'cibleRedondance'],
],
);
});
test('un champ de la génération : clé generation.<nom>, libellé generation.reglage.<nom>, une unité ; une bascule : clé <nom>, libellé generation.contrainte.<nom>, sans unité', () => {
const generation = reglagesDeSection(REGLAGES_GENERATION, 'generation');
const champs = reglagesDuGenre(generation, 'champ');
assert.equal(champs.length, 3);
for (const { nom, cle, etiquette } of champs) {
assert.equal(cle, `generation.${nom}`);
assert.equal(etiquette, `generation.reglage.${nom}`);
}
assert.deepEqual(champs.map(({ unite }) => unite), ['propositions', 'mouvements', 'mouvements']);
const bascules = reglagesDuGenre(generation, 'bascule');
assert.equal(bascules.length, 5);
for (const { nom, cle, etiquette, unite } of bascules) {
assert.equal(cle, nom);
assert.equal(etiquette, `generation.contrainte.${nom}`);
assert.equal(unite, null);
}
assert.equal(champs.length + bascules.length, generation.length);
});
test('la cible de la somme de redondance : un champ de la section des propositions, à la racine des réglages, sous le libellé de son champ, sans unité', () => {
assert.deepEqual(reglagesDeSection(REGLAGES_GENERATION, 'propositions'), [
{
nom: 'cibleRedondance',
cle: 'cibleRedondance',
etiquette: 'propositions.cible.etiquette',
unite: null,
defaut: CIBLE_REDONDANCE_PAR_DEFAUT,
genre: 'champ',
section: 'propositions',
},
]);
});
test('chaque défaut est celui d’une charge neuve : GENERATION_PAR_DEFAUT pour les champs, les bascules de creerCharge, CIBLE_REDONDANCE_PAR_DEFAUT pour la cible absente', () => {
const { reglages } = chargeNeuveEpreuve();
assert.equal(Object.hasOwn(reglages, 'cibleRedondance'), false);
for (const entree of REGLAGES_GENERATION) {
assert.equal(entree.defaut, valeurDuReglage(reglages, entree), entree.nom);
}
const generation = reglagesDeSection(REGLAGES_GENERATION, 'generation');
assert.deepEqual(
Object.fromEntries(reglagesDuGenre(generation, 'champ').map(({ nom, defaut }) => [nom, defaut])),
{ ...GENERATION_PAR_DEFAUT },
);
assert.deepEqual(
reglagesDuGenre(generation, 'bascule').map(({ defaut }) => defaut),
[true, true, true, true, false],
);
});
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau', () => {
const chemins = cheminsDuSchema();
assert.ok(chemins.includes('cibleRedondance') && chemins.includes('generation.nombre'), chemins.join(', '));
assert.deepEqual(
[...REGLAGES_GENERATION.map(({ cle }) => cle)].sort(),
[...chemins].sort(),
);
});
test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => {
let charge = chargeNeuveEpreuve();
for (const entree of REGLAGES_GENERATION) {
const valeur = entree.genre === 'bascule' ? !entree.defaut : entree.defaut + 1;
const [commande, args] = ECRITURES.get(entree.section)(entree, valeur);
charge = appliquerCommande(commande, charge, args).charge;
assert.equal(valeurDuReglage(charge.reglages, entree), valeur, entree.cle);
assert.notEqual(t(entree.etiquette), entree.etiquette);
}
assert.deepEqual([...new Set(REGLAGES_GENERATION.map(({ section }) => section))], [...ECRITURES.keys()]);
});
});
describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
test('la valeur d’un réglage se lit au chemin de sa clé : sous generation pour un champ de la génération, à la racine sinon', () => {
const reglages = { nouvelleTable: false, cibleRedondance: 7, generation: { nombre: 9 } };
const [nombre] = REGLAGES_GENERATION;
const nouvelleTable = REGLAGES_GENERATION.find(({ nom }) => nom === 'nouvelleTable');
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
assert.equal(valeurDuReglage(reglages, nombre), 9);
assert.equal(valeurDuReglage(reglages, nouvelleTable), false);
assert.equal(valeurDuReglage(reglages, cible), 7);
});
test('un réglage facultatif absent vaut son défaut', () => {
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT);
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3);
});
test('un genre inconnu lève, et le nomme', () => {
assert.throws(() => valeurDuReglage({ generation: {} }, { nom: 'x', cle: 'x', genre: 'curseur' }), { name: 'RangeError', message: /"curseur"/ });
});
test('reglagesDuGenre et reglagesDeSection gardent l’ordre de la définition reçue, et une définition vide n’en rend aucun', () => {
const definition = [...REGLAGES_GENERATION].reverse();
assert.deepEqual(
reglagesDuGenre(definition, 'champ').map(({ nom }) => nom),
['cibleRedondance', 'historique', 'arret', 'nombre'],
);
assert.deepEqual(
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom),
['attribuerSieges', 'varierAppartenances'],
);
assert.deepEqual(reglagesDuGenre([], 'bascule'), []);
assert.deepEqual(reglagesDeSection([], 'generation'), []);
});
});

View file

@ -20,6 +20,7 @@ import { TABLE as IMPORT } from './libelles/import.js';
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
import { TABLE as PLAN } from './libelles/plan.js';
import { TABLE as PROPOSITIONS } from './libelles/propositions.js';
import { TABLE as RACCOURCIS } from './libelles/raccourcis.js';
import { TABLE as TABLES } from './libelles/tables.js';
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
@ -34,6 +35,7 @@ export const TABLES_INTERFACE = Object.freeze([
PARTICIPANTS,
PLAN,
PROPOSITIONS,
RACCOURCIS,
TABLES,
]);

View file

@ -65,12 +65,12 @@ async function tablesDuDossier() {
}
describe('libellés : les tables des écrans (§ 14.6)', () => {
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, onze, par ordre alphabétique', async () => {
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, douze, par ordre alphabétique', async () => {
const tables = await tablesDuDossier();
assert.equal(tables.length, 11);
assert.equal(tables.length, 12);
assert.deepEqual(tables.map(({ table }) => table), [
'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation',
'import', 'participants', 'plan', 'propositions', 'tables',
'import', 'participants', 'plan', 'propositions', 'raccourcis', 'tables',
]);
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
assert.equal(TABLES_INTERFACE.length, tables.length);

View file

@ -0,0 +1,33 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés des raccourcis clavier (§ 7.3, § 19.8) : ce que fait chaque
// effet de la table des raccourcis (raccourcis.js), une clé par contexte et
// par effet, raccourcis.<contexte>.<effet>. Le tableau engendré des
// raccourcis les lit ; le nom de la touche n'entre dans aucun de ces textes.
export const TABLE = new Map([
// --- Le plan -------------------------------------------------------------------
['raccourcis.plan.droite', 'Passe à la table voisine à droite, ou au siège suivant.'],
['raccourcis.plan.bas', 'Passe à la table voisine en dessous, ou au siège suivant.'],
['raccourcis.plan.gauche', 'Passe à la table voisine à gauche, ou au siège précédent.'],
['raccourcis.plan.haut', 'Passe à la table voisine au-dessus, ou au siège précédent.'],
['raccourcis.plan.entrer', 'Descend dans les sièges de la table, ou choisit la place.'],
['raccourcis.plan.remonter', 'Remonte du siège à sa table ; sur une table, oublie le choix.'],
// --- La grille des participants -----------------------------------------------
['raccourcis.grille.droite', 'Passe à la cellule de droite.'],
['raccourcis.grille.bas', 'Passe à la cellule du dessous.'],
['raccourcis.grille.gauche', 'Passe à la cellule de gauche.'],
['raccourcis.grille.haut', 'Passe à la cellule du dessus.'],
['raccourcis.grille.suivante', 'Valide la saisie en cours et passe à la cellule suivante.'],
['raccourcis.grille.precedente', 'Valide la saisie en cours et revient à la cellule précédente.'],
['raccourcis.grille.editer', "Ouvre la saisie de la cellule, ou bascule l'exclusion."],
['raccourcis.grille.effacer', 'Efface le refus affiché sous la cellule.'],
// --- Un champ --------------------------------------------------------------------
['raccourcis.champ.valider', 'Valide la saisie.'],
['raccourcis.champ.rendre', "Rend la valeur d'avant la saisie."],
// --- Une commande ----------------------------------------------------------------
['raccourcis.commande.activer', 'Exécute la commande une fois, même maintenue.'],
// --- Un geste, une fenêtre -------------------------------------------------------
['raccourcis.geste.annuler', 'Annule le geste en cours : la table revient où elle était.'],
['raccourcis.fenetre.fermer', "Ferme le panneau ou l'infobulle."],
]);

View file

@ -6,8 +6,11 @@
// d'une table, {table, siege|null} — se déplace par les flèches, selon la
// position des tables sur le plan ; l'entrée descend dans les sièges,
// l'échappement remonte. Aucune fonction ne lit le DOM : Plan.svelte
// applique l'élément actif rendu et l'effet qui l'accompagne.
// applique l'élément actif rendu et l'effet qui l'accompagne. Les touches
// se lisent dans la table des raccourcis, contexte 'plan' : ce module n'en
// nomme aucune.
import { ecranDepuisDessin } from '../../geometrie/vue.js';
import { effetDe } from '../raccourcis.js';
/** Les directions des flèches, sur l'écran (y vers le bas). */
const VECTEURS = new Map([
@ -17,16 +20,17 @@ const VECTEURS = new Map([
['bas', { x: 0, y: 1 }],
]);
/** La direction de chaque flèche, et le sens où elle fait tourner les sièges. */
/**
* Chaque effet de flèche de la table des raccourcis : la direction où il
* mène sur le plan, et le sens où il fait tourner les sièges.
*/
const FLECHES = new Map([
['ArrowRight', { direction: 'droite', sens: 1 }],
['ArrowDown', { direction: 'bas', sens: 1 }],
['ArrowLeft', { direction: 'gauche', sens: -1 }],
['ArrowUp', { direction: 'haut', sens: -1 }],
['droite', { direction: 'droite', sens: 1 }],
['bas', { direction: 'bas', sens: 1 }],
['gauche', { direction: 'gauche', sens: -1 }],
['haut', { direction: 'haut', sens: -1 }],
]);
const ENTREES = new Set(['Enter', ' ']);
/**
* La table voisine dans la direction d'une flèche : la plus proche, par la
* distance des centres, de celles dont le centre est dans le cône de ±45°
@ -89,7 +93,9 @@ export function siegeVoisin(capacite, siege, sens) {
* gauche et haut au précédent ; l'entrée rend l'effet selectionner de la
* place. L'échappement remonte du siège à sa table, puis, sur une table,
* rend l'effet remonter : le choix en attente et la sélection s'oublient.
* Un siège d'une table passée en régime table se lit comme sa table.
* Un siège d'une table passée en régime table se lit comme sa table. Les
* flèches, l'entrée et l'échappement sont les touches des effets droite,
* bas, gauche, haut, entrer et remonter du contexte 'plan' de RACCOURCIS.
*
* @param {{table: number, siege: number|null}|null} actif
* @param {string} cle KeyboardEvent.key
@ -101,9 +107,10 @@ export function siegeVoisin(capacite, siege, sens) {
* null pour une touche que le plan ne prend pas, ou sans table
*/
export function toucheDuPlan(actif, cle, { geometries, regimeDe, capaciteDe }) {
const fleche = FLECHES.get(cle);
const entree = ENTREES.has(cle);
const echap = cle === 'Escape';
const effet = effetDe('plan', cle);
const fleche = effet === null ? undefined : FLECHES.get(effet);
const entree = effet === 'entrer';
const echap = effet === 'remonter';
if (fleche === undefined && !entree && !echap) return null;
if (geometries.length === 0) return null;

View file

@ -46,6 +46,7 @@
import { getContext } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import { cibleRedondance } from '../../application/evaluation.js';
import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
@ -55,6 +56,9 @@
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, surlignee as colonneSurlignee, tableau } from './modele.js';
const t = getContext('t');
// Le champ de la cible lit l'entrée que lit aussi le tableau des réglages
// de la documentation (§ 19.8).
const [entreeCible] = reglagesDeSection(REGLAGES_GENERATION, 'propositions');
const seance = getContext('seance');
const ui = getContext('ui');
@ -180,8 +184,8 @@
{#if charge !== null}
<div class="cible" data-cible-redondance>
<Champ
cle="cibleRedondance"
etiquette="propositions.cible.etiquette"
cle={entreeCible.cle}
etiquette={entreeCible.etiquette}
valeur={cibleRedondance(charge)}
lire={lireEntier}
executer={changerCible}

View file

@ -0,0 +1,93 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La table des raccourcis clavier de l'interface (§ 7.3, § 13.5, § 19.8) :
// la seule source du tableau des raccourcis de la documentation. Le code qui
// répond à une touche la lit par effetDe ; les fichiers que la garde de
// test/sources_interface.test.js admet encore nomment leurs touches eux-mêmes,
// et chacune de ces touches doit avoir son entrée ici, sans quoi la garde la
// relève comme absente du tableau.
//
// Une entrée est { touche, contexte, effet, cle } :
// touche la valeur de KeyboardEvent.key, précédée de « Maj+ » quand la
// majuscule change l'effet (Maj+Tab dans la grille) ;
// contexte là où elle agit — 'plan' (l'élément actif du plan, les listes
// de travail et la réserve), 'grille' (une cellule de la grille
// des participants), 'champ' (une saisie), 'commande' (un
// bouton qui exécute une commande), 'geste' (un geste du
// pointeur en cours), 'fenetre' (un panneau, une infobulle) ;
// effet ce qu'elle y fait, un nom que le code du contexte interprète ;
// cle la clé du libellé qui décrit l'effet, raccourcis.<contexte>.<effet>.
// Deux touches d'un même effet partagent sa clé : Entrée et l'espace sur le
// plan, Entrée et F2 dans la grille.
/** @typedef {'plan'|'grille'|'champ'|'commande'|'geste'|'fenetre'} ContexteRaccourci */
/** Les contextes, dans l'ordre du tableau. */
export const CONTEXTES_RACCOURCIS = Object.freeze(['plan', 'grille', 'champ', 'commande', 'geste', 'fenetre']);
const raccourci = (touche, contexte, effet) => Object.freeze({ touche, contexte, effet, cle: `raccourcis.${contexte}.${effet}` });
/**
* Les raccourcis, par contexte dans l'ordre de CONTEXTES_RACCOURCIS. Sur le
* plan, les flèches mènent à la table voisine, ou au siège suivant (droite,
* bas) et précédent (gauche, haut) ; Entrée et l'espace descendent dans les
* sièges ou choisissent la place ; Échap remonte, et oublie le choix des
* listes (plan/clavier.js). Dans la grille des participants, les flèches
* mènent à la cellule voisine, Tab et Maj+Tab à la suivante et à la
* précédente — une saisie en cours validée d'abord —, Entrée et F2 ouvrent
* la saisie de la cellule ou basculent l'exclusion, Échap efface le refus
* de la cellule. Dans un champ, Entrée valide la saisie et Échap rend la
* valeur d'avant. Sur une commande, Entrée et l'espace l'exécutent une fois,
* maintenues comprises. Échap annule le geste en cours et ferme une
* fenêtre.
*
* @type {ReadonlyArray<Readonly<{touche: string, contexte: ContexteRaccourci, effet: string, cle: string}>>}
*/
export const RACCOURCIS = Object.freeze([
raccourci('ArrowRight', 'plan', 'droite'),
raccourci('ArrowDown', 'plan', 'bas'),
raccourci('ArrowLeft', 'plan', 'gauche'),
raccourci('ArrowUp', 'plan', 'haut'),
raccourci('Enter', 'plan', 'entrer'),
raccourci(' ', 'plan', 'entrer'),
raccourci('Escape', 'plan', 'remonter'),
raccourci('ArrowRight', 'grille', 'droite'),
raccourci('ArrowDown', 'grille', 'bas'),
raccourci('ArrowLeft', 'grille', 'gauche'),
raccourci('ArrowUp', 'grille', 'haut'),
raccourci('Tab', 'grille', 'suivante'),
raccourci('Maj+Tab', 'grille', 'precedente'),
raccourci('Enter', 'grille', 'editer'),
raccourci('F2', 'grille', 'editer'),
raccourci('Escape', 'grille', 'effacer'),
raccourci('Enter', 'champ', 'valider'),
raccourci('Escape', 'champ', 'rendre'),
raccourci('Enter', 'commande', 'activer'),
raccourci(' ', 'commande', 'activer'),
raccourci('Escape', 'geste', 'annuler'),
raccourci('Escape', 'fenetre', 'fermer'),
]);
// L'effet de chaque touche, par contexte.
const EFFETS = new Map(
CONTEXTES_RACCOURCIS.map((contexte) => [
contexte,
new Map(RACCOURCIS.filter((entree) => entree.contexte === contexte).map(({ touche, effet }) => [touche, effet])),
]),
);
/**
* L'effet d'une touche dans un contexte, ou null pour une touche que le
* contexte ne prend pas.
*
* @param {ContexteRaccourci} contexte
* @param {string} touche KeyboardEvent.key
* @returns {string|null}
* @throws {RangeError} pour un contexte hors de CONTEXTES_RACCOURCIS
*/
export function effetDe(contexte, touche) {
const effets = EFFETS.get(contexte);
if (effets === undefined) throw new RangeError(`contexte de raccourci inconnu : ${JSON.stringify(contexte)}`);
return effets.get(touche) ?? null;
}

View file

@ -0,0 +1,119 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la table des raccourcis clavier (§ 7.3, § 13.5, § 19.8) : sa
// forme, figée ; une touche par contexte au plus une fois ; les contextes et
// les effets que le plan, la grille des participants, un champ, une
// commande, un geste et une fenêtre lisent ; et effetDe, qui rend l'effet
// d'une touche dans un contexte ; le libellé de chaque clé, que le tableau
// engendré lit par la traduction.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
import { TABLES_INTERFACE } from './libelles.js';
import { CONTEXTES_RACCOURCIS, RACCOURCIS, effetDe } from './raccourcis.js';
const { t, cles: clesTraduites } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
describe('RACCOURCIS : la table, seule source des touches (§ 19.8)', () => {
test('chaque entrée porte touche, contexte, effet et cle, dans cet ordre, et la table comme chaque entrée sont figées', () => {
assert.ok(RACCOURCIS.length > 0, 'table vide');
assert.ok(Object.isFrozen(RACCOURCIS));
for (const entree of RACCOURCIS) {
assert.deepEqual(Object.keys(entree), ['touche', 'contexte', 'effet', 'cle']);
assert.ok(Object.isFrozen(entree), JSON.stringify(entree));
assert.equal(typeof entree.touche, 'string');
assert.ok(entree.touche.length > 0);
assert.match(entree.effet, /^[a-z][A-Za-z]*$/);
}
assert.ok(Object.isFrozen(CONTEXTES_RACCOURCIS));
assert.throws(() => RACCOURCIS.push({}), TypeError);
});
test('les six contextes, dans l’ordre du tableau ; chaque entrée en porte un, et chacun a ses entrées, groupées', () => {
assert.deepEqual(CONTEXTES_RACCOURCIS, ['plan', 'grille', 'champ', 'commande', 'geste', 'fenetre']);
const ordre = RACCOURCIS.map(({ contexte }) => CONTEXTES_RACCOURCIS.indexOf(contexte));
assert.ok(!ordre.includes(-1), 'contexte hors de la liste');
assert.deepEqual(ordre, [...ordre].sort((a, b) => a - b));
assert.deepEqual([...new Set(ordre)], [0, 1, 2, 3, 4, 5]);
});
test('une touche au plus une fois par contexte : son effet n’est jamais ambigu', () => {
const paires = RACCOURCIS.map(({ contexte, touche }) => `${contexte}:${touche}`);
assert.equal(new Set(paires).size, paires.length);
});
test('la clé du libellé est raccourcis.<contexte>.<effet> : deux touches d’un même effet la partagent', () => {
for (const { contexte, effet, cle } of RACCOURCIS) assert.equal(cle, `raccourcis.${contexte}.${effet}`);
const partagees = (contexte, effet) => RACCOURCIS.filter((entree) => entree.contexte === contexte && entree.effet === effet).map(({ touche }) => touche);
assert.deepEqual(partagees('plan', 'entrer'), ['Enter', ' ']);
assert.deepEqual(partagees('grille', 'editer'), ['Enter', 'F2']);
assert.deepEqual(partagees('commande', 'activer'), ['Enter', ' ']);
});
test('chaque clé a son libellé dans les tables de l’application et de l’interface', () => {
const cles = [...new Set(RACCOURCIS.map(({ cle }) => cle))];
const traduites = new Set(clesTraduites());
assert.deepEqual(cles.filter((cle) => !traduites.has(cle)), []);
for (const cle of cles) assert.ok(t(cle).length > 0, cle);
});
test('les raccourcis du plan, de la grille, d’un champ, d’une commande, d’un geste et d’une fenêtre (§ 7.3)', () => {
assert.deepEqual(
RACCOURCIS.map(({ touche, contexte, effet }) => [contexte, touche, effet]),
[
['plan', 'ArrowRight', 'droite'],
['plan', 'ArrowDown', 'bas'],
['plan', 'ArrowLeft', 'gauche'],
['plan', 'ArrowUp', 'haut'],
['plan', 'Enter', 'entrer'],
['plan', ' ', 'entrer'],
['plan', 'Escape', 'remonter'],
['grille', 'ArrowRight', 'droite'],
['grille', 'ArrowDown', 'bas'],
['grille', 'ArrowLeft', 'gauche'],
['grille', 'ArrowUp', 'haut'],
['grille', 'Tab', 'suivante'],
['grille', 'Maj+Tab', 'precedente'],
['grille', 'Enter', 'editer'],
['grille', 'F2', 'editer'],
['grille', 'Escape', 'effacer'],
['champ', 'Enter', 'valider'],
['champ', 'Escape', 'rendre'],
['commande', 'Enter', 'activer'],
['commande', ' ', 'activer'],
['geste', 'Escape', 'annuler'],
['fenetre', 'Escape', 'fermer'],
],
);
});
});
describe('effetDe : l’effet d’une touche dans un contexte', () => {
test('chaque entrée se retrouve par son contexte et sa touche', () => {
for (const { contexte, touche, effet } of RACCOURCIS) assert.equal(effetDe(contexte, touche), effet);
});
test('une même touche agit selon son contexte : Échap remonte, efface, rend, annule ou ferme ; une commande ne la prend pas', () => {
assert.deepEqual(
CONTEXTES_RACCOURCIS.map((contexte) => effetDe(contexte, 'Escape')),
['remonter', 'effacer', 'rendre', null, 'annuler', 'fermer'],
);
});
test('une touche que le contexte ne prend pas : null, majuscule et code physique compris', () => {
assert.equal(effetDe('champ', ' '), null);
assert.equal(effetDe('plan', 'a'), null);
assert.equal(effetDe('plan', 'escape'), null);
assert.equal(effetDe('plan', 'Space'), null);
assert.equal(effetDe('geste', 'Enter'), null);
assert.equal(effetDe('grille', 'Shift+Tab'), null);
assert.equal(effetDe('plan', 'Tab'), null);
});
test('un contexte inconnu lève, et nomme le contexte', () => {
assert.throws(() => effetDe('tiroir', 'Escape'), { name: 'RangeError', message: /"tiroir"/ });
assert.throws(() => effetDe('toString', 'Escape'), RangeError);
});
});

View file

@ -8,12 +8,13 @@
// la règle porte sur ce que le code fait, et un commentaire qui nomme un
// interdit le documente.
//
// Huit gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
// Neuf gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
// t qui n'existent pas, le contenu des tables de traduction (§ 5.5, § 7.6,
// § 7.7, § 12.8, § 12.10.8), les couleurs (§ 12.7, § 13.3, § 13.5), le
// balisage et les fenêtres du navigateur (§ 13.1, § 2.1), le texte comprimé et
// les animations (§ 7.4, § 19.2), le câblage des composants (§ 12.9, § 13.3),
// les sélecteurs que le compilateur élague (§ 13.3). Chaque garde rend un
// les sélecteurs que le compilateur élague (§ 13.3), les touches nommées hors
// de la table des raccourcis (§ 7.3, § 19.8). Chaque garde rend un
// relevé « chemin:ligne motif « extrait » », trié, qui nomme d'un coup chaque
// fichier et chaque ligne fautifs ; elle lève sur un balayage vide (§ 14.2).
//
@ -32,6 +33,7 @@ import { TABLES_APPLICATION } from '../src/application/libelles.js';
import { creerTraduction } from '../src/application/traduction.js';
import { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { JETONS_CONFLIT } from '../src/interface/marques.js';
import { CONTEXTES_RACCOURCIS, RACCOURCIS } from '../src/interface/raccourcis.js';
import { DESIGNATIONS } from './navigateur/designations.js';
import { describe, test } from './lanceur.js';
@ -1053,6 +1055,154 @@ function releverDesignations(racine, designations = DESIGNATIONS) {
return releves;
}
// --- Garde 9 : les touches nommées (§ 7.3, § 19.8) ----------------------------
// La table des raccourcis, seul module de l'interface qui nomme une touche :
// le tableau engendré des raccourcis la lit, et une touche nommée ailleurs
// lui échappe.
const TABLE_RACCOURCIS = 'src/interface/raccourcis.js';
// Une valeur de KeyboardEvent.key qui nomme une touche sans caractère, seule
// ou précédée de modificateurs, comme « Maj+Tab » ; les noms anciens « Esc »
// et « Spacebar » comptent.
const TOUCHE_NOMMEE =
/^(?:(?:Maj|Shift|Ctrl|Control|Alt|Meta)\+)*(?:Arrow(?:Left|Right|Up|Down)|Escape|Esc|Enter|Tab|Home|End|PageUp|PageDown|Delete|Backspace|Insert|Spacebar|F(?:[1-9]|1\d|2[0-4]))$/;
// Vrai pour la lecture d'une touche : un membre key, comme evenement.key.
const litLaTouche = (noeud) => noeud?.type === 'MemberExpression' && nomDeMembre(noeud) === 'key';
// La valeur d'une chaîne littérale, ou d'un gabarit sans expression ; null
// pour tout autre nœud.
function valeurDeChaine(noeud) {
if (noeud?.type === 'Literal' && typeof noeud.value === 'string') return noeud.value;
if (noeud?.type === 'TemplateLiteral' && noeud.expressions.length === 0) return noeud.quasis[0].value.cooked ?? null;
return null;
}
// Les fichiers de src/interface qui nomment encore leurs touches, chacun avec
// les contextes de RACCOURCIS où il agit et le relevé de ses touches, ligne
// omise, trié : la garde les admet tels quels. Un écart — une touche de
// plus, une de moins — relève les touches du fichier et demande de revoir
// son entrée ; une entrée dont le fichier ne nomme plus rien est sans objet.
// Faire lire RACCOURCIS à l'un d'eux retire son entrée. Tant qu'il ne la lit
// pas, chacune de ses touches a son entrée dans RACCOURCIS sous l'un de ses
// contextes : le tableau engendré décrit ce que le fichier fait, et une
// entrée retirée ou renommée dans la table se relève ici.
const enAttente = (contextes, touches) => Object.freeze({ contextes: Object.freeze(contextes), touches: Object.freeze(touches) });
const TOUCHES_EN_ATTENTE = new Map([
['src/interface/participants/Formulaire.svelte', enAttente(['champ'], ['touche "Enter"'])],
[
'src/interface/participants/Grille.svelte',
enAttente(
['grille', 'champ'],
[
'touche "ArrowDown"',
'touche "ArrowLeft"',
'touche "ArrowRight"',
'touche "ArrowUp"',
'touche "Enter"',
'touche "Enter"',
'touche "Escape"',
'touche "Escape"',
'touche "F2"',
'touche "Maj+Tab"',
'touche "Maj+Tab"',
'touche "Maj+Tab"',
'touche "Tab"',
'touche "Tab"',
'touche "Tab"',
'touche "Tab"',
'touche "Tab"',
],
),
],
[
'src/interface/participants/grille.js',
enAttente(
['grille'],
['touche "ArrowDown"', 'touche "ArrowLeft"', 'touche "ArrowRight"', 'touche "ArrowUp"', 'touche "Maj+Tab"', 'touche "Tab"'],
),
],
['src/interface/plan/ListesTravail.svelte', enAttente(['plan'], ['touche "Escape"'])],
['src/interface/plan/PanneauPlace.svelte', enAttente(['fenetre'], ['touche "Escape"'])],
['src/interface/plan/PanneauTable.svelte', enAttente(['fenetre'], ['touche "Escape"'])],
['src/interface/plan/Plan.svelte', enAttente(['plan', 'geste'], ['touche "Escape"', 'touche "Escape"'])],
['src/interface/plan/Reserve.svelte', enAttente(['plan'], ['touche "Escape"'])],
['src/interface/primitives/Champ.svelte', enAttente(['champ'], ['touche "Enter"', 'touche "Escape"'])],
['src/interface/primitives/Commande.svelte', enAttente(['commande'], ['touche " "', 'touche "Enter"'])],
['src/interface/primitives/Infobulle.svelte', enAttente(['fenetre'], ['touche "Escape"'])],
['src/interface/tables/ListeTables.svelte', enAttente(['champ'], ['touche "Escape"'])],
]);
// Les touches des fichiers en attente qu'aucune entrée de raccourcis ne
// décrit sous un des contextes du fichier : « chemin touche "…" hors de
// RACCOURCIS (contextes) », trié. Un contexte qui n'est pas dans
// contextesConnus se relève aussi.
function touchesSansEntree(raccourcis, contextesConnus, enAttenteDe = TOUCHES_EN_ATTENTE) {
const decrites = new Set(raccourcis.map(({ contexte, touche }) => `${contexte}:${touche}`));
const releves = [];
for (const [chemin, { contextes, touches }] of enAttenteDe) {
for (const contexte of contextes) {
if (!contextesConnus.includes(contexte)) releves.push(`${chemin} contexte ${JSON.stringify(contexte)} inconnu`);
}
for (const motif of new Set(touches)) {
const touche = JSON.parse(motif.slice('touche '.length));
if (!contextes.some((contexte) => decrites.has(`${contexte}:${touche}`))) {
releves.push(`${chemin} ${motif} hors de RACCOURCIS (${contextes.join(', ')})`);
}
}
}
return releves.sort();
}
// Relevé des touches nommées dans les composants et les modules de
// src/interface, hors de la table des raccourcis : une chaîne qui nomme une
// touche (TOUCHE_NOMMEE), où qu'elle soit dans le code, et toute chaîne —
// l'espace, une lettre — comparée à une lecture de key ou cas d'un switch
// sur elle. Une touche se relève une fois, sous la forme « touche "…" ».
// Puis les fichiers en attente : admis quand leur relevé est le leur.
function releverTouches(racine, enAttenteDe = TOUCHES_EN_ATTENTE) {
const chemins = exigerNonVide(
sources(racine, ['src/interface'], ['.svelte', '.js']).filter((chemin) => chemin !== TABLE_RACCOURCIS),
'touches nommées',
);
const parFichier = new Map();
for (const chemin of chemins) {
const composant = chemin.endsWith('.svelte');
const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin);
const releves = [];
const vus = new Set();
const relever = (noeud) => {
const valeur = valeurDeChaine(noeud);
if (valeur === null || vus.has(noeud)) return;
vus.add(noeud);
releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `touche ${JSON.stringify(valeur)}` });
};
parcourir(composant ? partiesCode(ast) : [ast], (noeud) => {
if (noeud.type === 'BinaryExpression' && ['===', '!==', '==', '!='].includes(noeud.operator)) {
if (litLaTouche(noeud.left)) relever(noeud.right);
if (litLaTouche(noeud.right)) relever(noeud.left);
} else if (noeud.type === 'SwitchStatement' && litLaTouche(noeud.discriminant)) {
for (const cas of noeud.cases) relever(cas.test);
} else if (TOUCHE_NOMMEE.test(valeurDeChaine(noeud) ?? '')) {
relever(noeud);
}
});
if (releves.length > 0) parFichier.set(chemin, releves);
}
const releves = [];
const aRevoir = [];
for (const [chemin, deFichier] of parFichier) {
const admis = enAttenteDe.get(chemin)?.touches;
const motifs = deFichier.map(({ texte }) => texte).sort();
if (admis !== undefined && JSON.stringify(motifs) === JSON.stringify([...admis].sort())) continue;
releves.push(...deFichier);
if (admis !== undefined) aRevoir.push(`${chemin} : en attente, entrée à revoir`);
}
const sansObjet = [...enAttenteDe.keys()].filter((chemin) => !parFichier.has(chemin)).map((chemin) => `${chemin} : en attente sans objet`);
return [...trier(releves), ...[...aRevoir, ...sansObjet].sort()];
}
// --- Arbres d'épreuve ---------------------------------------------------------
// Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le
@ -1690,3 +1840,98 @@ describe('sources de l’interface : les désignations stables (contrainte 16)',
assert.ok(Object.isFrozen(DESIGNATIONS.get('data-ecran').valeurs));
});
});
describe('sources de l’interface : les touches nommées (§ 7.3, § 19.8)', () => {
test('aucune touche nommée sous src/interface hors de raccourcis.js, sauf les fichiers en attente, tels quels', () => {
exigerVide(releverTouches(RACINE), 'touches nommées');
});
test('la garde relève une touche nommée, et toute chaîne comparée à key ou cas d’un switch sur elle, à toute profondeur, et pas leurs voisines', () => {
const fichiers = {
'src/interface/plan/sous/b.js': [
'export function f(evenement, touche) {',
" if (evenement.key === 'Escape') return 1;",
" if (' ' === evenement.key || evenement['key'] !== 'a') return 2;",
" switch (evenement.key) { case 'q': return 3; default: }",
" const m = new Map([['ArrowLeft', 1], [`Maj+Tab`, 2]]);",
" // evenement.key === 'Enter' dans un commentaire",
" return [m, touche === 'F2', 'Entrée', 'Tableau', 'Enter ', ' ', evenement.code === 'KeyA', 'F25', touche === ' '];",
'}',
'',
].join('\n'),
'src/interface/A.svelte': composant({
script: " const surTouche = (e) => { if (e.key === 'Enter') e.preventDefault(); };",
gabarit: "<input onkeydown={(e) => e.key === `Tab` && surTouche(e)} />\n<p>{' '}</p>",
}),
'src/interface/raccourcis.js': "export const R = ['Escape', 'Enter'];\nexport const lire = (e) => e.key === ' ';\n",
'src/interface/A.test.js': "if (e.key === 'Escape') f();\n",
'src/application/c.js': "export const k = (e) => e.key === 'Escape';\n",
};
assert.deepEqual(avecArbre(fichiers, (racine) => releverTouches(racine, new Map())), [
'src/interface/A.svelte:2 touche "Enter"',
'src/interface/A.svelte:4 touche "Tab"',
'src/interface/plan/sous/b.js:2 touche "Escape"',
'src/interface/plan/sous/b.js:3 touche " "',
'src/interface/plan/sous/b.js:3 touche "a"',
'src/interface/plan/sous/b.js:4 touche "q"',
'src/interface/plan/sous/b.js:5 touche "ArrowLeft"',
'src/interface/plan/sous/b.js:5 touche "Maj+Tab"',
'src/interface/plan/sous/b.js:7 touche "F2"',
]);
});
test('un fichier en attente n’est admis qu’avec son relevé exact ; un écart relève ses touches, une entrée que rien ne sert est sans objet', () => {
const fichiers = {
'src/interface/x.js': "export const f = (e) => e.key === 'Escape' || e.key === 'Enter';\n",
'src/interface/y.js': "export const g = (e) => e.key === 'Escape';\n",
'src/interface/z.js': 'export const h = 1;\n',
};
const attente = new Map([
['src/interface/x.js', enAttente(['champ'], ['touche "Escape"', 'touche "Enter"'])],
['src/interface/y.js', enAttente(['champ'], ['touche "Escape"', 'touche "Tab"'])],
['src/interface/z.js', enAttente(['champ'], ['touche "Escape"'])],
['src/interface/w.js', enAttente(['champ'], ['touche "Escape"'])],
]);
assert.deepEqual(avecArbre(fichiers, (racine) => releverTouches(racine, attente)), [
'src/interface/y.js:1 touche "Escape"',
'src/interface/w.js : en attente sans objet',
'src/interface/y.js : en attente, entrée à revoir',
'src/interface/z.js : en attente sans objet',
]);
});
test('chaque touche d’un fichier en attente a son entrée dans RACCOURCIS sous un des contextes du fichier : le tableau engendré décrit ce qu’il fait', () => {
assert.ok(TOUCHES_EN_ATTENTE.size > 0 || RACCOURCIS.length > 0);
exigerVide(touchesSansEntree(RACCOURCIS, CONTEXTES_RACCOURCIS), 'touches en attente sans entrée');
});
test('une touche en attente que la table ne décrit plus sous les contextes du fichier se relève, comme un contexte inconnu', () => {
const raccourcis = [
{ touche: 'Escape', contexte: 'fenetre', effet: 'fermer' },
{ touche: 'Enter', contexte: 'champ', effet: 'valider' },
{ touche: 'q', contexte: 'geste', effet: 'annuler' },
];
const attente = new Map([
['src/interface/a.svelte', enAttente(['fenetre'], ['touche "Escape"', 'touche "Escape"'])],
['src/interface/b.svelte', enAttente(['geste', 'champ'], ['touche "Enter"', 'touche "Escape"'])],
['src/interface/c.svelte', enAttente(['fenetre', 'tiroir'], ['touche "Escape"'])],
]);
assert.deepEqual(touchesSansEntree(raccourcis, ['champ', 'geste', 'fenetre'], attente), [
'src/interface/b.svelte touche "Escape" hors de RACCOURCIS (geste, champ)',
'src/interface/c.svelte contexte "tiroir" inconnu',
]);
// La table où Échap de la fenêtre devient « q » : le fichier qui ferme sur Échap se relève.
const deplacee = raccourcis.map((entree) => (entree.contexte === 'fenetre' ? { ...entree, touche: 'q' } : entree));
assert.deepEqual(touchesSansEntree(deplacee, ['champ', 'geste', 'fenetre'], new Map([...attente].slice(0, 1))), [
'src/interface/a.svelte touche "Escape" hors de RACCOURCIS (fenetre)',
]);
});
test('un arbre sans source d’interface, ou réduit à la table des raccourcis, fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTouches), /touches nommées : aucun fichier examiné/);
assert.throws(
() => avecArbre({ 'src/interface/raccourcis.js': "export const R = ['Escape'];\n" }, releverTouches),
/touches nommées : aucun fichier examiné/,
);
});
});