Compare commits
10 commits
811cd16b76
...
fca74b6672
| Author | SHA1 | Date | |
|---|---|---|---|
| fca74b6672 | |||
| ddd587da87 | |||
| 03a6f73337 | |||
| dd7ad19cc9 | |||
| b3d59f2079 | |||
| 8186b06713 | |||
| a2812cddb9 | |||
| 71b9e75363 | |||
| 819a3853e5 | |||
| 4b442ded14 |
119 changed files with 13187 additions and 764 deletions
2
Makefile
2
Makefile
|
|
@ -77,7 +77,7 @@ verifier: ## Contrôle la version, le nom du livrable et le changelog (§ 18.5)
|
|||
$(AVEC_NODE) node scripts/version.js controler
|
||||
|
||||
.PHONY: essai_demarrage
|
||||
essai_demarrage: ## Lance Electron, lit le titre de sa fenêtre et ferme
|
||||
essai_demarrage: ## Construit la page et joue une séance par gestes dans Electron, en mode portable
|
||||
$(AVEC_NODE) node scripts/essai_demarrage.js
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@
|
|||
// plus grande à égalité ; une table d'une autre capacité porte la sienne
|
||||
// en surcharge (§ 6.1) ;
|
||||
// - les tables rondes, rangées en grille dans l'ordre de la configuration,
|
||||
// rangée par rangée, leurs centres espacés de 300 cm ; la grille a la plus
|
||||
// petite largeur dont le carré contient toutes les tables, et seule sa
|
||||
// dernière rangée est incomplète ;
|
||||
// rangée par rangée ; la grille a la plus petite largeur dont le carré
|
||||
// contient toutes les tables, et seule sa dernière rangée est incomplète.
|
||||
// Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que
|
||||
// l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de
|
||||
// noms d'une table, de toute sa largeur, tienne entre deux colonnes —
|
||||
// aucune liste ne passe sur une autre table (§ 7.4) —, ses rangées de
|
||||
// 300 cm au moins ;
|
||||
// - les contraintes de la configuration, les sièges non attribués, la
|
||||
// génération par défaut de la recherche ;
|
||||
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
|
||||
|
|
@ -47,6 +51,7 @@ import { decoder } from '../src/csv/encodage.js';
|
|||
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
|
||||
import { decouper } from '../src/csv/lecture.js';
|
||||
import { CATALOGUE } from '../src/demo/catalogue.js';
|
||||
import { ecartsGrille } from '../src/geometrie/tables.js';
|
||||
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
|
||||
import { analyser, creerCharge } from '../src/stockage/document.js';
|
||||
import { VERSION } from '../src/version.genere.js';
|
||||
|
|
@ -66,9 +71,6 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
|
|||
// Séparateur de la variante windows-1252 (§ 10.3).
|
||||
const VIRGULE = ',';
|
||||
|
||||
// Écart entre les centres de deux tables voisines de la grille, en cm.
|
||||
const ECART_CM = 300;
|
||||
|
||||
// Le siège de l'animateur, et le titre qu'il porte.
|
||||
const SIEGE_ANIMATEUR = 1;
|
||||
const ANIMATION = 'animation';
|
||||
|
|
@ -134,6 +136,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
|
|||
const animateurs = animateursDe(reservations, cle);
|
||||
const reserves = new Set(animateurs.values());
|
||||
const colonnes = colonnesDe(tables.length);
|
||||
const pas = ecartsGrille(tables.map(({ capacite }) => ({ forme: 'ronde', places: capacite })));
|
||||
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
|
||||
return {
|
||||
...neuve,
|
||||
|
|
@ -154,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
|
|||
numero,
|
||||
sieges: capacite === siegesParDefaut ? null : capacite,
|
||||
forme: 'ronde',
|
||||
position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM },
|
||||
position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees },
|
||||
})),
|
||||
reservations: [...animateurs].map(([table, participant]) => ({
|
||||
participant,
|
||||
|
|
|
|||
|
|
@ -209,18 +209,22 @@ describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('les tables : rondes, en grille rangée par rangée, centres à 300 cm, la dernière rangée seule incomplète', () => {
|
||||
test('les tables : rondes, en grille rangée par rangée, la dernière rangée seule incomplète ; rangées à 300 cm, colonnes assez écartées pour le bloc de noms', () => {
|
||||
// Écart des colonnes : 2 × l'emprise de la plus grande table, deux écarts
|
||||
// de liste (36 cm) et la largeur du bloc de noms (500 cm), arrondi au cm
|
||||
// supérieur — petite, 3 places, emprise 79 : 158 + 72 + 500 = 730 ;
|
||||
// grande, 8 places, emprise 120,39 : ⌈240,79 + 72 + 500⌉ = 813.
|
||||
assert.deepEqual(chargeDeDemonstration(entreeDe('petite')).tables, [
|
||||
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } },
|
||||
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 730, y: 0 } },
|
||||
{ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 0, y: 300 } },
|
||||
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 300, y: 300 } },
|
||||
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 730, y: 300 } },
|
||||
]);
|
||||
// 33 tables : six colonnes, cinq rangées pleines, puis trois tables.
|
||||
const { tables } = chargeDeDemonstration(entreeDe('grande'));
|
||||
const positions = tables.map(({ position: { x, y } }) => [x, y]);
|
||||
assert.deepEqual(positions.slice(0, 7), [[0, 0], [300, 0], [600, 0], [900, 0], [1200, 0], [1500, 0], [0, 300]]);
|
||||
assert.deepEqual(positions.slice(-4), [[1500, 1200], [0, 1500], [300, 1500], [600, 1500]]);
|
||||
assert.deepEqual(positions.slice(0, 7), [[0, 0], [813, 0], [1626, 0], [2439, 0], [3252, 0], [4065, 0], [0, 300]]);
|
||||
assert.deepEqual(positions.slice(-4), [[4065, 1200], [0, 1500], [813, 1500], [1626, 1500]]);
|
||||
assert.equal(new Set(positions.map(String)).size, 33);
|
||||
assert.ok(tables.every(({ forme }) => forme === 'ronde'));
|
||||
assert.deepEqual(tables.map(({ id, numero }) => [id, numero]), positions.map((_, rang) => [rang + 1, rang + 1]));
|
||||
|
|
|
|||
|
|
@ -2,90 +2,393 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Essai de démarrage : construit la page, lance la vraie coquille Electron
|
||||
// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier
|
||||
// écran, puis ferme. Il prouve ce que la coquille factice des épreuves
|
||||
// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce
|
||||
// système, avec ses bibliothèques, y charge la page, et que l'application y
|
||||
// monte jusqu'à la liste des événements.
|
||||
// par Playwright, et y joue une courte séance par gestes. Il prouve ce que la
|
||||
// coquille factice des épreuves (test/coquille.test.js) ne peut pas dire :
|
||||
// qu'Electron démarre sur ce système, avec ses bibliothèques, y charge la
|
||||
// page, que l'application y monte, que la détermination du dossier de
|
||||
// travail retient le mode portable, qu'un travailleur de génération naît et
|
||||
// rend son issue dans la page chargée sous file://, et que la sortie rend
|
||||
// les verrous.
|
||||
//
|
||||
// node scripts/essai_demarrage.js
|
||||
//
|
||||
// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et
|
||||
// le bandeau — qui porte aussi provenance, thème, mode et « À propos » —
|
||||
// contenir la version affichée.
|
||||
// La séance, dans l'ordre :
|
||||
// 1. le titre de la fenêtre vaut le nom suivi de la version affichée, le
|
||||
// bandeau porte la version affichée, le collecteur d'erreurs est posé ;
|
||||
// 2. la liste affiche le dossier de travail exe/data ; créer un événement,
|
||||
// qui s'ouvre, revenir à la liste, puis charger la petite
|
||||
// démonstration, qui s'ouvre ;
|
||||
// 3. le mode portable : leurs deux .gtt.json sous exe/data, aucun sous home
|
||||
// ni sous temp ;
|
||||
// 4. « Modifier » prend un verrou sous exe/data ; le compte d'arrêt passe à
|
||||
// 2 000 mouvements par son champ, puis « Générer », dans la page chargée
|
||||
// sous file://, fait naître un travailleur et ajoute des propositions,
|
||||
// que le fichier d'état porte, sans refus dans la section ni erreur
|
||||
// recueillie ;
|
||||
// 5. le collecteur est vide ; la fenêtre se ferme, le processus sort, et
|
||||
// aucun .gtt.verrou ne reste sous exe/data.
|
||||
//
|
||||
// La coquille se lance comme publiée par le lanceur portable dans un dossier
|
||||
// neuf, créé sous node_modules/.cache/ et retiré en sortant : la sonde de la
|
||||
// détermination y écrit son dossier de travail. Sans lui, elle créerait le
|
||||
// dossier du produit dans les vrais Documents de la machine ; et un dossier
|
||||
// sous le dossier temporaire du système relève des données applicatives
|
||||
// (§ 8.6, point 2), qui renvoient aux mêmes Documents.
|
||||
// neuf, créé par mkdtemp sous le dossier temporaire du système et retiré en
|
||||
// sortant. Il y prend trois dossiers distincts : exe, que le lanceur publie
|
||||
// (PORTABLE_EXECUTABLE_DIR) ; home, le dossier personnel (HOME) ; temp, le
|
||||
// dossier temporaire (TMPDIR, TEMP, TMP). XDG_CONFIG_HOME est retiré : sous
|
||||
// Linux, les données applicatives et les Documents se déduisent alors de
|
||||
// home seul. Sous Windows, le profil (USERPROFILE), AppData et LOCALAPPDATA
|
||||
// passent eux aussi sous home : le temporaire du poste, où mkdtemp prend la
|
||||
// racine, se range d'ordinaire sous LOCALAPPDATA.
|
||||
// La coquille range le dossier temporaire et LOCALAPPDATA parmi les données
|
||||
// applicatives (electron/fichiers.js, § 8.6 point 2) : un exe placé sous
|
||||
// l'un d'eux ferait retenir les Documents. Sous Windows, les Documents sont
|
||||
// un dossier connu qu'aucune variable ne déplace : leur sonde y créerait le
|
||||
// dossier du produit, que la suppression de la racine ne retire pas. Avant
|
||||
// tout lancement, l'essai refuse donc un exe que l'une des données
|
||||
// applicatives prévisibles de la coquille porterait.
|
||||
//
|
||||
// Sous Linux sans session graphique, Electron se lance sur la plateforme
|
||||
// Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge
|
||||
// et s'y met en page, sans serveur d'affichage. Sous root, ou avec
|
||||
// GTT_SANS_BAC_A_SABLE=1 là où le système refuse les espaces de noms
|
||||
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
|
||||
// Sort à 0 sur un démarrage conforme, à 1 sinon.
|
||||
// Sort à 0 sur un démarrage conforme, à 1 sinon : chaque constat s'imprime,
|
||||
// et le premier échec arrête la séance.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { _electron } from 'playwright';
|
||||
import { decrireSysteme } from '../electron/fichiers.js';
|
||||
import { estSous } from '../src/stockage/dossier_travail.js';
|
||||
import { VERSION } from '../src/version.genere.js';
|
||||
import { titreAvecVersion } from '../src/interface/libelles.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const ATTENTE_MS = 60_000;
|
||||
const SONDAGE_MS = 100;
|
||||
|
||||
const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1';
|
||||
const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
||||
// Le nom de l'événement créé, la démonstration chargée, le compte d'arrêt.
|
||||
const NOM_EVENEMENT = "Soirée d'essai";
|
||||
const DEMONSTRATION = 'petite';
|
||||
const ARRET = 2000;
|
||||
|
||||
// npm est un script .cmd sous Windows, que Node ne lance que par un shell.
|
||||
execFileSync('npm', ['run', '--silent', 'build'], {
|
||||
cwd: RACINE,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
const cache = join(RACINE, 'node_modules', '.cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const publie = mkdtempSync(join(cache, 'essai-demarrage-'));
|
||||
|
||||
const attendu = titreAvecVersion(VERSION.affichee);
|
||||
let conforme = false;
|
||||
try {
|
||||
const application = await _electron.launch({
|
||||
args: [
|
||||
...(sansBacASable ? ['--no-sandbox'] : []),
|
||||
...(sansEcran ? ['--ozone-platform=headless'] : []),
|
||||
'.',
|
||||
],
|
||||
cwd: RACINE,
|
||||
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie },
|
||||
timeout: ATTENTE_MS,
|
||||
});
|
||||
try {
|
||||
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
|
||||
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
|
||||
// le bandeau s'y voit par sa largeur minimale, la liste n'y est que
|
||||
// présente.
|
||||
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
|
||||
await fenetre.locator('[data-ecran="accueil"]').waitFor({ state: 'attached', timeout: ATTENTE_MS });
|
||||
const titre = await fenetre.title();
|
||||
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
|
||||
const erreurs = await fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
|
||||
conforme = titre === attendu && bandeau.includes(VERSION.affichee) && Array.isArray(erreurs) && erreurs.length === 0;
|
||||
console.log(`Titre de la fenêtre : ${titre}`);
|
||||
console.log(`Bandeau : ${bandeau}`);
|
||||
console.log(`Erreurs recueillies : ${JSON.stringify(erreurs)}`);
|
||||
if (!conforme) console.error(`Attendu : titre « ${attendu} », bandeau portant ${VERSION.affichee}, collecteur posé et vide`);
|
||||
} finally {
|
||||
await application.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(publie, { recursive: true, force: true });
|
||||
/**
|
||||
* Les trois dossiers distincts de l'essai, sous racine.
|
||||
* @param {string} racine
|
||||
* @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes
|
||||
* @returns {{exe: string, home: string, temp: string}}
|
||||
*/
|
||||
export function dossiersDEssai(racine, chemins = path) {
|
||||
return { exe: chemins.join(racine, 'exe'), home: chemins.join(racine, 'home'), temp: chemins.join(racine, 'temp') };
|
||||
}
|
||||
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
|
||||
process.exit(conforme ? 0 : 1);
|
||||
|
||||
/**
|
||||
* L'environnement de la coquille : une copie de env où le lanceur portable
|
||||
* publie exe, où home est le dossier personnel (HOME, USERPROFILE) et porte
|
||||
* AppData (APPDATA) et LOCALAPPDATA, où temp est le dossier temporaire, sans
|
||||
* XDG_CONFIG_HOME. Sous X11 (DISPLAY posé) sans XAUTHORITY, Xlib cherche son
|
||||
* fichier d'autorisation dans le dossier personnel : XAUTHORITY nomme alors
|
||||
* celui du dossier personnel de env, que le remplacement de HOME ferait
|
||||
* perdre. env n'est pas modifié.
|
||||
* @param {Record<string, string|undefined>} env
|
||||
* @param {{exe: string, home: string, temp: string}} dossiers
|
||||
* @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes
|
||||
* @returns {Record<string, string|undefined>}
|
||||
*/
|
||||
export function environnementDEssai(env, { exe, home, temp }, chemins = path) {
|
||||
const { XDG_CONFIG_HOME: _retire, ...garde } = env;
|
||||
const autorisation =
|
||||
garde.DISPLAY && !garde.XAUTHORITY && garde.HOME ? { XAUTHORITY: chemins.join(garde.HOME, '.Xauthority') } : {};
|
||||
return {
|
||||
...garde,
|
||||
...autorisation,
|
||||
PORTABLE_EXECUTABLE_DIR: exe,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: chemins.join(home, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: chemins.join(home, 'AppData', 'Local'),
|
||||
TMPDIR: temp,
|
||||
TEMP: temp,
|
||||
TMP: temp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les données applicatives que la coquille lancée sous env retiendrait et
|
||||
* qui portent l'exécutable publié : vide quand le mode portable reste
|
||||
* possible. La liste est celle que compose decrireSysteme
|
||||
* (electron/fichiers.js, § 8.6 point 2), appelée comme la coquille l'appelle,
|
||||
* et estSous compare comme le système, selon la casse et le séparateur
|
||||
* qu'elle décrit. L'essai ne sait en propre que ce que rend app.getPath :
|
||||
* appData prend chaque valeur qu'il peut rendre — sous Windows, l'APPDATA de
|
||||
* env et celle du poste, dossier connu qu'aucune variable ne déplace ; sous
|
||||
* macOS, Library/Application Support sous l'un et l'autre HOME ; ailleurs,
|
||||
* XDG_CONFIG_HOME ou .config sous HOME —, decrireSysteme étant appelée une
|
||||
* fois pour chacune ; temp est TMP, puis TEMP, sous Windows, TMPDIR, puis
|
||||
* /tmp, ailleurs. Un autre nom demandé à app.getPath lève : l'essai
|
||||
* jugerait une liste à laquelle il manque un emplacement. Les dossiers se
|
||||
* rendent dans l'ordre des appels, sans doublon ; sans exécutable publié,
|
||||
* rien ne le porte.
|
||||
* @param {Record<string, string|undefined>} env l'environnement de la coquille
|
||||
* @param {Record<string, string|undefined>} poste l'environnement du poste
|
||||
* @param {string} plateforme process.platform
|
||||
* @param {typeof decrireSysteme} [decrire] la description du poste par la coquille
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function donneesApplicativesSurExe(env, poste, plateforme, decrire = decrireSysteme) {
|
||||
const executable = env.PORTABLE_EXECUTABLE_DIR || null;
|
||||
if (executable === null) return [];
|
||||
const chemins = plateforme === 'win32' ? path.win32 : path.posix;
|
||||
const sousMaison = (maison, ...segments) => (maison ? chemins.join(maison, ...segments) : undefined);
|
||||
const appData =
|
||||
plateforme === 'win32'
|
||||
? [env.APPDATA, poste.APPDATA]
|
||||
: plateforme === 'darwin'
|
||||
? [env.HOME, poste.HOME].map((maison) => sousMaison(maison, 'Library', 'Application Support'))
|
||||
: [env.XDG_CONFIG_HOME || sousMaison(env.HOME, '.config')];
|
||||
const temp = plateforme === 'win32' ? env.TMP || env.TEMP : env.TMPDIR || '/tmp';
|
||||
const portent = appData.flatMap((dossierAppData) => {
|
||||
const connus = { appData: dossierAppData, temp, documents: '' };
|
||||
const cheminSysteme = (nom) => {
|
||||
if (!Object.hasOwn(connus, nom)) throw new Error(`app.getPath('${nom}') : l'essai ne sait pas l'émuler`);
|
||||
return connus[nom];
|
||||
};
|
||||
const { emplacements } = decrire({ env, plateforme, cheminSysteme, nomProduit: '' });
|
||||
return emplacements.donneesApplicatives.filter((dossier) => estSous(executable, dossier, emplacements));
|
||||
});
|
||||
return [...new Set(portent)];
|
||||
}
|
||||
|
||||
// Les fichiers sous dossier, récursivement, dont le nom finit par suffixe,
|
||||
// relatifs à dossier et triés ; aucun quand dossier n'existe pas.
|
||||
function fichiersSous(dossier, suffixe) {
|
||||
let entrees;
|
||||
try {
|
||||
entrees = readdirSync(dossier, { recursive: true, withFileTypes: true });
|
||||
} catch (erreur) {
|
||||
if (erreur?.code === 'ENOENT') return [];
|
||||
throw erreur;
|
||||
}
|
||||
return entrees
|
||||
.filter((e) => e.isFile() && e.name.endsWith(suffixe))
|
||||
.map((e) => relative(dossier, join(e.parentPath, e.name)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Sonde condition() jusqu'à ce qu'elle rende une valeur vraie, qu'elle rend ;
|
||||
// au-delà d'ATTENTE_MS, rend la dernière valeur lue.
|
||||
async function attendre(condition) {
|
||||
const limite = performance.now() + ATTENTE_MS;
|
||||
let valeur = await condition();
|
||||
while (!valeur && performance.now() < limite) {
|
||||
await new Promise((fin) => setTimeout(fin, SONDAGE_MS));
|
||||
valeur = await condition();
|
||||
}
|
||||
return valeur;
|
||||
}
|
||||
|
||||
// La charge d'un fichier d'état (§ 8.9), ou null tant qu'il ne se lit pas.
|
||||
function lireEtat(chemin) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(chemin, 'utf8')).charge ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class EchecDEssai extends Error {}
|
||||
|
||||
// Imprime un constat ; un constat faux arrête la séance.
|
||||
function constater(libelle, vrai, detail) {
|
||||
console.log(`${vrai ? '[conforme]' : '[ÉCHEC] '} ${libelle}${detail === undefined ? '' : ` : ${detail}`}`);
|
||||
if (!vrai) throw new EchecDEssai(libelle);
|
||||
}
|
||||
|
||||
async function seance(application, dossiers) {
|
||||
const data = join(dossiers.exe, 'data');
|
||||
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
|
||||
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
|
||||
// la taille de la coquille lui est rendue, pour que chaque geste atteigne
|
||||
// un élément visible.
|
||||
await application.evaluate(({ BrowserWindow }) => {
|
||||
for (const w of BrowserWindow.getAllWindows()) w.setContentSize(1366, 768);
|
||||
});
|
||||
const collecteur = () => fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
|
||||
|
||||
// 1. Titre, bandeau, collecteur.
|
||||
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
|
||||
await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS });
|
||||
const titre = await fenetre.title();
|
||||
constater('titre de la fenêtre', titre === titreAvecVersion(VERSION.affichee), titre);
|
||||
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
|
||||
constater('bandeau portant la version affichée', bandeau.includes(VERSION.affichee), bandeau);
|
||||
const erreursAuDepart = await collecteur();
|
||||
constater('collecteur posé et vide', Array.isArray(erreursAuDepart) && erreursAuDepart.length === 0, JSON.stringify(erreursAuDepart));
|
||||
|
||||
// 2. Le dossier affiché, un événement créé, la démonstration chargée.
|
||||
const affiche = (await fenetre.locator('[data-dossier-travail]').textContent()).trim();
|
||||
constater('dossier de travail affiché', resolve(affiche) === resolve(data), affiche);
|
||||
await fenetre.locator('[data-ecran="accueil"] input[data-champ="nom"]').fill(NOM_EVENEMENT);
|
||||
await fenetre.locator('[data-ecran="accueil"] [data-commande="creer"]').click();
|
||||
await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS });
|
||||
constater('événement créé et ouvert', true, NOM_EVENEMENT);
|
||||
await fenetre.locator('[data-commande="liste"]').first().click();
|
||||
await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS });
|
||||
constater('événement créé dans la liste', (await fenetre.locator('[data-evenement]').count()) === 1);
|
||||
await fenetre.locator(`[data-demonstration="${DEMONSTRATION}"]`).click();
|
||||
await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS });
|
||||
constater('démonstration ouverte', true, DEMONSTRATION);
|
||||
|
||||
// 3. Le mode portable.
|
||||
const etats = fichiersSous(data, '.gtt.json');
|
||||
constater('deux .gtt.json sous exe/data', etats.length === 2, etats.join(', '));
|
||||
const egares = [dossiers.home, dossiers.temp].flatMap((d) => fichiersSous(d, '.gtt.json').map((f) => join(d, f)));
|
||||
constater('aucun .gtt.json sous home ni temp', egares.length === 0, egares.join(', ') || 'aucun');
|
||||
|
||||
// 4. « Modifier », le compte d'arrêt, « Générer ».
|
||||
await fenetre.locator('[data-commande="modifier"]').first().click();
|
||||
const verrous = await attendre(() => {
|
||||
const trouves = fichiersSous(data, '.gtt.verrou');
|
||||
return trouves.length > 0 ? trouves : null;
|
||||
});
|
||||
constater('« Modifier » prend un verrou sous exe/data', verrous !== null, (verrous ?? []).join(', '));
|
||||
const avant = new Set(etats);
|
||||
const ouvert = fichiersSous(data, '.gtt.json').find((f) => {
|
||||
const etat = lireEtat(join(data, f));
|
||||
return etat !== null && etat.evenement?.nom !== NOM_EVENEMENT && avant.has(f);
|
||||
});
|
||||
constater("fichier d'état de la démonstration", ouvert !== undefined, ouvert);
|
||||
const cheminOuvert = join(data, ouvert);
|
||||
await fenetre.locator('[data-commande="section"][data-cible="generation"]').click();
|
||||
const section = fenetre.locator('[data-section="generation"]');
|
||||
await section.waitFor({ timeout: ATTENTE_MS });
|
||||
const arret = section.locator('input[data-champ="generation.arret"]');
|
||||
await arret.fill(String(ARRET));
|
||||
await arret.press('Tab');
|
||||
const regle = await attendre(() => lireEtat(cheminOuvert)?.reglages?.generation?.arret === ARRET);
|
||||
constater('compte d’arrêt écrit', regle, String(lireEtat(cheminOuvert)?.reglages?.generation?.arret));
|
||||
const propositionsAvant = lireEtat(cheminOuvert)?.propositions?.length ?? 0;
|
||||
// Chaque génération prend un travailleur neuf (src/main.js) : Playwright
|
||||
// en signale la naissance, qui attend son rattachement avant d'exécuter
|
||||
// son script. Un exécuteur qui calcule dans la page n'en fait naître aucun.
|
||||
constater('page chargée sous file://', fenetre.url().startsWith('file:'), fenetre.url());
|
||||
const travailleurs = [];
|
||||
fenetre.on('worker', (travailleur) => travailleurs.push(travailleur.url()));
|
||||
await section.locator('[data-commande="generer"]').click();
|
||||
// La première fin qui se montre : l'issue, un refus dans la section, ou
|
||||
// une erreur recueillie — un travailleur qui ne se charge pas n'affiche
|
||||
// ni l'une ni l'autre, et le collecteur seul le dit.
|
||||
const texte = async (selecteur) => (await section.locator(selecteur).first().textContent()).replace(/\s+/g, ' ').trim();
|
||||
const fin = await attendre(async () => {
|
||||
if ((await section.locator('[data-refus]').count()) > 0) return { issue: false, detail: await texte('[data-refus]') };
|
||||
const recueillies = await collecteur();
|
||||
if (recueillies?.length > 0) return { issue: false, detail: JSON.stringify(recueillies) };
|
||||
if ((await section.locator('[data-issue]').count()) > 0) return { issue: true, detail: await texte('[data-issue]') };
|
||||
return null;
|
||||
});
|
||||
constater('génération achevée, sans refus ni erreur', fin?.issue === true, fin?.detail ?? `rien en ${ATTENTE_MS} ms`);
|
||||
constater(
|
||||
'un travailleur de génération né après « Générer »',
|
||||
travailleurs.length > 0,
|
||||
travailleurs.length === 0 ? 'aucun' : travailleurs.map((url) => url.slice(0, 64)).join(', '),
|
||||
);
|
||||
const ajoutees = await attendre(() => {
|
||||
const propositions = lireEtat(cheminOuvert)?.propositions ?? [];
|
||||
return propositions.length > propositionsAvant ? propositions.slice(propositionsAvant) : null;
|
||||
});
|
||||
constater(
|
||||
`propositions ajoutées au fichier d'état, chacune arrêtée à ${ARRET} mouvements`,
|
||||
ajoutees !== null && ajoutees.every((p) => p.arret === ARRET),
|
||||
ajoutees === null ? 'aucune' : ajoutees.map((p) => `${p.id}:${p.arret}`).join(', '),
|
||||
);
|
||||
|
||||
// 5. Collecteur, fermeture, verrous.
|
||||
const erreurs = await collecteur();
|
||||
constater('collecteur vide après la séance', Array.isArray(erreurs) && erreurs.length === 0, JSON.stringify(erreurs));
|
||||
const sortie = application.waitForEvent('close', { timeout: ATTENTE_MS });
|
||||
await application.evaluate(({ BrowserWindow }) => {
|
||||
for (const w of BrowserWindow.getAllWindows()) w.close();
|
||||
});
|
||||
await sortie;
|
||||
constater('la fermeture de la fenêtre termine la coquille', true);
|
||||
const restants = fichiersSous(data, '.gtt.verrou');
|
||||
constater('aucun .gtt.verrou sous exe/data après la sortie', restants.length === 0, restants.join(', ') || 'aucun');
|
||||
}
|
||||
|
||||
async function principal() {
|
||||
const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1';
|
||||
const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
||||
|
||||
// npm est un script .cmd sous Windows, que Node ne lance que par un shell.
|
||||
execFileSync('npm', ['run', '--silent', 'build'], {
|
||||
cwd: RACINE,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
const racine = mkdtempSync(join(tmpdir(), 'gtt-essai-demarrage-'));
|
||||
const dossiers = dossiersDEssai(racine);
|
||||
const env = environnementDEssai(process.env, dossiers);
|
||||
for (const dossier of [...Object.values(dossiers), env.APPDATA, env.LOCALAPPDATA]) mkdirSync(dossier, { recursive: true });
|
||||
let conforme = false;
|
||||
let retrait = null;
|
||||
try {
|
||||
// Un exe sous des données applicatives ferait retenir les Documents du
|
||||
// poste, où la sonde écrirait : rien n'est lancé.
|
||||
const portent = donneesApplicativesSurExe(env, process.env, process.platform);
|
||||
constater(
|
||||
'exe hors des données applicatives de la coquille',
|
||||
portent.length === 0,
|
||||
portent.length === 0 ? dossiers.exe : `${dossiers.exe} sous ${portent.join(', ')}`,
|
||||
);
|
||||
const { _electron } = await import('playwright');
|
||||
const application = await _electron.launch({
|
||||
args: [...(sansBacASable ? ['--no-sandbox'] : []), ...(sansEcran ? ['--ozone-platform=headless'] : []), '.'],
|
||||
cwd: RACINE,
|
||||
env,
|
||||
timeout: ATTENTE_MS,
|
||||
});
|
||||
try {
|
||||
await seance(application, dossiers);
|
||||
conforme = true;
|
||||
} finally {
|
||||
await application.close().catch(() => {});
|
||||
}
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof EchecDEssai)) console.error(erreur);
|
||||
} finally {
|
||||
// Un retrait refusé (EBUSY sous Windows, un fichier encore ouvert) ne
|
||||
// masque pas le verdict : il s'imprime après lui, en avertissement, et
|
||||
// ne change pas le code de sortie.
|
||||
try {
|
||||
rmSync(racine, { recursive: true, force: true });
|
||||
} catch (erreur) {
|
||||
retrait = erreur;
|
||||
}
|
||||
}
|
||||
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
|
||||
if (retrait !== null) console.warn(`Avertissement : ${racine} reste en place, son retrait a échoué : ${retrait.message}`);
|
||||
process.exit(conforme ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai quand script, le fichier que node exécute (process.argv[1]), est
|
||||
* fichier, ce module. Les deux chemins se comparent résolus par realpath :
|
||||
* un script atteint par un lien symbolique est le fichier qu'il désigne. Un
|
||||
* script absent ou illisible n'est pas ce module.
|
||||
* @param {string|undefined} script
|
||||
* @param {string} [fichier]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function estLanceDirectement(script, fichier = fileURLToPath(import.meta.url)) {
|
||||
if (script === undefined) return false;
|
||||
try {
|
||||
return realpathSync(script) === realpathSync(fichier);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// L'essai ne part que lorsque node exécute ce fichier ; l'épreuve qui
|
||||
// l'importe n'en lit que les fonctions.
|
||||
if (estLanceDirectement(process.argv[1])) await principal();
|
||||
|
|
|
|||
177
scripts/essai_demarrage.test.js
Normal file
177
scripts/essai_demarrage.test.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'environnement où l'essai de démarrage lance la coquille (§ 8.6) : trois
|
||||
// dossiers distincts sous la racine de l'essai, aucun sous un autre ; le
|
||||
// lanceur portable publie exe, le poste a home pour dossier personnel et
|
||||
// temp pour dossier temporaire, et la configuration XDG du poste réel n'y
|
||||
// entre pas. Le module se charge sans rien lancer : l'essai ne part que
|
||||
// lorsque node l'exécute.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { dirname, join, relative, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decrireSysteme } from '../electron/fichiers.js';
|
||||
import { estSous } from '../src/stockage/dossier_travail.js';
|
||||
import { describe, test } from '../test/lanceur.js';
|
||||
import { donneesApplicativesSurExe, dossiersDEssai, environnementDEssai, estLanceDirectement } from './essai_demarrage.js';
|
||||
|
||||
const RACINE_ESSAI = join('/', 'racine-essai', 'gtt-essai-demarrage-x');
|
||||
|
||||
// Vrai quand b est a ou se trouve sous a.
|
||||
const sous = (a, b) => {
|
||||
const chemin = relative(a, b);
|
||||
return chemin === '' || (!chemin.startsWith('..') && !isAbsolute(chemin));
|
||||
};
|
||||
|
||||
describe('essai de démarrage : dossiers et environnement (§ 8.6)', () => {
|
||||
test('trois dossiers distincts sous la racine, aucun sous un autre', () => {
|
||||
const dossiers = dossiersDEssai(RACINE_ESSAI);
|
||||
const noms = ['exe', 'home', 'temp'];
|
||||
assert.deepEqual(Object.keys(dossiers).sort(), noms);
|
||||
const chemins = noms.map((nom) => dossiers[nom]);
|
||||
assert.equal(chemins.length, 3);
|
||||
for (const chemin of chemins) assert.ok(sous(RACINE_ESSAI, chemin) && chemin !== RACINE_ESSAI, chemin);
|
||||
for (const a of chemins) {
|
||||
for (const b of chemins) if (a !== b) assert.ok(!sous(a, b), `${b} sous ${a}`);
|
||||
}
|
||||
assert.equal(new Set(chemins).size, 3);
|
||||
});
|
||||
|
||||
test('exe publié, home et temp posés, XDG_CONFIG_HOME retiré, le reste gardé', () => {
|
||||
const dossiers = dossiersDEssai(RACINE_ESSAI);
|
||||
const poste = Object.freeze({
|
||||
HOME: join('/', 'maison-originale'),
|
||||
XDG_CONFIG_HOME: join('/', 'maison-originale', 'configuration'),
|
||||
TMPDIR: join('/', 'temporaire-du-poste'),
|
||||
PATH: join('/', 'outils'),
|
||||
LANG: 'fr_CA.UTF-8',
|
||||
});
|
||||
const env = environnementDEssai(poste, dossiers);
|
||||
assert.equal(env.PORTABLE_EXECUTABLE_DIR, dossiers.exe);
|
||||
assert.equal(env.HOME, dossiers.home);
|
||||
assert.equal(env.TMPDIR, dossiers.temp);
|
||||
assert.equal(env.TEMP, dossiers.temp);
|
||||
assert.equal(env.TMP, dossiers.temp);
|
||||
assert.ok(!Object.hasOwn(env, 'XDG_CONFIG_HOME'));
|
||||
assert.equal(env.PATH, poste.PATH);
|
||||
assert.equal(env.LANG, poste.LANG);
|
||||
// L'environnement du poste reste tel quel.
|
||||
assert.equal(poste.HOME, join('/', 'maison-originale'));
|
||||
});
|
||||
|
||||
test('sous X11 sans XAUTHORITY, le fichier d’autorisation reste celui du dossier personnel réel', () => {
|
||||
const dossiers = dossiersDEssai(RACINE_ESSAI);
|
||||
const maison = join('/', 'maison-originale');
|
||||
const avecX = environnementDEssai({ HOME: maison, DISPLAY: ':9' }, dossiers);
|
||||
assert.equal(avecX.XAUTHORITY, join(maison, '.Xauthority'));
|
||||
const pose = join('/', 'autorisation-posee');
|
||||
assert.equal(environnementDEssai({ HOME: maison, DISPLAY: ':9', XAUTHORITY: pose }, dossiers).XAUTHORITY, pose);
|
||||
assert.ok(!Object.hasOwn(environnementDEssai({ HOME: maison }, dossiers), 'XAUTHORITY'));
|
||||
});
|
||||
|
||||
test('sous Windows, un temporaire du poste rangé sous LOCALAPPDATA ne met pas exe sous les données applicatives', () => {
|
||||
const W = path.win32;
|
||||
const profil = 'C:\\Users\\profil-essai';
|
||||
const local = W.join(profil, 'AppData', 'Local');
|
||||
const poste = Object.freeze({
|
||||
USERPROFILE: profil,
|
||||
APPDATA: W.join(profil, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: local,
|
||||
TEMP: W.join(local, 'Temp'),
|
||||
TMP: W.join(local, 'Temp'),
|
||||
});
|
||||
const dossiers = dossiersDEssai(W.join(poste.TEMP, 'gtt-essai-demarrage-x'), W);
|
||||
const env = environnementDEssai(poste, dossiers, W);
|
||||
// Les données applicatives de la coquille (§ 8.6 point 2) : AppData,
|
||||
// dossier connu que l'environnement ne déplace pas, le LOCALAPPDATA de
|
||||
// son environnement, et le temporaire, que TMP désigne.
|
||||
const emplacements = { insensibleCasse: true, separateur: '\\' };
|
||||
assert.equal(env.PORTABLE_EXECUTABLE_DIR, W.join(poste.TEMP, 'gtt-essai-demarrage-x', 'exe'));
|
||||
assert.deepEqual(
|
||||
[poste.APPDATA, env.LOCALAPPDATA, env.TMP].filter((dossier) => estSous(dossiers.exe, dossier, emplacements)),
|
||||
[],
|
||||
);
|
||||
// Le profil, AppData et LOCALAPPDATA de la coquille sont sous home.
|
||||
assert.equal(env.USERPROFILE, dossiers.home);
|
||||
assert.ok(estSous(env.APPDATA, dossiers.home, emplacements) && env.APPDATA !== dossiers.home, env.APPDATA);
|
||||
assert.ok(estSous(env.LOCALAPPDATA, dossiers.home, emplacements) && env.LOCALAPPDATA !== dossiers.home, env.LOCALAPPDATA);
|
||||
assert.deepEqual(donneesApplicativesSurExe(env, poste, 'win32'), []);
|
||||
assert.equal(poste.LOCALAPPDATA, local);
|
||||
});
|
||||
|
||||
test("la garde nomme les données applicatives qui portent exe, avant tout lancement", () => {
|
||||
// Sous Linux : des chemins composés par path.posix de bout en bout, que
|
||||
// le système hôte de l'épreuve ne change pas.
|
||||
const P = path.posix;
|
||||
const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x');
|
||||
const dossiers = dossiersDEssai(R, P);
|
||||
const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P);
|
||||
assert.deepEqual(donneesApplicativesSurExe(env, {}, 'linux'), []);
|
||||
// Le temporaire de la coquille au-dessus d'exe.
|
||||
assert.deepEqual(donneesApplicativesSurExe({ ...env, TMPDIR: R }, {}, 'linux'), [R]);
|
||||
// Le dossier de configuration XDG au-dessus d'exe.
|
||||
assert.deepEqual(donneesApplicativesSurExe({ ...env, XDG_CONFIG_HOME: R }, {}, 'linux'), [R]);
|
||||
// Sous Windows : l'AppData du poste, dossier connu, que l'environnement
|
||||
// de la coquille ne déplace pas ; puis le LOCALAPPDATA de la coquille.
|
||||
const W = path.win32;
|
||||
const racine = W.join('D:\\', 'essais', 'gtt-essai-demarrage-x');
|
||||
const dossiersW = dossiersDEssai(racine, W);
|
||||
const envW = environnementDEssai({}, dossiersW, W);
|
||||
assert.deepEqual(donneesApplicativesSurExe(envW, {}, 'win32'), []);
|
||||
assert.deepEqual(donneesApplicativesSurExe(envW, { APPDATA: W.join('D:\\', 'essais') }, 'win32'), [
|
||||
W.join('D:\\', 'essais'),
|
||||
]);
|
||||
assert.deepEqual(donneesApplicativesSurExe({ ...envW, LOCALAPPDATA: 'd:\\ESSAIS' }, {}, 'win32'), ['d:\\ESSAIS']);
|
||||
// Sans exécutable publié, rien ne le porte.
|
||||
assert.deepEqual(donneesApplicativesSurExe({ TMPDIR: '/' }, {}, 'linux'), []);
|
||||
});
|
||||
|
||||
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');
|
||||
const dossiers = dossiersDEssai(R, P);
|
||||
const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P);
|
||||
const autre = P.join('/', 'racine-essai');
|
||||
// Une coquille qui range aussi GTT_DONNEES parmi ses données applicatives.
|
||||
const avecUnDossier = (poste) => {
|
||||
const decrit = decrireSysteme(poste);
|
||||
const donneesApplicatives = [...decrit.emplacements.donneesApplicatives, poste.env.GTT_DONNEES];
|
||||
return { ...decrit, emplacements: { ...decrit.emplacements, donneesApplicatives } };
|
||||
};
|
||||
assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: autre }, {}, 'linux', avecUnDossier), [autre]);
|
||||
assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: P.join('/', 'ailleurs') }, {}, 'linux', avecUnDossier), []);
|
||||
// Une coquille qui lit un autre dossier du système : l'essai le refuse
|
||||
// plutôt que de juger une liste incomplète.
|
||||
const avecUserData = (poste) => {
|
||||
poste.cheminSysteme('userData');
|
||||
return decrireSysteme(poste);
|
||||
};
|
||||
assert.throws(() => donneesApplicativesSurExe(env, {}, 'linux', avecUserData), /userData/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('essai de démarrage : lancement direct', () => {
|
||||
const MODULE = fileURLToPath(new URL('./essai_demarrage.js', import.meta.url));
|
||||
|
||||
// Le lien désigne le dossier du module : une jonction sous Windows, qui se
|
||||
// crée sans privilège, un lien symbolique ailleurs.
|
||||
test('le script atteint par un lien symbolique est le module', () => {
|
||||
const dossier = mkdtempSync(join(tmpdir(), 'gtt-essai-lien-'));
|
||||
try {
|
||||
const lien = join(dossier, 'scripts');
|
||||
symlinkSync(dirname(MODULE), lien, 'junction');
|
||||
assert.equal(estLanceDirectement(join(lien, 'essai_demarrage.js')), true);
|
||||
assert.equal(estLanceDirectement(MODULE), true);
|
||||
} finally {
|
||||
rmSync(dossier, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('un autre script, un script absent ou aucun script ne lance pas l’essai', () => {
|
||||
assert.equal(estLanceDirectement(fileURLToPath(import.meta.url)), false);
|
||||
assert.equal(estLanceDirectement(join(tmpdir(), 'gtt-essai-absent', 'essai_demarrage.js')), false);
|
||||
assert.equal(estLanceDirectement(undefined), false);
|
||||
});
|
||||
});
|
||||
|
|
@ -574,6 +574,16 @@ describe('version : contrôle du § 18.5, un défaut à la fois', () => {
|
|||
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]);
|
||||
}));
|
||||
|
||||
test('point 6 : un composant de src/interface qui porte la version affichée en dur', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'src/interface/Bandeau.svelte',
|
||||
"<script>\n let { version } = $props();\n</script>\n\n<span class=\"version\">{version}</span>\n<span>v2026.10.05.01</span>\n",
|
||||
);
|
||||
exigerEchecs(racine, [6], [/^6\. src\/interface\/Bandeau\.svelte, ligne 6 : « 2026\.10\.05\.01 », forme affichée/]);
|
||||
}));
|
||||
|
||||
test('point 6 : une forme technique hors de package.json et du module engendré', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n");
|
||||
|
|
|
|||
380
spec.md
380
spec.md
|
|
@ -61,10 +61,11 @@ Aucun des deux ne dépend de l'étape atteinte.
|
|||
jamais l'un sans l'autre (§ 8.2) ;
|
||||
2. il se reprend par un retour à un instant, et le journal ne se tronque jamais
|
||||
(§ 8.3) ;
|
||||
3. s'il est refusé, le refus nomme sa cause et le geste qui la lève, **là où le
|
||||
geste a eu lieu** : sur la place pour un geste du plan (§ 5.9), dans
|
||||
l'aperçu d'import pour une ligne de CSV (§ 10.1), sur le champ pour une
|
||||
saisie. Seul un geste qui détruit de la saisie ouvre une fenêtre à confirmer.
|
||||
3. s'il est refusé, le refus nomme sa cause et, quand un geste la lève, ce
|
||||
geste (§ 5.9), **là où le geste a eu lieu** : sur la place pour un geste du
|
||||
plan, dans l'aperçu d'import pour une ligne de CSV (§ 10.1), sur le champ
|
||||
pour une saisie. Seul un geste qui détruit de la saisie ouvre une fenêtre à
|
||||
confirmer.
|
||||
|
||||
Les étapes 8 et 10 — lire le diagnostic, comparer — ne modifient rien et restent
|
||||
vivantes en lecture (§ 8.4).
|
||||
|
|
@ -102,9 +103,10 @@ en bloc une baisse qu'une seule table refuse (§ 6.1).
|
|||
retrouve les **ancrages** intacts, parce que la portée « tous les tours » n'est
|
||||
jamais développée (§ 4.3). La protection s'arrête là, et le logiciel le dit
|
||||
**avant** d'appliquer : retirer le tour *r* détruit les réservations de portée
|
||||
« tour désigné » portant sur *r*, et les tours au-delà de *r* des placements
|
||||
déjà engendrés. Les annoncer après coup ferait découvrir la perte à
|
||||
l'impression.
|
||||
« tour désigné » portant sur *r*. Les placements déjà engendrés, eux, ne sont
|
||||
pas tronqués : ils ne portent plus le nombre de tours de la soirée, restent
|
||||
entiers **en dérive** (§ 9), et le logiciel nomme ceux que le changement
|
||||
touche. Les annoncer après coup ferait découvrir la perte à l'impression.
|
||||
|
||||
**6. Désigner les animateurs, table par table.** *Facultative — une soirée sans
|
||||
aucun animateur est une configuration valide, et la petite démonstration en est
|
||||
|
|
@ -339,7 +341,9 @@ aperçoive**.
|
|||
|
||||
Moyennant cet ajout, le moteur n'a **qu'un seul chemin de code** : la
|
||||
réservation est un prétraitement qui réduit les capacités, retire des
|
||||
participants et annote les tables — jamais un second algorithme.
|
||||
participants et annote les tables — jamais un second algorithme. Une garde de
|
||||
l'arborescence le tient : dans le moteur, seuls la normalisation de la
|
||||
configuration et le diagnostic lisent les réservations.
|
||||
|
||||
### 5.3 Les contraintes, activables une à une
|
||||
|
||||
|
|
@ -391,10 +395,10 @@ une lecture et non un jugement :
|
|||
| **manque** | plafond réalisé − rencontres (§ 5.5) — la grandeur qui compare deux propositions (§ 12.10) |
|
||||
| **écart d'itinéraire** | plafond a priori − plafond réalisé (§ 5.5, § 12.10) |
|
||||
|
||||
**Les agrégats portent sur tous les participants placés.** Le minimum et la
|
||||
moyenne se calculent sur l'ensemble, ancrés compris, et **deux lignes
|
||||
secondaires** donnent la même mesure restreinte aux **mobiles** et aux
|
||||
**ancrés**. Un participant partiellement fixé compte parmi les mobiles.
|
||||
**Les agrégats portent sur tous les participants placés.** Le minimum se calcule
|
||||
sur l'ensemble, ancrés compris, et **deux lignes secondaires** donnent la même
|
||||
mesure restreinte aux **mobiles** et aux **ancrés**. Un participant
|
||||
partiellement fixé compte parmi les mobiles.
|
||||
|
||||
> Un ancré n'est pas structurellement désavantagé : à table de 8 avec un seul
|
||||
> ancré et R = 4, son plafond vaut 28, exactement celui d'un mobile visitant
|
||||
|
|
@ -404,8 +408,16 @@ secondaires** donnent la même mesure restreinte aux **mobiles** et aux
|
|||
> global désigne un défaut qu'une relance corrige, ou la conséquence de son
|
||||
> propre plan de réservation.
|
||||
|
||||
**Le minimum compte plus que la moyenne.** Il décrit le sort de la personne la
|
||||
plus mal servie, qui est celle qui se plaindra.
|
||||
**Des rencontres, le minimum s'affiche, jamais la moyenne.** Le minimum décrit
|
||||
le sort de la personne la plus mal servie, qui est celle qui se plaindra. La
|
||||
moyenne ne sépare rien : quand les tables sont pleines à chaque tour, le nombre
|
||||
de voisinages créés est fixé par le mobilier, et la moyenne est la même pour
|
||||
toutes les propositions (§ 12.8).
|
||||
|
||||
**La redondance se lit par sa somme**, Σ r(p), la grandeur que la recherche et
|
||||
le classement lisent (§ 5.7). Elle va dans l'autre sens que les autres
|
||||
colonnes — plus elle est grande, pire est le sort —, et son minimum désignerait
|
||||
la personne la mieux servie.
|
||||
|
||||
**Les deux unités de collision ne se mélangent jamais.** Un chiffre de collision
|
||||
cité quelque part — § 5.6, § 12.4, § 15.3, une étape du pilotage — **nomme son
|
||||
|
|
@ -668,6 +680,12 @@ Une fois un placement retenu, le même geste déplace une personne réelle. **Le
|
|||
geste est identique ; ce qui change est l'état des données**, et l'interface le
|
||||
dit, pour que l'opérateur sache lequel des deux il fait.
|
||||
|
||||
**Le placement se prépare, il ne suit pas la salle.** Retirer une personne du
|
||||
placement retenu libère sa chaise, et personne d'autre ne change de siège ni de
|
||||
table : la chaise reste vide jusqu'à ce qu'un geste y assoie quelqu'un, et un
|
||||
geste n'assied que les personnes qu'il nomme. Quand les sièges sont attribués,
|
||||
le fichier écrit cette chaise vide à sa place (§ 8.9).
|
||||
|
||||
**Il n'existe aucune commande qui répartit les animateurs sur les tables.**
|
||||
L'opérateur désigne lui-même quelle personne anime quelle table (§ 2.1,
|
||||
étape 6). Une commande qui attribuerait un animateur par numéro de table le
|
||||
|
|
@ -715,6 +733,18 @@ Un refus **nomme sa cause et propose le geste qui le lève**, et se manifeste
|
|||
détruit de la saisie ouvre une fenêtre à confirmer : une suite de modales
|
||||
apprend à cliquer sans lire, et le premier refus qui comptait passe inaperçu.
|
||||
|
||||
**Quand aucun geste ne lève le refus, il nomme sa cause seule.** Ainsi d'une
|
||||
valeur qui se corrige dans le champ même, dont le texte dit la valeur attendue —
|
||||
moins de deux sièges, une saisie non entière, moins d'un tour, un libellé ou un
|
||||
nom vides ; d'une cible absente ou disparue entre-temps — une personne, une
|
||||
proposition, une réservation, un titre, des réservations à convertir —,
|
||||
qu'aucune commande ne fait revenir et sur laquelle l'interface n'offre pas le
|
||||
geste ; d'un compteur au bout de sa règle, d'un plan qui n'est plus bloqué, d'un
|
||||
geste sans effet, et d'un refus du CSV relayé par l'import, qui se lève dans le
|
||||
fichier ou dans l'association de ses colonnes — `NOM_NON_ASSOCIE` quand aucune
|
||||
colonne n'est associée au nom. Un remède posé pour la forme enverrait
|
||||
l'opérateur vers un geste qui ne change rien.
|
||||
|
||||
### 5.10 Le coût d'évaluation
|
||||
|
||||
A(p) et r(p) demandent de parcourir les personnes rencontrées par chacun.
|
||||
|
|
@ -737,15 +767,20 @@ d'écran (§ 19.4) tomberait avec. L'interdit de `performance.now` dans le moteu
|
|||
|
||||
**Le compte est un réglage, et son plafond d'usage est de trente minutes.** Il
|
||||
est exposé dans les réglages de génération, avec son unité ; le logiciel affiche
|
||||
à côté la **durée réellement observée du dernier calcul sur cette machine**, qui
|
||||
est la seule façon d'étalonner un compte. Le réglage livré par défaut est celui
|
||||
qui, sur la machine de référence, reste bien en deçà de ce plafond.
|
||||
à côté la **durée réellement observée du dernier calcul de la séance**, qui est
|
||||
la seule façon d'étalonner un compte. Elle vaut pour la séance et ne se garde
|
||||
pas au-delà — rien ne survit à la séance hors du dossier de travail (§ 8.6) : au
|
||||
premier calcul d'un lancement, aucune mesure n'existe, et l'estimation se dit
|
||||
inconnue. Le réglage livré par défaut est celui qui, sur la machine de
|
||||
référence, reste bien en deçà de ce plafond.
|
||||
|
||||
**Une génération longue s'annonce avant de partir.** À partir de la dernière
|
||||
durée mesurée, le logiciel énonce l'ordre de grandeur attendu — « environ vingt
|
||||
minutes à ce réglage » — et demande. Une recherche d'une demi-heure lancée par
|
||||
mégarde est une demi-heure de soirée perdue, et aucun réglage par défaut ne
|
||||
protège de cela.
|
||||
minutes à ce réglage » — et, **au-delà d'une minute estimée**, demande, par une
|
||||
question posée en place, sans fenêtre. Sans mesure, il demande dès que les
|
||||
mouvements demandés — propositions × compte d'arrêt — dépassent dix fois ceux du
|
||||
réglage par défaut. Une recherche d'une demi-heure lancée par mégarde est une
|
||||
demi-heure de soirée perdue, et aucun réglage par défaut ne protège de cela.
|
||||
|
||||
**Pendant la recherche, l'application reste vivante.** Le calcul ne tient pas le
|
||||
fil d'exécution de l'interface : le plan reste lisible, la page de qualité des
|
||||
|
|
@ -821,10 +856,16 @@ table refuse, l'opération entière est refusée, nommément, et rien ne change.
|
|||
sièges disparaissent par numéro décroissant ; le refus les liste.
|
||||
- Une capacité non entière, négative, ou saisie en texte.
|
||||
|
||||
Deux cas **avertissent sans refuser** : réduire sous le nombre de personnes
|
||||
assises (les surnuméraires vont dans la réserve visible, nommément listées,
|
||||
jamais effacées en silence) ; et dépasser le nombre de sièges au-delà duquel le
|
||||
dessin cesse d'être lisible — seuil **mesuré** au sens du § 14.1.
|
||||
Deux cas **avertissent sans refuser** : réduire **par le champ** sous le nombre
|
||||
de personnes assises (les surnuméraires vont dans la réserve visible, nommément
|
||||
listées, jamais effacées en silence) ; et dépasser le nombre de sièges au-delà
|
||||
duquel le dessin cesse d'être lisible — seuil **mesuré** au sens du § 14.1.
|
||||
|
||||
**Le champ avertit, la poignée refuse.** Saisir une capacité est un geste
|
||||
délibéré, fait en lisant le nombre ; tirer une poignée est un geste de
|
||||
géométrie, fait sans regarder les autres tours : la même réduction y est refusée
|
||||
(§ 7.1). Un changement de défaut (§ 6.1) se contrôle, table par table, comme une
|
||||
saisie au champ.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -840,7 +881,7 @@ l'opérateur agit directement dessus.
|
|||
| cliquer une chaise | sélectionne la place — l'assigner, la réserver, lui donner un titre |
|
||||
| glisser une personne sur un siège libre | la déplace |
|
||||
| glisser une personne sur une autre | les échange |
|
||||
| glisser une personne hors des tables | la retire du tour ; elle attend dans la **réserve** visible |
|
||||
| glisser une personne hors des tables | la retire du tour ; elle attend dans la **réserve** visible, et sa chaise reste vide (§ 5.8) |
|
||||
| glisser une table | change sa position |
|
||||
| tirer la poignée d'une table | **change son nombre de places** |
|
||||
| survoler une table en conflit | affiche le détail des règles violées |
|
||||
|
|
@ -858,7 +899,13 @@ chaises apparaissent et disparaissent sous le pointeur.
|
|||
**La poignée refuse de descendre sous le nombre de sièges occupés, tous tours
|
||||
confondus** — sinon un geste de géométrie désassoit deux personnes au troisième
|
||||
tour, que l'opérateur ne regarde pas. Le logiciel nomme le nombre bloquant et le
|
||||
tour concerné.
|
||||
tour concerné. Le champ de capacité, saisie délibérée, accepte la même réduction
|
||||
et l'annonce (§ 6.2).
|
||||
|
||||
**Une personne qu'une réservation tient à sa table ne se retire pas du tour.**
|
||||
Le refus nomme la réservation et offre « Retirer la réservation » : une
|
||||
réservation se défait par sa propre commande, jamais en passant par un
|
||||
glissement.
|
||||
|
||||
**La rotation d'une table carrée n'existe pas dans la première itération** :
|
||||
elle introduirait une orientation dans la numérotation des sièges et dans la
|
||||
|
|
@ -897,7 +944,10 @@ unités, et le plan répondrait faux partout.
|
|||
|
||||
Une épreuve en navigateur confronte la fonction pure à
|
||||
`getScreenCTM().inverse()`. Ce n'est pas une seconde implémentation : c'est un
|
||||
**oracle** fourni par le moteur de rendu.
|
||||
**oracle** fourni par le moteur de rendu. L'écart admis est d'**un millième de
|
||||
pixel d'écran** : le moteur de rendu lit les nombres de l'attribut `transform`
|
||||
en flottants de 32 bits et compose sa matrice à cette précision, quand une
|
||||
violation de l'un des trois invariants décale le plan d'un pixel au moins.
|
||||
|
||||
**Le zoom se fait sous le curseur**, sinon le contenu fuit vers un coin et
|
||||
l'opérateur poursuit la table qu'il visait.
|
||||
|
|
@ -955,6 +1005,13 @@ et la table devient la cible**, le siège se choisissant dans son panneau. Le
|
|||
logiciel le montre en cessant de dessiner les chaises individuellement, plutôt
|
||||
qu'en laissant l'opérateur manquer sa cible sans comprendre.
|
||||
|
||||
**Le seuil se décide table par table**, sur le pas de chacune — la distance
|
||||
entre deux sièges voisins au bord du plateau. Au même zoom, une petite table
|
||||
ronde, dont le plancher du § 7.7 écarte les sièges de plus de 60 cm, garde ses
|
||||
sièges pour cibles quand ses voisines de 8 n'en ont plus. Un seuil unique, réglé
|
||||
sur la table la plus serrée, retirerait ses sièges à une table qui les montre
|
||||
pointables.
|
||||
|
||||
**Il n'y a pas de survol au doigt** : le détail d'un conflit est accessible par
|
||||
survol, par focus clavier **et par pression longue** — un seul composant
|
||||
d'infobulle, trois déclencheurs.
|
||||
|
|
@ -1033,16 +1090,30 @@ diamètre = max(70, places × 60 / π) en centimètres
|
|||
```
|
||||
|
||||
Soit 115 cm pour 6 places, 153 pour 8, 191 pour 10. **Le plancher n'est pas
|
||||
cosmétique** : en deçà de six convives ce n'est plus le périmètre qui borne la
|
||||
table mais le plateau, et la formule seule dessinerait des guéridons. Les deux
|
||||
valeurs sont des **réglages par défaut, non des normes vérifiées** ; un diamètre
|
||||
saisi à la main l'emporte.
|
||||
cosmétique** : en deçà de quatre convives — la formule atteint 70 cm à 3,67
|
||||
places — ce n'est plus le périmètre qui borne la table mais le plateau, et la
|
||||
formule seule dessinerait des guéridons. Les deux valeurs sont des **réglages
|
||||
par défaut, non des normes vérifiées** ; un diamètre saisi à la main l'emporte,
|
||||
dès que le fichier d'état peut le porter : le format 1 n'a pas de champ pour
|
||||
lui, et toute table s'y dessine selon la formule.
|
||||
|
||||
**Les étiquettes ne sont pas à l'échelle, le mobilier l'est.** Une liste de dix
|
||||
noms couvre plusieurs mètres carrés fictifs. **Le plan à l'échelle du bâtiment
|
||||
est le plan listes masquées**, et le logiciel le dit sur chaque impression
|
||||
portant les listes.
|
||||
|
||||
**Le bloc de noms d'une table se pose à droite de son emprise** — le plateau et
|
||||
ses chaises —, à 36 cm d'elle : l'écart laisse passer le contour de focus de la
|
||||
table sans qu'aucune marque couvre la liste. Sa largeur est fixée dans le
|
||||
dessin, et un nom trop long s'y abrège (§ 7.4). **Aucune liste ne passe sur les
|
||||
chaises d'une voisine posée sur la grille** : une table créée prend la première
|
||||
case libre d'une grille dont les colonnes s'écartent d'une emprise, de deux
|
||||
écarts et d'un bloc de noms, à un pas entier au moins, sur l'un des deux axes,
|
||||
de toute table déjà posée, et les démonstrations (§ 15) sont rangées sur la même
|
||||
grille. Le prix est la place : au zoom ajusté, une grande salle descend sous le
|
||||
seuil qui retire les listes (§ 17, point 6), et un grand événement peut s'ouvrir
|
||||
sans listes de noms, qui se dessinent quand l'opérateur rapproche la vue.
|
||||
|
||||
**Tant qu'aucune dimension de salle n'est saisie, le plan est exact dans ses
|
||||
tables et arbitraire dans son cadre.** Le logiciel porte alors, sur le plan et
|
||||
sur **chaque impression**, la mention « disposition relative — l'encombrement
|
||||
|
|
@ -1201,6 +1272,14 @@ qu'un calcul tourne. Le retour **automatique** ne peut donc jamais tomber
|
|||
pendant un geste ; le retour **explicite**, lui, annule un glissement en cours
|
||||
comme le ferait un `pointercancel`.
|
||||
|
||||
**Est une activité la fin d'un geste**, qui relance le compte : un geste achevé
|
||||
ou refusé, une saisie validée ou abandonnée, une fenêtre à confirmer refermée,
|
||||
et tout appui relâché sur le plan — un clic qui n'exécute rien et un déplacement
|
||||
de vue compris. Regarder sans appuyer n'en est pas une : zoomer à la molette,
|
||||
une commande de vue, changer de tour affiché ne relancent rien. Le retour
|
||||
automatique se voit au cadre, au libellé et au bandeau, et un avertissement non
|
||||
modal le dit jusqu'au passage en écriture suivant.
|
||||
|
||||
**Le mode est visible sur la surface de travail elle-même** — un cadre et un
|
||||
libellé permanents autour du plan — et non dans un coin de barre d'outils :
|
||||
l'opérateur regarde le plan.
|
||||
|
|
@ -1299,7 +1378,9 @@ règle écrite, et l'affiche :
|
|||
sauvegarde est la copie d'un seul dossier.
|
||||
5. L'épreuve échoue, ou le point 2 l'a interrompue → le dossier de travail est
|
||||
un dossier nommé d'après le produit, dans les *Documents* de l'opérateur. Le
|
||||
logiciel **dit pourquoi**, une fois, à la première ouverture, sans bloquer.
|
||||
logiciel **dit pourquoi**, une fois par séance, sans bloquer : s'en souvenir
|
||||
d'une séance à l'autre demanderait un réglage local de plus, et le § 8.5 les
|
||||
borne à deux.
|
||||
|
||||
**Le dossier retenu est éprouvé à son tour, et c'est cette épreuve qui le
|
||||
crée** : le dossier des *Documents* naît à la détermination, avant que la liste
|
||||
|
|
@ -1454,7 +1535,8 @@ plateforme**.
|
|||
parce que l'enregistrement automatique la rend définitive en une seconde.
|
||||
- **Version de format.** Un fichier **plus récent** s'ouvre en lecture seule
|
||||
sans passage possible en écriture ; un fichier **plus ancien** se convertit au
|
||||
premier passage en écriture, jamais à l'ouverture.
|
||||
premier geste qui l'écrit, jamais à l'ouverture ni au passage en écriture, qui
|
||||
ne réécrit pas le fichier d'état.
|
||||
- **Une seule séance en écriture**, le verrou se prenant **à l'entrée en mode
|
||||
écriture** et non à l'ouverture.
|
||||
- **Contrôle de cohérence** : l'en-tête annonce ses comptes, sa révision, sa
|
||||
|
|
@ -2342,9 +2424,10 @@ perd ?*
|
|||
|
||||
**Le minimum gouverne la page** : chaque vue place le cas le plus défavorable à
|
||||
une position **fixe et prévisible** — le premier point, la première ligne —
|
||||
jamais là où il faut le chercher. La moyenne l'accompagne en encre secondaire et
|
||||
n'apparaît jamais seule. Un placement où chacun rencontre 27 personnes sauf une
|
||||
qui en rencontre 14 a une excellente moyenne et un problème.
|
||||
jamais là où il faut le chercher. La moyenne des rencontres ne s'affiche pas : à
|
||||
tables pleines, elle est la même pour toutes les propositions (§ 12.8). Un
|
||||
placement où chacun rencontre 27 personnes sauf une qui en rencontre 14 a une
|
||||
excellente moyenne et un problème.
|
||||
|
||||
### 12.2 L'en-tête chiffré
|
||||
|
||||
|
|
@ -2452,7 +2535,10 @@ et deux tris opposés dans une même page se contredisent devant l'opérateur.
|
|||
relâchant les contraintes : c'est une **borne inférieure**, jamais un fait.
|
||||
Nommer « évitable » le complément affirmerait qu'un meilleur placement le
|
||||
retire, et condamnerait l'opérateur à relancer indéfiniment sur un conflit
|
||||
qu'aucun placement ne retire.
|
||||
qu'aucun placement ne retire. Le mot garde son sens dans les remèdes du
|
||||
diagnostic (§ 5.6), et là seulement : une table de plus, ou une appartenance
|
||||
répartie autrement, « la rend évitable » parce qu'elle change la
|
||||
**configuration** — jamais parce qu'une relance ferait mieux.
|
||||
|
||||
**Les participants sans appartenance ne forment pas un groupe** : sinon un
|
||||
pseudo-groupe de quarante personnes produit la plus longue barre, un plancher
|
||||
|
|
@ -2836,11 +2922,12 @@ service.
|
|||
| `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux |
|
||||
| `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison |
|
||||
|
||||
**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne fournit
|
||||
de plateforme de bureau que par une extension communautaire, et une plateforme
|
||||
dont la mise à jour ne dépend pas du projet fige le moteur d'exécution embarqué :
|
||||
l'exécutable livré porterait un Chromium que plus personne ne corrige. La coquille
|
||||
du projet tient en deux fichiers — le processus principal et le script de
|
||||
**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne
|
||||
fournit de plateforme de bureau que par une extension communautaire, et une
|
||||
plateforme dont la mise à jour ne dépend pas du projet fige le moteur
|
||||
d'exécution embarqué : l'exécutable livré porterait un Chromium que plus
|
||||
personne ne corrige. La coquille du projet tient en trois fichiers — le
|
||||
processus principal, ses primitives de fichiers (`fichiers.js`) et le script de
|
||||
préchargement — et fixe elle-même sa version d'Electron, que la construction met
|
||||
à jour comme n'importe quelle dépendance.
|
||||
|
||||
|
|
@ -2864,16 +2951,24 @@ doit afficher une version et non une importation manquante, et **contrôlé par
|
|||
rejeu du script**, parce qu'un fichier engendré et oublié affiche une version
|
||||
périmée avec l'assurance d'une constante.
|
||||
|
||||
**Ce qui n'est pas un composant s'éprouve sous `node`** : `src/application` et
|
||||
les modules purs de `src/interface` — tri, filtre, mise en forme, modèle de
|
||||
dessin, navigation au clavier — entrent dans le projet `node` du § 14.8, à côté
|
||||
du moteur ; seuls les composants Svelte, et ce qu'ils peignent, passent au
|
||||
navigateur.
|
||||
|
||||
### 13.2 Une seule langue, et c'est une exigence
|
||||
|
||||
Tout — le moteur, la géométrie, l'interface — est écrit en **JavaScript**.
|
||||
Aucune partie du calcul n'est dupliquée dans un second langage.
|
||||
|
||||
Cette propriété doit être **préservée délibérément**. Dès que la même
|
||||
arithmétique existe en deux endroits, une suite de tests peut prouver qu'ils
|
||||
s'accordent **sans jamais prouver qu'ils ont raison** : les deux dérivent
|
||||
ensemble et les tests restent verts. Une seule implémentation rend ce mode de
|
||||
défaillance impossible.
|
||||
Cette propriété doit être **préservée délibérément**, et une garde de
|
||||
l'arborescence la tient : sous `src/`, `electron/` et `scripts/`, rien d'autre
|
||||
que du JavaScript, des composants Svelte, des feuilles de style, des données
|
||||
JSON et les scripts d'installation nommés. Dès que la même arithmétique existe
|
||||
en deux endroits, une suite de tests peut prouver qu'ils s'accordent **sans
|
||||
jamais prouver qu'ils ont raison** : les deux dérivent ensemble et les tests
|
||||
restent verts. Une seule implémentation rend ce mode de défaillance impossible.
|
||||
|
||||
**Une exception, nommée, et une seule** : le calcul incrémental des indicateurs
|
||||
du § 5.10. Elle tient à une condition écrite — **le recalcul complet est la
|
||||
|
|
@ -2936,8 +3031,13 @@ message d'erreur**.
|
|||
listes, il rend des placements. C'est ce qui le rend testable sous `node`, en
|
||||
quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusement.
|
||||
|
||||
**La géométrie du plan** est elle aussi un module de **fonctions pures**, séparé
|
||||
du rendu.
|
||||
**La géométrie du plan** est une couche à part, de **fonctions pures**, séparée
|
||||
du rendu et placée plus bas que le moteur : elle n'importe ni le moteur, ni
|
||||
l'analyseur CSV, ni le générateur de démonstrations, ni l'application, ni
|
||||
l'interface, ni le stockage, et ne touche pas le navigateur. Elle est
|
||||
**requise** : sa garde échoue quand son arbre ne fournit aucun module. Le
|
||||
dessin, la désignation d'un siège et le dépôt partagent ses conversions et ses
|
||||
chaises (§ 7.2, § 7.3).
|
||||
|
||||
Les **deux frontières de plateforme** sont le système de fichiers et
|
||||
l'impression : une interface unique, deux implémentations — plus, pour le système
|
||||
|
|
@ -2958,12 +3058,15 @@ ou de `src/geometrie` importe de `src/interface`, de `src/application`, de
|
|||
d'une plateforme, ou touche le navigateur hors de ses deux implémentations du
|
||||
système de fichiers, qu'aucun autre de ses modules n'importe ; que `src/csv`,
|
||||
qui lit le modèle du stockage, importe de `src/application`, de `src/interface`
|
||||
ou d'une plateforme ; et que `src/application` importe de `src/interface` ou de
|
||||
Svelte. Un type que la documentation d'un module importe compte comme un
|
||||
import. Le projet `node` ne chargeant pas le greffon Svelte (§ 14.8), un test de
|
||||
moteur qui importerait un composant échoue au chargement — mais cette garde-là
|
||||
ne couvre que les composants, et un module qui touche `document` ne tomberait
|
||||
qu'à l'exécution de la branche fautive.
|
||||
ou d'une plateforme ; que `src/geometrie` importe en outre du moteur, de
|
||||
`src/csv` ou de `src/demo` ; que `src/application` importe de `src/interface` ou
|
||||
de Svelte ; et que `src/interface` importe autre chose que Svelte, ses propres
|
||||
modules, `src/application` et `src/geometrie` — elle atteint le CSV et le moteur
|
||||
par l'application, jamais en direct. Un type que la documentation d'un module
|
||||
importe compte comme un import. Le projet `node` ne chargeant pas le greffon
|
||||
Svelte (§ 14.8), un test de moteur qui importerait un composant échoue au
|
||||
chargement — mais cette garde-là ne couvre que les composants, et un module qui
|
||||
touche `document` ne tomberait qu'à l'exécution de la branche fautive.
|
||||
|
||||
### 13.5 Ce que le SVG donne et que le canvas ferait payer
|
||||
|
||||
|
|
@ -3042,15 +3145,16 @@ suivante ; une règle qui fait échouer la construction tient.
|
|||
|
||||
| niveau | ce qu'il couvre | quand |
|
||||
|---|---|---|
|
||||
| **`node`, série surveillée** | moteur, géométrie, CSV, indicateurs et plafonds — des fonctions pures ; cohérence des versions (§ 18.5) ; appariement captures/manifeste (§ 19.7) ; aller-retour et canonicité du stockage sur la petite démonstration ; **une** production de planche de badges avec contrôle de géométrie et de couche de texte ; grille de propriétés sur petites instances | **à chaque modification**, quelques secondes (§ 14.14) |
|
||||
| **`node`, série surveillée** | moteur, géométrie, CSV, indicateurs et plafonds — des fonctions pures ; l'application et les modules purs de l'interface, sur le système de fichiers simulé et l'horloge pilotée ; cohérence des versions (§ 18.5) ; appariement captures/manifeste (§ 19.7) ; aller-retour et canonicité du stockage sur la petite démonstration ; **une** production de planche de badges avec contrôle de géométrie et de couche de texte ; grille de propriétés sur petites instances | **à chaque modification**, quelques secondes (§ 14.14) |
|
||||
| **`node:long`** | accord de l'incrémental à 1, 10, 100, 1 000 et 10 000 mouvements ; rejeu du générateur et empreinte ; pagination des badges sur ses cinq comptes A4, ses quatre comptes Lettre et ses deux impositions ; reconstruction de tous les instants du journal ; aller-retour sur la grande démonstration ; coût d'enregistrement ; sonde d'écriture sur système de fichiers réel ; grande grille de propriétés | avant un commit, et à la construction |
|
||||
| **navigateur** | ce que l'interface dessine, ce que les gestes produisent, les coordonnées ; le scénario de pilotage (§ 19.3) ; le banc de mesure (§ 19.10) ; les épreuves de style calculé | avant chaque livraison |
|
||||
| **manuel** | une soirée complète, de la saisie à l'impression ; le décalage des imprimantes (§ 11.3) ; `pointercancel` et `touch-action` (§ 7.3) | avant chaque livraison au client |
|
||||
| **sa propre commande** | l'audit par mutation (§ 14.13) | avant une livraison, sous aucun budget |
|
||||
|
||||
**La commande surveillée ne lance que le projet `node`.** Le lanceur en porte
|
||||
deux (§ 14.8) ; lancés ensemble, ils démarrent un navigateur à chaque
|
||||
modification, et le niveau navigateur reste à « avant chaque livraison ».
|
||||
trois (§ 14.8) — `node`, `node-long` et `navigateur` ; lancés ensemble, ils
|
||||
démarrent un navigateur et la série longue à chaque modification, et le niveau
|
||||
navigateur reste à « avant chaque livraison ».
|
||||
|
||||
**Le partage entre les deux séries `node` est écrit avant la première mesure**,
|
||||
et non après. Trois familles tombent naturellement dans la série surveillée sans
|
||||
|
|
@ -3093,21 +3197,33 @@ chaîne venue de la table de traduction d'une chaîne écrite dans le composant
|
|||
les deux s'affichent à l'identique. Les deux gardes sont un **balayage des
|
||||
sources** sous `node`, qui refuse une chaîne visible en dur et **échoue sur un
|
||||
balayage vide** (§ 14.2), et une **exécution en langue témoin** où chaque chaîne
|
||||
traduite est décorée d'une marque : tout ce qui apparaît à l'écran sans sa marque
|
||||
est écrit en dur.
|
||||
traduite est encadrée d'une marque — `⁅` et `⁆`, U+2045 et U+2046, une fois,
|
||||
autour du texte entier : tout ce qui apparaît à l'écran sans sa marque est écrit
|
||||
en dur. La langue témoin est un mode du mécanisme, que la construction livrée
|
||||
n'emploie jamais.
|
||||
|
||||
**Un seul mécanisme, deux familles de tables.** Les textes de l'application —
|
||||
codes de refus et d'avertissement, mise en forme des nombres et des durées — et
|
||||
ceux des écrans vivent dans des tables distinctes, que réunit un seul mécanisme
|
||||
de traduction. Un code s'affiche par sa propre clé, un libellé par une clé qui
|
||||
nomme sa table ; une clé portée par deux tables, ou absente, lève — l'écran ne
|
||||
montre jamais une clé brute ni un texte vide. Un texte qui devient une donnée —
|
||||
le libellé figé d'une entrée du journal (§ 8.2), le motif du CSV des lignes
|
||||
refusées (§ 10.1) — se compose en français, sans la marque de la langue témoin.
|
||||
|
||||
### 14.7 Le déterminisme
|
||||
|
||||
Aucune source non reproductible — `Math.random`, `Date.now`, `performance.now`,
|
||||
`crypto.getRandomValues` — dans le **moteur**, dans le **générateur de
|
||||
démonstrations**, ni dans le **stockage** et l'**analyseur CSV**, qui reçoivent
|
||||
l'horloge et l'aléa en paramètre. Un test de l'arborescence le refuse, **et
|
||||
échoue si son balayage ne trouve aucun fichier**. Son périmètre est nommé :
|
||||
`src/moteur`, `src/demo`, `src/stockage` et `src/csv`, à l'exclusion du code
|
||||
d'épreuve, où un tirage sert légitimement (§ 14.12). L'interdit ne porte pas sur
|
||||
le reste de l'application : l'application lit l'horloge pour horodater les
|
||||
entrées de l'historique, que le stockage écrit telles qu'il les reçoit, et le
|
||||
banc de mesure du § 19.10 relève des temps par image.
|
||||
`crypto.getRandomValues` — dans le **moteur**, dans la **géométrie du plan**,
|
||||
dans le **générateur de démonstrations**, ni dans le **stockage** et
|
||||
l'**analyseur CSV**, qui reçoivent l'horloge et l'aléa en paramètre. Un test de
|
||||
l'arborescence le refuse, **et échoue si son balayage ne trouve aucun fichier**.
|
||||
Son périmètre est nommé : `src/moteur`, `src/geometrie`, `src/demo`,
|
||||
`src/stockage` et `src/csv`, à l'exclusion du code d'épreuve, où un tirage sert
|
||||
légitimement (§ 14.12). L'interdit ne porte pas sur le reste de l'application :
|
||||
l'application lit l'horloge pour horodater les entrées de l'historique, que le
|
||||
stockage écrit telles qu'il les reçoit, et le banc de mesure du § 19.10 relève
|
||||
des temps par image.
|
||||
|
||||
**Ce n'est pas une règle d'hygiène.** L'interdit de `performance.now` dans le
|
||||
moteur est ce qui rend vraie la phrase « à graine et entrée égales, placement
|
||||
|
|
@ -3122,22 +3238,25 @@ d'horloge à l'exécution (§ 18.5) : le moteur et le générateur ne la lisent
|
|||
|
||||
### 14.8 L'outillage des épreuves
|
||||
|
||||
**Un seul lanceur, deux projets : Vitest.** Le niveau `node` et le niveau
|
||||
navigateur du § 14.4 sont deux configurations du même lanceur, pas deux outils.
|
||||
**Un seul lanceur, trois projets : Vitest.** Les deux séries `node` et le niveau
|
||||
navigateur du § 14.4 sont trois configurations du même lanceur, pas trois
|
||||
outils.
|
||||
|
||||
| projet | environnement | ce qu'il inclut | greffon Svelte |
|
||||
|---|---|---|---|
|
||||
| `node` | `node`, aucun DOM | `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`, `src/demo`, `src/pdf` | **absent** |
|
||||
| `navigateur` | un vrai moteur de rendu, piloté par le mode navigateur de Vitest | `src/interface`, les épreuves de coordonnées et de style calculé | présent |
|
||||
| `node` | `node`, aucun DOM | `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`, `src/demo`, `src/pdf`, `src/application`, et les modules purs de `src/interface` | **absent** |
|
||||
| `node-long` | `node`, aucun DOM | les épreuves lourdes du niveau `node:long` (§ 14.4), suffixe `.long.test.js`, hors de la série surveillée | **absent** |
|
||||
| `navigateur` | un vrai moteur de rendu, piloté par le mode navigateur de Vitest | les composants de `src/interface`, les épreuves de coordonnées et de style calculé | présent |
|
||||
|
||||
**Pourquoi Vitest.** Il lit la **configuration Vite qui construit le livrable** : un module importé par un test se résout exactement comme un module
|
||||
**Pourquoi Vitest.** Il lit la **configuration Vite qui construit le
|
||||
livrable** : un module importé par un test se résout exactement comme un module
|
||||
importé par l'application. Deux chaînes de résolution — l'une pour construire,
|
||||
l'autre pour éprouver — sont le mode de défaillance du § 13.2 transposé à
|
||||
l'outillage : le test passe sur un fichier que l'application ne charge jamais, un
|
||||
alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe de
|
||||
dépendances**, donc une modification du moteur ne rejoue que les tests du moteur,
|
||||
et son mode navigateur offre **la même API d'assertion** au niveau 2, de sorte
|
||||
que l'oracle de géométrie du § 7.2 s'écrit une fois.
|
||||
l'outillage : le test passe sur un fichier que l'application ne charge jamais,
|
||||
un alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe
|
||||
de dépendances**, donc une modification du moteur ne rejoue que les tests du
|
||||
moteur, et son mode navigateur offre **la même API d'assertion** au niveau 2, de
|
||||
sorte que l'oracle de géométrie du § 7.2 s'écrit une fois.
|
||||
|
||||
**Les trois durées sont des cibles, mesurées la première semaine.** Le démarrage
|
||||
à froid, la relance sous surveillance et la série complète se mesurent sur un
|
||||
|
|
@ -3192,12 +3311,15 @@ une donnée d'épreuve qui emprunte une racine d'organisation plausible le fait
|
|||
échouer. Un test vérifie cette disjonction, faute de quoi la garde du § 15.6
|
||||
tombe au premier exemple écrit de bonne foi.
|
||||
|
||||
**La co-localisation se garde.** Un test de la construction affirme que le paquet
|
||||
livré ne contient **aucun fichier de test ni aucun import du lanceur**. Le § 14.15
|
||||
pose que la suite n'est pas livrée ; avec des tests rangés dans `src/`, cette
|
||||
propriété tient par la seule analyse statique de Vite, c'est-à-dire par
|
||||
chance, jusqu'au jour où un module de production importe une
|
||||
donnée d'épreuve « pour un cas par défaut ».
|
||||
**La co-localisation se garde.** Un test de la construction affirme que le
|
||||
paquet livré ne contient **aucun fichier de test ni aucun import du lanceur**.
|
||||
Le § 14.15 pose que la suite n'est pas livrée ; avec des tests rangés dans
|
||||
`src/`, cette propriété tient par la seule analyse statique de Vite,
|
||||
c'est-à-dire par chance, jusqu'au jour où un module de production importe une
|
||||
donnée d'épreuve « pour un cas par défaut ». Une garde de l'arborescence exige
|
||||
aussi que chaque test de `src/` importe un module de son propre répertoire, hors
|
||||
d'exceptions nommées avec leur raison, et qu'aucune donnée de `test/fixtures/`
|
||||
n'égale, octet pour octet, un fichier livré.
|
||||
|
||||
### 14.10 La carte de ce qui s'éprouve
|
||||
|
||||
|
|
@ -3209,7 +3331,8 @@ donnée d'épreuve « pour un cas par défaut ».
|
|||
| analyseur CSV | `node` | encodage, séparateur, association des colonnes, réconciliation des appartenances, lignes refusées, aller-retour |
|
||||
| générateur de démonstrations | `node` | vecteur du générateur pseudo-aléatoire, empreinte des fichiers livrés, refus, noms forgés |
|
||||
| PDF | `node` | couche de texte, pagination, police embarquée et table `ToUnicode`, géométrie de la planche, reproductibilité |
|
||||
| interface | navigateur | ce que les gestes produisent, le style calculé, les trois invariants du § 7.2 |
|
||||
| application | `node` | les commandes et leurs refus, la séance observable, les contrôles de pose et de capacité, la table d'évaluation — sur le système de fichiers simulé et l'horloge pilotée |
|
||||
| interface | navigateur ; ses modules purs sous `node` | ce que les gestes produisent, le style calculé, les trois invariants du § 7.2 |
|
||||
|
||||
#### Le moteur
|
||||
|
||||
|
|
@ -3278,14 +3401,15 @@ sait pas fragmenter un SVG ; c'est du code qu'il faut éprouver, pas une règle
|
|||
style.
|
||||
|
||||
**Au niveau navigateur**, la fonction pure est confrontée à
|
||||
`getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après défilement
|
||||
de la page. **Les trois invariants du § 7.2 ont chacun leur test** : que le `<svg>`
|
||||
racine ne porte pas d'attribut `viewBox` ; que son style calculé donne une bordure
|
||||
et un remplissage intérieur nuls — `getBoundingClientRect` les inclut, donc
|
||||
l'origine du contenu s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de
|
||||
transformation CSS. Il refuse la feuille de style qui ajoute un jour
|
||||
`transform: translateZ(0)` « pour la fluidité » : le plan répondrait faux partout,
|
||||
et la cause serait cherchée dans le module de géométrie.
|
||||
`getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après
|
||||
défilement de la page, à un millième de pixel d'écran près (§ 7.2). **Les trois
|
||||
invariants du § 7.2 ont chacun leur test** : que le `<svg>` racine ne porte pas
|
||||
d'attribut `viewBox` ; que son style calculé donne une bordure et un remplissage
|
||||
intérieur nuls — `getBoundingClientRect` les inclut, donc l'origine du contenu
|
||||
s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de transformation CSS. Il
|
||||
refuse la feuille de style qui ajoute un jour `transform: translateZ(0)` « pour
|
||||
la fluidité » : le plan répondrait faux partout, et la cause serait cherchée
|
||||
dans le module de géométrie.
|
||||
|
||||
#### Le stockage
|
||||
|
||||
|
|
@ -3523,16 +3647,25 @@ produire une configuration à partir d'une graine. Une boucle sur une grille de
|
|||
graines et de tailles, appelant les deux vérificateurs, couvre l'essentiel sans
|
||||
dépendance nouvelle.
|
||||
|
||||
**Une bibliothèque de propriétés se justifie à deux endroits, et seulement deux** :
|
||||
la formule du plafond et la détection du séparateur. Toutes deux prennent des
|
||||
entrées petites et combinatoires, toutes deux bénéficient du **rétrécissement** —
|
||||
quand une configuration à 120 participants échoue, l'outil rend un contre-exemple à
|
||||
trois tables, là où la réduction manuelle coûte une heure. Leurs oracles ne sont
|
||||
pas de même nature et le texte ne les confond pas : le plafond s'arbitre par
|
||||
**énumération exhaustive** ; le séparateur s'arbitre par **construction** — un
|
||||
tableau de champs engendré, rendu avec un séparateur connu, et la détection doit le
|
||||
retrouver. Ailleurs, non : une propriété sur la recherche rejouerait la partie
|
||||
lente et épuiserait le budget du § 14.14.
|
||||
**Une bibliothèque de propriétés se justifie à deux endroits du calcul, et
|
||||
seulement deux** : la formule du plafond et la détection du séparateur. Toutes
|
||||
deux prennent des entrées petites et combinatoires, toutes deux bénéficient du
|
||||
**rétrécissement** — quand une configuration à 120 participants échoue, l'outil
|
||||
rend un contre-exemple à trois tables, là où la réduction manuelle coûte une
|
||||
heure. Leurs oracles ne sont pas de même nature et le texte ne les confond pas :
|
||||
le plafond s'arbitre par **énumération exhaustive** ; le séparateur s'arbitre
|
||||
par **construction** — un tableau de champs engendré, rendu avec un séparateur
|
||||
connu, et la détection doit le retrouver. Ailleurs, non : une propriété sur la
|
||||
recherche rejouerait la partie lente et épuiserait le budget du § 14.14.
|
||||
|
||||
**Le stockage l'emploie pour deux propriétés de plus, nommées.** L'aller-retour
|
||||
des correctifs du journal — appliquer à une charge la différence qui mène à une
|
||||
autre rend l'autre, sur des paires de charges tirées — et la restitution de
|
||||
chaque instant d'une séance tirée, qui vit dans la série longue parce que ses
|
||||
séances lancent des générations, à compte d'arrêt court. Leurs entrées sont des
|
||||
suites d'opérations, où le **rétrécissement** sert comme au plafond : le
|
||||
contre-exemple rendu tient en quelques opérations. La liste est fermée : une
|
||||
garde de l'arborescence refuse la bibliothèque hors de ces quatre familles.
|
||||
|
||||
**La bibliothèque tourne sur une graine fixée.** Une bibliothèque de propriétés
|
||||
retire par défaut une graine neuve à chaque exécution : le même code échoue une
|
||||
|
|
@ -4045,7 +4178,9 @@ valeur soit fixée ; un seul reste entièrement ouvert.
|
|||
les 33 tables avec listes. Aucune capture d'écran ne le mesure — une image est
|
||||
muette sur la durée qui l'a produite. Ce qui reste à dire est ce qu'est un temps
|
||||
par image **acceptable sur la machine de l'opérateur**, qui n'est pas celle du
|
||||
développement.
|
||||
développement. Sa valeur de travail a déjà une conséquence visible : sur un
|
||||
écran de 1 366 × 700, la grande démonstration s'ajuste sous elle et s'ouvre
|
||||
sans listes de noms, qui se dessinent en rapprochant la vue (§ 7.7).
|
||||
7. **OUVERT, et hors périmètre de la première itération — les dégagements de
|
||||
référence d'une salle.** Le mot « conforme » reste absent de l'interface et la
|
||||
mention « disposition relative » reste sur chaque impression du plan (§ 7.7).
|
||||
|
|
@ -4187,10 +4322,12 @@ Tout le reste en dérive, par un script de construction :
|
|||
script puis compare — la même discipline qu'au § 15.5 pour les fichiers de
|
||||
démonstration (§ 13.1).
|
||||
|
||||
**Le module porte aussi la provenance de la construction** — livraison, séance de
|
||||
développement, ou construction de documentation — posée par la commande qui
|
||||
l'engendre, jamais lue dans l'horloge ni dans l'environnement. Sans elle, une
|
||||
capture d'écran prise pendant le développement annonce un numéro déjà livré, et le
|
||||
**Le module engendré ne porte que la version.** La **provenance de la
|
||||
construction** — livraison, séance de développement, ou construction de
|
||||
documentation — est le mode de construction que pose la commande qui construit,
|
||||
celui de Vite, que l'application reçoit avec ses dépendances ; elle n'est jamais
|
||||
lue dans l'horloge ni dans l'environnement du poste. Sans elle, une capture
|
||||
d'écran prise pendant le développement annonce un numéro déjà livré, et le
|
||||
diagnostic porte sur un binaire que personne ne détient.
|
||||
|
||||
### 18.4 Le `CHANGELOG.md`
|
||||
|
|
@ -4321,7 +4458,7 @@ question. Le logiciel fait donc en sorte qu'on n'ait pas à la chercher.
|
|||
| endroit | forme |
|
||||
|---|---|
|
||||
| **bandeau d'application**, sur tout écran (§ 8.4) | `2026.10.05.01`, avec la provenance et — quand un plan est ouvert — le libellé de mode |
|
||||
| **premier écran** (§ 8.1), liste des événements comme répertoire vide | `version 2026.10.05.01` |
|
||||
| **premier écran** (§ 8.1), liste des événements comme répertoire vide | `version 2026.10.05.01`, ou la marque de provenance d'une construction de documentation |
|
||||
| **titre de la fenêtre** | `Gestion table tournante Libre — 2026.10.05.01` |
|
||||
| **panneau « À propos »** | un bloc d'une ligne que l'opérateur **copie** |
|
||||
| **pied de chaque page imprimée** (§ 11.7) | la version, à côté de l'horodatage du placement |
|
||||
|
|
@ -4345,7 +4482,7 @@ moment où l'opérateur peut encore se ménager un retour en arrière, c'est-à-
|
|||
avant le premier geste.
|
||||
|
||||
**Le bloc copiable** du panneau « À propos » tient en une ligne — version affichée,
|
||||
version technique, plateforme, provenance de la construction, version de format
|
||||
version Windows, plateforme, provenance de la construction, version de format
|
||||
des fichiers — et se colle dans un courriel. Un chiffre recopié à la main se
|
||||
recopie faux. Il porte les **deux** formes parce qu'un opérateur qui lit la fiche
|
||||
de propriétés du fichier sous Windows y trouve `2026.1005.1.0` et non
|
||||
|
|
@ -4363,12 +4500,13 @@ fois lors du premier geste suivant un remplacement d'exécutable, ce qui coûte
|
|||
correctif et non du bruit à chaque geste. Il **n'est pas** la version de format,
|
||||
qui reste un nombre séparé, comparé selon ses propres règles.
|
||||
|
||||
**Pendant une construction de documentation, le numéro est remplacé par une marque
|
||||
de provenance** — dans le bandeau, au pied des pages imprimées et sur la feuille de
|
||||
contrôle des badges. La raison est au § 19.4 : sans cette marque, le texte relevé
|
||||
de chaque capture d'écran change à chaque incrément de version, et toutes les
|
||||
images se réécrivent. Le manifeste, lui, enregistre la **version réelle**, et le
|
||||
contrôle de version de la livraison la compare (§ 19.7).
|
||||
**Pendant une construction de documentation, le numéro est remplacé par une
|
||||
marque de provenance** — dans le bandeau, sur le premier écran, au pied des
|
||||
pages imprimées et sur la feuille de contrôle des badges. La raison est au
|
||||
§ 19.4 : sans cette marque, le texte relevé de chaque capture d'écran change à
|
||||
chaque incrément de version, et toutes les images se réécrivent. Le manifeste,
|
||||
lui, enregistre la **version réelle**, et le contrôle de version de la livraison
|
||||
la compare (§ 19.7).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -4439,8 +4577,10 @@ ne les offre de façon portable :
|
|||
- **L'émulation d'une préférence système.** Le logiciel règle son **propre**
|
||||
réglage de thème, qui est celui que l'opérateur manipule (§ 13.3), et injecte une
|
||||
feuille de style qui annule transitions et animations. La préférence système
|
||||
n'est consultée qu'au premier lancement, que le profil neuf du § 19.4 produit à
|
||||
chaque exécution : **le scénario la pose explicitement et ne la déduit jamais.**
|
||||
est consultée à chaque lancement, et le réglage de l'opérateur vaut pour la
|
||||
séance : rien ne l'enregistre, ni fichier ni stockage du navigateur (§ 8.6).
|
||||
**Le scénario pose donc le thème par le réglage, à chaque exécution, et ne le
|
||||
déduit jamais de la machine.**
|
||||
|
||||
### 19.3 Le parcours, et ce qu'il photographie
|
||||
|
||||
|
|
@ -4548,7 +4688,7 @@ neutralise chaque source de variation nommément.
|
|||
| **le cadrage mémorisé** | le § 8.5 range le cadrage dans un fichier de réglages locaux : le scénario démarre sur un **profil neuf**, ce qui lui donne du même geste un dossier de travail neuf |
|
||||
| **le numéro d'ordre du nom de démonstration** | charger une démonstration crée « ‹nom› 2 » à la deuxième exécution (§ 15) ; le répertoire neuf ramène chaque exécution au premier ordinal |
|
||||
| **l'horloge** — horodatage d'historique, pied de page, « dernière modification », retour automatique en lecture | **une seule source d'horloge dans toute l'application hors moteur**, et **elle possède aussi les minuteries**. Un instant figé ne suffit pas : un compte à rebours bâti sur une minuterie du moteur d'exécution continue d'avancer sous une horloge gelée, et **le retour automatique en lecture tombe au milieu d'une étape**. Le scénario installe une horloge dont il contrôle l'avance, ce qui lui permet aussi d'éprouver les durées |
|
||||
| **le numéro de version affiché** | une construction de documentation pose une **marque de provenance à la place du numéro** (§ 18.6), dans le bandeau comme au pied des pages imprimées et sur la feuille de contrôle des badges. Sans elle, le texte relevé de chaque capture change à chaque livraison, l'empreinte d'étape change avec lui, et **toutes les images se réécrivent à chaque incrément** — précisément le bruit que la réécriture conditionnelle du § 19.5 existe pour supprimer |
|
||||
| **le numéro de version affiché** | une construction de documentation pose une **marque de provenance à la place du numéro** (§ 18.6), dans le bandeau, sur le premier écran, au pied des pages imprimées et sur la feuille de contrôle des badges. Sans elle, le texte relevé de chaque capture change à chaque livraison, l'empreinte d'étape change avec lui, et **toutes les images se réécrivent à chaque incrément** — précisément le bruit que la réécriture conditionnelle du § 19.5 existe pour supprimer |
|
||||
| **le chemin affiché** dans la liste des événements (§ 8.1) | racine de travail neutre, et le contrôle **refuse une capture dont le texte relevé — celui de tous les nœuds de texte de la région photographiée, pas des seuls éléments que l'étape nomme — porte un chemin hors de cette racine**. Un chemin réel porte un nom de compte, qu'une documentation publie |
|
||||
| **la police réellement rendue** | la police embarquée (§ 11.1) est celle du pilotage. Le scénario **mesure une chaîne témoin** par `getComputedTextLength()` et la compare à la valeur enregistrée **avec une tolérance relative, constante mesurée** (§ 14.1) — exiger l'égalité exacte reproduirait, sur une mesure de texte, l'erreur que cette section refuse sur les pixels. La tolérance se fixe au-dessous de l'écart qu'introduit un repli de police, qui est d'un tout autre ordre |
|
||||
| **le thème** | réglé par le réglage de l'application, non par une préférence système émulée (§ 19.2) |
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@
|
|||
// Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne
|
||||
// décrit plus la configuration, le second sur un placement fautif.
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { LIBRE, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { verifierInvariants } from '../moteur/verification.js';
|
||||
import { configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { examiner, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { nomAffiche } from './personnes.js';
|
||||
import { couvre, placesTenues, presents, reservationsActives } from './places.js';
|
||||
|
||||
const avertissement = (code, details) => ({ code, details });
|
||||
|
||||
|
|
@ -45,12 +47,139 @@ function raisonsParPlacement(examen) {
|
|||
return raisons;
|
||||
}
|
||||
|
||||
// Les violations du retenu cohérent d'une charge, contre son instance, la
|
||||
// réserve admise : des surnuméraires versés en réserve ne violent rien.
|
||||
// [] sans retenu, pour un retenu fautif, ou sans instance.
|
||||
function violationsDuRetenu(charge, examen, instance) {
|
||||
// Les réservations au siège que le retenu, sièges attribués, ne tient pas à
|
||||
// ce siège alors que sa personne est assise à la même table : celles qui
|
||||
// tiennent une chaise selon la règle de l'affichage (placesTenues), dans la
|
||||
// capacité courante. Par tour, dans l'ordre de retenu.tables, par siège ;
|
||||
// occupant, la personne présente sur ce siège, null pour une chaise vide ou
|
||||
// celle d'un absent. Une personne assise à une autre table relève de la
|
||||
// règle de la table, que verifierInvariants juge.
|
||||
function siegesNonTenus(charge, retenu, instance) {
|
||||
if (!retenu.siegesAttribues) return [];
|
||||
const actives = reservationsActives(charge);
|
||||
const ids = presents(charge);
|
||||
const enregistrementDe = new Map(charge.tables.map((enregistrement) => [enregistrement.id, enregistrement]));
|
||||
const violations = [];
|
||||
const lus = Math.min(retenu.tours, instance.R, retenu.placement.length);
|
||||
for (let tour = 1; tour <= lus; tour += 1) {
|
||||
const couvrantes = actives.filter((reservation) => couvre(reservation, tour));
|
||||
retenu.tables.forEach((idTable, rang) => {
|
||||
const enregistrement = enregistrementDe.get(idTable);
|
||||
if (enregistrement === undefined || retenu.tables.indexOf(idTable) !== rang) return;
|
||||
const liste = retenu.placement[tour - 1].sieges[rang] ?? [];
|
||||
const { tenu } = placesTenues(
|
||||
couvrantes.filter(({ table }) => table === idTable),
|
||||
capacite(charge, enregistrement),
|
||||
);
|
||||
for (const siege of [...tenu.keys()].sort((a, b) => a - b)) {
|
||||
const { participant } = tenu.get(siege);
|
||||
const assis = liste[siege - 1] ?? null;
|
||||
if (assis === participant || !liste.includes(participant)) continue;
|
||||
const occupant = assis !== null && ids.has(assis) ? assis : null;
|
||||
violations.push({ code: 'RESERVATION', participant, table: idTable, tour, siege, occupant });
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les violations d'un retenu lisible (fautes vide) contre l'instance de la
|
||||
* charge (§ 14.12) : celles de verifierInvariants, la réserve admise — des
|
||||
* surnuméraires versés en réserve ne violent rien —, complétées de deux
|
||||
* réservations non tenues qu'il ne relève pas. Une personne que le retenu
|
||||
* ne place pas à un tour, et qu'une réservation impose à une table à ce
|
||||
* tour, reçoit RESERVATION {participant, table, tour} juste après son
|
||||
* NON_ASSIS : ajoutée ou réintégrée depuis la retenue, elle n'est honorée
|
||||
* nulle part. Sièges attribués, une réservation au siège que le retenu
|
||||
* donne à un autre, sa personne assise à la même table, reçoit RESERVATION
|
||||
* {participant, table, tour, siege, occupant} (siegesNonTenus), après les
|
||||
* autres. Le retenu se juge tel quel, sans retouche (§ 5.8).
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {import('../stockage/types.js').Retenu} retenu
|
||||
* @param {import('../moteur/types.js').Instance} instance celle de la charge
|
||||
* @returns {Object[]} les violations, dans cet ordre
|
||||
*/
|
||||
export function violationsDuRetenu(charge, retenu, instance) {
|
||||
const { R, indexDe, idsTables, fixe } = instance;
|
||||
const violations = [];
|
||||
for (const violation of verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true })) {
|
||||
violations.push(violation);
|
||||
if (violation.code !== 'NON_ASSIS' || violation.tour > R) continue;
|
||||
const imposee = fixe[indexDe.get(violation.participant) * R + violation.tour - 1];
|
||||
if (imposee !== LIBRE) {
|
||||
violations.push({ code: 'RESERVATION', participant: violation.participant, table: idsTables[imposee], tour: violation.tour });
|
||||
}
|
||||
}
|
||||
return [...violations, ...siegesNonTenus(charge, retenu, instance)];
|
||||
}
|
||||
|
||||
// Les violations du retenu cohérent d'une charge (violationsDuRetenu). []
|
||||
// sans retenu, pour un retenu fautif, ou sans instance.
|
||||
function violationsCourantes(charge, examen, instance) {
|
||||
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
|
||||
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
|
||||
return violationsDuRetenu(charge, charge.retenu, instance);
|
||||
}
|
||||
|
||||
// Le nom affiché de la personne id dans la première charge qui la porte,
|
||||
// ou null ; le numéro de la table id de même.
|
||||
const nomDans = (id, charges) => {
|
||||
for (const charge of charges) {
|
||||
const trouvee = charge.participants.find((candidate) => candidate.id === id);
|
||||
if (trouvee !== undefined) return nomAffiche(trouvee);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const numeroDans = (id, charges) => {
|
||||
for (const charge of charges) {
|
||||
const trouvee = charge.tables.find((candidate) => candidate.id === id);
|
||||
if (trouvee !== undefined) return trouvee.numero;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Les raisons de dérive nommées (§ 9) : chacune garde ses champs, et gagne
|
||||
* personne, le nom affiché de la personne qu'elle désigne, et numero, le
|
||||
* numéro de la table qu'elle désigne, lus dans la première des charges qui
|
||||
* les porte ; une personne ou une table qu'aucune ne porte garde son seul
|
||||
* identifiant. Rend des objets neufs.
|
||||
*
|
||||
* @param {Object[]} raisons
|
||||
* @param {...import('../stockage/types.js').Charge} charges
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
export function raisonsNommees(raisons, ...charges) {
|
||||
return raisons.map((raison) => {
|
||||
const nommee = { ...raison };
|
||||
if (raison.participant !== undefined) {
|
||||
const personne = nomDans(raison.participant, charges);
|
||||
if (personne !== null) nommee.personne = personne;
|
||||
}
|
||||
if (raison.table !== undefined) {
|
||||
const numero = numeroDans(raison.table, charges);
|
||||
if (numero !== null) nommee.numero = numero;
|
||||
}
|
||||
return nommee;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* L'examen d'une charge (examiner, S2), ses raisons de dérive nommées sur
|
||||
* elle (raisonsNommees) : celles du retenu et de chaque proposition en
|
||||
* dérive. Un objet neuf ; l'examen reçu ne change pas.
|
||||
*
|
||||
* @param {import('../stockage/placements.js').Examen} examen
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @returns {import('../stockage/placements.js').Examen}
|
||||
*/
|
||||
export function examenNomme(examen, charge) {
|
||||
return {
|
||||
...examen,
|
||||
derives: examen.derives.map((derive) => ({ ...derive, raisons: raisonsNommees(derive.raisons, charge) })),
|
||||
retenu: { ...examen.retenu, raisons: raisonsNommees(examen.retenu.raisons, charge) },
|
||||
};
|
||||
}
|
||||
|
||||
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
|
||||
|
|
@ -77,7 +206,8 @@ function raisonQuiLaNomme(violation, raisons) {
|
|||
* dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans
|
||||
* retenu, pour un retenu fautif, ou sur une configuration qui ne se
|
||||
* normalise pas. Une réservation que le retenu ne tient pas en est le cas
|
||||
* ordinaire : la dérive ne compare pas les réservations.
|
||||
* ordinaire, à sa table ou à son siège, sa personne placée ou non
|
||||
* (violationsDuRetenu) : la dérive ne compare pas les réservations.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {import('../stockage/placements.js').Examen} [examen] celui de la
|
||||
|
|
@ -87,7 +217,7 @@ function raisonQuiLaNomme(violation, raisons) {
|
|||
export function invariantViole(charge, examen = examiner(charge)) {
|
||||
const { instance } = instanceDe(charge);
|
||||
const raisons = examen.retenu.raisons;
|
||||
const violations = violationsDuRetenu(charge, examen, instance).filter(
|
||||
const violations = violationsCourantes(charge, examen, instance).filter(
|
||||
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
|
||||
);
|
||||
if (violations.length === 0) return null;
|
||||
|
|
@ -109,13 +239,19 @@ export function invariantViole(charge, examen = examiner(charge)) {
|
|||
* normalisait pas) ;
|
||||
* DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les
|
||||
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
|
||||
* ne portait pas, comparées champ pour champ ; les propositions par
|
||||
* identifiant, puis le retenu, sous id 'retenu' ;
|
||||
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation
|
||||
* RESERVATION de verifierInvariants(instance d'après,
|
||||
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
|
||||
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
|
||||
* configuration ne se normalisait pas.
|
||||
* ne portait pas, comparées champ pour champ, puis nommées sur la charge
|
||||
* d'après, ou d'avant pour ce que le geste retire (raisonsNommees) ; les
|
||||
* propositions par identifiant, puis le retenu, sous id 'retenu' ;
|
||||
* RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
|
||||
* chaque violation RESERVATION de violationsDuRetenu(apres, apres.retenu,
|
||||
* instance d'après) absente du même calcul sur avant, qui n'en compte
|
||||
* aucune quand son retenu est nul ou fautif, ou que sa configuration ne
|
||||
* se normalisait pas ; personne est le nom affiché du participant,
|
||||
* numero celui de la table, lus sur la charge d'après, qui porte l'une et
|
||||
* l'autre puisque l'instance qui les viole en vient. Au siège, les
|
||||
* détails portent en plus siege et occupant, ceux de la violation : le
|
||||
* geste qui la lève est l'échange de la personne avec l'occupant
|
||||
* (echangerDansRetenu), ou, chaise vide, son déplacement au siège.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} avant
|
||||
* @param {import('../stockage/types.js').Charge} apres
|
||||
|
|
@ -142,16 +278,24 @@ export function avertissementsApres(avant, apres) {
|
|||
for (const [id, raisons] of raisonsParPlacement(examenApres)) {
|
||||
const connues = anciennes.get(id) ?? [];
|
||||
const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison)));
|
||||
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles });
|
||||
if (nouvelles.length > 0) touchees.push({ id, raisons: raisonsNommees(nouvelles, apres, avant) });
|
||||
}
|
||||
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
|
||||
|
||||
const reservations = (charge, examen, inst) =>
|
||||
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
|
||||
violationsCourantes(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
|
||||
const tenues = reservations(avant, examenAvant, instanceAvant);
|
||||
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
|
||||
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
|
||||
if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour }));
|
||||
for (const violation of reservations(apres, examenApres, instance)) {
|
||||
const { participant, table, tour, siege = null } = violation;
|
||||
const dejaLa = tenues.some(
|
||||
(connue) =>
|
||||
connue.participant === participant && connue.table === table && connue.tour === tour && (connue.siege ?? null) === siege,
|
||||
);
|
||||
if (dejaLa) continue;
|
||||
const personne = nomAffiche(apres.participants.find(({ id }) => id === participant));
|
||||
const { numero } = apres.tables.find(({ id }) => id === table);
|
||||
const auSiege = siege === null ? {} : { siege, occupant: violation.occupant };
|
||||
rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero, ...auSiege }));
|
||||
}
|
||||
return rendus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import assert from 'node:assert/strict';
|
|||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { examiner } from '../stockage/placements.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
|
||||
|
||||
const personne = (id, nom, exclu = false) => ({
|
||||
id,
|
||||
|
|
@ -184,7 +185,7 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
|
|||
test('exclure une personne : DERIVE_NOUVELLE nomme chaque proposition par identifiant, puis le retenu, et la raison', () => {
|
||||
const avant = petiteSoiree();
|
||||
const apres = avec(avant, (charge) => (charge.participants[2].exclu = true));
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{
|
||||
code: 'DERIVE_NOUVELLE',
|
||||
|
|
@ -196,14 +197,14 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
|
|||
test("une raison que le placement portait déjà ne se redit pas ; une raison au champ changé est nouvelle", () => {
|
||||
const une = avec(petiteSoiree(), (charge) => (charge.participants[2].exclu = true));
|
||||
const deux = avec(une, (charge) => (charge.participants[3].exclu = true));
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4 }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4, personne: 'Lacasse' }];
|
||||
assert.deepEqual(avertissementsApres(une, deux), [
|
||||
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
|
||||
]);
|
||||
assert.deepEqual(avertissementsApres(deux, avec(deux, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
|
||||
const trois = avec(petiteSoiree(), (charge) => (charge.tables[0].sieges = 3));
|
||||
const quatre = avec(trois, (charge) => (charge.tables[0].sieges = 4));
|
||||
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4 }];
|
||||
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4, numero: 1 }];
|
||||
assert.deepEqual(avertissementsApres(trois, quatre), [
|
||||
{
|
||||
code: 'DERIVE_NOUVELLE',
|
||||
|
|
@ -218,23 +219,84 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
|
|||
charge.participants[2].exclu = true;
|
||||
reserverGrisaille(charge);
|
||||
});
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avertissements : la dérive nomme les personnes et les tables (§ 9)', () => {
|
||||
test("une personne se nomme par son nom affiché, une table par son numéro ; celles que le geste retire, par la charge d'avant", () => {
|
||||
const avant = avec(petiteSoiree(), (charge) => {
|
||||
charge.tables[1].numero = 7;
|
||||
charge.participants[1].prenom = 'Aubépine';
|
||||
});
|
||||
const supprimees = avec(avant, (charge) => {
|
||||
charge.participants = charge.participants.filter(({ id }) => id !== 2);
|
||||
charge.reservations = charge.reservations.filter(({ participant }) => participant !== 2);
|
||||
charge.tables = charge.tables.filter(({ id }) => id !== 2);
|
||||
});
|
||||
const raisons = [
|
||||
{ code: 'PARTICIPANT_SUPPRIME', participant: 2, personne: 'Aubépine Grisaille' },
|
||||
{ code: 'TABLE_SUPPRIMEE', table: 2, numero: 7 },
|
||||
];
|
||||
const [derive] = avertissementsApres(avant, supprimees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
|
||||
assert.deepEqual(derive.details.touchees, [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }]);
|
||||
|
||||
const ajoutees = avec(avant, (charge) => {
|
||||
charge.participants.push(personne(5, 'Ancolie'));
|
||||
charge.tables.push({ ...table(3, null), numero: 9 });
|
||||
charge.prochainsIds.participant = 6;
|
||||
charge.prochainsIds.table = 4;
|
||||
});
|
||||
const [nouvelle] = avertissementsApres(avant, ajoutees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
|
||||
assert.deepEqual(nouvelle.details.touchees[0].raisons, [
|
||||
{ code: 'PARTICIPANT_NON_PLACE', participant: 5, personne: 'Ancolie' },
|
||||
{ code: 'TABLE_AJOUTEE', table: 3, numero: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("examenNomme nomme chaque raison de l'examen sur la charge, sans toucher l'examen reçu ; une personne ou une table que la charge n'a plus garde son identifiant", () => {
|
||||
const charge = avec(petiteSoiree(), (copie) => {
|
||||
copie.participants[2].exclu = true;
|
||||
copie.participants.pop();
|
||||
});
|
||||
const examen = examiner(charge);
|
||||
const copie = structuredClone(examen);
|
||||
const nomme = examenNomme(examen, charge);
|
||||
assert.deepEqual(examen, copie);
|
||||
const attendues = [
|
||||
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' },
|
||||
{ code: 'PARTICIPANT_SUPPRIME', participant: 4 },
|
||||
];
|
||||
assert.deepEqual(nomme.retenu.raisons, attendues);
|
||||
assert.deepEqual(nomme.derives.map(({ raisons }) => raisons), [attendues, attendues]);
|
||||
assert.deepEqual(nomme.fautives, examen.fautives);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => {
|
||||
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
|
||||
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}', () => {
|
||||
const avant = petiteSoiree();
|
||||
const apres = avec(avant, reserverGrisaille);
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
|
||||
]);
|
||||
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
|
||||
});
|
||||
|
||||
test('la personne se nomme par son nom affiché, et la table par son numéro, distinct de son identifiant', () => {
|
||||
const avant = avec(petiteSoiree(), (charge) => {
|
||||
charge.tables[1].numero = 7;
|
||||
charge.participants[1].prenom = 'Aubépine';
|
||||
});
|
||||
const apres = avec(avant, reserverGrisaille);
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Aubépine Grisaille', numero: 7 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
|
||||
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
|
||||
const apres = avec(avant, (charge) => {
|
||||
|
|
@ -242,7 +304,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
|
|||
charge.evenement.etat = 'retenu';
|
||||
});
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -250,7 +312,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
|
|||
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
|
||||
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -321,3 +383,110 @@ describe('avertissements : les violations du retenu hors de la dérive (§ 14.12
|
|||
assert.equal(invariantViole(incoherente), null);
|
||||
});
|
||||
});
|
||||
|
||||
// Huit personnes, deux tables de quatre, un tour, sièges attribués : le
|
||||
// retenu assied 1 à 4 aux sièges 1 à 4 de la table 1, et 5 à 8 à la table 2.
|
||||
function soireeAuxSieges() {
|
||||
const charge = creerCharge({ id: 'evt-sieges', nom: 'Soirée des Chaises', siegesParDefaut: 4, tours: 1 });
|
||||
const ids = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
charge.participants = ids.map((id) => personne(id, `Convive ${id}`));
|
||||
charge.tables = [table(1, null), table(2, null)];
|
||||
charge.reglages.attribuerSieges = true;
|
||||
const plan = { tables: [1, 2], capacites: [4, 4], tours: 1, participants: ids, placement: [{ sieges: [[1, 2, 3, 4], [5, 6, 7, 8]], reserve: [] }] };
|
||||
charge.propositions = [
|
||||
{ id: 1, graine: 1, arret: 40, historique: 1, produitVersion: VERSION.affichee, siegesAttribues: true, ...structuredClone(plan) },
|
||||
];
|
||||
charge.retenu = { proposition: 1, siegesAttribues: true, ...structuredClone(plan) };
|
||||
charge.prochainsIds = { participant: 9, table: 3, proposition: 2 };
|
||||
charge.evenement.etat = 'retenu';
|
||||
return charge;
|
||||
}
|
||||
|
||||
const reserverAuSiege = (participant, idTable, siege) => (charge) =>
|
||||
charge.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
|
||||
const deCode = (code) => (avertissements) => avertissements.filter((avertissement) => avertissement.code === code);
|
||||
|
||||
describe("avertissements : la réservation que le retenu n'honore pas au siège (§ 5.8, § 14.12)", () => {
|
||||
test('sièges attribués, une réservation au siège 3 que le retenu donne à un autre, la personne assise à la même table : RESERVATION_NON_HONOREE au siège, avec son occupant', () => {
|
||||
const avant = soireeAuxSieges();
|
||||
const apres = avec(avant, reserverAuSiege(2, 1, 3));
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{
|
||||
code: 'RESERVATION_NON_HONOREE',
|
||||
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: 3 },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(invariantViole(apres), {
|
||||
code: 'INVARIANT_VIOLE',
|
||||
details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 1, tour: 1, siege: 3, occupant: 3 }] },
|
||||
});
|
||||
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
|
||||
});
|
||||
|
||||
test("la chaise réservée vide : l'occupant est null", () => {
|
||||
const avant = avec(soireeAuxSieges(), (charge) => {
|
||||
charge.retenu.placement[0].sieges[0] = [1, 2, null, 4];
|
||||
charge.retenu.placement[0].reserve = [3];
|
||||
});
|
||||
const apres = avec(avant, reserverAuSiege(2, 1, 3));
|
||||
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), [
|
||||
{
|
||||
code: 'RESERVATION_NON_HONOREE',
|
||||
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("tenue à son siège, réservée sans siège, ou sans attribution des sièges : rien au siège", () => {
|
||||
const avant = soireeAuxSieges();
|
||||
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(3, 1, 3))), []);
|
||||
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(2, 1, null))), []);
|
||||
const sansAttribution = avec(avant, (charge) => {
|
||||
charge.reglages.attribuerSieges = false;
|
||||
charge.retenu.siegesAttribues = false;
|
||||
charge.propositions[0].siegesAttribues = false;
|
||||
});
|
||||
assert.deepEqual(avertissementsApres(sansAttribution, avec(sansAttribution, reserverAuSiege(2, 1, 3))), []);
|
||||
assert.equal(invariantViole(avec(sansAttribution, reserverAuSiege(2, 1, 3))), null);
|
||||
});
|
||||
|
||||
test('deux réservations au même siège : la première dans l’ordre canonique le tient, la seconde se lit sans siège', () => {
|
||||
const avant = soireeAuxSieges();
|
||||
const apres = avec(avant, (charge) => {
|
||||
reserverAuSiege(3, 1, 3)(charge);
|
||||
reserverAuSiege(4, 1, 3)(charge);
|
||||
});
|
||||
assert.deepEqual(avertissementsApres(avant, apres), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avertissements : la réservation d'une personne que le retenu ne place pas (§ 9, § 14.12)", () => {
|
||||
test("une personne ajoutée après la retenue et réservée : RESERVATION_NON_HONOREE, en plus de la dérive qui la dit non placée ; INVARIANT_VIOLE la garde", () => {
|
||||
const avant = soireeAuxSieges();
|
||||
const apres = avec(avant, (charge) => {
|
||||
charge.participants.push(personne(9, 'Ancolie'));
|
||||
charge.prochainsIds.participant = 10;
|
||||
reserverAuSiege(9, 2, null)(charge);
|
||||
});
|
||||
const rendus = avertissementsApres(avant, apres);
|
||||
assert.deepEqual(codes(rendus), ['PLACES_MANQUANTES', 'DERIVE_NOUVELLE', 'RESERVATION_NON_HONOREE']);
|
||||
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(rendus)[0].details, {
|
||||
participant: 9,
|
||||
table: 2,
|
||||
tour: 1,
|
||||
personne: 'Ancolie',
|
||||
numero: 2,
|
||||
});
|
||||
assert.deepEqual(invariantViole(apres).details.violations, [{ code: 'RESERVATION', participant: 9, table: 2, tour: 1 }]);
|
||||
});
|
||||
|
||||
test("non réservée, la personne ajoutée ne relève que de la dérive", () => {
|
||||
const avant = soireeAuxSieges();
|
||||
const apres = avec(avant, (charge) => {
|
||||
charge.participants.push(personne(9, 'Ancolie'));
|
||||
charge.prochainsIds.participant = 10;
|
||||
});
|
||||
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), []);
|
||||
assert.equal(invariantViole(apres), null);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
|
||||
// modifie ce qu'elle reçoit.
|
||||
import { ordreDesReservations } from '../csv/import.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { SIEGES_MAX, capacite } from '../stockage/document.js';
|
||||
import { controlerCapacites } from './capacite.js';
|
||||
import { presents } from './places.js';
|
||||
|
||||
|
|
@ -46,8 +46,10 @@ const identifiants = (placements, touche) =>
|
|||
* Chaque table dont la capacité change se contrôle comme une saisie au champ
|
||||
* (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
|
||||
* details}, par ordre de charge.tables ; avertissements, de même, ceux des
|
||||
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
|
||||
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
|
||||
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places, ou plus de
|
||||
* SIEGES_MAX, refusent pour l'événement entier, qu'une table suive le
|
||||
* défaut ou non : un seul refus, MOINS_DE_DEUX ou TROP_DE_PLACES {max},
|
||||
* table null.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} sieges
|
||||
|
|
@ -64,10 +66,11 @@ export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}
|
|||
const changements = new Map(
|
||||
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
|
||||
);
|
||||
const controle =
|
||||
sieges < 2
|
||||
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
|
||||
: controlerCapacites(charge, changements, { origine: 'champ' });
|
||||
const pourLEvenement = (code, details) => ({ refus: [{ table: null, code, details: { ...details, remede: null } }], avertissements: [] });
|
||||
let controle;
|
||||
if (sieges < 2) controle = pourLEvenement('MOINS_DE_DEUX', {});
|
||||
else if (sieges > SIEGES_MAX) controle = pourLEvenement('TROP_DE_PLACES', { max: SIEGES_MAX });
|
||||
else controle = controlerCapacites(charge, changements, { origine: 'champ' });
|
||||
return {
|
||||
suivent: charge.tables.filter((table) => table.sieges === null).length,
|
||||
avant: charge.evenement.siegesParDefaut,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { nombrePlaces, normaliser } from '../moteur/configuration.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { SIEGES_MAX, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
|
||||
|
|
@ -125,6 +125,15 @@ describe('bilans : changer le défaut (§ 6.1)', () => {
|
|||
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
|
||||
});
|
||||
|
||||
test("au-delà de SIEGES_MAX, un seul refus pour l'événement, TROP_DE_PLACES, même quand aucune table ne suit le défaut", () => {
|
||||
const refus = [{ table: null, code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } }];
|
||||
const suivent = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
|
||||
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX + 1).refus, refus);
|
||||
const surchargees = chargeDe({ tables: [{ id: 1, sieges: 6 }], n: 4 });
|
||||
assert.deepEqual(bilanChangementDefaut(surchargees, SIEGES_MAX + 1).refus, refus);
|
||||
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX).refus, []);
|
||||
});
|
||||
|
||||
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
|
||||
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
//
|
||||
// Un refus a la forme { code, details }, le remède dans details.remede. Ils
|
||||
// se contrôlent dans cet ordre, et le premier seul est rendu :
|
||||
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de
|
||||
// MOINS_DE_DEUX ; TROP_DE_PLACES, au-delà de SIEGES_MAX, la borne que
|
||||
// l'analyse du fichier pose aussi ; SOUS_RESERVATIONS, sous le plus grand compte de
|
||||
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
|
||||
// porte un titre ou une réservation, suspendue comprise — l'analyse du
|
||||
// fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
// fonction ne modifie ce qu'elle reçoit.
|
||||
import { ordreDesReservations } from '../csv/import.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { SIEGES_MAX, capacite } from '../stockage/document.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
|
||||
import { reservationsParTour } from './pose.js';
|
||||
|
|
@ -148,6 +149,7 @@ function occupation(charge, idTable) {
|
|||
// Le premier refus d'une capacité, ou null.
|
||||
function refusDeCapacite(charge, idTable, sieges, origine) {
|
||||
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
|
||||
if (sieges > SIEGES_MAX) return refusDe('TROP_DE_PLACES', { max: SIEGES_MAX }, null);
|
||||
const reservees = maximum(reservationsParTour(charge, idTable));
|
||||
if (sieges < reservees.n) {
|
||||
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
|
||||
|
|
@ -379,7 +381,8 @@ export function controlerCapacites(charge, changements, { origine }) {
|
|||
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
|
||||
* SEUIL_SIEGES_LISIBLES places.
|
||||
*
|
||||
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n,
|
||||
* Refus : MOINS_DE_DEUX {}, remède null ; TROP_DE_PLACES {max}, au-delà de
|
||||
* SIEGES_MAX, remède null ; SOUS_RESERVATIONS {table, n,
|
||||
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
|
||||
* remède { geste: 'afficherTour', tour, proposition: null } ;
|
||||
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
|
|||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { analyser, creerCharge } from '../stockage/document.js';
|
||||
import { SIEGES_MAX, analyser, creerCharge } from '../stockage/document.js';
|
||||
import { controlerCapacite, lireEntier } from './capacite.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
|
@ -96,6 +96,17 @@ describe("capacité : les refus d'une capacité (§ 6.2)", () => {
|
|||
assert.equal(champ(charge, 2).refus, null);
|
||||
});
|
||||
|
||||
test('au-delà de SIEGES_MAX places : TROP_DE_PLACES {max}, remède null, au champ comme à la poignée ; SIEGES_MAX passe, et la charge se relit', () => {
|
||||
const charge = chargeUneTable();
|
||||
for (const sieges of [SIEGES_MAX + 1, 99_999_999_999, 2 ** 32]) {
|
||||
assert.deepEqual(champ(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
|
||||
assert.deepEqual(poignee(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
|
||||
}
|
||||
assert.equal(champ(charge, SIEGES_MAX).refus, null);
|
||||
const { charge: rendue } = COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: String(SIEGES_MAX), origine: 'champ' });
|
||||
assert.doesNotThrow(() => analyser(serialiser(rendue, { revision: 1, produitVersion: 'essai' })));
|
||||
});
|
||||
|
||||
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => {
|
||||
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
|
||||
assert.deepEqual(champ(cinq, 4).refus, {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
||||
// table et changer les tours laissent propositions et retenu en dérive.
|
||||
import { caseLibre } from '../geometrie/tables.js';
|
||||
import { SCHEMA, capacite } from '../stockage/document.js';
|
||||
import { SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { bilanChangementDefaut } from './bilans.js';
|
||||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||||
|
|
@ -91,8 +91,9 @@ function exigerCapacite(charge, id, sieges, origine) {
|
|||
/**
|
||||
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
|
||||
* prochainsIds.table, numérotée après la plus grande, posée sur la première
|
||||
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
|
||||
* capacité et une liste vide à chaque tour.
|
||||
* case libre (caseLibre) de la grille des démonstrations, au pas que donnent
|
||||
* les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu
|
||||
* lisible, elle y entre avec sa capacité et une liste vide à chaque tour.
|
||||
*/
|
||||
function creerTable(charge, { forme } = {}) {
|
||||
exigerModifiable(charge);
|
||||
|
|
@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) {
|
|||
const id = charge.prochainsIds.table;
|
||||
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
|
||||
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
|
||||
const position = caseLibre(charge.tables.map((table) => table.position));
|
||||
const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) }));
|
||||
const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut });
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.tables.push({ id, numero, sieges: null, forme, position });
|
||||
suivante.prochainsIds.table = id + 1;
|
||||
|
|
@ -212,7 +214,8 @@ function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
|
|||
|
||||
/**
|
||||
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
|
||||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
|
||||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà
|
||||
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
|
||||
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
|
||||
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
|
||||
* dérive TOURS_CHANGES, jamais tronqués.
|
||||
|
|
@ -221,6 +224,7 @@ function changerTours(charge, { tours: saisie } = {}) {
|
|||
exigerModifiable(charge);
|
||||
const tours = entierSaisi(saisie);
|
||||
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
|
||||
if (tours > TOURS_MAX) throw refus('TROP_DE_TOURS', { max: TOURS_MAX });
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.evenement.tours = tours;
|
||||
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { caseLibre } from '../geometrie/tables.js';
|
||||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { caseLibre, geometrieTable } from '../geometrie/tables.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js';
|
||||
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { creer as creerDansLeDepot } from '../stockage/depot.js';
|
||||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { SIEGES_MAX, TOURS_MAX, analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { lireJournal } from '../stockage/journal.js';
|
||||
import { SUFFIXES } from '../stockage/noms.js';
|
||||
import { derive, examiner, fautes } from '../stockage/placements.js';
|
||||
|
|
@ -118,6 +121,45 @@ function seNormalise(charge) {
|
|||
}
|
||||
}
|
||||
|
||||
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
|
||||
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le
|
||||
// dessin la mesure au navigateur.
|
||||
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
|
||||
|
||||
// Les fautes « une liste passe sur une autre table » d'une charge : pour
|
||||
// chaque paire ordonnée de tables, la bande du bloc de noms de la première —
|
||||
// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de
|
||||
// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la
|
||||
// seconde, bords exclus. Avec avec, seules les paires où figure la table
|
||||
// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires
|
||||
// examinées.
|
||||
function fautesBandes(charge, { avec = null } = {}) {
|
||||
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
||||
const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) }));
|
||||
const fautes = [];
|
||||
let paires = 0;
|
||||
for (const { g, places } of geometries) {
|
||||
const bande = {
|
||||
gauche: g.origineListe.x,
|
||||
droite: g.origineListe.x + largeur,
|
||||
haut: g.origineListe.y,
|
||||
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
|
||||
};
|
||||
for (const { g: autre } of geometries) {
|
||||
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
|
||||
paires += 1;
|
||||
const e = autre.rayonEmprise;
|
||||
const recoupe =
|
||||
autre.centre.x + e > bande.gauche &&
|
||||
autre.centre.x - e < bande.droite &&
|
||||
autre.centre.y + e > bande.haut &&
|
||||
autre.centre.y - e < bande.bas;
|
||||
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
|
||||
}
|
||||
}
|
||||
return { fautes, paires };
|
||||
}
|
||||
|
||||
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
|
||||
// se relit, et se normalise quand la charge reçue se normalisait.
|
||||
function accepter(nom, charge, args) {
|
||||
|
|
@ -222,6 +264,56 @@ describe('mobilier : le registre (§ 8.2)', () => {
|
|||
});
|
||||
|
||||
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
|
||||
test('une table créée se pose sur la grille des démonstrations : le pas tient compte des emprises et du bloc de noms, aucune liste ne passe sur une autre table', () => {
|
||||
let charge = chargeDe();
|
||||
for (let i = 0; i < 5; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||
// Rondes de 8 : colonnes à 813 cm, rangées à 300 (ecartsGrille).
|
||||
assert.deepEqual(
|
||||
charge.tables.map(({ position }) => [position.x, position.y]),
|
||||
[[0, 0], [813, 0], [0, 300], [813, 300], [1626, 0]],
|
||||
);
|
||||
// La bande du bloc de noms de chaque table, de toute sa largeur, ne
|
||||
// recoupe l'emprise d'aucune autre.
|
||||
assert.deepEqual(fautesBandes(charge), { fautes: [], paires: 5 * 4 });
|
||||
// Une table passée à 16 places : le pas devient 966 × 430 ; toute case
|
||||
// à moins d'un pas d'une table posée, sur les deux axes, est prise, la
|
||||
// suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
|
||||
// table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
|
||||
// la liste de sa voisine, ce que la création ne change pas.
|
||||
charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge;
|
||||
charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
|
||||
assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
|
||||
assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
|
||||
});
|
||||
|
||||
test('dans la grande démonstration, la table 1 passée à 16 places : la table créée au pas 966 × 430 évite toute liste, (2898, 1720)', () => {
|
||||
// La case (4830, 0) est à 765 cm de la table (4065, 0), dont la liste,
|
||||
// de 4221,4 à 4721,4, couvrirait la table nouvelle, de 4709,6 à 4950,4 :
|
||||
// elle est prise, comme toute case à moins d'un pas d'une table posée.
|
||||
// La rangée 1720 est à 220 cm de la dernière rangée, de trois tables en
|
||||
// 0, 813 et 1626 : la case (2898, 1720) est la première libre. Seules
|
||||
// les paires où figure la table créée sont examinées : la table 1
|
||||
// agrandie porte sa liste sur sa voisine, ce que la création ne change
|
||||
// pas.
|
||||
let charge = analyser(texteLivre('grande')).charge;
|
||||
const premiere = charge.tables.find(({ numero }) => numero === 1);
|
||||
assert.deepEqual(premiere.position, { x: 0, y: 0 });
|
||||
charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge;
|
||||
charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||
assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
|
||||
const creee = charge.tables.at(-1).id;
|
||||
assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
|
||||
});
|
||||
|
||||
test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
|
||||
// 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
|
||||
// trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
|
||||
const grande = analyser(texteLivre('grande')).charge;
|
||||
assert.equal(grande.tables.length, 33);
|
||||
const { charge } = accepter('creerTable', grande, { forme: 'ronde' });
|
||||
assert.deepEqual(charge.tables.at(-1).position, { x: 2439, y: 1500 });
|
||||
});
|
||||
|
||||
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
|
||||
let charge = chargeDe();
|
||||
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||
|
|
@ -234,7 +326,10 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
|||
numero: 3,
|
||||
sieges: null,
|
||||
forme: 'carree',
|
||||
position: caseLibre(charge.tables.map(({ position }) => position)),
|
||||
position: caseLibre(
|
||||
charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })),
|
||||
{ forme: 'carree', places: charge.evenement.siegesParDefaut },
|
||||
),
|
||||
});
|
||||
assert.equal(cree.charge.prochainsIds.table, 5);
|
||||
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
|
||||
|
|
@ -272,6 +367,20 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
|||
assert.equal(capacite(dix, dix.tables[0]), 10);
|
||||
});
|
||||
|
||||
test('une table créée se pose au pas de sa propre capacité, le défaut : sous un défaut de 16, à côté d’une ronde de 8, en (966, 0) et non en (813, 0)', () => {
|
||||
// Une ronde de 8 posée en (0, 0) : la grille a deux colonnes, et la case
|
||||
// (0, 0) est prise. Le pas se calcule sur la plus grande emprise, celle
|
||||
// de la table nouvelle à 16 places : 966 cm entre colonnes, là où deux
|
||||
// rondes de 8 n'en demandent que 813.
|
||||
const charge = chargeDe({ tables: [{ id: 1, sieges: 8 }], defaut: 16 });
|
||||
charge.tables[0].position = { x: 0, y: 0 };
|
||||
const { charge: apres } = accepter('creerTable', charge, { forme: 'ronde' });
|
||||
const creee = apres.tables.at(-1);
|
||||
assert.equal(capacite(apres, creee), 16);
|
||||
assert.deepEqual(creee.position, { x: 966, y: 0 });
|
||||
assert.deepEqual(fautesBandes(apres), { fautes: [], paires: 2 });
|
||||
});
|
||||
|
||||
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
|
||||
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
|
||||
|
|
@ -628,7 +737,11 @@ describe("mobilier : les places d'une table, par le champ et par la poignée (§
|
|||
});
|
||||
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
|
||||
assert.deepEqual(avertissements, surnumeraires, nom);
|
||||
assert.equal(invariantViole(apres), null, nom);
|
||||
// La réduction n'ajoute aucune violation : un siège réservé que le
|
||||
// retenu donnait déjà à un autre peut le rester, rien d'autre.
|
||||
const violations = (etat) => (invariantViole(etat)?.details.violations ?? []).map((v) => JSON.stringify(v));
|
||||
const deja = violations(charge);
|
||||
assert.deepEqual(violations(apres).filter((v) => !deja.includes(v)), [], nom);
|
||||
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne.
|
||||
assert.deepEqual(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
|
||||
assert.deepEqual(fautes(apres.retenu), [], nom);
|
||||
|
|
@ -833,6 +946,29 @@ describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
|
|||
assert.equal(ecritures(), avant);
|
||||
assert.equal(seance.charge.evenement.tours, 2);
|
||||
});
|
||||
|
||||
test('au-delà de TOURS_MAX : TROP_DE_TOURS {max}, remède null, sans écriture ; TOURS_MAX passe', async () => {
|
||||
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
|
||||
const avant = ecritures();
|
||||
for (const tours of [String(TOURS_MAX + 1), '99999999999']) {
|
||||
await refuseAsync(seance.executer('changerTours', { tours }), 'TROP_DE_TOURS', { max: TOURS_MAX, remede: null });
|
||||
}
|
||||
assert.equal(ecritures(), avant);
|
||||
await seance.executer('changerTours', { tours: String(TOURS_MAX) });
|
||||
assert.equal(seance.charge.evenement.tours, TOURS_MAX);
|
||||
});
|
||||
|
||||
test('au-delà de SIEGES_MAX, le défaut comme une table refusent TROP_DE_PLACES {max}, remède null, sans rien écrire', async () => {
|
||||
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
|
||||
const avant = ecritures();
|
||||
await refuseAsync(seance.executer('changerDefaut', { sieges: '1000000', realigner: false }), 'TROP_DE_PLACES', { max: SIEGES_MAX, remede: null });
|
||||
await refuseAsync(
|
||||
seance.executer('changerPlacesTable', { id: 1, sieges: '99999999999', origine: 'champ' }),
|
||||
'TROP_DE_PLACES',
|
||||
{ max: SIEGES_MAX, remede: null },
|
||||
);
|
||||
assert.equal(ecritures(), avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@
|
|||
// retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente,
|
||||
// celle d'une dérive (§ 9), ne bloque rien.
|
||||
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
|
||||
import { verifierInvariants } from '../moteur/verification.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { fautes, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { violationsDuRetenu } from './avertissements.js';
|
||||
import { refus } from './erreurs.js';
|
||||
import { libelle } from './libelles.js';
|
||||
import { conversionsPossibles } from './listes_travail.js';
|
||||
|
|
@ -418,35 +418,57 @@ function rangDeTable(retenu, idTable, places) {
|
|||
return retenu.tables.length - 1;
|
||||
}
|
||||
|
||||
// Clé d'une violation : ses champs, dans un ordre fixe.
|
||||
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]);
|
||||
// Clé d'une violation : ses champs, dans un ordre fixe ; l'occupant d'un
|
||||
// siège réservé n'y entre pas, la réservation non tenue reste la même.
|
||||
const cleDeViolation = ({ code, participant, table, tour, detail, siege }) =>
|
||||
JSON.stringify([code, participant, table, tour, detail, siege]);
|
||||
|
||||
// Les violations de verifierInvariants que suivant ajoute au retenu
|
||||
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
|
||||
// Les violations du retenu (violationsDuRetenu, celles de verifierInvariants,
|
||||
// la réserve étant une place du plan, et les réservations non tenues qu'il
|
||||
// ne relève pas) que suivant ajoute au retenu precedent : celles dont la
|
||||
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
|
||||
// personne une fois, et verifierInvariants n'y rend jamais deux violations
|
||||
// de même clé. [] quand la configuration ne se normalise pas.
|
||||
// personne une fois, et n'y rend jamais deux violations de même clé. []
|
||||
// quand la configuration ne se normalise pas.
|
||||
function violationsNouvelles(charge, precedent, suivant) {
|
||||
const instance = instanceDe(charge);
|
||||
if (instance === null) return [];
|
||||
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true });
|
||||
const juger = (retenu) => violationsDuRetenu(charge, retenu, instance);
|
||||
const connues = new Set(juger(precedent).map(cleDeViolation));
|
||||
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
|
||||
}
|
||||
|
||||
// La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute
|
||||
// une violation au retenu reçu. Le versement en réserve des personnes que le
|
||||
// plan n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
|
||||
// une violation au retenu reçu, son remède celui que remedeDe(violations)
|
||||
// rend, null par défaut. Le versement en réserve des personnes que le plan
|
||||
// n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
|
||||
// aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle
|
||||
// réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2).
|
||||
function retouche(charge, suivante) {
|
||||
function retouche(charge, suivante, remedeDe = () => null) {
|
||||
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
|
||||
if (violations.length > 0) {
|
||||
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
|
||||
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations }, remedeDe(violations));
|
||||
}
|
||||
return suivante;
|
||||
}
|
||||
|
||||
// Le remède d'un retrait du tour que la réservation de la personne à sa
|
||||
// table interdit : le retrait de cette réservation, la première dans l'ordre
|
||||
// canonique qui tient la personne à la table de la violation au tour, « tous
|
||||
// les tours » avant « ce tour ». null quand aucune violation RESERVATION ne
|
||||
// nomme la personne.
|
||||
function remedeDuRetrait(charge, participant, tour, violations) {
|
||||
const violation = violations.find((v) => v.code === 'RESERVATION' && v.participant === participant);
|
||||
if (violation === undefined) return null;
|
||||
const [tenue] = charge.reservations
|
||||
.filter((r) => r.participant === participant && r.table === violation.table && (r.portee === 'tous' || r.tour === tour))
|
||||
.sort(ordreDesReservations);
|
||||
if (tenue === undefined) return null;
|
||||
return {
|
||||
commande: 'retirerReservation',
|
||||
arguments: { participant, table: tenue.table, portee: tenue.portee, tour: tenue.tour },
|
||||
};
|
||||
}
|
||||
|
||||
// Le numéro de la table où le retenu place id à ce tour, null en réserve,
|
||||
// hors du retenu, ou à une table que la charge n'a plus.
|
||||
function numeroDuLieu(charge, tour, id) {
|
||||
|
|
@ -458,8 +480,9 @@ function numeroDuLieu(charge, tour, id) {
|
|||
/**
|
||||
* deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) :
|
||||
* la personne s'assied à cette table au tour donné, et sa place d'avant se
|
||||
* libère. Sièges attribués, au siège donné — sans siège, la première chaise
|
||||
* libre —, les rangs sautés à null ; la chaise d'un présent refuse
|
||||
* libère. Sièges attribués, au siège donné — sans siège, celui que sa
|
||||
* réservation lui tient à cette table, sinon la première chaise libre que
|
||||
* ne tient pas la réservation d'un autre —, les rangs sautés à null ; la chaise d'un présent refuse
|
||||
* SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la
|
||||
* charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste
|
||||
* triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine
|
||||
|
|
@ -498,8 +521,25 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
|
|||
const liste = etage.sieges[rangTable];
|
||||
let cible = siege;
|
||||
if (cible === null) {
|
||||
cible = 1;
|
||||
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1;
|
||||
// Sans siège, la chaise que la réservation de la personne lui tient à
|
||||
// cette table, sinon la première chaise libre — vide ou d'un absent —
|
||||
// que ne tient pas la réservation active d'un autre : le contrôle de
|
||||
// pose ne juge que le siège donné.
|
||||
const tenues = siegesTenus(charge, tour, idTable);
|
||||
const sienne = [...tenues.keys()].find((numero) => tenues.get(numero) === participant);
|
||||
const libre = (numero) => {
|
||||
const assis = liste[numero - 1] ?? null;
|
||||
return (assis === null || !ids.has(assis)) && (!tenues.has(numero) || tenues.get(numero) === participant);
|
||||
};
|
||||
if (sienne !== undefined && (liste[sienne - 1] ?? null) === participant) {
|
||||
throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
|
||||
}
|
||||
if (sienne !== undefined && libre(sienne)) {
|
||||
cible = sienne;
|
||||
} else {
|
||||
cible = 1;
|
||||
while (cible <= places && !libre(cible)) cible += 1;
|
||||
}
|
||||
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
|
||||
}
|
||||
const occupant = liste[cible - 1] ?? null;
|
||||
|
|
@ -553,6 +593,13 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
|
|||
};
|
||||
}
|
||||
|
||||
// Les sièges de la table idTable que tient au tour une réservation active,
|
||||
// tels que le plan les montre (etatPlaces) : Map siège → titulaire.
|
||||
function siegesTenus(charge, tour, idTable) {
|
||||
const { chaises } = etatPlaces(charge, { tour }).tables.find(({ id }) => id === idTable);
|
||||
return new Map(chaises.filter(({ reservation }) => reservation !== null).map(({ siege, reservation }) => [siege, reservation.participant]));
|
||||
}
|
||||
|
||||
// Les personnes que le plan du tour montre assises (etatPlaces, source le
|
||||
// retenu). Une place du retenu à une table que la charge n'a plus, ou à un
|
||||
// rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme
|
||||
|
|
@ -677,7 +724,9 @@ function echangerDansRetenu(charge, { tour, a, b } = {}) {
|
|||
* tour donné et attend dans la réserve de ce tour ; sa chaise se libère.
|
||||
* Une personne que le plan du tour ne montre pas assise (placeDe) — en
|
||||
* réserve, hors du retenu, à une table que la charge n'a plus ou à un rang
|
||||
* au-delà de sa capacité courante — refuse SANS_EFFET.
|
||||
* au-delà de sa capacité courante — refuse SANS_EFFET. Une personne que sa
|
||||
* réservation tient à cette table refuse INVARIANT_VIOLE, remède le retrait
|
||||
* de cette réservation.
|
||||
*/
|
||||
function retirerDuTour(charge, { tour, participant } = {}) {
|
||||
exigerModifiable(charge);
|
||||
|
|
@ -696,7 +745,7 @@ function retirerDuTour(charge, { tour, participant } = {}) {
|
|||
oter(suivante.retenu, tour, participant);
|
||||
enReserve(suivante.retenu, tour, participant);
|
||||
return {
|
||||
charge: retouche(charge, suivante),
|
||||
charge: retouche(charge, suivante, (violations) => remedeDuRetrait(charge, participant, tour, violations)),
|
||||
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
|
||||
avertissements: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -728,19 +728,31 @@ describe('commandes des places : le retenu et la dérive (§ 9, § 4.4)', () =>
|
|||
});
|
||||
|
||||
describe('commandes des places : le vérificateur d’invariants du retenu (§ 14.12, § 14.15)', () => {
|
||||
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, sans écriture', () => {
|
||||
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, remède le retrait de la réservation', () => {
|
||||
// Le retenu reçu porte déjà une violation : 7, réservé à la table 4 au
|
||||
// tour 2, y est assis à la table 7 ; elle n'entre pas dans le refus.
|
||||
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null, 1), resa(7, 5, null, 2)] }));
|
||||
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||
const leve = refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||
proposition: 1,
|
||||
violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }],
|
||||
remede: null,
|
||||
remede: { commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee: 'tour', tour: 1 } },
|
||||
});
|
||||
// Le remède lève le refus : la réservation retirée, le retrait passe.
|
||||
const { commande, arguments: argumentsDuRemede } = leve.details.remede;
|
||||
const { charge: sansReservation } = executer(commande, charge, argumentsDuRemede);
|
||||
const { charge: retire } = executer('retirerDuTour', sansReservation, { tour: 1, participant: 4 });
|
||||
assert.deepEqual(reserveDe(retire, 1), [4]);
|
||||
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 5 });
|
||||
assert.deepEqual(reserveDe(apres, 1), [5]);
|
||||
});
|
||||
|
||||
test('retirer du tour une personne réservée « tous les tours » à sa table : le remède désigne cette réservation', () => {
|
||||
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null)] }));
|
||||
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||
remede: { commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee: 'tous', tour: null } },
|
||||
});
|
||||
});
|
||||
|
||||
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
|
||||
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
|
||||
charge.participants.push(personne(13));
|
||||
|
|
@ -752,6 +764,53 @@ describe('commandes des places : le vérificateur d’invariants du retenu (§ 1
|
|||
});
|
||||
});
|
||||
|
||||
describe('commandes des places : le dépôt sans siège et les réservations au siège (§ 5.9, § 8.9)', () => {
|
||||
// À chaque tour, la table 4 assied 1, 2, 3 aux sièges 1 à 3, la table 7
|
||||
// assied 4 à 11, et 12 attend en réserve.
|
||||
const tours = [1, 2, 3].map(() => [[1, 2, 3], [4, 5, 6, 7, 8, 9, 10, 11]]);
|
||||
|
||||
test('sièges attribués, un dépôt sans siège saute la chaise que tient la réservation active d’un autre', () => {
|
||||
const charge = figer(chargeRetenue({ tours, reservations: [resa(9, 5, 4)] }));
|
||||
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null });
|
||||
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, 12]);
|
||||
const chaise4 = etatPlaces(apres, { tour: 1 }).tables[0].chaises[3];
|
||||
assert.deepEqual([chaise4.occupant, chaise4.reservation?.participant], [null, 9]);
|
||||
});
|
||||
|
||||
test('sièges attribués, sans siège, la personne réservée à un siège de la table y va ; déjà assise sur lui, SANS_EFFET', () => {
|
||||
const charge = figer(chargeRetenue({ tours, reservations: [resa(10, 5, 6)] }));
|
||||
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 10, table: 5, siege: null });
|
||||
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, null, 10]);
|
||||
refuse(() => executer('deplacerDansRetenu', apres, { tour: 1, participant: 10, table: 5, siege: null }), 'SANS_EFFET');
|
||||
});
|
||||
|
||||
test('une personne ajoutée après la retenue et réservée à une table : déposée à cette table au tour 2, elle y est assise, sans INVARIANT_VIOLE', () => {
|
||||
const charge = chargeRetenue();
|
||||
charge.participants.push(personne(13));
|
||||
charge.prochainsIds.participant = 14;
|
||||
charge.reservations.push(resa(13, 5, null));
|
||||
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 13, table: 5, siege: null });
|
||||
assert.deepEqual(listes(apres, 2)[0], [1, 8, 9, 10, 11, 12, 13]);
|
||||
assert.deepEqual(reserveDe(apres, 1), [13]);
|
||||
});
|
||||
|
||||
test('sièges attribués, désigner au siège 3 la personne assise au siège 2 : le retenu reste tel quel, RESERVATION_NON_HONOREE au siège, et son remède, l’échange, la lève', () => {
|
||||
const charge = chargeRetenue();
|
||||
const { charge: designee } = appliquerCommande('designer', charge, { participant: 2, table: 5, siege: 3, libelle: 'Animation' });
|
||||
assert.deepEqual(designee.retenu, charge.retenu);
|
||||
// Au tour 2, le retenu assied 2 à la table 7 : la réservation n'y est
|
||||
// pas tenue à sa table ; aux tours 1 et 3, à la table 4, mais au siège 2.
|
||||
const nonTenues = (avant, apres) =>
|
||||
avertissementsApres(avant, apres)
|
||||
.filter(({ code }) => code === 'RESERVATION_NON_HONOREE')
|
||||
.map(({ details }) => [details.tour, details.siege ?? null, details.occupant ?? null]);
|
||||
assert.deepEqual(nonTenues(charge, designee), [[2, null, null], [1, 3, 3], [3, 3, 3]]);
|
||||
const { charge: echangee } = executer('echangerDansRetenu', designee, { tour: 1, a: 2, b: 3 });
|
||||
assert.deepEqual(listes(echangee, 1)[0], [1, 3, 2, 4, 5, 6]);
|
||||
assert.deepEqual(nonTenues(charge, echangee), [[2, null, null], [3, 3, 3]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enregistrerGeneration : les sièges réservés dans une génération (§ 4, § 5.3, § 8.9)', () => {
|
||||
// Douze personnes, deux tables, Théo réservé « tous » au siège siege de la
|
||||
// table 2, sièges attribués.
|
||||
|
|
@ -882,6 +941,8 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
|
|||
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
|
||||
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
|
||||
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
|
||||
// Le refus du dépôt sur le sol (src/interface/plan/depot.js), dont le texte vit ici.
|
||||
['RESERVATION_TOUS_LES_TOURS', [{ participant: 1, table: 3 }, 'tous les tours']],
|
||||
]);
|
||||
|
||||
test('chaque code de la table des places a son texte, composé de ses détails', () => {
|
||||
|
|
@ -894,6 +955,12 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
|
|||
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
|
||||
}
|
||||
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
|
||||
// Le refus du dépôt sur le sol dit ce que le sol ne fait pas, sans rien
|
||||
// promettre du glissement vers une autre chaise : deplacerReservation y
|
||||
// garde la portée « tous », et la déplace à chaque tour.
|
||||
const tous = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
|
||||
assert.ok(tous.includes('hors des tables'), tous);
|
||||
assert.ok(!tous.includes('ne change que'), tous);
|
||||
assert.equal(libelle('places.titreParDefaut'), 'animateur');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@
|
|||
// ← { type: 'avancement', fait, total } remis aux abonnés
|
||||
// ← { type: 'resultat', propositions } résout le résultat
|
||||
// ← { type: 'erreur', code, details } rejette par ErreurGeneration
|
||||
// ← { type: 'faute', message, pile } rejette par une Error, une faute
|
||||
// ✕ canal d'erreur du travailleur rejette par TRAVAILLEUR_EN_ECHEC
|
||||
//
|
||||
// Le travailleur poste ses fautes de calcul en message : son canal d'erreur
|
||||
// ne porte que l'échec de son script, qui ne se charge ou ne se compile pas.
|
||||
//
|
||||
// Chaque lancer prend un travailleur neuf de la fabrique et le termine à
|
||||
// l'issue : résultat, erreur, faute du travailleur ou annulation. Terminé,
|
||||
// l'issue : résultat, erreur, faute, échec du script ou annulation. Terminé,
|
||||
// le travailleur ne remet plus rien : un message déjà en file à cet instant
|
||||
// ne parvient ni aux abonnés ni au résultat. L'annulation est terminate(),
|
||||
// dans le même tour : immédiate, sans résultat partiel. La fabrique est un
|
||||
|
|
@ -32,8 +37,14 @@ import { ErreurAnnulee, ErreurGeneration } from './generation.js';
|
|||
* lancer(configuration, reglages) rend une Execution : avancement →
|
||||
* surAvancement ; resultat → la promesse résolue aux propositions reçues,
|
||||
* puis terminate() ; erreur {code, details} → rejet par
|
||||
* ErreurGeneration(code, details) ; faute du travailleur, son canal
|
||||
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
|
||||
* ErreurGeneration(code, details) ; faute {message, pile} → rejet par
|
||||
* une Error sans code, dont le message porte celui du travailleur et la
|
||||
* pile la sienne : elle remonte comme une faute, comme sous l'exécuteur
|
||||
* direct ; canal d'erreur du travailleur — son script ne se charge ou ne
|
||||
* se compile pas —, → rejet par ErreurGeneration('TRAVAILLEUR_EN_ECHEC',
|
||||
* { remede: null }), l'événement traité : aucun geste de la page ne
|
||||
* relance un travailleur que le navigateur refuse, et le texte du refus
|
||||
* nomme sa cause (§ 5.9) ;
|
||||
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
|
||||
* avancement. Après l'issue, annuler() ne fait rien.
|
||||
*/
|
||||
|
|
@ -69,11 +80,22 @@ export function creerExecuteurTravailleur(fabrique) {
|
|||
} else if (data.type === 'erreur') {
|
||||
rejeter(new ErreurGeneration(data.code, data.details));
|
||||
terminer();
|
||||
} else if (data.type === 'faute') {
|
||||
const faute = new Error(`travailleur de génération : ${data.message}`);
|
||||
faute.stack = `${faute.stack}\n${data.pile}`;
|
||||
rejeter(faute);
|
||||
terminer();
|
||||
}
|
||||
};
|
||||
// Le script du travailleur ne se charge ou ne se compile pas : le refus
|
||||
// TRAVAILLEUR_EN_ECHEC, que la commande « Générer » pose à côté
|
||||
// d'elle, jamais le message du moteur d'exécution. L'événement est
|
||||
// traité : le navigateur ne le signale pas en plus comme une faute de
|
||||
// la page. Une faute du calcul arrive en message, jamais ici.
|
||||
travailleur.onerror = (evenement) => {
|
||||
evenement?.preventDefault?.();
|
||||
if (termine) return;
|
||||
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
|
||||
rejeter(new ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null }));
|
||||
terminer();
|
||||
};
|
||||
travailleur.postMessage({ type: 'lancer', configuration, reglages });
|
||||
|
|
|
|||
|
|
@ -158,17 +158,51 @@ describe('exécuteur du travailleur (§ 5.10)', () => {
|
|||
assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus');
|
||||
});
|
||||
|
||||
test('le canal d’erreur du travailleur : rejet par une erreur sans code, le travailleur terminé', async () => {
|
||||
test('le canal d’erreur du travailleur — un script qui ne se charge ou ne se compile pas : rejet par ErreurGeneration TRAVAILLEUR_EN_ECHEC, remède null, sans le message ; l’événement est traité, le travailleur terminé', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const [travailleur] = travailleurs;
|
||||
assert.equal(typeof travailleur.onerror, 'function');
|
||||
travailleur.onerror({ message: 'faute d’épreuve' });
|
||||
const evenement = {
|
||||
message: 'faute d’épreuve',
|
||||
traite: false,
|
||||
preventDefault() {
|
||||
this.traite = true;
|
||||
},
|
||||
};
|
||||
travailleur.onerror(evenement);
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
assert.ok(lue.erreur instanceof Error);
|
||||
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
|
||||
assert.equal(lue.erreur.code, 'TRAVAILLEUR_EN_ECHEC');
|
||||
assert.deepEqual(lue.erreur.details, { remede: null });
|
||||
assert.doesNotMatch(JSON.stringify(lue.erreur.details), /épreuve/);
|
||||
assert.equal(evenement.traite, true);
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
// Un événement d'erreur sans preventDefault, comme le rendrait une portée factice, ne lève pas.
|
||||
const seconde = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
travailleurs[1].onerror({});
|
||||
assert.equal((await issue(seconde.resultat)).erreur.code, 'TRAVAILLEUR_EN_ECHEC');
|
||||
});
|
||||
|
||||
test('une faute pendant le calcul, postée { type: faute, message, pile } : rejet par une Error sans code, qui porte le message et la pile du travailleur ; aucun refus, le travailleur terminé, la suite ignorée', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const avancements = [];
|
||||
execution.surAvancement((fait, total) => avancements.push([fait, total]));
|
||||
const [travailleur] = travailleurs;
|
||||
travailleur.poster({ type: 'faute', message: 'TypeError: invariant d’épreuve rompu', pile: 'TypeError: invariant d’épreuve rompu\n at calcul (épreuve.js:1:1)' });
|
||||
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
|
||||
travailleur.poster({ type: 'resultat', propositions: [] });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
assert.ok(lue.erreur instanceof Error, String(lue.erreur));
|
||||
assert.ok(!(lue.erreur instanceof ErreurGeneration), 'une faute n’est pas un refus');
|
||||
assert.ok(!(lue.erreur instanceof ErreurAnnulee));
|
||||
assert.equal(lue.erreur.code, undefined);
|
||||
assert.match(lue.erreur.message, /faute d’épreuve/);
|
||||
assert.match(lue.erreur.message, /TypeError: invariant d’épreuve rompu/);
|
||||
assert.match(lue.erreur.stack, /at calcul \(épreuve\.js:1:1\)/);
|
||||
assert.deepEqual(avancements, []);
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,12 +16,17 @@
|
|||
// versErreurGeneration, le code et
|
||||
// les détails d'une
|
||||
// ErreurConfiguration
|
||||
// ← { type: 'faute', message, pile } une erreur que versErreurGeneration
|
||||
// ne convertit pas : une faute de
|
||||
// programmation, son texte et sa
|
||||
// pile, que l'exécuteur relève
|
||||
// comme une faute
|
||||
//
|
||||
// L'annulation est terminate() du côté de l'interface : immédiate, sans
|
||||
// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un
|
||||
// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit
|
||||
// pas est relevée, et le travailleur la signale par son propre canal
|
||||
// d'erreur.
|
||||
// autre type ne lance rien. Le gestionnaire ne lève jamais : le canal
|
||||
// d'erreur du travailleur ne porte ainsi que l'échec de son script — qui ne
|
||||
// se charge ou ne se compile pas —, et l'exécuteur ne confond pas les deux.
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
|
||||
|
||||
|
|
@ -42,16 +47,19 @@ export function brancherTravailleur(portee) {
|
|||
progressions += 1;
|
||||
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
|
||||
};
|
||||
let propositions;
|
||||
try {
|
||||
exigerReglages(data.reglages);
|
||||
propositions = rechercher(data.configuration, data.reglages, { progression });
|
||||
const propositions = rechercher(data.configuration, data.reglages, { progression });
|
||||
portee.postMessage({ type: 'resultat', propositions });
|
||||
} catch (erreur) {
|
||||
const convertie = versErreurGeneration(erreur);
|
||||
if (!(convertie instanceof ErreurGeneration)) throw convertie;
|
||||
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
|
||||
return;
|
||||
if (convertie instanceof ErreurGeneration) {
|
||||
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
|
||||
} else {
|
||||
const message = String(convertie);
|
||||
const pile = typeof convertie?.stack === 'string' ? convertie.stack : message;
|
||||
portee.postMessage({ type: 'faute', message, pile });
|
||||
}
|
||||
}
|
||||
portee.postMessage({ type: 'resultat', propositions });
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ describe('travailleur de génération (§ 5.10)', () => {
|
|||
assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]);
|
||||
});
|
||||
|
||||
test('une faute que versErreurGeneration ne convertit pas poste { type: faute, message, pile }, sans lever : le canal d’erreur du travailleur reste au script qui ne se charge pas', () => {
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
assert.doesNotThrow(() => envoyer({ type: 'lancer', configuration: null, reglages: { graine: 1, nombre: 1, arret: 10 } }));
|
||||
assert.equal(postes.length, 1);
|
||||
const [faute] = postes;
|
||||
assert.deepEqual(Object.keys(faute).sort(), ['message', 'pile', 'type']);
|
||||
assert.equal(faute.type, 'faute');
|
||||
assert.match(faute.message, /^TypeError: /);
|
||||
assert.equal(typeof faute.pile, 'string');
|
||||
assert.match(faute.pile, /TypeError/);
|
||||
});
|
||||
|
||||
test("un message d'un autre type ne lance rien", () => {
|
||||
const { postes, envoyer } = porteeFactice();
|
||||
envoyer({ type: 'inconnu' });
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
|
|||
|
||||
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||
|
||||
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
||||
// Les arbres balayés, et les fichiers qui portent une table de codes : le
|
||||
// dépôt du plan lève un refus que la commande n'a pas à lever, et son texte
|
||||
// vit ici, avec ceux des commandes des places.
|
||||
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
|
||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js', 'src/interface/plan/depot.js'];
|
||||
// L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce
|
||||
// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre.
|
||||
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
|
||||
|
|
@ -411,6 +413,7 @@ const EXEMPLES = new Map([
|
|||
['DEPASSEMENT', { index: 4, participant: 9 }],
|
||||
// Génération
|
||||
['GENERATION_EN_COURS', { remede: null }],
|
||||
['TRAVAILLEUR_EN_ECHEC', { remede: null }],
|
||||
['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }],
|
||||
['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
|
||||
[
|
||||
|
|
@ -493,6 +496,7 @@ const EXEMPLES = new Map([
|
|||
['PROPOSITION_MONTREE', { proposition: 4, remede: null }],
|
||||
['SIEGE_OCCUPE', { table: 5, siege: 2, tour: 1, occupant: 2, remede: null }],
|
||||
['TABLE_PLEINE', { table: 5, tour: 1, capacite: 6, remede: null }],
|
||||
['RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null }],
|
||||
['ENTREE_RESERVER', { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }],
|
||||
['ENTREE_RETIRER_RESERVATION', { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }],
|
||||
['ENTREE_DEPLACER_RESERVATION', { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }],
|
||||
|
|
@ -507,6 +511,8 @@ const EXEMPLES = new Map([
|
|||
['NON_ENTIER', { saisie: '8abc', remede: null }],
|
||||
['MOINS_DE_DEUX', { remede: null }],
|
||||
['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
|
||||
['TROP_DE_PLACES', { max: 1000, remede: null }],
|
||||
['TROP_DE_TOURS', { max: 100, remede: null }],
|
||||
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } }],
|
||||
['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
|
||||
[
|
||||
|
|
@ -593,6 +599,53 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('RESERVATION_NON_HONOREE nomme la personne et le numéro de la table quand ils sont donnés, sinon leurs identifiants', () => {
|
||||
assert.equal(
|
||||
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3 }),
|
||||
"Le placement retenu n'honore pas la réservation de Iris Ombrelle à la table 3 au tour 1 : il la place ailleurs.",
|
||||
);
|
||||
assert.equal(
|
||||
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }),
|
||||
"Le placement retenu n'honore pas la réservation de la personne n° 4 à la table d'identifiant 5 au tour 1 : il la place ailleurs.",
|
||||
);
|
||||
});
|
||||
|
||||
test('RESERVATION_NON_HONOREE au siège nomme le siège et ce qu’il porte', () => {
|
||||
assert.equal(
|
||||
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3, siege: 2, occupant: 7 }),
|
||||
"Le placement retenu n'honore pas la réservation de Iris Ombrelle au siège 2 de la table 3 au tour 1 : " +
|
||||
"il l'assied à un autre siège de cette table, et donne le siège 2 à une autre personne.",
|
||||
);
|
||||
assert.equal(
|
||||
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, siege: 2, occupant: null }),
|
||||
"Le placement retenu n'honore pas la réservation de la personne n° 4 au siège 2 de la table d'identifiant 5 au tour 1 : " +
|
||||
"il l'assied à un autre siège de cette table, et laisse le siège 2 vide.",
|
||||
);
|
||||
});
|
||||
|
||||
test('une raison de dérive nomme la personne et la table quand ses détails les portent, sinon leurs identifiants', () => {
|
||||
const nommees = [
|
||||
['PARTICIPANT_EXCLU', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle est exclue depuis.'],
|
||||
['PARTICIPANT_SUPPRIME', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle a été supprimée depuis.'],
|
||||
[
|
||||
'PARTICIPANT_NON_PLACE',
|
||||
{ participant: 14, personne: 'Iris Ombrelle' },
|
||||
"Iris Ombrelle, ajoutée ou réintégrée depuis, n'y est pas placée.",
|
||||
],
|
||||
['TABLE_SUPPRIMEE', { table: 9, numero: 3 }, 'La table 3 a été supprimée depuis.'],
|
||||
['TABLE_AJOUTEE', { table: 9, numero: 3 }, 'La table 3 a été ajoutée depuis.'],
|
||||
['CAPACITE_CHANGEE', { table: 9, numero: 3, avant: 8, maintenant: 6 }, 'La table 3 est passée de 8 à 6 places.'],
|
||||
];
|
||||
assert.equal(nommees.length, 6);
|
||||
for (const [code, details, attendu] of nommees) {
|
||||
assert.equal(libelle(code, details), attendu, code);
|
||||
const { personne, numero, ...nus } = details;
|
||||
assert.doesNotMatch(libelle(code, nus), /Iris|table 3/, code);
|
||||
assert.match(libelle(code, nus), personne === undefined ? /identifiant 9/ : /n° 14/, code);
|
||||
void numero;
|
||||
}
|
||||
});
|
||||
|
||||
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
|
||||
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
|
||||
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
|
||||
|
|
@ -888,6 +941,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
|
|||
['MODE_LECTURE', 'seance'],
|
||||
['SUPPORT_ROMPU', 'seance'],
|
||||
['GENERATION_EN_COURS', 'generation'],
|
||||
['TRAVAILLEUR_EN_ECHEC', 'generation'],
|
||||
['AUCUN_PARTICIPANT', 'generation'],
|
||||
['AUCUNE_TABLE', 'generation'],
|
||||
['SURRESERVATION', 'moteur'],
|
||||
|
|
@ -897,7 +951,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
|
|||
['GENERATION_INVARIANT_VIOLE', 'generation'],
|
||||
['COMPTEUR_SATURE', 'stockage'],
|
||||
];
|
||||
assert.equal(REFUS.length, 15);
|
||||
assert.equal(REFUS.length, 16);
|
||||
for (const [code, table] of REFUS) {
|
||||
assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`);
|
||||
assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ export const TABLE = new Map([
|
|||
() => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.",
|
||||
],
|
||||
['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."],
|
||||
// Le travailleur de génération n'a pas démarré, ou s'est arrêté sans
|
||||
// résultat : rien n'est enregistré, et aucun geste de la page ne le relance.
|
||||
[
|
||||
'TRAVAILLEUR_EN_ECHEC',
|
||||
() =>
|
||||
"Le calcul hors de la page n'a pas pu se faire : le navigateur n'a pas démarré le travailleur de génération, " +
|
||||
"ou l'a arrêté sans résultat. Rien n'est enregistré ; relancer le logiciel le recharge.",
|
||||
],
|
||||
[
|
||||
'CONFIRMATION_REQUISE',
|
||||
({ estimation }, texte) =>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export const TABLE = new Map([
|
|||
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.",
|
||||
],
|
||||
['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
|
||||
['TROP_DE_PLACES', ({ max }) => `Une table compte au plus ${max} places : le plan ne dessinerait pas plus de chaises.`],
|
||||
['TROP_DE_TOURS', ({ max }) => `Une soirée compte au plus ${max} tours.`],
|
||||
|
||||
// --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
|
||||
[
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export const TABLE = new Map([
|
|||
({ siege, tour }) => `Le siège ${siege} est occupé au tour ${tour} : déposer la personne sur l'autre les échange.`,
|
||||
],
|
||||
['TABLE_PLEINE', ({ capacite, tour }) => `La table est pleine au tour ${tour} : ses ${capacite} places sont occupées.`],
|
||||
[
|
||||
'RESERVATION_TOUS_LES_TOURS',
|
||||
() =>
|
||||
'Cette personne est réservée ici pour tous les tours : la déposer hors des tables ne retire pas sa réservation. ' +
|
||||
'« Retirer la réservation » la retire de tous les tours.',
|
||||
],
|
||||
|
||||
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
|
||||
[
|
||||
|
|
|
|||
|
|
@ -114,11 +114,25 @@ export const TABLE = new Map([
|
|||
);
|
||||
},
|
||||
],
|
||||
// La personne se nomme par personne, et la table par numero, quand les
|
||||
// détails les portent (avertissementsApres les lit sur la charge) ; une
|
||||
// violation d'INVARIANT_VIOLE, qui ne les porte pas, garde les identifiants.
|
||||
// Au siège, siege et occupant disent la chaise réservée et ce qu'elle
|
||||
// porte : la personne est assise à la même table, à un autre siège.
|
||||
[
|
||||
'RESERVATION_NON_HONOREE',
|
||||
({ participant, table, tour }) =>
|
||||
`Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` +
|
||||
`à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`,
|
||||
({ participant, table, tour, personne = null, numero = null, siege, occupant = null }) => {
|
||||
const qui = personne ?? `la personne n° ${participant}`;
|
||||
const ou = numero === null ? `table d'identifiant ${table}` : `table ${numero}`;
|
||||
if (siege === undefined) {
|
||||
return `Le placement retenu n'honore pas la réservation de ${qui} à la ${ou} au tour ${tour} : il la place ailleurs.`;
|
||||
}
|
||||
const chaise = occupant === null ? `laisse le siège ${siege} vide` : `donne le siège ${siege} à une autre personne`;
|
||||
return (
|
||||
`Le placement retenu n'honore pas la réservation de ${qui} au siège ${siege} de la ${ou} au tour ${tour} : ` +
|
||||
`il l'assied à un autre siège de cette table, et ${chaise}.`
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'INVARIANT_VIOLE',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ function touchees({ ids, retenu }) {
|
|||
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
|
||||
}
|
||||
|
||||
// La personne d'une raison de dérive, par son nom affiché quand les détails
|
||||
// le portent ; la table, par son numéro de même.
|
||||
const laPersonne = ({ participant, personne = null }) => personne ?? `La personne n° ${participant}`;
|
||||
const laTable = ({ table, numero = null }) => (numero === null ? `La table d'identifiant ${table}` : `La table ${numero}`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------
|
||||
[
|
||||
|
|
@ -175,18 +180,17 @@ export const TABLE = new Map([
|
|||
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
|
||||
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
|
||||
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
|
||||
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
|
||||
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
|
||||
[
|
||||
'PARTICIPANT_NON_PLACE',
|
||||
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
|
||||
],
|
||||
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
|
||||
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
|
||||
// Une raison de dérive nomme la personne par personne et la table par
|
||||
// numero quand ses détails les portent (raisonsNommees, de l'application) ;
|
||||
// sinon par leur identifiant.
|
||||
['PARTICIPANT_EXCLU', (details) => `${laPersonne(details)} est exclue depuis.`],
|
||||
['PARTICIPANT_SUPPRIME', (details) => `${laPersonne(details)} a été supprimée depuis.`],
|
||||
['PARTICIPANT_NON_PLACE', (details) => `${laPersonne(details)}, ajoutée ou réintégrée depuis, n'y est pas placée.`],
|
||||
['TABLE_SUPPRIMEE', (details) => `${laTable(details)} a été supprimée depuis.`],
|
||||
['TABLE_AJOUTEE', (details) => `${laTable(details)} a été ajoutée depuis.`],
|
||||
[
|
||||
'CAPACITE_CHANGEE',
|
||||
({ table, avant, maintenant }) =>
|
||||
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||
({ avant, maintenant, ...details }) => `${laTable(details)} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||
],
|
||||
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
|
||||
|
||||
|
|
|
|||
|
|
@ -138,13 +138,20 @@ function planEvalue(charge, instance, placement) {
|
|||
}
|
||||
}
|
||||
|
||||
// Les places que les réservations actives couvrantes tiennent à une table :
|
||||
// tenu, Map siège → réservation, la première dans l'ordre canonique de
|
||||
// chaque siège dans la capacité, une personne n'en tenant qu'un ; sansSiege,
|
||||
// Map personne → réservation, pour chaque autre personne réservée à la
|
||||
// table — sans siège, ou dont le siège est pris : elle se lit comme une
|
||||
// réservation sans siège.
|
||||
function placesTenues(couvrantes, places) {
|
||||
/**
|
||||
* Les places que les réservations actives couvrantes d'une table tiennent,
|
||||
* la règle de l'affichage : tenu, Map siège → réservation, la première dans
|
||||
* l'ordre canonique de chaque siège dans la capacité, une personne n'en
|
||||
* tenant qu'un ; siegeDe, Map personne → son siège tenu ; sansSiege, Map
|
||||
* personne → réservation, pour chaque autre personne réservée à la table —
|
||||
* sans siège, ou dont le siège est pris : elle se lit comme une réservation
|
||||
* sans siège.
|
||||
*
|
||||
* @param {Object[]} couvrantes les réservations actives qui couvrent le tour à cette table, ordre canonique
|
||||
* @param {number} places la capacité courante de la table
|
||||
* @returns {{tenu: Map<number, Object>, siegeDe: Map<number, number>, sansSiege: Map<number, Object>}}
|
||||
*/
|
||||
export function placesTenues(couvrantes, places) {
|
||||
const tenu = new Map();
|
||||
const siegeDe = new Map();
|
||||
const sansSiege = new Map();
|
||||
|
|
@ -163,13 +170,17 @@ function placesTenues(couvrantes, places) {
|
|||
return { tenu, siegeDe, sansSiege };
|
||||
}
|
||||
|
||||
// Le remplissage et les marques d'une chaise (§ 7.6) : plein et cadenas
|
||||
// fermé quand une réservation tient la place, l'anneau en plus pour la portée
|
||||
// « tous les tours » ; sinon cadenas ouvert pour un titre, hachure quand rien
|
||||
// ne le pourvoit à ce tour.
|
||||
function aspect(reservation, titre) {
|
||||
// Le remplissage et les marques d'une chaise (§ 7.6) : une réservation
|
||||
// porte le cadenas fermé, l'anneau en plus pour la portée « tous les
|
||||
// tours » ; la chaise est pleine quand le placement la tient, hachurée — à
|
||||
// pourvoir — quand il la laisse vide ou y assied une autre personne. Le
|
||||
// cadenas ouvert reste au titre seul, hachuré quand rien ne le pourvoit à ce
|
||||
// tour : la forme distingue toujours une réservation d'un titre.
|
||||
function aspect(reservation, occupant, titre) {
|
||||
if (reservation !== null) {
|
||||
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] };
|
||||
const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
|
||||
const remplissage = reservation.participant === occupant ? 'plein' : 'hachure';
|
||||
return { remplissage, marques: ['cadenasFerme', ...anneau] };
|
||||
}
|
||||
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
|
||||
return { remplissage: 'normal', marques: [] };
|
||||
|
|
@ -184,7 +195,11 @@ function aspect(reservation, titre) {
|
|||
* réservations, repartirSieges répartit les occupants de la table, chacun à
|
||||
* son siège réservé, une personne réservée sans siège qui pourvoit un titre
|
||||
* au siège de ce titre ; quand la liste dépasse la capacité, les personnes
|
||||
* réservées à la table passent avant les autres. Un exclu, une personne que la charge n'a plus,
|
||||
* réservées à la table passent avant les autres. Sièges attribués, la
|
||||
* source se lit telle quelle : la réservation qui tient un siège que la
|
||||
* liste donne à une autre personne, ou laisse vide, se lit sur la chaise
|
||||
* sans la remplir — hachure et cadenas fermé —, et sa personne est là où
|
||||
* la liste l'assied. Un exclu, une personne que la charge n'a plus,
|
||||
* n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne
|
||||
* reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au
|
||||
* tour, null quand la source est en dérive ou que la configuration ne se
|
||||
|
|
@ -271,7 +286,7 @@ export function etatPlaces(charge, { tour, proposition = null }) {
|
|||
const reservation = tenue === null ? null : { participant: tenue.participant, portee: tenue.portee, tour: tenue.tour };
|
||||
const rangTitre = titreDePlace.get(`${id}:${siege}`);
|
||||
const titre = rangTitre === undefined ? null : { libelle: titres[rangTitre].libelle, pourvu: pourvuAuTour(rangTitre) };
|
||||
return { siege, occupant, reservation, titre, ...aspect(reservation, titre) };
|
||||
return { siege, occupant, reservation, titre, ...aspect(reservation, occupant, titre) };
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -85,8 +85,10 @@ const PARFAIT = [
|
|||
[[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]],
|
||||
];
|
||||
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
|
||||
// Chaque appel rend des listes neuves : une épreuve qui modifie son plan ne
|
||||
// touche pas PARFAIT.
|
||||
const planParfait = (siegesAttribues = false) =>
|
||||
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues });
|
||||
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: structuredClone(PARFAIT), siegesAttribues });
|
||||
|
||||
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
|
||||
// retenu quand retenu est vrai.
|
||||
|
|
@ -460,6 +462,58 @@ describe('etatPlaces : titres, réservations et marques (§ 4.1, § 4.4, § 7.6)
|
|||
});
|
||||
});
|
||||
|
||||
describe('etatPlaces : la réservation au siège que le placement ne tient pas (§ 5.8, § 7.6, § 14.12)', () => {
|
||||
// La petite démonstration, son plan parfait retenu sous des sièges
|
||||
// attribués : au tour 1, la table 1 assied 1, 2, 3 aux sièges 1 à 3.
|
||||
function auxSieges() {
|
||||
const charge = petite();
|
||||
charge.reglages.attribuerSieges = true;
|
||||
charge.propositions = [propositionDe(1, planParfait(true))];
|
||||
charge.prochainsIds.proposition = 2;
|
||||
charge.retenu = retenuDe(1, planParfait(true));
|
||||
charge.evenement.etat = 'retenu';
|
||||
return charge;
|
||||
}
|
||||
|
||||
test("la chaise réservée à 2, qu'occupe 3 : la réservation se lit, mais ne la tient pas — hachure et cadenas fermé, l'anneau de sa portée ; la chaise de 2 reste sans marque", () => {
|
||||
const charge = auxSieges();
|
||||
charge.reservations.push(resa(2, 1, 3));
|
||||
charge.titres.push({ table: 1, siege: 3, libelle: 'Animation' });
|
||||
const [, deux, trois] = etatPlaces(charge, { tour: 1 }).tables[0].chaises;
|
||||
assert.equal(trois.occupant, 3);
|
||||
assert.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null });
|
||||
assert.deepEqual([trois.remplissage, trois.marques], ['hachure', ['cadenasFerme', 'anneau']]);
|
||||
assert.deepEqual([deux.occupant, deux.reservation, deux.remplissage, deux.marques], [2, null, 'normal', []]);
|
||||
});
|
||||
|
||||
test('vide, la chaise réservée ne se tient pas non plus ; tenue par sa personne, elle est pleine', () => {
|
||||
const vide = auxSieges();
|
||||
vide.retenu.placement[0].sieges[0] = [1, 2];
|
||||
vide.retenu.placement[0].reserve = [3];
|
||||
vide.reservations.push(resa(2, 1, 3, 1));
|
||||
const chaise = etatPlaces(vide, { tour: 1 }).tables[0].chaises[2];
|
||||
assert.deepEqual([chaise.occupant, chaise.reservation?.participant, chaise.remplissage, chaise.marques], [null, 2, 'hachure', ['cadenasFerme']]);
|
||||
|
||||
const tenue = auxSieges();
|
||||
tenue.reservations.push(resa(3, 1, 3));
|
||||
const pleine = etatPlaces(tenue, { tour: 1 }).tables[0].chaises[2];
|
||||
assert.deepEqual([pleine.occupant, pleine.remplissage, pleine.marques], [3, 'plein', ['cadenasFerme', 'anneau']]);
|
||||
});
|
||||
|
||||
test("une réservation d'un tour que la chaise vide ne tient pas, et un titre que rien ne pourvoit : deux formes, cadenas fermé d'un côté, ouvert de l'autre (§ 7.6)", () => {
|
||||
const charge = auxSieges();
|
||||
charge.retenu.placement[0].sieges[0] = [1, 2];
|
||||
charge.retenu.placement[0].reserve = [3];
|
||||
charge.reservations.push(resa(2, 1, 3, 1));
|
||||
charge.titres.push({ table: 2, siege: 1, libelle: 'Accueil' });
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
const reservee = etat.tables[0].chaises[2];
|
||||
const titree = etat.tables[1].chaises[0];
|
||||
assert.deepEqual([reservee.reservation?.participant, reservee.titre, reservee.remplissage, reservee.marques], [2, null, 'hachure', ['cadenasFerme']]);
|
||||
assert.deepEqual([titree.reservation, titree.titre, titree.remplissage, titree.marques], [null, { libelle: 'Accueil', pourvu: false }, 'hachure', ['cadenasOuvert']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('libellePropose et statuts (§ 4, § 4.2)', () => {
|
||||
test('le titre pressenti, puis le libellé le plus fréquent, le plus petit à égalité, puis « animateur », jamais marqué', () => {
|
||||
const charge = creerCharge({ id: 'evt-libelles', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });
|
||||
|
|
|
|||
1075
src/application/registre.test.js
Normal file
1075
src/application/registre.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -90,7 +90,7 @@ import { ErreurStockage } from '../stockage/erreurs.js';
|
|||
import { fil, reconstruire } from '../stockage/journal.js';
|
||||
import { SUFFIXES } from '../stockage/noms.js';
|
||||
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
|
||||
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
|
||||
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
|
|
@ -245,13 +245,14 @@ export function creerSeance({
|
|||
const annulations = new Set();
|
||||
let instantane = null;
|
||||
// Par Ouvert, sa charge et son examen figés, et INVARIANT_VIOLE : un
|
||||
// Ouvert ne change jamais, ses copies se font une fois.
|
||||
// Ouvert ne change jamais, ses copies se font une fois. L'examen de l'état
|
||||
// nomme ses raisons de dérive sur la charge (examenNomme).
|
||||
const parOuvert = new WeakMap();
|
||||
|
||||
function figesDe(ouvert) {
|
||||
let figes = parOuvert.get(ouvert);
|
||||
if (figes === undefined) {
|
||||
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen });
|
||||
const copie = structuredClone({ charge: ouvert.charge, examen: examenNomme(ouvert.examen, ouvert.charge) });
|
||||
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
|
||||
parOuvert.set(ouvert, figes);
|
||||
}
|
||||
|
|
@ -1245,6 +1246,13 @@ export function creerSeance({
|
|||
* lecture, admise dans tout mode, qui n'écrit rien.
|
||||
*/
|
||||
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
|
||||
/**
|
||||
* Une promesse résolue quand les appels déjà en file ont fini, réussis
|
||||
* ou refusés : ce qu'un lecteur de etat() lit ensuite porte leurs
|
||||
* écritures. Rien ne s'écrit ni ne se publie, et rien ne s'ajoute à la
|
||||
* file ; elle ne rejette jamais.
|
||||
*/
|
||||
attendre: () => file,
|
||||
|
||||
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
|
||||
// ils s'emboîtent, et un fini de trop ne compte pas.
|
||||
|
|
|
|||
|
|
@ -745,7 +745,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
|
|||
await seance.executer('retenirProposition', { id: 1 });
|
||||
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
|
||||
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
|
||||
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8 }];
|
||||
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8, personne: 'Epicéa' }];
|
||||
assert.deepEqual(enRetenu.avertissements, [
|
||||
{ code: 'IMPORT_EN_RETENU', details: {} },
|
||||
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } },
|
||||
|
|
@ -1981,7 +1981,9 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => {
|
|||
|
||||
// Laisse passer les tâches en attente : l'écrivain différé des réglages
|
||||
// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse.
|
||||
const laisser = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// setImmediate rappelle une fois vidées les microtâches, où s'enchaînent
|
||||
// les écritures du système de fichiers d'épreuve ; il n'attend aucune durée.
|
||||
const laisser = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Compte les objets atteints depuis valeur, chacun une fois, en affirmant
|
||||
// que chacun est figé.
|
||||
|
|
@ -2453,14 +2455,14 @@ describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)"
|
|||
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||
await seance.executer('retenirProposition', { id: 1 });
|
||||
const exclusion = await seance.executer('exclureParticipant', { id: 3 });
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' }];
|
||||
assert.deepEqual(exclusion.avertissements, [
|
||||
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
|
||||
]);
|
||||
const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } });
|
||||
assert.deepEqual(suivant.avertissements, []);
|
||||
const autre = await seance.executer('exclureParticipant', { id: 2 });
|
||||
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2 }];
|
||||
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2, personne: 'Grisaille' }];
|
||||
assert.deepEqual(autre.avertissements, [
|
||||
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } },
|
||||
]);
|
||||
|
|
@ -2732,6 +2734,51 @@ describe('séance observable : fautives et examen (§ 8.9, A2)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('séance : attendre la file (§ 5.10, § 8.4)', () => {
|
||||
test("attendre se résout quand les appels déjà en file ont fini, réussis ou refusés, sans rien écrire ni publier de plus", async () => {
|
||||
const { seance, fs } = await ouvertSur();
|
||||
await seance.passerEnEcriture();
|
||||
const ecrit = seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
||||
const refuse = seance.executer('ajouterParticipant', { champs: { nom: '' } }).catch((erreur) => erreur);
|
||||
const avant = seance.charge.participants.length;
|
||||
const ecritures = fs.compteurs.ecritures;
|
||||
let publications = 0;
|
||||
seance.subscribe(() => {
|
||||
publications += 1;
|
||||
});
|
||||
publications = 0;
|
||||
await seance.attendre();
|
||||
assert.equal(seance.charge.participants.length, avant + 1);
|
||||
assert.equal((await refuse).code !== undefined, true);
|
||||
await ecrit;
|
||||
const apres = publications;
|
||||
const ecrituresApres = fs.compteurs.ecritures;
|
||||
await seance.attendre();
|
||||
assert.equal(publications, apres, 'attendre ne publie rien');
|
||||
assert.equal(fs.compteurs.ecritures, ecrituresApres, 'attendre n’écrit rien');
|
||||
assert.ok(ecrituresApres > ecritures);
|
||||
});
|
||||
});
|
||||
|
||||
describe('séance observable : la dérive nommée (§ 9, A2)', () => {
|
||||
test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => {
|
||||
const charge = avecRetenu(chargeEssai());
|
||||
charge.participants[2].exclu = true;
|
||||
charge.tables[1].numero = 7;
|
||||
charge.tables[1].sieges = 3;
|
||||
const { seance } = await ouvertSur(charge);
|
||||
const { raisons } = seance.etat().examen.retenu;
|
||||
assert.deepEqual(raisons, [
|
||||
{ code: 'CAPACITE_CHANGEE', table: 2, avant: 2, maintenant: 3, numero: 7 },
|
||||
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
seance.ouvert.examen.retenu.raisons,
|
||||
raisons.map(({ personne, numero, ...nue }) => nue),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => {
|
||||
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => {
|
||||
const fs = creerFichiersSimules();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { versionVoisine } from '../../test/version_voisine.js';
|
|||
import { apercevoir } from '../csv/apercu.js';
|
||||
import { decoder } from '../csv/encodage.js';
|
||||
import { exporterParticipants } from '../csv/export.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { geometrieTable } from '../geometrie/tables.js';
|
||||
import { serialiser } from '../stockage/canonique.js';
|
||||
import { analyser } from '../stockage/document.js';
|
||||
import { examiner } from '../stockage/placements.js';
|
||||
|
|
@ -155,6 +157,25 @@ describe('démonstrations livrées : les fichiers (§ 15.5, point 5)', () => {
|
|||
assert.deepEqual([charge.propositions, charge.retenu], [[], null], cle);
|
||||
}
|
||||
});
|
||||
|
||||
test("le bloc de noms d'une table, de toute sa largeur, ne rencontre l'emprise d'aucune autre table, d'aucune rangée (§ 7.4)", () => {
|
||||
// Le bloc s'étend de origineListe.x sur LARGEUR_BLOC_NOMS_CM, où toute
|
||||
// ligne s'abrège ; sa hauteur, mesurée sur la police, ne se connaît pas
|
||||
// ici : l'épreuve exige la bande verticale entière libre de toute emprise.
|
||||
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
||||
for (const { cle, texte } of LIVREES) {
|
||||
const { charge } = analyser(texte);
|
||||
const geometries = charge.tables.map((table) => geometrieTable(table, table.sieges ?? charge.evenement.siegesParDefaut));
|
||||
const rencontres = geometries.flatMap((g) =>
|
||||
geometries
|
||||
.filter((autre) => autre !== g)
|
||||
.filter((autre) => autre.centre.x - autre.rayonEmprise < g.origineListe.x + largeur && autre.centre.x + autre.rayonEmprise > g.origineListe.x)
|
||||
.map((autre) => `table ${g.numero} sur la table ${autre.numero}`),
|
||||
);
|
||||
assert.ok(geometries.length > 1, cle);
|
||||
assert.deepEqual(rencontres, [], cle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('démonstrations livrées : aller-retour sur la petite (§ 8.9, § 14.4)', () => {
|
||||
|
|
|
|||
|
|
@ -268,38 +268,38 @@
|
|||
],
|
||||
"tables": [
|
||||
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}},
|
||||
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}},
|
||||
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}},
|
||||
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}},
|
||||
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}},
|
||||
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
|
||||
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}},
|
||||
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}},
|
||||
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}},
|
||||
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}},
|
||||
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}},
|
||||
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}},
|
||||
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}},
|
||||
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}},
|
||||
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}},
|
||||
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}},
|
||||
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
|
||||
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}},
|
||||
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}},
|
||||
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}},
|
||||
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}},
|
||||
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}},
|
||||
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}},
|
||||
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}},
|
||||
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}},
|
||||
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}},
|
||||
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}},
|
||||
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
|
||||
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}},
|
||||
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}},
|
||||
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}},
|
||||
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}},
|
||||
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}},
|
||||
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}},
|
||||
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}},
|
||||
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}},
|
||||
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}},
|
||||
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}},
|
||||
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
|
||||
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}},
|
||||
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}},
|
||||
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}},
|
||||
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}},
|
||||
{"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":1500,"y":1200}},
|
||||
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}},
|
||||
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}},
|
||||
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}},
|
||||
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}},
|
||||
{"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":4065,"y":1200}},
|
||||
{"id":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"y":1500}},
|
||||
{"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":300,"y":1500}},
|
||||
{"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":600,"y":1500}}
|
||||
{"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":813,"y":1500}},
|
||||
{"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1500}}
|
||||
],
|
||||
"reservations": [
|
||||
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},
|
||||
|
|
|
|||
|
|
@ -268,38 +268,38 @@
|
|||
],
|
||||
"tables": [
|
||||
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}},
|
||||
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}},
|
||||
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}},
|
||||
{"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}},
|
||||
{"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}},
|
||||
{"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
|
||||
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}},
|
||||
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}},
|
||||
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}},
|
||||
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}},
|
||||
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}},
|
||||
{"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}},
|
||||
{"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}},
|
||||
{"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}},
|
||||
{"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}},
|
||||
{"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}},
|
||||
{"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}},
|
||||
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}},
|
||||
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}},
|
||||
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}},
|
||||
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}},
|
||||
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}},
|
||||
{"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}},
|
||||
{"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}},
|
||||
{"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}},
|
||||
{"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}},
|
||||
{"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}},
|
||||
{"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}},
|
||||
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}},
|
||||
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}},
|
||||
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}},
|
||||
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}},
|
||||
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}},
|
||||
{"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}},
|
||||
{"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}},
|
||||
{"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}},
|
||||
{"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}},
|
||||
{"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}},
|
||||
{"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}},
|
||||
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}},
|
||||
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}},
|
||||
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}},
|
||||
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}},
|
||||
{"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":1500,"y":1200}},
|
||||
{"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}},
|
||||
{"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}},
|
||||
{"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}},
|
||||
{"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}},
|
||||
{"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":4065,"y":1200}},
|
||||
{"id":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"y":1500}},
|
||||
{"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":300,"y":1500}},
|
||||
{"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":600,"y":1500}}
|
||||
{"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":813,"y":1500}},
|
||||
{"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":1626,"y":1500}}
|
||||
],
|
||||
"reservations": [
|
||||
{"participant":2,"table":17,"siege":1,"portee":"tous","tour":null},
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
],
|
||||
"tables": [
|
||||
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
|
||||
],
|
||||
"reservations": [],
|
||||
"titres": [],
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
],
|
||||
"tables": [
|
||||
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
|
||||
],
|
||||
"reservations": [],
|
||||
"titres": [],
|
||||
|
|
|
|||
|
|
@ -38,10 +38,13 @@ export function regimeCibles(k, geometrie) {
|
|||
* de la peinture : la première dont la surface peinte contient le point
|
||||
* décide. En régime table, la surface est l'emprise — disque d'une ronde,
|
||||
* carré d'une carrée — et la table est désignée. En régime sièges, elle est
|
||||
* le plateau et les chaises : un point dans une chaise désigne son siège ;
|
||||
* un point sur le plateau ne désigne rien, et cache les chaises des tables
|
||||
* peintes dessous ; entre les chaises, le point passe aux tables de dessous.
|
||||
* Deux chaises d'une même table ne se recouvrent pas.
|
||||
* le plateau et les cibles des sièges : un point à rayonCible au plus du
|
||||
* centre d'une chaise — max(RAYON_CHAISE, pas / 2), la cible a la taille du
|
||||
* pas — désigne son siège ; un point sur le plateau hors de toute cible ne
|
||||
* désigne rien, et cache les chaises des tables peintes dessous ; hors des
|
||||
* cibles et du plateau, le point passe aux tables de dessous. Deux cibles
|
||||
* d'une même table se touchent au plus : sur leur bord commun, le plus petit
|
||||
* siège l'emporte.
|
||||
*
|
||||
* @param {{x: number, y: number}} point cm
|
||||
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
|
||||
|
|
@ -55,7 +58,7 @@ export function designer(point, geometries, k) {
|
|||
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
|
||||
continue;
|
||||
}
|
||||
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
|
||||
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= geometrie.rayonCible);
|
||||
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
|
||||
if (dans(point, geometrie, geometrie.demiTaille)) return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,33 @@ const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, posit
|
|||
|
||||
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||
|
||||
// Écart sous le bord d'une cible, en cm.
|
||||
const EPS = 0.01;
|
||||
|
||||
// Un point entre deux chaises voisines d'une table : leur milieu, éloigné de
|
||||
// 4 cm de la droite qui les joint, du côté opposé au centre de la table. Sur
|
||||
// un côté de carrée à sièges espacés du pas, le milieu est au bord des deux
|
||||
// cibles ; éloigné, il est hors des deux et reste dans l'emprise. Quand le
|
||||
// milieu est le centre — deux chaises opposées, de part et d'autre d'un côté
|
||||
// sans siège —, le point est sur ce côté, à la distance de la première
|
||||
// chaise plus 4 cm, du côté où tourne la première dans le sens horaire de
|
||||
// l'écran.
|
||||
function entre(g, a, b) {
|
||||
const m = milieu(a, b);
|
||||
const longueur = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
let nx = -(b.y - a.y) / longueur;
|
||||
let ny = (b.x - a.x) / longueur;
|
||||
const vers = { x: m.x - g.centre.x, y: m.y - g.centre.y };
|
||||
const degenere = Math.hypot(vers.x, vers.y) < 1e-6;
|
||||
const reference = degenere ? { x: -(a.y - g.centre.y), y: a.x - g.centre.x } : vers;
|
||||
if (nx * reference.x + ny * reference.y < 0) {
|
||||
nx = -nx;
|
||||
ny = -ny;
|
||||
}
|
||||
const ecart = degenere ? Math.hypot(a.x - g.centre.x, a.y - g.centre.y) + 4 : 4;
|
||||
return { x: m.x + nx * ecart, y: m.y + ny * ecart };
|
||||
}
|
||||
|
||||
describe('regimeCibles (§ 7.3)', () => {
|
||||
test('le seuil est de 24 px', () => {
|
||||
assert.equal(CIBLE_MIN_PX, 24);
|
||||
|
|
@ -65,9 +92,16 @@ describe('l’emprise d’une carrée est un carré', () => {
|
|||
assert.equal(surLeSol(point, geometries), false);
|
||||
});
|
||||
|
||||
test('(140, 0) est le sol', () => {
|
||||
test('(140, 0) : hors de l’emprise, mais à 28 cm du siège 5, en (112, 0), dans sa cible de rayon pas/2 = 30', () => {
|
||||
const point = { x: 140, y: 0 };
|
||||
assert.equal(designer(point, geometries, 0.2), null);
|
||||
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 5 });
|
||||
assert.equal(surLeSol(point, geometries), true);
|
||||
});
|
||||
|
||||
test('(150, 0) est le sol', () => {
|
||||
const point = { x: 150, y: 0 };
|
||||
assert.equal(designer(point, geometries, 0.2), null);
|
||||
assert.equal(designer(point, geometries, 1), null);
|
||||
assert.equal(surLeSol(point, geometries), true);
|
||||
});
|
||||
|
|
@ -112,20 +146,42 @@ describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)',
|
|||
assert.equal(chaises, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
|
||||
let milieux = 0;
|
||||
test(`${nom}, k = 1 : ni un point entre deux chaises voisines, à plus de pas/2 de chacune, ni le centre d’une table ne désigne rien`, () => {
|
||||
let points = 0;
|
||||
for (const g of geometries) {
|
||||
const n = g.chaises.length;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
|
||||
const point = entre(g, g.chaises[i], g.chaises[(i + 1) % n]);
|
||||
for (const voisine of [g.chaises[i], g.chaises[(i + 1) % n]]) {
|
||||
assert.ok(Math.hypot(point.x - voisine.x, point.y - voisine.y) > g.pas / 2, `table ${g.id}, siège ${voisine.siege}`);
|
||||
}
|
||||
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
|
||||
assert.equal(surLeSol(point, geometries), false);
|
||||
milieux += 1;
|
||||
points += 1;
|
||||
}
|
||||
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
|
||||
assert.equal(surLeSol(g.centre, geometries), false);
|
||||
}
|
||||
assert.equal(milieux, ATTENDUES[nom]);
|
||||
assert.equal(points, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom}, au seuil k = 24/pas de chaque table : un point à pas/2 − ε d’une chaise, vers sa voisine, la désigne`, () => {
|
||||
let points = 0;
|
||||
for (const g of geometries) {
|
||||
const k = (CIBLE_MIN_PX / g.pas) * (1 + 1e-12);
|
||||
assert.equal(regimeCibles(k, g), 'sieges');
|
||||
const n = g.chaises.length;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const chaise = g.chaises[i];
|
||||
const voisine = g.chaises[(i + 1) % n];
|
||||
const d = Math.hypot(voisine.x - chaise.x, voisine.y - chaise.y);
|
||||
const portee = g.pas / 2 - EPS;
|
||||
const point = { x: chaise.x + ((voisine.x - chaise.x) * portee) / d, y: chaise.y + ((voisine.y - chaise.y) * portee) / d };
|
||||
assert.deepEqual(designer(point, [g], k), { table: g.id, siege: chaise.siege }, `table ${g.id}, siège ${chaise.siege}`);
|
||||
points += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(points, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom} : un point à 10 m de tout est le sol`, () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
// dessin pose les chaises par geometrieTable et la désignation d'un siège
|
||||
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
|
||||
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
|
||||
// porte aucun angle.
|
||||
// porte aucun angle. La grille où se posent les tables des démonstrations et
|
||||
// celles que l'opérateur crée a un seul pas, ecartsGrille.
|
||||
import { constante } from './constantes.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeometrieTable en cm
|
||||
|
|
@ -19,6 +21,8 @@
|
|||
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
|
||||
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
|
||||
* @property {{x: number, y: number}} poignee centre du disque de la poignée
|
||||
* @property {number} rayonCible rayon du disque, centré sur une chaise, où un point désigne
|
||||
* son siège en régime sièges : max(RAYON_CHAISE, pas / 2)
|
||||
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
|
||||
* (ronde) ou demi-côté du carré d'emprise (carrée)
|
||||
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
|
||||
|
|
@ -35,10 +39,14 @@ export const RAYON_CHAISE = 22;
|
|||
export const RAYON_POIGNEE = 15;
|
||||
/** Distance entre l'emprise et le bord de la poignée, en cm. */
|
||||
export const ECART_POIGNEE = 8;
|
||||
/** Distance entre l'emprise et le bloc de noms, en cm. */
|
||||
export const ECART_LISTE = 10;
|
||||
/** Distance entre deux cases de la grille des tables créées, en cm. */
|
||||
export const PAS_GRILLE = 300;
|
||||
/** Distance entre l'emprise et le bloc de noms, en cm : le carré du focus
|
||||
* que l'interface pose autour de l'emprise, ou d'une chaise, s'étend à
|
||||
* 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
|
||||
* aucune marque ne couvre la liste. */
|
||||
export const ECART_LISTE = 36;
|
||||
/** Écart minimal entre les centres de deux rangées de la grille des tables,
|
||||
* en cm. */
|
||||
export const ECART_RANGEES = 300;
|
||||
|
||||
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
|
||||
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
|
||||
|
|
@ -55,42 +63,37 @@ const COTES = [
|
|||
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
|
||||
];
|
||||
|
||||
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un
|
||||
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie
|
||||
// d'une table se calcule sans valeur de repli.
|
||||
function exigerPlaces(places, saisi) {
|
||||
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1 : la
|
||||
// géométrie d'une table se calcule sans valeur de repli.
|
||||
function exigerPlaces(places) {
|
||||
if (!Number.isInteger(places) || places < 1) {
|
||||
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
|
||||
}
|
||||
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
|
||||
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
|
||||
* diamètre saisi, qui l'emporte.
|
||||
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π). Le format 1
|
||||
* ne porte aucun diamètre saisi : la formule dessine toute table, et le pas
|
||||
* qu'en tire geometrieTable ne connaît qu'elle.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function diametre(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
|
||||
export function diametre(places) {
|
||||
exigerPlaces(places);
|
||||
return Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
|
||||
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
|
||||
* garni porte ⌈places / 4⌉ sièges.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function cote(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||
export function cote(places) {
|
||||
exigerPlaces(places);
|
||||
return Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||
}
|
||||
|
||||
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
|
||||
|
|
@ -138,6 +141,12 @@ function chaisesCarree(centre, demiTaille, places) {
|
|||
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
|
||||
* sièges du côté le plus garni.
|
||||
*
|
||||
* La cible d'un siège est un disque de rayon max(RAYON_CHAISE, pas / 2)
|
||||
* centré sur sa chaise : au zoom où le régime des cibles passe aux sièges,
|
||||
* k × pas ≥ 24 px (designation.js), elle mesure au moins 24 px à l'écran.
|
||||
* Deux chaises d'une même table sont à un pas au moins l'une de l'autre :
|
||||
* deux cibles se touchent au plus, sans se recouvrir.
|
||||
*
|
||||
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
|
||||
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
|
||||
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
|
||||
|
|
@ -172,6 +181,7 @@ export function geometrieTable(table, places) {
|
|||
demiTaille,
|
||||
pas,
|
||||
chaises,
|
||||
rayonCible: Math.max(RAYON_CHAISE, pas / 2),
|
||||
poignee: { x: centre.x + decalage, y: centre.y + decalage },
|
||||
rayonEmprise,
|
||||
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
|
||||
|
|
@ -179,9 +189,13 @@ export function geometrieTable(table, places) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Rectangle englobant des emprises : pour chaque table, le carré de
|
||||
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une
|
||||
* ronde et égale l'emprise d'une carrée.
|
||||
* Rectangle englobant de ce que le dessin pose de chaque table et qui se
|
||||
* pointe : le carré de demi-côté rayonEmprise centré sur elle — il
|
||||
* circonscrit le disque d'une ronde et égale l'emprise d'une carrée —, le
|
||||
* carré circonscrit à la cible de chaque siège, qui déborde l'emprise de
|
||||
* pas / 2 − RAYON_CHAISE, et celui du disque de la poignée, posée au-delà du
|
||||
* coin d'une carrée. L'ajustement (vue.js) le fait tenir dans le cadre :
|
||||
* toute cible et toute poignée y restent visibles.
|
||||
*
|
||||
* @param {GeometrieTable[]} geometries
|
||||
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
|
||||
|
|
@ -192,35 +206,77 @@ export function englobant(geometries) {
|
|||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const { centre, rayonEmprise } of geometries) {
|
||||
minX = Math.min(minX, centre.x - rayonEmprise);
|
||||
minY = Math.min(minY, centre.y - rayonEmprise);
|
||||
maxX = Math.max(maxX, centre.x + rayonEmprise);
|
||||
maxY = Math.max(maxY, centre.y + rayonEmprise);
|
||||
const etendre = ({ x, y }, demi) => {
|
||||
minX = Math.min(minX, x - demi);
|
||||
minY = Math.min(minY, y - demi);
|
||||
maxX = Math.max(maxX, x + demi);
|
||||
maxY = Math.max(maxY, y + demi);
|
||||
};
|
||||
for (const { centre, rayonEmprise, chaises, rayonCible, poignee } of geometries) {
|
||||
etendre(centre, rayonEmprise);
|
||||
for (const chaise of chaises) etendre(chaise, rayonCible);
|
||||
etendre(poignee, RAYON_POIGNEE);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pas de la grille des tables, en cm entiers, pour la plus grande emprise
|
||||
* E des tables données, chacune de sa forme et de son nombre de places :
|
||||
* entre deux colonnes, ⌈2E + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ — le bloc
|
||||
* de noms d'une table, à ECART_LISTE de son emprise et large de
|
||||
* LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège, s'achève à ECART_LISTE de
|
||||
* l'emprise de la colonne suivante : aucune liste ne passe sur une autre
|
||||
* table de la grille, d'aucune rangée ; entre deux rangées,
|
||||
* max(ECART_RANGEES, ⌈2E + ECART_LISTE⌉) — deux emprises voisines restent à
|
||||
* ECART_LISTE l'une de l'autre, la marge du carré de focus.
|
||||
*
|
||||
* @param {Array<{forme: 'ronde'|'carree', places: number}>} tables au moins une
|
||||
* @returns {{colonnes: number, rangees: number}}
|
||||
*/
|
||||
export function ecartsGrille(tables) {
|
||||
const emprise = Math.max(
|
||||
...tables.map(({ forme, places }) => geometrieTable({ id: 0, numero: 0, forme, position: { x: 0, y: 0 } }, places).rayonEmprise),
|
||||
);
|
||||
return {
|
||||
colonnes: Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')),
|
||||
rangees: Math.max(ECART_RANGEES, Math.ceil(2 * emprise + ECART_LISTE)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Position d'une table créée : la première case libre, ligne par ligne, d'une
|
||||
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
|
||||
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
|
||||
* moins. centres : les positions de charge.tables, que l'appelant passe sans
|
||||
* construire de géométrie.
|
||||
* grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas
|
||||
* d'ecartsGrille des tables posées et de la nouvelle — le pas des
|
||||
* démonstrations (scripts/engendrer_livrables.js). Une case est prise quand
|
||||
* une table posée est à moins d'un pas de son centre sur chacun des deux
|
||||
* axes, bornes exclues. Une table à un pas de colonne ou plus a son emprise
|
||||
* hors de la bande du bloc de noms de la table nouvelle, et la sienne
|
||||
* s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
|
||||
* la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
|
||||
* emprise des deux. Une table à un pas de rangée ou plus a son emprise à
|
||||
* ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors
|
||||
* de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée,
|
||||
* ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne
|
||||
* à travers une colonne.
|
||||
*
|
||||
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
|
||||
* deux cases, le plus petit disque qui contient trois cases ayant un rayon
|
||||
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
|
||||
* libre.
|
||||
* La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
|
||||
* au plus deux multiples du pas, et une table prend donc au plus quatre
|
||||
* cases ; l'une des 4T + 1 premières cases est libre.
|
||||
*
|
||||
* @param {Array<{x: number, y: number}>} centres
|
||||
* @param {Array<{position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} tables
|
||||
* les tables posées, chacune à sa capacité courante
|
||||
* @param {{forme: 'ronde'|'carree', places: number}} nouvelle la table à poser
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
export function caseLibre(centres) {
|
||||
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
|
||||
export function caseLibre(tables, nouvelle) {
|
||||
const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
|
||||
const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
|
||||
for (let rang = 0; ; rang += 1) {
|
||||
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
|
||||
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
|
||||
const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY };
|
||||
const prise = tables.some(
|
||||
({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY,
|
||||
);
|
||||
if (!prise) return candidate;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,22 @@
|
|||
// attendues se calculent à la main dans le commentaire qui les précède.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { constante } from './constantes.js';
|
||||
import { surLeSol } from './designation.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
|
||||
import { MARGE_AJUSTEMENT_PX, ajuster, ecranDepuisDessin } from './vue.js';
|
||||
import {
|
||||
ECART_LISTE,
|
||||
ECART_POIGNEE,
|
||||
ECART_RANGEES,
|
||||
LARGEUR_PAR_PLACE,
|
||||
PAS_GRILLE,
|
||||
PLANCHER_DIAMETRE,
|
||||
RAYON_CHAISE,
|
||||
RAYON_POIGNEE,
|
||||
caseLibre,
|
||||
cote,
|
||||
diametre,
|
||||
ecartsGrille,
|
||||
englobant,
|
||||
geometrieTable,
|
||||
placesSousPoignee,
|
||||
|
|
@ -42,6 +46,48 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|||
// Une table du fichier d'état : identifiant, numéro, forme et centre.
|
||||
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
||||
|
||||
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
|
||||
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le
|
||||
// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas.
|
||||
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
|
||||
|
||||
// Les fautes « une liste passe sur une autre table » d'un ensemble de tables
|
||||
// posées {position, forme, places} : pour chaque paire ordonnée, la bande du
|
||||
// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis
|
||||
// origineListe, places + 1 lignes de haut, en-tête d'une table gelée
|
||||
// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec
|
||||
// avec, seules les paires où figure la table de ce rang, compté depuis 1,
|
||||
// sont examinées. Rend les fautes et le nombre de paires examinées.
|
||||
function fautesBandes(posees, { avec = null } = {}) {
|
||||
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
||||
const geometries = posees.map(({ position, forme, places }, rang) => ({
|
||||
g: geometrieTable(table(rang + 1, forme, position.x, position.y), places),
|
||||
places,
|
||||
}));
|
||||
const fautes = [];
|
||||
let paires = 0;
|
||||
for (const { g, places } of geometries) {
|
||||
const bande = {
|
||||
gauche: g.origineListe.x,
|
||||
droite: g.origineListe.x + largeur,
|
||||
haut: g.origineListe.y,
|
||||
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
|
||||
};
|
||||
for (const { g: autre } of geometries) {
|
||||
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
|
||||
paires += 1;
|
||||
const e = autre.rayonEmprise;
|
||||
const recoupe =
|
||||
autre.centre.x + e > bande.gauche &&
|
||||
autre.centre.x - e < bande.droite &&
|
||||
autre.centre.y + e > bande.haut &&
|
||||
autre.centre.y - e < bande.bas;
|
||||
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
|
||||
}
|
||||
}
|
||||
return { fautes, paires };
|
||||
}
|
||||
|
||||
describe('constantes', () => {
|
||||
test('les valeurs du contrat', () => {
|
||||
assert.equal(PLANCHER_DIAMETRE, 70);
|
||||
|
|
@ -49,8 +95,8 @@ describe('constantes', () => {
|
|||
assert.equal(RAYON_CHAISE, 22);
|
||||
assert.equal(RAYON_POIGNEE, 15);
|
||||
assert.equal(ECART_POIGNEE, 8);
|
||||
assert.equal(ECART_LISTE, 10);
|
||||
assert.equal(PAS_GRILLE, 300);
|
||||
assert.equal(ECART_LISTE, 36);
|
||||
assert.equal(ECART_RANGEES, 300);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -69,9 +115,9 @@ describe('diametre (§ 7.7)', () => {
|
|||
assert.equal(Math.round(diametre(10)), 191);
|
||||
});
|
||||
|
||||
test('un diamètre saisi l’emporte', () => {
|
||||
assert.equal(diametre(8, { saisi: 180 }), 180);
|
||||
assert.equal(diametre(2, { saisi: 50 }), 50);
|
||||
test('une seule formule : le format 1 ne porte aucun diamètre saisi, et une option ne change rien', () => {
|
||||
assert.equal(diametre(8, { saisi: 180 }), diametre(8));
|
||||
assert.equal(diametre(2, { saisi: 50 }), 70);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -83,8 +129,8 @@ describe('cote', () => {
|
|||
assert.equal(cote(9), 180);
|
||||
});
|
||||
|
||||
test('un côté saisi l’emporte', () => {
|
||||
assert.equal(cote(8, { saisi: 150 }), 150);
|
||||
test('une seule formule : une option ne change pas le côté', () => {
|
||||
assert.equal(cote(8, { saisi: 150 }), 120);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -119,7 +165,7 @@ describe('geometrieTable, ronde', () => {
|
|||
proche(g.rayonEmprise, r + 44, 'rayon d’emprise');
|
||||
const ecart = (r + 44 + 23) / Math.SQRT2;
|
||||
procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée');
|
||||
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste');
|
||||
procheDuPoint(g.origineListe, { x: 300 + r + 80, y: 600 - r }, 'origine de la liste');
|
||||
});
|
||||
|
||||
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
|
||||
|
|
@ -163,10 +209,10 @@ describe('geometrieTable, carrée', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => {
|
||||
test('emprise 104, poignée au-delà du coin, liste en (440, 540)', () => {
|
||||
assert.equal(g.rayonEmprise, 104);
|
||||
procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée');
|
||||
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste');
|
||||
procheDuPoint(g.origineListe, { x: 440, y: 540 }, 'origine de la liste');
|
||||
});
|
||||
|
||||
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
|
||||
|
|
@ -238,13 +284,34 @@ describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('la cible d’un siège a la taille du pas (§ 7.3)', () => {
|
||||
test('de 2 à 40 places, deux formes : rayon max(RAYON_CHAISE, pas/2) ; deux cibles ne se recouvrent pas ; le centre de la poignée n’est dans aucune', () => {
|
||||
let tables = 0;
|
||||
for (const forme of ['ronde', 'carree']) {
|
||||
for (let places = 2; places <= 40; places += 1) {
|
||||
const g = geometrieTable(table(1, forme, 10, -20), places);
|
||||
assert.equal(g.rayonCible, Math.max(RAYON_CHAISE, g.pas / 2), `${forme} de ${places}`);
|
||||
for (let i = 0; i < places; i += 1) {
|
||||
for (let j = i + 1; j < places; j += 1) {
|
||||
const d = distance(g.chaises[i], g.chaises[j]);
|
||||
assert.ok(d >= 2 * g.rayonCible - EPSILON, `${forme} de ${places}, sièges ${i + 1} et ${j + 1} à ${d} cm`);
|
||||
}
|
||||
assert.ok(distance(g.poignee, g.chaises[i]) > g.rayonCible, `${forme} de ${places}, poignée et siège ${i + 1}`);
|
||||
}
|
||||
tables += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(tables, 78);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aucune rotation (§ 7.1)', () => {
|
||||
test('la géométrie ne porte aucun angle et une seule poignée', () => {
|
||||
for (const forme of ['ronde', 'carree']) {
|
||||
const g = geometrieTable(table(1, forme, 0, 0), 8);
|
||||
assert.deepEqual(
|
||||
Object.keys(g).sort(),
|
||||
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'],
|
||||
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonCible', 'rayonEmprise'],
|
||||
);
|
||||
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
|
||||
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
|
||||
|
|
@ -259,57 +326,166 @@ describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () =>
|
|||
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
|
||||
});
|
||||
|
||||
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
|
||||
test('diametre et cote : places invalides', () => {
|
||||
assert.throws(() => diametre(0), RangeError);
|
||||
assert.throws(() => cote(1.5), RangeError);
|
||||
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
|
||||
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
|
||||
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
|
||||
assert.throws(() => diametre(Number.NaN), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('englobant', () => {
|
||||
describe('englobant : tout ce que le dessin pose de pointable', () => {
|
||||
test('null sans table', () => {
|
||||
assert.equal(englobant([]), null);
|
||||
});
|
||||
|
||||
test('deux tables : min et max exacts de leurs emprises', () => {
|
||||
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en
|
||||
// (−100, 50), emprise 104.
|
||||
const r = 240 / Math.PI + 44;
|
||||
test('deux tables : min et max exacts de leurs emprises, des cibles de leurs sièges et de leurs poignées', () => {
|
||||
// Ronde de 8 en (300, 600) : chaises à r + 22 du centre, r = 240/π,
|
||||
// cibles de rayon pas/2 = 30, soit r + 52 de chaque côté — la poignée,
|
||||
// à (r + 67)/√2 + 15 ≈ 116,4 sur chaque axe, reste dedans. Carrée de 8
|
||||
// en (−100, 50) : emprise 104 ; chaises de gauche en x = −182 et du haut
|
||||
// en y = −32, cibles de rayon 30 : −212 et −62.
|
||||
const r = 240 / Math.PI;
|
||||
const e = englobant([
|
||||
geometrieTable(table(1, 'ronde', 300, 600), 8),
|
||||
geometrieTable(table(2, 'carree', -100, 50), 8),
|
||||
]);
|
||||
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
|
||||
proche(e.minX, -204, 'minX');
|
||||
proche(e.minY, -54, 'minY');
|
||||
proche(e.maxX, 300 + r, 'maxX');
|
||||
proche(e.maxY, 600 + r, 'maxY');
|
||||
proche(e.minX, -212, 'minX');
|
||||
proche(e.minY, -62, 'minY');
|
||||
proche(e.maxX, 300 + r + 52, 'maxX');
|
||||
proche(e.maxY, 600 + r + 52, 'maxY');
|
||||
});
|
||||
|
||||
test('une carrée seule : la poignée, au-delà du coin de l’emprise, borne le bas et la droite', () => {
|
||||
// Carrée de 8 en (−100, 50) : centre de la poignée en (4 + 23/√2,
|
||||
// 154 + 23/√2), son bord 15 cm plus loin ; les cibles du haut et de la
|
||||
// gauche bornent l'autre coin.
|
||||
const e = englobant([geometrieTable(table(2, 'carree', -100, 50), 8)]);
|
||||
proche(e.minX, -212, 'minX');
|
||||
proche(e.minY, -62, 'minY');
|
||||
proche(e.maxX, 4 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxX');
|
||||
proche(e.maxY, 154 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxY');
|
||||
});
|
||||
|
||||
test('après ajuster, sur une carrée seule de 4 et de 8, le disque de la poignée et chaque cible tiennent dans le cadre moins ses marges', () => {
|
||||
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
|
||||
const dedans = (vue, { x, y }, rayon, libelle) => {
|
||||
const p = ecranDepuisDessin(vue, cadre, x, y);
|
||||
const r = rayon * vue.k;
|
||||
assert.ok(p.x - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.x + r <= cadre.largeur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, x`);
|
||||
assert.ok(p.y - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.y + r <= cadre.hauteur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, y`);
|
||||
};
|
||||
let disques = 0;
|
||||
for (const places of [4, 8]) {
|
||||
const g = geometrieTable(table(1, 'carree', 0, 0), places);
|
||||
const vue = ajuster(englobant([g]), cadre);
|
||||
dedans(vue, g.poignee, RAYON_POIGNEE, `carrée de ${places}, poignée`);
|
||||
for (const chaise of g.chaises) dedans(vue, chaise, g.rayonCible, `carrée de ${places}, siège ${chaise.siege}`);
|
||||
disques += 1 + g.chaises.length;
|
||||
}
|
||||
assert.equal(disques, 14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caseLibre', () => {
|
||||
test('sans table, l’origine', () => {
|
||||
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
|
||||
describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
|
||||
// colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
|
||||
// rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la
|
||||
// plus grande des tables données. LARGEUR_BLOC_NOMS_CM vaut 500.
|
||||
test('la largeur du bloc de noms est celle du registre', () => {
|
||||
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
|
||||
});
|
||||
|
||||
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
|
||||
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
|
||||
test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => {
|
||||
assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 });
|
||||
});
|
||||
|
||||
test('un centre en (150, 0) prend les deux premières cases', () => {
|
||||
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
|
||||
test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => {
|
||||
// 2 × 120,3944 + 72 + 500 = 812,789 → 813 ; 2 × 120,3944 + 36 = 276,8 → 300.
|
||||
assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 8 }]), { colonnes: 813, rangees: 300 });
|
||||
});
|
||||
|
||||
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
|
||||
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
|
||||
test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => {
|
||||
assert.deepEqual(ecartsGrille([{ forme: 'carree', places: 8 }]), { colonnes: 780, rangees: 300 });
|
||||
});
|
||||
|
||||
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
|
||||
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
|
||||
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
|
||||
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
|
||||
test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => {
|
||||
// diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ;
|
||||
// 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430.
|
||||
const tables = [{ forme: 'ronde', places: 8 }, { forme: 'ronde', places: 16 }, { forme: 'carree', places: 8 }];
|
||||
assert.deepEqual(ecartsGrille(tables), { colonnes: 966, rangees: 430 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('caseLibre : la place d\u{2019}une table créée, sur la grille des démonstrations', () => {
|
||||
const huit = { forme: 'ronde', places: 8 };
|
||||
const posee = (x, y, autres = huit) => ({ position: { x, y }, ...autres });
|
||||
|
||||
test('sans table, l\u{2019}origine', () => {
|
||||
assert.deepEqual(caseLibre([], huit), { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test('deux rondes de 8 en (0, 0) et (813, 0) : deux colonnes, la case suivante est (0, 300)', () => {
|
||||
assert.deepEqual(caseLibre([posee(0, 0), posee(813, 0)], huit), { x: 0, y: 300 });
|
||||
});
|
||||
|
||||
test('un centre à moins d\u{2019}un pas de colonne, (406,6 ; 0) ou (812,9 ; 0), prend les deux premières cases ; à 813, la première est libre', () => {
|
||||
// À 406,6 de (0, 0), le bloc de la table nouvelle, de 156,4 à 656,4,
|
||||
// couvrirait l'emprise posée, de 286,2 à 527 : la case est refusée.
|
||||
assert.deepEqual(caseLibre([posee(406.6, 0)], huit), { x: 0, y: 300 });
|
||||
assert.deepEqual(caseLibre([posee(812.9, 0)], huit), { x: 0, y: 300 });
|
||||
assert.deepEqual(caseLibre([posee(813, 0)], huit), { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test('un centre à moins d\u{2019}un pas de rangée, (0 ; 150,1) ou (0 ; 299,9), prend la case (0, 0) ; à 300, elle est libre', () => {
|
||||
assert.deepEqual(caseLibre([posee(0, 150.1)], huit), { x: 813, y: 0 });
|
||||
assert.deepEqual(caseLibre([posee(0, 299.9)], huit), { x: 813, y: 0 });
|
||||
assert.deepEqual(caseLibre([posee(0, 300)], huit), { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test('une table déplacée en (330, 0) prend aussi (813, 0) : la case suivante est (0, 300), et aucun bloc ne passe sur l\u{2019}autre table', () => {
|
||||
// En (813, 0), à 483 cm, la liste de la table déplacée, de 486,4 à
|
||||
// 986,4, couvrirait la table nouvelle, de 692,6 à 933,4.
|
||||
const posees = [posee(330, 0)];
|
||||
const position = caseLibre(posees, huit);
|
||||
assert.deepEqual(position, { x: 0, y: 300 });
|
||||
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)]), { fautes: [], paires: 2 });
|
||||
});
|
||||
|
||||
test('l\u{2019}épreuve des bandes voit la faute : une ronde de 8 en (330, 0) et une autre en (813, 0)', () => {
|
||||
assert.equal(fautesBandes([posee(330, 0), posee(813, 0)]).fautes.length, 1);
|
||||
});
|
||||
|
||||
test('une ronde de 8 agrandie à 16 au milieu de rondes de 8 : la table créée au nouveau pas ne porte ni ne subit aucune liste', () => {
|
||||
// Vingt rondes de 8 sur cinq colonnes au pas 813 × 300, la première
|
||||
// passée à 16 places : le pas devient 966 × 430, sur cinq colonnes. La
|
||||
// case (3864, 0) est à 612 cm de la table (3252, 0), dont la liste, de
|
||||
// 3408,4 à 3908,4, couvrirait la table nouvelle, de 3743,6 à 3984,4 :
|
||||
// elle est prise, comme toute case à moins d'un pas d'une table posée,
|
||||
// jusqu'à la rangée 1720, à 820 cm de la dernière. Seules les paires où
|
||||
// figure la table nouvelle sont examinées : la table agrandie porte sa
|
||||
// liste sur ses voisines, ce que la création ne change pas.
|
||||
const posees = Array.from({ length: 20 }, (_, rang) => posee((rang % 5) * 813, Math.floor(rang / 5) * 300));
|
||||
posees[0] = posee(0, 0, { forme: 'ronde', places: 16 });
|
||||
const position = caseLibre(posees, huit);
|
||||
assert.deepEqual(position, { x: 0, y: 1720 });
|
||||
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)], { avec: 21 }), { fautes: [], paires: 2 * 20 });
|
||||
});
|
||||
|
||||
test('le pas suit la table à créer et les tables posées : une ronde de 16 posée écarte la case suivante à 966', () => {
|
||||
assert.deepEqual(caseLibre([posee(0, 0, { forme: 'ronde', places: 16 })], huit), { x: 966, y: 0 });
|
||||
assert.deepEqual(caseLibre([posee(0, 0)], { forme: 'ronde', places: 16 }), { x: 966, y: 0 });
|
||||
});
|
||||
|
||||
test('trente-trois rondes de 8 créées une à une : les cases de la grille des démonstrations, et aucun bloc de noms ne passe sur une autre table', () => {
|
||||
const posees = [];
|
||||
for (let n = 0; n < 33; n += 1) posees.push(posee(...Object.values(caseLibre(posees, huit))));
|
||||
assert.equal(posees.length, 33);
|
||||
assert.equal(new Set(posees.map(({ position: { x, y } }) => `${x},${y}`)).size, 33);
|
||||
for (const { position } of posees) {
|
||||
assert.equal(position.x % 813, 0, `x ${position.x}`);
|
||||
assert.equal(position.y % 300, 0, `y ${position.y}`);
|
||||
}
|
||||
assert.deepEqual(fautesBandes(posees), { fautes: [], paires: 33 * 32 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
|
||||
// l'application — système de fichiers, horloge, exécuteur de génération,
|
||||
// presse-papiers, identifiants, langue, provenance, démonstrations, format
|
||||
// courant, version du produit, et en option le thème de départ — que
|
||||
// src/main.js construit pour la machine et monterApp pour une épreuve : App
|
||||
// ne choisit aucune plateforme.
|
||||
// courant, version du produit et sa forme Windows, et en option le thème
|
||||
// de départ — que src/main.js construit pour la machine et monterApp pour
|
||||
// une épreuve : App ne choisit aucune plateforme, et ne lit la version que
|
||||
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
|
||||
// le bloc « À propos ».
|
||||
//
|
||||
// À son initialisation, App crée une fois chacun la séance, sans racine,
|
||||
// le contrôleur de génération et la façade, et les donne aux composants par
|
||||
|
|
@ -40,7 +42,6 @@
|
|||
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||
import { creerSeance } from '../application/seance.js';
|
||||
import { creerTraduction } from '../application/traduction.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import APropos from './APropos.svelte';
|
||||
import Accueil from './accueil/Accueil.svelte';
|
||||
import Bandeau from './Bandeau.svelte';
|
||||
|
|
@ -64,11 +65,19 @@
|
|||
demonstrations,
|
||||
formatCourant,
|
||||
produitVersion,
|
||||
versionWindows,
|
||||
themeInitial,
|
||||
} = untrack(() => dependances);
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
|
||||
const format = creerFormat(t);
|
||||
// Le bloc « À propos » se compose au montage, avant toute séance : une
|
||||
// forme de la version que les dépendances ne portent pas lève RangeError
|
||||
// ici, et App ne se monte pas avec un bloc copiable faux (§ 18.6).
|
||||
const bloc = blocAPropos(
|
||||
{ version: { affichee: produitVersion, windows: versionWindows }, plateforme: fs.nature, provenance, format: formatCourant },
|
||||
t,
|
||||
);
|
||||
const seanceApp = creerSeance({
|
||||
fs,
|
||||
horloge,
|
||||
|
|
@ -192,7 +201,6 @@
|
|||
const titre = t('coquille.titre', { version: produitVersion });
|
||||
const version = versionVisible(provenance, produitVersion, t);
|
||||
const provenanceAffichee = provenanceVisible(provenance, t);
|
||||
const bloc = blocAPropos({ version: VERSION, plateforme: fs.nature, provenance, format: formatCourant }, t);
|
||||
|
||||
// « Liste des événements » : ferme l'événement, sans question ; sous un
|
||||
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
|
||||
|
|
|
|||
|
|
@ -184,6 +184,22 @@ async function creerParLeFormulaire(m, nom, date = '') {
|
|||
await cliquer(dans(m, '[data-commande="creer"]'));
|
||||
}
|
||||
|
||||
// Retient les ajouts de ligne du système simulé — le journal s'écrit avant
|
||||
// l'état — jusqu'à liberer() : l'opération lancée reste en cours, comme sur
|
||||
// un support lent, sans délai ni minuterie.
|
||||
function retenirEcritures(m) {
|
||||
const ajouterLigne = m.fs.ajouterLigne;
|
||||
let liberer;
|
||||
const porte = new Promise((resoudre) => {
|
||||
liberer = resoudre;
|
||||
});
|
||||
m.fs.ajouterLigne = async (...parametres) => {
|
||||
await porte;
|
||||
return ajouterLigne.apply(m.fs, parametres);
|
||||
};
|
||||
return () => liberer();
|
||||
}
|
||||
|
||||
// Aucune fenêtre ni boîte de dialogue dans le document.
|
||||
function aucuneFenetre() {
|
||||
expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0);
|
||||
|
|
@ -460,7 +476,11 @@ describe('signalements et récupération (§ 8.1, § 8.8)', () => {
|
|||
[b, 'Merles A'],
|
||||
]) {
|
||||
expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
|
||||
expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre);
|
||||
const doublon = element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`);
|
||||
expect(doublon?.textContent).toBe(autre);
|
||||
// La base nommée se lit à l'écran, pas seulement dans l'arbre.
|
||||
expect(visible(doublon), autre).toBe(true);
|
||||
expect(atteint(doublon), autre).toBe(true);
|
||||
const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles'));
|
||||
expect(noeuds).toEqual([autre]);
|
||||
}
|
||||
|
|
@ -678,7 +698,11 @@ describe('le dossier de travail (§ 8.5, § 8.6)', () => {
|
|||
const cheminAutre = `${SOUS_APPDATA}\\data`;
|
||||
expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 }));
|
||||
// Le chemin est une donnée : son propre nœud de texte, à part de la phrase.
|
||||
expect(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre);
|
||||
const chemin = dans(m, '[data-autre-chemin]');
|
||||
expect(chemin?.textContent).toBe(cheminAutre);
|
||||
// Le chemin se lit à l'écran, pas seulement dans l'arbre.
|
||||
expect(visible(chemin)).toBe(true);
|
||||
expect(atteint(chemin)).toBe(true);
|
||||
expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]);
|
||||
expect(proposition.textContent).toContain(t('accueil.autre.originaux'));
|
||||
// En tête : avant la création, les démonstrations et toute ligne.
|
||||
|
|
@ -802,6 +826,10 @@ describe('les opérations de fichiers (§ 8.7)', () => {
|
|||
]) {
|
||||
expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
|
||||
expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]);
|
||||
const doublon = signalement(base).querySelector(`[data-doublon="${CSS.escape(autre)}"]`);
|
||||
expect(doublon?.textContent).toBe(autre);
|
||||
expect(visible(doublon), autre).toBe(true);
|
||||
expect(atteint(doublon), autre).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -937,6 +965,55 @@ describe('les opérations de fichiers (§ 8.7)', () => {
|
|||
|
||||
// --- Les démonstrations (§ 15) -------------------------------------------------------------
|
||||
|
||||
describe('un geste en cours ne se relance pas (§ 8.7, § 15)', () => {
|
||||
test('créer : une seconde soumission pendant l’écriture ne crée rien ; « Créer » se dit occupé', async () => {
|
||||
const m = await monter();
|
||||
const liberer = retenirEcritures(m);
|
||||
saisir(dans(m, '[data-champ="nom"]'), 'Soirée des Merles');
|
||||
const creer = dans(m, '[data-commande="creer"]');
|
||||
await cliquer(creer);
|
||||
expect(creer.getAttribute('aria-busy')).toBe('true');
|
||||
expect(creer.getAttribute('aria-disabled')).toBe('true');
|
||||
await cliquer(creer);
|
||||
dans(m, '[data-champ="nom"]').form.requestSubmit();
|
||||
await tick();
|
||||
liberer();
|
||||
await attendreEvenement(m, ({ nom }) => nom === 'Soirée des Merles');
|
||||
await revenirALaListe(m, 1);
|
||||
expect(lignes(m)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('une démonstration cliquée deux fois pendant son chargement n’en charge qu’une', async () => {
|
||||
const m = await monter();
|
||||
const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom;
|
||||
const liberer = retenirEcritures(m);
|
||||
const bouton = dans(m, '[data-demonstration="petite"]');
|
||||
await cliquer(bouton);
|
||||
expect(bouton.getAttribute('aria-busy')).toBe('true');
|
||||
await cliquer(bouton);
|
||||
liberer();
|
||||
await attendreEvenement(m, (e) => e.nom === `${nom} 1`);
|
||||
await revenirALaListe(m, 1);
|
||||
expect(lignes(m).map((ligne) => colonne(ligne, 'nom'))).toEqual([`${nom} 1`]);
|
||||
});
|
||||
|
||||
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');
|
||||
await cliquer(ligne.querySelector('[data-commande="copier"]'));
|
||||
saisir(ligne.querySelector('[data-champ="nomCopie"]'), 'Bal des Grisailles, variante');
|
||||
const liberer = retenirEcritures(m);
|
||||
const valider = ligne.querySelector('[data-commande="validerCopie"]');
|
||||
await cliquer(valider);
|
||||
expect(valider.getAttribute('aria-busy')).toBe('true');
|
||||
await cliquer(valider);
|
||||
liberer();
|
||||
await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante');
|
||||
await revenirALaListe(m, 2);
|
||||
expect(lignes(m)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les démonstrations (§ 15)', () => {
|
||||
test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => {
|
||||
const m = await monter();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
// Un état qui ne s'ouvre pas (ETAT_ILLISIBLE) mène à la récupération, qui
|
||||
// tient lieu de liste jusqu'à son retour. Chaque refus s'affiche, traduit,
|
||||
// là où son geste a eu lieu, sans remède : sur la liste, aucun événement
|
||||
// n'est ouvert pour l'exécuter (refusDeLaListe).
|
||||
// n'est ouvert pour l'exécuter (refusDeLaListe). Un geste qui écrit ne se
|
||||
// relance pas pendant qu'il s'écrit : la création, comme chaque commande,
|
||||
// ignore une seconde demande et se dit occupée.
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { versionVisible } from '../apropos.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
|
@ -59,6 +61,9 @@
|
|||
let nom = $state('');
|
||||
let date = $state('');
|
||||
let refusCreation = $state.raw(null);
|
||||
// Vrai pendant l'écriture d'une création : une seconde soumission ne
|
||||
// crée rien, et « Créer » se dit occupé.
|
||||
let creationEnCours = $state(false);
|
||||
const champRefuse = $derived(champDuRefus(refusCreation));
|
||||
|
||||
const question = $derived(issue?.dossier.question ?? null);
|
||||
|
|
@ -181,13 +186,17 @@
|
|||
|
||||
async function creer(evenement) {
|
||||
evenement.preventDefault();
|
||||
if (creationEnCours) return;
|
||||
refusCreation = null;
|
||||
creationEnCours = true;
|
||||
try {
|
||||
await surLaListe(() => facade.creer({ nom, date }));
|
||||
} catch (refus) {
|
||||
if (typeof refus?.code !== 'string') throw refus;
|
||||
refusCreation = refus;
|
||||
return;
|
||||
} finally {
|
||||
creationEnCours = false;
|
||||
}
|
||||
apresOperation();
|
||||
}
|
||||
|
|
@ -254,7 +263,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="creer"
|
||||
aria-busy={creationEnCours ? 'true' : undefined}
|
||||
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
|
||||
>
|
||||
{#if refusCreation !== null && champRefuse === null}
|
||||
<Refus refus={refusCreation} />
|
||||
{/if}
|
||||
|
|
@ -347,4 +361,10 @@
|
|||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
//
|
||||
// demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ;
|
||||
// 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.
|
||||
import { getContext } from 'svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
||||
|
|
@ -17,6 +21,19 @@
|
|||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
|
||||
// La clé de la démonstration qui se charge, ou null.
|
||||
let enCours = $state(null);
|
||||
|
||||
async function choisir(cle) {
|
||||
if (enCours !== null) return;
|
||||
enCours = cle;
|
||||
try {
|
||||
await charger(cle);
|
||||
} finally {
|
||||
enCours = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
|
||||
|
|
@ -25,7 +42,13 @@
|
|||
<ul>
|
||||
{#each demonstrations as { cle, nom } (cle)}
|
||||
<li>
|
||||
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button>
|
||||
<button
|
||||
type="button"
|
||||
data-demonstration={cle}
|
||||
aria-busy={enCours === cle ? 'true' : undefined}
|
||||
aria-disabled={enCours === cle ? 'true' : undefined}
|
||||
onclick={() => choisir(cle)}>{nom}</button
|
||||
>
|
||||
{#if refus !== null && refus.cle === cle}
|
||||
<Refus refus={refus.refus} />
|
||||
{/if}
|
||||
|
|
@ -81,4 +104,10 @@
|
|||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
// la corbeille et se confirme en place, par un second clic : aucune
|
||||
// fenêtre. Chaque refus s'affiche contre le champ ou à côté de sa
|
||||
// commande, sans remède (surLaListe) ; apresOperation() suit chaque
|
||||
// opération réussie.
|
||||
// opération réussie. Une validation en cours ne se relance pas : une
|
||||
// seconde soumission pendant l'écriture ne fait rien, et le bouton de
|
||||
// validation se dit occupé ; supprimer passe par la Commande, qui a la
|
||||
// même garde.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
|
@ -26,6 +29,8 @@
|
|||
// texte }, ou null ; et son refus.
|
||||
let saisie = $state(null);
|
||||
let refus = $state.raw(null);
|
||||
// Vrai pendant l'écriture d'une copie ou d'un renommage.
|
||||
let validationEnCours = $state(false);
|
||||
const surLeChamp = $derived(champDuRefus(refus) === 'nom');
|
||||
|
||||
function ouvrirSaisie(operation) {
|
||||
|
|
@ -46,14 +51,18 @@
|
|||
|
||||
async function valider(evenement) {
|
||||
evenement.preventDefault();
|
||||
if (validationEnCours) return;
|
||||
refus = null;
|
||||
const { operation, texte } = saisie;
|
||||
validationEnCours = true;
|
||||
try {
|
||||
await surLaListe(() => GESTES[operation](texte));
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refus = erreur;
|
||||
return;
|
||||
} finally {
|
||||
validationEnCours = false;
|
||||
}
|
||||
fermerSaisie();
|
||||
apresOperation();
|
||||
|
|
@ -87,7 +96,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-commande={saisie.commande}
|
||||
aria-busy={validationEnCours ? 'true' : undefined}
|
||||
aria-disabled={validationEnCours ? 'true' : undefined}>{t(saisie.valider)}</button
|
||||
>
|
||||
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
|
||||
{#if refus !== null && !surLeChamp}
|
||||
<Refus {refus} />
|
||||
|
|
@ -149,4 +163,10 @@
|
|||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -16,14 +16,24 @@ import { aUnLibelle } from './libelles.js';
|
|||
* quand le mode n'en a pas ; la plateforme sous sa nature.
|
||||
*
|
||||
* @param {Object} champs
|
||||
* @param {{affichee: string, windows: string}} champs.version VERSION
|
||||
* @param {{affichee: string, windows: string}} champs.version les deux formes
|
||||
* de la version, celles que reçoit App (produitVersion, versionWindows)
|
||||
* @param {string} champs.plateforme la nature du système de fichiers
|
||||
* @param {string} champs.provenance le mode de construction de Vite
|
||||
* @param {number} champs.format la version de format des fichiers
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
* @returns {string} sans saut de ligne
|
||||
* @throws {RangeError} une forme de la version absente, vide ou qui n'est
|
||||
* pas une chaîne, en la nommant : une dépendance d'App oubliée
|
||||
* échoue au montage, plutôt que de donner un bloc copiable faux
|
||||
*/
|
||||
export function blocAPropos({ version, plateforme, provenance, format }, t) {
|
||||
for (const forme of ['affichee', 'windows']) {
|
||||
const valeur = version[forme];
|
||||
if (typeof valeur !== 'string' || valeur === '') {
|
||||
throw new RangeError(`À propos : la version ${forme} manque (${JSON.stringify(valeur) ?? 'undefined'})`);
|
||||
}
|
||||
}
|
||||
return t('coquille.apropos.bloc', {
|
||||
affichee: version.affichee,
|
||||
windows: version.windows,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// version de format —, et ce qui tient lieu de version à l'écran, la marque
|
||||
// de provenance dans une construction de documentation (§ 19.4).
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
|
||||
|
|
@ -49,6 +50,23 @@ describe('blocAPropos (§ 18.6)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('une forme de la version absente, vide ou qui n’est pas une chaîne lève RangeError en la nommant : jamais un bloc copiable faux', () => {
|
||||
const fautes = [undefined, null, '', 7];
|
||||
assert.equal(fautes.length, 4);
|
||||
for (const faute of fautes) {
|
||||
for (const [forme, version] of [
|
||||
['windows', { affichee: VERSION.affichee, windows: faute }],
|
||||
['affichee', { affichee: faute, windows: VERSION.windows }],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => blocAPropos({ version, plateforme: 'web', provenance: 'test', format: FORMAT }, t),
|
||||
(erreur) => erreur instanceof RangeError && erreur.message.includes(forme),
|
||||
`${forme} ${String(faute)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('en langue témoin, le bloc entier est encadré une fois', () => {
|
||||
const bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
|
||||
const { ouverture, fermeture } = MARQUE_TEMOIN;
|
||||
|
|
@ -87,3 +105,17 @@ describe('provenanceVisible (§ 18.6)', () => {
|
|||
assert.equal(provenanceVisible('production', temoin), 'production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('la version de l’interface vient des dépendances (§ 13.4, § 18.6)', () => {
|
||||
test('App.svelte ne lit pas la version engendrée : la version affichée et la forme Windows arrivent par ses propriétés, produitVersion et versionWindows', () => {
|
||||
const source = readFileSync(new URL('./App.svelte', import.meta.url), 'utf8');
|
||||
const imports = [...source.matchAll(/^\s*import\s[^;]*?from\s+'([^']+)'/gmu)].map(([, chemin]) => chemin);
|
||||
assert.ok(imports.length > 0, 'des imports relevés');
|
||||
assert.deepEqual(
|
||||
imports.filter((chemin) => chemin.includes('version.genere')),
|
||||
[],
|
||||
);
|
||||
assert.match(source, /\bversionWindows\b/u);
|
||||
assert.match(source, /blocAPropos\(\s*\{\s*version:\s*\{\s*affichee:\s*produitVersion,\s*windows:\s*versionWindows\s*\}/u);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
// propos », le tour montré, la personne mise en évidence, la proposition
|
||||
// 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 et le refus posé sur une place. Regarder n'écrit jamais (§ 8.4) : cet
|
||||
// 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.
|
||||
//
|
||||
|
|
@ -24,7 +25,7 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
|||
* @property {number|null} proposition la proposition demandée au plan
|
||||
* @property {null|{texte: string, origine: string}} importEnAttente
|
||||
* @property {boolean} listes les listes de noms sont montrées
|
||||
* @property {boolean} pleinEcran
|
||||
* @property {boolean} pleinEcran vrai seulement sur la section du plan
|
||||
* @property {Object|null} selection la place sélectionnée
|
||||
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
|
||||
* @property {null|{genre: 'personne'|'reservation', participant: number,
|
||||
|
|
@ -38,6 +39,8 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
|||
* avertissements: Object[]}} pose
|
||||
* le refus, ou les avertissements, posés sur une place du plan, que
|
||||
* Plan.svelte montre sur la place (§ 5.9)
|
||||
* @property {string} appartenance l'appartenance collante du formulaire d'ajout
|
||||
* des participants : celle du dernier ajout (§ 10.2), '' au départ
|
||||
*/
|
||||
|
||||
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
|
||||
|
|
@ -54,6 +57,7 @@ const DEPART = Object.freeze({
|
|||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
appartenance: '',
|
||||
});
|
||||
|
||||
const GENRES_CHOIX = new Set(['personne', 'reservation']);
|
||||
|
|
@ -98,16 +102,19 @@ function choixValide(choix) {
|
|||
* prendreImport: () => null|{texte: string, origine: string},
|
||||
* basculerListes: Function, basculerPleinEcran: Function,
|
||||
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
|
||||
* poserSurPlace: Function}}
|
||||
* poserSurPlace: Function, garderAppartenance: Function}}
|
||||
*/
|
||||
export function creerEtatUi() {
|
||||
let etat = Object.freeze({ ...DEPART, apropos: false });
|
||||
const abonnes = new Set();
|
||||
|
||||
// Publie l'état que donnent les changements, s'il diffère du courant sur
|
||||
// l'une de ses propriétés.
|
||||
// l'une de ses propriétés. Le plein écran ne vaut que pour le plan : hors
|
||||
// de lui, il tombe, quelle que soit la transition qui quitte le plan, et
|
||||
// la navigation des sections, que le plein écran efface, revient.
|
||||
function changer(changements) {
|
||||
const suivant = { ...etat, ...changements };
|
||||
if (suivant.section !== 'plan') suivant.pleinEcran = false;
|
||||
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
|
||||
etat = Object.freeze(suivant);
|
||||
for (const fn of [...abonnes]) fn(etat);
|
||||
|
|
@ -189,6 +196,12 @@ export function creerEtatUi() {
|
|||
}
|
||||
changer({ pose: Object.freeze({ place, refus, avertissements }) });
|
||||
},
|
||||
/** Garde l'appartenance collante du formulaire d'ajout, une chaîne ;
|
||||
* RangeError sinon. */
|
||||
garderAppartenance(texte) {
|
||||
if (typeof texte !== 'string') throw new RangeError(`appartenance : chaîne attendue, reçu ${String(texte)}`);
|
||||
changer({ appartenance: texte });
|
||||
},
|
||||
/** Ouvre la fiche de la personne id, entier positif, dans la section des
|
||||
* participants ; null referme la fiche, la section reste. RangeError
|
||||
* sinon. */
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const DEPART = {
|
|||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
appartenance: '',
|
||||
};
|
||||
|
||||
// Un état et le relevé de ce que reçoit son abonné.
|
||||
|
|
@ -149,6 +150,29 @@ describe('état de navigation : chaque transition', () => {
|
|||
assert.deepEqual(ui.etat(), DEPART);
|
||||
});
|
||||
|
||||
test('le plein écran ne vaut que pour le plan : quitter le plan le retire, par allerA, ouvrirFiche ou ouvrirImport ; hors du plan, il ne se pose pas', () => {
|
||||
const quitters = [
|
||||
...SECTIONS.filter((section) => section !== 'plan').map((section) => [`allerA ${section}`, (ui) => ui.allerA(section)]),
|
||||
['ouvrirFiche', (ui) => ui.ouvrirFiche(4)],
|
||||
['ouvrirImport', (ui) => ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'collage' })],
|
||||
];
|
||||
assert.ok(SECTIONS.includes('plan'));
|
||||
assert.equal(quitters.length, SECTIONS.length - 1 + 2);
|
||||
for (const [nom, quitter] of quitters) {
|
||||
const ui = creerEtatUi();
|
||||
ui.allerA('plan');
|
||||
ui.basculerPleinEcran();
|
||||
assert.equal(ui.etat().pleinEcran, true, nom);
|
||||
quitter(ui);
|
||||
assert.notEqual(ui.etat().section, 'plan', nom);
|
||||
assert.equal(ui.etat().pleinEcran, false, nom);
|
||||
ui.basculerPleinEcran();
|
||||
assert.equal(ui.etat().pleinEcran, false, `${nom} : hors du plan, la bascule ne pose rien`);
|
||||
ui.allerA('plan');
|
||||
assert.equal(ui.etat().pleinEcran, false, `${nom} : revenu au plan, la tête est là`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.allerA('plan');
|
||||
|
|
@ -169,6 +193,22 @@ describe('état de navigation : chaque transition', () => {
|
|||
assert.equal(ui.etat(), avant);
|
||||
});
|
||||
|
||||
test('garderAppartenance tient l’appartenance collante du formulaire d’ajout (§ 10.2) : elle survit au changement de section, et un autre événement repart vide', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.allerA('participants');
|
||||
ui.garderAppartenance('Club des Merles');
|
||||
assert.equal(ui.etat().appartenance, 'Club des Merles');
|
||||
ui.allerA('plan');
|
||||
ui.allerA('participants');
|
||||
ui.ouvrirApropos();
|
||||
ui.fermerApropos();
|
||||
assert.equal(ui.etat().appartenance, 'Club des Merles');
|
||||
ui.reinitialiser();
|
||||
assert.equal(ui.etat().appartenance, '');
|
||||
for (const faute of [null, undefined, 3, {}]) assert.throws(() => ui.garderAppartenance(faute), RangeError, String(faute));
|
||||
assert.equal(ui.etat().appartenance, '');
|
||||
});
|
||||
|
||||
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
|
||||
const { ui, recus } = suivi();
|
||||
ui.allerA(SECTION_OUVERTURE);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
// l'ordre de la séance (avertissementsOrdonnes, modele.js) : chacun par le
|
||||
// texte de son code, puis ce qu'il nomme — les propositions écartées, leur
|
||||
// numéro et leurs fautes ; les placements en dérive et leurs raisons —,
|
||||
// puis son remède en Commande, par actionRemede (primitives/commande.js).
|
||||
// puis son remède en Commande, par actionRemede (primitives/commande.js),
|
||||
// confirmé en place quand actionRemede le demande — « Débloquer ».
|
||||
// Un avertissement n'empêche rien.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
|
@ -62,7 +63,13 @@
|
|||
</ul>
|
||||
{/if}
|
||||
{#if action !== null}
|
||||
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} />
|
||||
<Commande
|
||||
cle={action.cle}
|
||||
details={action.details}
|
||||
nom={action.nom}
|
||||
confirmer={action.confirmer}
|
||||
executer={action.executer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -74,7 +81,6 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
margin-bottom: var(--espace-3);
|
||||
}
|
||||
|
||||
.avertissement {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,30 @@
|
|||
// à droite, la place fixe, le libellé de mode, puis « Défaire » et
|
||||
// « Refaire » (§ 8.3). Sous la barre vient la tête, tete, quand elle est
|
||||
// donnée — l'en-tête de l'événement et la navigation des sections —, puis
|
||||
// la surface, children, qui défile seule. La barre ouvre le cadre : rien
|
||||
// ne la précède dans l'écran de l'événement, si bien qu'un refus posé dans
|
||||
// la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête
|
||||
// sans déplacer la place fixe (§ 8.4).
|
||||
// les annonces, annonces, quand il y en a — support rompu et
|
||||
// avertissements —, puis la surface, children. La barre ouvre le cadre :
|
||||
// rien ne la précède dans l'écran de l'événement, si bien qu'un refus posé
|
||||
// dans la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête
|
||||
// sans déplacer la place fixe (§ 8.4). Tête, annonces et surface défilent
|
||||
// chacune dans sa zone : des avertissements nombreux ne poussent pas la
|
||||
// surface hors du cadre, ni le cadre hors de la fenêtre. La surface porte
|
||||
// le plan, que l'opérateur regarde : elle réclame les trois quarts du
|
||||
// cadre avant que les annonces ne dépassent leur première ligne, sauf
|
||||
// devant un support rompu, dont les issues restent en vue.
|
||||
//
|
||||
// Sous une tête, et hors d'un support rompu, les annonces se posent à
|
||||
// côté d'elle, dans la place que sa navigation laisse à droite : leur zone
|
||||
// prend la hauteur de la tête et défile dedans, si bien qu'un
|
||||
// avertissement ne retire aucune hauteur à la surface, ni au plan qu'elle
|
||||
// porte. Une fenêtre trop étroite pour les deux les passe à la ligne, et
|
||||
// la zone garde alors sa hauteur minimale.
|
||||
//
|
||||
// La tête se rend en un seul point du DOM, quelle que soit la place des
|
||||
// annonces : seules les annonces passent de côté à dessous quand une
|
||||
// annonce vient, s'en va, ou que le support se rompt. Un rendu de la tête
|
||||
// dans deux blocs la démonterait à chacun de ces passages — la Commande
|
||||
// en cours détruite, le refus posé à côté du geste perdu (§ 2.1), le
|
||||
// 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 —,
|
||||
|
|
@ -29,7 +49,7 @@
|
|||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
|
||||
|
||||
let { children, tete = null } = $props();
|
||||
let { children, tete = null, annonces = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -38,12 +58,17 @@
|
|||
// forme de la lecture, et n'a pas de libellé.
|
||||
const cadre = $derived(cadreDeMode($seance));
|
||||
const forme = $derived(cadre?.forme ?? 'tirets');
|
||||
// Le trait du cadre est une marque (§ 14.5) : data-marque nomme l'entrée
|
||||
// du registre (marques.js) qui porte le jeton de sa couleur.
|
||||
const MARQUE_DE_LA_FORME = { tirets: 'cadreLecture', plein: 'cadreEcriture', double: 'cadreBloque' };
|
||||
const commande = $derived(commandeFixe($seance));
|
||||
const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null);
|
||||
const reprise = $derived(repriseOfferte($seance));
|
||||
// Les annonces se posent à côté de la tête, ou dessous.
|
||||
const aCote = $derived(tete !== null && annonces !== null && $seance.supportRompu === null);
|
||||
</script>
|
||||
|
||||
<div class="cadre" data-mode={forme} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
|
||||
<div class="cadre" data-mode={forme} data-marque={MARQUE_DE_LA_FORME[forme]} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
|
||||
<div class="barre" role="group" aria-label={t('evenement.cadre.commandes')}>
|
||||
<div class="ligne">
|
||||
<div class="fixe" data-place-fixe>
|
||||
|
|
@ -96,8 +121,22 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if tete !== null}
|
||||
<div class="tete">
|
||||
{@render tete()}
|
||||
<div class="tete" class:cote={aCote}>
|
||||
<div class="contenu">
|
||||
{@render tete()}
|
||||
</div>
|
||||
{#if aCote}
|
||||
<div class="place">
|
||||
<div class="annonces" data-annonces>
|
||||
{@render annonces()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if annonces !== null && !aCote}
|
||||
<div class="annonces seules" class:rompu={$seance.supportRompu !== null} data-annonces>
|
||||
{@render annonces()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="surface">
|
||||
|
|
@ -201,8 +240,81 @@
|
|||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
/* Les annonces défilent dans leur zone, au-delà de sa hauteur. */
|
||||
.annonces {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
overflow-y: auto;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
/* À côté de la tête : la tête passe en ligne, son contenu à gauche, la
|
||||
place des annonces à droite. La place ne donne à la ligne que sa
|
||||
hauteur minimale ; la zone des annonces la remplit, posée en absolu,
|
||||
et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête.
|
||||
La place prend une part fixe de la largeur, et le contenu le reste :
|
||||
la zone ne bouge pas d'une section à l'autre, quand le libellé de la
|
||||
section ouverte, en gras, change la largeur de la navigation. Sous
|
||||
30rem de contenu, la place passe à la ligne. */
|
||||
.tete.cote {
|
||||
flex-flow: row wrap;
|
||||
align-items: stretch;
|
||||
gap: 0 var(--espace-3);
|
||||
padding-right: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.contenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.cote > .contenu {
|
||||
flex: 1 1 0;
|
||||
min-width: min(100%, 30rem);
|
||||
padding-block: var(--espace-2);
|
||||
}
|
||||
|
||||
.place {
|
||||
position: relative;
|
||||
flex: 0 0 32%;
|
||||
min-width: 16rem;
|
||||
min-height: 3rem;
|
||||
border-left: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
.place > .annonces {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Seules — sans tête, en plein écran, ou devant un support rompu —, les
|
||||
annonces ne prennent jamais plus du quart du cadre, et cèdent d'abord
|
||||
leur hauteur à la surface : leur facteur de rétrécissement, mille fois
|
||||
celui de la surface, leur fait absorber presque tout le manque de
|
||||
place, jusqu'à leur hauteur minimale : un avertissement d'une ligne en
|
||||
entier, et le haut du suivant, qui dit que la zone en porte d'autres. */
|
||||
.annonces.seules {
|
||||
flex: 0 1000 auto;
|
||||
min-height: 3rem;
|
||||
max-height: 25%;
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
/* Le support rompu, en tête des annonces, porte les seules issues de
|
||||
l'opérateur : tant qu'il est là, les annonces ne cèdent rien à la
|
||||
surface, et gardent leur hauteur jusqu'au quart du cadre ; les
|
||||
avertissements qui le suivent défilent sous lui. */
|
||||
.annonces.rompu {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* La surface part des trois quarts du cadre, s'étend à ce qui reste, et
|
||||
ne rétrécit, après les annonces, que pour la barre et la tête. */
|
||||
.surface {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 75%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--espace-3);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
// 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';
|
||||
|
|
@ -30,7 +31,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 } from '../../../test/navigateur/entrees.js';
|
||||
import { fixerCadre, molette } 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';
|
||||
|
|
@ -87,6 +88,38 @@ const auCentre = (element) => {
|
|||
return document.elementFromPoint(left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// 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.
|
||||
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 });
|
||||
flushSync();
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// Attend que la file de la séance ait passé ce qui s'y trouve, par une
|
||||
// lecture admise dans tout mode, qui n'écrit rien.
|
||||
async function viderFile(m) {
|
||||
await m.seance.formeNommee(null).catch(() => undefined);
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
|
||||
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
|
||||
|
||||
|
|
@ -214,6 +247,27 @@ describe('la place fixe (§ 8.4, § 8.5)', () => {
|
|||
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
|
||||
expect(visible(fixe(m, 'debloquer'))).toBe(true);
|
||||
});
|
||||
|
||||
test('un double-clic sur « Modifier » passe en écriture et y reste : le second clic de la série tombe sur « Revenir en lecture », venu à sa place, qui ne l’exécute pas', async () => {
|
||||
const m = await neuf();
|
||||
const avant = m.trace.length;
|
||||
const clics = [];
|
||||
m.racine.addEventListener('click', (evenement) => clics.push([evenement.target.dataset.commande, evenement.detail]), true);
|
||||
const point = await cliquerEnSerie(fixe(m, 'modifier'), 1);
|
||||
await attendre(() => expect(fixe(m, 'lecture')).not.toBeNull());
|
||||
await viderFile(m);
|
||||
await cliquerEnSerie(point, 2);
|
||||
await viderFile(m);
|
||||
expect(clics).toEqual([
|
||||
['modifier', 1],
|
||||
['lecture', 2],
|
||||
]);
|
||||
expect(m.seance.etat().mode).toBe('ecriture');
|
||||
expect(fixe(m, 'lecture')).not.toBeNull();
|
||||
const verrous = m.trace.slice(avant).filter(([primitive]) => primitive === 'verrouiller' || primitive === 'deverrouiller');
|
||||
expect(verrous.map(([primitive]) => primitive)).toEqual(['verrouiller']);
|
||||
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
|
||||
|
|
@ -230,6 +284,11 @@ describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
|
|||
expect(titre.textContent).toBe(nom);
|
||||
expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth);
|
||||
expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth);
|
||||
// Ni le titre ni la tête qui le porte ne débordent : la tête défile en
|
||||
// hauteur, et un nom qui ne passerait pas à la ligne s'étendrait en
|
||||
// largeur sous elle sans élargir la page.
|
||||
const tete = titre.closest('header').parentElement;
|
||||
expect([titre.scrollWidth, tete.scrollWidth], motif).toEqual([titre.clientWidth, tete.clientWidth]);
|
||||
expect(coin(fixe(m, 'modifier')), motif).toEqual(place);
|
||||
expect(visible(fixe(m, 'modifier')), motif).toBe(true);
|
||||
await m.seance.fermer();
|
||||
|
|
@ -475,6 +534,25 @@ describe('défaire, refaire (§ 8.3)', () => {
|
|||
await attendre(() => expect(historique(m, 'refaire')).toBeNull());
|
||||
});
|
||||
|
||||
test('un double-clic sur « Défaire » rend un seul geste : le second clic de la série, reçu une fois le premier retour écrit, n’en défait pas un autre', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
|
||||
flushSync();
|
||||
const { entrees } = journalDe(m);
|
||||
const point = await cliquerEnSerie(historique(m, 'defaire'), 1);
|
||||
await attendre(() => expect(nombre(m)).toBe(2));
|
||||
await viderFile(m);
|
||||
expect(historique(m, 'defaire').getAttribute('aria-busy')).toBeNull();
|
||||
await cliquerEnSerie(point, 2);
|
||||
await viderFile(m);
|
||||
expect(nombre(m)).toBe(2);
|
||||
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
|
||||
// Un clic simple ensuite défait de nouveau.
|
||||
await cliquerEnSerie(historique(m, 'defaire'), 1);
|
||||
await attendre(() => expect(nombre(m)).toBe(1));
|
||||
});
|
||||
|
||||
test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => {
|
||||
const m = await neuf();
|
||||
await m.seance.passerEnEcriture();
|
||||
|
|
@ -552,6 +630,36 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
|
|||
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
|
||||
});
|
||||
|
||||
test('en plein écran, le remède d’une dérive mène à sa section, et la navigation des sections y revient : le plein écran ne vaut que pour le plan', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
|
||||
dans(m, '[data-commande="pleinEcran"]').click();
|
||||
flushSync();
|
||||
expect(tous(m, '[data-commande="section"]')).toHaveLength(0);
|
||||
const remede = await attendre(() => {
|
||||
const trouve = dans(m, '[data-annonces] [data-avertissement="DERIVE_NOUVELLE"] [data-commande="ouvrirSection"]');
|
||||
expect(trouve).not.toBeNull();
|
||||
return trouve;
|
||||
});
|
||||
remede.click();
|
||||
await attendre(() => expect(m.ui.etat().section).not.toBe('plan'));
|
||||
flushSync();
|
||||
const navigation = tous(m, '[data-commande="section"]');
|
||||
expect(navigation).toHaveLength(SECTIONS.length);
|
||||
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
|
||||
navigation.find((bouton) => bouton.dataset.cible === 'tables').click();
|
||||
flushSync();
|
||||
expect(dans(m, '[data-section]').dataset.section).toBe('tables');
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
|
||||
});
|
||||
|
||||
test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
|
|
@ -578,6 +686,170 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', () => {
|
||||
// Ferme l'événement garni et généré de base, l'abîme, puis le rouvre : une
|
||||
// proposition de longueur fausse, une périmée, et une entrée du journal
|
||||
// abîmée lui donnent trois avertissements à l'ouverture, au moins.
|
||||
async function rouvrirAbime(m, base) {
|
||||
await m.seance.fermer();
|
||||
reecrireEtat(m, base, (charge) => {
|
||||
charge.propositions[0].placement.pop();
|
||||
charge.participants[0].exclu = true;
|
||||
});
|
||||
const chemin = `${base}${SUFFIXES.journal}`;
|
||||
const lignes = m.fs.contenu('documents', chemin).split('\n');
|
||||
const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 4);
|
||||
expect(rang).toBeGreaterThan(0);
|
||||
lignes[rang] = '{"type":"entree", abîmée';
|
||||
m.fs.deposer('documents', chemin, lignes.join('\n'));
|
||||
await m.seance.ouvrir(base);
|
||||
flushSync();
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
|
||||
test('à 1 366 × 700, trois avertissements et plus : la page ne défile pas, le plan garde sa hauteur ; les avertissements défilent à la molette, sans rien déplacer', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m, { retenir: false });
|
||||
const base = baseDe(m);
|
||||
// La hauteur du dessin du plan sans aucune annonce : la mesure de
|
||||
// référence de ce que les avertissements peuvent lui prendre.
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||||
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
|
||||
expect(hauteurSeule).toBeGreaterThan(0);
|
||||
await rouvrirAbime(m, base);
|
||||
expect(m.ui.etat().section).toBe('plan');
|
||||
const montres = tous(m, '[data-avertissement]');
|
||||
expect(montres.length).toBeGreaterThanOrEqual(3);
|
||||
const boite = (element) => element.getBoundingClientRect().toJSON();
|
||||
// La page ne défile pas : le cadre tient dans la fenêtre, et la section
|
||||
// du plan dans le cadre, sans que la surface qui la porte ait à défiler.
|
||||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||||
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
|
||||
const section = dans(m, '[data-section]');
|
||||
const porteur = section.parentElement;
|
||||
expect(section.getBoundingClientRect().bottom).toBeLessThanOrEqual(porteur.getBoundingClientRect().bottom);
|
||||
expect(porteur.scrollHeight).toBeLessThanOrEqual(porteur.clientHeight);
|
||||
expect(visible(dans(m, '[data-plan]'))).toBe(true);
|
||||
// Le plan reste la surface de travail : les annonces ne prennent pas
|
||||
// plus du quart de la hauteur de son dessin.
|
||||
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule * 0.75);
|
||||
// Les avertissements débordent leur zone, qui défile seule, à la
|
||||
// molette, comme sous la main de l'opérateur : le dernier, amené dans la
|
||||
// zone, se voit, et ni la page, ni la place fixe, ni la section n'ont
|
||||
// bougé.
|
||||
const zone = montres[0].closest('[data-annonces]');
|
||||
expect(zone).not.toBeNull();
|
||||
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
|
||||
expect(zone.scrollHeight).toBeGreaterThan(zone.clientHeight);
|
||||
expect(['auto', 'scroll']).toContain(getComputedStyle(zone).overflowY);
|
||||
expect(visible(montres[0])).toBe(true);
|
||||
const place = coin(fixe(m, 'modifier'));
|
||||
const avant = boite(section);
|
||||
const surZone = zone.getBoundingClientRect();
|
||||
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
|
||||
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
|
||||
expect(zone.scrollTop).toBeGreaterThan(0);
|
||||
expect(window.scrollY).toBe(0);
|
||||
expect(coin(fixe(m, 'modifier'))).toEqual(place);
|
||||
expect(boite(section)).toEqual(avant);
|
||||
// Sur chaque section, la page reste sans défilement, et la zone garde
|
||||
// sa boîte.
|
||||
const zoneAvant = boite(zone);
|
||||
for (const nom of SECTIONS) {
|
||||
m.ui.allerA(nom);
|
||||
flushSync();
|
||||
expect(document.documentElement.scrollHeight, nom).toBeLessThanOrEqual(innerHeight);
|
||||
expect(boite(dans(m, '[data-annonces]')), nom).toEqual(zoneAvant);
|
||||
}
|
||||
});
|
||||
|
||||
test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan garde toute sa hauteur, et ses chaises au zoom ajusté', async () => {
|
||||
const m = await monter();
|
||||
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
|
||||
await m.seance.passerEnEcriture();
|
||||
for (let n = 0; n < 18; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `${NOMS[n % NOMS.length]} ${n + 1}` } });
|
||||
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
|
||||
await generer(m);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
const ajuster = async () => {
|
||||
dans(m, '[data-commande="ajuster"]').click();
|
||||
flushSync();
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
flushSync();
|
||||
};
|
||||
await ajuster();
|
||||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||||
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
|
||||
expect(hauteurSeule).toBeGreaterThan(0);
|
||||
// Trois gestes admis sur le retenu, chacun une dérive annoncée.
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||||
await m.seance.executer('creerTable', { forme: 'ronde' });
|
||||
await m.seance.executer('changerTours', { tours: 4 });
|
||||
flushSync();
|
||||
await ajuster();
|
||||
const montres = tous(m, '[data-annonces] [data-avertissement]');
|
||||
expect(montres.length).toBeGreaterThan(0);
|
||||
// La zone se voit, et le premier avertissement s'ouvre dans elle.
|
||||
const zone = dans(m, '[data-annonces]');
|
||||
expect(visible(zone)).toBe(true);
|
||||
const [boiteZone, premier] = [zone.getBoundingClientRect(), montres[0].getBoundingClientRect()];
|
||||
expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top);
|
||||
expect(premier.top).toBeLessThan(boiteZone.bottom - 16);
|
||||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||||
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - 1);
|
||||
const tables = tous(m, '[data-plan] [data-table]');
|
||||
expect(tables.length).toBe(4);
|
||||
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);
|
||||
// La navigation et la place fixe restent atteintes.
|
||||
const navigation = tous(m, '[data-commande="section"]');
|
||||
expect(navigation).toHaveLength(SECTIONS.length);
|
||||
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
|
||||
expect(fixe(m, 'lecture').contains(auCentre(fixe(m, 'lecture')))).toBe(true);
|
||||
});
|
||||
|
||||
test('à 1 366 × 700, un support rompu et trois avertissements et plus : chaque issue du support rompu se voit sans défiler, la page ne défile pas, et les avertissements défilent sous lui', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m, { retenir: false });
|
||||
await rouvrirAbime(m, baseDe(m));
|
||||
expect(tous(m, '[data-avertissement]').length).toBeGreaterThanOrEqual(3);
|
||||
fixe(m, 'modifier').click();
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('ecriture'));
|
||||
m.fs.pannes.ecriture('documents');
|
||||
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
|
||||
flushSync();
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
expect(m.seance.etat().supportRompu).not.toBeNull();
|
||||
const montres = tous(m, '[data-avertissement]');
|
||||
expect(montres.length).toBeGreaterThanOrEqual(3);
|
||||
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
|
||||
expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight);
|
||||
// Les issues du support rompu, seule sortie de l'opérateur, se voient
|
||||
// sans que rien ne défile.
|
||||
const message = dans(m, '[data-support-rompu]');
|
||||
for (const nom of ['choisirDossier', 'abandonner']) {
|
||||
const issue = message.querySelector(`[data-commande="${nom}"]`);
|
||||
expect(issue, nom).not.toBeNull();
|
||||
expect(visible(issue), nom).toBe(true);
|
||||
}
|
||||
expect(visible(fixe(m, 'lecture'))).toBe(true);
|
||||
expect(visible(dans(m, '[data-plan]'))).toBe(true);
|
||||
// Les avertissements restent dans la zone, qui défile seule : le
|
||||
// dernier, amené à la molette, se voit, et la page n'a pas bougé.
|
||||
const zone = message.closest('[data-annonces]');
|
||||
expect(zone).not.toBeNull();
|
||||
for (const montre of montres) expect(zone.contains(montre)).toBe(true);
|
||||
const surZone = zone.getBoundingClientRect();
|
||||
await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight);
|
||||
await attendre(() => expect(visible(montres.at(-1))).toBe(true));
|
||||
expect(window.scrollY).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le journal (§ 8.6, § 8.2)', () => {
|
||||
test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => {
|
||||
const m = await neuf();
|
||||
|
|
@ -634,7 +906,12 @@ describe('la dérive (§ 9)', () => {
|
|||
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
|
||||
const element = avertissement(m, 'DERIVE_NOUVELLE');
|
||||
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
|
||||
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue }));
|
||||
// La raison telle que la séance la rend, détails compris : son texte
|
||||
// est celui que l'avertissement affiche.
|
||||
const { raisons } = nouvelle.details.touchees.find(({ id }) => id === 'retenu');
|
||||
const exclusion = raisons.find(({ code }) => code === 'PARTICIPANT_EXCLU');
|
||||
expect(exclusion.participant).toBe(exclue);
|
||||
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', exclusion));
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||||
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
|
||||
});
|
||||
|
|
@ -666,6 +943,43 @@ describe('la dérive (§ 9)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('la tête reste en place quand les annonces vont et viennent (§ 2.1, § 8.4)', () => {
|
||||
test('une dérive affichée, l’écriture refusée : « Bloqué » cliqué reste le même élément, et porte à côté de lui le refus ECRITURE', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||||
flushSync();
|
||||
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
|
||||
m.fs.pannes.ecriture('documents');
|
||||
const avant = boutonEtat(m, 'bloque');
|
||||
avant.click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
|
||||
await viderFile(m);
|
||||
// Le support rompu fait passer les annonces sous la tête : le bouton du
|
||||
// geste reste celui qu'a reçu le clic, et le refus se pose à côté de lui.
|
||||
expect(boutonEtat(m, 'bloque')).toBe(avant);
|
||||
expect(avant.isConnected).toBe(true);
|
||||
await attendre(() => expect(refusPres(avant, 'ECRITURE')).not.toBeNull());
|
||||
expect(visible(refusPres(avant, 'ECRITURE'))).toBe(true);
|
||||
});
|
||||
|
||||
test('le premier avertissement venu, le focus du clavier reste sur le bouton de la tête qui le portait', async () => {
|
||||
const m = await neuf();
|
||||
await garnir(m);
|
||||
await generer(m);
|
||||
expect(dans(m, '[data-annonces]')).toBeNull();
|
||||
const bouton = dans(m, '[data-commande="section"][data-cible="plan"]');
|
||||
bouton.focus();
|
||||
expect(document.activeElement).toBe(bouton);
|
||||
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
|
||||
flushSync();
|
||||
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
|
||||
expect(dans(m, '[data-commande="section"][data-cible="plan"]')).toBe(bouton);
|
||||
expect(document.activeElement).toBe(bouton);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le verrou d’une autre séance (§ 8.8)', () => {
|
||||
test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => {
|
||||
const premier = await neuf();
|
||||
|
|
@ -742,6 +1056,14 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
const message = (m) => dans(m, '[data-support-rompu]');
|
||||
const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]');
|
||||
|
||||
// Le message offre exactement les issues noms, et chacune se voit au cadre
|
||||
// de l'épreuve : c'est la seule sortie de l'opérateur d'un support rompu.
|
||||
function issuesVisibles(m, noms) {
|
||||
const offertes = [...message(m).querySelectorAll('[data-commande]')].map((issue) => issue.dataset.commande);
|
||||
expect(offertes).toEqual(noms);
|
||||
for (const nom of noms) expect(visible(message(m).querySelector(`[data-commande="${nom}"]`)), nom).toBe(true);
|
||||
}
|
||||
|
||||
// L'événement ouvert en écriture, puis le support de racine refuse
|
||||
// l'écriture d'un geste.
|
||||
async function rompre(m, racine) {
|
||||
|
|
@ -776,7 +1098,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
expect(visible(fixe(m, 'lecture'))).toBe(true);
|
||||
expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true);
|
||||
expect(m.seance.etat().evenement.base).toBe(base);
|
||||
expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull();
|
||||
issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']);
|
||||
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||||
const nouvelle = baseDe(m);
|
||||
|
|
@ -798,7 +1120,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
const m = await neuf();
|
||||
await rompre(m, 'documents');
|
||||
expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
|
||||
expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull();
|
||||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||||
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
|
||||
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
|
||||
choisir.click();
|
||||
|
|
@ -810,6 +1132,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => {
|
||||
const m = await neuf();
|
||||
await rompre(m, 'documents');
|
||||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||||
const avant = m.trace.length;
|
||||
const abandonner = () => message(m).querySelector('[data-commande="abandonner"]');
|
||||
expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner'));
|
||||
|
|
@ -826,6 +1149,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => {
|
||||
const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } });
|
||||
await rompre(m, 'documents');
|
||||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||||
message(m).querySelector('[data-commande="choisirDossier"]').click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||||
expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' });
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@
|
|||
// globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran
|
||||
// — le conteneur de l'application et son <main> — tant qu'il porte cet
|
||||
// écran, et à lui seul.
|
||||
//
|
||||
// Le support rompu et les avertissements se posent dans la zone des
|
||||
// annonces du cadre, qui défile seule, et non au-dessus de la section : la
|
||||
// section garde la hauteur de la surface, plan compris, quel que soit le
|
||||
// nombre des avertissements, et le support rompu, en tête de la zone,
|
||||
// garde ses issues en vue (CadreMode.svelte).
|
||||
import { getContext } from 'svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import SectionGeneration from '../generation/SectionGeneration.svelte';
|
||||
|
|
@ -30,6 +36,7 @@
|
|||
import CadreMode from './CadreMode.svelte';
|
||||
import EnteteEvenement from './EnteteEvenement.svelte';
|
||||
import SupportRompu from './SupportRompu.svelte';
|
||||
import { avertissementsOrdonnes } from './modele.js';
|
||||
import { SECTIONS, cleDeSection } from './sections.js';
|
||||
|
||||
const t = getContext('t');
|
||||
|
|
@ -48,6 +55,9 @@
|
|||
]);
|
||||
|
||||
const Section = $derived(COMPOSANTS.get($ui.section));
|
||||
// La zone des annonces n'existe que lorsqu'elle a quelque chose à dire :
|
||||
// sans annonce, le cadre ne lui garde ni hauteur ni trait.
|
||||
const annoncer = $derived($seance.supportRompu !== null || avertissementsOrdonnes($seance).length > 0);
|
||||
</script>
|
||||
|
||||
{#snippet tete()}
|
||||
|
|
@ -65,12 +75,15 @@
|
|||
</nav>
|
||||
{/snippet}
|
||||
|
||||
{#snippet annonces()}
|
||||
{#if $seance.supportRompu !== null}
|
||||
<SupportRompu />
|
||||
{/if}
|
||||
<Avertissements />
|
||||
{/snippet}
|
||||
|
||||
<div class="evenement" data-ecran="evenement">
|
||||
<CadreMode tete={$ui.pleinEcran ? null : tete}>
|
||||
{#if $seance.supportRompu !== null}
|
||||
<SupportRompu />
|
||||
{/if}
|
||||
<Avertissements />
|
||||
<CadreMode tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
|
||||
<Section />
|
||||
</CadreMode>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin-bottom: var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-left: 3px solid var(--couleur-refus);
|
||||
color: var(--couleur-refus);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ const REMEDES = new Map([
|
|||
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
|
||||
]);
|
||||
|
||||
// Le remède d'un avertissement : celui de REMEDES, sauf une réservation que
|
||||
// le retenu ne tient pas à son siège (details.siege), que lève l'échange de
|
||||
// sa personne avec l'occupant, ou, chaise vide, son déplacement au siège.
|
||||
function remedeDe(code, details) {
|
||||
if (code === 'RESERVATION_NON_HONOREE' && details.siege !== undefined) {
|
||||
const { tour, participant, table, siege, occupant } = details;
|
||||
return occupant === null
|
||||
? { commande: 'deplacerDansRetenu', arguments: { tour, participant, table, siege } }
|
||||
: { commande: 'echangerDansRetenu', arguments: { tour, a: participant, b: occupant } };
|
||||
}
|
||||
return REMEDES.get(code) ?? null;
|
||||
}
|
||||
|
||||
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
|
||||
// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
|
||||
// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
|
||||
|
|
@ -126,7 +139,7 @@ function derivesNommees({ ids = [], retenu = false }, examen) {
|
|||
*/
|
||||
export function avertissementsOrdonnes(etatSeance) {
|
||||
return etatSeance.avertissements.map(({ code, details }) => {
|
||||
const rendu = { code, details, remede: REMEDES.get(code) ?? null };
|
||||
const rendu = { code, details, remede: remedeDe(code, details) };
|
||||
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
|
||||
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
|
||||
return rendu;
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ function executeurAMain() {
|
|||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||||
const section = (m) => dans(m, '[data-section="generation"]');
|
||||
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
|
||||
const bascule = (m, cle) => section(m).querySelector(`input[type="checkbox"][data-champ="${cle}"]`);
|
||||
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
|
||||
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
|
||||
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
|
||||
|
|
@ -278,7 +279,7 @@ async function regler(m, reglages) {
|
|||
describe('les réglages (§ 5.7, § 5.10)', () => {
|
||||
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const entrees = [...section(m).querySelectorAll('input')];
|
||||
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
|
||||
expect(entrees.length).toBe(4);
|
||||
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
|
||||
for (const cle of CHAMPS) {
|
||||
|
|
@ -370,6 +371,137 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
|
||||
// Les cinq bascules, dans l'ordre du § 5.3.
|
||||
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
|
||||
const NOMS_DU_SPEC = [
|
||||
'Séparer les appartenances',
|
||||
'Nouveaux voisins à chaque tour',
|
||||
'Nouvelle table à chaque tour',
|
||||
'Varier les appartenances rencontrées',
|
||||
'Attribuer les sièges',
|
||||
];
|
||||
|
||||
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
|
||||
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual(BASCULES);
|
||||
BASCULES.forEach((cle, rang) => {
|
||||
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
|
||||
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
|
||||
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
|
||||
});
|
||||
for (const cle of BASCULES) {
|
||||
const avant = structuredClone(charge(m).reglages);
|
||||
const r0 = revision(m);
|
||||
const entrees = journalDe(m).entrees.length;
|
||||
await cliquer(m, bascule(m, cle));
|
||||
expect(revision(m), cle).toBe(r0 + 1);
|
||||
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
|
||||
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
|
||||
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
|
||||
}
|
||||
// La barre d'espace bascule aussi, par le même geste.
|
||||
const cle = BASCULES[0];
|
||||
const avant = charge(m).reglages[cle];
|
||||
bascule(m, cle).focus();
|
||||
await touche(' ');
|
||||
await passer(m);
|
||||
expect(charge(m).reglages[cle]).toBe(!avant);
|
||||
expect(bascule(m, cle).checked).toBe(!avant);
|
||||
});
|
||||
|
||||
test('direct : bascule refusée en lecture (« Modifier ») et sur un plan bloqué (« Débloquer »), rien d’écrit', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
|
||||
const cle = 'attribuerSieges';
|
||||
const valeur = charge(m).reglages[cle];
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, bascule(m, cle));
|
||||
const lecture = refusPres(bascule(m, cle), 'MODE_LECTURE');
|
||||
expect(lecture).not.toBeNull();
|
||||
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(bascule(m, cle).checked).toBe(valeur);
|
||||
expect(charge(m).reglages[cle]).toBe(valeur);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
const ecrituresBloque = m.trace.length;
|
||||
await cliquer(m, bascule(m, 'nouvelleTable'));
|
||||
const bloque = refusPres(bascule(m, 'nouvelleTable'), 'LECTURE_SEULE');
|
||||
expect(bloque).not.toBeNull();
|
||||
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
|
||||
expect(bascule(m, 'nouvelleTable').checked).toBe(charge(m).reglages.nouvelleTable);
|
||||
expect(m.trace.length).toBe(ecrituresBloque);
|
||||
expect(fenetre()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('« Générer » derrière la file de la séance (§ 5.10, § 8.2)', () => {
|
||||
test('relevé, disque lent : « Générer » pressé en quittant le compte d’arrêt part après son écriture, sur l’arrêt saisi', async () => {
|
||||
const { executeur, lancements } = releve(creerExecuteurDirect());
|
||||
const m = await demonstration('petite', { executeur });
|
||||
// Le disque tient chaque écriture jusqu'à ce que l'épreuve la libère.
|
||||
const tenues = [];
|
||||
for (const nom of ['ecrireAtomique', 'ajouterLigne']) {
|
||||
const primitive = m.fs[nom];
|
||||
m.fs[nom] = (...args) =>
|
||||
new Promise((resoudre, rejeter) => {
|
||||
tenues.push(() => Promise.resolve(primitive.apply(m.fs, args)).then(resoudre, rejeter));
|
||||
});
|
||||
}
|
||||
const arret = champ(m, 'generation.arret');
|
||||
await cliquer(m, arret);
|
||||
arret.select();
|
||||
for (const caractere of '1000') await touche(caractere);
|
||||
const bouton = generer(m);
|
||||
bouton.scrollIntoView({ block: 'center' });
|
||||
const { left, top, width, height } = bouton.getBoundingClientRect();
|
||||
await pointer('presser', left + width / 2, top + height / 2);
|
||||
await pointer('relacher', left + width / 2, top + height / 2);
|
||||
await images(5);
|
||||
expect(tenues.length, 'une écriture tenue').toBeGreaterThan(0);
|
||||
expect(lancements, 'rien ne part avant l’écriture du réglage').toEqual([]);
|
||||
for (let image = 0; lancements.length === 0; image += 1) {
|
||||
expect(image, 'images avant le lancement').toBeLessThan(600);
|
||||
for (const liberer of tenues.splice(0)) liberer();
|
||||
await images(1);
|
||||
}
|
||||
expect(lancements[0].reglages.arret).toBe(1000);
|
||||
expect(charge(m).reglages.generation.arret).toBe(1000);
|
||||
// Les écritures de l'enregistrement passent, et l'issue s'affiche.
|
||||
for (let image = 0; dans(m, '[data-issue]') === null; image += 1) {
|
||||
expect(image, 'images avant l’issue').toBeLessThan(600);
|
||||
for (const liberer of tenues.splice(0)) liberer();
|
||||
await images(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('le travailleur qui ne démarre pas (§ 5.9, § 5.10)', () => {
|
||||
test('travailleur qui ne se charge pas : TRAVAILLEUR_EN_ECHEC près de « Générer », rien d’écrit', async () => {
|
||||
const adresse = URL.createObjectURL(new Blob(['ceci ( ne se compile pas'], { type: 'text/javascript' }));
|
||||
const suivi = { travailleurs: 0 };
|
||||
const executeur = creerExecuteurTravailleur(() => {
|
||||
suivi.travailleurs += 1;
|
||||
return new Worker(adresse);
|
||||
});
|
||||
const m = await demonstration('petite', { executeur });
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, generer(m));
|
||||
await attendre(() => expect(refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC')).not.toBeNull());
|
||||
const refus = refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC');
|
||||
expect(refus.querySelector('p').textContent).toBe(t('TRAVAILLEUR_EN_ECHEC', { remede: null }));
|
||||
expect(suivi.travailleurs).toBe(1);
|
||||
expect(enCours(m)).toBe(false);
|
||||
expect(section(m).getAttribute('aria-busy')).toBe('false');
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(fenetre()).toBeNull();
|
||||
URL.revokeObjectURL(adresse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
|
||||
test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
|
||||
const { executeur: travailleur, suivi } = executeurTravailleur();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@
|
|||
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
|
||||
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
|
||||
// appelle saisirGraine, qui le retire.
|
||||
//
|
||||
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
|
||||
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
|
||||
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
|
||||
// bascule. À l'issue du geste, la case montre la valeur de la charge,
|
||||
// écrite ou inchangée. Un refus de la séance — lecture, plan
|
||||
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
|
||||
// rien ne s'écrit.
|
||||
import { getContext } from 'svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
|
@ -45,6 +53,36 @@
|
|||
};
|
||||
|
||||
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
|
||||
|
||||
// Les bascules, dans l'ordre du § 5.3 : la clé du réglage et celle de son
|
||||
// libellé.
|
||||
const BASCULES = [
|
||||
{ cle: 'separerAppartenances', etiquette: 'generation.contrainte.separerAppartenances' },
|
||||
{ cle: 'nouveauxVoisins', etiquette: 'generation.contrainte.nouveauxVoisins' },
|
||||
{ cle: 'nouvelleTable', etiquette: 'generation.contrainte.nouvelleTable' },
|
||||
{ cle: 'varierAppartenances', etiquette: 'generation.contrainte.varierAppartenances' },
|
||||
{ cle: 'attribuerSieges', etiquette: 'generation.contrainte.attribuerSieges' },
|
||||
];
|
||||
|
||||
// Le refus de la dernière bascule, { cle, refus }, posé à côté d'elle.
|
||||
let refusBascule = $state.raw(null);
|
||||
|
||||
// Le clic sur une case : la séance écrit la valeur contraire de celle de
|
||||
// la charge ; à l'issue, la case montre celle de la charge, écrite ou
|
||||
// inchangée. Annuler le clic ne suffirait pas : la séance peut avoir écrit
|
||||
// avant que le navigateur rétablisse la case, à la fin de l'événement.
|
||||
async function basculer(evenement, cle) {
|
||||
const caseACocher = evenement.currentTarget;
|
||||
refusBascule = null;
|
||||
try {
|
||||
await seance.executer('modifierReglage', { cle, valeur: !charge.reglages[cle] });
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refusBascule = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
||||
} finally {
|
||||
caseACocher.checked = charge.reglages[cle];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="reglages" data-reglages>
|
||||
|
|
@ -74,6 +112,24 @@
|
|||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<fieldset class="bascules">
|
||||
<legend>{t('generation.contraintes.titre')}</legend>
|
||||
{#each BASCULES as { cle, etiquette } (cle)}
|
||||
<div class="bascule">
|
||||
<input
|
||||
id={`${uid}-${cle}`}
|
||||
type="checkbox"
|
||||
data-champ={cle}
|
||||
checked={charge.reglages[cle]}
|
||||
onclick={(evenement) => basculer(evenement, cle)}
|
||||
/>
|
||||
<label for={`${uid}-${cle}`}>{t(etiquette)}</label>
|
||||
{#if refusBascule !== null && refusBascule.cle === cle}
|
||||
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{#if mesure === null}
|
||||
<p data-duree>{t('generation.duree.inconnue')}</p>
|
||||
{:else}
|
||||
|
|
@ -122,6 +178,32 @@
|
|||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
.bascules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2) var(--espace-4);
|
||||
margin: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.bascule {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.bascule input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,12 +85,17 @@
|
|||
}
|
||||
|
||||
// Lance une génération sur la graine saisie et les réglages de la charge ;
|
||||
// confirmee : la réponse à la question d'une génération longue.
|
||||
// confirmee : la réponse à la question d'une génération longue. Les
|
||||
// réglages se lisent quand la file de la séance a passé ce qui s'y
|
||||
// trouve : un réglage quitté par le clic même s'écrit d'abord, et la
|
||||
// génération part sur lui, quelle que soit la lenteur du disque.
|
||||
async function lancer(confirmee) {
|
||||
refusGraine = null;
|
||||
issue = null;
|
||||
if (!confirmee) question = null;
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
|
||||
const saisie = graine;
|
||||
await seance.attendre();
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine: saisie });
|
||||
const deGraine = refus.find(({ champ }) => champ === 'graine');
|
||||
if (deGraine !== undefined) {
|
||||
refusGraine = { code: deGraine.code, details: deGraine.details };
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { creerCharge } from '../../stockage/document.js';
|
|||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { pointer } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
|
||||
|
|
@ -551,6 +552,41 @@ describe('une seule entrée, des doublons signalés, une mise à jour qui n’ef
|
|||
expect(etats.length).toBe(1);
|
||||
});
|
||||
|
||||
test('deux clics réels sur « Importer » pendant l’écriture d’un support lent : la liste s’importe une fois, une entrée du journal', async () => {
|
||||
const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
|
||||
await coller(e, 'nom;prenom\nOmbrelle;Iris\nPervenche;Théo\nLacasse;Ondine');
|
||||
dans(e, '[data-champ="mode"] input[value="ajouter"]').click();
|
||||
flushSync();
|
||||
const entreesAvant = entrees(e);
|
||||
// Le journal du système simulé retient ses ajouts de ligne jusqu'à
|
||||
// liberer() : l'import reste en cours d'écriture.
|
||||
const ajouterLigne = e.m.fs.ajouterLigne;
|
||||
let liberer;
|
||||
const porte = new Promise((resoudre) => {
|
||||
liberer = resoudre;
|
||||
});
|
||||
e.m.fs.ajouterLigne = async (...parametres) => {
|
||||
await porte;
|
||||
return ajouterLigne.apply(e.m.fs, parametres);
|
||||
};
|
||||
const bouton = commande(e, 'importer');
|
||||
bouton.scrollIntoView({ block: 'center' });
|
||||
const boite = bouton.getBoundingClientRect();
|
||||
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
|
||||
for (let clic = 0; clic < 2; clic += 1) {
|
||||
await pointer('presser', x, y);
|
||||
await pointer('relacher', x, y);
|
||||
flushSync();
|
||||
}
|
||||
await vi.waitFor(() => expect(imports(e).length).toBeGreaterThan(0));
|
||||
expect(imports(e)).toHaveLength(1);
|
||||
liberer();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
|
||||
await e.m.seance.formeNommee(null).catch(() => undefined);
|
||||
expect(e.m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(['Ombrelle', 'Pervenche', 'Lacasse']);
|
||||
expect(entrees(e)).toBe(entreesAvant + 1);
|
||||
});
|
||||
|
||||
test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => {
|
||||
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
|
||||
await choisir(
|
||||
|
|
@ -731,6 +767,30 @@ describe('importer selon l’état (§ 10.1, § 9)', () => {
|
|||
expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
|
||||
test('sous « Remplacer », bloqué puis en lecture : le refus à côté de la commande, avec son remède, aucune fenêtre, rien d’écrit', async () => {
|
||||
const cas = [
|
||||
['LECTURE_SEULE', 'debloquer', { prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) }],
|
||||
['MODE_LECTURE', 'modifier', { ecriture: false }],
|
||||
];
|
||||
for (const [code, remede, options] of cas) {
|
||||
const e = await ouvrir({ participants: QUATRE(), ...options });
|
||||
expect(e.m.seance.etat().refus?.code, code).toBe(code);
|
||||
const avant = ecritures(e);
|
||||
await choisir(e, 'tardif.csv', enUtf8(FICHIER));
|
||||
choisirMode(e, 'remplacer');
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, `[data-refus="${code}"]`), code).not.toBeNull());
|
||||
const pose = dans(e, `[data-refus="${code}"]`);
|
||||
expect(commande(e, 'importer').parentElement.contains(pose), code).toBe(true);
|
||||
expect(pose.querySelector(`[data-commande="${remede}"]`)?.textContent, code).toBe(t(`communs.remede.${remede}`));
|
||||
expect(vu(pose), code).toBe(true);
|
||||
expect(dans(e, 'dialog'), code).toBeNull();
|
||||
expect(imports(e), code).toEqual([]);
|
||||
expect(ecritures(e), code).toBe(avant);
|
||||
expect(participantsDe(e).length, code).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ export const TABLE = new Map([
|
|||
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
|
||||
['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
|
||||
|
||||
// --- Les contraintes et l'attribution des sièges, sous leur nom du § 5.3 ---
|
||||
['generation.contraintes.titre', 'Contraintes de la génération'],
|
||||
['generation.contrainte.separerAppartenances', 'Séparer les appartenances'],
|
||||
['generation.contrainte.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
|
||||
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
|
||||
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'],
|
||||
['generation.contrainte.attribuerSieges', 'Attribuer les sièges'],
|
||||
|
||||
// --- La durée du dernier calcul de cette séance (§ 5.10) --------------------
|
||||
[
|
||||
'generation.duree.inconnue',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export const TABLE = new Map([
|
|||
['participants.fiche.itineraire', 'Placement retenu, tour par tour'],
|
||||
['participants.fiche.tour', 'Tour'],
|
||||
['participants.fiche.enReserve', 'en réserve'],
|
||||
['participants.fiche.horsRetenu', 'hors du placement retenu'],
|
||||
['participants.fiche.siegeNonAttribue', 'non attribué'],
|
||||
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],
|
||||
|
||||
|
|
|
|||
|
|
@ -84,12 +84,24 @@ export const TABLE = new Map([
|
|||
['plan.vue.listes', 'Listes de noms'],
|
||||
['plan.vue.pleinEcran', 'Plein écran'],
|
||||
['plan.vue.dessin', 'Plan de salle'],
|
||||
// Le placement que le plan montre, et la commande qui quitte une
|
||||
// proposition montrée.
|
||||
['plan.source.retenu', 'Placement retenu'],
|
||||
['plan.source.reservations', 'Réservations'],
|
||||
['plan.source.retenuFautif', 'Réservations : le placement retenu ne se lit pas'],
|
||||
['plan.source.propositionReservations', ({ proposition }) => `Proposition n° ${proposition} : un geste y pose une réservation`],
|
||||
['plan.source.propositionVueSeule', ({ proposition }) => `Proposition n° ${proposition}, vue seule`],
|
||||
['plan.source.revenirRetenu', 'Revenir au placement retenu'],
|
||||
['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). Le nom de la personne est une donnée,
|
||||
// dans son propre nœud.
|
||||
// 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.
|
||||
['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.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.
|
||||
|
|
@ -156,8 +168,17 @@ export const TABLE = new Map([
|
|||
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
|
||||
['plan.menu.exclure', 'Exclure'],
|
||||
['plan.menu.reintegrer', 'Réintégrer'],
|
||||
['plan.menu.mettreEnEvidence', 'Mettre en évidence'],
|
||||
['plan.menu.oterEvidence', 'Ôter la mise en évidence'],
|
||||
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
|
||||
|
||||
// --- La mise en évidence d'une personne (§ 7.1, § 8.4) ----------------------
|
||||
// Le nom de la personne mise en évidence est une donnée dans son nœud.
|
||||
['plan.evidence.personne', 'Mise en évidence : '],
|
||||
['plan.evidence.absente', 'une personne qui n\u{2019}est plus dans la liste'],
|
||||
['plan.evidence.oter', 'Ôter la mise en évidence'],
|
||||
['plan.evidence.voir', 'Voir sur le plan'],
|
||||
|
||||
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
|
||||
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
|
||||
// des données, chacun dans son nœud, entre ces textes.
|
||||
|
|
|
|||
1249
src/interface/parcours.navigateur.test.js
Normal file
1249
src/interface/parcours.navigateur.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,8 @@
|
|||
<script>
|
||||
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
|
||||
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
|
||||
// réservations, et sa table et son siège à chaque tour du retenu. La fiche
|
||||
// réservations, et sa table et son siège à chaque tour du retenu ; un
|
||||
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
|
||||
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
|
||||
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
|
||||
// propre nœud de texte, et rien n'y est interprété comme du balisage.
|
||||
|
|
@ -115,6 +116,8 @@
|
|||
<td>{etape.tour}</td>
|
||||
{#if etape.reserve}
|
||||
<td colspan="2">{t('participants.fiche.enReserve')}</td>
|
||||
{:else if etape.horsRetenu}
|
||||
<td colspan="2">{t('participants.fiche.horsRetenu')}</td>
|
||||
{:else}
|
||||
<td>{etape.numero}</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -10,23 +10,37 @@
|
|||
// refus — le mode, l'état du plan — se pose contre « Ajouter ».
|
||||
//
|
||||
// L'appartenance est collante : après un ajout, chaque champ se vide sauf
|
||||
// elle. Une appartenance qui ne diffère d'une existante que par la casse,
|
||||
// elle, et l'état de navigation la garde (garderAppartenance) : le
|
||||
// formulaire remonté — retour d'une autre section, fermeture d'« À propos »
|
||||
// — repart d'elle. Une appartenance qui ne diffère d'une existante que par la casse,
|
||||
// les accents ou les blancs (orthographeExistante, grille.js) est signalée
|
||||
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
|
||||
// rien ne fusionne seul.
|
||||
//
|
||||
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
|
||||
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
|
||||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
//
|
||||
// L'envoi n'est pas ré-entrant : tant que l'ajout s'écrit, une seconde
|
||||
// soumission ne fait rien, et « Ajouter » porte aria-busy et
|
||||
// aria-disabled. Le formulaire envoie une copie des champs ; l'ajout
|
||||
// abouti, il ne vide que ceux qui portent encore la valeur envoyée : ce
|
||||
// que l'opérateur tape pendant l'écriture, la personne suivante, reste.
|
||||
// Le focus revient au nom quand rien n'a été tapé entre-temps.
|
||||
//
|
||||
// Les notes sont une zone de texte, qui garde les sauts de ligne :
|
||||
// Maj+Entrée y insère un saut de ligne, Entrée seule envoie
|
||||
// (entreeValide, grille.js).
|
||||
import { getContext, onDestroy, tick, untrack } from 'svelte';
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cleDeColonne, orthographeExistante } from './grille.js';
|
||||
import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
|
||||
|
||||
let { participants } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
const geste = creerGeste(seance);
|
||||
|
||||
|
|
@ -35,10 +49,20 @@
|
|||
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
|
||||
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
|
||||
|
||||
let valeurs = $state({ nom: '', prenom: '', appartenance: '', courriel: '', titrePressenti: '', notes: '' });
|
||||
let valeurs = $state({
|
||||
nom: '',
|
||||
prenom: '',
|
||||
appartenance: untrack(() => ui.etat().appartenance),
|
||||
courriel: '',
|
||||
titrePressenti: '',
|
||||
notes: '',
|
||||
});
|
||||
let refusNom = $state.raw(null);
|
||||
let refusAjout = $state.raw(null);
|
||||
let entreeNom = $state(null);
|
||||
let formulaire;
|
||||
// Vrai pendant l'écriture d'un ajout.
|
||||
let envoiEnCours = $state(false);
|
||||
|
||||
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
|
||||
const idRefusNom = `${uid}-refus-nom`;
|
||||
|
|
@ -55,6 +79,7 @@
|
|||
|
||||
async function ajouter(evenement) {
|
||||
evenement.preventDefault();
|
||||
if (envoiEnCours) return;
|
||||
refusNom = null;
|
||||
refusAjout = null;
|
||||
const champs = {
|
||||
|
|
@ -65,6 +90,7 @@
|
|||
titrePressenti: valeurs.titrePressenti,
|
||||
notes: valeurs.notes,
|
||||
};
|
||||
envoiEnCours = true;
|
||||
try {
|
||||
await seance.executer('ajouterParticipant', { champs });
|
||||
} catch (erreur) {
|
||||
|
|
@ -73,12 +99,25 @@
|
|||
if (refus.code === 'NOM_REQUIS') refusNom = refus;
|
||||
else refusAjout = refus;
|
||||
return;
|
||||
} finally {
|
||||
envoiEnCours = false;
|
||||
}
|
||||
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' };
|
||||
ui.garderAppartenance(champs.appartenance);
|
||||
const intacts = EPHEMERES.filter((cle) => valeurs[cle] === champs[cle]);
|
||||
for (const cle of intacts) valeurs[cle] = '';
|
||||
if (intacts.length < EPHEMERES.length) return;
|
||||
await tick();
|
||||
entreeNom?.focus();
|
||||
}
|
||||
|
||||
// Entrée dans les notes envoie le formulaire ; Maj+Entrée y garde le
|
||||
// saut de ligne de la zone de texte.
|
||||
function surToucheNotes(evenement) {
|
||||
if (evenement.key !== 'Enter' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return;
|
||||
evenement.preventDefault();
|
||||
formulaire.requestSubmit();
|
||||
}
|
||||
|
||||
function reprendre() {
|
||||
valeurs.appartenance = orthographe;
|
||||
}
|
||||
|
|
@ -86,7 +125,7 @@
|
|||
onDestroy(() => geste.liberer());
|
||||
</script>
|
||||
|
||||
<form aria-labelledby={`${uid}-titre`} onsubmit={ajouter}>
|
||||
<form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter} bind:this={formulaire}>
|
||||
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
|
||||
<div class="champs">
|
||||
{#each CHAMPS as cle (cle)}
|
||||
|
|
@ -122,6 +161,8 @@
|
|||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if cle === 'notes'}
|
||||
<textarea id={`${uid}-${cle}`} rows="2" data-champ={cle} bind:value={valeurs.notes} onkeydown={surToucheNotes}></textarea>
|
||||
{:else}
|
||||
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
|
||||
{/if}
|
||||
|
|
@ -129,7 +170,12 @@
|
|||
{/each}
|
||||
</div>
|
||||
<div class="issue">
|
||||
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="ajouter"
|
||||
aria-busy={envoiEnCours ? 'true' : undefined}
|
||||
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
|
||||
>
|
||||
{#if refusAjout !== null}
|
||||
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
|
||||
{/if}
|
||||
|
|
@ -162,7 +208,8 @@
|
|||
max-width: 18rem;
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
textarea {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
|
|
@ -177,6 +224,7 @@
|
|||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
|
|
@ -218,4 +266,10 @@
|
|||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident,
|
||||
// Échap rend la valeur enregistrée. Les notes s'éditent dans une zone de
|
||||
// texte, qui garde leurs sauts de ligne ; un champ d'une ligne les
|
||||
// retirerait de toute note retouchée. Une validation qui change la valeur
|
||||
// retirerait de toute note retouchée. Maj+Entrée y insère un saut de
|
||||
// ligne, Entrée seule valide (entreeValide). Une validation qui change la valeur
|
||||
// exécute modifierParticipant par la séance, sur ce seul champ ; une
|
||||
// saisie qui se lit comme la valeur enregistrée — en NFC, sans blancs de
|
||||
// bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
|
||||
|
|
@ -21,6 +22,9 @@
|
|||
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
|
||||
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
|
||||
//
|
||||
// « Voir sur le plan » met la personne en évidence et ouvre le plan, au
|
||||
// tour montré (§ 7.1, § 8.4) : un geste de regard, qui n'écrit rien.
|
||||
//
|
||||
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
|
||||
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
|
||||
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
|
||||
|
|
@ -31,13 +35,18 @@
|
|||
import Commande from '../primitives/Commande.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js';
|
||||
import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js';
|
||||
|
||||
let { lignes, tri = $bindable(null), supprimer } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
function voirSurLePlan(id) {
|
||||
ui.mettreEnEvidence(id);
|
||||
ui.allerA('plan');
|
||||
}
|
||||
const uid = $props.id();
|
||||
const geste = creerGeste(seance);
|
||||
|
||||
|
|
@ -171,6 +180,7 @@
|
|||
evenement.preventDefault();
|
||||
abandonner();
|
||||
} else if (touche === 'Enter') {
|
||||
if (!entreeValide({ maj: evenement.shiftKey, multiligne: edition.colonne === 'notes' })) return;
|
||||
evenement.preventDefault();
|
||||
valider();
|
||||
} else if (touche === 'Tab') {
|
||||
|
|
@ -327,6 +337,9 @@
|
|||
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
|
||||
{t('participants.commande.fiche')}
|
||||
</button>
|
||||
<button type="button" data-commande="voirSurLePlan" onclick={() => voirSurLePlan(personne.id)}>
|
||||
{t('plan.evidence.voir')}
|
||||
</button>
|
||||
{#if personne.exclu}
|
||||
<Commande
|
||||
cle="participants.commande.reintegrer"
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ async function majTab() {
|
|||
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
|
||||
}
|
||||
|
||||
// Maj+Entrée réel, le saut de ligne d'une zone de texte ; 8 est le masque
|
||||
// de Maj.
|
||||
async function majEntree() {
|
||||
const commun = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, modifiers: 8 };
|
||||
await cdp().send('Input.dispatchKeyEvent', { type: 'keyDown', ...commun, text: '\r', unmodifiedText: '\r' });
|
||||
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
|
||||
await rendu();
|
||||
}
|
||||
|
||||
// La valeur de la fiche que nomme l'étiquette de clé donnée.
|
||||
function valeurDeFiche(m, cle) {
|
||||
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
|
||||
|
|
@ -199,6 +208,23 @@ function ecart(a, b) {
|
|||
return Math.max(dx, dy);
|
||||
}
|
||||
|
||||
// Retient les ajouts de ligne au journal du système simulé jusqu'à
|
||||
// liberer() : l'écriture d'un geste reste en cours, comme sur un support
|
||||
// lent, sans délai ni minuterie. L'application lit la primitive sur fs au
|
||||
// moment du geste.
|
||||
function retenirJournal(m) {
|
||||
const ajouterLigne = m.fs.ajouterLigne;
|
||||
let liberer;
|
||||
const porte = new Promise((resoudre) => {
|
||||
liberer = resoudre;
|
||||
});
|
||||
m.fs.ajouterLigne = async (...parametres) => {
|
||||
await porte;
|
||||
return ajouterLigne.apply(m.fs, parametres);
|
||||
};
|
||||
return () => liberer();
|
||||
}
|
||||
|
||||
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
|
||||
|
||||
describe('la saisie (§ 4, § 10.2)', () => {
|
||||
|
|
@ -227,6 +253,53 @@ describe('la saisie (§ 4, § 10.2)', () => {
|
|||
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
|
||||
});
|
||||
|
||||
test('une seconde Entrée pendant l’écriture n’ajoute rien ; ce qui est tapé pendant l’écriture reste, ce qui a été envoyé se vide (§ 10.2)', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const avant = entrees(m).length;
|
||||
const liberer = retenirJournal(m);
|
||||
const ajout = () => section(m).querySelector('form [data-commande="ajouter"]');
|
||||
expect(ajout()).not.toBeNull();
|
||||
const styleRepos = getComputedStyle(ajout()).borderTopStyle;
|
||||
expect(ajout().getAttribute('aria-busy')).toBeNull();
|
||||
expect(ajout().getAttribute('aria-disabled')).toBeNull();
|
||||
await saisir(champ(m, 'nom'), 'Ombrelle');
|
||||
await saisir(champ(m, 'notes'), 'arrive tard');
|
||||
champ(m, 'nom').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
// L'écriture retenue : « Ajouter » se dit occupé, d'un filet tireté.
|
||||
expect(ajout().getAttribute('aria-busy')).toBe('true');
|
||||
expect(ajout().getAttribute('aria-disabled')).toBe('true');
|
||||
expect(getComputedStyle(ajout()).borderTopStyle).toBe('dashed');
|
||||
expect(getComputedStyle(ajout()).borderTopStyle).not.toBe(styleRepos);
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
// La personne suivante se tape pendant l'écriture de la première.
|
||||
await saisir(champ(m, 'prenom'), 'Théo');
|
||||
await saisir(champ(m, 'nom'), 'Pervenche');
|
||||
liberer();
|
||||
await vi.waitFor(() => expect(participants(m)).toHaveLength(1));
|
||||
await attendreSeance(m);
|
||||
await rendu();
|
||||
expect(ajout().getAttribute('aria-busy')).toBeNull();
|
||||
expect(ajout().getAttribute('aria-disabled')).toBeNull();
|
||||
expect(getComputedStyle(ajout()).borderTopStyle).toBe(styleRepos);
|
||||
expect(participants(m)).toHaveLength(1);
|
||||
expect(participants(m)[0]).toMatchObject({ nom: 'Ombrelle', prenom: null, notes: 'arrive tard' });
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(champ(m, 'nom').value).toBe('Pervenche');
|
||||
expect(champ(m, 'prenom').value).toBe('Théo');
|
||||
expect(champ(m, 'notes').value).toBe('');
|
||||
// La saisie gardée s'envoie à son tour.
|
||||
champ(m, 'nom').focus();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(participants(m).map((p) => [p.nom, p.prenom])).toEqual([
|
||||
['Ombrelle', null],
|
||||
['Pervenche', 'Théo'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => {
|
||||
const m = await evenementOuvert();
|
||||
const trace = m.trace.length;
|
||||
|
|
@ -262,6 +335,28 @@ describe('la saisie (§ 4, § 10.2)', () => {
|
|||
expect(participants(m).at(-1).appartenance).toBeNull();
|
||||
});
|
||||
|
||||
test('l’appartenance collante survit à un aller-retour par le plan et par « À propos » ; un autre événement repart d’un champ vide', async () => {
|
||||
const m = await evenementOuvert();
|
||||
await ajouter(m, { nom: 'Ombrelle', appartenance: CLUB });
|
||||
m.ui.allerA('plan');
|
||||
await rendu();
|
||||
m.ui.allerA('participants');
|
||||
await rendu();
|
||||
expect(champ(m, 'appartenance').value).toBe(CLUB);
|
||||
m.ui.ouvrirApropos();
|
||||
await rendu();
|
||||
m.ui.fermerApropos();
|
||||
await rendu();
|
||||
expect(champ(m, 'appartenance').value).toBe(CLUB);
|
||||
await ajouter(m, { nom: 'Pervenche' });
|
||||
expect(participants(m).at(-1).appartenance).toBe(CLUB);
|
||||
await m.seance.fermer();
|
||||
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
|
||||
m.ui.allerA('participants');
|
||||
await rendu();
|
||||
expect(champ(m, 'appartenance').value).toBe('');
|
||||
});
|
||||
|
||||
test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => {
|
||||
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
|
||||
const trace = m.trace.length;
|
||||
|
|
@ -410,6 +505,34 @@ describe('la grille (§ 10.2, § 19.3)', () => {
|
|||
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
|
||||
});
|
||||
|
||||
test('une note saisie sur deux lignes : Maj+Entrée insère le saut de ligne, dans la grille comme au formulaire ; Entrée valide (§ 10.2, § 10.3)', async () => {
|
||||
const m = await evenementOuvert();
|
||||
// Au formulaire : la note de deux lignes, puis Entrée dans les notes envoie.
|
||||
await saisir(champ(m, 'nom'), 'Lacasse');
|
||||
champ(m, 'notes').focus();
|
||||
for (const caractere of 'a') await touche(caractere);
|
||||
await majEntree();
|
||||
for (const caractere of 'b') await touche(caractere);
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(participants(m)).toHaveLength(1);
|
||||
expect(participants(m)[0].notes).toBe('a\nb');
|
||||
// Dans la grille : la cellule des notes, éditée, reçoit une troisième ligne.
|
||||
const id = idDe(m, 'Lacasse');
|
||||
const avant = entrees(m).length;
|
||||
cellule(m, id, 'notes').focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await majEntree();
|
||||
for (const caractere of 'c') await touche(caractere);
|
||||
await rendu();
|
||||
expect(entrees(m)).toHaveLength(avant);
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(participants(m)[0].notes).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS });
|
||||
const parNom = (sens) =>
|
||||
|
|
@ -668,6 +791,34 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('retenu à 3 tours, l’événement passé à 4 : la fiche garde les tours du retenu, et dit le quatrième hors du retenu (§ 9)', async () => {
|
||||
const m = await evenementOuvert({ sieges: 3, tours: 3, tables: 2, personnes: NOMS.slice(0, 6).map((nom) => ({ nom })) });
|
||||
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
|
||||
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
|
||||
const [proposition] = m.seance.etat().charge.propositions;
|
||||
await m.seance.executer('retenirProposition', { id: proposition.id });
|
||||
await m.seance.executer('changerTours', { tours: 4 });
|
||||
await rendu();
|
||||
const charge = m.seance.etat().charge;
|
||||
expect(charge.evenement.tours).toBe(4);
|
||||
expect(charge.retenu.placement).toHaveLength(3);
|
||||
const forme = formeNommee(charge.retenu, charge);
|
||||
expect(charge.participants).toHaveLength(6);
|
||||
for (const { id: personne } of charge.participants) {
|
||||
m.ui.ouvrirFiche(personne);
|
||||
await rendu();
|
||||
expect(fiche(m).textContent).not.toContain(t('participants.fiche.aucunRetenu'));
|
||||
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
|
||||
expect(tableau).toBeDefined();
|
||||
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
|
||||
const attendus = forme
|
||||
.filter((l) => l.participant === personne)
|
||||
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
|
||||
expect(attendus).toHaveLength(3);
|
||||
expect(lus).toEqual([...attendus, ['4', t('participants.fiche.horsRetenu')]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => {
|
||||
const long = 'Ombrelle'.repeat(25);
|
||||
expect(long).toHaveLength(200);
|
||||
|
|
|
|||
|
|
@ -179,20 +179,27 @@ export function orthographeExistante(saisie, participants, cleNormalisee) {
|
|||
* L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque
|
||||
* tour, de 1 à tours, la table (numéro affiché) et le siège — null quand le
|
||||
* retenu n'attribue pas les sièges —, ou la réserve. Lu dans etatDuTour(r)
|
||||
* = etatPlaces(charge, {tour: r}), jamais recalculé ; null quand la source
|
||||
* n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni
|
||||
* = etatPlaces(charge, {tour: r}), jamais recalculé. Un tour dont la source
|
||||
* n'est pas 'retenu' — le retenu gardé à moins de tours que l'événement
|
||||
* n'en compte, après une hausse des tours (§ 9) — se dit hors du retenu,
|
||||
* et les tours que le retenu porte restent. null quand le tour 1 n'a pas le
|
||||
* retenu pour source, ou que la personne n'est, à un tour du retenu, ni
|
||||
* assise ni en réserve — une exclue, absente de tout tour.
|
||||
*
|
||||
* @param {number} id
|
||||
* @param {number} tours
|
||||
* @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour
|
||||
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null}
|
||||
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}|{tour: number, horsRetenu: true}>|null}
|
||||
*/
|
||||
export function itineraire(id, tours, etatDuTour) {
|
||||
const etapes = [];
|
||||
for (let tour = 1; tour <= tours; tour += 1) {
|
||||
const etat = etatDuTour(tour);
|
||||
if (etat.source !== 'retenu') return null;
|
||||
if (etat.source !== 'retenu') {
|
||||
if (tour === 1) return null;
|
||||
etapes.push({ tour, horsRetenu: true });
|
||||
continue;
|
||||
}
|
||||
let etape = null;
|
||||
for (const table of etat.tables) {
|
||||
const chaise = table.chaises.find(({ occupant }) => occupant === id);
|
||||
|
|
@ -208,6 +215,18 @@ export function itineraire(id, tours, etatDuTour) {
|
|||
return etapes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai quand Entrée valide la saisie : toujours, sauf Maj+Entrée dans une
|
||||
* zone de texte — les notes —, qui y insère un saut de ligne.
|
||||
*
|
||||
* @param {{maj: boolean, multiligne: boolean}} touche maj : Maj tenue ;
|
||||
* multiligne : la saisie est une zone de texte
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function entreeValide({ maj, multiligne }) {
|
||||
return !(maj && multiligne);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les réservations d'une personne, pour la fiche et la fenêtre de
|
||||
* suppression : dans l'ordre de la charge, chacune avec le numéro affiché
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
celluleSuivante,
|
||||
cleDeColonne,
|
||||
cleDeStatut,
|
||||
entreeValide,
|
||||
itineraire,
|
||||
lignesGrille,
|
||||
nomExport,
|
||||
|
|
@ -218,17 +219,36 @@ describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () =
|
|||
assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]);
|
||||
});
|
||||
|
||||
test('source reservations ou proposition, à un tour : null', () => {
|
||||
test('un tour que le retenu ne porte pas — tours haussés après la retenue — se dit hors du retenu ; les tours du retenu restent (§ 9)', () => {
|
||||
const retenu = etat('retenu', true, [tableDe(4, [7])], []);
|
||||
for (const source of ['reservations', 'proposition']) {
|
||||
const etats = new Map([
|
||||
[1, retenu],
|
||||
[2, etat(source, true, [tableDe(4, [7])], [])],
|
||||
[2, retenu],
|
||||
[3, etat('retenu', true, [tableDe(4, [5])], [7])],
|
||||
[4, etat(source, true, [tableDe(4, [7])], [])],
|
||||
]);
|
||||
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source);
|
||||
assert.deepEqual(
|
||||
itineraire(7, 4, (r) => etats.get(r)),
|
||||
[
|
||||
{ tour: 1, numero: 4, siege: 1 },
|
||||
{ tour: 2, numero: 4, siege: 1 },
|
||||
{ tour: 3, reserve: true },
|
||||
{ tour: 4, horsRetenu: true },
|
||||
],
|
||||
source,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('sans retenu au tour 1 : null, quelle que soit la source des tours suivants', () => {
|
||||
const etats = new Map([
|
||||
[1, etat('reservations', true, [tableDe(4, [7])], [])],
|
||||
[2, etat('retenu', true, [tableDe(4, [7])], [])],
|
||||
]);
|
||||
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null);
|
||||
});
|
||||
|
||||
test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => {
|
||||
const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]);
|
||||
assert.equal(itineraire(7, 1, (r) => etats.get(r)), null);
|
||||
|
|
@ -303,3 +323,18 @@ describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
|
|||
assert.throws(() => cleDeStatut('exclu'), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entreeValide : Entrée valide, Maj+Entrée garde le saut de ligne des notes (§ 10.2, § 10.3)', () => {
|
||||
test('Entrée seule valide, dans un champ d’une ligne comme dans une zone de texte', () => {
|
||||
assert.equal(entreeValide({ maj: false, multiligne: false }), true);
|
||||
assert.equal(entreeValide({ maj: false, multiligne: true }), true);
|
||||
});
|
||||
|
||||
test('Maj+Entrée ne valide pas une zone de texte : elle y insère un saut de ligne', () => {
|
||||
assert.equal(entreeValide({ maj: true, multiligne: true }), false);
|
||||
});
|
||||
|
||||
test('Maj+Entrée valide un champ d’une ligne, qui n’a pas de saut de ligne', () => {
|
||||
assert.equal(entreeValide({ maj: true, multiligne: false }), true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,10 +10,22 @@
|
|||
// 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.
|
||||
//
|
||||
// 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
|
||||
// d'ôter la mise en évidence, que la personne soit assise au tour montré
|
||||
// ou non ; le menu de la personne la pose (MenuPersonne). Une personne que
|
||||
// la charge n'a plus se dit telle, sans nom.
|
||||
//
|
||||
// source : ce que la barre dit du placement montré, que rend sourceMontree
|
||||
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
|
||||
// et, quand une proposition est demandée, offre de ne plus la montrer
|
||||
// (ui.montrerProposition(null)), sous le libellé revenir. Ni l'un ni
|
||||
// l'autre n'écrit l'événement.
|
||||
import { getContext } from 'svelte';
|
||||
import { toursOfferts } from './vue_plan.js';
|
||||
|
||||
let { tour, tours, zoomer, ajuster } = $props();
|
||||
let { tour, tours, zoomer, ajuster, evidence = null, source } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
|
|
@ -22,6 +34,12 @@
|
|||
</script>
|
||||
|
||||
<div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}>
|
||||
<p class="source" data-source>{t(source.libelle.cle, source.libelle.details)}</p>
|
||||
{#if source.revenir !== null}
|
||||
<button type="button" data-commande="quitterProposition" onclick={() => ui.montrerProposition(null)}
|
||||
>{t(source.revenir)}</button
|
||||
>
|
||||
{/if}
|
||||
<label class="tour">
|
||||
<span>{t('plan.vue.tour')}</span>
|
||||
<select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}>
|
||||
|
|
@ -39,6 +57,14 @@
|
|||
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
|
||||
>{t('plan.vue.pleinEcran')}</button
|
||||
>
|
||||
{#if evidence !== null}
|
||||
<p class="evidence" data-evidence={evidence.id}>
|
||||
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{:else}<span
|
||||
>{t('plan.evidence.absente')}</span
|
||||
>{/if}
|
||||
<button type="button" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}>{t('plan.evidence.oter')}</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -49,6 +75,18 @@
|
|||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.evidence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// (§ 14.8).
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import '../jetons.css';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
|
|
@ -31,11 +32,12 @@ import { rechercher } from '../../moteur/recherche.js';
|
|||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { versFichier } from '../../stockage/placements.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js';
|
||||
import { composantes, couleursPeintes, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
|
||||
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import BandeauPlan from './BandeauPlan.svelte';
|
||||
import EchellePlan from './EchellePlan.svelte';
|
||||
import { creerMesureur } from './mesure_texte.js';
|
||||
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
|
||||
import PlanDessin from './PlanDessin.svelte';
|
||||
|
||||
|
|
@ -71,8 +73,10 @@ afterEach(demonterTout);
|
|||
|
||||
/** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la
|
||||
* fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est
|
||||
* en display: none, et conteneur.style.display = '' le montre. */
|
||||
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) {
|
||||
* en display: none, et conteneur.style.display = '' le montre. props
|
||||
* remplace { modele } : un accesseur qui lit un état réactif change le
|
||||
* modèle du composant monté. */
|
||||
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false, props = { modele } } = {}) {
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.dataset.surface = '';
|
||||
if (cache) conteneur.style.display = 'none';
|
||||
|
|
@ -87,7 +91,7 @@ function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}
|
|||
groupe.setAttribute('data-vue', '');
|
||||
groupe.setAttribute('transform', transformDe(vue));
|
||||
svg.append(groupe);
|
||||
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE });
|
||||
const composant = mount(PlanDessin, { target: groupe, props, context: CONTEXTE });
|
||||
flushSync();
|
||||
montes.push({ composant, conteneur });
|
||||
const boite = svg.getBoundingClientRect();
|
||||
|
|
@ -483,6 +487,113 @@ describe('le plan monté caché (point d\u{2019}attention 1)', () => {
|
|||
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('montré, caché, son modèle changé pendant qu\u{2019}il est caché, puis remontré : chaque ligne tient, chaque nom long s\u{2019}abrège sur la marque', async () => {
|
||||
const charge = chargeDeTables([4, 8]);
|
||||
avecProposition(charge);
|
||||
const reactif = new SvelteMap([['modele', modeleDe(charge, { proposition: 1 })]]);
|
||||
const { svg, conteneur } = monterPlan(null, { k: 1, tx: 300, ty: 350 }, {
|
||||
props: {
|
||||
get modele() {
|
||||
return reactif.get('modele');
|
||||
},
|
||||
},
|
||||
});
|
||||
await deuxImages();
|
||||
flushSync();
|
||||
expect(lignesDe(svg, 1).length).toBe(4);
|
||||
conteneur.style.display = 'none';
|
||||
await deuxImages();
|
||||
flushSync();
|
||||
// Un autre modèle, posé pendant que le plan est caché : quatre-vingts
|
||||
// « W » pour chaque nom.
|
||||
const longs = structuredClone(charge);
|
||||
for (const p of longs.participants) {
|
||||
p.prenom = null;
|
||||
p.nom = 'W'.repeat(80);
|
||||
}
|
||||
reactif.set('modele', modeleDe(longs, { proposition: 1 }));
|
||||
flushSync();
|
||||
await deuxImages();
|
||||
conteneur.style.display = '';
|
||||
await deuxImages();
|
||||
flushSync();
|
||||
const lignes = tous(svg, '[data-liste] > tspan');
|
||||
expect(lignes.length).toBe(12);
|
||||
for (const ligne of lignes) {
|
||||
expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
|
||||
const nom = ligne.querySelector('[data-partie=nom]').textContent;
|
||||
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('creerMesureur : la largeur nulle d\u{2019}un texte non rendu ne se retient pas ; rendu, la chaîne se mesure', () => {
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.style.display = 'none';
|
||||
document.body.prepend(conteneur);
|
||||
try {
|
||||
const svg = document.createElementNS(SVG, 'svg');
|
||||
const texte = document.createElementNS(SVG, 'text');
|
||||
texte.setAttribute('font-size', '18');
|
||||
svg.append(texte);
|
||||
conteneur.append(svg);
|
||||
const mesurer = creerMesureur(texte);
|
||||
expect(mesurer('Ombrelle')).toBe(0);
|
||||
conteneur.style.display = '';
|
||||
expect(mesurer('Ombrelle')).toBeGreaterThan(0);
|
||||
expect(mesurer('')).toBe(0);
|
||||
} finally {
|
||||
conteneur.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('un nom porteur de balisage, des homonymes (point d\u{2019}attention 3)', () => {
|
||||
test('le balisage reste du texte, dans un seul nœud ; deux « Iris Ombrelle » ont chacune leur ligne et leur numéro', () => {
|
||||
const BALISAGES = ['<tspan font-size="200">X</tspan>', '</text><circle r="999"/>'];
|
||||
const charge = chargeDeTables([8, 4]);
|
||||
charge.participants[1].prenom = 'Iris';
|
||||
charge.participants[1].nom = 'Ombrelle';
|
||||
charge.participants[2].prenom = null;
|
||||
charge.participants[2].nom = BALISAGES[0];
|
||||
charge.participants[3].prenom = null;
|
||||
charge.participants[3].nom = BALISAGES[1];
|
||||
// Une proposition qui assied les huit premiers à la table 1.
|
||||
avecProposition(charge, {
|
||||
id: 1,
|
||||
graine: 1,
|
||||
arret: 1,
|
||||
historique: 1,
|
||||
produitVersion: 'epreuve',
|
||||
siegesAttribues: false,
|
||||
tables: [1, 2],
|
||||
capacites: [8, 4],
|
||||
tours: 1,
|
||||
participants: charge.participants.map(({ id }) => id),
|
||||
placement: [{ sieges: [[1, 2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12]], reserve: [] }],
|
||||
});
|
||||
const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 });
|
||||
const liste = svg.querySelector('[data-liste="1"]');
|
||||
const lignes = lignesDe(svg, 1);
|
||||
expect(lignes.length).toBe(8);
|
||||
// Chaque ligne d'un occupant sans réservation ni titre : sa tête et son
|
||||
// nom, deux tspan ; rien d'autre sous la liste.
|
||||
expect(liste.querySelectorAll('*').length).toBe(8 * 3);
|
||||
expect([...liste.querySelectorAll('*')].every((e) => e.localName === 'tspan')).toBe(true);
|
||||
expect(svg.querySelectorAll('circle[r="999"], [font-size="200"]').length).toBe(0);
|
||||
const noms = tous(liste, '[data-partie=nom]');
|
||||
expect(noms.length).toBe(8);
|
||||
for (const nom of noms) expect(nom.childElementCount).toBe(0);
|
||||
for (const balisage of BALISAGES) {
|
||||
const portes = noms.filter(({ textContent }) => textContent === balisage || (textContent.endsWith(MARQUE) && balisage.startsWith(textContent.slice(0, -MARQUE.length))));
|
||||
expect(portes.length, balisage).toBe(1);
|
||||
}
|
||||
const homonymes = noms.filter(({ textContent }) => textContent === 'Iris Ombrelle');
|
||||
expect(homonymes.length).toBe(2);
|
||||
const numeros = homonymes.map((nom) => nom.previousElementSibling.textContent);
|
||||
expect(numeros.every((tete) => /^\d+\. $/.test(tete))).toBe(true);
|
||||
expect(new Set(numeros).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le signal de conflit (§ 7.5, § 13.3)', () => {
|
||||
|
|
@ -510,6 +621,11 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
|
|||
expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille));
|
||||
expect(visible(pastilles[0].querySelector('text'))).toBe(true);
|
||||
expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]);
|
||||
// La pastille se pose sur un socle du fond du plateau : la trame ne
|
||||
// passe pas sous elle (§ 14.5), et reste peinte autour du socle.
|
||||
expect(fondPeintSous(pastilles[0].querySelector('[data-marque=pastille]'))).toEqual([jeton('--couleur-table-fond')]);
|
||||
const socle = trames[0].closest('[data-table]').querySelector('[data-socle]');
|
||||
expect(Number(socle.getAttribute('r'))).toBeLessThan(Number(enConflit.getAttribute('r')));
|
||||
expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none');
|
||||
demonterTout();
|
||||
|
||||
|
|
@ -637,8 +753,43 @@ describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => {
|
|||
});
|
||||
|
||||
describe('dessin et désignation (§ 7.3, § 13.5)', () => {
|
||||
test('au seuil k = 24/pas de chaque table, la cible d’un siège a la taille du pas : à pas/2 − 1 px de sa chaise, vers sa voisine, le DOM et designer désignent la chaise ; à pas/2 + 1 px, ils désignent la même place', () => {
|
||||
const charge = chargeDeTables([3, 8, 8, 12], { pas: 800, formes: ['ronde', 'ronde', 'carree', 'carree'] });
|
||||
let points = 0;
|
||||
for (const id of [1, 2, 3, 4]) {
|
||||
const geometrie = modeleDe(charge, { k: 1 }).tables[id - 1].geometrie;
|
||||
const k = (24 / geometrie.pas) * (1 + 1e-9);
|
||||
const vue = { k, tx: innerWidth / 2 - k * geometrie.centre.x, ty: innerHeight / 2 };
|
||||
const modele = modeleDe(charge, { k, listes: false });
|
||||
expect(modele.tables[id - 1].regime).toBe('sieges');
|
||||
const geometries = modele.tables.map(({ geometrie: g }) => g);
|
||||
const { cadre, ecran } = monterPlan(modele, vue);
|
||||
const { chaises } = geometries[id - 1];
|
||||
chaises.forEach((chaise, i) => {
|
||||
const voisine = chaises[(i + 1) % chaises.length];
|
||||
const c = ecran(chaise.x, chaise.y);
|
||||
const v = ecran(voisine.x, voisine.y);
|
||||
const d = Math.hypot(v.x - c.x, v.y - c.y);
|
||||
for (const [ecart, attendu] of [[-1, `${id}:${chaise.siege}`], [1, undefined]]) {
|
||||
const portee = (geometrie.pas / 2) * k + ecart;
|
||||
const p = { x: c.x + ((v.x - c.x) * portee) / d, y: c.y + ((v.y - c.y) * portee) / d };
|
||||
const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null;
|
||||
const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, k);
|
||||
const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null;
|
||||
expect(parDom, `table ${id}, siège ${chaise.siege}, écart ${ecart}`).toBe(parGeometrie);
|
||||
if (attendu !== undefined) expect(parDom, `table ${id}, siège ${chaise.siege}`).toBe(attendu);
|
||||
points += 1;
|
||||
}
|
||||
});
|
||||
demonterTout();
|
||||
}
|
||||
expect(points).toBe(2 * (3 + 8 + 8 + 12));
|
||||
});
|
||||
|
||||
test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => {
|
||||
const charge = chargeDeTables([8, 8], { pas: 200 });
|
||||
// La table 2 si près que la liste de la table 1 passe sur ses chaises de
|
||||
// gauche.
|
||||
const charge = chargeDeTables([8, 8], { pas: 280 });
|
||||
const vue = { k: 1.5, tx: 300, ty: 350 };
|
||||
const modele = modeleDe(charge, { k: vue.k });
|
||||
const { svg, cadre, ecran } = monterPlan(modele, vue);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
// écrit son attribut transform à chaque image, directement, et le montre
|
||||
// par la propriété fantome : le nom de la personne, dans son propre nœud,
|
||||
// puis le libellé de ce que le geste modifie — la clé que cleFantome
|
||||
// (gestes.js) tire de la cible de l'état des places. Caché, il ne porte
|
||||
// (gestes.js) tire de la cible de l'état des places et de la portée de la
|
||||
// réservation qui tient la personne. Caché, il ne porte
|
||||
// rien ; montré ou non, il ne reçoit pas le pointeur.
|
||||
//
|
||||
// fantome : {nom, cle} | null ; element : le <g>, lié pour Plan.svelte.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { lireJournal } from '../../stockage/journal.js';
|
|||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?raw';
|
||||
import { fixerCadre, glisser, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { fixerCadre, glisser, molette, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
|
||||
|
||||
|
|
@ -731,6 +731,39 @@ describe('les gestes d’une personne (§ 7.1, § 8.2, § 5.8)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('un refus posé sur une place ne couvre pas la cible suivante (§ 5.9, § 7.3)', () => {
|
||||
test('un appui sur une chaise que couvre le texte du refus atteint le plan : le refus se ferme, la chaise se sélectionne', async () => {
|
||||
const m = await ouvrir(huitRetenue());
|
||||
m.ui.montrerProposition(1);
|
||||
flushSync();
|
||||
await images(2);
|
||||
await glisser(pointChaise(m, 3, 1), pointChaise(m, 7, 1));
|
||||
const place = dans(m, '[data-siege="7:1"]');
|
||||
await attendre(() => expect(decrit(place)).not.toBeNull());
|
||||
const refus = decrit(place);
|
||||
const boite = refus.getBoundingClientRect();
|
||||
// Les chaises dont le centre tombe dans la boîte du refus, hors de ses
|
||||
// boutons.
|
||||
const couvertes = tous(m, '[data-siege]')
|
||||
.map((chaise) => {
|
||||
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
|
||||
return { chaise, point: pointChaise(m, table, siege) };
|
||||
})
|
||||
.filter(({ point: { x, y } }) => x > boite.left + 2 && x < boite.right - 2 && y > boite.top + 2 && y < boite.bottom - 2)
|
||||
.filter(({ point: { x, y } }) => document.elementFromPoint(x, y)?.closest('button') === null);
|
||||
expect(couvertes.length).toBeGreaterThan(0);
|
||||
const { chaise, point } = couvertes[0];
|
||||
const avant = fichiers(m);
|
||||
await pointer('presser', point.x, point.y);
|
||||
await pointer('relacher', point.x, point.y);
|
||||
await images(2);
|
||||
expect(decrit(place)).toBeNull();
|
||||
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
|
||||
expect(m.ui.etat().selection).toEqual({ table, siege });
|
||||
await rienEcrit(m, avant);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Glisser une table (§ 7.1, § 9) ---------------------------------------------
|
||||
|
||||
describe('glisser une table (§ 7.1, § 9)', () => {
|
||||
|
|
@ -1009,6 +1042,54 @@ describe('un clic n’est pas un glisser, la lecture, l’inactivité (§ 8.4)',
|
|||
});
|
||||
});
|
||||
|
||||
describe('regarder pendant un geste et regarder sans appuyer (§ 7.2, § 7.3, § 8.4)', () => {
|
||||
test('la molette pendant le glissement d’une personne ne change pas la vue : la commande nomme la chaise sous le pointeur au relâcher', async () => {
|
||||
const m = await ouvrir(huitRetenue());
|
||||
m.ui.montrerTour(2);
|
||||
flushSync();
|
||||
await avecVue(m, { k: 0.5, tx: 150, ty: 60 });
|
||||
const commandes = releverCommandes(m);
|
||||
const r = fichiers(m).revision;
|
||||
const depart = pointChaise(m, 4, 1);
|
||||
await presserEtBouger(depart, decale(depart, 30, 0));
|
||||
const vue = groupe(m).getAttribute('transform');
|
||||
const cadre = cadreDe(m);
|
||||
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -300);
|
||||
await images(3);
|
||||
expect(groupe(m).getAttribute('transform')).toBe(vue);
|
||||
const vers = pointChaise(m, 7, 3);
|
||||
await pointer('bouger', vers.x, vers.y);
|
||||
await uneImage();
|
||||
await pointer('relacher', vers.x, vers.y);
|
||||
await uneEntree(m, r);
|
||||
expect(commandes).toEqual([{ nom: 'deplacerDansRetenu', args: { tour: 2, participant: 1, table: 7, siege: 3 } }]);
|
||||
});
|
||||
|
||||
test('la molette, « Zoom + », « Ajuster » et le changement de tour ne relancent pas le compte d’inactivité : le retour en lecture tombe à son heure', async () => {
|
||||
const m = await ouvrir(huitRetenue());
|
||||
expect(m.seance.etat().mode).toBe('ecriture');
|
||||
m.horloge.avancer(INACTIVITE_MS - 1000);
|
||||
await images(2);
|
||||
const transform = groupe(m).getAttribute('transform');
|
||||
const cadre = cadreDe(m);
|
||||
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -100);
|
||||
await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBe(transform));
|
||||
dans(m, '[data-commande="zoomer"]').click();
|
||||
flushSync();
|
||||
dans(m, '[data-commande="ajuster"]').click();
|
||||
flushSync();
|
||||
const select = dans(m, '[data-champ="tour"]');
|
||||
select.value = '2';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
expect(m.ui.etat().tour).toBe(2);
|
||||
await images(2);
|
||||
expect(m.seance.etat().mode).toBe('ecriture');
|
||||
m.horloge.avancer(1001);
|
||||
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
|
||||
});
|
||||
});
|
||||
|
||||
// L'écart d'une poignée tirée de trois places, au zoom relu.
|
||||
const pasDeLecture = (m) => ((3 * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
// réservations « tour » à une table qu'une seule « tous les tours »
|
||||
// remplacerait. « Convertir » l'exécute ; « Ignorer » la retire de la
|
||||
// liste jusqu'au prochain montage, sans rien écrire.
|
||||
// Chaque ligne de titre a pour clé son rang : deux titres égaux à une
|
||||
// même place restent deux lignes.
|
||||
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
|
||||
// des données, chacun dans son nœud. Échap, sur le panneau ou l'un de ses
|
||||
// contrôles, oublie le choix en attente. Le panneau lit 'seance' et 'ui'
|
||||
|
|
@ -74,7 +76,7 @@
|
|||
{#if charge !== null}
|
||||
<h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4>
|
||||
<ul data-liste-travail="titres" aria-labelledby="{uid}-titres">
|
||||
{#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)}
|
||||
{#each titres as titre, rang (rang)}
|
||||
<li>
|
||||
<button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)}
|
||||
><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee"
|
||||
|
|
|
|||
|
|
@ -19,14 +19,15 @@
|
|||
// n'est pas assise (executerSurPlace), sinon à côté de la commande.
|
||||
//
|
||||
// participant : l'identifiant de la personne ; le menu porte data-menu.
|
||||
// etatTour : l'état des places du tour affiché, que SectionPlan calcule et
|
||||
// que le panneau de la place passe, ou null sans événement.
|
||||
import { getContext } from 'svelte';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { commandeDeDepot } from './depot.js';
|
||||
import { actionsPersonne, executerSurPlace } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
let { participant } = $props();
|
||||
let { participant, etatTour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -35,8 +36,8 @@
|
|||
|
||||
const charge = $derived($seance.charge);
|
||||
const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant));
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(fiche === undefined ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etat = $derived(fiche === undefined ? null : etatTour);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
// La place que la personne occupe au tour montré, et sa chaise, ou null.
|
||||
const assise = $derived.by(() => {
|
||||
if (etat === null) return null;
|
||||
|
|
@ -51,7 +52,10 @@
|
|||
? null
|
||||
: commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }),
|
||||
);
|
||||
const actions = $derived(fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait }));
|
||||
const enEvidence = $derived($ui.miseEnEvidence === participant);
|
||||
const actions = $derived(
|
||||
fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait, enEvidence }),
|
||||
);
|
||||
|
||||
const dependances = { seance, ui };
|
||||
// Où poser un refus : sa place, sinon la place sélectionnée.
|
||||
|
|
@ -87,6 +91,8 @@
|
|||
deplacerReservation,
|
||||
exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }),
|
||||
reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }),
|
||||
mettreEnEvidence: () => ui.mettreEnEvidence(participant),
|
||||
oterEvidence: () => ui.mettreEnEvidence(null),
|
||||
ouvrirFiche: () => ui.ouvrirFiche(participant),
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
</pattern>
|
||||
<pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)">
|
||||
<rect class="fond-table" width="12" height="12" />
|
||||
<line class="trait-trame" x1="6" y1="0" x2="6" y2="12" />
|
||||
<line class="trait-conflit" x1="6" y1="0" x2="6" y2="12" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
fill: var(--couleur-table-fond);
|
||||
}
|
||||
|
||||
.trait-trame {
|
||||
.trait-conflit {
|
||||
stroke: var(--couleur-conflit);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,19 @@
|
|||
// — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue.
|
||||
//
|
||||
// Échap referme le panneau et rend le focus à la racine du plan. Le
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
// panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
|
||||
// du tour affiché, que SectionPlan calcule, ou null sans événement, et le
|
||||
// passe au menu.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { etatPlaces, libellePropose } from '../../application/places.js';
|
||||
import { libellePropose } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { commandeDeDepot } from './depot.js';
|
||||
import { libellePlace } from './gestes.js';
|
||||
import MenuPersonne from './MenuPersonne.svelte';
|
||||
import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -39,8 +43,8 @@
|
|||
const charge = $derived($seance.charge);
|
||||
const selection = $derived($ui.selection);
|
||||
const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(place === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etat = $derived(place === null ? null : etatTour);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table));
|
||||
const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null));
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
|
|
@ -199,7 +203,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if menuDe !== null}
|
||||
<MenuPersonne participant={menuDe} />
|
||||
<MenuPersonne participant={menuDe} {etatTour} />
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -22,14 +22,17 @@
|
|||
// (executerSurPlace), LIBELLE_VIDE à côté du champ.
|
||||
//
|
||||
// Échap referme le panneau et rend le focus à la racine du plan. Le
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
// panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
|
||||
// du tour affiché, que SectionPlan calcule, ou null sans événement.
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { comptesTitres } from '../../application/listes_travail.js';
|
||||
import { etatPlaces, libellePropose } from '../../application/places.js';
|
||||
import { libellePropose } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { libellePlace } from './gestes.js';
|
||||
import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -39,8 +42,7 @@
|
|||
const charge = $derived($seance.charge);
|
||||
const selection = $derived($ui.selection);
|
||||
const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(table === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etat = $derived(table === null ? null : etatTour);
|
||||
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table));
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table));
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@
|
|||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
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 { etatPlaces } from '../../application/places.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { surLeSol } from '../../geometrie/designation.js';
|
||||
import { geometrieTable } from '../../geometrie/tables.js';
|
||||
|
|
@ -39,6 +42,8 @@ import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?
|
|||
import TEXTE_CONFLIT from '../../../test/fixtures/plan/conflit_table_un.gtt.json?raw';
|
||||
import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/navigateur/entrees.js';
|
||||
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';
|
||||
|
||||
const BASE = 'soiree';
|
||||
|
|
@ -261,16 +266,24 @@ async function cliquerPoint({ x, y }) {
|
|||
await images(2);
|
||||
}
|
||||
|
||||
// Un clic réel au centre d'une chaise. Quand un avertissement paru au-dessus
|
||||
// du plan l'a poussé sous le bas de la fenêtre, la page défile d'abord
|
||||
// jusqu'à la chaise.
|
||||
// Un clic réel au centre d'une chaise. Quand elle est hors du cadre du
|
||||
// plan — les annonces du cadre de mode, avertissements compris, prennent
|
||||
// leur hauteur à la surface —, la vue se déplace d'abord, par le cadrage de
|
||||
// la séance, pour poser la chaise au centre du cadre, au même k.
|
||||
async function cliquerChaise(m, table, siege) {
|
||||
const atteinte = () => {
|
||||
const { x, y } = pointChaise(m, table, siege);
|
||||
return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false;
|
||||
};
|
||||
if (!atteinte()) {
|
||||
chaiseEl(m, table, siege).scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const vue = vueLue(m);
|
||||
const cadre = cadreDe(m);
|
||||
const { x, y } = pointChaise(m, table, siege);
|
||||
await avecVue(m, {
|
||||
k: vue.k,
|
||||
tx: vue.tx + cadre.gauche + cadre.largeur / 2 - x,
|
||||
ty: vue.ty + cadre.haut + cadre.hauteur / 2 - y,
|
||||
});
|
||||
await attendre(() => expect(atteinte()).toBe(true));
|
||||
}
|
||||
await cliquerPoint(pointChaise(m, table, siege));
|
||||
|
|
@ -284,6 +297,34 @@ async function presserBouton(element) {
|
|||
await cliquerPoint({ x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 });
|
||||
}
|
||||
|
||||
// Presse un bouton par le vrai pointeur, et dit s'il a reçu l'appui : un
|
||||
// bouton que le pointeur traverse laisse l'appui à ce qui est dessous.
|
||||
async function presseRecue(element) {
|
||||
let recu = false;
|
||||
const noter = () => {
|
||||
recu = true;
|
||||
};
|
||||
element.addEventListener('pointerdown', noter);
|
||||
try {
|
||||
await presserBouton(element);
|
||||
} finally {
|
||||
element.removeEventListener('pointerdown', noter);
|
||||
}
|
||||
return recu;
|
||||
}
|
||||
|
||||
// Presse au point depuis et avance jusqu'au point vers sans relâcher : le
|
||||
// fantôme reste montré.
|
||||
async function presserEtBouger(depuis, vers, { pas = 6 } = {}) {
|
||||
await pointer('presser', depuis.x, depuis.y);
|
||||
for (let i = 1; i <= pas; i += 1) {
|
||||
const t = i / pas;
|
||||
await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t);
|
||||
await images(1);
|
||||
}
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Clique un bouton par son clic HTML, puis laisse la séance écrire.
|
||||
async function cliquer(element) {
|
||||
expect(element, 'bouton présent').not.toBeNull();
|
||||
|
|
@ -465,7 +506,10 @@ describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => {
|
|||
const tables = charge(m).tables;
|
||||
const premiere = tables[0];
|
||||
expect(actif(m)).toBe(`table-${premiere.id}`);
|
||||
const voisine = tables.find(({ position }) => position.x === premiere.position.x + 300 && position.y === premiere.position.y);
|
||||
// La voisine de droite : la plus proche à droite, sur la même rangée.
|
||||
const voisine = tables
|
||||
.filter(({ position }) => position.y === premiere.position.y && position.x > premiere.position.x)
|
||||
.reduce((plus, table) => (plus === undefined || table.position.x < plus.position.x ? table : plus), undefined);
|
||||
expect(voisine).toBeDefined();
|
||||
await touche('ArrowRight');
|
||||
flushSync();
|
||||
|
|
@ -877,6 +921,163 @@ describe('le menu fait ce que fait le geste (§ 7.1, § 10.2)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// --- Deux titres identiques à une même place (§ 5.8) -----------------------------
|
||||
|
||||
describe('les listes de travail tiennent deux titres identiques à une même place (§ 5.8)', () => {
|
||||
test('deux titres « animateur » au siège 2 de la table 1, rendus par titresNonPourvus : deux lignes, aucune erreur de rendu', () => {
|
||||
// Le panneau est monté seul, sur une séance d'épreuve qui porte la
|
||||
// charge telle quelle : une charge que l'analyse admettrait ou non, le
|
||||
// rendu ne doit pas tomber sur elle.
|
||||
const charge = sansProposition();
|
||||
charge.titres = [
|
||||
{ table: 1, siege: 2, libelle: 'animateur' },
|
||||
{ table: 1, siege: 2, libelle: 'animateur' },
|
||||
];
|
||||
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]]);
|
||||
let composant = null;
|
||||
try {
|
||||
composant = mount(ListesTravail, { target: conteneur, context: contexte });
|
||||
flushSync();
|
||||
const lignes = [...conteneur.querySelectorAll('[data-liste-travail="titres"] [data-titre="1:2"]')];
|
||||
expect(lignes).toHaveLength(2);
|
||||
expect(lignes.map(texteDe)).toEqual(['table 1 : titre animateur non pourvu', 'table 1 : titre animateur non pourvu']);
|
||||
} finally {
|
||||
if (composant !== null) unmount(composant);
|
||||
conteneur.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le plan dit le placement qu'il montre (§ 2.1 étape 11, § 5.8) ----------------
|
||||
|
||||
describe('le plan dit le placement qu’il montre, et revient d’une proposition (§ 2.1 étape 11, § 5.8)', () => {
|
||||
const source = (m) => texteDe(dans(m, '[data-source]'));
|
||||
const reserve = (m) => [...dans(m, '[data-reserve]').querySelectorAll('[data-participant]')].map((e) => Number(e.dataset.participant));
|
||||
|
||||
test('avant la retenue : « Voir » une proposition, puis revenir aux réservations depuis le plan ; la réserve liste de nouveau les non-réservés', async () => {
|
||||
const m = await ouvrir(huitAvantRetenue());
|
||||
expect(source(m)).toBe('Réservations');
|
||||
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
|
||||
const nonReserves = reserve(m);
|
||||
expect(nonReserves).toHaveLength(charge(m).participants.length);
|
||||
m.ui.montrerProposition(1);
|
||||
flushSync();
|
||||
await images(2);
|
||||
expect(source(m)).toBe('Proposition n° 1 : un geste y pose une réservation');
|
||||
expect(reserve(m)).not.toEqual(nonReserves);
|
||||
const quitter = dans(m, '[data-commande="quitterProposition"]');
|
||||
expect(texteDe(quitter)).toBe('Revenir aux réservations');
|
||||
await presserBouton(quitter);
|
||||
flushSync();
|
||||
await images(2);
|
||||
expect(m.ui.etat().proposition).toBeNull();
|
||||
expect(source(m)).toBe('Réservations');
|
||||
expect(reserve(m)).toEqual(nonReserves);
|
||||
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('après la retenue : la proposition montrée se dit en vue seule, et la commande revient au placement retenu', async () => {
|
||||
const m = await ouvrir(huitRetenue());
|
||||
expect(source(m)).toBe('Placement retenu');
|
||||
m.ui.montrerProposition(1);
|
||||
flushSync();
|
||||
await images(2);
|
||||
expect(source(m)).toBe('Proposition n° 1, vue seule');
|
||||
const quitter = dans(m, '[data-commande="quitterProposition"]');
|
||||
expect(texteDe(quitter)).toBe('Revenir au placement retenu');
|
||||
await presserBouton(quitter);
|
||||
flushSync();
|
||||
await images(2);
|
||||
expect(m.ui.etat().proposition).toBeNull();
|
||||
expect(source(m)).toBe('Placement retenu');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Un geste à un tour ne change pas les autres tours (§ 7.1, § 5.8) -----------
|
||||
|
||||
describe('une réservation « tous les tours » ne se retire pas par un geste (§ 7.1)', () => {
|
||||
test('sol ou « Retirer du tour » : refus sur la place, rien d’écrit ; le remède la retire ; « ce tour » se retire par le sol', 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 place = () => chaiseEl(m, 3, 1);
|
||||
const refusSur = () => decrit(place())?.querySelector('[data-refus="RESERVATION_TOUS_LES_TOURS"]') ?? null;
|
||||
|
||||
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(`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');
|
||||
expect(fermer).not.toBeNull();
|
||||
expect(await presseRecue(fermer)).toBe(true);
|
||||
await attendre(() => expect(refusSur()).toBeNull());
|
||||
expect(m.ui.etat().pose).toBeNull();
|
||||
await rienEcrit(m, avant);
|
||||
|
||||
await activerSiegeAuClavier(m, 3, 1);
|
||||
await presserBouton(dans(m, `[data-menu="${IRIS}"] [data-commande="retirerDuTour"]`));
|
||||
await attendre(() => expect(refusSur()).not.toBeNull());
|
||||
await rienEcrit(m, avant);
|
||||
|
||||
// Le remède, pressé par le vrai pointeur : il reçoit l'appui, et sa
|
||||
// commande écrit une entrée.
|
||||
const remede = refusSur().querySelector('[data-commande="retirerReservation"]');
|
||||
expect(remede).not.toBeNull();
|
||||
const r = fichiers(m).revision;
|
||||
expect(await presseRecue(remede)).toBe(true);
|
||||
await uneEntree(m, r);
|
||||
expect(charge(m).reservations).toEqual([{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }]);
|
||||
|
||||
avant = fichiers(m);
|
||||
await glisser(pointChaise(m, 4, 2), pointSol(m));
|
||||
await uneEntree(m, avant.revision);
|
||||
expect(charge(m).reservations).toEqual([]);
|
||||
});
|
||||
|
||||
test('une personne réservée glissée vers une chaise libre : le fantôme dit qu’elle déplace sa réservation, et sa portée ; « tous les tours » se déplace à chaque tour', 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"]'));
|
||||
|
||||
let r = fichiers(m).revision;
|
||||
let vers = pointChaise(m, 4, 3);
|
||||
await presserEtBouger(pointChaise(m, 3, 1), vers);
|
||||
expect(etatDuFantome()).toBe('déplace sa réservation, à tous les tours');
|
||||
await pointer('relacher', vers.x, vers.y);
|
||||
await uneEntree(m, r);
|
||||
expect(charge(m).reservations).toContainEqual({ participant: IRIS, table: 4, siege: 3, portee: 'tous', tour: null });
|
||||
|
||||
r = fichiers(m).revision;
|
||||
vers = pointChaise(m, 4, 4);
|
||||
await presserEtBouger(pointChaise(m, 4, 2), vers);
|
||||
expect(etatDuFantome()).toBe('déplace sa réservation de ce tour');
|
||||
await pointer('relacher', vers.x, vers.y);
|
||||
await uneEntree(m, r);
|
||||
expect(charge(m).reservations).toContainEqual({ participant: THEO, table: 4, siege: 4, portee: 'tour', tour: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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)', () => {
|
||||
|
|
@ -964,15 +1165,16 @@ describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)
|
|||
expect(apres.titres).toHaveLength(origine.titres.length + 1);
|
||||
expect(apres.retenu).toEqual(origine.retenu);
|
||||
expect(apres.propositions).toEqual(origine.propositions);
|
||||
expect(m.seance.etat().avertissements).toContainEqual({ code: 'RESERVATION_NON_HONOREE', details: { participant: IRIS, table: 3, tour: 1 } });
|
||||
expect(m.seance.etat().avertissements).toContainEqual({
|
||||
code: 'RESERVATION_NON_HONOREE',
|
||||
details: { participant: IRIS, table: 3, tour: 1, personne: 'Iris Ombrelle', numero: 3 },
|
||||
});
|
||||
await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
// L'avertissement nomme Iris et la table 3 : son texte vient du libellé
|
||||
// d'A2 et du composant des avertissements d'I4, que le correctif
|
||||
// rapports/I11-avertissement-nomme.diff complète. L'épreuve échoue tant
|
||||
// qu'il n'est pas appliqué ; appliqué, test.fails la signale à retourner.
|
||||
test.fails('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3 (attend le correctif d’A2 et d’I4)', async () => {
|
||||
// L'avertissement nomme Iris et la table 3 : ses détails portent le nom
|
||||
// affiché de la personne et le numéro de la table (avertissementsApres).
|
||||
test('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3', async () => {
|
||||
const base = chargeDe({
|
||||
id: 'evt-apres-generation',
|
||||
places: [4, 4, 4],
|
||||
|
|
@ -1385,3 +1587,97 @@ describe('les listes de travail mènent à la place ou à la personne (§ 5.8)',
|
|||
expect(m.ui.etat().choix).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- La mise en évidence d'une personne (§ 7.1, § 8.2, § 8.4) ------------------------
|
||||
|
||||
describe('la mise en évidence d’une personne : posée et ôtée par un geste, en lecture, sans rien écrire (§ 7.1, § 8.4)', () => {
|
||||
// La chaise qui porte la marque de mise en évidence, ou null ; la marque
|
||||
// se voit au style calculé.
|
||||
const marquees = (m) => tous(m, '[data-marque="miseEnEvidence"]');
|
||||
const chaiseMarquee = (m) => {
|
||||
const [marque, ...autres] = marquees(m);
|
||||
expect(autres).toEqual([]);
|
||||
if (marque === undefined) return null;
|
||||
expect(marque.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
|
||||
return marque.closest('[data-siege]').dataset.siege;
|
||||
};
|
||||
// La place qu'occupe une personne au tour montré, « table:siège ».
|
||||
const placeDe = (m, personne) => {
|
||||
for (const table of etatMontre(m).tables) {
|
||||
const chaise = table.chaises.find(({ occupant }) => occupant === personne);
|
||||
if (chaise !== undefined) return `${table.id}:${chaise.siege}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
test('par le menu de la personne : sa chaise marquée, et elle seule, à chaque tour montré ; ôtée par le menu, puis posée et ôtée par la barre de vue', async () => {
|
||||
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
const avant = fichiers(m);
|
||||
await cliquerChaise(m, 2, 1);
|
||||
const personne = etatMontre(m).tables[1].chaises[0].occupant;
|
||||
expect(personne).not.toBeNull();
|
||||
expect(chaiseMarquee(m)).toBeNull();
|
||||
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
|
||||
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
|
||||
expect(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)).toBeNull();
|
||||
// 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(Number(barre.dataset.evidence)).toBe(personne);
|
||||
for (const tour of [2, 3, 1]) {
|
||||
m.ui.montrerTour(tour);
|
||||
flushSync();
|
||||
await images(2);
|
||||
expect(chaiseMarquee(m), `tour ${tour}`).toBe(placeDe(m, personne));
|
||||
}
|
||||
await cliquerChaise(m, 2, 1);
|
||||
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="oterEvidence"]`));
|
||||
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
|
||||
expect(dans(m, '[data-evidence]')).toBeNull();
|
||||
expect(m.ui.etat().miseEnEvidence).toBeNull();
|
||||
|
||||
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
|
||||
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
|
||||
await cliquer(dans(m, '[data-evidence] [data-commande="oterEvidence"]'));
|
||||
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
|
||||
expect(fichiers(m)).toEqual(avant);
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
});
|
||||
|
||||
test('une personne mise en évidence puis supprimée : la barre dit qu’elle n’est plus dans la liste, et offre toujours d’ôter la mise en évidence', async () => {
|
||||
const m = await ouvrir(huitRetenue());
|
||||
const personne = etatMontre(m).tables[1].chaises[0].occupant;
|
||||
m.ui.mettreEnEvidence(personne);
|
||||
flushSync();
|
||||
await m.seance.executer('supprimerParticipant', { id: personne });
|
||||
flushSync();
|
||||
await images(2);
|
||||
const barre = dans(m, '[data-evidence]');
|
||||
expect(barre).not.toBeNull();
|
||||
expect(texteDe(barre)).toContain('une personne qui n’est plus dans la liste');
|
||||
await cliquer(barre.querySelector('[data-commande="oterEvidence"]'));
|
||||
expect(dans(m, '[data-evidence]')).toBeNull();
|
||||
});
|
||||
|
||||
test('depuis sa ligne de la grille des participants : « Voir sur le plan » ouvre le plan, sa chaise marquée ; rien d’écrit', async () => {
|
||||
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
|
||||
const avant = fichiers(m);
|
||||
const personne = etatMontre(m).tables[2].chaises[1].occupant;
|
||||
m.ui.allerA('participants');
|
||||
flushSync();
|
||||
const voir = await attendre(() => {
|
||||
const bouton = dans(m, `[data-participant="${personne}"] [data-commande="voirSurLePlan"]`);
|
||||
expect(bouton).not.toBeNull();
|
||||
return bouton;
|
||||
});
|
||||
await cliquer(voir);
|
||||
await attendre(() => expect(m.ui.etat().section).toBe('plan'));
|
||||
await attendre(() => expect(tous(m, '[data-table]').length).toBe(8));
|
||||
await attendre(() => expect(chaiseMarquee(m)).toBe('3:2'));
|
||||
expect(m.ui.etat().miseEnEvidence).toBe(personne);
|
||||
expect(fichiers(m)).toEqual(avant);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
//
|
||||
// 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
|
||||
// SectionPlan calcule pour le plan et les panneaux.
|
||||
//
|
||||
// Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul
|
||||
// écouteur de chaque type, sur la racine : pointerdown identifie la source
|
||||
|
|
@ -42,9 +44,10 @@
|
|||
// aucune mise en page ; pointerup achève, par au plus une commande de la
|
||||
// séance ; pointercancel, Échap — écouté sur document, en phase de
|
||||
// capture, le temps du geste — et le passage en lecture (surAnnulation)
|
||||
// restaurent. Pendant le glissement, le composant écrit l'attribut
|
||||
// transform directement — groupe de la vue, groupe d'une table et ses
|
||||
// frères, fantôme — et ne confie le résultat au modèle qu'au relâcher :
|
||||
// restaurent. Pendant un geste, la molette ne zoome pas : le geste se
|
||||
// juge sur la vue de l'appui. Pendant le glissement, le composant écrit
|
||||
// l'attribut transform directement — groupe de la vue, groupe d'une table
|
||||
// et ses frères, fantôme — et ne confie le résultat au modèle qu'au relâcher :
|
||||
// aucun élément capturant n'est remplacé. La poignée passe son nombre au
|
||||
// modèle de dessin (option poignee), sans rien écrire. Un refus — du
|
||||
// dépôt, du contrôle de la poignée, de la commande — se pose sur la place
|
||||
|
|
@ -86,13 +89,12 @@
|
|||
// suivant.
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { controlerCapacite } from '../../application/capacite.js';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
|
||||
import BandeauPlan from './BandeauPlan.svelte';
|
||||
import BarreVue from './BarreVue.svelte';
|
||||
import { toucheDuPlan, vueQuiMontre } from './clavier.js';
|
||||
import { deposer } from './depot.js';
|
||||
import { deposer, reservationTenue } from './depot.js';
|
||||
import EchellePlan from './EchellePlan.svelte';
|
||||
import Fantome from './Fantome.svelte';
|
||||
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
|
||||
|
|
@ -104,14 +106,14 @@
|
|||
cadreDepuisRect,
|
||||
englobantDuTour,
|
||||
nomDeCharge,
|
||||
tourAffiche,
|
||||
sourceMontree,
|
||||
vueApresCadre,
|
||||
vueInitiale,
|
||||
zoomerBouton,
|
||||
zoomerMolette,
|
||||
} from './vue_plan.js';
|
||||
|
||||
let { diagnostic = null } = $props();
|
||||
let { diagnostic = null, etatTour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -148,12 +150,17 @@
|
|||
|
||||
const charge = $derived($seance.charge);
|
||||
const tours = $derived(charge.evenement.tours);
|
||||
const tour = $derived(tourAffiche($ui.tour, tours));
|
||||
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etat = $derived(etatTour);
|
||||
const tour = $derived(etat.tour);
|
||||
const englobantPlan = $derived(englobantDuTour(etat));
|
||||
const nomDe = $derived(nomDeCharge(charge));
|
||||
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));
|
||||
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);
|
||||
const modele = $derived(
|
||||
|
|
@ -378,6 +385,12 @@
|
|||
// retirerait le transform d'un second glissement de la même table.
|
||||
let commandeEnCours = false;
|
||||
|
||||
// Vrai de l'appui d'un geste à son relâcher ou à son annulation : la
|
||||
// machine juge le geste entier sur la vue relevée à l'appui, et la
|
||||
// molette ne zoome pas pendant ce temps — le point du relâcher se
|
||||
// convertirait dans un autre repère que celui où le fantôme le montre.
|
||||
let gesteEnCours = false;
|
||||
|
||||
// Confie la commande d'un geste à la séance ; un refus se pose sur la
|
||||
// place. La cible apres se restaure une fois la charge rendue : la table
|
||||
// se dessine alors à sa nouvelle position, ou revient.
|
||||
|
|
@ -421,6 +434,7 @@
|
|||
});
|
||||
const surMolette = (evenement) => {
|
||||
evenement.preventDefault();
|
||||
if (gesteEnCours) return;
|
||||
poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement));
|
||||
};
|
||||
observateur.observe(element);
|
||||
|
|
@ -456,6 +470,7 @@
|
|||
function appliquer({ etat: suivant, effets }) {
|
||||
const pointeur = etatGeste.pointeur;
|
||||
etatGeste = suivant;
|
||||
gesteEnCours = suivant.phase !== 'repos';
|
||||
// Un appui devenu glissement — au-delà du seuil, que la machine juge —
|
||||
// n'est plus une pression longue.
|
||||
if (suivant.phase === 'glisse') oublierPression();
|
||||
|
|
@ -480,7 +495,10 @@
|
|||
else for (const cible of elementsDeTable(effet.cible.table)) cible.setAttribute('transform', effet.transform);
|
||||
break;
|
||||
case 'montrerFantome':
|
||||
fantome = { nom: nomDe(effet.participant), cle: cleFantome(etat.cible) };
|
||||
fantome = {
|
||||
nom: nomDe(effet.participant),
|
||||
cle: cleFantome(etat.cible, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null),
|
||||
};
|
||||
break;
|
||||
case 'montrerPlaces':
|
||||
poignee = { table: effet.table, places: effet.places };
|
||||
|
|
@ -678,6 +696,7 @@
|
|||
oublierImage();
|
||||
document.removeEventListener('keydown', surTouche, true);
|
||||
if (etatGeste.phase !== 'repos') seance.finirGeste();
|
||||
gesteEnCours = false;
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -693,7 +712,7 @@
|
|||
</script>
|
||||
|
||||
<div class="plan">
|
||||
<BarreVue {tour} {tours} {zoomer} {ajuster} />
|
||||
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} {source} />
|
||||
<div class="surface" data-surface bind:this={surface}>
|
||||
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
|
||||
<!-- Le plan est un composant interactif à deux dimensions, que le
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@
|
|||
// chacun avec la commande de son remède, puis chaque avertissement. La
|
||||
// place le désigne par aria-describedby, vers id.
|
||||
//
|
||||
// Posé sur le plan, le message couvre d'autres places : seuls ses
|
||||
// contrôles reçoivent le pointeur. Un appui sur le texte va au plan
|
||||
// dessous, dont l'appui retire la pose et saisit ce qui est visé — le
|
||||
// refus se ferme au geste suivant sans lui prendre sa cible.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -42,6 +47,7 @@
|
|||
.refus-place {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
|
@ -57,6 +63,10 @@
|
|||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.refus-place :global(button) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@
|
|||
// d'une liste vers le plan : la place se choisit ensuite sur le plan. Un
|
||||
// changement de tour change la liste et oublie le choix (etat_ui.js) ;
|
||||
// Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi.
|
||||
// Le panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
// Le panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des
|
||||
// places du tour affiché, que SectionPlan calcule, ou null sans événement.
|
||||
import { getContext } from 'svelte';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -22,8 +24,8 @@
|
|||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const reserve = $derived(charge === null ? [] : etatPlaces(charge, { tour, proposition: $ui.proposition }).reserve);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
const reserve = $derived(etatTour === null ? [] : etatTour.reserve);
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const choix = $derived($ui.choix);
|
||||
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;
|
||||
|
|
|
|||
|
|
@ -6,32 +6,44 @@
|
|||
// sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact,
|
||||
// dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ;
|
||||
// les listes de travail, la réserve, le panneau d'une place et celui
|
||||
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte : la
|
||||
// section ne leur passe rien. Le plan se remonte pour chaque événement :
|
||||
// un autre événement repart de sa propre vue, mémorisée ou ajustée.
|
||||
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte.
|
||||
//
|
||||
// L'état des places du tour affiché (etatPlaces, A5) se calcule ici, une
|
||||
// fois par changement de la charge, du tour affiché ou de la proposition
|
||||
// montrée — chacun un $derived, qu'un autre changement de l'état de
|
||||
// navigation ne relance pas —, et se passe au plan, à la réserve et aux
|
||||
// panneaux de la place et de la table (etatTour) : aucun ne le recalcule.
|
||||
// Le plan se remonte pour chaque événement : un autre événement repart de
|
||||
// sa propre vue, mémorisée ou ajustée.
|
||||
//
|
||||
// En plein écran, le titre et les panneaux s'effacent : le plan prend la
|
||||
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
|
||||
// cadre l'entoure toujours (§ 8.4).
|
||||
import { getContext } from 'svelte';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import ListesTravail from './ListesTravail.svelte';
|
||||
import PanneauPlace from './PanneauPlace.svelte';
|
||||
import PanneauTable from './PanneauTable.svelte';
|
||||
import Plan from './Plan.svelte';
|
||||
import Reserve from './Reserve.svelte';
|
||||
import { tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id);
|
||||
const charge = $derived($seance.charge);
|
||||
const evenement = $derived(charge === null ? null : charge.evenement.id);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const proposition = $derived($ui.proposition);
|
||||
const etatTour = $derived(charge === null ? null : etatPlaces(charge, { tour, proposition }));
|
||||
// Le diagnostic de la charge, que Plan donne au modèle de dessin : quand
|
||||
// toutes les tables sont en conflit, le bandeau du plan en énonce les
|
||||
// planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et
|
||||
// le bandeau ne porte que la phrase.
|
||||
const diagnostic = $derived($seance.charge === null ? null : diagnostiquerCharge($seance.charge).diagnostic);
|
||||
const diagnostic = $derived(charge === null ? null : diagnostiquerCharge(charge).diagnostic);
|
||||
</script>
|
||||
|
||||
<section data-section="plan" class="section-plan">
|
||||
|
|
@ -41,7 +53,7 @@
|
|||
<div class="corps">
|
||||
{#if evenement !== null}
|
||||
{#key evenement}
|
||||
<Plan {diagnostic} />
|
||||
<Plan {diagnostic} {etatTour} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if !$ui.pleinEcran}
|
||||
|
|
@ -50,9 +62,9 @@
|
|||
<Diagnostic compact />
|
||||
</div>
|
||||
<ListesTravail />
|
||||
<Reserve />
|
||||
<PanneauPlace />
|
||||
<PanneauTable />
|
||||
<Reserve {etatTour} />
|
||||
<PanneauPlace {etatTour} />
|
||||
<PanneauTable {etatTour} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,13 +13,20 @@
|
|||
// En régime table, les chaises ne se dessinent pas : l'emprise entière se
|
||||
// peint au fond de la chaise et devient la seule cible, sous le plateau.
|
||||
// En régime sièges, chaque chaise est un groupe [data-siege="table:siège"]
|
||||
// dont seul le disque reçoit le pointeur : ses marques, comme la pastille,
|
||||
// ont pointer-events: none, et la cible d'une chaise est le disque même que
|
||||
// la désignation lit (designation.js).
|
||||
// dont la cible reçoit le pointeur : un disque sans peinture, de rayon
|
||||
// rayonCible — max(RAYON_CHAISE, pas / 2), le disque même que la
|
||||
// désignation lit (designation.js) —, dernier enfant du groupe, au-dessus
|
||||
// de la chaise et de ses marques ; les marques, comme la pastille, ont
|
||||
// pointer-events: none. La cible ne porte aucune marque et ne peint rien :
|
||||
// le premier disque du groupe reste la chaise.
|
||||
//
|
||||
// Une table en conflit, quand le plan signale, porte un contour épaissi
|
||||
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
|
||||
// pastille porte le nombre de violations, signalée ou non (§ 7.5).
|
||||
// pastille porte le nombre de violations, signalée ou non (§ 7.5). Sur la
|
||||
// trame, un socle [data-socle] du fond du plateau, centré sous la
|
||||
// pastille, la sépare des traits de la trame : la pastille se mesure
|
||||
// contre le fond du plateau (§ 14.5), et la trame reste peinte autour.
|
||||
// Le socle précède la marque de gel, qui se peint sur lui.
|
||||
//
|
||||
// Le groupe de la table porte id="table-<id>", celui d'une chaise
|
||||
// id="siege-<table>-<siège>" : la racine du plan, seul arrêt de tabulation,
|
||||
|
|
@ -39,6 +46,7 @@
|
|||
ECART_FOCUS,
|
||||
ECART_MISE_EN_EVIDENCE,
|
||||
RAYON_PASTILLE,
|
||||
RAYON_SOCLE_PASTILLE,
|
||||
TAILLE_TEXTE_PASTILLE_CM,
|
||||
TRACE_GEL,
|
||||
formesTable,
|
||||
|
|
@ -87,6 +95,7 @@
|
|||
{:else}
|
||||
<rect class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
|
||||
{/if}
|
||||
<circle class="socle" data-socle cx={formes.pastille.x} cy={formes.pastille.y} r={RAYON_SOCLE_PASTILLE} />
|
||||
{/if}
|
||||
{#if table.gelee}
|
||||
<path class="gel" data-marque="gel" d={TRACE_GEL} transform="translate({formes.gel.x},{formes.gel.y})" />
|
||||
|
|
@ -133,6 +142,7 @@
|
|||
{#if chaise.focus}
|
||||
<rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} />
|
||||
{/if}
|
||||
<circle class="cible" cx={chaise.x} cy={chaise.y} r={table.geometrie.rayonCible} />
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
|
|
@ -180,11 +190,25 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.socle {
|
||||
fill: var(--couleur-table-fond);
|
||||
stroke: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gel {
|
||||
fill: var(--couleur-gel);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* La cible d'un siège : aucune peinture, le pointeur la reçoit tout de
|
||||
même. */
|
||||
.cible {
|
||||
fill: none;
|
||||
stroke: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.chaise {
|
||||
fill: var(--couleur-chaise-fond);
|
||||
stroke: var(--couleur-chaise-trait);
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () =
|
|||
});
|
||||
|
||||
describe('le cadrage (§ 8.5, § 13.3)', () => {
|
||||
test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en lecture, puis sur un plan bloqué', async () => {
|
||||
test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en écriture, en lecture, puis sur un plan bloqué', async () => {
|
||||
const m = await petite();
|
||||
const verifier = async (gestes) => {
|
||||
const avant = fichiers(m);
|
||||
|
|
@ -590,15 +590,24 @@ describe('le cadrage (§ 8.5, § 13.3)', () => {
|
|||
const lue = vueLue(m);
|
||||
expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue);
|
||||
};
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
await verifier(async () => {
|
||||
const zooms = async () => {
|
||||
await cran(m, -100);
|
||||
await cran(m, -100);
|
||||
cliquer(m, 'zoomer');
|
||||
cliquer(m, 'dezoomer');
|
||||
cliquer(m, 'ajuster');
|
||||
await cran(m, 100);
|
||||
});
|
||||
};
|
||||
// Le cas de base : l'écriture, sur un événement que rien ne bloque.
|
||||
await m.seance.passerEnEcriture();
|
||||
flushSync();
|
||||
expect(m.seance.etat().mode).toBe('ecriture');
|
||||
expect(m.seance.etat().evenement.etat).not.toBe('bloque');
|
||||
await verifier(zooms);
|
||||
await m.seance.passerEnLecture();
|
||||
flushSync();
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
await verifier(zooms);
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@
|
|||
// le geste modifie, et la commande juge la pose et la capacité.
|
||||
import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js';
|
||||
|
||||
/**
|
||||
* Codes d'un refus que le dépôt lève lui-même, leur texte dans
|
||||
* src/application/libelles/places.js :
|
||||
* RESERVATION_TOUS_LES_TOURS {participant, table, remede} : sous la cible
|
||||
* 'reservations', une personne que sa réservation « tous les tours » tient
|
||||
* à sa chaise, déposée hors des tables ; remède { commande:
|
||||
* 'retirerReservation', arguments: {participant, table, portee: 'tous',
|
||||
* tour: null} }.
|
||||
*/
|
||||
|
||||
// La chaise d'une table de l'état des places, ou undefined.
|
||||
function chaiseDe(etatPlaces, { table, siege }) {
|
||||
return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1];
|
||||
|
|
@ -23,15 +33,38 @@ function premierLibre(etatPlaces, table, libre) {
|
|||
return chaise === undefined ? null : chaise.siege;
|
||||
}
|
||||
|
||||
/**
|
||||
* La réservation qui tient une personne à sa chaise d'origine, telle que
|
||||
* l'état des places la porte — {participant, portee, tour} —, sinon null :
|
||||
* pas de chaise d'origine, une chaise que rien ne tient, ou que la
|
||||
* réservation d'une autre tient. Le dépôt et le libellé du fantôme la lisent
|
||||
* ici tous deux.
|
||||
*
|
||||
* @param {Object} etatPlaces EtatPlaces du tour affiché
|
||||
* @param {number} personne
|
||||
* @param {{table: number, siege: number}|null} depuis
|
||||
* @returns {{participant: number, portee: 'tous'|'tour', tour: number|null}|null}
|
||||
*/
|
||||
export function reservationTenue(etatPlaces, personne, depuis) {
|
||||
const reservation = depuis === null ? null : (chaiseDe(etatPlaces, depuis)?.reservation ?? null);
|
||||
return reservation?.participant === personne ? reservation : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat,
|
||||
* « Source ») :
|
||||
* - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte
|
||||
* pas — : une personne que sa chaise d'origine tient réservée déplace sa
|
||||
* réservation (deplacerReservation), qui garde portée et tour ; une
|
||||
* réservation (deplacerReservation), qui garde portée et tour — le
|
||||
* fantôme le dit pendant le geste, « tous les tours » compris ; une
|
||||
* autre reçoit une réservation de portée « tour » au tour affiché
|
||||
* (reserver) ; sur le sol, la réservation glissée part
|
||||
* (retirerReservation), et rien pour une personne non réservée. Un siège
|
||||
* (reserver) ; sur le sol, la réservation « ce tour » glissée part
|
||||
* (retirerReservation), et rien pour une personne non réservée. Une
|
||||
* réservation « tous les tours » ne part pas : un geste fait à un tour ne
|
||||
* 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 ;
|
||||
* - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une
|
||||
|
|
@ -69,11 +102,12 @@ export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, sur
|
|||
return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } };
|
||||
}
|
||||
|
||||
const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis);
|
||||
const tenue = origine?.reservation?.participant === personne ? origine.reservation : null;
|
||||
const tenue = reservationTenue(etatPlaces, personne, depuis);
|
||||
if (cible === null) {
|
||||
if (tenue === null) return null;
|
||||
return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
|
||||
const retrait = { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
|
||||
if (tenue.portee !== 'tous') return retrait;
|
||||
return { refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: personne, table: depuis.table, remede: retrait } } };
|
||||
}
|
||||
const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null);
|
||||
if (tenue !== null) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { appliquerCommande } from '../../application/commandes.js';
|
|||
import { etatPlaces } from '../../application/places.js';
|
||||
import { geometrieTable } from '../../geometrie/tables.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { commandeDeDepot, deposer } from './depot.js';
|
||||
import { commandeDeDepot, deposer, reservationTenue } from './depot.js';
|
||||
|
||||
const NOMS = [
|
||||
['Ombrelle', 'Iris'],
|
||||
|
|
@ -114,6 +114,39 @@ describe('commandeDeDepot sous la cible « reservations » : avant la retenue (
|
|||
});
|
||||
});
|
||||
|
||||
test('reservationTenue : la réservation qui tient la personne à sa chaise d’origine, sinon null', () => {
|
||||
// Le fantôme et le dépôt la lisent de la même fonction : le libellé du
|
||||
// geste dit la portée que deplacerReservation garde.
|
||||
const tous = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
|
||||
const etat = etatPlaces(tous, { tour: 2, proposition: 1 });
|
||||
assert.deepEqual(reservationTenue(etat, 1, { table: 1, siege: 1 }), { participant: 1, portee: 'tous', tour: null });
|
||||
assert.equal(reservationTenue(etat, 2, { table: 1, siege: 1 }), null);
|
||||
assert.equal(reservationTenue(etat, 1, { table: 2, siege: 1 }), null);
|
||||
assert.equal(reservationTenue(etat, 1, null), null);
|
||||
const tour = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
|
||||
assert.deepEqual(reservationTenue(etatPlaces(tour, { tour: 1, proposition: 1 }), 1, { table: 1, siege: 1 }), {
|
||||
participant: 1,
|
||||
portee: 'tour',
|
||||
tour: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('sur le sol, une réservation « tous les tours » ne se retire pas : { refus } RESERVATION_TOUS_LES_TOURS, son remède retirerReservation de portée « tous », que la charge exécute', () => {
|
||||
// Un geste fait à un tour ne change jamais en silence les autres tours :
|
||||
// la réservation se défait par sa propre commande (§ 7.1).
|
||||
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
|
||||
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
|
||||
for (const tour of [1, 2]) {
|
||||
const etat = etatPlaces(charge, { tour, proposition: 1 });
|
||||
assert.equal(etat.cible, 'reservations');
|
||||
assert.deepEqual(depot(etat, { table: 1, siege: 1 }, null, true), {
|
||||
refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: 1, table: 1, remede } },
|
||||
});
|
||||
}
|
||||
const apres = executer(charge, remede.commande, remede.arguments);
|
||||
assert.deepEqual(apres.reservations, []);
|
||||
});
|
||||
|
||||
test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => {
|
||||
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
|
||||
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
|
||||
|
|
|
|||
|
|
@ -232,17 +232,34 @@ const CLES_FANTOME = new Map([
|
|||
[null, 'plan.fantome.vueSeule'],
|
||||
]);
|
||||
|
||||
// Sous la cible 'reservations', la clé d'une personne que sa réservation
|
||||
// tient à sa chaise, par portée : son dépôt sur une chaise est
|
||||
// deplacerReservation, qui garde la portée.
|
||||
const CLES_RESERVATION_TENUE = new Map([
|
||||
['tour', 'plan.fantome.reservationTour'],
|
||||
['tous', 'plan.fantome.reservationTous'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La clé du libellé que le fantôme porte pendant le glissement d'une
|
||||
* personne (§ 5.8) : « pose une réservation » sous la cible 'reservations',
|
||||
* « déplace dans le placement retenu » sous 'retenu', la vue seule sous une
|
||||
* cible null. Une autre valeur lève : elle ne prend aucun libellé par défaut.
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cleFantome(cible) {
|
||||
export function cleFantome(cible, portee = null) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,6 +331,23 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
|
|||
for (const cible of ['reservations', 'retenu', null]) assert.equal(LIBELLES.has(cleFantome(cible)), true);
|
||||
});
|
||||
|
||||
test('cleFantome : sous les réservations, une personne réservée déplace sa réservation, et le libellé en dit la portée', () => {
|
||||
// Le dépôt d'une personne réservée exécute deplacerReservation, qui garde
|
||||
// la portée : « tous les tours » se dit pendant le geste (§ 5.8, § 7.1).
|
||||
assert.equal(cleFantome('reservations', null), 'plan.fantome.reservations');
|
||||
assert.equal(cleFantome('reservations', 'tour'), 'plan.fantome.reservationTour');
|
||||
assert.equal(cleFantome('reservations', 'tous'), 'plan.fantome.reservationTous');
|
||||
for (const portee of [null, 'tour', 'tous']) {
|
||||
assert.equal(cleFantome('retenu', portee), 'plan.fantome.retenu');
|
||||
assert.equal(cleFantome(null, portee), 'plan.fantome.vueSeule');
|
||||
assert.equal(LIBELLES.has(cleFantome('reservations', portee)), true);
|
||||
}
|
||||
assert.match(LIBELLES.get(cleFantome('reservations', 'tous')), /tous les tours/);
|
||||
for (const portee of ['Tous', 'tours', '', 0]) {
|
||||
assert.throws(() => cleFantome('reservations', portee), { name: 'TypeError', message: /^portée 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/ });
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export const LARGEUR_ECHELLE_PX = 120;
|
|||
/** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel
|
||||
* d'une table de 2, la plus petite. */
|
||||
export const RAYON_PASTILLE = 15;
|
||||
/** Rayon du socle de la pastille, en cm de dessin : un disque du fond du
|
||||
* plateau, posé sur la trame d'une table signalée, sous la pastille, qui
|
||||
* l'en sépare (§ 14.5). Plus petit que le plateau le plus petit, de rayon
|
||||
* PLANCHER_DIAMETRE / 2 : la trame reste peinte autour. */
|
||||
export const RAYON_SOCLE_PASTILLE = 20;
|
||||
/** Corps du chiffre de la pastille, en cm de dessin. */
|
||||
export const TAILLE_TEXTE_PASTILLE_CM = 18;
|
||||
|
||||
|
|
@ -468,15 +473,16 @@ export function actionsPlace({ reservation, titre }, choix) {
|
|||
* montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un
|
||||
* refus (retrait non null) ; réserver ici quand elle est assise sur une
|
||||
* place qu'aucune réservation ne tient ; déplacer sa réservation quand la
|
||||
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la
|
||||
* fiche.
|
||||
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; la mettre en
|
||||
* évidence, ou ôter sa mise en évidence quand elle l'est ; ouvrir la fiche.
|
||||
*
|
||||
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne
|
||||
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin
|
||||
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null, enEvidence: boolean}} personne
|
||||
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin ;
|
||||
* enEvidence, vrai quand elle est la personne mise en évidence (ui.miseEnEvidence)
|
||||
* @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci,
|
||||
* deplacerReservation, exclure, reintegrer, ouvrirFiche
|
||||
* deplacerReservation, exclure, reintegrer, mettreEnEvidence, oterEvidence, ouvrirFiche
|
||||
*/
|
||||
export function actionsPersonne({ exclu, chaise, retrait }) {
|
||||
export function actionsPersonne({ exclu, chaise, retrait, enEvidence }) {
|
||||
const assise = chaise !== null;
|
||||
const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
|
||||
return [
|
||||
|
|
@ -487,6 +493,8 @@ export function actionsPersonne({ exclu, chaise, retrait }) {
|
|||
['deplacerReservation', tenue],
|
||||
['exclure', !exclu],
|
||||
['reintegrer', exclu],
|
||||
['mettreEnEvidence', !enEvidence],
|
||||
['oterEvidence', enEvidence],
|
||||
['ouvrirFiche', true],
|
||||
]
|
||||
.filter(([, offerte]) => offerte)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { creerTraduction } from '../../application/traduction.js';
|
|||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { regimeCibles } from '../../geometrie/designation.js';
|
||||
import { geometrieTable } from '../../geometrie/tables.js';
|
||||
import { PLANCHER_DIAMETRE, RAYON_POIGNEE, geometrieTable } from '../../geometrie/tables.js';
|
||||
import { indexerPlan } from '../../moteur/configuration.js';
|
||||
import { conflitsParTable } from '../../moteur/conflits.js';
|
||||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
|
|
@ -26,7 +26,13 @@ import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage
|
|||
import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
ECART_ANNEAU,
|
||||
ECART_DESIGNATION,
|
||||
ECART_FOCUS,
|
||||
ECART_MISE_EN_EVIDENCE,
|
||||
LARGEUR_ECHELLE_PX,
|
||||
RAYON_PASTILLE,
|
||||
RAYON_SOCLE_PASTILLE,
|
||||
actionsPersonne,
|
||||
actionsPlace,
|
||||
ajusterLigne,
|
||||
|
|
@ -708,6 +714,36 @@ describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1,
|
|||
});
|
||||
});
|
||||
|
||||
describe('les marques autour d\u{2019}une table laissent la liste de noms à découvert (§ 7.4, § 14.5)', () => {
|
||||
// Le trait du carré du focus fait 3 px à l'écran (TablePlan) : sa moitié
|
||||
// extérieure couvre au plus 1,5 px, soit 1,5 / k cm, et les listes ne se
|
||||
// rendent qu'à k ≥ K_LISTES.
|
||||
const DEMI_TRAIT_FOCUS_CM = 1.5 / K_LISTES;
|
||||
|
||||
test('le focus, de la table ou de sa chaise la plus à droite, et la poignée finissent avant le bloc de noms', () => {
|
||||
for (const forme of ['ronde', 'carree']) {
|
||||
for (const places of [2, 3, 5, 8, 12, 16]) {
|
||||
const g = geometrieTable({ id: 1, numero: 1, forme, position: { x: 40, y: -70 } }, places);
|
||||
const droite = Math.max(...g.chaises.map((chaise) => chaise.x + chaise.rayon));
|
||||
const bords = {
|
||||
'focus de la table': g.centre.x + g.rayonEmprise + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM,
|
||||
'focus de la chaise': droite + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM,
|
||||
poignée: g.poignee.x + RAYON_POIGNEE,
|
||||
};
|
||||
for (const [quoi, bord] of Object.entries(bords)) {
|
||||
assert.ok(bord < g.origineListe.x, `${forme} de ${places} : ${quoi} jusqu'à ${bord}, liste dès ${g.origineListe.x}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('les autres marques d\u{2019}une chaise restent sous le focus ; le socle de la pastille, entre elle et le plus petit plateau', () => {
|
||||
for (const ecart of [ECART_ANNEAU, ECART_MISE_EN_EVIDENCE, ECART_DESIGNATION]) assert.ok(ecart < ECART_FOCUS, String(ecart));
|
||||
assert.ok(RAYON_PASTILLE < RAYON_SOCLE_PASTILLE);
|
||||
assert.ok(RAYON_SOCLE_PASTILLE < PLANCHER_DIAMETRE / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => {
|
||||
test('chaque partie présente, nommée ; les absentes omises', () => {
|
||||
const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null };
|
||||
|
|
@ -929,25 +965,42 @@ describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => {
|
|||
const assise = { siege: 2, occupant: 4, reservation: null, titre: null };
|
||||
const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } };
|
||||
|
||||
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [
|
||||
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, mettre en évidence, ouvrir la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait, enEvidence: false }), [
|
||||
'deplacer',
|
||||
'echanger',
|
||||
'retirerDuTour',
|
||||
'reserverIci',
|
||||
'exclure',
|
||||
'mettreEnEvidence',
|
||||
'ouvrirFiche',
|
||||
]);
|
||||
});
|
||||
|
||||
test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => {
|
||||
const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } };
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']);
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null, enEvidence: false }), [
|
||||
'deplacer',
|
||||
'echanger',
|
||||
'deplacerReservation',
|
||||
'exclure',
|
||||
'mettreEnEvidence',
|
||||
'ouvrirFiche',
|
||||
]);
|
||||
});
|
||||
|
||||
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']);
|
||||
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']);
|
||||
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, la mise en évidence et la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'exclure', 'mettreEnEvidence', 'ouvrirFiche']);
|
||||
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'reintegrer', 'mettreEnEvidence', 'ouvrirFiche']);
|
||||
});
|
||||
|
||||
test('mise en évidence (§ 8.4) : la personne mise en évidence offre de l\u{2019}ôter, à la même place, et rien d\u{2019}autre ne change', () => {
|
||||
for (const [chaise, sonRetrait] of [[assise, retrait], [null, null]]) {
|
||||
const sans = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: false });
|
||||
const avec = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: true });
|
||||
assert.equal(sans.filter((action) => action === 'mettreEnEvidence').length, 1);
|
||||
assert.deepEqual(avec, sans.map((action) => (action === 'mettreEnEvidence' ? 'oterEvidence' : action)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1005,11 +1058,11 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
|
|||
const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } };
|
||||
const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
|
||||
const menu = new Set([
|
||||
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }),
|
||||
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }),
|
||||
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {}, enEvidence: false }),
|
||||
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {}, enEvidence: true }),
|
||||
]);
|
||||
assert.equal(actions.size, 7);
|
||||
assert.equal(menu.size, 8);
|
||||
assert.equal(menu.size, 10);
|
||||
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
|
||||
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
|
||||
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');
|
||||
|
|
|
|||
|
|
@ -127,3 +127,36 @@ export function nomDeCharge(charge) {
|
|||
const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)]));
|
||||
return (id) => noms.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la barre de vue dit du placement que le plan montre (§ 2.1, étape
|
||||
* 11 ; § 5.8), lu sur l'état des places du tour : le placement retenu, les
|
||||
* seules réservations — ou un retenu qui ne se lit pas, sous lequel le plan
|
||||
* montre les réservations —, une proposition dont les gestes posent des
|
||||
* réservations (avant la retenue), ou une proposition en vue seule (après).
|
||||
* Quand une proposition est demandée (ui.proposition), la barre offre de ne
|
||||
* plus la montrer : revenir au retenu quand il existe, aux réservations
|
||||
* sinon ; une proposition demandée qu'un tour ne porte pas laisse le plan
|
||||
* sur sa source réelle, et la commande reste offerte.
|
||||
*
|
||||
* @param {{source: string, proposition: number|null, cible: string|null, refus: Object|null}} etat
|
||||
* EtatPlaces du tour affiché
|
||||
* @param {number|null} demandee la proposition demandée au plan
|
||||
* @param {boolean} retenu la charge porte un placement retenu
|
||||
* @returns {{libelle: {cle: string, details: Object}, revenir: string|null}}
|
||||
* revenir : la clé du libellé de la commande, null sans proposition demandée
|
||||
* @throws {TypeError} pour une source hors du contrat
|
||||
*/
|
||||
export function sourceMontree(etat, demandee, retenu) {
|
||||
const revenir = demandee === null ? null : retenu ? 'plan.source.revenirRetenu' : 'plan.source.revenirReservations';
|
||||
const avec = (cle, details = {}) => ({ libelle: { cle, details }, revenir });
|
||||
if (etat.source === 'retenu') return avec('plan.source.retenu');
|
||||
if (etat.source === 'proposition') {
|
||||
const cle = etat.cible === 'reservations' ? 'plan.source.propositionReservations' : 'plan.source.propositionVueSeule';
|
||||
return avec(cle, { proposition: etat.proposition });
|
||||
}
|
||||
if (etat.source === 'reservations') {
|
||||
return avec(etat.refus?.code === 'RETENU_FAUTIF' ? 'plan.source.retenuFautif' : 'plan.source.reservations');
|
||||
}
|
||||
throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
cadreDepuisRect,
|
||||
englobantDuTour,
|
||||
nomDeCharge,
|
||||
sourceMontree,
|
||||
tourAffiche,
|
||||
toursOfferts,
|
||||
vueApresCadre,
|
||||
|
|
@ -249,3 +250,47 @@ describe('l’englobant d’un tour, le tour montré, les noms', () => {
|
|||
assert.equal(nomDe(2), 'Grisaille');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2.1 étape 11, § 5.8)', () => {
|
||||
// Les champs d'EtatPlaces que la barre lit : source, proposition, cible, refus.
|
||||
const etat = (source, { proposition = null, cible = null, refus = null } = {}) => ({ source, proposition, cible, refus });
|
||||
|
||||
test('le retenu, les réservations, un retenu illisible : un libellé chacun, aucune commande sans proposition demandée', () => {
|
||||
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), null, true), {
|
||||
libelle: { cle: 'plan.source.retenu', details: {} },
|
||||
revenir: null,
|
||||
});
|
||||
assert.deepEqual(sourceMontree(etat('reservations', { cible: 'reservations' }), null, false), {
|
||||
libelle: { cle: 'plan.source.reservations', details: {} },
|
||||
revenir: null,
|
||||
});
|
||||
const fautif = etat('reservations', { refus: { code: 'RETENU_FAUTIF', details: {} } });
|
||||
assert.deepEqual(sourceMontree(fautif, null, true).libelle, { cle: 'plan.source.retenuFautif', details: {} });
|
||||
});
|
||||
|
||||
test('une proposition montrée avant la retenue : ses gestes posent des réservations ; la commande revient aux réservations', () => {
|
||||
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 3, cible: 'reservations' }), 3, false), {
|
||||
libelle: { cle: 'plan.source.propositionReservations', details: { proposition: 3 } },
|
||||
revenir: 'plan.source.revenirReservations',
|
||||
});
|
||||
});
|
||||
|
||||
test('une proposition montrée après la retenue : vue seule ; la commande revient au retenu', () => {
|
||||
const refus = { code: 'PROPOSITION_MONTREE', details: { proposition: 2, remede: null } };
|
||||
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 2, refus }), 2, true), {
|
||||
libelle: { cle: 'plan.source.propositionVueSeule', details: { proposition: 2 } },
|
||||
revenir: 'plan.source.revenirRetenu',
|
||||
});
|
||||
});
|
||||
|
||||
test('une proposition demandée qu’un tour ne porte pas : le plan dit sa source réelle, et la commande reste offerte', () => {
|
||||
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), 2, true), {
|
||||
libelle: { cle: 'plan.source.retenu', details: {} },
|
||||
revenir: 'plan.source.revenirRetenu',
|
||||
});
|
||||
});
|
||||
|
||||
test('une source hors du contrat lève', () => {
|
||||
assert.throws(() => sourceMontree(etat('autre'), null, false), TypeError);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,17 @@
|
|||
// libellé de confirmation, un premier clic arme la commande — son libellé
|
||||
// devient celui de confirmer — et seul un second exécute ; armée, elle se
|
||||
// désarme au terme de DELAI_CONFIRMATION_MS, par l'horloge du contexte.
|
||||
// La machine est celle de commande.js.
|
||||
// La machine est celle de commande.js : le clic répété d'une série
|
||||
// — detail supérieur à 1 — est sans effet (§ 8.5). Le clavier n'en porte
|
||||
// pas : un appui d'Entrée ou d'Espace que la répétition automatique émet
|
||||
// — keydown dont repeat est vrai — voit son action par défaut empêchée, et
|
||||
// n'active pas le bouton. Une touche tenue n'active la commande qu'une
|
||||
// fois, et ne confirme jamais celle qu'elle vient d'armer.
|
||||
//
|
||||
// Pendant qu'une exécution qui rend une promesse s'achève, la commande est
|
||||
// occupée : un clic ne fait rien, et le bouton porte aria-busy et
|
||||
// aria-disabled, dessiné d'un filet tireté. Une exécution sans promesse
|
||||
// rend la commande aussitôt.
|
||||
//
|
||||
// Un refus — une erreur qui porte un code — s'affiche à côté du bouton,
|
||||
// avec son remède, et le bouton reste actif : jamais un bouton grisé muet.
|
||||
|
|
@ -27,6 +37,7 @@
|
|||
const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) });
|
||||
|
||||
let armee = $state(false);
|
||||
let enCours = $state(false);
|
||||
let refus = $state(null);
|
||||
let minuterie = null;
|
||||
|
||||
|
|
@ -45,9 +56,10 @@
|
|||
armee = machine.armee;
|
||||
}
|
||||
|
||||
async function cliquer() {
|
||||
const { action } = machine.cliquer();
|
||||
async function cliquer(evenement) {
|
||||
const { action } = machine.cliquer({ repete: evenement.detail > 1 });
|
||||
armee = machine.armee;
|
||||
if (action === 'aucune') return;
|
||||
if (action === 'armer') {
|
||||
minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS);
|
||||
return;
|
||||
|
|
@ -55,18 +67,36 @@
|
|||
desarmer();
|
||||
refus = null;
|
||||
try {
|
||||
await executer();
|
||||
const rendu = executer();
|
||||
if (typeof rendu?.then === 'function') {
|
||||
enCours = true;
|
||||
await rendu;
|
||||
}
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refus = { code: erreur.code, details: erreur.details ?? {} };
|
||||
} finally {
|
||||
machine.terminer();
|
||||
enCours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function surTouche(evenement) {
|
||||
if (evenement.repeat && (evenement.key === 'Enter' || evenement.key === ' ')) evenement.preventDefault();
|
||||
}
|
||||
|
||||
onDestroy(desarmer);
|
||||
</script>
|
||||
|
||||
<div class="commande">
|
||||
<button type="button" data-commande={designation} onclick={cliquer}>{libelle}</button>
|
||||
<button
|
||||
type="button"
|
||||
data-commande={designation}
|
||||
aria-busy={enCours ? 'true' : undefined}
|
||||
aria-disabled={enCours ? 'true' : undefined}
|
||||
onkeydown={surTouche}
|
||||
onclick={cliquer}>{libelle}</button
|
||||
>
|
||||
{#if refus !== null}
|
||||
<Refus {refus} lever={() => (refus = null)} {gestes} />
|
||||
{/if}
|
||||
|
|
@ -91,6 +121,12 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,19 @@
|
|||
// confirmer s'arme au premier clic — son libellé devient celui de la
|
||||
// confirmation — et n'exécute qu'au second ; le composant la désarme au
|
||||
// terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin
|
||||
// n'exécute une commande à confirmer en un clic.
|
||||
// n'exécute une commande à confirmer en un clic (§ 8.5).
|
||||
//
|
||||
// Le clic répété d'une série — celui dont le compte de clics, detail,
|
||||
// dépasse 1 — est sans effet sur toute commande : il n'exécute, n'arme ni ne
|
||||
// confirme rien. Le second clic d'un double-clic tombe souvent sur une autre
|
||||
// commande que le premier, celle qu'a montée l'exécution à la même place
|
||||
// — « Revenir en lecture » à la place de « Modifier » —, et il n'est jamais
|
||||
// une seconde exécution voulue : « Défaire » double-cliqué ne rend qu'un
|
||||
// geste. Armée, une commande le reste ; un clic simple la confirme.
|
||||
//
|
||||
// Une commande n'est pas ré-entrante : de l'exécution lancée à terminer(),
|
||||
// que le composant appelle quand l'action rend la main, tout clic est sans
|
||||
// effet. Un second clic pendant une écriture lente ne relance rien.
|
||||
//
|
||||
// Un remède (contrat « Refus et avertissements ») est null, une commande
|
||||
// exécutable telle quelle, { commande, arguments }, ou un geste,
|
||||
|
|
@ -58,24 +70,37 @@ export const SECTIONS_REMEDE = Object.freeze([
|
|||
*
|
||||
* @param {{confirmer?: string|null}} [options] la clé du libellé de
|
||||
* confirmation ; null pour une commande qui exécute au premier clic
|
||||
* @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}}
|
||||
* cliquer arme une commande à confirmer désarmée, et exécute sinon, en la
|
||||
* désarmant ; expirer la désarme.
|
||||
* @returns {{readonly armee: boolean, readonly enCours: boolean,
|
||||
* cliquer: (clic?: {repete?: boolean}) => ({action: 'armer'}|{action: 'executer'}|{action: 'aucune'}),
|
||||
* terminer: () => void, expirer: () => void}}
|
||||
* cliquer ne fait rien pendant une exécution, ni pour le clic répété
|
||||
* d'une série (repete vrai) ; sinon il arme une commande à confirmer
|
||||
* désarmée, et exécute, en la désarmant et en la marquant en cours ;
|
||||
* terminer la rend aux clics ; expirer la désarme.
|
||||
*/
|
||||
export function creerMachineCommande({ confirmer = null } = {}) {
|
||||
let armee = false;
|
||||
let enCours = false;
|
||||
return {
|
||||
get armee() {
|
||||
return armee;
|
||||
},
|
||||
cliquer() {
|
||||
get enCours() {
|
||||
return enCours;
|
||||
},
|
||||
cliquer({ repete = false } = {}) {
|
||||
if (enCours || repete) return { action: 'aucune' };
|
||||
if (confirmer !== null && !armee) {
|
||||
armee = true;
|
||||
return { action: 'armer' };
|
||||
}
|
||||
armee = false;
|
||||
enCours = true;
|
||||
return { action: 'executer' };
|
||||
},
|
||||
terminer() {
|
||||
enCours = false;
|
||||
},
|
||||
expirer() {
|
||||
armee = false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -65,10 +65,12 @@ describe('commande : la machine', () => {
|
|||
assert.equal(DELAI_CONFIRMATION_MS, 10_000);
|
||||
});
|
||||
|
||||
test('sans confirmer, chaque clic exécute, et la commande ne s’arme jamais', () => {
|
||||
test('sans confirmer, chaque clic exécute une fois l’exécution précédente terminée, et la commande ne s’arme jamais', () => {
|
||||
const commande = creerMachineCommande();
|
||||
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||
commande.terminer();
|
||||
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||
commande.terminer();
|
||||
assert.equal(commande.armee, false);
|
||||
});
|
||||
|
||||
|
|
@ -79,9 +81,55 @@ describe('commande : la machine', () => {
|
|||
assert.equal(commande.armee, true);
|
||||
assert.deepEqual(commande.cliquer(), { action: 'executer' });
|
||||
assert.equal(commande.armee, false);
|
||||
commande.terminer();
|
||||
assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau');
|
||||
});
|
||||
|
||||
test('pendant l’exécution, un clic ne fait rien : ni seconde exécution, ni armement ; terminer() la rend', () => {
|
||||
const simple = creerMachineCommande();
|
||||
assert.deepEqual(simple.cliquer(), { action: 'executer' });
|
||||
assert.equal(simple.enCours, true);
|
||||
assert.deepEqual(simple.cliquer(), { action: 'aucune' });
|
||||
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' });
|
||||
simple.terminer();
|
||||
assert.equal(simple.enCours, false);
|
||||
assert.deepEqual(simple.cliquer(), { action: 'executer' });
|
||||
|
||||
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||
confirmee.cliquer();
|
||||
assert.deepEqual(confirmee.cliquer(), { action: 'executer' });
|
||||
assert.deepEqual(confirmee.cliquer(), { action: 'aucune' }, 'occupée, le clic ne la réarme pas');
|
||||
assert.equal(confirmee.armee, false);
|
||||
confirmee.terminer();
|
||||
assert.deepEqual(confirmee.cliquer(), { action: 'armer' });
|
||||
});
|
||||
|
||||
test('armée, le clic répété d’un double-clic ne confirme pas : elle reste armée, et un clic simple ensuite exécute (§ 8.5)', () => {
|
||||
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'armer' });
|
||||
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' });
|
||||
assert.equal(commande.armee, true);
|
||||
assert.equal(commande.enCours, false);
|
||||
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' }, 'un triple clic non plus');
|
||||
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'executer' });
|
||||
assert.equal(commande.armee, false);
|
||||
});
|
||||
|
||||
test('le clic répété d’une série n’exécute jamais, avec ou sans confirmer : il ne fait rien, et le clic simple suivant exécute', () => {
|
||||
const simple = creerMachineCommande();
|
||||
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'reçu sans le premier clic de sa série');
|
||||
assert.equal(simple.enCours, false);
|
||||
assert.deepEqual(simple.cliquer({ repete: false }), { action: 'executer' });
|
||||
simple.terminer();
|
||||
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le second clic d’un double-clic');
|
||||
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le troisième');
|
||||
assert.deepEqual(simple.cliquer(), { action: 'executer' });
|
||||
|
||||
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||
assert.deepEqual(confirmee.cliquer({ repete: true }), { action: 'aucune' }, 'désarmée, le clic répété ne l’arme pas');
|
||||
assert.equal(confirmee.armee, false);
|
||||
});
|
||||
|
||||
test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => {
|
||||
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
|
||||
commande.cliquer();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { cdp } from 'vitest/browser';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import '../jetons.css';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
|
|
@ -132,6 +133,41 @@ async function cliquerAilleurs() {
|
|||
await tick();
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
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 tick();
|
||||
}
|
||||
|
||||
// Une touche Entrée tenue : un premier appui, puis repetitions appuis que
|
||||
// Chromium marque répétés (autoRepeat, d'où repeat vrai sur keydown), comme
|
||||
// la répétition automatique du clavier, et un seul relâcher. Les événements
|
||||
// vont à l'élément qui a le focus.
|
||||
async function tenirEntree(repetitions) {
|
||||
const entree = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
|
||||
const appui = { type: 'keyDown', ...entree, text: '\r', unmodifiedText: '\r' };
|
||||
await cdp().send('Input.dispatchKeyEvent', appui);
|
||||
for (let n = 0; n < repetitions; n += 1) await cdp().send('Input.dispatchKeyEvent', { ...appui, autoRepeat: true });
|
||||
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...entree });
|
||||
await tick();
|
||||
}
|
||||
|
||||
async function taper(texte) {
|
||||
for (const caractere of texte) await touche(caractere);
|
||||
await tick();
|
||||
|
|
@ -539,6 +575,39 @@ describe('le remède « Débloquer » (§ 8.5)', () => {
|
|||
expect(montage.seance.etat().evenement.etat).toBe('bloque');
|
||||
});
|
||||
|
||||
test('un double-clic arme la commande sans débloquer : elle reste armée, rien d’écrit ; un clic simple ensuite débloque', async () => {
|
||||
const montage = await preparer({ bloque: true });
|
||||
const { message } = await refusAuPremierCaractere2(montage);
|
||||
const bouton = message.querySelector('button');
|
||||
const ecritures = montage.fs.compteurs.ecritures;
|
||||
const clics = [];
|
||||
bouton.addEventListener('click', (evenement) => clics.push(evenement.detail));
|
||||
await doubleCliquer(bouton);
|
||||
expect(clics).toEqual([1, 2]);
|
||||
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
|
||||
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
|
||||
expect(montage.seance.etat().evenement.etat).toBe('bloque');
|
||||
await cliquer(bouton);
|
||||
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
|
||||
});
|
||||
|
||||
test('Entrée tenue arme la commande sans débloquer : les appuis répétés du clavier ne confirment pas, rien d’écrit ; un appui simple ensuite débloque', async () => {
|
||||
const montage = await preparer({ bloque: true });
|
||||
const { message } = await refusAuPremierCaractere2(montage);
|
||||
const bouton = message.querySelector('button');
|
||||
const ecritures = montage.fs.compteurs.ecritures;
|
||||
const appuis = [];
|
||||
bouton.addEventListener('keydown', (evenement) => appuis.push(evenement.repeat));
|
||||
bouton.focus();
|
||||
await tenirEntree(3);
|
||||
expect(montage.seance.etat().evenement.etat).toBe('bloque');
|
||||
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
|
||||
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
|
||||
expect(appuis).toEqual([false, true, true, true]);
|
||||
await tenirEntree(0);
|
||||
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
|
||||
});
|
||||
|
||||
// Le refus d'un plan bloqué posé contre le champ par une frappe.
|
||||
async function refusAuPremierCaractere2(montage) {
|
||||
const { cible, entree } = monterChamp(montage);
|
||||
|
|
@ -824,6 +893,7 @@ 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(montage.seance.etat().charge.participants).toHaveLength(1);
|
||||
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
|
||||
|
|
@ -836,10 +906,32 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
|
|||
// entre elles : la seconde Échap ferme le <dialog> sans que cancel puisse
|
||||
// l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt,
|
||||
// modale et toujours occupée ; la fin de l'action seule décide.
|
||||
async function echapEnRafale(fois) {
|
||||
for (let i = 0; i < fois; i += 1) await touche('Escape');
|
||||
// L'événement close part dans une tâche à lui, après la touche.
|
||||
await new Promise((fin) => setTimeout(fin, 0));
|
||||
//
|
||||
// L'événement close part dans une tâche à lui, après la touche : la rafale
|
||||
// compte les fermetures — l'attribut open retiré, que MutationObserver
|
||||
// signale dès la touche traitée — et les événements close reçus, et attend
|
||||
// que chaque fermeture ait eu le sien. Au moins une Échap ferme la fenêtre.
|
||||
async function echapEnRafale(fenetre, fois) {
|
||||
let fermetures = 0;
|
||||
let evenements = 0;
|
||||
const observateur = new MutationObserver((releves) => {
|
||||
fermetures += releves.filter(({ oldValue }) => oldValue !== null).length;
|
||||
});
|
||||
const surClose = () => {
|
||||
evenements += 1;
|
||||
};
|
||||
observateur.observe(fenetre, { attributes: true, attributeFilter: ['open'], attributeOldValue: true });
|
||||
fenetre.addEventListener('close', surClose);
|
||||
try {
|
||||
for (let i = 0; i < fois; i += 1) await touche('Escape');
|
||||
await vi.waitFor(() => {
|
||||
expect(fermetures).toBeGreaterThan(0);
|
||||
expect(evenements).toBe(fermetures);
|
||||
});
|
||||
} finally {
|
||||
observateur.disconnect();
|
||||
fenetre.removeEventListener('close', surClose);
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
|
||||
|
|
@ -848,6 +940,7 @@ 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(montage.seance.etat().charge.participants).toHaveLength(1);
|
||||
}
|
||||
|
|
@ -857,10 +950,10 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
|
|||
const montage = await preparer();
|
||||
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
|
||||
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
|
||||
await echapEnRafale(fois);
|
||||
await echapEnRafale(fenetre, fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
// Une seconde rafale, la fenêtre déjà rouverte une fois.
|
||||
await echapEnRafale(fois);
|
||||
await echapEnRafale(fenetre, fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
attente.resoudre();
|
||||
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
|
||||
|
|
@ -874,7 +967,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
|
|||
const montage = await preparer();
|
||||
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
|
||||
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
|
||||
await echapEnRafale(fois);
|
||||
await echapEnRafale(fenetre, fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
await montage.seance.passerEnLecture();
|
||||
attente.resoudre();
|
||||
|
|
@ -883,6 +976,7 @@ 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(appels).toEqual(['confirmer']);
|
||||
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
|
||||
|
|
@ -957,6 +1051,52 @@ describe('commande (§ 2.1)', () => {
|
|||
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2));
|
||||
});
|
||||
|
||||
test('pendant son exécution, un second clic réel ne relance rien : le bouton se dit occupé ; finie, un clic l’exécute de nouveau', async () => {
|
||||
const montage = await preparer();
|
||||
let appels = 0;
|
||||
let liberer;
|
||||
const cible = monter(
|
||||
Commande,
|
||||
{
|
||||
cle: 'communs.remede.ajouterParticipant',
|
||||
executer: () => {
|
||||
appels += 1;
|
||||
return new Promise((resoudre) => {
|
||||
liberer = resoudre;
|
||||
});
|
||||
},
|
||||
},
|
||||
montage.contexte,
|
||||
);
|
||||
const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]');
|
||||
const styleRepos = getComputedStyle(bouton).borderTopStyle;
|
||||
await cliquer(bouton);
|
||||
expect(appels).toBe(1);
|
||||
expect(bouton.getAttribute('aria-busy')).toBe('true');
|
||||
expect(bouton.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos);
|
||||
await cliquer(bouton);
|
||||
await doubleCliquer(bouton);
|
||||
expect(appels).toBe(1);
|
||||
liberer();
|
||||
await vi.waitFor(() => expect(bouton.getAttribute('aria-busy')).toBeNull());
|
||||
expect(bouton.getAttribute('aria-disabled')).toBeNull();
|
||||
await cliquer(bouton);
|
||||
expect(appels).toBe(2);
|
||||
liberer();
|
||||
});
|
||||
|
||||
test('une exécution sans promesse rend aussitôt la commande : deux clics de suite, 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);
|
||||
const bouton = cible.querySelector('button');
|
||||
bouton.click();
|
||||
bouton.click();
|
||||
expect(appels).toBe(2);
|
||||
expect(bouton.getAttribute('aria-busy')).toBeNull();
|
||||
});
|
||||
|
||||
test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => {
|
||||
const montage = await preparer();
|
||||
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte);
|
||||
|
|
|
|||
|
|
@ -242,6 +242,42 @@ describe('une table créée (§ 4, § 6.1)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('un geste en cours ne se relance pas (§ 2.1)', () => {
|
||||
test('« Créer une table » cliquée deux fois pendant l’écriture d’un support lent : une table, une entrée', async () => {
|
||||
const m = await preparer(chargeDe({ n: 2 }));
|
||||
const r0 = revision(m);
|
||||
// Le système simulé retient ses écritures jusqu'à liberer() : le geste
|
||||
// reste en cours d'écriture. Le premier geste crée le journal, par une
|
||||
// écriture entière ; les suivants y ajoutent une ligne.
|
||||
let liberer;
|
||||
const porte = new Promise((resoudre) => {
|
||||
liberer = resoudre;
|
||||
});
|
||||
for (const primitive of ['ajouterLigne', 'ecrireAtomique']) {
|
||||
const ecrire = m.fs[primitive];
|
||||
m.fs[primitive] = async (...parametres) => {
|
||||
await porte;
|
||||
return ecrire.apply(m.fs, parametres);
|
||||
};
|
||||
}
|
||||
const bouton = commande(section(m), 'creerTableRonde');
|
||||
bouton.scrollIntoView({ block: 'center' });
|
||||
const boite = bouton.getBoundingClientRect();
|
||||
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
|
||||
for (let clic = 0; clic < 2; clic += 1) {
|
||||
await pointer('presser', x, y);
|
||||
await pointer('relacher', x, y);
|
||||
flushSync();
|
||||
}
|
||||
expect(bouton.getAttribute('aria-busy')).toBe('true');
|
||||
liberer();
|
||||
await attendre(m);
|
||||
expect(charge(m).tables).toHaveLength(1);
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(bouton.getAttribute('aria-busy')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => {
|
||||
test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => {
|
||||
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 }));
|
||||
|
|
@ -354,6 +390,12 @@ describe('changer le défaut (§ 6.1, § 6.2, § 19.3 06)', () => {
|
|||
expect(fenetre()).toBeNull();
|
||||
const refus = enonce(m).querySelector('[data-refus-realigner]');
|
||||
expect(refus).not.toBeNull();
|
||||
// L'en-tête est celui du réalignement refusé, qui laisse « Appliquer »
|
||||
// offert, et non celui d'un défaut que rien n'applique.
|
||||
const tete = refus.querySelector('[data-phrase]');
|
||||
expect(tete?.textContent).toBe(t('tables.defaut.realignementRefuse', { avant: 8, apres: 10 }));
|
||||
expect(visible(tete)).toBe(true);
|
||||
expect(refus.textContent).not.toContain(t('tables.defaut.refusee', { avant: 8, apres: 10 }));
|
||||
expect(refus.textContent).toContain('Table 2');
|
||||
expect(refus.textContent).toContain('11 réservations');
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
|
|
|
|||
1275
src/interface/transversal.navigateur.test.js
Normal file
1275
src/interface/transversal.navigateur.test.js
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue