[FIX] interface: ghost labels, keyboard, valid HTML, closed data-* list

The drag ghost names what releasing at the point changes, floor included.
Escape on a seat returns to its table, a refused Tab stays put, the legend
toggle exposes aria-expanded, and the header markup is valid HTML. Every
data-* attribute the interface sets belongs to one closed list that a guard
reads. The tree guards follow helpers a test imports, and the reporter says
the whole series stays watched under coverage on a small machine.
Checked: every new test red first; 3001 node, 635 browser and 63 long tests
green from the index alone.

--- FR ---

[FIX] interface : fantôme nommé, clavier, HTML valide, data-* fermés

Le fantôme du glisser nomme ce que relâcher au point modifie, sol compris.
Échap sur un siège remonte à sa table, un Tab refusé reste en place, la
bascule de la légende expose aria-expanded, et la tête est du HTML valide.
Chaque attribut data-* que l'interface pose appartient à une liste fermée
qu'une garde lit. Les gardes de l'arbre suivent les aides qu'une épreuve
importe, et le rapporteur dit la série entière surveillée sous couverture.
Vérifié : chaque épreuve rouge d'abord ; 3001 node, 635 navigateur et 63
longues vertes depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 04:01:57 -04:00
parent d5a1560915
commit 3028fca2ed
61 changed files with 1936 additions and 492 deletions

View file

@ -128,6 +128,31 @@ describe('essai de démarrage : dossiers et environnement (§ 8.6)', () => {
assert.deepEqual(donneesApplicativesSurExe({ TMPDIR: '/' }, {}, 'linux'), []);
});
test('sous macOS, Application Support de la coquille et du poste ; sous Windows, TMP l’emporte sur TEMP', () => {
// macOS : un essai rangé sous le dossier Application Support du poste,
// que le HOME de la coquille ne déplace pas, puis sous celui de la
// coquille.
const P = path.posix;
const maison = P.join('/', 'maison-essai');
const support = P.join(maison, 'Library', 'Application Support');
const dossiers = dossiersDEssai(P.join(support, 'gtt-essai-demarrage-x'), P);
const env = environnementDEssai({ HOME: maison }, dossiers, P);
assert.deepEqual(donneesApplicativesSurExe(env, {}, 'darwin'), []);
assert.deepEqual(donneesApplicativesSurExe(env, { HOME: maison }, 'darwin'), [support]);
assert.deepEqual(donneesApplicativesSurExe({ ...env, HOME: maison }, {}, 'darwin'), [support]);
// Sous Linux, le même poste ne range rien sous Application Support.
assert.deepEqual(donneesApplicativesSurExe(env, { HOME: maison }, 'linux'), []);
// Windows : le temporaire est TMP, puis TEMP à défaut.
const W = path.win32;
const racine = W.join('D:\\', 'essais', 'gtt-essai-demarrage-x');
const envW = environnementDEssai({}, dossiersDEssai(racine, W), W);
const dessus = W.join('D:\\', 'essais');
const ailleurs = W.join('E:\\', 'temporaire');
assert.deepEqual(donneesApplicativesSurExe({ ...envW, TMP: dessus, TEMP: ailleurs }, {}, 'win32'), [dessus]);
assert.deepEqual(donneesApplicativesSurExe({ ...envW, TMP: ailleurs, TEMP: dessus }, {}, 'win32'), []);
assert.deepEqual(donneesApplicativesSurExe({ ...envW, TMP: '', TEMP: dessus }, {}, 'win32'), [dessus]);
});
test('la liste des données applicatives est celle que decrireSysteme compose : un emplacement qu’elle ajoute est jugé, et un app.getPath que l’essai ne sait pas émuler lève', () => {
const P = path.posix;
const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x');

View file

@ -247,6 +247,29 @@ describe('exécuteur du travailleur : le groupe (§ 5.7, § 5.10)', () => {
execution.annuler();
});
test('l’avancement ne décroît jamais : un avancement plus petit, ou égal, que le dernier de sa proposition ne publie rien', () => {
const { fabrique, travailleurs } = fabriqueFactice();
const reglages = { ...REGLAGES, nombre: 2, arret: 100 };
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, reglages, { coeurs: 2 });
execution.resultat.catch(() => undefined);
const recus = [];
execution.surAvancement((fait, total) => recus.push([fait, total]));
const [premier, second] = travailleurs;
premier.poster({ type: 'avancement', fait: 60, total: 100 });
premier.poster({ type: 'avancement', fait: 20, total: 100 });
second.poster({ type: 'avancement', fait: 50, total: 100 });
second.poster({ type: 'avancement', fait: 50, total: 100 });
premier.poster({ type: 'proposition', proposition: propositionDe(1) });
second.poster({ type: 'avancement', fait: 40, total: 100 });
assert.deepEqual(recus, [
[60, 200],
[110, 200],
[150, 200],
]);
for (let i = 1; i < recus.length; i += 1) assert.ok(recus[i][0] >= recus[i - 1][0], `avancement ${i}`);
execution.annuler();
});
test('un abonné retiré ne reçoit plus rien', () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);

View file

@ -21,6 +21,7 @@
// Chaque texte décrit l'événement demo:<clé>, à la révision 1, en brouillon,
// sans proposition.
import { serialiser } from '../stockage/canonique.js';
import { CATALOGUE } from './catalogue.js';
import grande from './livrees/grande.gtt.json' with { type: 'json' };
import grandeSansException from './livrees/grande-sans-exception.gtt.json' with { type: 'json' };
import petite from './livrees/petite.gtt.json' with { type: 'json' };
@ -55,3 +56,16 @@ export function texteLivre(cle) {
if (livree === undefined) throw new RangeError(`aucune démonstration livrée de clé ${JSON.stringify(cle)}`);
return livree.texte;
}
/**
* Les démonstrations qu'App offre (§ 15) : chaque démonstration livrée, dans
* l'ordre de LIVREES, nommée par l'entrée de même clé du catalogue — { cle,
* nom, texte }, un tableau neuf à chaque appel. src/main.js et le montage
* des épreuves du navigateur les passent tous deux à App : la composition
* vit ici seule.
*
* @returns {Array<{cle: string, nom: string, texte: string}>}
*/
export function demonstrationsLivrees() {
return LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((entree) => entree.cle === cle).nom, texte }));
}

View file

@ -17,7 +17,8 @@
// Refus, qui pose son texte et son remède en Commande — écrire ailleurs
// sous un support rompu. Ce refus se pose sous les commandes, hors du
// flux : le bandeau garde sa hauteur, et l'écran de l'événement, sa place
// fixe comprise, ne bouge pas (§ 8.4).
// fixe comprise, ne bouge pas (§ 8.4). Le refus est un bloc : les
// commandes qui le portent sont un <div>, non un <span>.
import { getContext } from 'svelte';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
import Refus from './primitives/Refus.svelte';
@ -41,7 +42,7 @@
{#if cleAnnonce !== null}
<span class="annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
{/if}
<span class="commandes">
<div class="commandes">
{#if etat.evenement !== null}
<button type="button" data-commande="liste" onclick={revenirALaListe}>{t('coquille.liste')}</button>
{#if refusListe !== null}
@ -62,7 +63,7 @@
{/each}
</span>
<button type="button" data-commande="apropos" onclick={ouvrirApropos}>{t('coquille.apropos.titre')}</button>
</span>
</div>
</header>
<style>

View file

@ -1019,6 +1019,60 @@ describe('un geste en cours ne se relance pas (§ 8.7, § 15)', () => {
expect(lignes(m).map((ligne) => colonne(ligne, 'nom'))).toEqual([`${nom} 1`]);
});
test('une autre démonstration cliquée pendant un chargement : refusée et dite, rien de plus chargé ; l’explication part avec le chargement', async () => {
const m = await monter();
const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom;
const autre = CATALOGUE.find(({ cle }) => cle !== 'petite').cle;
const liberer = retenirEcritures(m);
const bouton = dans(m, '[data-demonstration="petite"]');
const second = dans(m, `[data-demonstration="${autre}"]`);
await cliquer(bouton);
// Toutes les démonstrations se disent indisponibles ; seule la chargée
// est occupée.
expect(second.getAttribute('aria-disabled')).toBe('true');
expect(second.hasAttribute('aria-busy')).toBe(false);
expect(dans(m, '[data-attente-demonstration]')).toBeNull();
await cliquer(second);
const attente = await vi.waitFor(() => {
const lue = dans(m, '[data-attente-demonstration]');
expect(lue).not.toBeNull();
return lue;
});
expect(attente.getAttribute('role')).toBe('status');
expect(attente.textContent).toBe(t('accueil.demonstrations.enCours', { nom }));
expect(second.getAttribute('aria-describedby')).toBe(attente.id);
liberer();
await attendreEvenement(m, (e) => e.nom === `${nom} 1`);
await revenirALaListe(m, 1);
expect(lignes(m).map((ligne) => colonne(ligne, 'nom'))).toEqual([`${nom} 1`]);
expect(dans(m, '[data-attente-demonstration]')).toBeNull();
expect(dans(m, `[data-demonstration="${autre}"]`).hasAttribute('aria-disabled')).toBe(false);
});
test('un chargement refusé, l’accueil resté monté : l’explication de l’autre démonstration part avec le refus, et plus rien n’est indisponible', async () => {
const m = await monter();
const autre = CATALOGUE.find(({ cle }) => cle !== 'petite').cle;
const liberer = retenirEcritures(m);
await cliquer(dans(m, '[data-demonstration="petite"]'));
await cliquer(dans(m, `[data-demonstration="${autre}"]`));
await vi.waitFor(() => expect(dans(m, '[data-attente-demonstration]')).not.toBeNull());
// L'écriture retenue échoue une fois libérée : le chargement est refusé.
m.fs.pannes.ecriture('documents');
liberer();
const refus = await vi.waitFor(() => {
const lu = dans(m, '[data-demonstration="petite"]').parentElement.querySelector('[data-refus]');
expect(lu).not.toBeNull();
return lu;
});
expect(refus.isConnected).toBe(true);
expect(dans(m, '[data-attente-demonstration]')).toBeNull();
expect(dans(m, `[data-demonstration="${autre}"]`).hasAttribute('aria-describedby')).toBe(false);
for (const bouton of m.racine.querySelectorAll('[data-demonstration]')) {
expect(bouton.hasAttribute('aria-disabled'), bouton.dataset.demonstration).toBe(false);
expect(bouton.hasAttribute('aria-busy'), bouton.dataset.demonstration).toBe(false);
}
});
test('copier : une seconde validation pendant l’écriture ne fait pas de seconde copie', async () => {
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
const ligne = ligneDe(m, 'bal');
@ -1444,6 +1498,26 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
expect(ecritures(m)).toBe(avant);
});
test('démontée avant le premier pas, l’application arrête le flux des résumés : aucun état ne se relit ensuite', async () => {
const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') };
const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) });
expect(signes(m).length).toBe(8);
const relus = [];
const lireTexte = m.fs.lireTexte;
m.fs.lireTexte = (racine, chemin) => {
if (chemin.endsWith(SUFFIXES.etat)) relus.push(chemin);
return lireTexte.call(m.fs, racine, chemin);
};
m.demonter();
montes = montes.filter((monte) => monte !== m);
for (let pas = 0; pas < 4; pas += 1) {
await uneImage();
m.horloge.avancer(0);
}
await uneImage();
expect(relus).toEqual([]);
});
test('un état qui ne se relit pas pendant le chargement : « — » dans ses quatre valeurs, et l’autre ligne résolue', async () => {
const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') };
const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) });

View file

@ -12,8 +12,11 @@
// refus : { cle, refus } du dernier échec, ou null.
//
// Un chargement en cours ne se relance pas : tant que charger n'a pas
// rendu la main, un clic sur une démonstration ne fait rien, et celle qui
// se charge porte aria-busy et aria-disabled.
// rendu la main, chaque démonstration porte aria-disabled, celle qui se
// charge aussi aria-busy, et un clic ne charge rien. Un clic sur une autre
// se dit : sous elle, un message [data-attente-demonstration] nomme celle
// qui se charge, et le bouton le désigne par aria-describedby. Le message
// part avec le chargement.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
@ -22,16 +25,24 @@
const t = getContext('t');
const uid = $props.id();
// La clé de la démonstration qui se charge, ou null.
// La clé de la démonstration qui se charge, ou null ; celle d'une autre
// cliquée pendant ce temps, ou null.
let enCours = $state(null);
let attente = $state(null);
const nomDe = (cle) => demonstrations.find((demonstration) => demonstration.cle === cle)?.nom ?? '';
async function choisir(cle) {
if (enCours !== null) return;
if (enCours !== null) {
if (cle !== enCours) attente = cle;
return;
}
enCours = cle;
try {
await charger(cle);
} finally {
enCours = null;
attente = null;
}
}
</script>
@ -46,9 +57,15 @@
type="button"
data-demonstration={cle}
aria-busy={enCours === cle ? 'true' : undefined}
aria-disabled={enCours === cle ? 'true' : undefined}
aria-disabled={enCours !== null ? 'true' : undefined}
aria-describedby={attente === cle ? `${uid}-attente` : undefined}
onclick={() => choisir(cle)}>{nom}</button
>
{#if attente === cle}
<p id={`${uid}-attente`} role="status" data-attente-demonstration
>{t('accueil.demonstrations.enCours', { nom: nomDe(enCours) })}</p
>
{/if}
{#if refus !== null && refus.cle === cle}
<Refus refus={refus.refus} />
{/if}

View file

@ -13,8 +13,9 @@
// data-meilleure-collisions, « — » l'un et l'autre sans proposition
// classée —, ses signalements traduits — les bases qu'un doublon
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
// le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide :
// la création et les démonstrations de l'accueil en tiennent lieu.
// le refus de son ouverture, et ses opérations. Un dossier sans événement
// n'affiche pas de liste vide : la création et les démonstrations de
// l'accueil en tiennent lieu.
//
// lignes : LigneListe[] de la façade ; resumes : les résumés reçus de
// facade.resumer, par base — une ligne sans le sien attend, et chacune de

View file

@ -3,7 +3,7 @@
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
// de la liste des événements affichent, sans arithmétique ni texte. Une
// ligne de la liste (LigneListe de la façade, A3) devient ses six
// ligne de la liste (LigneListe de la façade, evenements.js) devient ses six
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
// deux mesures de son retenu et la meilleure proposition de son
// classement ; une question du dossier de travail, ses réponses ; un état
@ -60,7 +60,7 @@ const CLE_VIDE = 'format.vide';
// Les réponses de chaque question du dossier de travail, dans l'ordre où
// l'accueil les offre, et la clé de leur libellé : celles qu'admet
// facade.repondre (A3).
// facade.repondre (evenements.js).
const REPONSES = new Map([
[
'PORTABLE_NON_INSCRIPTIBLE',
@ -102,25 +102,27 @@ export function cleDEtat(etat) {
}
/**
* Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details, noms }
* : la traduction compose cle et details, et noms sont les données qu'il
* nomme hors de son texte, que le composant rend chacune dans son propre
* nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un journal
* sans état compris, de raison ABSENT —, avec la base, la raison et les
* secours lus par la façade, et aucun nom à part ;
* Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details,
* noms } : la traduction compose cle et details, et noms sont les données
* qu'il nomme hors de son texte, que le composant rend chacune dans son
* propre nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un
* journal sans état compris, de raison ABSENT —, avec la base, la raison et
* les secours lus par la façade, et aucun nom à part ;
* accueil.signalement.doublon pour un état dont d'autres bases portent
* l'identifiant, avec leur nombre, et ces bases pour noms, dans l'ordre de la
* façade.
* Un verrou présent ne se signale pas : seule la sonde d'une opération dit si
* sa séance vit. Les détails et les noms sont des copies.
* façade. Un verrou présent ne se signale pas : seule la sonde d'une
* opération dit si sa séance vit. Les détails, leurs secours compris, et les
* noms sont des copies.
*
* @param {Object} ligne LigneListe (A3)
* @param {Object} ligne LigneListe (evenements.js)
* @returns {Array<{cle: string, details: Object, noms: string[]}>}
*/
export function signalementsDe(ligne) {
const signalements = [];
if (ligne.illisible !== null) {
signalements.push({ cle: 'ETAT_ILLISIBLE', details: { base: ligne.base, ...ligne.illisible }, noms: [] });
const { secours = null } = ligne.illisible;
const details = { base: ligne.base, ...ligne.illisible, secours: secours === null ? null : { ...secours } };
signalements.push({ cle: 'ETAT_ILLISIBLE', details, noms: [] });
}
if (ligne.doublonAvec.length > 0) {
const noms = [...ligne.doublonAvec];
@ -150,7 +152,7 @@ export function signalementsDe(ligne) {
* valent la valeur absente, que le composant remplace par le signe du
* chargement.
*
* @param {Object} ligne LigneListe (A3)
* @param {Object} ligne LigneListe (evenements.js)
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @param {{mesuresDuRetenu: Object|null, meilleureDuClassement: Object|null}} [resume]
* @returns {{nom: string, date: string, etat: string, participants: string,

View file

@ -48,7 +48,7 @@ const RACINE = 'C:\\Epreuve\\Tablees';
// proposition classée.
const AUCUN = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null });
// Une ligne de la liste telle que la façade la rend (LigneListe, A3), lisible.
// Une ligne de la liste telle que la façade la rend (LigneListe, evenements.js), lisible.
function ligne(champs = {}) {
return {
base: 'Bal des Grisailles',
@ -170,6 +170,13 @@ describe('signalementsDe (§ 8.1, § 8.8)', () => {
const [signalement] = signalementsDe(recue);
signalement.noms.push('Merles Z');
assert.deepEqual(recue.doublonAvec, ['Merles B']);
// Les secours d'un état illisible sont copiés eux aussi.
const illisible = { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: 2, journal: null } };
const lue = ligne({ base: 'Soirée vide', nom: null, illisible });
const [etat] = signalementsDe(lue);
assert.notEqual(etat.details.secours, illisible.secours);
etat.details.secours.journal = 7;
assert.deepEqual(lue.illisible.secours, { precedent: 2, journal: null });
});
});

View file

@ -6,9 +6,10 @@
// demandée au plan, le texte d'un import en attente, les listes de noms, le
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
// du plan, le refus posé sur une place et l'appartenance collante du
// formulaire d'ajout. Ce qu'il tient survit au démontage des sections. Regarder n'écrit jamais (§ 8.4) : cet
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
// rien de ce qu'il tient ne quitte la mémoire.
// formulaire d'ajout. Ce qu'il tient survit au démontage des sections.
// Regarder n'écrit jamais (§ 8.4) : cet état ne reçoit ni la séance, ni la
// façade, ni un système de fichiers, et rien de ce qu'il tient ne quitte la
// mémoire.
//
// Il suit le contrat des magasins de Svelte : subscribe(fn) appelle fn(etat)
// tout de suite, puis à chaque nouvel état ; un état est figé, et une

View file

@ -34,7 +34,7 @@
// focus du clavier rendu au corps de la page.
//
// La place fixe porte la commande que rend commandeFixe (modele.js) :
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
// « Modifier », « Débloquer » — confirmé en place, par la primitive Commande —,
// « Revenir en lecture », ou, pour un format plus récent, l'explication du
// refus courant à la place de toute commande. Le verrou d'une autre séance
// qui a refusé le passage en écriture s'y nomme à côté du refus — sa
@ -227,7 +227,7 @@
font-weight: bold;
}
/* La tête ne prend jamais plus du tiers du cadre : au-delà, elle défile,
/* La tête ne prend jamais plus de 35 % du cadre : au-delà, elle défile,
et la surface garde sa place. */
.tete {
display: flex;

View file

@ -10,7 +10,8 @@
// « Proposé » sans proposition — s'affiche à côté de l'état cliqué, avec
// son remède. L'état courant se distingue par sa forme : gras, et un trait
// sous lui. Le nom se coupe où il le faut, même sans espace : un nom long
// de glyphes larges passe à la ligne au lieu d'élargir la page.
// de glyphes larges passe à la ligne au lieu d'élargir la page. Chaque état
// est un bloc : la commande et son refus en sont.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { ETATS_PLAN, cleEtat } from './modele.js';
@ -25,9 +26,9 @@
<h1>{$seance.evenement?.nom}</h1>
<div class="etats" role="group" aria-label={t('evenement.etat.titre')}>
{#each ETATS_PLAN as etat (etat)}
<span class="etat" class:courant={courant === etat} data-etat={etat}>
<div class="etat" class:courant={courant === etat} data-etat={etat}>
<Commande cle={cleEtat(etat)} nom="etat" executer={() => seance.executer('changerEtat', { etat })} />
</span>
</div>
{/each}
</div>
</header>

View file

@ -18,7 +18,6 @@
// du support rompu. Le texte attendu vient de la même traduction que celle
// de l'application ; les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { INACTIVITE_MS } from '../../application/seance.js';
@ -31,7 +30,7 @@ import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../s
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, molette } from '../../../test/navigateur/entrees.js';
import { fixerCadre, molette, pointer } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -90,25 +89,16 @@ const auCentre = (element) => {
// Un appui réel annoncé par Chromium comme le rang-ième clic d'une série
// (clickCount) : son événement click porte detail rang. cible est un élément,
// visé en son centre, ou le point que rend l'appui précédent de la série. Les
// coordonnées du cadre de l'épreuve passent dans la fenêtre de la page qui le
// porte, d'un cadre à son parent, comme dans test/navigateur/entrees.js.
// visé en son centre, ou le point que rend l'appui précédent de la série,
// dans le repère du cadre de l'épreuve.
async function cliquerEnSerie(cible, rang) {
let { x, y } = cible;
if (cible instanceof Element) {
const { left, top, width, height } = cible.getBoundingClientRect();
[x, y] = [left + width / 2, top + height / 2];
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const porteur = fenetre.frameElement;
const boite = porteur.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(porteur);
const echelle = porteur.offsetWidth > 0 ? boite.width / porteur.offsetWidth : 1;
x = boite.left + (porteur.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (porteur.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
}
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: rang });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: rang });
await pointer('presser', x, y, { clickCount: rang });
await pointer('relacher', x, y, { clickCount: rang });
flushSync();
return { x, y };
}
@ -889,7 +879,11 @@ describe('le journal (§ 8.6, § 8.2)', () => {
flushSync();
expect(m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(NOMS.slice(0, 2));
expect(m.seance.etat().evenement.revision).toBe(3);
expect(avertissement(m, 'JOURNAL_REPRIS').textContent).toContain(t('JOURNAL_REPRIS', {}));
// L'avertissement se lit comme le reste de l'écran : rendu, et son texte
// celui de la table.
const repris = avertissement(m, 'JOURNAL_REPRIS');
expect(visible(repris)).toBe(true);
expect(repris.textContent).toContain(t('JOURNAL_REPRIS', {}));
expect(m.trace.length).toBe(avant);
});
});
@ -1097,10 +1091,16 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
expect(coin(fixe(m, 'lecture'))).toEqual(place);
expect(visible(fixe(m, 'lecture'))).toBe(true);
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
// Le refus est un bloc : aucun <span> ne le contient (HTML valide).
expect(dans(m, '[data-bandeau] span [data-refus="liste"]')).toBeNull();
expect(m.seance.etat().evenement.base).toBe(base);
issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']);
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
// Le refus de « Liste » valait pour l'état d'avant : le changement
// suivant de la séance l'efface.
flushSync();
expect(dans(m, '[data-refus="liste"]')).toBeNull();
const nouvelle = baseDe(m);
expect(m.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).not.toBeNull();
const entrees = journalDe(m, 'documents').entrees.length;
@ -1123,9 +1123,16 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
issuesVisibles(m, ['choisirDossier', 'abandonner']);
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
const rompu = m.seance.etat().supportRompu;
const avant = m.trace.length;
choisir.click();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
flushSync();
// Le geste commence, puis s'achève sur le dialogue annulé : on attend sa
// fin avant de juger que rien n'a changé.
expect(choisir.getAttribute('aria-busy')).toBe('true');
await attendre(() => expect(choisir.hasAttribute('aria-busy')).toBe(false));
expect(m.seance.etat().supportRompu).toEqual(rompu);
expect(m.trace.length).toBe(avant);
expect(visible(message(m))).toBe(true);
});

View file

@ -134,6 +134,8 @@ describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
'JALON_NON_ECRIT',
'SUPPORT_AMOVIBLE',
'RESERVATIONS_SUSPENDUES',
'RESERVATIONS_RETABLIES',
'RESERVATIONS_RETIREES',
'IMPORT_EN_RETENU',
'PLACES_MANQUANTES',
'DERIVE_NOUVELLE',

View file

@ -317,7 +317,6 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
// de la démonstration.
const saisies = { nombre: 3, arret: 40000, historique: 500 };
const noms = Object.keys(saisies);
expect(noms.length).toBe(3);
for (const nom of noms) {
const avant = { ...charge(m).reglages.generation };
expect(avant[nom], nom).not.toBe(saisies[nom]);
@ -364,7 +363,6 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
const saisies = ['-1', '1,5', String(2 ** 32)];
expect(saisies.length).toBe(3);
for (const saisie of saisies) {
const attendu = controleur(m).valider({ ...controleur(m).reglagesProposes(), graine: saisie }).refus;
expect(attendu.map(({ champ: nom }) => nom)).toEqual(['graine']);
@ -654,7 +652,6 @@ describe('les cœurs de calcul, un réglage du poste (§ 5.7, § 5.10)', () => {
['5', 'HORS_DOMAINE', { min: 1, max: 4, remede: null }],
['x', 'NON_ENTIER', { saisie: 'x', remede: null }],
];
expect(refusees.length).toBe(3);
for (const [saisie, code, details] of refusees) {
await saisir(m, champCoeurs(m), saisie);
const refus = refusPres(champCoeurs(m), code);
@ -679,6 +676,27 @@ describe('les cœurs de calcul, un réglage du poste (§ 5.7, § 5.10)', () => {
expect(fenetre()).toBeNull();
});
test('Échap dans « Nombre de cœurs » rend le dernier nombre accepté : une saisie en cours, puis une saisie refusée, son refus retiré ; le focus reste, rien n’est écrit', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) });
const ecritures = m.trace.length;
await cliquer(m, bascule(m, 'coeursAutomatiques'));
await saisir(m, champCoeurs(m), '2', { fin: 'Enter' });
expect(controleur(m).coeurs().nombre).toBe(2);
await saisir(m, champCoeurs(m), '3', { fin: 'Escape' });
expect(champCoeurs(m).value).toBe('2');
expect(document.activeElement).toBe(champCoeurs(m));
expect(controleur(m).coeurs().nombre).toBe(2);
await saisir(m, champCoeurs(m), '9', { fin: 'Enter' });
expect(refusPres(champCoeurs(m), 'HORS_DOMAINE')).not.toBeNull();
await touche('Escape');
await passer(m);
expect(champCoeurs(m).value).toBe('2');
expect(champCoeurs(m).parentElement.querySelector('[data-refus]')).toBeNull();
expect(champCoeurs(m).hasAttribute('aria-invalid')).toBe(false);
expect(controleur(m).coeurs()).toEqual({ automatique: false, manuel: 2, disponibles: 4, nombre: 2 });
expect(m.trace.length).toBe(ecritures);
});
test('en lecture, le réglage se change sans refus ni écriture ; il survit au départ de la section', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect({ coeursDisponibles: 3 }), ecriture: false });
const ecritures = m.trace.length;

View file

@ -50,7 +50,7 @@
import { explicationDeColonne, texteCellule } from '../propositions/modele.js';
import Avancement from './Avancement.svelte';
import Reglages from './Reglages.svelte';
import { COLONNE_ANNONCEE, meilleureProposition } from './meilleure.js';
import { CLE_ANNONCEE, COLONNE_ANNONCEE, meilleureProposition } from './meilleure.js';
const t = getContext('t');
const format = getContext('format');
@ -182,7 +182,7 @@
onmouseenter={expliquer}
onmouseleave={taire}
onfocus={expliquer}
onblur={taire}>{t('propositions.colonne.collisionsCumulees')}</dt
onblur={taire}>{t(CLE_ANNONCEE)}</dt
>
<dd data-meilleure-collisions>{texteCellule(meilleure.cellule, { t, format })}</dd>
</div>

View file

@ -12,11 +12,15 @@
// que la liste des événements lit aussi par resumeDuMeilleur.
import { premiereDuClassement } from '../../application/evaluation.js';
import { evaluation, tableau } from '../propositions/modele.js';
import { COLONNES, evaluation, tableau } from '../propositions/modele.js';
/** La colonne du tableau que l'annonce reprend. */
export const COLONNE_ANNONCEE = 'collisionsCumulees';
/** La clé de l'intitulé de l'annonce : l'en-tête de sa colonne, tel que le
* tableau des propositions l'écrit. */
export const CLE_ANNONCEE = COLONNES.find(({ nom }) => nom === COLONNE_ANNONCEE).cle;
/**
* La proposition au rang 1 du classement, et sa cellule des collisions
* cumulées.

View file

@ -15,8 +15,8 @@ import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { tableau } from '../propositions/modele.js';
import { meilleureProposition } from './meilleure.js';
import { evaluation, tableau } from '../propositions/modele.js';
import { CLE_ANNONCEE, meilleureProposition } from './meilleure.js';
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
@ -90,6 +90,10 @@ function rangUn(charge) {
}
describe('la meilleure proposition (§ 5.4, § 5.7)', () => {
test('l’annonce s’intitule par l’en-tête de sa colonne du tableau, la même dont elle reprend la cellule', () => {
assert.equal(CLE_ANNONCEE, 'propositions.colonne.collisionsCumulees');
});
test('sans proposition ni retenu : null', () => {
assert.equal(meilleureProposition(chargeDemo('petite')), null);
assert.equal(meilleureProposition(chargeSix()), null);
@ -131,6 +135,35 @@ describe('la meilleure proposition (§ 5.4, § 5.7)', () => {
assert.deepEqual(rendu, rangUn(charge));
});
test('le retenu ne change pas le rang 1 : la 1 retenue reste derrière la 2 ; seule à se classer, elle l’est', () => {
const charge = chargeSix();
charge.propositions = [propositionDe(charge, 1, SEPARES), propositionDe(charge, 2, ENSEMBLE)];
charge.prochainsIds.proposition = 3;
const retenue = executer(charge, 'retenirProposition', { id: 1 });
assert.equal(retenue.retenu.proposition, 1);
const rendu = meilleureProposition(retenue);
assert.equal(rendu.id, 2);
assert.equal(rendu.cellule.valeur, 4);
assert.deepEqual(rendu, rangUn(retenue));
// Effacer les propositions garde la retenue, seule au classement.
const seule = executer(retenue, 'effacerPropositions', {});
assert.deepEqual(seule.propositions.map(({ id }) => id), [1]);
assert.deepEqual(meilleureProposition(seule), { ...rangUn(seule), id: 1 });
assert.equal(meilleureProposition(seule).cellule.valeur, 0);
});
test('une configuration que la mesure refuse : null, sans lever, là où l’évaluation rend son refus', () => {
const charge = chargeSix();
charge.propositions = [propositionDe(charge, 1, SEPARES)];
charge.prochainsIds.proposition = 2;
assert.notEqual(meilleureProposition(charge), null);
// Quatre personnes réservées à la table 1, de trois places, à chaque tour.
const refusee = structuredClone(charge);
refusee.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
assert.equal(evaluation(refusee).refus?.code, 'SURRESERVATION');
assert.equal(meilleureProposition(refusee), null);
});
test('aucune proposition comparable — population changée — : null', () => {
const charge = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300, historique: 1000 });
assert.notEqual(meilleureProposition(charge), null);

View file

@ -132,7 +132,7 @@ async function choisir(e, nom, octets) {
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe(nom));
}
// Remet un texte comme le collage de la grille (I5) : ui.ouvrirImport,
// Remet un texte comme le collage de la grille : ui.ouvrirImport,
// puis ui.allerA('import').
async function coller(e, texte) {
e.m.ui.ouvrirImport({ texte, origine: 'collage' });
@ -184,12 +184,16 @@ function entrees({ m }) {
return texte === null ? 0 : lireJournal(texte).entrees.length;
}
// Les comptes du rapport, chacun lu à son [data-compte] ; et ceux qu'il
// doit afficher : le résumé que rend seance.executer, par format.nombre.
// Les comptes du rapport, chacun lu à son [data-compte] — le nombre, puis
// son libellé — ; et ceux qu'il doit afficher : le résumé que rend
// seance.executer, par format.nombre, et le libellé accordé au nombre.
const COMPTES_DU_RAPPORT = ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees'];
const comptesAffiches = (e) =>
COMPTES_DU_RAPPORT.map((nom) => [nom, dans(e, `[data-rapport] [data-compte="${nom}"] [data-nombre]`).textContent]);
const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom])]);
COMPTES_DU_RAPPORT.map((nom) => {
const compte = dans(e, `[data-rapport] [data-compte="${nom}"]`);
return [nom, compte.querySelector('[data-nombre]').textContent, compte.querySelector('[data-nombre] + span').textContent];
});
const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom]), t(`import.rapport.${nom}`, { n: resume[nom] })]);
// Un glissement sur la fenêtre, portant un fichier ou un texte seul : rend
// l'événement distribué, dont defaultPrevented dit s'il a été accepté.
@ -325,6 +329,8 @@ describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => {
flushSync();
expect(new Map(association(dans(e, '[data-apercu]'))).get('entreprise')).toBe(t('import.champ.appartenance'));
expect(dans(e, '[data-champ="choix-appartenance"]')).toBeNull();
// Le refus valait pour l'ambiguïté : tranchée, il ne reste pas affiché.
expect(dans(e, '[data-refus="AMBIGUITE"]')).toBeNull();
const { rendu } = await importer(e);
expect(rendu.resume.ajoutes).toBe(1);
expect(participantsDe(e).map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Ombrelle', 'Grisaille']]);
@ -344,6 +350,7 @@ describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => {
dans(e, '[data-champ="choix-appartenance"] input[value="1"]').click();
flushSync();
expect(dans(e, '[data-refus="AMBIGUITE"]')).toBeNull();
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
dans(e, 'dialog [data-commande="renoncer"]').click();
@ -616,12 +623,15 @@ describe('une seule entrée, des doublons signalés, une mise à jour qui n’ef
await choisir(
e,
'noms_prenoms.csv',
enUtf8('nom;prenom\r\nOmbrelle;Iris\r\nLacasse;\r\nPervenche;Théo\r\nPervenche;\r\nMorillon;Aurèle\r\n'),
enUtf8('nom;prenom;appartenance\r\nOmbrelle;Iris;Club des Merles\r\nLacasse;;\r\nPervenche;Théo;\r\nPervenche;;\r\nMorillon;Aurèle;\r\n'),
);
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
const { rendu } = await importer(e);
expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 0, inchanges: 2, retires: 0, reservationsRetirees: 0 });
// Iris reçoit une appartenance : une mise à jour ; Théo, sans valeur
// nouvelle, reste inchangé.
expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 1, inchanges: 1, retires: 0, reservationsRetirees: 0 });
expect(comptesAffiches(e)).toEqual(comptesDu(rendu.resume));
expect(participantsDe(e).find(({ id }) => id === 1).appartenance).toBe('Club des Merles');
const courriels = new Map(participantsDe(e).map(({ id, courriel }) => [id, courriel]));
expect([courriels.get(1), courriels.get(2), courriels.get(4)]).toEqual(['iris@exemple.test', 'ondine@exemple.test', 'theo@exemple.test']);
const rapport = dans(e, '[data-rapport]');

View file

@ -144,7 +144,9 @@
// la charge sans rien écrire (appliquerCommande, le calcul même que
// seance.executer applique) — une ambiguïté non tranchée, AMBIGUITE —
// se posent à côté de la commande, et la fenêtre n'annonce jamais une
// destruction qui n'aura pas lieu.
// destruction qui n'aura pas lieu. Le refus vaut pour l'aperçu qui l'a
// reçu : un choix de colonne ou de mode, qui en fait un autre, le retire
// (la commande se recrée sous {#key apercu}).
async function importer() {
const applique = apercu;
const sonMode = mode;
@ -210,7 +212,9 @@
<Commande cle="import.commande.ordreDeLaGrille" nom="ordreDeLaGrille" executer={lireDansLOrdreDeLaGrille} />
{/if}
{#if modele.global === null}
<Commande cle="import.commande.importer" nom="importer" executer={importer} />
{#key apercu}
<Commande cle="import.commande.importer" nom="importer" executer={importer} />
{/key}
{/if}
{#if modele.refusees.length > 0}
<Commande

View file

@ -104,6 +104,9 @@ export const TABLE = new Map([
'Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; ' +
'aucun autre événement n\u{2019}est touché.',
],
// Une autre démonstration cliquée pendant un chargement : le nom de celle
// qui se charge est une donnée.
['accueil.demonstrations.enCours', ({ nom }) => `${nom} se charge : attendre qu\u{2019}elle s\u{2019}ouvre.`],
// --- La corbeille (§ 8.7) --------------------------------------------------
['accueil.corbeille.titre', 'Corbeille'],

View file

@ -95,13 +95,16 @@ export const TABLE = new Map([
['plan.source.revenirReservations', 'Revenir aux réservations'],
// --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) -------------------------
// Le fantôme d'une personne glissée dit ce que le geste modifie : l'état
// des données, non le geste (§ 5.8) ; une personne réservée déplace sa
// réservation, dont il dit la portée. Le nom de la personne est une
// donnée, dans son propre nœud.
// Le fantôme d'une personne glissée dit ce que le relâcher au point
// courant modifie : l'état des données, non le geste (§ 5.8) ; une
// personne réservée déplace sa réservation, dont il dit la portée, et
// au-dessus du sol la retire (« ce tour ») ou se la voit refuser (« tous
// les tours »). Le nom de la personne est une donnée, dans son propre nœud.
['plan.fantome.reservations', 'pose une réservation'],
['plan.fantome.reservationTour', 'déplace sa réservation de ce tour'],
['plan.fantome.reservationTous', 'déplace sa réservation, à tous les tours'],
['plan.fantome.retireReservationTour', 'retire sa réservation de ce tour'],
['plan.fantome.refuseTous', 'refusé : sa réservation vaut pour tous les tours'],
['plan.fantome.retenu', 'déplace dans le placement retenu'],
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
// La place où un refus ou un avertissement du plan se pose.
@ -115,13 +118,15 @@ export const TABLE = new Map([
// puis ses groupes : les noms sont des données, chacun dans son nœud,
// reliés par « et » dans une paire et séparés d'un groupe à l'autre. Une
// ligne de séparer les appartenances nomme l'appartenance de ses paires,
// une donnée dans son nœud, entre separerAppartenances et finAppartenance.
// une donnée dans son nœud, entre separerAppartenances et finAppartenance :
// la phrase se partage en deux clés autour d'elle. Chaque ligne s'ouvre sur
// une capitale.
['plan.vue.role', 'plan de salle interactif'],
['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`],
['plan.infobulle.separerAppartenances', 'Même appartenance ('],
['plan.infobulle.finAppartenance', ') : '],
['plan.infobulle.nouveauxVoisins', 'voisins revus : '],
['plan.infobulle.nouvelleTable', 'table revue : '],
['plan.infobulle.nouveauxVoisins', 'Voisins revus : '],
['plan.infobulle.nouvelleTable', 'Table revue : '],
['plan.infobulle.et', ' et '],
['plan.infobulle.separateur', ', '],

View file

@ -52,7 +52,10 @@ export const JETONS_CONFLIT = Object.freeze(CONFLITS.map(({ jeton }) => jeton));
* conflit à chaque autre marque du plan. Le second seuil est plus bas : les
* gris du mobilier occupent la clarté où une teinte de conflit tient 3:1
* sur le plateau sombre, et la forme distingue toujours la trame de ce
* qu'elle côtoie. */
* qu'elle côtoie. Sa valeur, 5, ne vient d'aucune référence de perception :
* elle se pose juste sous la plus petite séparation que la palette tient
* entre une teinte de conflit et une autre marque, et garde la palette de
* reculer sans établir qu'un écart de 5 se perçoit. */
export const SEPARATIONS = Object.freeze({ entreConflits: 12, autresMarques: 5 });
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la

View file

@ -11,7 +11,7 @@ import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { CONFLITS, JETONS_CONFLIT } from './marques.js';
import { JETONS_CONFLIT } from './marques.js';
import {
SIMULATIONS,
contraste,
@ -432,6 +432,5 @@ describe('jetons.css', () => {
assert.deepEqual([...jetons.keys()].filter((n) => n.startsWith('--couleur-conflit')), JETONS_CONFLIT, theme);
}
assert.deepEqual(verifierTeintesReservees(JETONS, JETONS_CONFLIT), []);
assert.deepEqual(CONFLITS.map(({ jeton }) => jeton), JETONS_CONFLIT);
});
});

View file

@ -23,7 +23,7 @@
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { bilanRemplacement, cleNormalisee } from '../application/csv.js';
@ -111,6 +111,18 @@ beforeAll(async () => {
m = await monterApp();
});
// La capture d'un échec prend pour nom de fichier le titre de son groupe et
// celui de l'épreuve, un caractère pour un caractère ; un nom de plus de
// 255 octets lève ENAMETOOLONG, et l'échec perd sa capture. Le titre complet,
// compté en octets UTF-8 — une borne haute du nom —, garde une marge sous
// cette limite.
const TITRE_MAX_OCTETS = 200;
beforeEach(({ task }) => {
const titre = `${task.suite?.name ?? ''} ${task.name}`;
expect(new TextEncoder().encode(titre).length, titre).toBeLessThanOrEqual(TITRE_MAX_OCTETS);
});
afterEach(() => {
expect(m.erreurs(), 'erreurs reçues par le collecteur').toEqual([]);
});
@ -363,8 +375,8 @@ async function choisir(conteneur, participant = null) {
async function oublierChoix() {
if (m.ui.etat().choix === null) return;
const oublier = actionPlace('oublierChoix');
if (oublier !== null) await appuyer(oublier, 'oublier le choix');
else m.ui.choisir(null);
expect(oublier, 'la commande qui oublie le choix').not.toBeNull();
await appuyer(oublier, 'oublier le choix');
await attendre('le choix oublié', () => m.ui.etat().choix === null);
}
@ -393,8 +405,9 @@ async function refusSurLePlan(poseAvant, attendue, code, remede, geste) {
// plan, il couvrirait l'élément que vise le geste suivant.
async function fermerLaPose() {
if (m.ui.etat().pose === null) return;
const fermer = [...tous('[data-surface] button')].find((bouton) => texteDe(bouton) === t('plan.refus.fermer'));
if (fermer !== undefined) await appuyer(fermer, 'fermer le refus posé');
const fermer = dans('[data-surface] [data-commande="fermerPose"]');
expect(fermer, 'la commande qui ferme la pose').not.toBeNull();
await appuyer(fermer, 'fermer le refus posé');
await attendre('la pose fermée', () => m.ui.etat().pose === null);
}
@ -407,8 +420,8 @@ async function refusPres(porteur, code, remede, geste) {
// --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------
describe('le parcours, depuis un répertoire neuf, par gestes seulement', { timeout: DUREE }, () => {
test('01 le répertoire vide : chaque démonstration du catalogue offerte, la version, le chemin ; créer « Soirée d’essai » l’ouvre en lecture', async () => {
describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => {
test('01 le répertoire vide : le catalogue offert, la version, le chemin ; « Soirée d’essai » créée s’ouvre en lecture', async () => {
await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-dossier-travail]'));
expect(tous('[data-evenement]')).toHaveLength(0);
const offertes = tous('[data-demonstration]');
@ -459,7 +472,7 @@ describe('le parcours, depuis un répertoire neuf, par gestes seulement', { time
expect(charge().tables[0].sieges).toBeNull();
});
test('02 l’aperçu de l’import des cas limites : encodage et séparateur nommés, ceux de l’analyseur ; un groupe d’orthographes fondu, une ligne refusée', async () => {
test('02 l’aperçu des cas limites : encodage et séparateur de l’analyseur ; un groupe fondu, une ligne refusée', async () => {
await ouvrirSection('import');
const octets = await octetsCasLimites();
m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets });
@ -614,7 +627,7 @@ describe('le parcours, depuis un répertoire neuf, par gestes seulement', { time
expect(charge().evenement.tours).toBe(3);
});
test('07 le plan à l’échelle : chaque plateau mesure son diamètre au zoom lu, l’échelle graphique, la mention « disposition relative »', async () => {
test('07 le plan à l’échelle : chaque plateau à son diamètre au zoom lu, l’échelle, « disposition relative »', async () => {
await ouvrirPlan();
const { k } = vueDepuisTransform(dans('[data-vue]').getAttribute('transform'));
expect(k).toBeGreaterThan(0);
@ -722,7 +735,7 @@ describe('le parcours, depuis un répertoire neuf, par gestes seulement', { time
await oublierChoix();
});
test('11 générer à la graine écrite et au compte réduit : trois propositions au moins, celles du moteur ; l’ordre écrit, celui de la clé du moteur ; les colonnes de diversité ; retenir', async () => {
test('11 générer, graine écrite, compte réduit : trois propositions du moteur, dans l’ordre de sa clé ; diversité ; retenir', async () => {
await ouvrirSection('generation');
await taper(dans('[data-section="generation"] input[data-champ="graine"]'), String(GRAINE));
let r = revision();
@ -790,7 +803,8 @@ describe('le parcours, depuis un répertoire neuf, par gestes seulement', { time
expect(charge().retenu.proposition).toBe(memoire.retenue);
});
test('20 la lecture : glisser une personne, une table, tirer une poignée, poser une réservation — empreinte inchangée ; « Défaire » refusé avec « Modifier », puis rend l’état d’avant 19', async () => {
// Glisser une personne, une table, tirer une poignée, poser une réservation.
test('20 la lecture : quatre gestes du plan, empreinte inchangée ; « Défaire » refusé avec « Modifier », puis rend 19', async () => {
await cliquer('[data-place-fixe] [data-commande="lecture"]');
await attendre('la lecture', () => etat().mode === 'lecture');
expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture');
@ -835,7 +849,9 @@ describe('le parcours, depuis un répertoire neuf, par gestes seulement', { time
// --- L'ordre de lecture (§ 2.1) ---------------------------------------------------------
describe('l’ordre de lecture, pas une séquence imposée', { timeout: DUREE }, () => {
test('sur l’événement retenu, en écriture : ajouter une personne, créer une table, désigner une animatrice, changer le nombre de tours — une entrée chacun, sans « Suivant »', async () => {
// Ajouter une personne, créer une table, désigner une animatrice, changer
// le nombre de tours.
test('sur le retenu, en écriture : une personne, une table, une animatrice, les tours — une entrée chacun, sans « Suivant »', async () => {
expect(etat().mode).toBe('ecriture');
expect(charge().retenu).not.toBeNull();
@ -901,14 +917,17 @@ describe('l’ordre de lecture, pas une séquence imposée', { timeout: DUREE },
// --- Les fenêtres (§ 2.1, § 5.9) --------------------------------------------------------
describe('les fenêtres : seul un geste qui détruit de la saisie en ouvre une', { timeout: DUREE }, () => {
describe('les fenêtres : seul un geste qui détruit en ouvre une', { timeout: DUREE }, () => {
test('aucune fenêtre pendant les gestes non destructeurs du parcours, chacun exécuté', () => {
expect(GESTES_NON_DESTRUCTEURS.length).toBeGreaterThan(0);
expect([...gestesSurveilles].sort()).toEqual([...GESTES_NON_DESTRUCTEURS].sort());
expect(fenetresVues).toEqual([]);
});
test('« Remplacer », « Appliquer et réaligner », retirer un tour réservé, supprimer une personne ou une table réservées, « Retenir » sur un retenu ajusté : une fenêtre qui nomme ce qui part ; Annuler n’écrit rien', async () => {
// « Remplacer », « Appliquer et réaligner », retirer un tour réservé,
// supprimer une personne ou une table réservées, « Retenir » sur un retenu
// ajusté.
test('cinq gestes destructeurs : une fenêtre qui nomme ce qui part ; Annuler n’écrit rien', async () => {
expect(etat().mode).toBe('ecriture');
const [t1] = charge().tables.map(({ id }) => id);
@ -1050,7 +1069,7 @@ describe('les fenêtres : seul un geste qui détruit de la saisie en ouvre une',
// --- Lecture et bloqué, toutes familles (§ 8.4, § 9, § 19.3 20) -------------------------
describe('lecture et bloqué : chaque famille de geste modifiant refusée, rien d’écrit', { timeout: DUREE }, () => {
describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', { timeout: DUREE }, () => {
// Chaque famille, tentée par son geste : le refus se pose là où le geste
// a eu lieu, avec le remède attendu.
async function toutesFamilles(code, remede) {
@ -1212,7 +1231,7 @@ describe('lecture et bloqué : chaque famille de geste modifiant refusée, rien
// différé dans reglages_locaux.json (contrainte 4).
const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !chemin.endsWith('reglages_locaux.json'));
test('en lecture : quatorze familles refusées avec « Modifier », la charge et les fichiers inchangés ; le tri, le filtre, les listes de travail vivants', async () => {
test('en lecture : quatorze familles refusées avec « Modifier », rien d’écrit ; tri, filtre, listes de travail vivants', async () => {
await cliquer('[data-place-fixe] [data-commande="lecture"]');
await attendre('la lecture', () => etat().mode === 'lecture');
const empreinte = serialiserCharge(charge());
@ -1227,7 +1246,7 @@ describe('lecture et bloqué : chaque famille de geste modifiant refusée, rien
await rienEcrit(avant, 'lecture, regarder');
});
test('sur un plan bloqué : quatorze familles refusées avec « Débloquer », la charge et les fichiers inchangés ; le tri, le filtre, les listes de travail vivants', async () => {
test('plan bloqué : quatorze familles refusées avec « Débloquer », rien d’écrit ; tri, filtre, listes de travail vivants', async () => {
await cliquer('[data-place-fixe] [data-commande="modifier"]');
await attendre('l’écriture', () => etat().mode === 'ecriture');
const r = revision();

View file

@ -32,8 +32,10 @@
// s'écrit ici.
//
// Groupée (groupes non null, ceux de groupesGrille), la grille porte un
// corps par groupe du premier niveau. Chaque groupe, de tout niveau, y
// rend un en-tête, en retrait selon son niveau, dont le bouton replie ou
// corps par groupe du premier niveau, dont l'en-tête porte
// scope="rowgroup" ; celui d'un sous-groupe porte scope="row", et ses
// lignes n'héritent pas des en-têtes de ses aînés. Chaque groupe, de tout
// niveau, y rend un en-tête, en retrait selon son niveau, dont le bouton replie ou
// déplie le groupe et dit son état en texte et dans aria-expanded, puis,
// déplié, ses sous-groupes, ou ses lignes au dernier niveau. Une ligne
// porte la clé du plus étroit groupe qui la compte. Les lignes visibles
@ -60,7 +62,7 @@
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { cleDeRaison } from './conflits.js';
import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, entreeValide, lignesVisibles } from './grille.js';
import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, deplierChemin, entreeValide, lignesVisibles, ligneVoisine } from './grille.js';
let {
lignes,
@ -166,44 +168,61 @@
}
// Valide la saisie de la cellule en édition : rien quand elle se lit comme
// la valeur enregistrée, la commande sinon. Refusée, l'édition reste
// ouverte sur la saisie, le refus contre elle.
function valider() {
if (edition === null) return null;
// la valeur enregistrée, la commande sinon. Rend une promesse de vrai quand
// l'édition se clôt, de faux quand la commande est refusée : l'édition
// reste alors ouverte sur la saisie, le refus contre elle, le focus dans
// le champ.
async function valider() {
if (edition === null) return true;
const { id, colonne } = edition;
const rendu = machine.valider();
saisie = machine.saisie;
if (rendu.action !== 'executer') return fermerEdition();
if (rendu.action !== 'executer') {
await fermerEdition();
return true;
}
const enCours = machine;
const rangAvant = rangs.get(id) ?? 0;
let promesse;
try {
promesse = seance.executer('modifierParticipant', { id, champs: { [colonne]: rendu.valeur } });
} catch (erreur) {
return echouer(enCours, id, colonne, erreur);
await echouer(enCours, id, colonne, erreur);
return false;
}
const garderFocus = entree !== null && document.activeElement === entree;
fermerEdition();
return Promise.resolve(promesse).then(
() => suivreLigne(id, colonne, garderFocus),
(erreur) => echouer(enCours, id, colonne, erreur),
);
try {
await promesse;
} catch (erreur) {
await echouer(enCours, id, colonne, erreur);
return false;
}
await suivreLigne(id, colonne, garderFocus, rangAvant);
return true;
}
// Après une modification écrite, quand la cellule avait le focus : la
// ligne que la valeur a menée sous des groupes repliés, de tout niveau,
// les déplie, puis la cellule reprend le focus que le retrait de son
// ancienne ligne a rendu au document. Un focus posé ailleurs entre-temps
// reste où il est.
async function suivreLigne(id, colonne, garderFocus) {
// ancienne ligne a rendu au document. Une ligne que le filtre ne montre
// plus a laissé son rang, rangAvant, à la ligne qui la suivait — à la
// dernière quand elle fermait la grille — : la même colonne de cette
// voisine prend le focus. Un focus posé ailleurs entre-temps reste où il
// est.
async function suivreLigne(id, colonne, garderFocus, rangAvant) {
if (!garderFocus) return;
await tick();
const chemin = groupes === null ? [] : cheminDe(groupes, id);
if (chemin.some((cle) => replies.has(cle))) {
replies = new Set([...replies].filter((cle) => !chemin.includes(cle)));
const deplies = groupes === null ? replies : deplierChemin(replies, cheminDe(groupes, id));
if (deplies !== replies) {
replies = deplies;
await tick();
}
const actuel = document.activeElement;
if (actuel === null || actuel === document.body) cellule(id, colonne)?.focus();
if (actuel !== null && actuel !== document.body) return;
const voisine = ligneVoisine(rangAvant, visibles.length);
if (rangs.has(id)) cellule(id, colonne)?.focus();
else if (voisine !== null) activer(voisine, COLONNES.indexOf(colonne));
}
async function echouer(enCours, id, colonne, erreur) {
@ -240,11 +259,13 @@
evenement.preventDefault();
valider();
} else if (touche === 'Tab') {
// Une saisie refusée garde l'édition et le focus : Tab ne quitte pas
// le champ, dont la sortie validerait une seconde fois.
evenement.preventDefault();
const { id } = edition;
const colonne = COLONNES.indexOf(edition.colonne);
const sens = evenement.shiftKey ? 'Maj+Tab' : 'Tab';
await valider();
if (!(await valider())) return;
await tick();
tabulerDepuis(id, rang, colonne, sens);
}
@ -284,9 +305,10 @@
const nom = personne.exclu ? 'reintegrerParticipant' : 'exclureParticipant';
refusCellule = null;
const garderFocus = document.activeElement === cellule(personne.id, 'exclu');
const rangAvant = rangs.get(personne.id) ?? 0;
try {
Promise.resolve(seance.executer(nom, { id: personne.id })).then(
() => suivreLigne(personne.id, 'exclu', garderFocus),
() => suivreLigne(personne.id, 'exclu', garderFocus, rangAvant),
(erreur) => refuser(personne.id, 'exclu', erreur),
);
} catch (erreur) {
@ -403,7 +425,7 @@
{#snippet bloc(groupe)}
{@const replie = replies.has(groupe.cle)}
<tr class="groupe" data-groupe={groupe.cle} data-niveau={groupe.niveau}>
<th scope="rowgroup" colspan={COLONNES.length + 1}>
<th scope={groupe.niveau === 1 ? 'rowgroup' : 'row'} colspan={COLONNES.length + 1}>
<button
type="button"
data-commande="basculerGroupe"

View file

@ -99,7 +99,7 @@ const csv = (personnes) =>
...personnes.map((p) => [p.nom, p.prenom ?? '', p.appartenance ?? '', p.exclu ? 'oui' : '', p.titre ?? '', p.notes ?? ''].join(';')),
].join('\n');
// Importe un texte par la commande, comme le ferait l'aperçu (I6).
// Importe un texte par la commande, comme le ferait l'aperçu.
const importer = (m, texte) => m.seance.executer('importerParticipants', { apercu: apercevoir(texte), mode: 'ajouter' });
// Les octets d'un CSV livré, lus par son adresse.
@ -477,6 +477,44 @@ describe('la grille (§ 10.2, § 19.3)', () => {
expect(document.activeElement).toBe(cellule(m, second, COLONNES[0]));
});
test('Entrée sur une saisie qui retire la ligne du filtre : la même colonne de la personne qui la suivait prend le focus, non le document', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
const [premier, second] = idsAffiches(m);
cellule(m, premier, 'appartenance').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, premier, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
await touche('Enter');
await attendreSeance(m);
expect(idsAffiches(m)).toEqual([second]);
await vi.waitFor(() => expect(document.activeElement).toBe(cellule(m, second, 'appartenance')));
});
test('Tab sur une saisie refusée : une seule commande, l’édition et le focus restent dans le champ, le refus contre lui', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const premier = idsAffiches(m)[0];
cellule(m, premier, 'nom').focus();
await touche('Enter');
await rendu();
const champ = cellule(m, premier, 'nom').querySelector('input');
await saisir(champ, ' ');
const executer = vi.spyOn(m.seance, 'executer');
const avant = entrees(m).length;
await touche('Tab');
await attendreSeance(m);
await rendu();
expect(executer).toHaveBeenCalledTimes(1);
const refuse = cellule(m, premier, 'nom').querySelector('input');
expect(refuse).not.toBeNull();
expect(document.activeElement).toBe(refuse);
expect(refuse.getAttribute('aria-invalid')).toBe('true');
expect(cellule(m, premier, 'nom').querySelector('[data-refus]')).not.toBeNull();
expect(entrees(m)).toHaveLength(avant);
});
test('Maj+Tab après une saisie qui retire la ligne du filtre mène à la dernière cellule de la personne qui la précédait', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
@ -1117,7 +1155,12 @@ describe('la liste groupée par une colonne ou plusieurs', () => {
test('le filtre passe d’abord : un groupe sans ligne disparaît, chaque compte est celui des lignes montrées ; trié par la colonne groupée, les groupes suivent le sens', async () => {
const m = await evenementOuvert({ personnes: CINQ });
// Deux orthographes du même groupe : la plus ancienne personne le nomme.
await m.seance.executer('modifierParticipant', { id: idDe(m, 'Pervenche'), champs: { appartenance: 'CLUB DES MERLES' } });
await grouperPar(m, 'appartenance');
const libelleEntier = entete(m, MERLES).libelle;
expect(participants(m).find(({ nom }) => nom === 'Pervenche').appartenance).toBe('CLUB DES MERLES');
expect(libelleEntier).toBe(CLUB);
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pinsons');
expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS]);
expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 2 }));
@ -1127,7 +1170,19 @@ describe('la liste groupée par une colonne ou plusieurs', () => {
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'iris');
expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([MERLES]);
expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 1 }));
// Le libellé ne suit pas le filtre : chacune des deux orthographes seule
// montrée, le groupe garde celui de la liste entière.
expect(entete(m, MERLES).libelle).toBe(libelleEntier);
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pervenche');
expect(entete(m, MERLES).libelle).toBe(libelleEntier);
// Tout replier vise chaque groupe, même ceux que le filtre cache.
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pinsons');
await cliquer(section(m).querySelector('[data-commande="toutReplier"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), '');
expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']);
expect(ouverts(m)).toEqual(['false', 'false', 'false']);
expect(lignes(m)).toHaveLength(0);
await cliquer(section(m).querySelector('[data-commande="toutDeplier"]'));
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]'));
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]'));
@ -1281,6 +1336,11 @@ describe('la liste groupée par une colonne ou plusieurs', () => {
expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 2 }));
expect(idsDuGroupe(m, `${MERLES}/statut:ancre`)).toEqual([ombrelle]);
expect(idsDuGroupe(m, `${MERLES}/statut:mobile`)).toEqual([pervenche]);
// Seul l'en-tête d'un groupe du premier niveau couvre son corps
// (rowgroup) ; celui d'un sous-groupe ne nomme que sa ligne (§ 14.5).
for (const g of groupes(m)) {
expect(g.querySelector('th').getAttribute('scope'), g.dataset.groupe).toBe(g.dataset.niveau === '1' ? 'rowgroup' : 'row');
}
// Un en-tête de sous-groupe est en retrait de celui de son parent.
expect(bascule(m, `${MERLES}/statut:ancre`).getBoundingClientRect().left).toBeGreaterThan(bascule(m, MERLES).getBoundingClientRect().left);

View file

@ -17,8 +17,11 @@
// colonnesOffertes, et son choix passe par choisirGroupement — la valeur
// vide retire son niveau et ceux d'après. Le statut se lit dans statuts
// (src/application/places.js), calculé seulement quand il groupe.
// « Tout replier » replie les groupes du premier niveau, « Tout déplier »
// les groupes de tout niveau. Le groupement et les groupes repliés vivent
// Le libellé d'un groupe se lit sur la liste entière, avant le filtre, et
// ne change pas avec lui. « Tout replier » replie les groupes du premier
// niveau de la liste entière, ceux que le filtre cache compris : effacer
// le filtre les montre repliés. « Tout déplier » déplie les groupes de tout
// niveau. Le groupement et les groupes repliés vivent
// dans la section, le temps qu'elle est ouverte : changer de groupement
// déplie tout, et rien ne s'écrit.
//
@ -49,6 +52,7 @@
import {
choisirGroupement,
cleDeGroupement,
clesPremierNiveau,
colonnesOffertes,
groupesGrille,
lignesGrille,
@ -75,8 +79,15 @@
const charge = $derived($seance.charge);
const participants = $derived(charge?.participants ?? []);
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
const toutes = $derived(lignesGrille(participants, { tri, filtre: '' }, cleNormalisee));
const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
const groupes = $derived(groupement.length === 0 ? null : groupesGrille(lignes, groupement, { tri, cleNormalisee, statuts: parStatut }));
const groupes = $derived(
groupement.length === 0 ? null : groupesGrille(lignes, groupement, { tri, cleNormalisee, statuts: parStatut, toutes }),
);
// Les groupes de la liste entière, que « Tout replier » replie.
const groupesEntiers = $derived(
groupement.length === 0 ? null : groupesGrille(toutes, groupement, { tri, cleNormalisee, statuts: parStatut }),
);
const niveaux = $derived(niveauxAffiches(groupement));
const conflits = $derived(
groupes === null || charge === null ? null : lignesDeConflit(conflitsDuPlacement(charge, { proposition: $ui.proposition }), participants),
@ -140,7 +151,7 @@
</div>
{/each}
{#if groupes !== null}
<button type="button" data-commande="toutReplier" onclick={() => (replies = new Set(groupes.map(({ cle }) => cle)))}>
<button type="button" data-commande="toutReplier" onclick={() => (replies = new Set(clesPremierNiveau(groupesEntiers)))}>
{t('participants.groupe.toutReplier')}
</button>
<button type="button" data-commande="toutDeplier" onclick={() => (replies = new Set())}>

View file

@ -210,7 +210,10 @@ const segment = (texte) => texte.replaceAll('%', '%25').replaceAll('/', '%2F');
*
* Une colonne de texte groupe par la clé normalisée, comme le tri la lit :
* le groupe se nomme par l'orthographe du plus petit identifiant qu'il
* compte, que ni le tri ni l'ordre reçu ne changent. Un champ null forme le
* compte parmi toutes les lignes (options.toutes, celles d'avant le filtre,
* les lignes reçues sans elles), que ni le tri, ni l'ordre reçu, ni le
* filtre ne changent : à chaque niveau, les lignes de toutes qui portent les
* valeurs du chemin du groupe le nomment. Un champ null forme le
* groupe vide, après toute valeur dans les deux sens. L'exclusion groupe
* les présentes avant les exclues. Le statut groupe par statuts.get(id),
* dans l'ordre de GROUPES_STATUT : une exclue, absente des statuts
@ -237,13 +240,15 @@ const segment = (texte) => texte.replaceAll('%', '%25').replaceAll('/', '%2F');
* au moins une, la plus large d'abord
* @param {{tri: null|{colonne: string, sens: 'croissant'|'decroissant'},
* cleNormalisee: (texte: string) => string,
* statuts: Map<number, 'ancre'|'partiel'|'mobile'>|null}} options
* statuts: Map<number, 'ancre'|'partiel'|'mobile'>|null,
* toutes?: Array<Object>}} options toutes : les lignes d'avant le
* filtre, qui contiennent celles reçues ; elles ne font que nommer
* @returns {Array<{cle: string, niveau: number, colonne: string, libelle: string|null,
* cleLibelle: string|null, compte: number, lignes: Array<Object>, sousGroupes: Array<Object>|null}>}
* @throws {RangeError} colonnes qui n'est pas une liste, vide, répétée ou
* hors de GROUPEMENTS
*/
export function groupesGrille(lignes, colonnes, { tri, cleNormalisee, statuts }) {
export function groupesGrille(lignes, colonnes, { tri, cleNormalisee, statuts, toutes = lignes }) {
if (
!Array.isArray(colonnes) ||
colonnes.length === 0 ||
@ -252,13 +257,14 @@ export function groupesGrille(lignes, colonnes, { tri, cleNormalisee, statuts })
) {
throw new RangeError(`groupement inconnu : ${JSON.stringify(colonnes)}`);
}
return grouperNiveau(lignes, colonnes, 1, null, { tri, cleNormalisee, statuts });
return grouperNiveau(lignes, toutes, colonnes, 1, null, { tri, cleNormalisee, statuts });
}
// Les groupes des lignes sur colonnes[niveau - 1], chacun sous-groupé par
// les colonnes suivantes ; parent est la clé du groupe qui les contient,
// null au premier niveau.
function grouperNiveau(lignes, colonnes, niveau, parent, options) {
// les colonnes suivantes et nommé par les lignes de toutes qui portent sa
// valeur ; parent est la clé du groupe qui les contient, null au premier
// niveau.
function grouperNiveau(lignes, toutes, colonnes, niveau, parent, options) {
const { tri, cleNormalisee, statuts } = options;
const colonne = colonnes[niveau - 1];
const signe = tri !== null && tri.colonne === colonne ? SENS.get(tri.sens) : 1;
@ -280,20 +286,26 @@ function grouperNiveau(lignes, colonnes, niveau, parent, options) {
return { segment: `texte:${segment(cle)}`, rang: cle, libelle: p[colonne], cleLibelle: null };
};
const parSegment = new Map();
for (const p of lignes) {
const valeur = valeurDe(p);
const groupe = parSegment.get(valeur.segment);
if (groupe === undefined) {
parSegment.set(valeur.segment, { ...valeur, plusPetitId: p.id, lignes: [p] });
continue;
// Les lignes par segment, chaque segment nommé par son plus petit identifiant.
const parValeur = (liste) => {
const parSegment = new Map();
for (const p of liste) {
const valeur = valeurDe(p);
const groupe = parSegment.get(valeur.segment);
if (groupe === undefined) {
parSegment.set(valeur.segment, { ...valeur, plusPetitId: p.id, lignes: [p] });
continue;
}
groupe.lignes.push(p);
if (p.id < groupe.plusPetitId) {
groupe.plusPetitId = p.id;
groupe.libelle = valeur.libelle;
}
}
groupe.lignes.push(p);
if (p.id < groupe.plusPetitId) {
groupe.plusPetitId = p.id;
groupe.libelle = valeur.libelle;
}
}
return parSegment;
};
const parSegment = parValeur(lignes);
const nommes = toutes === lignes ? parSegment : parValeur(toutes);
// Deux groupes ont deux rangs distincts : la comparaison ne rend jamais 0.
const comparer = (a, b) => {
@ -304,15 +316,17 @@ function grouperNiveau(lignes, colonnes, niveau, parent, options) {
};
return [...parSegment.values()].sort(comparer).map(({ segment: propre, libelle, cleLibelle, lignes: membres }) => {
const cle = parent === null ? propre : `${parent}/${propre}`;
const nomme = nommes.get(propre);
const semblables = nomme === undefined ? membres : nomme.lignes;
return {
cle,
niveau,
colonne,
libelle,
libelle: nomme === undefined ? libelle : nomme.libelle,
cleLibelle,
compte: membres.length,
lignes: membres,
sousGroupes: niveau === colonnes.length ? null : grouperNiveau(membres, colonnes, niveau + 1, cle, options),
sousGroupes: niveau === colonnes.length ? null : grouperNiveau(membres, semblables, colonnes, niveau + 1, cle, options),
};
});
}
@ -349,6 +363,47 @@ export function basculerGroupe(replies, cle) {
return suivants;
}
/**
* Les groupes repliés une fois déplié chaque groupe du chemin — celui d'une
* ligne que la grille doit montrer, de cheminDe. Sans clé repliée sur le
* chemin, l'ensemble reçu lui-même, que la grille n'a pas à réécrire ; sinon
* un nouvel ensemble.
*
* @param {Set<string>} replies
* @param {Array<string>} chemin
* @returns {Set<string>}
*/
export function deplierChemin(replies, chemin) {
if (!chemin.some((cle) => replies.has(cle))) return replies;
return new Set([...replies].filter((cle) => !chemin.includes(cle)));
}
/**
* Le rang de la ligne qui prend la place d'une ligne sortie de la liste —
* le filtre ne la montre plus —, au rang rangAvant qu'elle occupait : la
* suivante, qui a glissé à ce rang, ou la dernière quand elle fermait la
* liste ; null quand la liste, de nombre lignes, est vide.
*
* @param {number} rangAvant
* @param {number} nombre
* @returns {number|null}
*/
export function ligneVoisine(rangAvant, nombre) {
if (nombre === 0) return null;
return Math.min(rangAvant, nombre - 1);
}
/**
* Les clés des groupes du premier niveau, dans leur ordre : celles que
* « Tout replier » replie, leurs sous-groupes se cachant avec eux.
*
* @param {Array<{cle: string}>} groupes ceux de groupesGrille
* @returns {Array<string>}
*/
export function clesPremierNiveau(groupes) {
return groupes.map(({ cle }) => cle);
}
/**
* Les clés des groupes qui comptent la personne id, du premier niveau au
* dernier ; vide quand aucun ne la compte — le filtre l'a retirée.

View file

@ -20,12 +20,15 @@ import {
cleDeColonne,
cleDeGroupement,
cleDeStatut,
clesPremierNiveau,
colonnesOffertes,
deplierChemin,
entreeValide,
groupesGrille,
itineraire,
lignesGrille,
lignesVisibles,
ligneVoisine,
niveauxAffiches,
nomExport,
orthographeExistante,
@ -389,6 +392,24 @@ describe('groupesGrille : la liste groupée par une colonne', () => {
}
});
test('le libellé ne dépend pas du filtre : toutes les lignes, avant le filtre, nomment chaque groupe, à chaque niveau', () => {
// Le filtre ne garde qu'une personne du groupe, qui n'en est pas le plus petit identifiant.
const filtrees = lignesDe(membres, { filtre: 'ombrelle' });
assert.deepEqual(ids(filtrees), [3]);
assert.equal(grouper(filtrees, 'appartenance')[0].libelle, 'Club des Merles');
const toutes = lignesDe(membres);
const options = { tri: null, cleNormalisee, statuts: null, toutes };
assert.deepEqual(resume(groupesGrille(filtrees, ['appartenance'], options)), [
{ cle: 'texte:club des merles', libelle: 'CLUB DES MERLES', cleLibelle: null, compte: 1, ids: [3] },
]);
const [present] = groupesGrille(filtrees, ['exclu', 'appartenance'], options);
assert.deepEqual(resume(present.sousGroupes), [
{ cle: 'exclu:non/texte:club des merles', libelle: 'CLUB DES MERLES', cleLibelle: null, compte: 1, ids: [3] },
]);
// Sans filtre, les mêmes groupes que sans l'option.
assert.deepEqual(groupesGrille(toutes, ['appartenance'], options), grouper(toutes, 'appartenance'));
});
test('trié par la colonne groupée, les groupes suivent son sens ; le groupe vide reste après toute valeur', () => {
const tri = { colonne: 'appartenance', sens: 'decroissant' };
const groupes = grouper(lignesDe(membres, { tri }), 'appartenance', { tri });
@ -716,6 +737,29 @@ describe('lignesVisibles, cheminDe : les groupes repliés et la navigation', ()
assert.deepEqual([...replies], ['vide']);
});
test('deplierChemin : retire des repliés chaque clé du chemin ; sans clé repliée sur le chemin, l’ensemble reçu lui-même', () => {
const replies = new Set(['vide', 'texte:club des merles', 'texte:cercle des pinsons']);
const deplies = deplierChemin(replies, cheminDe(groupes, 3));
assert.deepEqual([...deplies], ['vide', 'texte:cercle des pinsons']);
assert.deepEqual([...replies], ['vide', 'texte:club des merles', 'texte:cercle des pinsons']);
// Rien à déplier : le même ensemble, que la grille ne réécrit pas.
assert.equal(deplierChemin(deplies, cheminDe(groupes, 3)), deplies);
assert.equal(deplierChemin(replies, []), replies);
});
test('clesPremierNiveau : les clés des groupes du premier niveau, dans leur ordre, sans leurs sous-groupes', () => {
assert.deepEqual(clesPremierNiveau(groupes), groupes.map(({ cle }) => cle));
assert.deepEqual(clesPremierNiveau([]), []);
});
test('ligneVoisine : le rang qu’une ligne sortie de la liste laisse à sa suivante, la dernière quand elle fermait la liste ; null sur une liste vide', () => {
assert.equal(ligneVoisine(0, 3), 0);
assert.equal(ligneVoisine(2, 3), 2);
assert.equal(ligneVoisine(3, 3), 2);
assert.equal(ligneVoisine(5, 1), 0);
assert.equal(ligneVoisine(0, 0), null);
});
test('cheminDe : la clé du groupe d’une personne, vide hors de tout groupe', () => {
assert.deepEqual(cheminDe(groupes, 3), ['texte:club des merles']);
assert.deepEqual(cheminDe(groupes, 4), ['vide']);
@ -734,6 +778,15 @@ describe('lignesVisibles, cheminDe : les groupes repliés et la navigation', ()
);
const MERLES = 'texte:club des merles';
test('imbriqués : clesPremierNiveau ne rend aucun sous-groupe ; deplierChemin déplie chaque niveau du chemin', () => {
const premiers = clesPremierNiveau(imbriques);
assert.deepEqual(premiers, imbriques.map(({ cle }) => cle));
assert.ok(premiers.every((cle) => !cle.includes('/')));
const chemin = cheminDe(imbriques, 3);
assert.equal(chemin.length, 2);
assert.deepEqual([...deplierChemin(new Set([...chemin, 'vide']), chemin)], ['vide']);
});
test('imbriqués : les lignes visibles suivent les sous-groupes ; replier un sous-groupe ne retire que les siennes, replier son parent retire tout le parent', () => {
assert.deepEqual(ids(lignesVisibles(imbriques, new Set())), [5, 2, 1, 3, 4]);
assert.deepEqual(ids(lignesVisibles(imbriques, new Set([`${MERLES}/texte:theo`]))), [5, 2, 4]);

View file

@ -3,13 +3,14 @@
<script>
// La barre de vue du plan (§ 7.2, § 7.5, § 7.7, § 8.4) : le sélecteur de
// tour, « Zoom + », « Zoom moins », « Ajuster », l'affichage des listes, le
// plein écran et la légende des conflits. Des contrôles HTML, hors du <svg data-plan>, tous vivants en
// lecture comme sur un plan bloqué : aucun n'écrit l'événement. Le tour,
// les listes et le plein écran passent par l'état de navigation ('ui') ;
// les zooms et l'ajustement par Plan.svelte, qui tient la vue :
// zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit
// son état par aria-pressed, que la forme du bouton redit.
// tour, « Zoom + », « Zoom − », « Ajuster », l'affichage des listes, la
// légende des conflits et le plein écran. Des contrôles HTML, hors du
// <svg data-plan>, tous vivants en lecture comme sur un plan bloqué : aucun
// n'écrit l'événement. Le tour, les listes et le plein écran passent par
// l'état de navigation ('ui') ; les zooms et l'ajustement par Plan.svelte,
// qui tient la vue : zoomer(sens), sens valant 1 ou -1, et ajuster(). Un
// bouton à bascule dit son état par aria-pressed, le bouton de la légende,
// qui dévoile, par aria-expanded ; la forme du bouton redit l'un et l'autre.
//
// evidence : la personne mise en évidence, {id, nom} — nom null pour une
// personne que la charge n'a plus —, ou null. La barre la nomme et offre
@ -19,9 +20,10 @@
//
// legende : vrai quand la légende des conflits est ouverte ; basculerLegende
// l'ouvre ou la ferme ; idLegende, l'identifiant de la légende, que le
// bouton désigne par aria-controls. Plan.svelte tient cet état : ouvrir la
// légende n'écrit rien. Le plein écran reste le dernier contrôle de la
// barre, celui qui précède le plan dans l'ordre de tabulation.
// bouton désigne par aria-controls tant qu'elle est ouverte — fermée, elle
// n'est pas dans le DOM, et l'attribut n'est pas posé. Plan.svelte tient
// cet état : ouvrir la légende n'écrit rien. Le plein écran est la dernière
// commande de vue ; une mise en évidence pose après lui son nom et « ôter ».
//
// source : ce que la barre dit du placement montré, que rend sourceMontree
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
@ -60,7 +62,12 @@
<button type="button" data-commande="listes" aria-pressed={$ui.listes} onclick={() => ui.basculerListes()}
>{t('plan.vue.listes')}</button
>
<button type="button" data-commande="legende" aria-pressed={legende} aria-controls={idLegende} onclick={() => basculerLegende()}
<button
type="button"
data-commande="legende"
aria-expanded={legende}
aria-controls={legende ? idLegende : undefined}
onclick={() => basculerLegende()}
>{t('plan.legende.ouvrir')}</button
>
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
@ -116,9 +123,11 @@
cursor: pointer;
}
/* Une bascule active se distingue par sa forme : trait intérieur épais,
texte souligné ; sa boîte ne change pas, et la barre ne bouge pas. */
button[aria-pressed='true'] {
/* Une bascule active, ou la légende dévoilée, se distingue par sa forme :
trait intérieur épais, texte souligné ; sa boîte ne change pas, et la
barre ne bouge pas. */
button[aria-pressed='true'],
button[aria-expanded='true'] {
box-shadow: inset 0 0 0 2px var(--couleur-page-texte);
text-decoration: underline;
}

View file

@ -41,6 +41,7 @@ import Legende from './Legende.svelte';
import EchellePlan from './EchellePlan.svelte';
import { creerMesureur } from './mesure_texte.js';
import { LARGEUR_ECHELLE_PX, legendeConflits, modeleDessin } from './modele.js';
import { nomDeCharge } from './vue_plan.js';
import PlanDessin from './PlanDessin.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -143,10 +144,7 @@ const personne = (id, nom, prenom, appartenance = null) => ({
exclu: false,
});
const nomsDe = (charge) => {
const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`]));
return (id) => noms.get(id);
};
const nomsDe = nomDeCharge;
/** Le modèle d'un tour de la charge, sous la source demandée. */
function modeleDe(charge, { tour = 1, proposition = null, ...options } = {}) {
@ -386,6 +384,36 @@ describe('la liste fait foi (§ 7.6)', () => {
expect(lignes[1].textContent).toBe('2. (libre) \u{2014} titre animateur non pourvu');
});
test('« réservée à … » porte deux noms de « W » : la ligne tient sous LARGEUR_BLOC_NOMS_CM, et l\u{2019}occupant s\u{2019}abrège avant la titulaire', () => {
// La ligne 2 de la table 1 : la titulaire, réservée là pour tous les
// tours, que la proposition assied ailleurs, et l'occupant qu'elle y met.
function ligneReservee(longueurTitulaire, longueurOccupant) {
const charge = avecProposition(chargeDeTables([8, 8]));
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
const assis = new Set(etat.tables[0].chaises.map(({ occupant }) => occupant));
const titulaire = charge.participants.find(({ id }) => !assis.has(id)).id;
const occupant = etat.tables[0].chaises[1].occupant;
expect(occupant).not.toBeNull();
Object.assign(charge.participants.find(({ id }) => id === titulaire), { nom: 'W'.repeat(longueurTitulaire), prenom: null });
Object.assign(charge.participants.find(({ id }) => id === occupant), { nom: 'W'.repeat(longueurOccupant), prenom: null });
charge.reservations = [{ participant: titulaire, table: 1, siege: 2, portee: 'tous', tour: null }];
const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 400, ty: 350 });
const ligne = lignesDe(svg, 1)[1];
expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const partie = (nom) => ligne.querySelector(`[data-partie=${nom}]`)?.textContent ?? null;
return { titulaire: partie('titulaire'), occupant: partie('nom') };
}
// Une titulaire courte reste entière ; l'occupant s'abrège à sa suite.
const courte = ligneReservee(6, 80);
expect(courte.titulaire).toBe('W'.repeat(6));
expect(courte.occupant).toMatch(new RegExp(`^W+${MARQUE}$`));
// Deux noms de 80 « W » : l'occupant tombé, la titulaire s'abrège à son
// tour, et la ligne tient encore.
const longues = ligneReservee(80, 80);
expect(longues.occupant).toBeNull();
expect(longues.titulaire).toMatch(new RegExp(`^W+${MARQUE}$`));
});
test('grande démonstration livrée, tour 1, modèle tel quel : chaque ligne tient, chaque place occupée garde son nom, chaque titre son libellé', () => {
const charge = analyser(texteLivre('grande')).charge;
const modele = modeleDe(charge);

View file

@ -27,6 +27,7 @@ import { flushSync, mount, unmount } from 'svelte';
import { get, writable } from 'svelte/store';
import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js';
@ -44,7 +45,8 @@ import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/nav
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import ListesTravail from './ListesTravail.svelte';
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
import { detailConflits } from './modele.js';
import { appartenanceDeCharge, cadreDepuisRect, englobantDuTour, nomDeCharge } from './vue_plan.js';
const BASE = 'soiree';
const DUREE_PRESSION = constante('DUREE_PRESSION_LONGUE_MS');
@ -63,7 +65,7 @@ const IRIS = 1;
const THEO = 2;
const ONDINE = 3;
// La charge du fichier d'épreuve d'I10 : huit rondes de trois, en deux
// La charge de huit_tables_retenu.gtt.json : huit rondes de trois, en deux
// rangées de quatre, vingt personnes, trois tours, la proposition 1
// retenue. Au tour 1, la table 2 assied 4, 5 et 6 ; la table 8, la seule
// personne 20 : ses sièges 2 et 3 sont libres.
@ -233,6 +235,7 @@ const cadreDe = (m) => cadreDepuisRect(racinePlan(m).getBoundingClientRect());
const vueLue = (m) => vueDepuisTransform(groupe(m).getAttribute('transform'));
const charge = (m) => m.seance.etat().charge;
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const actif = (m) => racinePlan(m).getAttribute('aria-activedescendant');
// Les commandes d'un conteneur : la valeur de data-commande de chaque
@ -577,7 +580,6 @@ describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => {
// --- L'appartenance nommée, la légende des conflits (§ 7.5) -------------------
describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légende des conflits (§ 7.5)', () => {
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Deux tables de trois, six personnes, un tour : la table 1 réunit Iris
// et Théo, du Club des Merles ; la table 2, Basile et Capucine, d'une
@ -626,18 +628,23 @@ describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légen
expect(bouton).not.toBeNull();
expect(bouton.closest('[role="toolbar"]')).not.toBeNull();
expect(bouton.textContent).toBe(t('plan.legende.ouvrir'));
expect(bouton.getAttribute('aria-pressed')).toBe('false');
// Un dévoilement : aria-expanded, et non aria-pressed ; aria-controls ne
// désigne la légende que lorsqu'elle est dans le DOM.
expect(bouton.hasAttribute('aria-pressed')).toBe(false);
expect(bouton.getAttribute('aria-expanded')).toBe('false');
expect(bouton.hasAttribute('aria-controls')).toBe(false);
expect(dans(m, '[data-legende]')).toBeNull();
await cliquer(bouton);
await attendre(() => expect(dans(m, '[data-legende]')).not.toBeNull());
expect(bouton.getAttribute('aria-pressed')).toBe('true');
expect(bouton.getAttribute('aria-expanded')).toBe('true');
expect(bouton.getAttribute('aria-controls')).toBe(dans(m, '[data-legende]').id);
expect(tous(m, '[data-legende] [data-legende-regle]').map((e) => e.dataset.legendeRegle)).toEqual(['separerAppartenances', 'nouveauxVoisins']);
expect(dans(m, '[data-legende] [data-marque="trameTable"]')).toBeNull();
expect(infobulleVisible(dans(m, '[data-legende]'))).toBe(true);
await cliquer(bouton);
await attendre(() => expect(dans(m, '[data-legende]')).toBeNull());
expect(bouton.getAttribute('aria-pressed')).toBe('false');
expect(bouton.getAttribute('aria-expanded')).toBe('false');
expect(bouton.hasAttribute('aria-controls')).toBe(false);
expect(m.seance.etat().evenement.revision).toBe(revision);
});
}
@ -646,19 +653,24 @@ describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légen
// --- L'infobulle (§ 7.1, § 7.3, § 19.4) ---------------------------------------
describe('l’infobulle : un composant, trois déclencheurs (§ 7.1, § 7.3)', () => {
// Le détail que rend le moteur pour la table 1 au tour 2, en mots.
const LIBELLES = { separerAppartenances: 'appartenances réunies : ', nouveauxVoisins: 'voisins revus : ', nouvelleTable: 'table revue : ' };
// Le détail que rend le moteur pour la table 1 au tour 2, nommé par
// detailConflits, en mots : chaque ligne de l'infobulle, telle que ses
// nœuds la composent.
function detailAttendu(m) {
const table = etatMontre(m).tables.find(({ id }) => id === 1);
const nom = (id) => {
const { nom: n, prenom } = charge(m).participants.find((p) => p.id === id);
return prenom === null ? n : `${prenom} ${n}`;
};
expect(table.conflits.nombre).toBeGreaterThan(0);
const detail = detailConflits(table, nomDeCharge(charge(m)), appartenanceDeCharge(charge(m)));
return [
`Table 1 : ${table.conflits.nombre} conflits`,
...table.conflits.regles.map(
(regle) => `${LIBELLES[regle.regle]}${(regle.paires ?? regle.personnes.map((id) => [id])).map((g) => g.map(nom).join(' et ')).join(', ')}`,
t('plan.infobulle.entete', { numero: detail.numero, nombre: detail.nombre }),
...detail.lignes.map(({ cle, appartenance, groupes }) =>
[
t(cle),
...(appartenance === null ? [] : [appartenance, t('plan.infobulle.finAppartenance')]),
groupes.map((groupe) => groupe.join(t('plan.infobulle.et'))).join(t('plan.infobulle.separateur')),
]
.join('')
.replace(/\s+/g, ' ')
.trim(),
),
];
}
@ -821,7 +833,7 @@ describe('assigner, par le choix en attente (§ 7.1, § 5.8)', () => {
});
describe('Échap oublie le choix en attente (§ 7.1, § 7.3)', () => {
test('choisie dans la réserve, puis dans les pressentis : Échap sur son bouton l’oublie ; sur le plan, Échap l’oublie depuis une table active comme depuis un siège actif, qui remonte à sa table', async () => {
test('choisie dans la réserve, puis dans les pressentis : Échap sur son bouton l’oublie ; sur le plan, Échap d’un siège remonte à sa table et garde le choix, Échap sur la table l’oublie', async () => {
const base = sansProposition();
base.participants[ONDINE - 1].titrePressenti = 'hôte';
const m = await ouvrir(base);
@ -854,6 +866,10 @@ describe('Échap oublie le choix en attente (§ 7.1, § 7.3)', () => {
await touche('Escape');
flushSync();
expect(actif(m)).toBe(`table-${siege.split('-')[1]}`);
// Au clavier, Échap est le seul chemin d'un siège vers une autre table :
// remonter ne perd pas la personne choisie.
expect(m.ui.etat().choix?.participant).toBe(THEO);
await touche('Escape');
await attendre(() => expect(m.ui.etat().choix).toBeNull());
});
});
@ -1005,7 +1021,6 @@ describe('les listes de travail tiennent deux titres identiques à une même pla
const seance = writable({ charge });
const etatUi = writable({ choix: null, selection: null });
const ui = { subscribe: etatUi.subscribe, etat: () => get(etatUi), selectionner: () => {}, choisir: () => {} };
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const conteneur = document.createElement('div');
document.body.prepend(conteneur);
const contexte = new Map([['t', t], ['seance', seance], ['ui', ui]]);
@ -1085,15 +1100,16 @@ describe('une réservation « tous les tours » ne se retire pas par un geste (
let avant = fichiers(m);
await glisser(pointChaise(m, 3, 1), pointSol(m));
await attendre(() => expect(refusSur()).not.toBeNull());
expect(texteDe(decrit(place()))).toContain('tous les tours');
expect(texteDe(decrit(place()))).toContain(t('RESERVATION_TOUS_LES_TOURS', { participant: IRIS, table: 3 }).replace(/\s+/g, ' ').trim());
expect(texteDe(decrit(place()))).toContain(`Table ${charge(m).tables.find(({ id }) => id === 3).numero}, siège 1`);
await rienEcrit(m, avant);
expect(charge(m).reservations).toEqual(base.reservations);
// Le message laisse passer le pointeur, ses boutons le prennent : « Fermer »,
// pressé par le vrai pointeur, reçoit l'appui et retire la pose.
const fermer = decrit(place()).querySelector('button.fermer');
const fermer = decrit(place()).querySelector('[data-commande="fermerPose"]');
expect(fermer).not.toBeNull();
expect(texteDe(fermer)).toBe(t('plan.refus.fermer'));
expect(await presseRecue(fermer)).toBe(true);
await attendre(() => expect(refusSur()).toBeNull());
expect(m.ui.etat().pose).toBeNull();
@ -1147,6 +1163,52 @@ describe('une réservation « tous les tours » ne se retire pas par un geste (
});
});
// Mène le pointeur, déjà pressé, de depuis à vers en six pas, une image
// entre deux.
async function mener(depuis, vers, pas = 6) {
for (let i = 1; i <= pas; i += 1) {
await pointer('bouger', depuis.x + (vers.x - depuis.x) * (i / pas), depuis.y + (vers.y - depuis.y) * (i / pas));
await images(1);
}
flushSync();
}
describe('le fantôme d’une personne réservée au-dessus du sol (§ 5.8)', () => {
test('« ce tour » se dit retirée, « tous les tours » refusée ; revenue sur une chaise libre, le fantôme redit le déplacement ; rien d’écrit', async () => {
const base = executer(
executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }),
'reserver',
{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 },
);
const m = await ouvrir(base);
expect(etatMontre(m).cible).toBe('reservations');
const etatDuFantome = () => texteDe(dans(m, '[data-fantome] [data-partie="etat"]'));
const avant = fichiers(m);
const cas = [
{ depuis: [3, 1], surLeSol: 'plan.fantome.refuseTous', surUneChaise: 'plan.fantome.reservationTous' },
{ depuis: [4, 2], surLeSol: 'plan.fantome.retireReservationTour', surUneChaise: 'plan.fantome.reservationTour' },
];
for (const { depuis, surLeSol, surUneChaise } of cas) {
const origine = pointChaise(m, ...depuis);
const libre = pointChaise(m, 4, 3);
const sol = pointSol(m);
await presserEtBouger(origine, libre);
expect(etatDuFantome(), surUneChaise).toBe(t(surUneChaise));
await mener(libre, sol);
expect(etatDuFantome(), surLeSol).toBe(t(surLeSol));
await mener(sol, libre);
expect(etatDuFantome(), surUneChaise).toBe(t(surUneChaise));
// Rendue à sa chaise, la personne n'a pas bougé.
await mener(libre, origine);
await pointer('relacher', origine.x, origine.y);
await images(2);
await attendre(() => expect(dans(m, '[data-fantome] [data-partie="etat"]')).toBeNull());
}
await rienEcrit(m, avant);
expect(charge(m).reservations).toEqual(base.reservations);
});
});
// --- Réserver, titrer, désigner (§ 4, § 2.1 étape 6, § 5.8) ---------------------
describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)', () => {
@ -1693,8 +1755,7 @@ describe('la mise en évidence d’une personne : posée et ôtée par un geste,
// La barre de vue nomme la personne mise en évidence.
const barre = dans(m, '[data-evidence]');
expect(barre).not.toBeNull();
const { nom, prenom } = charge(m).participants.find(({ id }) => id === personne);
expect(texteDe(barre)).toContain(prenom === null ? nom : `${prenom} ${nom}`);
expect(texteDe(barre)).toContain(nomAffiche(charge(m).participants.find(({ id }) => id === personne)));
expect(Number(barre.dataset.evidence)).toBe(personne);
for (const tour of [2, 3, 1]) {
m.ui.montrerTour(tour);

View file

@ -22,7 +22,7 @@
// changement de taille : tant que la vue est null, il la calcule par
// vueInitiale, du cadrage que la séance a mémorisé ; ensuite par
// vueApresCadre. Ni l'une ni l'autre ne s'écrit : seul un geste de
// l'opérateur — molette, « Zoom + », « Zoom moins », « Ajuster » — confie sa
// l'opérateur — molette, « Zoom + », « Zoom − », « Ajuster » — confie sa
// vue à seance.demanderCadrage, qui l'écrit, différée, dans
// reglages_locaux.json, jamais dans l'événement.
//
@ -36,7 +36,7 @@
//
// diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou
// null — le bandeau ne porte alors que la phrase des conflits (§ 7.5).
// etatTour : l'état des places du tour affiché (EtatPlaces, A5), que
// etatTour : l'état des places du tour affiché (EtatPlaces, places.js), que
// SectionPlan calcule pour le plan et les panneaux.
//
// Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul
@ -97,7 +97,7 @@
import BandeauPlan from './BandeauPlan.svelte';
import BarreVue from './BarreVue.svelte';
import { toucheDuPlan, vueQuiMontre } from './clavier.js';
import { deposer, reservationTenue } from './depot.js';
import { deposer, designerAuPoint, reservationTenue } from './depot.js';
import EchellePlan from './EchellePlan.svelte';
import Fantome from './Fantome.svelte';
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
@ -169,10 +169,10 @@
const legende = $derived(legendeConflits(charge.reglages));
const selection = $derived($ui.selection);
const pose = $derived($ui.pose);
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
// null pour une personne que la charge n'a plus ; null sans mise en évidence.
// Le placement que le plan montre, que la barre de vue nomme.
const source = $derived(sourceMontree(etat, $ui.proposition, charge.retenu !== null));
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
// null pour une personne que la charge n'a plus ; null sans mise en évidence.
const evidence = $derived($ui.miseEnEvidence === null ? null : { id: $ui.miseEnEvidence, nom: nomDe($ui.miseEnEvidence) ?? null });
// L'élément actif que le dessin marque : sous un focus venu du clavier.
const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
@ -510,7 +510,7 @@
case 'montrerFantome':
fantome = {
nom: nomDe(effet.participant),
cle: cleFantome(etat.cible, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null),
cle: cleFantome(etat.cible, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null, effet.surLeSol),
};
break;
case 'montrerPlaces':
@ -590,6 +590,7 @@
geometries,
k,
}),
surLeSol: (point) => designerAuPoint(point, geometries, k).surLeSol,
};
const point = { x: evenement.clientX, y: evenement.clientY };
appliquer(geste(etatGeste, { type: 'presser', source, pointeur: evenement.pointerId, point, vue, cadre }, contexte));
@ -662,12 +663,15 @@
};
// Une touche sur la racine, hors d'un geste : Échap d'un geste en cours
// passe d'abord par surTouche, en phase de capture.
// passe d'abord par surTouche, en phase de capture. Échap d'un siège
// remonte à sa table et garde la personne choisie : c'est le seul chemin
// du clavier vers une autre table. Échap sur une table, ou sans élément
// actif, l'oublie.
const surToucheClavier = (evenement) => {
if (etatGeste.phase !== 'repos' || modele === null || evenement.altKey || evenement.ctrlKey || evenement.metaKey) return;
auClavier = true;
if (evenement.key === 'Escape') ui.choisir(null);
const rendu = toucheDuPlan(actif, evenement.key, contexteClavier());
if (evenement.key === 'Escape' && (rendu === null || rendu.effet !== null)) ui.choisir(null);
if (rendu === null) return;
evenement.preventDefault();
actif = rendu.actif;

View file

@ -19,7 +19,7 @@
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
// en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un
// remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus
// reçoit tels quels.
// reçoit tels quels. « Fermer » se désigne par data-commande="fermerPose".
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
import { libellePlace } from './gestes.js';
@ -40,7 +40,7 @@
{#each pose.avertissements as avertissement, rang (rang)}
<p class="avertissement" role="status" data-avertissement={avertissement.code}>{t(avertissement.code, avertissement.details)}</p>
{/each}
<button type="button" class="fermer" onclick={fermer}>{t('plan.refus.fermer')}</button>
<button type="button" class="fermer" data-commande="fermerPose" onclick={fermer}>{t('plan.refus.fermer')}</button>
</div>
<style>

View file

@ -321,7 +321,7 @@ describe('les trois invariants du <svg data-plan> (§ 7.2)', () => {
});
});
// Zoome à la molette, par « Zoom + » et par « Zoom moins » en restant au-dessus
// Zoome à la molette, puis par les deux boutons de zoom, en restant au-dessus
// des seuils de rendu, sous un MutationObserver posé sur la racine ; rend
// ses relevés et la largeur de la barre d'échelle avant et après.
async function zoomsObserves(m) {
@ -342,7 +342,7 @@ async function zoomsObserves(m) {
}
describe('zoomer écrit un attribut sur un élément (§ 7.2)', () => {
test('molette, « Zoom + » et « Zoom moins » : seul le transform du groupe change sous la racine, il décrit la vue tenue, et la barre d’échelle, hors du <svg>, change de longueur', async () => {
test('molette, « Zoom + » et « Zoom \u{2212} » : seul le transform du groupe change sous la racine, il décrit la vue tenue, et la barre d’échelle, hors du <svg>, change de longueur', async () => {
const m = await petite();
cliquer(m, 'ajuster');
await deuxImages();
@ -501,7 +501,8 @@ const transformFiniOuAbsent = (m) => {
};
describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () => {
test('surface en display: none → aucun transform non fini ; montrée → vueInitiale dans le cadre rendu ; 683 × 700 → vueApresCadre', async () => {
test('surface en display: none → aucun transform non fini ; montrée → vueInitiale dans le cadre rendu ; dézoomée à fond puis élargie → vueApresCadre', async () => {
await fixerCadre(683, 700);
const montrer = feuille('[data-surface] { display: none !important; }');
const m = await monter();
await m.facade.chargerDemonstration('petite');
@ -513,12 +514,21 @@ describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () =
montrer();
await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull());
expect(groupe(m).getAttribute('transform')).toBe(transformDe(vueInitiale(m.seance.cadrage(), englobantDe(m), cadreDe(m))));
// Un cadre qui rétrécit laisse la vue telle quelle : seul un cadre qui
// s'élargit sous une vue dézoomée à fond la change. La vue attendue
// diffère de celle d'avant : l'assertion ne tient qu'une fois le
// recadrage fait, et l'attente le guette au lieu de compter des images.
for (let precedent = null; precedent !== vueLue(m).k; ) {
precedent = vueLue(m).k;
cliquer(m, 'dezoomer');
}
const avant = vueLue(m);
const largeurAvant = cadreDe(m).largeur;
await fixerCadre(683, 700);
await fixerCadre(1366, 700);
await attendre(() => expect(cadreDe(m).largeur).not.toBe(largeurAvant));
await deuxImages();
expect(groupe(m).getAttribute('transform')).toBe(transformDe(vueApresCadre(avant, englobantDe(m), cadreDe(m))));
const attendue = transformDe(vueApresCadre(avant, englobantDe(m), cadreDe(m)));
expect(attendue).not.toBe(transformDe(avant));
await attendre(() => expect(groupe(m).getAttribute('transform')).toBe(attendue));
});
test('dézoomé à k_min à 683 × 700, puis élargi à 1 366 × 700 → vueApresCadre relève k au k_min du nouveau cadre', async () => {

View file

@ -4,7 +4,7 @@
// Le dépôt d'une personne glissée sur le plan (§ 5.8, § 5.9, § 7.1, § 7.3,
// § 14.10), pur : la place se désigne par la géométrie des tables — celles
// mêmes que le dessin pose —, jamais dans le DOM, et la commande se lit sur
// la cible de l'état des places (EtatPlaces.cible, A5), jamais sur la
// la cible de l'état des places (EtatPlaces.cible, places.js), jamais sur la
// présence d'un retenu. L'interface ne choisit rien : la cible dit ce que
// le geste modifie, et la commande juge la pose et la capacité.
import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js';
@ -64,9 +64,9 @@ export function reservationTenue(etatPlaces, personne, depuis) {
* change pas les autres tours en silence, et une réservation se défait
* par sa propre commande (§ 7.1) ; le dépôt rend le refus
* RESERVATION_TOUS_LES_TOURS {participant, table}, dont le remède est ce
* retrait, de portée « tous ». Un siège
* qu'occupe une personne non réservée de la proposition montrée vaut un
* siège libre : la proposition reste identique, et dérive ;
* retrait, de portée « tous ». Un siège qu'occupe une personne non
* réservée de la proposition montrée vaut un siège libre : la proposition
* reste identique, et dérive ;
* - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une
* autre → echangerDansRetenu, le sol → retirerDuTour ;
* - null — proposition montrée après la retenue, retenu fautif — : un dépôt
@ -124,20 +124,38 @@ export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, sur
}
/**
* Le dépôt au point de dessin : la place que désignent designer et surLeSol
* sur les géométries du modèle de dessin, au zoom k, et la commande de
* Ce que désigne un point de dessin, tel que le dépôt le lit : la place de
* designer sur les géométries au zoom k, et surLeSol, vrai seulement quand
* aucune place n'est désignée et que le point tombe hors de toute table.
* Entre deux chaises ou sur un plateau, les deux disent rien. Le dépôt et le
* libellé du fantôme pendant le geste le lisent ici tous deux.
*
* @param {{x: number, y: number}} point cm
* @param {Object[]} geometries TableDessin.geometrie de chaque table, dans
* l'ordre du dessin
* @param {number} k
* @returns {{cible: {table: number, siege?: number}|null, surLeSol: boolean}}
*/
export function designerAuPoint(point, geometries, k) {
const cible = designer(point, geometries, k);
return { cible, surLeSol: cible === null && horsDesTables(point, geometries) };
}
/**
* Le dépôt au point de dessin : la place que désigne designerAuPoint sur
* les géométries du modèle de dessin, au zoom k, et la commande de
* commandeDeDepot. place est la place où poser un refus : la place
* désignée — chaise ou table —, sinon la chaise d'origine.
*
* @param {Object} options ceux de commandeDeDepot, cible et surLeSol exceptés, plus :
* @param {{x: number, y: number}} options.point cm
* @param {Object[]} options.geometries TableDessin.geometrie de chaque table, dans l'ordre du dessin
* @param {Object[]} options.geometries TableDessin.geometrie de chaque table,
* dans l'ordre du dessin
* @param {number} options.k
* @returns {{resultat: ReturnType<typeof commandeDeDepot>, place: Object|null}}
*/
export function deposer({ etatPlaces, tour, personne, depuis, point, geometries, k }) {
const cible = designer(point, geometries, k);
const sol = cible === null && horsDesTables(point, geometries);
const resultat = commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol: sol });
const { cible, surLeSol } = designerAuPoint(point, geometries, k);
const resultat = commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol });
return { resultat, place: cible ?? depuis };
}

View file

@ -89,7 +89,11 @@ function glisser(etat, point, contexte, entree) {
effets.push({ type: 'ecrireTransform', cible: 'vue', transform: transformDe(deplacerVue(vue, depart, point)) });
} else if (source.genre === 'personne') {
if (source.participant !== null) {
if (entree) effets.push({ type: 'montrerFantome', participant: source.participant, depuis: placeDe(source) });
const surLeSol = contexte.surLeSol(dessinDepuisEcran(vue, cadre, point.x, point.y));
if (entree || surLeSol !== etat.surLeSol) {
effets.push({ type: 'montrerFantome', participant: source.participant, depuis: placeDe(source), surLeSol });
suivant = { ...etat, surLeSol };
}
const transform = `translate(${point.x - cadre.gauche},${point.y - cadre.haut})`;
effets.push({ type: 'ecrireTransform', cible: 'fantome', transform });
}
@ -165,9 +169,11 @@ function achever(etat, point, contexte) {
* annuler {cause: 'pointercancel'|'echap'|'lecture'}.
* Contexte : controlerPlaces(table, places) → {refus, avertissements}, le contrôle de
* capacité d'origine 'poignee' sur la charge du geste ; deposer(source, point) → {resultat,
* place}, le dépôt d'une personne au point de dessin (depot.js).
* place}, le dépôt d'une personne au point de dessin (depot.js) ; surLeSol(point) → boolean,
* vrai quand le point de dessin tombe sur le sol, là où le dépôt retire ou refuse.
* Effets, chacun par son type : capturer {pointeur}, debuterGeste, finirGeste,
* ecrireTransform {cible, transform}, montrerFantome {participant, depuis},
* ecrireTransform {cible, transform}, montrerFantome {participant, depuis, surLeSol} — au
* franchissement du seuil, puis chaque fois que le pointeur passe sur le sol ou le quitte —,
* montrerPlaces {table, places, avertissements}, montrerRefus {place, refus} — refus null
* efface —, restaurer {cible}, poserVue {vue}, selectionner {place}, executer {commande,
* arguments, place, apres} — place, où poser un refus de la commande ; apres, la cible à
@ -240,27 +246,39 @@ const CLES_RESERVATION_TENUE = new Map([
['tous', 'plan.fantome.reservationTous'],
]);
// La même personne au-dessus du sol, par portée : le dépôt y retire une
// réservation « ce tour » et refuse une « tous les tours »
// (RESERVATION_TOUS_LES_TOURS, depot.js).
const CLES_RESERVATION_SUR_LE_SOL = new Map([
['tour', 'plan.fantome.retireReservationTour'],
['tous', 'plan.fantome.refuseTous'],
]);
/**
* La clé du libellé que le fantôme porte pendant le glissement d'une
* personne (§ 5.8, § 7.1) : sous la cible 'reservations', « pose une
* réservation » pour une personne que rien ne tient, sinon le déplacement
* de sa réservation et sa portée — « tous les tours » s'y dit ; « déplace
* dans le placement retenu » sous 'retenu' ; la vue seule sous une cible
* null. Une cible ou une portée hors du contrat lève : elle ne prend aucun
* libellé par défaut.
* personne (§ 5.8, § 7.1), qui dit ce que le relâcher au point courant
* modifie : sous la cible 'reservations', « pose une réservation » pour une
* personne que rien ne tient, sinon le déplacement de sa réservation et sa
* portée — « tous les tours » s'y dit — ; au-dessus du sol, la même
* réservation se dit retirée (« ce tour ») ou refusée (« tous les tours ») ;
* « déplace dans le placement retenu » sous 'retenu' ; la vue seule sous une
* cible null. Une cible, une portée ou un sol hors du contrat lève : il ne
* prend aucun libellé par défaut.
*
* @param {'reservations'|'retenu'|null} cible EtatPlaces.cible
* @param {'tous'|'tour'|null} [portee] la portée de la réservation qui tient
* la personne à sa chaise d'origine (reservationTenue de depot.js), ou null
* @param {boolean} [surLeSol] vrai quand le pointeur est au-dessus du sol
* @returns {string}
*/
export function cleFantome(cible, portee = null) {
export function cleFantome(cible, portee = null, surLeSol = false) {
if (!CLES_FANTOME.has(cible)) throw new TypeError(`cible du fantôme hors du contrat : ${JSON.stringify(cible)}`);
if (portee !== null && !CLES_RESERVATION_TENUE.has(portee)) {
throw new TypeError(`portée du fantôme hors du contrat : ${JSON.stringify(portee)}`);
}
if (cible === 'reservations' && portee !== null) return CLES_RESERVATION_TENUE.get(portee);
return CLES_FANTOME.get(cible);
if (typeof surLeSol !== 'boolean') throw new TypeError(`sol du fantôme hors du contrat : ${JSON.stringify(surLeSol)}`);
if (cible !== 'reservations' || portee === null) return CLES_FANTOME.get(cible);
return (surLeSol ? CLES_RESERVATION_SUR_LE_SOL : CLES_RESERVATION_TENUE).get(portee);
}
/**

View file

@ -30,13 +30,14 @@ const SOURCES = {
sol: { genre: 'sol', table: null, siege: null, participant: null },
};
// Un contexte dont le contrôle accepte tout, et dont le dépôt rend la
// commande donnée.
function contexte({ controlerPlaces = () => ({ refus: null, avertissements: [] }), depot = null } = {}) {
// Un contexte dont le contrôle accepte tout, dont le dépôt rend la commande
// donnée, et dont le sol est ce que dit surLeSol au point de dessin.
function contexte({ controlerPlaces = () => ({ refus: null, avertissements: [] }), depot = null, surLeSol = () => false } = {}) {
const deposes = [];
return {
deposes,
controlerPlaces,
surLeSol,
deposer(source, point) {
deposes.push({ source, point });
return depot ?? { resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1 } }, place: { table: 4, siege: 1 } };
@ -194,7 +195,7 @@ describe('geste : glisser une personne (§ 7.1, § 7.3, § 5.8)', () => {
test('le fantôme se montre au franchissement du seuil, suit le pointeur en px du <svg>, et le dépôt se demande au point de dessin', () => {
const ctx = contexte();
const { effets } = jouer([presser(SOURCES.personne), bouger(10, 0), bouger(50, 30), relacher(50, 30)], ctx);
assert.deepEqual(effets[1][0], { type: 'montrerFantome', participant: 11, depuis: { table: 3, siege: 2 } });
assert.deepEqual(effets[1][0], { type: 'montrerFantome', participant: 11, depuis: { table: 3, siege: 2 }, surLeSol: false });
assert.deepEqual(effets[1][1], { type: 'ecrireTransform', cible: 'fantome', transform: `translate(${DEPART.x + 10 - CADRE.gauche},${DEPART.y - CADRE.haut})` });
assert.equal(ctx.deposes.length, 1);
assert.deepEqual(ctx.deposes[0].point, dessinDepuisEcran(VUE, CADRE, DEPART.x + 50, DEPART.y + 30));
@ -219,6 +220,25 @@ describe('geste : glisser une personne (§ 7.1, § 7.3, § 5.8)', () => {
assert.equal(deType(tous, 'executer').length, 0);
});
test('le fantôme se redit quand le pointeur passe sur le sol ou le quitte, jugé au point de dessin, et seulement alors', () => {
// Le sol à droite de x = 450 px : le libellé change à son passage, dans
// un sens puis dans l'autre ; un mouvement qui reste du même côté ne le
// redit pas.
const points = [];
const surLeSol = (point) => {
points.push(point);
return point.x > dessinDepuisEcran(VUE, CADRE, 450, 0).x;
};
const { effets } = jouer([presser(SOURCES.personne), bouger(10, 0), bouger(20, 0), bouger(80, 0), bouger(90, 0), bouger(30, 0)], contexte({ surLeSol }));
const fantomes = effets.map((pas) => deType(pas, 'montrerFantome').map(({ surLeSol: sol }) => sol));
assert.deepEqual(fantomes, [[], [false], [], [true], [], [false]]);
assert.deepEqual(points[0], dessinDepuisEcran(VUE, CADRE, DEPART.x + 10, DEPART.y));
assert.equal(points.length, 5);
for (const pas of effets.slice(3)) {
for (const effet of deType(pas, 'montrerFantome')) assert.deepEqual(effet.depuis, { table: 3, siege: 2 });
}
});
test('un dépôt manqué (null) : la personne revient, rien d’autre', () => {
const ctx = contexte({ depot: { resultat: null, place: { table: 3, siege: 2 } } });
const { effets } = jouer([presser(SOURCES.personne), bouger(50, 30), relacher(50, 30)], ctx);
@ -348,6 +368,26 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
}
});
test('cleFantome : au-dessus du sol, une réservation « ce tour » se dit retirée, une « tous les tours » refusée (§ 5.8)', () => {
// Le dépôt sur le sol retire la première et refuse la seconde : le
// fantôme dit ce qui arrivera au relâcher, non ce qui arrive sur une
// chaise. Une personne que rien ne tient, et les autres cibles, gardent
// leur libellé.
assert.equal(cleFantome('reservations', 'tour', true), 'plan.fantome.retireReservationTour');
assert.equal(cleFantome('reservations', 'tous', true), 'plan.fantome.refuseTous');
assert.equal(cleFantome('reservations', 'tour', false), 'plan.fantome.reservationTour');
assert.equal(cleFantome('reservations', 'tous', false), 'plan.fantome.reservationTous');
assert.equal(cleFantome('reservations', null, true), 'plan.fantome.reservations');
assert.equal(cleFantome('retenu', null, true), 'plan.fantome.retenu');
assert.equal(cleFantome(null, 'tous', true), 'plan.fantome.vueSeule');
assert.match(LIBELLES.get('plan.fantome.retireReservationTour'), /retire/);
assert.match(LIBELLES.get('plan.fantome.refuseTous'), /refusé/);
assert.match(LIBELLES.get('plan.fantome.refuseTous'), /tous les tours/);
for (const sol of [0, 'oui', null]) {
assert.throws(() => cleFantome('reservations', 'tour', sol), { name: 'TypeError', message: /^sol du fantôme hors du contrat/ });
}
});
test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => {
for (const cible of ['reservation', 'Retenu', undefined, '', 0]) {
assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ });

View file

@ -26,6 +26,7 @@ import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage
import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { CONFLITS } from '../marques.js';
import { appartenanceDeCharge, nomDeCharge } from './vue_plan.js';
import {
ECART_ANNEAU,
ECART_DESIGNATION,
@ -115,11 +116,9 @@ const FIXTURE = new URL('../../../test/fixtures/plan/toutes_en_conflit.gtt.json'
// La petite démonstration livrée, relue en mémoire.
const petite = (cle = 'petite') => analyser(texteLivre(cle)).charge;
// nomDe sur les participants de la charge : prénom et nom, ou le nom seul.
const nomsDe = (charge) => {
const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`]));
return (id) => noms.get(id);
};
// nomDe sur les participants de la charge : le nom affiché, celui que le
// plan reçoit.
const nomsDe = nomDeCharge;
const options = (charge, autres = {}) => ({ k: 1, listes: true, miseEnEvidence: null, nomDe: nomsDe(charge), ...autres });
@ -761,7 +760,7 @@ describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', ()
});
});
// --- L'option poignee (I10) ------------------------------------------------------
// --- L'option poignee -----------------------------------------------------------
describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tiré, sans toucher l\u{2019}état (§ 7.1)', () => {
test('sans option, ou poignee null : chaque table à sa capacité', () => {
@ -819,7 +818,7 @@ describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tir
});
});
// --- La sélection et l'élément actif (I11) ---------------------------------------
// --- La sélection et l'élément actif ---------------------------------------------
describe('modeleDessin : les options selection et actif marquent leur chaise, ou leur table, et elle seule (§ 7.1, § 7.3)', () => {
// Les porteurs d'une marque : 'table:<id>' ou 'siege:<id>:<siège>'.
@ -880,36 +879,40 @@ describe('modeleDessin : les options selection et actif marquent leur chaise, ou
});
});
// --- Le détail d'une table en conflit (I11) --------------------------------------
// --- Le détail d'une table en conflit --------------------------------------------
// L'appartenance de chaque participant de la charge, par identifiant.
const appartenancesDe = (charge) => {
const appartenances = new Map(charge.participants.map(({ id, appartenance }) => [id, appartenance]));
return (id) => appartenances.get(id) ?? null;
};
// L'appartenance de chaque participant de la charge, par identifiant : celle
// que l'infobulle reçoit.
const appartenancesDe = appartenanceDeCharge;
describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3)', () => {
// Les lignes attendues d'une table : séparer les appartenances, une ligne
// par appartenance, dans l'ordre de leur première paire ; les autres
// règles, une ligne chacune.
function lignesAttendues(table, nomDe, appartenanceDe) {
const lignes = [];
// Ce que le détail d'une table doit tenir, jugé sans en refaire le calcul :
// les règles dans l'ordre du moteur, une ligne par règle hors séparer, et,
// pour séparer, des lignes d'appartenances deux à deux distinctes dont la
// suite des groupes, ligne après ligne, est une permutation stable des
// paires du moteur, chaque paire sous l'appartenance de ses deux personnes.
function verifierDetail(detail, table, nomDe, appartenanceDe) {
assert.equal(detail.numero, table.numero);
assert.equal(detail.nombre, table.conflits.nombre);
const regles = [...new Set(detail.lignes.map(({ regle }) => regle))];
assert.deepEqual(regles, table.conflits.regles.map(({ regle }) => regle));
for (const regle of table.conflits.regles) {
const cle = `plan.infobulle.${regle.regle}`;
const lignes = detail.lignes.filter((ligne) => ligne.regle === regle.regle);
for (const ligne of lignes) assert.equal(ligne.cle, `plan.infobulle.${regle.regle}`);
const groupes = (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe));
if (regle.regle !== 'separerAppartenances') {
const groupes = (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe));
lignes.push({ regle: regle.regle, cle, appartenance: null, groupes });
assert.deepEqual(lignes, [{ regle: regle.regle, cle: lignes[0].cle, appartenance: null, groupes }]);
continue;
}
for (const [a, b] of regle.paires) {
const appartenance = appartenanceDe(a);
assert.equal(appartenanceDe(b), appartenance);
const ligne = lignes.find((l) => l.regle === regle.regle && l.appartenance === appartenance);
if (ligne === undefined) lignes.push({ regle: regle.regle, cle, appartenance, groupes: [[nomDe(a), nomDe(b)]] });
else ligne.groupes.push([nomDe(a), nomDe(b)]);
const appartenances = lignes.map(({ appartenance }) => appartenance);
assert.deepEqual(appartenances, [...new Set(regle.paires.map(([a]) => appartenanceDe(a)))]);
for (const ligne of lignes) {
const paires = regle.paires.filter(([a]) => appartenanceDe(a) === ligne.appartenance);
for (const [a, b] of paires) assert.equal(appartenanceDe(b), ligne.appartenance);
assert.deepEqual(ligne.groupes, paires.map((paire) => paire.map(nomDe)));
}
assert.equal(lignes.reduce((n, ligne) => n + ligne.groupes.length, 0), groupes.length);
}
return lignes;
}
test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur ; chaque paire réunie nomme son appartenance', () => {
@ -926,7 +929,7 @@ describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3
continue;
}
vus += 1;
assert.deepEqual(detail, { numero: table.numero, nombre: table.conflits.nombre, lignes: lignesAttendues(table, nomDe, appartenanceDe) });
verifierDetail(detail, table, nomDe, appartenanceDe);
for (const ligne of detail.lignes) {
assert.ok(typeof t(ligne.cle) === 'string' && t(ligne.cle).length > 0, ligne.cle);
if (ligne.regle === 'separerAppartenances') {
@ -1054,7 +1057,7 @@ describe('legendeConflits : les règles actives, leur marque et leur libellé (
});
});
// --- Le texte entier d'une ligne, et les actions des panneaux (I11) --------------
// --- Le texte entier d'une ligne, et les actions des panneaux --------------------
describe('texteLigne : la ligne d’une chaise en un texte, nom accessible du siège', () => {
const nomDe = (id) => ({ 1: 'Iris Ombrelle', 2: 'Théo Pervenche' })[id];
@ -1207,4 +1210,10 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');
assert.equal(typeof t('plan.infobulle.finAppartenance'), 'string');
});
test('plan.infobulle.<règle> : chaque ligne de l’infobulle s’ouvre sur une capitale, la même casse pour toutes', () => {
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) {
assert.match(t(`plan.infobulle.${regle}`), /^\p{Lu}/u, regle);
}
});
});

View file

@ -30,10 +30,15 @@ import { TABLES_INTERFACE } from '../libelles.js';
import Champ from './Champ.svelte';
import Commande from './Commande.svelte';
import Confirmation from './Confirmation.svelte';
import Infobulle from './Infobulle.svelte';
import { DELAI_CONFIRMATION_MS } from './commande.js';
import { MARGE_INFOBULLE_PX, placerInfobulle } from './infobulle.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Les deux issues d'une fenêtre à confirmer, par leurs désignations.
const ISSUES = '[data-commande="renoncer"], [data-commande="confirmer"]';
const EVENEMENT = 'Soirée des Merles';
const NOM = 'Ombrelle';
@ -135,22 +140,12 @@ async function cliquerAilleurs() {
// Un double-clic réel au centre de l'élément : deux appuis, le second
// annoncé par Chromium comme le deuxième clic d'une série (clickCount 2),
// si bien que son événement click porte detail 2. Les coordonnées du cadre
// de l'épreuve passent dans la fenêtre de la page qui le porte, d'un cadre
// à son parent, comme dans test/navigateur/entrees.js.
// si bien que son événement click porte detail 2.
async function doubleCliquer(element) {
let { x, y } = centre(element);
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const cadre = fenetre.frameElement;
const boite = cadre.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(cadre);
const echelle = cadre.offsetWidth > 0 ? boite.width / cadre.offsetWidth : 1;
x = boite.left + (cadre.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (cadre.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
const { x, y } = centre(element);
for (const clickCount of [1, 2]) {
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount });
await pointer('presser', x, y, { clickCount });
await pointer('relacher', x, y, { clickCount });
}
await tick();
}
@ -893,8 +888,8 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const message = fenetre.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(visible(message)).toBe(true);
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(montage.seance.etat().charge.participants).toHaveLength(1);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
expect(appels).toEqual(['confirmer', 'annuler']);
@ -940,8 +935,8 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.getAttribute('aria-busy')).toBe('true');
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(montage.seance.etat().charge.participants).toHaveLength(1);
}
@ -976,8 +971,8 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(fenetre.getAttribute('aria-busy')).toBeNull();
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(fenetre.querySelectorAll(ISSUES)).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll(ISSUES)) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(appels).toEqual(['confirmer']);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
expect(appels).toEqual(['confirmer', 'annuler']);
@ -1086,7 +1081,7 @@ describe('commande (§ 2.1)', () => {
liberer();
});
test('une exécution sans promesse rend aussitôt la commande : deux clics de suite, deux exécutions', async () => {
test('une exécution sans promesse rend aussitôt la commande : deux click() synthétiques, chacun hors d’une série de clics, deux exécutions', async () => {
const montage = await preparer();
let appels = 0;
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => void (appels += 1) }, montage.contexte);
@ -1106,3 +1101,87 @@ describe('commande (§ 2.1)', () => {
expect(bouton.getAttribute('type')).toBe('button');
});
});
describe('infobulle (§ 12.7, § 14.5)', () => {
// Un terme posé en position fixe au point (gauche, haut) de la fenêtre,
// et l'infobulle ouverte sur lui ; fermer est compté.
function ouvrirSur({ gauche, haut, texte = 'terme' }) {
const ancre = document.createElement('span');
ancre.textContent = texte;
Object.assign(ancre.style, { position: 'fixed', left: `${gauche}px`, top: `${haut}px` });
document.body.append(ancre);
const fermetures = [];
const cible = monter(
Infobulle,
{ id: 'infobulle-epreuve', ouverte: { ancre, cle: 'propositions.explication.rencontresTous', details: {} }, fermer: () => fermetures.push(1) },
new Map([['t', t]]),
);
const bulle = cible.querySelector('[role="tooltip"]');
return { ancre, bulle, fermetures, retirer: () => ancre.remove() };
}
// La place que placerInfobulle donne à l'infobulle, sur les rectangles du rendu.
function placeAttendue(ancre, bulle) {
const a = ancre.getBoundingClientRect();
const b = bulle.getBoundingClientRect();
const fenetre = document.documentElement;
return placerInfobulle(
{ gauche: a.left, haut: a.top, droite: a.right, bas: a.bottom },
{ largeur: b.width, hauteur: b.height },
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight },
);
}
// La place écrite dans le style, que le navigateur sérialise au millième
// de px : elle se compare à la place attendue à ce grain.
const placeLue = (bulle) => ({ gauche: parseFloat(bulle.style.left), haut: parseFloat(bulle.style.top) });
function exigerPlace(bulle, attendue) {
const lue = placeLue(bulle);
expect(lue.gauche).toBeCloseTo(attendue.gauche, 2);
expect(lue.haut).toBeCloseTo(attendue.haut, 2);
}
test('un terme au coin bas droit de la fenêtre : l’infobulle reste dans le cadre, à la marge de chaque bord', async () => {
const { ancre, bulle, retirer } = ouvrirSur({ gauche: innerWidth - 20, haut: innerHeight - 20 });
try {
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
const boite = bulle.getBoundingClientRect();
const fenetre = document.documentElement;
expect(boite.right).toBeLessThanOrEqual(fenetre.clientWidth - MARGE_INFOBULLE_PX + 0.5);
expect(boite.left).toBeGreaterThanOrEqual(MARGE_INFOBULLE_PX - 0.5);
expect(boite.bottom).toBeLessThanOrEqual(fenetre.clientHeight - MARGE_INFOBULLE_PX + 0.5);
expect(boite.top).toBeGreaterThanOrEqual(MARGE_INFOBULLE_PX - 0.5);
// Au-dessus du terme, qu'elle ne couvre pas.
expect(boite.bottom).toBeLessThanOrEqual(ancre.getBoundingClientRect().top);
} finally {
retirer();
}
});
test('la fenêtre redimensionnée : l’infobulle se replace sur les rectangles relus', async () => {
const { ancre, bulle, retirer } = ouvrirSur({ gauche: 40, haut: 40 });
try {
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
exigerPlace(bulle, placeAttendue(ancre, bulle));
// Le terme suit le bas de la fenêtre ; seul resize le dit à l'infobulle.
Object.assign(ancre.style, { top: `${innerHeight - 30}px` });
const avant = placeLue(bulle);
window.dispatchEvent(new Event('resize'));
flushSync();
const attendue = placeAttendue(ancre, bulle);
expect(Math.abs(attendue.haut - avant.haut)).toBeGreaterThan(1);
exigerPlace(bulle, attendue);
} finally {
retirer();
}
});
test('l’ancre retirée du document : le prochain replacement ferme l’infobulle, une fois', async () => {
const { bulle, fermetures, retirer } = ouvrirSur({ gauche: 40, haut: 40 });
await vi.waitFor(() => expect(bulle.style.left).not.toBe(''));
expect(fermetures).toEqual([]);
retirer();
window.dispatchEvent(new Event('resize'));
flushSync();
expect(fermetures).toEqual([1]);
});
});

View file

@ -38,6 +38,7 @@ import { centre } from '../../../test/navigateur/ecrans.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { evaluation, tableau } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
@ -89,7 +90,7 @@ function avecPlans(charge, plans) {
// Trois plans de la petite démonstration au conflit inévitable, dans l'ordre
// des identifiants : (4, 1), (4, 2), (6, 6) collisions (cumulées,
// distinctes), au même plus grand écart au plafond a priori (A6).
// distinctes), au même plus grand écart au plafond a priori.
const PLANS_CONFLIT = [
[
[[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]],
@ -265,7 +266,7 @@ function exigerChiffres(tr, attendue) {
// --- Le tableau classé (§ 5.4, § 5.7, § 19.3 capture 11) --------------------------
describe('le tableau classé', () => {
test('les trois plans d’A6 : (6, 6), (4, 2), (4, 1) ; la phrase cite les cinq critères puis l’identifiant ; chaque chiffre est celui du moteur', async () => {
test('les trois plans de PLANS_CONFLIT : (6, 6), (4, 2), (4, 1) ; la phrase cite les cinq critères puis l’identifiant ; chaque chiffre est celui du moteur', async () => {
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
const m = await ouvrir(chargeLue);
expect(ids(m)).toEqual([3, 2, 1]);
@ -382,6 +383,36 @@ describe('retenir, voir sur le plan', () => {
expect(occupantsDuPlacement(charge(m), charge(m).retenu)).not.toEqual(occupantsDuPlacement(charge(m), proposition(1)));
});
test('la ligne du retenu montre ses violations ; la colonne rang se lit, « Retenu » puis 1 à n dans l’ordre', async () => {
const base = executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 });
// Une réservation que le retenu n'honore pas : la personne 1, assise
// ailleurs qu'à la première table.
base.reservations = [{ participant: 1, table: base.tables[0].id, siege: 1, portee: 'tous', tour: null }];
const m = await ouvrir(base);
const vue = tableau(evaluation(charge(m)).table);
expect(vue.retenu.violations.length).toBeGreaterThan(0);
const cellule = ligneRetenu(m).querySelector('td[data-colonne="proposition"]');
for (const violation of vue.retenu.violations) {
expect(texteDe(cellule)).toContain(t(violation.cle, violation.details).replace(/\s+/g, ' ').trim());
}
expect(texteDe(ligneRetenu(m).querySelector('[data-colonne="rang"]'))).toBe(t('propositions.retenu.titre'));
expect(lignes(m).map((tr) => texteDe(tr.querySelector('[data-colonne="rang"]')))).toEqual(vue.lignes.map((_, rang) => format.nombre(rang + 1)));
expect(ids(m)).toEqual(vue.lignes.map(({ id }) => id));
});
test('« Voir » de la ligne du retenu montre le retenu sur le plan, après une proposition demandée, sans écriture', async () => {
const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }));
m.ui.montrerProposition(1);
const ecritures = m.trace.length;
const voir = commande(ligneRetenu(m), 'voir');
expect(texteDe(voir)).toBe(t('propositions.commande.voir'));
voir.click();
await attendre(() => expect(m.racine.querySelector('[data-section="plan"]')).not.toBeNull());
expect(m.ui.etat().proposition).toBe(null);
await exigerPlanMontre(m, charge(m).retenu);
expect(m.trace.length).toBe(ecritures);
});
test('les indicateurs suivent l’ajustement : la ligne du retenu égale sa mesure, celle de la proposition 2 n’a pas changé', async () => {
const m = await ouvrir(chargeSix());
commande(ligne(m, 2), 'retenir').click();
@ -672,6 +703,36 @@ describe('la phrase de l’ordre désigne les colonnes (§ 5.7)', () => {
await attendre(() => expect(surlignees(m)).toEqual([]));
});
test('le survol et le focus tiennent chacun leur nom : quitter le survolé rend la colonne du focalisé, et le nom actif se souligne plein', async () => {
const m = await ouvrir(chargeRetenue());
const nom = (critere) => section(m).querySelector(`[data-ordre] [data-critere="${critere}"]`);
const [[a, colonneA], [b, colonneB]] = COLONNE_DESIGNEE;
// Focus sur B, puis survol de A et sortie : B reste surlignée.
nom(b).focus();
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, colonneB)));
expect(getComputedStyle(nom(b)).textDecorationStyle).toBe('solid');
let point = await centre(nom(a));
await pointer('bouger', point.x, point.y);
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, colonneA)));
expect(getComputedStyle(nom(a)).textDecorationStyle).toBe('solid');
expect(getComputedStyle(nom(b)).textDecorationStyle).toBe('dotted');
await pointer('bouger', 0, 0);
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, colonneB)));
// Survol de A, puis Tab vers B, puis sortie de A : B reste surlignée.
nom(b).blur();
await attendre(() => expect(surlignees(m)).toEqual([]));
point = await centre(nom(a));
await pointer('bouger', point.x, point.y);
nom(a).focus();
await touche('Tab');
await attendre(() => expect(document.activeElement).toBe(nom(b)));
await pointer('bouger', 0, 0);
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, colonneB)));
// Le focus perdu, plus rien.
nom(b).blur();
await attendre(() => expect(surlignees(m)).toEqual([]));
});
test('le texte de la phrase reste celui de l’ordre, mot pour mot, et le surlignage ne change aucune couleur de texte', async () => {
const m = await ouvrir(chargeRetenue());
// Le texte brut, espaces compris : aucun blanc ne s'ajoute entre deux
@ -784,6 +845,24 @@ describe('les infobulles d’explication : chaque nom de critère, chaque en-tê
await attendre(() => expect(surlignees(m)).toEqual([]));
});
test('quitter un terme ne ferme que sa propre explication : survol d’un nom, focus d’un en-tête, puis le pointeur sort du nom — l’en-tête garde la sienne', async () => {
const m = await ouvrir(chargeRetenue());
const [[critere, nomColonne]] = COLONNE_DESIGNEE;
const nom = section(m).querySelector(`[data-ordre] [data-critere="${critere}"]`);
const th = enTetes(m).find((candidat) => candidat.dataset.colonne !== nomColonne);
const { x, y } = await centre(nom);
await pointer('bouger', x, y);
await attendre(() => exigerInfobulle(nom, explicationDeColonne(nomColonne)));
th.focus();
await attendre(() => exigerInfobulle(th, explicationDeColonne(th.dataset.colonne)));
await pointer('bouger', 0, 0);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
flushSync();
exigerInfobulle(th, explicationDeColonne(th.dataset.colonne));
th.blur();
await attendre(() => exigerFermee());
});
test('survoler chaque en-tête de colonne ouvre son explication, dans la fenêtre, hors de l’en-tête ; le quitter la ferme', async () => {
const m = await ouvrir(chargeRetenue());
const ths = enTetes(m);

View file

@ -9,7 +9,7 @@
// « Retenir » et « Effacer », le refus posé à côté du bouton.
//
// La table d'évaluation se lit dans la charge de l'état observable, par
// evaluation (modele.js) : une seule mesure, celle d'evaluer (A6), refaite
// evaluation (modele.js) : une seule mesure, celle d'evaluer (evaluation.js), refaite
// à chaque charge écrite ; les indicateurs du retenu suivent donc
// l'ajustement (§ 2.1 étape 11).
//
@ -27,8 +27,10 @@
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ;
// chaque nom de critère, et l'identifiant croissant, est un élément
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne
// cette colonne dans le tableau ; le quitter — pointeur sorti, focus perdu —
// efface le surlignage qu'il a posé, et laisse celui d'un autre nom.
// cette colonne dans le tableau. Le survol et le focus se tiennent à part,
// et surlignee (modele.js) choisit : le nom survolé, sinon celui qui a le
// focus. Quitter un nom — pointeur sorti, focus perdu — n'efface que l'état
// qu'il tenait, et laisse l'autre.
//
// Chaque nom de critère et chaque en-tête de colonne s'expliquent dans
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol
@ -50,7 +52,7 @@
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import TableauPropositions from './TableauPropositions.svelte';
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, tableau } from './modele.js';
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, surlignee as colonneSurlignee, tableau } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
@ -90,13 +92,11 @@
aConfirmer = null;
}
// La colonne surlignée, celle du dernier nom survolé ou focalisé ; null
// sans surlignage.
let surlignee = $state(null);
const surligner = (colonne) => (surlignee = colonne);
function quitter(colonne) {
if (surlignee === colonne) surlignee = null;
}
// Les colonnes du nom survolé et du nom focalisé, null sans lui ; la
// colonne surlignée s'en déduit.
let survolee = $state(null);
let focalisee = $state(null);
const surlignee = $derived(colonneSurlignee(survolee, focalisee));
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation, 'critere:<critère>' pour un nom de la phrase ou
@ -116,12 +116,15 @@
// L'aria-describedby d'un terme : l'infobulle quand elle l'explique.
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
function entrer(evenement, { critere, colonne }) {
surligner(colonne);
// L'entrée sur un nom, par le pointeur ou par le focus, et sa sortie.
function entrer(evenement, { critere, colonne }, parFocus) {
if (parFocus) focalisee = colonne;
else survolee = colonne;
expliquer(evenement, `critere:${critere}`, colonne);
}
function sortir({ critere, colonne }) {
quitter(colonne);
function sortir({ critere }, parFocus) {
if (parFocus) focalisee = null;
else survolee = null;
taire(`critere:${critere}`);
}
@ -159,10 +162,10 @@
data-critere={morceau.critere}
tabindex="0"
aria-describedby={decrit(`critere:${morceau.critere}`)}
onmouseenter={(evenement) => entrer(evenement, morceau)}
onmouseleave={() => sortir(morceau)}
onfocus={(evenement) => entrer(evenement, morceau)}
onblur={() => sortir(morceau)}>{t(morceau.cle, morceau.details)}</span
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
onmouseleave={() => sortir(morceau, false)}
onfocus={(evenement) => entrer(evenement, morceau, true)}
onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)}</span
>
{/if}
{/each}

View file

@ -8,6 +8,8 @@
// table d'évaluation ; puis celles hors classement, « non comparables »,
// avec leurs raisons et les personnes qu'elles nomment. Chaque cellule
// s'écrit par texteCellule (modele.js) ; aucune valeur ne se calcule ici.
// Les violations d'une ligne comparable — retenu compris — se lisent sous
// son identifiant.
//
// vue : ce que rend tableau(), libellés et étendues compris. nommer(id) :
// le nom affiché d'une personne, null pour id null ou pour une personne que
@ -75,17 +77,21 @@
{/if}
{/snippet}
{#snippet violations(ligne)}
{#if ligne.comparable}
{#each ligne.violations as violation, rang (rang)}
<p>{t(violation.cle, violation.details)}</p>
{/each}
{/if}
{/snippet}
{#snippet identite(ligne)}
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(ligne.id)}</span>
{#if ligne.retenue}
<span class="marque">{t('propositions.marque.retenue')}</span>
{/if}
{#if ligne.comparable}
{#each ligne.violations as violation, rang (rang)}
<p>{t(violation.cle, violation.details)}</p>
{/each}
{/if}
{@render violations(ligne)}
</td>
{/snippet}
@ -129,6 +135,7 @@
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(vue.retenu.id)}</span>
<p>{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}</p>
{@render violations(vue.retenu)}
</td>
{@render mesures(vue.retenu)}
<td data-colonne="commandes">

View file

@ -5,7 +5,7 @@
// § 12.6, § 12.9), pur : SectionPropositions.svelte et
// TableauPropositions.svelte le câblent.
//
// Le tableau lit la table d'évaluation d'evaluer (A6), la seule mesure : chaque
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule mesure : chaque
// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise
// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose.
// Des rencontres, il ne porte que le minimum, sur les trois populations du
@ -225,7 +225,7 @@ function ordre({ criteresAppliques, critereSaute }) {
}
/**
* Le tableau d'une table d'évaluation (A6).
* Le tableau d'une table d'évaluation (evaluer, evaluation.js).
*
* @param {import('../../application/evaluation.js').TableEvaluation} tableEvaluation
* @param {{ajuste?: boolean}} [options] ajuste : le retenu ne porte plus le
@ -253,9 +253,7 @@ export function tableau(tableEvaluation, { ajuste = false } = {}) {
const idRetenu = tableEvaluation.retenu?.id ?? null;
// Les comparables ouvrent la liste, dans l'ordre du classement : leur
// place y est leur rang.
const toutes = tableEvaluation.propositions.map((ligne, place) =>
ligneDe(ligne, tableEvaluation, idRetenu, ligne.statut === 'comparable' ? place + 1 : null),
);
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, place + 1));
return {
ordre: ordre(tableEvaluation.classement),
colonnes: COLONNES,
@ -273,6 +271,20 @@ function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) {
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } };
}
/**
* La colonne que la phrase de l'ordre surligne (§ 5.7), de deux états
* tenus à part : celle du nom survolé, sinon celle du nom qui a le focus,
* sinon null. Quitter l'un rend l'autre : un survol qui passe ne retire pas
* le surlignage du focus.
*
* @param {string|null} survolee la colonne du nom sous le pointeur
* @param {string|null} focalisee la colonne du nom qui a le focus
* @returns {string|null}
*/
export function surlignee(survolee, focalisee) {
return survolee ?? focalisee;
}
/**
* Le texte d'une cellule : t(cle) quand elle porte une clé, sinon son nombre
* mis en forme.

View file

@ -37,6 +37,7 @@ import {
explicationDeColonne,
nomDe,
retenuAjuste,
surlignee,
tableau,
texteCellule,
} from './modele.js';
@ -116,7 +117,7 @@ const DEUX_COLLEGUES = [
// Trois plans de la petite démonstration au conflit inévitable, nommés par
// leurs collisions (cumulées, distinctes), au même plus grand écart au
// plafond a priori : ceux de l'épreuve de la table d'évaluation (A6).
// plafond a priori : ceux de l'épreuve de la table d'évaluation.
const PLANS_CONFLIT = [
{
nom: '(4, 1)',
@ -381,7 +382,7 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
// Les morceaux qui désignent une colonne : leur critère et leur colonne.
const designes = (morceaux) => morceaux.filter(({ colonne }) => colonne !== null).map(({ critere, colonne }) => [critere, colonne]);
test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant, mot pour mot ; les trois plans d’A6 : (6, 6), (4, 2), (4, 1)', () => {
test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant, mot pour mot ; les trois plans de PLANS_CONFLIT : (6, 6), (4, 2), (4, 1)', () => {
const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
const vue = tableau(evaluer(charge));
assert.deepEqual(vue.lignes.map(({ id }) => id), [3, 2, 1]);
@ -759,3 +760,12 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
assert.throws(() => explicationDeColonne('moyenneRencontres'), TypeError);
});
});
describe('surlignee : la colonne surlignée, du survol et du focus (§ 5.7)', () => {
test('le nom survolé l’emporte ; sans survol, celui qui a le focus ; ni l’un ni l’autre, rien', () => {
assert.equal(surlignee('excedentCollisions', 'proposition'), 'excedentCollisions');
assert.equal(surlignee(null, 'proposition'), 'proposition');
assert.equal(surlignee('excedentCollisions', null), 'excedentCollisions');
assert.equal(surlignee(null, null), null);
});
});

View file

@ -3,11 +3,11 @@
// Le modèle de la section des tables et des tours (§ 4, § 6, § 2.1 étapes 4
// et 5), pur : les composants de tables/ le câblent. Il lit la charge et les
// bilans d'A4 — bilanChangementDefaut, bilanTours, bilanSuppressionTable —
// et rend des clés de libellé et leurs détails, jamais un texte : la
// traduction les compose. Il ne refait aucun contrôle : un refus vient d'un
// bilan ou de la commande elle-même, essayée sur la charge sans rien écrire.
// Aucune fonction ne modifie ce qu'elle reçoit.
// bilans de src/application/bilans.js — bilanChangementDefaut, bilanTours,
// bilanSuppressionTable — et rend des clés de libellé et leurs détails,
// jamais un texte : la traduction les compose. Il ne refait aucun contrôle :
// un refus vient d'un bilan ou de la commande elle-même, essayée sur la
// charge sans rien écrire. Aucune fonction ne modifie ce qu'elle reçoit.
import { appliquerCommande } from '../../application/commandes.js';
import { nomAffiche } from '../../application/personnes.js';
@ -130,11 +130,12 @@ export function surchargesPerdues(charge) {
}
/**
* Vrai quand le changement de avant à apres tours détruit de la saisie :
* une réservation « tour désigné » au-delà, ou, pour une baisse, les tours
* retirés d'un placement engendré (§ 2.1 étape 5). Une hausse ne détruit
* rien : les placements qu'elle laisse en dérive restent entiers, et la
* séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
* Vrai quand le changement de avant à apres tours s'annonce avant d'être
* appliqué (§ 2.1 étape 5) : il détruit une réservation « tour désigné »
* au-delà, ou, pour une baisse, il laisse en dérive un placement engendré,
* entier mais qui porte des tours que l'événement n'a plus. Une hausse ne
* détruit rien : les placements qu'elle laisse en dérive restent entiers,
* et la séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
*
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @param {number} avant le nombre de tours enregistré

View file

@ -515,14 +515,15 @@ export function releverMarques(racine) {
fautes.push(`${situer(element)} : marque hors du registre MARQUES`);
continue;
}
vues.add(nom);
for (const a of attenuations(element)) fautesAttenuation.push(`${situer(element)} : ${designation(a)} ${a.propriete}: ${a.valeur}`);
// Sous une fenêtre modale, la page est inerte et le test d'atteinte
// rencontre la fenêtre d'abord : une marque HTML de la page ne s'y
// mesure pas, comme ses textes, et se mesure sur les écrans sans
// fenêtre. Le dessin SVG, lu par sa géométrie, se mesure partout.
// fenêtre. Le dessin SVG, lu par sa géométrie, se mesure partout. Une
// marque ne compte comme vue que là où elle se mesure.
const modale = document.querySelector('dialog:modal');
if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue;
vues.add(nom);
amener(element);
const jeton = couleurDeJeton(entree.jeton);
try {
@ -822,6 +823,25 @@ export function fautesBandeau(racine, mode, { version = VERSION.affichee, proven
return fautes;
}
// Les éléments HTML de flux qu'un contenu de phrase ne peut porter, et les
// éléments dont le contenu est de phrase seulement.
const BLOCS = 'div, p, section, article, aside, nav, header, footer, form, fieldset, ul, ol, dl, table, figure, blockquote, pre, h1, h2, h3, h4, h5, h6';
const PHRASES = 'span, button, label, p, h1, h2, h3, h4, h5, h6';
/** Fautes d'imbrication sous racine : un bloc porté par un élément de
* phrase — un <div> dans un <span> —, que l'analyseur HTML déplacerait ou
* que le validateur refuse. L'espace de noms SVG n'est pas lu.
* → { fautes, lus }, lus le nombre de blocs lus. */
export function fautesImbrication(racine) {
const fautes = [];
const blocs = [...racine.querySelectorAll(BLOCS)].filter((element) => element.namespaceURI === 'http://www.w3.org/1999/xhtml');
for (const bloc of blocs) {
const parent = bloc.parentElement?.closest(PHRASES);
if (parent !== null && parent !== undefined && racine.contains(parent)) fautes.push(`${designation(bloc)} dans ${designation(parent)}`);
}
return { fautes, lus: blocs.length };
}
/** Fautes des formulaires sous racine (§ 2, § 14.10) : un <form> sans
* novalidate, un input[type=file] rendu. → { fautes, formulaires },
* formulaires le nombre de <form> lus. */
@ -879,6 +899,7 @@ beforeAll(async () => {
releve.emoji = fautesEmoji(racine, { titre: document.title });
releve.bandeau = fautesBandeau(racine, ecran.mode);
releve.formulaires = fautesFormulaires(racine);
releve.imbrication = fautesImbrication(racine);
releve.refus = releverRefus(racine).map((r) => ({ ...r, restes: horsMarques(r.texte).filter((x) => !estDonnee(x, connues)) }));
releve.etats = releverEtats(m);
releve.listes = releverListes(racine);
@ -938,6 +959,22 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautes).toEqual(['p : « Bonjour »', 'p : « Table 3 »', 'button[aria-label] : « Fermer »']);
});
test('imbrication : un bloc dans un <span>, un <p> dans un <p> par le DOM, une liste dans un <button> sont relevés ; un <span> dans un <div> et le SVG passent', () => {
const { fautes, lus } = avecFragment('<div><span>a</span></div><span class="x"><div>b</div></span><svg><g><text>c</text></g></svg>', (c) => {
const p = document.createElement('p');
p.append(document.createElement('p'));
const bouton = document.createElement('button');
bouton.append(document.createElement('ul'));
c.append(p, bouton);
return fautesImbrication(c);
});
expect(lus).toBe(5);
expect(fautes).toHaveLength(3);
expect(fautes[0]).toMatch(/^div.* dans span/);
expect(fautes[1]).toMatch(/^p.* dans p/);
expect(fautes[2]).toMatch(/^ul.* dans button/);
});
test('emoji : un pictogramme dans un texte est relevé', () => {
const { fautes, lus } = avecFragment(`<p>${OUVERTURE}Prêt${FERMETURE} \u{1F642}</p><p>${OUVERTURE}2 + 2${FERMETURE}</p>`, (c) => fautesEmoji(c));
expect(lus).toBe(2);
@ -969,6 +1006,23 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautes[2]).toContain('1.00:1');
});
test('marques sous une fenêtre modale : une marque HTML de la page, qui ne s’y mesure pas, ne compte pas comme vue ; une marque dans la fenêtre, si', () => {
const { vues } = avecFragment(
'<p data-marque="gel" style="border:2px solid var(--couleur-gel)">dehors</p>' +
'<dialog><p data-marque="selection" style="border:2px solid var(--couleur-selection)">dedans</p></dialog>',
(c) => {
const fenetre = c.querySelector('dialog');
fenetre.showModal();
try {
return releverMarques(c);
} finally {
fenetre.close();
}
},
);
expect(vues).toEqual(['selection']);
});
test('teintes réservées : un texte peint d’une teinte de conflit, et la trame d’une règle peinte de la teinte d’une autre, sont relevés ; une trame de sa propre teinte passe', () => {
const { fautes, lus } = avecFragment(
'<p style="color:var(--couleur-conflit-voisins)">alerte</p>' +
@ -1239,6 +1293,11 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
aucuneFaute(atteints().flatMap((r) => r.bandeau.map((f) => `${r.nom} — ${f}`)), 'bandeau');
});
test('imbrication : aucun bloc dans un élément de phrase', () => {
expect(atteints().reduce((n, r) => n + r.imbrication.lus, 0), 'blocs lus').toBeGreaterThan(0);
aucuneFaute([...new Set(atteints().flatMap((r) => r.imbrication.fautes.map((f) => `${r.nom} — ${f}`)))], 'imbrication');
});
test('formulaires : chaque <form> porte novalidate, aucun input[type=file] visible ; la page se déclare en français', () => {
expect(atteints().reduce((n, r) => n + r.formulaires.formulaires, 0), '<form> lus').toBeGreaterThan(0);
const fautes = [...new Set(atteints().flatMap((r) => r.formulaires.fautes.map((f) => `${r.nom} — ${f}`)))];

View file

@ -24,8 +24,7 @@
import { mount } from 'svelte';
import { creerExecuteurTravailleur } from './application/executeur_travailleur.js';
import { creerHorlogeSysteme } from './application/horloge.js';
import { CATALOGUE } from './demo/catalogue.js';
import { LIVREES } from './demo/livrees.js';
import { demonstrationsLivrees } from './demo/livrees.js';
import App from './interface/App.svelte';
import Travailleur from './interface/generation/travailleur.js?worker&inline';
import { FORMAT } from './stockage/document.js';
@ -41,14 +40,6 @@ const pressePapiers = sousElectron
? { copier: async (texte) => void (await pont.copier(texte)) }
: { copier: (texte) => navigator.clipboard.writeText(texte) };
// Chaque démonstration livrée, nommée par l'entrée de même clé du catalogue,
// dans l'ordre du catalogue.
const demonstrations = LIVREES.map(({ cle, texte }) => ({
cle,
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
texte,
}));
mount(App, {
target: document.body,
props: {
@ -61,7 +52,7 @@ mount(App, {
identifiant: () => crypto.randomUUID(),
langue: 'fr',
provenance: import.meta.env.MODE,
demonstrations,
demonstrations: demonstrationsLivrees(),
formatCourant: FORMAT,
produitVersion: VERSION.affichee,
versionWindows: VERSION.windows,

View file

@ -35,6 +35,8 @@ import { isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CATALOGUE } from '../src/demo/catalogue.js';
import { LIVREES, demonstrationsLivrees } from '../src/demo/livrees.js';
import configuration from '../vitest.config.js';
import { blocsScript, chargements, scriptsDe } from './chargements.js';
import { describe, test } from './lanceur.js';
@ -508,9 +510,10 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce
// qui les commande. La géométrie est une couche à part, plus bas que le
// moteur : des fonctions pures du plan, qui n'importent ni le moteur, ni
// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et écrit par le système de fichiers qu'il
// reçoit ; seules ses deux implémentations de plateforme touchent le
// navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron.
// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et
// écrit par le système de fichiers qu'il reçoit ; seules ses deux
// implémentations de plateforme touchent le navigateur, par l'OPFS sous web
// et par le pont window.gtt sous Electron.
// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur,
// le stockage, le CSV et les systèmes de fichiers des plateformes, sous
// l'interface. L'interface n'importe que svelte, ses propres modules,
@ -2520,7 +2523,8 @@ const COMMANDES_SURVEILLEES = new Set(['vitest --project node', 'vitest --projec
// Relevé de la série surveillée (§ 14.4, § 14.14), sur les épreuves du
// projet node donné : par épreuve, puis par ligne, un délai fixe, un appel de
// la géométrie du rendu, et un import d'écriture de node:fs dans un fichier
// qui n'appelle ni mkdtemp ni mkdtempSync, « fichier:ligne écrit sans
// dont le code — commentaires et chaînes exclus — n'appelle ni mkdtemp ni
// mkdtempSync, « fichier:ligne écrit sans
// mkdtemp : module fonctions » ; puis le script test:surveille de
// package.json quand il lance autre chose que vitest sur le projet node. La
// garde voit l'import, non le chemin : l'écriture hors du temporaire d'un
@ -2530,7 +2534,7 @@ function releverSerieSurveillee(racine, projet) {
const refus = epreuvesDuProjet(racine, projet).flatMap((fichier) => {
const texte = readFileSync(fichier, 'utf8');
const code = sansChaines(texte);
const ecritures = TEMPORAIRE.test(code)
const ecritures = TEMPORAIRE.test(codeSeul(texte))
? []
: importsDEcriture(texte, code).map(({ indice, ecrit }) => ({
ligne: ligneDe(texte, indice),
@ -2584,6 +2588,11 @@ describe('arborescence : la série surveillée (§ 14.4, § 14.14)', () => {
"import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';",
"const racine = mkdtempSync('arbre-');",
].join('\n'),
// Seul un commentaire nomme mkdtempSync( : le fichier n'en crée aucun.
'test/commente.test.js': [
"import { writeFileSync } from 'node:fs';",
'// mkdtempSync( se lit ici, sans être appelé',
].join('\n'),
'test/lecture.test.js': "import { readFileSync, existsSync } from 'node:fs';\nconst { statSync } = require('fs');\n",
'src/moteur/lourde.test.js': 'await new Promise((fin) => setTimeout(fin, 0));\n',
'src/moteur/a.long.test.js': "import { writeFileSync } from 'node:fs';\n",
@ -2597,6 +2606,7 @@ describe('arborescence : la série surveillée (§ 14.4, § 14.14)', () => {
'src/geometrie/vue.test.js:4 getScreenCTM',
'src/geometrie/vue.test.js:6 getComputedTextLength',
'src/geometrie/vue.test.js:7 import node:timers/promises',
'test/commente.test.js:1 écrit sans mkdtemp : node:fs writeFileSync',
'test/ecrit.test.js:1 écrit sans mkdtemp : node:fs entier',
'test/ecrit.test.js:2 écrit sans mkdtemp : node:fs/promises entier',
'test/ecrit.test.js:3 écrit sans mkdtemp : fs entier',
@ -2700,38 +2710,55 @@ const LECTURE_D_UN_FICHIER = /\b(?:readFileSync|readFile)\s*\(/;
const BALAYAGE_D_UN_DOSSIER = /\b(?:readdirSync|readdir|globSync|glob)\s*\(/;
// Les épreuves qui lisent un fichier par le disque, hors de tout graphe
// d'imports. Une épreuve le lit quand elle-même, ou l'un des modules
// JavaScript du projet qu'elle importe à toute profondeur (parFichier, que
// rend importateurs) : lit un fichier et porte une chaîne littérale qui finit
// sur son nom (« ./jetons.css », « src/interface/jetons.css ») ; ou balaie
// un dossier et nomme son extension, en chaîne littérale exacte (« .css »)
// ou dans une expression régulière (« \.css », « \.(svelte|css) »). Le
// relevé est textuel : un nom de fichier dans les données d'une épreuve
// compte comme une lecture, d'où des noms inventés dans ces données ; un
// balayage sans filtre d'extension, comme celui du contrôle de version, ne se
// d'imports. Une épreuve se juge avec les modules JavaScript du projet
// qu'elle importe à toute profondeur (parFichier, que rend importateurs),
// pris ensemble : elle lit un fichier quand l'un d'eux lit un fichier et que
// l'un d'eux — le même ou un autre — porte une chaîne littérale qui finit
// sur son nom (« ./jetons.css », « src/interface/jetons.css ») ; ou quand
// l'un d'eux balaie un dossier et que l'un d'eux nomme son extension, en
// chaîne littérale exacte (« .css ») ou dans une expression régulière
// (« \.css », « \.(svelte|css) »). Une épreuve qui nomme la feuille et
// passe le nom à une aide qui la lit la lit donc. Le relevé est textuel : un
// nom de fichier dans les données d'une épreuve qui lit compte comme une
// lecture, d'où des noms inventés dans ces données ; un balayage sans filtre
// d'extension ni extension nommée, comme celui du contrôle de version, ne se
// lie à aucun fichier et se déclare dans les relances requises. Rend une
// fonction : chemin relatif → Set des épreuves relatives qui le lisent.
function lecteursParLeDisque(racine, epreuves, parFichier) {
const chargeurs = new Map(epreuves.map((epreuve) => [enBarres(racine, epreuve), [enBarres(racine, epreuve)]]));
const modulesDe = new Map(epreuves.map((epreuve) => [enBarres(racine, epreuve), [enBarres(racine, epreuve)]]));
for (const [chemin, qui] of parFichier) {
if (!EXTENSIONS_MODULE.has(extname(chemin)) || chemin.split('/').includes('node_modules')) continue;
chargeurs.set(chemin, [...new Set([...(chargeurs.get(chemin) ?? []), ...qui])]);
for (const epreuve of qui) modulesDe.get(epreuve).push(chemin);
}
const modules = [...chargeurs].map(([chemin, qui]) => {
const texte = readFileSync(join(racine, chemin), 'utf8');
const code = sansChaines(texte);
return { qui, code, chaines: chainesDe(texte), lit: LECTURE_D_UN_FICHIER.test(code), balaie: BALAYAGE_D_UN_DOSSIER.test(code) };
const resumes = new Map();
const resume = (chemin) => {
if (!resumes.has(chemin)) {
const texte = readFileSync(join(racine, chemin), 'utf8');
const code = sansChaines(texte);
resumes.set(chemin, { code, chaines: chainesDe(texte), lit: LECTURE_D_UN_FICHIER.test(code), balaie: BALAYAGE_D_UN_DOSSIER.test(code) });
}
return resumes.get(chemin);
};
const ensembles = [...modulesDe].map(([epreuve, chemins]) => {
const membres = chemins.map(resume);
return {
epreuve,
codes: membres.map(({ code }) => code),
chaines: membres.flatMap(({ chaines }) => chaines),
lit: membres.some(({ lit }) => lit),
balaie: membres.some(({ balaie }) => balaie),
};
});
return (fichier) => {
const nom = basename(fichier);
const extension = extname(fichier);
const enExpression = new RegExp(String.raw`(?<![\w-])\\\.(?:\(\?:|\()?(?:\w+\|)*${extension.slice(1)}\b`);
const lisent = modules.filter(
({ code, chaines, lit, balaie }) =>
const lisent = ensembles.filter(
({ codes, chaines, lit, balaie }) =>
(lit && chaines.some((contenu) => contenu === nom || contenu.endsWith(`/${nom}`))) ||
(balaie && (chaines.includes(extension) || enExpression.test(code))),
(balaie && (chaines.includes(extension) || codes.some((code) => enExpression.test(code)))),
);
return new Set(lisent.flatMap(({ qui }) => qui));
return new Set(lisent.map(({ epreuve }) => epreuve));
};
}
@ -2741,7 +2768,8 @@ function lecteursParLeDisque(racine, epreuves, parFichier) {
// celles qui l'importent : par motif, dans l'ordre de la liste, chaque
// fichier du projet qui y répond et qu'importe une épreuve de la série
// surveillée que le motif ne relance pas, « motif n /…/ : fichier, qu'importe
// épreuve, ne la relance pas » ; chaque table de libellés qu'il nomme ;
// épreuve, ne la relance pas » ; « motif n /…/ : ne répond à aucun fichier »
// quand aucun fichier de l'arbre n'y répond ; chaque table de libellés qu'il nomme ;
// chaque épreuve qu'il relance et que l'arbre n'a pas. Puis les relances
// requises des composants (.svelte) de src/interface, et de ses feuilles de
// style (.css) : pour chaque fichier, celles de RELANCES_REQUISES_D_UN_COMPOSANT
@ -2764,7 +2792,9 @@ function releverSurveillance(racine, { watchTriggerPatterns: motifs = [], projec
motifs.forEach((motif, rang) => {
const nom = `motif ${rang + 1} ${motif.pattern}`;
const absentes = new Set();
for (const chemin of fichiers.filter((f) => repond(motif, f))) {
const repondants = fichiers.filter((f) => repond(motif, f));
if (repondants.length === 0) refus.push(`${nom} : ne répond à aucun fichier`);
for (const chemin of repondants) {
const relancees = relances(motif, chemin);
if (TABLE_DE_LIBELLES.test(chemin) && !EPREUVE.test(chemin)) refus.push(`${nom} : nomme la table de libellés ${chemin}`);
for (const epreuve of [...(parFichier.get(chemin) ?? [])].sort()) {
@ -2823,6 +2853,12 @@ describe('arborescence : les motifs de surveillance (§ 14.14)', () => {
// Nomme la feuille sans la lire, et lit une donnée qui la cite sans finir sur son nom.
'src/interface/nomme.test.js': "const chemin = './teintes.css';\n",
'src/interface/cite.test.js': "import { readFileSync } from 'node:fs';\nconst donnee = \" @import 'teintes.css';\";\nreadFileSync(donnee);\n",
// Nomme la feuille ; le module qu'elle importe la lit.
'src/interface/combine.test.js': "import { lireFeuille } from '../../test/lecture.js';\nlireFeuille('./teintes.css');\n",
'test/lecture.js': "import { readFileSync } from 'node:fs';\nexport const lireFeuille = (chemin) => readFileSync(chemin, 'utf8');\n",
// Nomme l'extension ; le module qu'elle importe balaie, sans filtre.
'src/interface/extension.test.js': "import { tout } from '../../test/inventaire.js';\nconst feuilles = tout().filter((nom) => nom.endsWith('.css'));\n",
'test/inventaire.js': "import { readdirSync } from 'node:fs';\nexport const tout = () => readdirSync('src', { recursive: true });\n",
'test/projets.test.js': "const App = await import('../src/interface/App.svelte?inline');\n",
'test/arborescence.test.js': '',
'scripts/version.test.js': '',
@ -2831,23 +2867,34 @@ describe('arborescence : les motifs de surveillance (§ 14.14)', () => {
{ pattern: /\.svelte$/, testsToRun: () => ['test/arborescence.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)src\/interface\/libelles\/[^/]+$/, testsToRun: () => 'src/interface/palette.test.js' },
{ pattern: /(^|\/)src\/interface\/(teintes\.css|modele\.js)$/, testsToRun: () => ['test/sources_interface.test.js'] },
// Mal écrit : aucun fichier n'y répond.
{ pattern: /(^|\/)electrn\/[^/]+$/, testsToRun: () => ['test/coquille.test.js'] },
];
assert.deepEqual(avecArbre(fichiers, (racine) => releverSurveillance(racine, { watchTriggerPatterns: motifs, projects })), [
"motif 1 /\\.svelte$/ : src/interface/App.svelte, qu'importe test/projets.test.js, ne la relance pas",
'motif 2 /(^|\\/)src\\/interface\\/libelles\\/[^/]+$/ : nomme la table de libellés src/interface/libelles/plan.js',
"motif 3 /(^|\\/)src\\/interface\\/(teintes\\.css|modele\\.js)$/ : relance test/sources_interface.test.js, absente de l'arbre",
'motif 4 /(^|\\/)electrn\\/[^/]+$/ : ne répond à aucun fichier',
'aucun des 2 composants de src/interface ne relance test/sources_interface.test.js',
'aucun des 2 composants de src/interface ne relance test/projets.test.js',
'aucun des 2 composants de src/interface ne relance test/entetes.test.js',
'src/interface/teintes.css ne relance pas src/interface/palette.test.js',
'src/interface/teintes.css ne relance pas scripts/version.test.js',
'src/interface/teintes.css ne relance pas src/interface/combine.test.js',
'src/interface/teintes.css ne relance pas src/interface/extension.test.js',
'src/interface/teintes.css ne relance pas src/interface/nuances.test.js',
'src/interface/teintes.css ne relance pas test/entetes.test.js',
]);
const conformes = [
{ pattern: /(^|\/)src\/interface\/.*\.svelte$/, testsToRun: () => [...RELANCES_REQUISES_D_UN_COMPOSANT, 'test/entetes.test.js'] },
{ pattern: /(^|\/)src\/interface\/teintes\.css$/,
testsToRun: () => [...RELANCES_REQUISES_D_UNE_FEUILLE, 'src/interface/nuances.test.js', 'test/entetes.test.js'] },
testsToRun: () => [
...RELANCES_REQUISES_D_UNE_FEUILLE,
'src/interface/combine.test.js',
'src/interface/extension.test.js',
'src/interface/nuances.test.js',
'test/entetes.test.js',
] },
{ pattern: /(^|\/)src\/interface\/plan\/Plan\.svelte$/, testsToRun: () => [] },
];
const complets = { ...fichiers, 'test/sources_interface.test.js': '' };
@ -2955,15 +3002,23 @@ const SCRIPTS_NOMMES = [
{ fichier: 'scripts/windows/lancer.ps1', raison: 'lance le projet sous Windows' },
];
// Fichiers que le disque porte sans qu'ils soient du projet : un nom caché
// (.DS_Store, l'échange .x.js.swp de Vim), une sauvegarde d'éditeur (x.js~,
// #x.js#), un reste de fusion ou de correctif (x.js.orig, x.js.rej). Le relevé
// des langues est le seul à admettre une extension par sa liste plutôt qu'à
// choisir la sienne : sans ce filtre, il tombe sur un arbre conforme dès qu'un
// éditeur est ouvert, et la série surveillée le relance à chaque sauvegarde.
// La liste de git ne les écarte pas : ces noms ne sont pas ignorés par le
// dépôt, et --others les rend comme tout fichier nouveau.
const estArtefact = (nom) => nom.startsWith('.') || nom.endsWith('~') || /^#.*#$/.test(nom) || /\.(orig|rej)$/.test(nom);
// Fichiers que le disque porte sans qu'ils soient du projet : l'index de
// dossier .DS_Store, l'échange de Vim (.x.js.swp, .x.js.swo…), le verrou
// d'Emacs (.#x.js), une sauvegarde d'éditeur (x.js~, #x.js#), un reste de
// fusion ou de correctif (x.js.orig, x.js.rej). Un autre nom caché est un
// fichier comme un autre : .calcul.py porte un second langage. Le relevé des
// langues est le seul à admettre une extension par sa liste plutôt qu'à
// choisir la sienne : sans ce filtre, il tombe sur un arbre conforme dès
// qu'un éditeur est ouvert, et la série surveillée le relance à chaque
// sauvegarde. La liste de git ne les écarte pas : ces noms ne sont pas
// ignorés par le dépôt, et --others les rend comme tout fichier nouveau.
const estArtefact = (nom) =>
nom === '.DS_Store' ||
/^\..+\.sw[a-p]$/.test(nom) ||
nom.startsWith('.#') ||
nom.endsWith('~') ||
/^#.*#$/.test(nom) ||
/\.(orig|rej)$/.test(nom);
// Relevé des fichiers d'une autre langue sous src/, electron/ et scripts/, à
// toute profondeur, artefacts d'éditeur et de fusion écartés : chacun
@ -2992,10 +3047,6 @@ const EPREUVES_SANS_VOISIN = [
fichier: 'src/interface/accueil/Accueil.navigateur.test.js',
raison: 'l’accueil n’existe qu’au sein de l’application : l’épreuve monte App par test/navigateur/montage.js',
},
{
fichier: 'src/interface/propositions/Propositions.navigateur.test.js',
raison: 'les propositions se lisent dans l’application montée, après génération : l’épreuve monte App',
},
{
fichier: 'src/interface/plan/ecouteurs.test.js',
raison: 'garde des sources : elle lit les composants de son répertoire par le disque, sans les importer',
@ -3107,6 +3158,50 @@ function releverFastCheck(racine, nommes = CHARGEURS_DE_FAST_CHECK) {
return [...refus, ...sansObjet];
}
// Les fichiers qui passent les démonstrations à App : la page et le montage
// des épreuves du navigateur.
const PORTEURS_DES_DEMONSTRATIONS = ['src/main.js', 'test/navigateur/montage.js'];
// Relevé des compositions de démonstrations hors de src/demo/livrees.js :
// chaque porteur qui ne passe pas demonstrationsLivrees() à App, ou qui lit
// LIVREES ou CATALOGUE lui-même — « fichier : raison ».
function releverCompositions(racine, porteurs = PORTEURS_DES_DEMONSTRATIONS) {
const releves = [];
for (const chemin of porteurs) {
const texte = readFileSync(join(racine, chemin), 'utf8');
if (!/\bdemonstrationsLivrees\(\)/.test(texte)) releves.push(`${chemin} : demonstrationsLivrees() absent`);
for (const nom of ['LIVREES', 'CATALOGUE']) if (new RegExp(`\\b${nom}\\b`).test(texte)) releves.push(`${chemin} : ${nom} lu`);
}
return releves;
}
describe('arborescence : une seule composition des démonstrations (§ 15)', () => {
test('src/main.js et le montage des épreuves passent demonstrationsLivrees() à App, et ne composent rien eux-mêmes', () => {
assert.deepEqual(releverCompositions(RACINE), []);
});
test('la garde relève un porteur qui recompose LIVREES par le catalogue, et un qui ne passe pas demonstrationsLivrees()', () => {
const recompose =
"import { CATALOGUE } from './demo/catalogue.js';\nimport { LIVREES } from './demo/livrees.js';\n" +
'const demonstrations = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((e) => e.cle === cle).nom, texte }));\n';
const releves = avecArbre(
{ 'src/main.js': recompose, 'test/navigateur/montage.js': 'const d = demonstrationsLivrees();\n' },
(racine) => releverCompositions(racine),
);
assert.deepEqual(releves, ['src/main.js : demonstrationsLivrees() absent', 'src/main.js : LIVREES lu', 'src/main.js : CATALOGUE lu']);
});
test('demonstrationsLivrees : chaque démonstration livrée, dans l’ordre de LIVREES, nommée par le catalogue ; un tableau neuf à chaque appel', () => {
const rendues = demonstrationsLivrees();
assert.deepEqual(rendues.map(({ cle }) => cle), LIVREES.map(({ cle }) => cle));
for (const { cle, nom, texte } of rendues) {
assert.equal(nom, CATALOGUE.find((entree) => entree.cle === cle).nom, cle);
assert.equal(texte, LIVREES.find((livree) => livree.cle === cle).texte, cle);
}
assert.notEqual(demonstrationsLivrees(), rendues);
});
});
describe('arborescence : un seul chemin de code pour les réservations (§ 5.2)', () => {
test('dans src/moteur, seuls configuration.js et diagnostic.js lisent les réservations', () => {
assert.deepEqual(releverLecteursDesReservations(RACINE), []);
@ -3184,6 +3279,8 @@ describe('arborescence : une seule langue (§ 13.2)', () => {
const fichiers = {
'src/moteur/recherche.js': '',
'src/moteur/.recherche.js.swp': '',
'src/moteur/.recherche.js.swo': '',
'src/moteur/.#recherche.js': '',
'src/interface/.DS_Store': '',
'src/moteur/recherche.js~': '',
'src/moteur/recherche.js.orig': '',
@ -3192,9 +3289,19 @@ describe('arborescence : une seule langue (§ 13.2)', () => {
'src/moteur/calcul.py': '',
'src/moteur/orig': '',
'src/moteur/calcul.py.bak': '',
// Un nom caché n'est pas pour autant un reste d'éditeur : un second
// langage s'y cacherait.
'src/moteur/.calcul.py': '',
'src/moteur/.cache': '',
};
const releves = avecArbre(fichiers, releverLangages).filter((r) => !r.startsWith('exception sans objet'));
assert.deepEqual(releves, ['src/moteur/calcul.py : .py', 'src/moteur/calcul.py.bak : .bak', 'src/moteur/orig : sans extension']);
assert.deepEqual(releves, [
'src/moteur/.cache : sans extension',
'src/moteur/.calcul.py : .py',
'src/moteur/calcul.py : .py',
'src/moteur/calcul.py.bak : .bak',
'src/moteur/orig : sans extension',
]);
assert.throws(
() => avecArbre({ 'src/moteur/.a.js.swp': '', 'electron/a.cjs~': '' }, releverLangages),
/aucun fichier sous src\/, electron\/ et scripts\//,

View file

@ -247,6 +247,9 @@ describe('construction : le relevé des adresses chargées', () => {
"var d=`<div style=\"background: url('https://exemple.invalid/n.png')\">${x}</div>`;",
'var s="<svg xmlns=\\"http://www.w3.org/2000/svg\\"><use href=\\"#a\\"/></svg>";',
'var o=new URL(e,import.meta.url);',
// url( précédé d'un point, d'un mot ou d'un $ est un appel de méthode
// ou de fonction, non une feuille : rien à relever.
'var y=x.url("https://exemple.invalid/u.png"),z=$url("https://exemple.invalid/v.png"),c=curl(\'https://exemple.invalid/w.png\');',
// Le guillemet d'une valeur d'attribut écrit en entité HTML, comme
// Svelte compile style='background: url("…")'.
'var p=$.from_html(`<div style="background: url(&quot;https://exemple.invalid/o.png&quot;)"></div>`);',

View file

@ -261,25 +261,6 @@ function langueDeLaPage(texte) {
return langues[0] ?? null;
}
// L'expression qui compose les démonstrations d'App dans un texte : la
// valeur de l'unique déclaration const dont l'initialiseur nomme LIVREES,
// blancs ramenés à un seul, retirés après une parenthèse, une accolade ou un
// crochet ouvrants et avant leurs fermants, virgule finale retirée. Lève
// quand le texte n'en porte aucune, ou plusieurs.
function compositionDesDemonstrations(texte) {
const compositions = [...texte.matchAll(/\bconst\s+[\p{L}_$][\p{L}\p{N}_$]*\s*=\s*([^;]*\bLIVREES\b[^;]*);/gu)].map(([, valeur]) =>
valeur
.replace(/\s+/g, ' ')
.replace(/,\s*([)}\]])/g, '$1')
.replace(/([({[])\s+/g, '$1')
.replace(/\s+([)}\]])/g, '$1')
.trim(),
);
if (compositions.length === 0) throw new Error('aucune composition de LIVREES');
if (compositions.length > 1) throw new Error('deux compositions de LIVREES');
return compositions[0];
}
describe('livrable : la page construite et ce que la coquille lui passe', () => {
test('la page construite vit dans www/ : la sortie de Vite, le répertoire web de Capacitor, la page que charge la coquille et ce qu’emporte electron-builder (§ 13.1)', async () => {
const page = await pageConstruite(RACINE);
@ -305,21 +286,4 @@ describe('livrable : la page construite et ce que la coquille lui passe', () =>
assert.equal(langueDeLaPage("const langue = 'fr';\nmount(Autre, { props: { langue: 'fr' } });\n"), null);
assert.throws(() => langueDeLaPage(page(" langue: 'fr',\n langue: 'temoin',")), /deux langues/);
});
test('les démonstrations que monterApp donne à App se composent comme celles de src/main.js', () => {
const deMain = compositionDesDemonstrations(readFileSync(join(RACINE, 'src', 'main.js'), 'utf8'));
const duMontage = compositionDesDemonstrations(readFileSync(join(RACINE, 'test', 'navigateur', 'montage.js'), 'utf8'));
assert.ok(deMain.startsWith('LIVREES.'), deMain);
assert.equal(duMontage, deMain);
});
test('la composition se lit à blancs et virgule finale près ; un filtre, un autre ordre ou deux compositions se distinguent', () => {
const une = 'const d = LIVREES.map(({ cle, texte }) => ({\n cle,\n nom: CATALOGUE.find((e) => e.cle === cle).nom,\n texte,\n}));\n';
const resserree = 'const demonstrations = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((e) => e.cle === cle).nom, texte }));\n';
assert.equal(compositionDesDemonstrations(une), compositionDesDemonstrations(resserree));
const filtree = une.replace('LIVREES.map', "LIVREES.filter(({ cle }) => cle !== 'grande').map");
assert.notEqual(compositionDesDemonstrations(filtree), compositionDesDemonstrations(une));
assert.throws(() => compositionDesDemonstrations('const d = [];\n'), /aucune composition de LIVREES/);
assert.throws(() => compositionDesDemonstrations(une + une), /deux compositions de LIVREES/);
});
});

View file

@ -60,11 +60,14 @@ export function reglagesSeries(coeurs, { couverture = false } = {}) {
export const coeursDisponibles = () => availableParallelism();
// Ligne que le rapporteur imprime, pour qu'un développeur sache sur quelle
// série sa relance a porté.
// série sa relance a porté. Une petite machine sans épreuve hors de la série
// surveillée — sous la mesure de la couverture — la dit entière.
export function ligneMachine({ petiteMachine, coeurs, horsSerieSurveillee }) {
const compte = coeurs === 1 ? '1 cœur' : `${coeurs} cœurs`;
if (!petiteMachine) return `Machine : ${compte} — processus complets, série surveillée entière.`;
const ou = coeurs === 1 ? 'sur son seul cœur' : `sur les ${coeurs} cœurs`;
return `Machine : ${compte}, en deçà de ${SEUIL_COEURS} — threads ${ou} ; `
+ `en série longue : ${horsSerieSurveillee.join(', ')}.`;
const serie = horsSerieSurveillee.length === 0
? 'série surveillée entière'
: `en série longue : ${horsSerieSurveillee.join(', ')}`;
return `Machine : ${compte}, en deçà de ${SEUIL_COEURS} — threads ${ou} ; ${serie}.`;
}

View file

@ -87,6 +87,11 @@ describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => {
+ 'en série longue : src/moteur/integration.test.js.',
);
assert.equal(ligneMachine(reglagesSeries(16)), 'Machine : 16 cœurs — processus complets, série surveillée entière.');
// Sous la mesure de la couverture, la petite machine garde tout en série node.
assert.equal(
ligneMachine(reglagesSeries(2, { couverture: true })),
'Machine : 2 cœurs, en deçà de 4 — threads sur les 2 cœurs ; série surveillée entière.',
);
});
});

View file

@ -0,0 +1,179 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les désignations stables de l'interface (contrainte 16) : la liste fermée
// des attributs data-… que src/interface pose — gabarit ou script d'un
// composant, code d'un module —, et par lesquels les épreuves atteignent un
// élément sans lire sa classe ni son texte traduit.
// test/sources_interface.test.js la compare au code dans les deux sens : un
// attribut posé hors de la liste se relève, une entrée que rien ne pose
// aussi. Un attribut nouveau entre ici avec le code qui le pose.
//
// Chaque entrée : attribut → { porteur, valeurs }. porteur dit l'élément qui
// le porte ; valeurs vaut null quand la valeur se calcule — un identifiant,
// un code, une clé —, sinon la liste fermée des valeurs littérales que le
// code écrit, '' pour un attribut posé sans valeur. Une liste fermée ne vaut
// que pour un attribut dont chaque pose est littérale ; la garde le vérifie.
// Le module est pur : il se lit sous node comme dans le navigateur.
// [attribut, porteur, valeurs], dans l'ordre des attributs.
const ENTREES = [
['data-affichee', 'import : la valeur affichée d’une cellule de l’aperçu', ['']],
['data-annonces', 'événement : la zone des annonces à côté de la tête', ['']],
['data-apercu', 'import : l’aperçu d’un fichier ou d’un collage', ['']],
['data-attente-demonstration', 'accueil : le message d’une démonstration cliquée pendant un chargement', ['']],
['data-autre', 'accueil : le choix d’un autre dossier de travail', ['']],
['data-autre-chemin', 'accueil : le chemin de l’autre dossier de travail', ['']],
['data-avancement', 'génération : la barre d’avancement', ['']],
['data-avertissement', 'un avertissement, par son code', null],
['data-bandeau', 'le bandeau d’application', ['']],
['data-bandeau-plan', 'plan : le bandeau au-dessus de la surface', ['']],
['data-bloc', 'à propos : le bloc de version à copier', ['']],
['data-cellule', 'import : une cellule de l’aperçu, par son champ', null],
['data-champ', 'un champ de saisie ou un sélecteur, par son nom', null],
['data-chargement', 'accueil : le signe d’un calcul en cours', ['']],
['data-chargement-liste', 'accueil : l’annonce du calcul des colonnes de la liste', ['']],
['data-choix-siege', 'plan : un siège offert par le panneau d’une table, par son rang', null],
['data-cible', 'la cible d’une commande : section, groupe ou colonne', null],
['data-cible-redondance', 'propositions : le champ de la cible de redondance', ['']],
['data-classement', 'propositions : les lignes classées', ['']],
['data-coeurs', 'génération : le réglage des cœurs de calcul', ['']],
['data-coeurs-disponibles', 'génération : la valeur automatique des cœurs', ['']],
['data-collage', 'import : l’origine collage de l’aperçu', ['']],
['data-colonne', 'une colonne d’un tableau, par son nom', null],
['data-colonnes', 'import : l’association des colonnes', ['']],
['data-commande', 'un bouton de commande, par son nom', null],
['data-compte', 'import : un compte du rapport, par son nom', null],
['data-conflit', 'participants : une ligne de conflit d’un groupe', ['']],
['data-conflit-personne', 'participants : les règles d’une personne en conflit', ['']],
['data-conflits', 'participants : les conflits d’un groupe, par sa clé', null],
['data-conversion', 'plan : une conversion offerte par une liste de travail', null],
['data-corbeille', 'accueil : la corbeille', ['']],
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null],
['data-critere', 'propositions : un nom de critère de la phrase de l’ordre', null],
['data-defaut', 'tables : le réglage du défaut de places', ['']],
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
['data-devient', 'import : ce que devient une ligne', ['']],
['data-donnee', 'diagnostic : une donnée nommée dans une phrase', ['']],
['data-dossier', 'accueil : le dossier de travail montré', ['']],
['data-dossier-travail', 'accueil : le bloc du dossier de travail', ['']],
['data-doublon', 'un doublon : base de la liste, ou ligne de l’aperçu', null],
['data-doublons', 'import : les doublons de l’aperçu', ['']],
['data-duree', 'génération : la durée estimée', ['']],
['data-ecran', 'un écran de l’application', ['accueil', 'apropos', 'evenement', 'recuperation']],
['data-encodage', 'import : l’encodage nommé', ['']],
['data-enonce', 'un énoncé : diagnostic, ou défaut des tables', null],
['data-enregistre', 'import : le chemin des refus enregistrés', ['']],
['data-entete', 'import : la ligne d’en-tête lue', ['']],
['data-estimation', 'génération : l’estimation de la durée', ['']],
['data-etat', 'événement : un état du plan dans la tête', null],
['data-etat-table', 'tables : l’état d’une table', ['']],
['data-evenement', 'accueil : une ligne de la liste, par sa base', null],
['data-evidence', 'plan : la personne mise en évidence', null],
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']],
['data-fautive', 'événement : une proposition fautive', null],
['data-fiche', 'participants : la fiche d’une personne', null],
['data-fichier', 'import : le fichier lu', ['']],
['data-focus-initial', 'plan : le siège qui reçoit le focus à l’ouverture du panneau', null],
['data-fusion', 'import : un groupe d’orthographes fondu', ['']],
['data-fusions', 'import : les groupes d’orthographes', ['']],
['data-groupe', 'participants : l’en-tête d’un groupe, par sa clé', null],
['data-groupe-compte', 'participants : le compte d’un groupe', ['']],
['data-groupe-conflits', 'participants : la marque de conflit d’un groupe', ['']],
['data-groupe-etat', 'participants : l’état replié ou déplié d’un groupe', ['']],
['data-groupe-libelle', 'participants : le libellé d’un groupe', ['']],
['data-groupe-ligne', 'participants : le groupe qui compte une ligne', null],
['data-historique', 'événement : la commande de l’historique', ['']],
['data-homonyme', 'import : une ligne homonyme', null],
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
['data-issue', 'génération : l’issue de la dernière génération', ['']],
['data-legende', 'plan : la légende des conflits', ['']],
['data-legende-pastille', 'plan : la pastille de la légende', ['']],
['data-legende-regle', 'plan : une entrée de la légende, par sa règle', null],
['data-ligne', 'import : une ligne de l’aperçu, par son rang', null],
['data-lignes', 'import : le tableau des lignes', ['']],
['data-liste', 'plan : la liste de noms d’une table', null],
['data-liste-travail', 'plan : une liste de travail', ['pressentis', 'titres']],
['data-marque', 'une marque porteuse d’information, par son nom au registre', null],
['data-meilleure', 'la meilleure proposition : accueil ou génération', ['']],
['data-meilleure-collisions', 'les collisions de la meilleure proposition', ['']],
['data-meilleure-id', 'l’identifiant de la meilleure proposition', ['']],
['data-meilleure-libelle-id', 'génération : le libellé de l’identifiant de la meilleure', ['']],
['data-meilleure-vide', 'génération : la meilleure absente', ['']],
['data-menu', 'plan : le menu d’une personne', null],
['data-mode', 'événement : le cadre de mode, par sa forme', null],
['data-mode-libelle', 'le libellé de mode', ['bandeau', 'cadre']],
['data-motif', 'import : le motif d’une ligne refusée', ['']],
['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null],
['data-niveau', 'participants : le niveau d’un groupe ou d’un sélecteur', null],
['data-nombre', 'import : un nombre affiché', ['']],
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
['data-panneau', 'plan : un panneau', ['place', 'table']],
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
['data-participant', 'une personne, par son identifiant', null],
['data-partie', 'plan : une partie d’une ligne de liste ou du fantôme', null],
['data-pastille', 'plan : la pastille d’une table', null],
['data-phrase', 'une phrase : diagnostic ou défaut des tables', ['']],
['data-place-fixe', 'événement : la place fixe du cadre de mode', ['']],
['data-plan', 'plan : le <svg> du plan', ['']],
['data-poignee', 'plan : la poignée d’une table', null],
['data-proposition', 'propositions : une ligne, par son identifiant', null],
['data-question', 'une question posée : dossier de travail ou durée longue', null],
['data-rapport', 'import : le rapport', ['']],
['data-refus', 'un refus, par son code ou sa place', null],
['data-refus-realigner', 'tables : le refus du réalignement', ['']],
['data-refusees', 'import : les lignes refusées', ['']],
['data-reglages', 'génération : les réglages', ['']],
['data-remede', 'diagnostic : un remède', ['']],
['data-reserve', 'plan : la réserve d’un tour', null],
['data-retenu', 'propositions : la ligne du retenu', null],
['data-secours', 'récupération : une source de secours', null],
['data-section', 'une section de l’événement', ['diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'tables']],
['data-separateur', 'import : le séparateur nommé', ['']],
['data-siege', 'plan : un siège, par sa table et son numéro', null],
['data-sieges-retires', 'tables : les sièges retirés', ['']],
['data-signalement', 'accueil : un signalement d’une ligne', null],
['data-socle', 'plan : le socle d’une pastille', ['']],
['data-source', 'plan : le placement montré, dans la barre de vue', ['']],
['data-support-rompu', 'événement : le message du support rompu', ['']],
['data-surface', 'plan : la surface du plan', ['']],
['data-table', 'une table, par son identifiant', null],
['data-table-panneau', 'plan : la table d’un panneau', null],
['data-terme', 'un terme expliqué par une infobulle', null],
['data-texte', 'import : le texte d’une ligne', ['']],
['data-theme', '<html> : le thème, clair ou sombre, que pose le script d’App', null],
['data-theme-choix', 'le bandeau : un choix de thème', null],
['data-titre', 'plan : un titre non pourvu d’une liste de travail', null],
['data-tours', 'tables : le réglage du nombre de tours', ['']],
['data-valides', 'import : le compte des lignes valides', ['']],
['data-verrou', 'événement : le verrou d’une autre séance', ['']],
['data-verrou-depuis', 'événement : l’instant du verrou', ['']],
['data-verrou-seance', 'événement : la séance du verrou', ['']],
['data-version', 'accueil : la version affichée', ['']],
['data-vue', 'plan : le groupe de la vue', ['']],
];
// La Map des entrées, gelée : Object.freeze ne gèle pas le contenu d'une
// Map, et ses modificateurs lèvent ici.
function geler(carte) {
for (const nom of ['set', 'delete', 'clear']) {
Object.defineProperty(carte, nom, {
value: () => {
throw new TypeError(`DESIGNATIONS est gelée : ${nom} refusé`);
},
});
}
return Object.freeze(carte);
}
/** Les désignations stables : attribut → { porteur, valeurs }, gelées. */
export const DESIGNATIONS = geler(
new Map(
ENTREES.map(([attribut, porteur, valeurs]) => [
attribut,
Object.freeze({ porteur, valeurs: valeurs === null ? null : Object.freeze([...valeurs]) }),
]),
),
);

View file

@ -25,7 +25,7 @@ import { ErreurAnnulee } from '../../src/application/generation.js';
import { texteLivre } from '../../src/demo/livrees.js';
import { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js';
import { analyser, configurationDepuisCharge } from '../../src/stockage/document.js';
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
import { VERSION } from '../../src/version.genere.js';
import urlCasLimites from '../../exemples/participants_cas_limites.csv?url';
@ -182,6 +182,43 @@ export function chargePrincipale() {
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
}
// Un placement de la petite démonstration en conflit, quatre tours de
// quatre tables de trois, sièges non attribués : au tour 3, la table 1 réunit
// une même appartenance, des voisins revus et un retour à sa table, la
// table 2 aucun conflit. Écrit tel quel, il ne dépend d'aucun chemin de la
// recherche.
const TROIS_CONFLITS = Object.freeze([
[[1, 2, 11], [4, 9, 10], [3, 5, 12], [6, 7, 8]],
[[9, 10, 11], [2, 4, 8], [3, 5, 6], [1, 7, 12]],
[[4, 5, 10], [6, 11, 12], [1, 7, 9], [2, 3, 8]],
[[1, 8, 10], [2, 9, 12], [3, 4, 6], [5, 7, 11]],
]);
// La charge reçue et une proposition écrite à la main, la seule : tours,
// par tour, la liste des personnes de chaque table, dans l'ordre des tables
// de la charge, sièges non attribués ; le plan devient proposé.
function avecTours(charge, tours) {
const suivante = structuredClone(charge);
suivante.propositions = [
{
id: 1,
graine: 1,
arret: 300,
historique: 1000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: suivante.tables.map(({ id }) => id),
capacites: suivante.tables.map((table) => capacite(suivante, table)),
tours: tours.length,
participants: suivante.participants.filter(({ exclu }) => !exclu).map(({ id }) => id),
placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste]), reserve: [] })),
},
];
suivante.prochainsIds.proposition = 2;
suivante.evenement.etat = 'propose';
return suivante;
}
// La principale, sa proposition retenue, puis le plan bloqué.
function chargeBloquee() {
const principale = chargePrincipale();
@ -548,14 +585,11 @@ export const ECRANS = Object.freeze([
nom: 'plan.troisConflits',
mode: 'ecriture',
async atteindre(contexte) {
// La petite démonstration en conflit et une proposition, graine 3,
// montrée au tour 3, choisi au clavier : la table 1 viole les trois
// règles et superpose leurs trames, la table 2 est saine ; puis la
// légende des conflits, ouverte par la barre de vue.
let charge = demonstration('petite-conflit');
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 300, historique: 1000 });
charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
// La petite démonstration en conflit et la proposition TROIS_CONFLITS,
// écrite à la main, montrée au tour 3, choisi au clavier : la table 1
// viole les trois règles et superpose leurs trames, la table 2 est
// saine ; puis la légende des conflits, ouverte par la barre de vue.
const charge = avecTours(demonstration('petite-conflit'), TROIS_CONFLITS);
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'plan' });
const choix = await element(m, '[data-champ="tour"]');
choix.focus();

View file

@ -78,20 +78,24 @@ function bouton(masque) {
/** Un événement de pointeur réel au point (x, y) du cadre. type : 'presser',
* 'bouger' ou 'relacher'. boutons, le masque de buttons : par défaut, 1 au
* presser, les boutons tenus pour bouger et relacher. genre : 'souris' ou
* 'stylet' ; le doigt passe par toucher. */
export async function pointer(type, x, y, { boutons, genre = 'souris' } = {}) {
* 'stylet' ; le doigt passe par toucher. clickCount, le rang de l'appui
* dans une série de clics, 1 par défaut, porté au presser et au relâcher :
* Chromium le rend en detail sur l'événement click — 2 pour le second clic
* d'un double-clic. Un rang qui n'est pas un entier d'au moins 1 lève. */
export async function pointer(type, x, y, { boutons, genre = 'souris', clickCount = 1 } = {}) {
const typeCdp = TYPES_POINTEUR[type];
if (typeCdp === undefined) throw new Error(`pointer : type inconnu « ${type} »`);
const pointerType = GENRES[genre];
if (pointerType === undefined) throw new Error(`pointer : genre inconnu « ${genre} »`);
if (!Number.isInteger(clickCount) || clickCount < 1) throw new Error(`pointer : clickCount hors d'un entier d'au moins 1 « ${clickCount} »`);
const masque = boutons ?? (type === 'presser' ? 1 : boutonsEnfonces);
const { x: px, y: py } = versPage(x, y);
const evenement = { type: typeCdp, x: px, y: py, pointerType, button: bouton(masque), buttons: masque };
if (type === 'presser') {
evenement.clickCount = 1;
evenement.clickCount = clickCount;
boutonsEnfonces = masque;
} else if (type === 'relacher') {
evenement.clickCount = 1;
evenement.clickCount = clickCount;
evenement.buttons = 0;
boutonsEnfonces = 0;
}

View file

@ -11,16 +11,20 @@
//
// Le collecteur d'erreurs de la page (public/collecteur.js), que index.html
// charge avant le module d'entrée, ne l'est pas dans la page des épreuves :
// monterApp l'y installe, une fois par page, dans un <script> classique.
// monterApp l'y installe, une fois par page, dans un <script> classique. Son
// texte se lit où le serveur de Vite sert le dossier public, à la racine :
// Vite refuse d'importer un script de public/, et l'importer par son chemin
// de fichier l'avertit à chaque série.
//
// Les démonstrations sont celles que src/main.js passe à App,
// demonstrationsLivrees() : une seule composition, celle de src/demo.
import { mount, unmount } from 'svelte';
import { creerExecuteurDirect } from '../../src/application/generation.js';
import { creerHorlogePilotee } from '../../src/application/horloge.js';
import { CATALOGUE } from '../../src/demo/catalogue.js';
import { LIVREES } from '../../src/demo/livrees.js';
import { demonstrationsLivrees } from '../../src/demo/livrees.js';
import App from '../../src/interface/App.svelte';
import { FORMAT } from '../../src/stockage/document.js';
import { VERSION } from '../../src/version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { creerFichiersSimules } from '../fichiers_simules.js';
/** Les primitives que le système simulé compte comme écritures, celles que
@ -36,25 +40,26 @@ export const PRIMITIVES_TRACEES = Object.freeze([
'deverrouiller',
]);
// Les démonstrations du contrat de données : LIVREES, chacune nommée par
// l'entrée de même clé du catalogue, dans l'ordre du catalogue.
const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({
cle,
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
texte,
}));
// Le rang du dernier montage de la page : il préfixe les identifiants que
// tire chaque montage.
let montages = 0;
// Pose public/collecteur.js dans la page, une fois : un script classique
// inséré s'exécute aussitôt, dans le contexte de la page.
// inséré s'exécute aussitôt, dans le contexte de la page. Deux montages
// lancés ensemble attendent la même installation. Un collecteur que le
// serveur ne rend pas lève, en le nommant.
let installation = null;
function installerCollecteur() {
if (globalThis.__gttErreurs !== undefined) return;
const script = document.createElement('script');
script.textContent = COLLECTEUR;
document.head.append(script);
if (globalThis.__gttErreurs !== undefined) return Promise.resolve();
installation ??= (async () => {
const reponse = await fetch('/collecteur.js');
if (!reponse.ok) throw new Error(`collecteur.js : réponse ${reponse.status} du serveur des épreuves`);
const script = document.createElement('script');
script.textContent = await reponse.text();
document.head.append(script);
if (globalThis.__gttErreurs === undefined) throw new Error('collecteur.js : le script posé n’a pas installé __gttErreurs');
})();
return installation;
}
/** Enveloppe les huit primitives d'écriture du système simulé — sonder,
@ -115,7 +120,7 @@ export async function monterApp({
formatCourant,
demonstrations,
} = {}) {
installerCollecteur();
await installerCollecteur();
montages += 1;
const numero = montages;
const premiereErreur = globalThis.__gttErreurs.length;
@ -157,7 +162,7 @@ export async function monterApp({
identifiant,
langue,
provenance,
demonstrations: demonstrations ?? DEMONSTRATIONS,
demonstrations: demonstrations ?? demonstrationsLivrees(),
formatCourant: formatCourant ?? FORMAT,
produitVersion: VERSION.affichee,
versionWindows: VERSION.windows,

View file

@ -185,7 +185,7 @@ export default class Rapporteur {
);
const { lignes, refus } = bilan({ duree, projets, epreuves, plafonds: this.#plafonds });
for (const ligne of lignes) this.#journal.log(ligne);
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles())));
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') })));
if (raison === 'interrupted') return;
for (const message of refus) this.#journal.error(message);
if (refus.length > 0) process.exitCode = 1;

View file

@ -173,7 +173,7 @@ describe('rapporteur : branché sur les événements de Vitest', () => {
' 19000 ms node-long src/c.long.test.js > c > trois',
' 7 ms node src/a.test.js > a > un',
// Le bilan finit sur le mode que cette machine fait retenir.
ligneMachine(reglagesSeries(coeursDisponibles())),
ligneMachine(reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') })),
]);
assert.deepEqual(journal.erreurs, ['Le projet node dure 30,80 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']);

View file

@ -31,6 +31,8 @@ import { compile, parse, parseCss } from 'svelte/compiler';
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 { DESIGNATIONS } from './navigateur/designations.js';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
@ -126,9 +128,11 @@ const partiesCode = (ast) => [ast.module, ast.instance, ast.fragment];
// Les chaînes littérales qu'une expression peut rendre telle quelle : un
// littéral, les morceaux fixes d'un gabarit de chaîne, les deux branches
// d'une condition, les opérandes d'une concaténation et d'un « || » ou « ?? »,
// le second d'un « && », le dernier terme d'une séquence. Un appel, un
// membre, un identifiant ne rendent aucun littéral : t('cle') n'affiche pas
// sa clé. Rend [{ valeur, debut }].
// le second d'un « && », le dernier terme d'une séquence, et chaque élément
// d'un tableau ou chaque valeur d'un objet littéral dont l'expression lit un
// membre (« ['Oui', 'Non'][i] »). Un appel, un autre membre, un identifiant
// ne rendent aucun littéral : t('cle') n'affiche pas sa clé. Rend
// [{ valeur, debut }].
function litterauxRendus(expression) {
if (expression === null || typeof expression !== 'object') return [];
switch (expression.type) {
@ -151,6 +155,12 @@ function litterauxRendus(expression) {
: [];
case 'SequenceExpression':
return litterauxRendus(expression.expressions.at(-1));
case 'MemberExpression':
if (expression.object.type === 'ArrayExpression') return expression.object.elements.flatMap(litterauxRendus);
if (expression.object.type === 'ObjectExpression') {
return expression.object.properties.flatMap((propriete) => (propriete.type === 'Property' ? litterauxRendus(propriete.value) : []));
}
return [];
default:
return [];
}
@ -184,6 +194,17 @@ const dansUnAttribut = (ancetres) => ancetres.some((a) => DANS_UN_ATTRIBUT.has(a
const LETTRE = /\p{L}/u;
// Attributs qu'une technologie d'assistance annonce, ou que la page montre.
const ATTRIBUTS_ANNONCES = new Set(['aria-label', 'aria-roledescription', 'title', 'placeholder', 'alt']);
// Les types d'<input> dont la page montre la value comme libellé.
const INPUTS_BOUTONS = new Set(['button', 'submit', 'reset']);
// Vrai quand l'attribut est la value d'un <input> bouton : un type statique
// button, submit ou reset.
function valeurDeBouton(attribut, element) {
if (attribut.name !== 'value' || element?.type !== 'RegularElement' || element.name !== 'input') return false;
const type = element.attributes.find((a) => a.type === 'Attribute' && a.name === 'type');
const [morceau] = type === undefined ? [] : morceauxAttribut(type);
return morceau !== undefined && 'statique' in morceau && INPUTS_BOUTONS.has(morceau.statique.trim().toLowerCase());
}
// Éléments du SVG dont le contenu est un texte : tout contenu statique non
// blanc y est une chaîne visible, ponctuation comprise.
const TEXTES_SVG = new Set(['title', 'desc', 'text', 'tspan']);
@ -203,8 +224,9 @@ function texteVisible(texte, element) {
const contenuLettre = (valeur) => [...valeur.matchAll(CHAINE_CSS)].some(([, , chaine]) => LETTRE.test(chaine));
// Relevé des chaînes visibles écrites dans les composants de src/interface :
// texte statique, attribut annoncé, contenu d'un texte du SVG, déclaration
// « content » lettrée, littéral que rend une expression du gabarit.
// texte statique, attribut annoncé ou value d'un <input> bouton, contenu
// d'un texte du SVG, déclaration « content » lettrée, littéral que rend une
// expression du gabarit.
function releverChainesEnDur(racine) {
const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'chaînes en dur');
const releves = [];
@ -221,7 +243,7 @@ function releverChainesEnDur(racine) {
for (const { valeur, debut } of litterauxRendus(noeud.expression)) {
if (texteVisible(valeur, element)) relever(debut, 'expression littérale', valeur);
}
} else if (noeud.type === 'Attribute' && ATTRIBUTS_ANNONCES.has(noeud.name)) {
} else if (noeud.type === 'Attribute' && (ATTRIBUTS_ANNONCES.has(noeud.name) || valeurDeBouton(noeud, elementParent(ancetres)))) {
for (const morceau of morceauxAttribut(noeud)) {
const valeurs = 'statique' in morceau
? [{ valeur: morceau.statique, debut: morceau.debut }]
@ -432,6 +454,8 @@ const FEUILLE_DES_JETONS = 'src/interface/jetons.css';
// qui les prolonge sans tiret n'en est pas.
const JETON_CONFLIT = /--couleur-conflit(?:-[\w-]+)?(?![\w-])/;
const litConflit = (valeur) => JETON_CONFLIT.test(valeur);
// Les jetons de conflit qu'une valeur lit, chacun une fois.
const jetonsLus = (valeur) => [...new Set(valeur.match(new RegExp(JETON_CONFLIT.source, 'g')))];
// Les couleurs nommées de CSS, et les couleurs du système : chacune est un
// littéral. transparent et currentColor ne portent aucune teinte et restent
@ -508,39 +532,48 @@ const declarationsEnLigne = (statique, debut) =>
.filter((d) => d !== null)
.map(([, propriete, valeur]) => ({ propriete, valeur, debut }));
// Une classe qui nomme l'état de conflit : « conflit », ou un nom en
// « -conflit » dont aucun segment ne le nie (« sans-conflit », « non-conflit »).
// « conflit-leve » ne finit pas par « -conflit » et ne le nomme pas.
const NEGATIONS = new Set(['sans', 'non', 'hors', 'pas', 'aucun']);
function classeDeConflit(nom) {
const segments = nom.toLowerCase().split('-');
return segments.at(-1) === 'conflit' && !segments.some((segment) => NEGATIONS.has(segment));
}
// Les classes de conflit, liste fermée, et le jeton que chacune lit :
// « conflit », l'état d'une table en conflit, --couleur-conflit ; puis
// « <sorte>-conflit », la trame d'une sorte de CONFLITS (marques.js), le
// jeton de cette sorte, --couleur-conflit-<sorte>. Une autre classe —
// « sans-conflit », « fin-de-conflit », « conflit-leve » — ne nomme pas un
// conflit.
const CLASSES_DE_CONFLIT = new Map([
['conflit', '--couleur-conflit'],
...JETONS_CONFLIT.map((jeton) => [`${jeton.slice('--couleur-conflit-'.length)}-conflit`, jeton]),
]);
// Vrai quand une déclaration du jeton de conflit est l'une des règles de
// conflit du plan : dans un composant de src/interface/plan/, sous une règle
// dont CHAQUE sélecteur de la liste porte, dans l'un de ses sélecteurs
// composés, une classe de conflit. Seules les classes posées directement sur
// un composé comptent : celles qu'un :not(…), un :is(…) ou un :global(…)
// enveloppe sont des arguments et ne qualifient pas la règle.
function regleDeConflit(chemin, prelude) {
// Vrai quand une déclaration qui lit les jetons de conflit jetons est l'une
// des règles de conflit du plan : dans un composant de src/interface/plan/,
// sous une règle dont CHAQUE sélecteur de la liste porte, dans l'un de ses
// sélecteurs composés, une classe de conflit dont le jeton est l'un de ceux
// lus, et dont aucun jeton lu n'appartient à une autre règle : la trame d'une
// sorte ne lit que la teinte de sa sorte. Seules les classes posées
// directement sur un composé comptent : celles qu'un :not(…), un :is(…) ou un
// :global(…) enveloppe sont des arguments et ne qualifient pas la règle.
function regleDeConflit(chemin, prelude, jetons) {
if (!chemin.startsWith('src/interface/plan/') || prelude?.type !== 'SelectorList') return false;
return prelude.children.every((complexe) =>
complexe.children.some((relatif) =>
relatif.selectors.some((simple) => simple.type === 'ClassSelector' && classeDeConflit(simple.name)),
),
);
return prelude.children.every((complexe) => {
const lus = complexe.children.flatMap((relatif) =>
relatif.selectors.filter((simple) => simple.type === 'ClassSelector' && CLASSES_DE_CONFLIT.has(simple.name)),
);
const permis = new Set(lus.map(({ name }) => CLASSES_DE_CONFLIT.get(name)));
return lus.length > 0 && jetons.every((jeton) => permis.has(jeton));
});
}
// Relevé des couleurs : littéral de couleur dans une feuille, un attribut
// style ou de couleur, une directive style:, ou une chaîne du script d'un
// composant ; déclaration de jeton hors de la feuille des jetons ; lecture
// d'une teinte de conflit hors des règles de conflit du plan. Les composants de
// src/interface et les feuilles .css de src/ sont lus, la feuille des jetons
// exceptée.
// d'une teinte de conflit hors des règles de conflit du plan. Les composants et
// les modules JavaScript de src/interface, épreuves exclues, et les feuilles
// .css de src/ sont lus, la feuille des jetons exceptée : une chaîne d'un
// module voisin atteint fill ou stroke par une expression.
function releverCouleurs(racine) {
const chemins = exigerNonVide(
sources(racine, ['src'], ['.svelte', '.css']).filter((c) => c !== FEUILLE_DES_JETONS && (c.endsWith('.css') || c.startsWith('src/interface/'))),
sources(racine, ['src'], ['.svelte', '.css', '.js']).filter(
(c) => c !== FEUILLE_DES_JETONS && (c.endsWith('.css') || c.startsWith('src/interface/')),
),
'couleurs',
);
const releves = [];
@ -553,14 +586,28 @@ function releverCouleurs(racine) {
const couleur = couleurLitterale(valeur);
if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${propriete}`);
if (propriete.startsWith('--')) relever(debut, `jeton déclaré ${propriete}`);
if (litConflit(valeur) && (enLigne || !regleDeConflit(chemin, prelude))) {
if (litConflit(valeur) && (enLigne || !regleDeConflit(chemin, prelude, jetonsLus(valeur)))) {
relever(debut, `jeton de conflit hors d'une règle de conflit du plan, ${enLigne ? 'en ligne' : extrait(selecteur)}`);
}
};
// Une chaîne du script qui est en entier un littéral de couleur, ou qui
// nomme le jeton de conflit.
const examinerScript = (noeud) => {
if (noeud.type !== 'Literal' || typeof noeud.value !== 'string') return;
const valeur = noeud.value.trim();
if (COULEUR_ENTIERE.test(valeur)) {
relever(noeud.start, `couleur ${extrait(valeur)} dans le script`);
}
if (litConflit(valeur)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script");
};
if (chemin.endsWith('.css')) {
texte = readFileSync(join(racine, chemin), 'utf8');
decls = declarations(parseCss(texte), texte);
for (const d of decls) examiner(d, false);
} else if (chemin.endsWith('.js')) {
const analyse = analyserModule(racine, chemin);
texte = analyse.texte;
parcourir(analyse.ast, examinerScript);
} else {
const analyse = analyserComposant(racine, chemin);
texte = analyse.texte;
@ -590,16 +637,7 @@ function releverCouleurs(racine) {
}
}
});
// Une chaîne du script qui est en entier un littéral de couleur, ou qui
// nomme le jeton de conflit.
parcourir([analyse.ast.module, analyse.ast.instance], (noeud) => {
if (noeud.type !== 'Literal' || typeof noeud.value !== 'string') return;
const valeur = noeud.value.trim();
if (COULEUR_ENTIERE.test(valeur)) {
relever(noeud.start, `couleur ${extrait(valeur)} dans le script`);
}
if (litConflit(valeur)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script");
});
parcourir([analyse.ast.module, analyse.ast.instance], examinerScript);
}
}
return trier(releves);
@ -749,9 +787,13 @@ const ANIMATIONS_ADMISES = [
},
];
// Vrai sous une règle @media qui demande le mouvement réduit.
// Vrai sous une règle @media qui demande le mouvement réduit, et lui seul :
// sa requête entière est (prefers-reduced-motion: reduce), à la casse et aux
// espaces près. Une requête niée, d'une autre préférence, ou jointe à une
// autre par une virgule ne l'est pas.
const REQUETE_MOUVEMENT_REDUIT = /^\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)$/i;
const sousMouvementReduit = (ancetres) =>
ancetres.some((a) => a.type === 'Atrule' && a.name === 'media' && /prefers-reduced-motion\s*:\s*reduce/.test(a.prelude));
ancetres.some((a) => a.type === 'Atrule' && a.name === 'media' && REQUETE_MOUVEMENT_REDUIT.test(a.prelude.trim()));
// Relevé de textLength et lengthAdjust sous src/interface et src/geometrie, de
// transitionend et animationend sous src/interface, et de toute transition
@ -915,6 +957,102 @@ function releverSelecteursElagues(racine) {
return trier(releves);
}
// --- Les désignations stables (contrainte 16) -------------------------------
// Les poses d'un attribut data-… par le script, dans un arbre de JavaScript :
// element.setAttribute('data-x', valeur), element.toggleAttribute('data-x')
// et element.dataset.x = valeur (dataset['x'] aussi), le nom de dataset
// rendu en data-… à tirets. Chacune { debut, nom, valeur } : nom null quand il
// se calcule, valeur la chaîne littérale posée, '' pour toggleAttribute, null
// quand elle se calcule. Une lecture — getAttribute, dataset.x lu — n'est pas
// une pose.
function posesParLeScript(arbre) {
const poses = [];
const chaineDe = (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;
return null;
};
parcourir(arbre, (noeud) => {
if (noeud.type === 'CallExpression' && noeud.callee.type === 'MemberExpression' && !noeud.callee.computed) {
const methode = noeud.callee.property.name;
if (methode !== 'setAttribute' && methode !== 'toggleAttribute') return;
const [premier, second] = noeud.arguments;
const nom = chaineDe(premier);
const calcule = nom === null && premier?.type === 'TemplateLiteral' && premier.quasis[0].value.cooked.startsWith('data-');
if (calcule) poses.push({ debut: noeud.start, nom: null, valeur: null });
else if (nom?.startsWith('data-')) poses.push({ debut: noeud.start, nom, valeur: methode === 'toggleAttribute' ? '' : chaineDe(second) });
} else if (noeud.type === 'AssignmentExpression' && noeud.left.type === 'MemberExpression') {
const { object, property, computed } = noeud.left;
if (object.type !== 'MemberExpression' || object.computed || object.property.name !== 'dataset') return;
const propre = computed ? chaineDe(property) : property.name;
const nom = propre === null ? null : `data-${propre.replace(/[A-Z]/g, (lettre) => `-${lettre.toLowerCase()}`)}`;
poses.push({ debut: noeud.start, nom, valeur: chaineDe(noeud.right) });
}
});
return poses;
}
// Relevé des désignations : chaque attribut data-… que pose un composant de
// src/interface, par son gabarit ou par son script, et chaque module
// JavaScript de src/interface, épreuves exclues, par son code
// (posesParLeScript), comparé à la liste fermée designations
// (test/navigateur/designations.js), dans les deux sens — « chemin:ligne
// attribut hors de la liste » ; « chemin:ligne data-… : nom calculé » pour un
// nom que le script compose ; pour une entrée à valeurs fermées, une valeur
// littérale hors de la liste, ou une pose calculée ; puis « attribut : aucun
// composant ne le pose » et « attribut="valeur" : aucun composant ne la
// pose ». Lève quand la liste est vide.
function releverDesignations(racine, designations = DESIGNATIONS) {
assert.ok(designations.size > 0, 'désignations : liste vide');
const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte', '.js']), 'désignations');
const releves = [];
const posees = new Map();
// Une pose : valeur null quand elle se calcule.
const noter = (ou, nom, valeur) => {
const entree = designations.get(nom);
if (entree === undefined) {
releves.push(`${ou} hors de la liste`);
return;
}
const valeurs = posees.get(nom) ?? new Set();
posees.set(nom, valeurs);
if (valeur === null) {
if (entree.valeurs !== null) releves.push(`${ou} : valeur calculée sous une liste fermée`);
return;
}
valeurs.add(valeur);
if (entree.valeurs !== null && !entree.valeurs.includes(valeur)) releves.push(`${ou}="${valeur}" hors des valeurs de la liste`);
};
for (const chemin of chemins) {
const composant = chemin.endsWith('.svelte');
const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin);
if (composant) {
parcourir(ast.fragment, (noeud) => {
if (noeud.type !== 'Attribute' || !noeud.name.startsWith('data-')) return;
const morceaux = noeud.value === true ? [] : [noeud.value].flat();
const litterale = morceaux.every((morceau) => morceau.type === 'Text');
noter(`${chemin}:${ligneDe(texte, noeud.start)} ${noeud.name}`, noeud.name, litterale ? morceaux.map((morceau) => morceau.data).join('') : null);
});
}
for (const { debut, nom, valeur } of posesParLeScript(composant ? [ast.module, ast.instance] : ast)) {
const ligne = `${chemin}:${ligneDe(texte, debut)}`;
if (nom === null) releves.push(`${ligne} data-\u{2026} : nom calculé`);
else noter(`${ligne} ${nom}`, nom, valeur);
}
}
for (const [attribut, { valeurs }] of designations) {
if (!posees.has(attribut)) {
releves.push(`${attribut} : aucun composant ne le pose`);
continue;
}
for (const valeur of valeurs ?? []) {
if (!posees.get(attribut).has(valeur)) releves.push(`${attribut}="${valeur}" : aucun composant ne la pose`);
}
}
return releves;
}
// --- Arbres d'épreuve ---------------------------------------------------------
// Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le
@ -966,6 +1104,8 @@ describe('sources de l’interface : les chaînes visibles en dur (§ 14.6)', ()
'<span class="vide" data-x="Nom">{x} / {x}</span><img alt="" />',
'<span style="content: \'Note\'">.</span>',
'<!-- Commentaire écrit en dur -->',
'<input type="button" value="Envoyer" /><input type="text" value="Nom" /><input type="submit" value={x ? \'Valider\' : t(\'k.v\')} />',
'<span>{[\'Oui\', \'Non\'][x ? 0 : 1]}</span><span>{({ a: \'Ouvert\' }).a}</span><span>{[1, 2][0]}</span>',
].join('\n'),
style: ' .vide::after { content: "Fin"; }\n .vide::before { content: counter(rang) ". "; }',
}),
@ -985,7 +1125,12 @@ describe('sources de l’interface : les chaînes visibles en dur (§ 14.6)', ()
'src/interface/plan/sous/B.svelte:8 expression littérale « Plein »',
'src/interface/plan/sous/B.svelte:8 expression littérale « places »',
'src/interface/plan/sous/B.svelte:10 content « content: \'Note\' »',
'src/interface/plan/sous/B.svelte:13 content « "Fin" »',
'src/interface/plan/sous/B.svelte:12 attribut value « Envoyer »',
'src/interface/plan/sous/B.svelte:12 attribut value « Valider »',
'src/interface/plan/sous/B.svelte:13 expression littérale « Non »',
'src/interface/plan/sous/B.svelte:13 expression littérale « Oui »',
'src/interface/plan/sous/B.svelte:13 expression littérale « Ouvert »',
'src/interface/plan/sous/B.svelte:15 content « "Fin" »',
]);
});
@ -1158,6 +1303,9 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
}),
'src/interface/autre.css': '.x { color: #123456; }\n',
'src/interface/C.navigateur.test.js': "const c = 'stroke: #c00';\n",
// Un module voisin : sa chaîne atteint fill ou stroke par une expression.
'src/interface/plan/teinte.js': "export const TEINTE = '#c00';\nexport const TRAME = 'var(--couleur-conflit)';\nexport const SORTE = 'mobile';\n",
'src/interface/plan/teinte.test.js': "export const TEINTE = '#c00';\n",
};
assert.deepEqual(avecArbre(fichiers, releverCouleurs), [
'src/interface/accueil/sous/B.svelte:2 couleur « #ABCDEF » dans le script',
@ -1173,6 +1321,8 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
'src/interface/plan/A.svelte:5 couleur « rgb( » dans color',
'src/interface/plan/A.svelte:6 couleur « white » dans outline-color',
'src/interface/plan/A.svelte:6 jeton déclaré --rang',
'src/interface/plan/teinte.js:1 couleur « #c00 » dans le script',
"src/interface/plan/teinte.js:2 jeton de conflit hors d'une règle de conflit du plan, dans le script",
]);
});
@ -1195,7 +1345,9 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
' .non-conflit { color: var(--couleur-conflit); }',
' .a, .b.conflit { fill: var(--couleur-conflit); }',
' .conflit-leve { fill: var(--couleur-conflit); }',
' .b.conflit, .b .trait-conflit, .plateau-en-conflit { stroke: var(--couleur-conflit); }',
' .b.conflit, .c .conflit { stroke: var(--couleur-conflit); }',
' .fin-de-conflit { fill: var(--couleur-conflit); }',
' .trait-conflit { fill: var(--couleur-conflit); }',
].join('\n'),
}),
};
@ -1208,6 +1360,8 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
"src/interface/plan/Trou.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .non-conflit »",
"src/interface/plan/Trou.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .a, .b.conflit »",
"src/interface/plan/Trou.svelte:7 jeton de conflit hors d'une règle de conflit du plan, « .conflit-leve »",
"src/interface/plan/Trou.svelte:9 jeton de conflit hors d'une règle de conflit du plan, « .fin-de-conflit »",
"src/interface/plan/Trou.svelte:10 jeton de conflit hors d'une règle de conflit du plan, « .trait-conflit »",
]);
});
@ -1226,6 +1380,7 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
' .trait.table-conflit { stroke: var(--couleur-conflit-table); }',
' .trait { fill: var(--couleur-conflit-voisins); }',
' .conflit-voisins { fill: var(--couleur-conflit-table); }',
' .trait.table-conflit { fill: var(--couleur-conflit-voisins); }',
].join('\n'),
}),
};
@ -1235,6 +1390,7 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
"src/interface/plan/Motif.svelte:1 jeton de conflit hors d'une règle de conflit du plan, en ligne",
"src/interface/plan/Motif.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .trait »",
"src/interface/plan/Motif.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .conflit-voisins »",
"src/interface/plan/Motif.svelte:7 jeton de conflit hors d'une règle de conflit du plan, « .trait.table-conflit »",
]);
});
@ -1390,6 +1546,17 @@ describe('sources de l’interface : texte comprimé et animations (§ 7.4, § 1
assert.deepEqual(avecArbre({ [admise.fichier]: style(tourne, images) }, releverTexteEtAnimation), [
`${admise.fichier}:3 animation admise sans arrêt sous prefers-reduced-motion : ${admise.selecteur}`,
]);
// Un arrêt sous une requête qui ne demande pas le mouvement réduit — niée,
// ou de l'autre préférence — n'en est pas un.
for (const requete of ['not (prefers-reduced-motion: reduce)', '(prefers-reduced-motion: no-preference)', 'print, (prefers-reduced-motion: reduce)']) {
const faux = ` @media ${requete} { ${admise.selecteur} { animation: none; } }`;
assert.deepEqual(avecArbre({ [admise.fichier]: style(tourne, images, faux) }, releverTexteEtAnimation), [
`${admise.fichier}:3 animation admise sans arrêt sous prefers-reduced-motion : ${admise.selecteur}`,
], requete);
}
// La requête admise, en minuscules ou non, avec ou sans espaces.
const serre = ` @media (PREFERS-REDUCED-MOTION:reduce) { ${admise.selecteur} { animation: none; } }`;
assert.deepEqual(avecArbre({ [admise.fichier]: style(tourne, images, serre) }, releverTexteEtAnimation), []);
// Un arbre qui ne la porte plus : l'exception est sans objet.
assert.deepEqual(avecArbre({ [ailleurs]: style() }, releverTexteEtAnimation), [
`exception sans objet : ${admise.fichier} ${admise.selecteur}`,
@ -1462,3 +1629,64 @@ describe('sources de l’interface : les sélecteurs élagués (§ 13.3, § 14.2
assert.throws(() => avecArbre({ 'src/interface/a.js': '' }, releverSelecteursElagues), /aucun fichier examiné/);
});
});
describe('sources de l’interface : les désignations stables (contrainte 16)', () => {
test('chaque data-… que pose src/interface — gabarit ou script d’un composant, code d’un module — est dans DESIGNATIONS, et chaque entrée est posée quelque part', () => {
assert.ok(DESIGNATIONS.size > 100, `${DESIGNATIONS.size} désignations`);
exigerVide(releverDesignations(RACINE), 'désignations');
});
test('la garde relève un attribut inventé, par le gabarit, le script ou un module, un nom composé, une valeur hors d’une liste fermée, une pose calculée sous elle, et une entrée que rien ne pose', () => {
const designations = new Map([
['data-ecran', { porteur: 'un écran', valeurs: ['accueil'] }],
['data-commande', { porteur: 'une commande', valeurs: null }],
['data-section', { porteur: 'une section', valeurs: ['plan', 'tables'] }],
['data-absente', { porteur: 'rien', valeurs: null }],
['data-theme', { porteur: 'posé par le script seul', valeurs: null }],
]);
const fichiers = {
// Des poses par le script d'un composant, et par un module voisin.
'src/interface/Theme.svelte': composant({
script: [
" document.documentElement.setAttribute('data-theme', theme);",
" element.dataset.jeuDeDonnees = '';",
" element.setAttribute('data-section', cle);",
" element.setAttribute(`data-${nom}`, '');",
" const lu = element.getAttribute('data-lue');",
].join('\n'),
gabarit: '<p></p>',
}),
'src/interface/pose.js': "export const poser = (element) => element.setAttribute('data-module', 'oui');\n",
'src/interface/pose.test.js': "element.setAttribute('data-epreuve', '');\n",
'src/interface/Ecran.svelte': composant({
gabarit: [
'<section data-ecran="accueil" data-section="plan">',
' <button data-commande={nom} data-invente>x</button>',
' <p data-ecran="ailleurs"></p>',
' <div data-section={cle}></div>',
'</section>',
].join('\n'),
}),
};
assert.deepEqual(avecArbre(fichiers, (racine) => releverDesignations(racine, designations)), [
'src/interface/Ecran.svelte:2 data-invente hors de la liste',
'src/interface/Ecran.svelte:3 data-ecran="ailleurs" hors des valeurs de la liste',
'src/interface/Ecran.svelte:4 data-section : valeur calculée sous une liste fermée',
'src/interface/Theme.svelte:3 data-jeu-de-donnees hors de la liste',
'src/interface/Theme.svelte:4 data-section : valeur calculée sous une liste fermée',
'src/interface/Theme.svelte:5 data-… : nom calculé',
'src/interface/pose.js:1 data-module hors de la liste',
'data-section="tables" : aucun composant ne la pose',
'data-absente : aucun composant ne le pose',
]);
assert.throws(() => releverDesignations(RACINE, new Map()), /désignations : liste vide/);
});
test('DESIGNATIONS est gelée : ni ajout, ni retrait, ni entrée modifiable', () => {
assert.throws(() => DESIGNATIONS.set('data-x', {}), TypeError);
assert.throws(() => DESIGNATIONS.delete('data-ecran'), TypeError);
assert.throws(() => DESIGNATIONS.clear(), TypeError);
assert.ok(Object.isFrozen(DESIGNATIONS.get('data-ecran')));
assert.ok(Object.isFrozen(DESIGNATIONS.get('data-ecran').valeurs));
});
});