diff --git a/src/csv/colonnes.js b/src/csv/colonnes.js index c2ae692..463ea86 100644 --- a/src/csv/colonnes.js +++ b/src/csv/colonnes.js @@ -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]>} + */ +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. diff --git a/src/csv/colonnes.test.js b/src/csv/colonnes.test.js index a129ddd..dc1b932 100644 --- a/src/csv/colonnes.test.js +++ b/src/csv/colonnes.test.js @@ -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'); diff --git a/src/interface/evenement/ListeSurVoisine.navigateur.test.js b/src/interface/evenement/ListeSurVoisine.navigateur.test.js new file mode 100644 index 0000000..baad73c --- /dev/null +++ b/src/interface/evenement/ListeSurVoisine.navigateur.test.js @@ -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); + }); +}); diff --git a/src/interface/evenement/modele.js b/src/interface/evenement/modele.js index 4f85e90..231f3fa 100644 --- a/src/interface/evenement/modele.js +++ b/src/interface/evenement/modele.js @@ -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 diff --git a/src/interface/evenement/modele.test.js b/src/interface/evenement/modele.test.js index 6cfc4c3..1dd3b00 100644 --- a/src/interface/evenement/modele.test.js +++ b/src/interface/evenement/modele.test.js @@ -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); diff --git a/src/interface/generation/Reglages.navigateur.test.js b/src/interface/generation/Reglages.navigateur.test.js new file mode 100644 index 0000000..7a1a197 --- /dev/null +++ b/src/interface/generation/Reglages.navigateur.test.js @@ -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']); + }); +}); diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte index bbaa6fc..35ab3ee 100644 --- a/src/interface/generation/Reglages.svelte +++ b/src/interface/generation/Reglages.svelte @@ -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); } } @@ -152,26 +159,27 @@ {/if} - {#each REGLAGES as nom (nom)} + {#each champs as entree (entree.cle)} {/each}
{t('generation.contraintes.titre')} - {#each BASCULES as { cle, etiquette } (cle)} + {#each bascules as entree (entree.cle)} + {@const { cle, etiquette } = entree}
basculer(evenement, cle)} + checked={valeurDuReglage(charge.reglages, entree)} + onclick={(evenement) => basculer(evenement, entree)} /> {#if refusBascule !== null && refusBascule.cle === cle} diff --git a/src/interface/generation/reglages.js b/src/interface/generation/reglages.js new file mode 100644 index 0000000..0d7b4f8 --- /dev/null +++ b/src/interface/generation/reglages.js @@ -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. pour un champ de la génération, 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} + */ +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} 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} 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; +} diff --git a/src/interface/generation/reglages.test.js b/src/interface/generation/reglages.test.js new file mode 100644 index 0000000..498257e --- /dev/null +++ b/src/interface/generation/reglages.test.js @@ -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 : +// pour une valeur à la racine des réglages, . 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., libellé generation.reglage., une unité ; une bascule : clé , libellé generation.contrainte., 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'), []); + }); +}); diff --git a/src/interface/libelles.js b/src/interface/libelles.js index 8130de9..1e161f5 100644 --- a/src/interface/libelles.js +++ b/src/interface/libelles.js @@ -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, ]); diff --git a/src/interface/libelles.test.js b/src/interface/libelles.test.js index 06ea21b..c5670ae 100644 --- a/src/interface/libelles.test.js +++ b/src/interface/libelles.test.js @@ -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); diff --git a/src/interface/libelles/raccourcis.js b/src/interface/libelles/raccourcis.js new file mode 100644 index 0000000..7a49e5c --- /dev/null +++ b/src/interface/libelles/raccourcis.js @@ -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... 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."], +]); diff --git a/src/interface/plan/clavier.js b/src/interface/plan/clavier.js index c3ba00a..fcc017f 100644 --- a/src/interface/plan/clavier.js +++ b/src/interface/plan/clavier.js @@ -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; diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index bf6e369..6b349b2 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -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}
.. +// 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>} + */ +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; +} diff --git a/src/interface/raccourcis.test.js b/src/interface/raccourcis.test.js new file mode 100644 index 0000000..029befe --- /dev/null +++ b/src/interface/raccourcis.test.js @@ -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.. : 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); + }); +}); diff --git a/test/sources_interface.test.js b/test/sources_interface.test.js index c8a7237..24add4f 100644 --- a/test/sources_interface.test.js +++ b/test/sources_interface.test.js @@ -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: " e.key === `Tab` && surTouche(e)} />\n

{' '}

", + }), + '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é/, + ); + }); +});