Compare commits
No commits in common. "fca74b6672e201d801d45129d59c587aeefe898f" and "811cd16b764f55376f1a4cc972ca7c9f3b2ecad3" have entirely different histories.
fca74b6672
...
811cd16b76
119 changed files with 757 additions and 13180 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: ## Construit la page et joue une séance par gestes dans Electron, en mode portable
|
||||
essai_demarrage: ## Lance Electron, lit le titre de sa fenêtre et ferme
|
||||
$(AVEC_NODE) node scripts/essai_demarrage.js
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
|
|
|
|||
|
|
@ -28,13 +28,9 @@
|
|||
// 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 ; 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 ;
|
||||
// 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 ;
|
||||
// - 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
|
||||
|
|
@ -51,7 +47,6 @@ 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';
|
||||
|
|
@ -71,6 +66,9 @@ 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';
|
||||
|
|
@ -136,7 +134,6 @@ 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,
|
||||
|
|
@ -157,7 +154,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
|
|||
numero,
|
||||
sieges: capacite === siegesParDefaut ? null : capacite,
|
||||
forme: 'ronde',
|
||||
position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees },
|
||||
position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM },
|
||||
})),
|
||||
reservations: [...animateurs].map(([table, participant]) => ({
|
||||
participant,
|
||||
|
|
|
|||
|
|
@ -209,22 +209,18 @@ describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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.
|
||||
test('les tables : rondes, en grille rangée par rangée, centres à 300 cm, la dernière rangée seule incomplète', () => {
|
||||
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: 730, 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: 0, y: 300 } },
|
||||
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 730, y: 300 } },
|
||||
{ id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 300, 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], [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.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.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,393 +2,90 @@
|
|||
// 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, 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.
|
||||
// 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.
|
||||
//
|
||||
// node scripts/essai_demarrage.js
|
||||
//
|
||||
// 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.
|
||||
// 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 coquille se lance comme publiée par le lanceur portable dans un dossier
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 : chaque constat s'imprime,
|
||||
// et le premier échec arrête la séance.
|
||||
// Sort à 0 sur un démarrage conforme, à 1 sinon.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { join, relative, resolve } from 'node:path';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decrireSysteme } from '../electron/fichiers.js';
|
||||
import { estSous } from '../src/stockage/dossier_travail.js';
|
||||
import { _electron } from 'playwright';
|
||||
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;
|
||||
|
||||
// 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;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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') };
|
||||
}
|
||||
// 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',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
const cache = join(RACINE, 'node_modules', '.cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const publie = mkdtempSync(join(cache, 'essai-demarrage-'));
|
||||
|
||||
/**
|
||||
* 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'], {
|
||||
const attendu = titreAvecVersion(VERSION.affichee);
|
||||
let conforme = false;
|
||||
try {
|
||||
const application = await _electron.launch({
|
||||
args: [
|
||||
...(sansBacASable ? ['--no-sandbox'] : []),
|
||||
...(sansEcran ? ['--ozone-platform=headless'] : []),
|
||||
'.',
|
||||
],
|
||||
cwd: RACINE,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie },
|
||||
timeout: ATTENTE_MS,
|
||||
});
|
||||
|
||||
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);
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
await application.close();
|
||||
}
|
||||
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);
|
||||
} finally {
|
||||
rmSync(publie, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
|
||||
process.exit(conforme ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
// © 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,16 +574,6 @@ 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,11 +61,10 @@ 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, 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.
|
||||
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.
|
||||
|
||||
Les étapes 8 et 10 — lire le diagnostic, comparer — ne modifient rien et restent
|
||||
vivantes en lecture (§ 8.4).
|
||||
|
|
@ -103,10 +102,9 @@ 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*. 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.
|
||||
« 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.
|
||||
|
||||
**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
|
||||
|
|
@ -341,9 +339,7 @@ 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. Une garde de
|
||||
l'arborescence le tient : dans le moteur, seuls la normalisation de la
|
||||
configuration et le diagnostic lisent les réservations.
|
||||
participants et annote les tables — jamais un second algorithme.
|
||||
|
||||
### 5.3 Les contraintes, activables une à une
|
||||
|
||||
|
|
@ -395,10 +391,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 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.
|
||||
**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.
|
||||
|
||||
> 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
|
||||
|
|
@ -408,16 +404,8 @@ partiellement fixé compte parmi les mobiles.
|
|||
> global désigne un défaut qu'une relance corrige, ou la conséquence de son
|
||||
> propre plan de réservation.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
|
@ -680,12 +668,6 @@ 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
|
||||
|
|
@ -733,18 +715,6 @@ 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.
|
||||
|
|
@ -767,20 +737,15 @@ 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 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.
|
||||
à 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.
|
||||
|
||||
**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, **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.
|
||||
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.
|
||||
|
||||
**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
|
||||
|
|
@ -856,16 +821,10 @@ 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 **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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -881,7 +840,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, et sa chaise reste vide (§ 5.8) |
|
||||
| glisser une personne hors des tables | la retire du tour ; elle attend dans la **réserve** visible |
|
||||
| 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 |
|
||||
|
|
@ -899,13 +858,7 @@ 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é. 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.
|
||||
tour concerné.
|
||||
|
||||
**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
|
||||
|
|
@ -944,10 +897,7 @@ 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. 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.
|
||||
**oracle** fourni par le moteur de rendu.
|
||||
|
||||
**Le zoom se fait sous le curseur**, sinon le contenu fuit vers un coin et
|
||||
l'opérateur poursuit la table qu'il visait.
|
||||
|
|
@ -1005,13 +955,6 @@ 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.
|
||||
|
|
@ -1090,30 +1033,16 @@ 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 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.
|
||||
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.
|
||||
|
||||
**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
|
||||
|
|
@ -1272,14 +1201,6 @@ 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.
|
||||
|
|
@ -1378,9 +1299,7 @@ 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 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.
|
||||
logiciel **dit pourquoi**, une fois, à la première ouverture, sans bloquer.
|
||||
|
||||
**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
|
||||
|
|
@ -1535,8 +1454,7 @@ 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 geste qui l'écrit, jamais à l'ouverture ni au passage en écriture, qui
|
||||
ne réécrit pas le fichier d'état.
|
||||
premier passage en écriture, jamais à l'ouverture.
|
||||
- **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
|
||||
|
|
@ -2424,10 +2342,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
### 12.2 L'en-tête chiffré
|
||||
|
||||
|
|
@ -2535,10 +2452,7 @@ 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. 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.
|
||||
qu'aucun placement ne retire.
|
||||
|
||||
**Les participants sans appartenance ne forment pas un groupe** : sinon un
|
||||
pseudo-groupe de quarante personnes produit la plus longue barre, un plancher
|
||||
|
|
@ -2922,12 +2836,11 @@ 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 trois fichiers — le
|
||||
processus principal, ses primitives de fichiers (`fichiers.js`) 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 deux fichiers — le processus principal 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.
|
||||
|
||||
|
|
@ -2951,24 +2864,16 @@ 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**, 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.
|
||||
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.
|
||||
|
||||
**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
|
||||
|
|
@ -3031,13 +2936,8 @@ 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 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).
|
||||
**La géométrie du plan** est elle aussi un module de **fonctions pures**, séparé
|
||||
du rendu.
|
||||
|
||||
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
|
||||
|
|
@ -3058,15 +2958,12 @@ 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 ; 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.
|
||||
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.
|
||||
|
||||
### 13.5 Ce que le SVG donne et que le canvas ferait payer
|
||||
|
||||
|
|
@ -3145,16 +3042,15 @@ 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 ; 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`, 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: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
|
||||
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 ».
|
||||
deux (§ 14.8) ; lancés ensemble, ils démarrent un navigateur à 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
|
||||
|
|
@ -3197,33 +3093,21 @@ 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 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.
|
||||
traduite est décorée d'une marque : tout ce qui apparaît à l'écran sans sa marque
|
||||
est écrit en dur.
|
||||
|
||||
### 14.7 Le déterminisme
|
||||
|
||||
Aucune source non reproductible — `Math.random`, `Date.now`, `performance.now`,
|
||||
`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.
|
||||
`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.
|
||||
|
||||
**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
|
||||
|
|
@ -3238,25 +3122,22 @@ 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, 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.
|
||||
**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.
|
||||
|
||||
| projet | environnement | ce qu'il inclut | greffon Svelte |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
**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
|
||||
|
|
@ -3311,15 +3192,12 @@ 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 ». 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é.
|
||||
**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 ».
|
||||
|
||||
### 14.10 La carte de ce qui s'éprouve
|
||||
|
||||
|
|
@ -3331,8 +3209,7 @@ n'égale, octet pour octet, un fichier livré.
|
|||
| 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é |
|
||||
| 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 |
|
||||
| interface | navigateur | ce que les gestes produisent, le style calculé, les trois invariants du § 7.2 |
|
||||
|
||||
#### Le moteur
|
||||
|
||||
|
|
@ -3401,15 +3278,14 @@ 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, à 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.
|
||||
`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.
|
||||
|
||||
#### Le stockage
|
||||
|
||||
|
|
@ -3647,25 +3523,16 @@ 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 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.
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
|
@ -4178,9 +4045,7 @@ 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. 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).
|
||||
développement.
|
||||
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).
|
||||
|
|
@ -4322,12 +4187,10 @@ 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 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
|
||||
**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
|
||||
diagnostic porte sur un binaire que personne ne détient.
|
||||
|
||||
### 18.4 Le `CHANGELOG.md`
|
||||
|
|
@ -4458,7 +4321,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`, ou la marque de provenance d'une construction de documentation |
|
||||
| **premier écran** (§ 8.1), liste des événements comme répertoire vide | `version 2026.10.05.01` |
|
||||
| **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 |
|
||||
|
|
@ -4482,7 +4345,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 Windows, plateforme, provenance de la construction, version de format
|
||||
version technique, 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
|
||||
|
|
@ -4500,13 +4363,12 @@ 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, 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).
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -4577,10 +4439,8 @@ 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
|
||||
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.**
|
||||
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.**
|
||||
|
||||
### 19.3 Le parcours, et ce qu'il photographie
|
||||
|
||||
|
|
@ -4688,7 +4548,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, 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 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 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,12 +13,10 @@
|
|||
// 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 { LIBRE, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||
import { verifierInvariants } from '../moteur/verification.js';
|
||||
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
|
||||
import { 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 });
|
||||
|
||||
|
|
@ -47,139 +45,12 @@ function raisonsParPlacement(examen) {
|
|||
return raisons;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
|
||||
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) },
|
||||
};
|
||||
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
|
||||
}
|
||||
|
||||
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
|
||||
|
|
@ -206,8 +77,7 @@ 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, à sa table ou à son siège, sa personne placée ou non
|
||||
* (violationsDuRetenu) : la dérive ne compare pas les réservations.
|
||||
* ordinaire : 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
|
||||
|
|
@ -217,7 +87,7 @@ function raisonQuiLaNomme(violation, raisons) {
|
|||
export function invariantViole(charge, examen = examiner(charge)) {
|
||||
const { instance } = instanceDe(charge);
|
||||
const raisons = examen.retenu.raisons;
|
||||
const violations = violationsCourantes(charge, examen, instance).filter(
|
||||
const violations = violationsDuRetenu(charge, examen, instance).filter(
|
||||
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
|
||||
);
|
||||
if (violations.length === 0) return null;
|
||||
|
|
@ -239,19 +109,13 @@ 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, 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.
|
||||
* 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.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} avant
|
||||
* @param {import('../stockage/types.js').Charge} apres
|
||||
|
|
@ -278,24 +142,16 @@ 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: raisonsNommees(nouvelles, apres, avant) });
|
||||
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles });
|
||||
}
|
||||
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
|
||||
|
||||
const reservations = (charge, examen, inst) =>
|
||||
violationsCourantes(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
|
||||
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
|
||||
const tenues = reservations(avant, examenAvant, instanceAvant);
|
||||
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 }));
|
||||
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 }));
|
||||
}
|
||||
return rendus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ 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, examenNomme, invariantViole } from './avertissements.js';
|
||||
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||
|
||||
const personne = (id, nom, exclu = false) => ({
|
||||
id,
|
||||
|
|
@ -185,7 +184,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, personne: 'Pervenche' }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{
|
||||
code: 'DERIVE_NOUVELLE',
|
||||
|
|
@ -197,14 +196,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, personne: 'Lacasse' }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4 }];
|
||||
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, numero: 1 }];
|
||||
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4 }];
|
||||
assert.deepEqual(avertissementsApres(trois, quatre), [
|
||||
{
|
||||
code: 'DERIVE_NOUVELLE',
|
||||
|
|
@ -219,84 +218,23 @@ 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, personne: 'Pervenche' }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
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, personne, numero}', () => {
|
||||
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
|
||||
const avant = petiteSoiree();
|
||||
const apres = avec(avant, reserverGrisaille);
|
||||
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
]);
|
||||
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) => {
|
||||
|
|
@ -304,7 +242,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, personne: 'Grisaille', numero: 2 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -312,7 +250,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, personne: 'Grisaille', numero: 2 } },
|
||||
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -383,110 +321,3 @@ 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 { SIEGES_MAX, capacite } from '../stockage/document.js';
|
||||
import { capacite } from '../stockage/document.js';
|
||||
import { controlerCapacites } from './capacite.js';
|
||||
import { presents } from './places.js';
|
||||
|
||||
|
|
@ -46,10 +46,8 @@ 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, 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.
|
||||
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
|
||||
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
|
||||
*
|
||||
* @param {import('../stockage/types.js').Charge} charge
|
||||
* @param {number} sieges
|
||||
|
|
@ -66,11 +64,10 @@ 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 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' });
|
||||
const controle =
|
||||
sieges < 2
|
||||
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
|
||||
: 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 { SIEGES_MAX, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
|
||||
|
|
@ -125,15 +125,6 @@ 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,8 +9,7 @@
|
|||
//
|
||||
// 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 ; 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
|
||||
// MOINS_DE_DEUX ; 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
|
||||
|
|
@ -38,7 +37,7 @@
|
|||
// fonction ne modifie ce qu'elle reçoit.
|
||||
import { ordreDesReservations } from '../csv/import.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { SIEGES_MAX, capacite } from '../stockage/document.js';
|
||||
import { 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';
|
||||
|
|
@ -149,7 +148,6 @@ 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));
|
||||
|
|
@ -381,8 +379,7 @@ 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 ; TROP_DE_PLACES {max}, au-delà de
|
||||
* SIEGES_MAX, remède null ; SOUS_RESERVATIONS {table, n,
|
||||
* Refus : MOINS_DE_DEUX {}, 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 { SIEGES_MAX, analyser, creerCharge } from '../stockage/document.js';
|
||||
import { analyser, creerCharge } from '../stockage/document.js';
|
||||
import { controlerCapacite, lireEntier } from './capacite.js';
|
||||
import { COMMANDES } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
|
@ -96,17 +96,6 @@ 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, TOURS_MAX, capacite } from '../stockage/document.js';
|
||||
import { SCHEMA, capacite } from '../stockage/document.js';
|
||||
import { fautes } from '../stockage/placements.js';
|
||||
import { bilanChangementDefaut } from './bilans.js';
|
||||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||||
|
|
@ -91,9 +91,8 @@ 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) 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.
|
||||
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
|
||||
* capacité et une liste vide à chaque tour.
|
||||
*/
|
||||
function creerTable(charge, { forme } = {}) {
|
||||
exigerModifiable(charge);
|
||||
|
|
@ -101,8 +100,7 @@ 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 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 position = caseLibre(charge.tables.map((table) => table.position));
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.tables.push({ id, numero, sieges: null, forme, position });
|
||||
suivante.prochainsIds.table = id + 1;
|
||||
|
|
@ -214,8 +212,7 @@ 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, TROP_DE_TOURS {max} au-delà
|
||||
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
|
||||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, 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.
|
||||
|
|
@ -224,7 +221,6 @@ 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,15 +11,12 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||
import { describe, test } from '../../test/lanceur.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 { caseLibre } from '../geometrie/tables.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 { SIEGES_MAX, TOURS_MAX, analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||
import { 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';
|
||||
|
|
@ -121,45 +118,6 @@ 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) {
|
||||
|
|
@ -264,56 +222,6 @@ 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;
|
||||
|
|
@ -326,10 +234,7 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
|||
numero: 3,
|
||||
sieges: null,
|
||||
forme: 'carree',
|
||||
position: caseLibre(
|
||||
charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })),
|
||||
{ forme: 'carree', places: charge.evenement.siegesParDefaut },
|
||||
),
|
||||
position: caseLibre(charge.tables.map(({ position }) => position)),
|
||||
});
|
||||
assert.equal(cree.charge.prochainsIds.table, 5);
|
||||
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
|
||||
|
|
@ -367,20 +272,6 @@ 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]]];
|
||||
|
|
@ -737,11 +628,7 @@ 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);
|
||||
// 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);
|
||||
assert.equal(invariantViole(apres), null, 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);
|
||||
|
|
@ -946,29 +833,6 @@ 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 } from '../stockage/placements.js';
|
||||
import { violationsDuRetenu } from './avertissements.js';
|
||||
import { fautes, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { refus } from './erreurs.js';
|
||||
import { libelle } from './libelles.js';
|
||||
import { conversionsPossibles } from './listes_travail.js';
|
||||
|
|
@ -418,57 +418,35 @@ function rangDeTable(retenu, idTable, places) {
|
|||
return retenu.tables.length - 1;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
// Clé d'une violation : ses champs, dans un ordre fixe.
|
||||
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]);
|
||||
|
||||
// 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
|
||||
// Les violations de verifierInvariants que suivant ajoute au retenu
|
||||
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
|
||||
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
|
||||
// personne une fois, et n'y rend jamais deux violations de même clé. []
|
||||
// quand la configuration ne se normalise pas.
|
||||
// personne une fois, et verifierInvariants 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) => violationsDuRetenu(charge, retenu, instance);
|
||||
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true });
|
||||
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, 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
|
||||
// une violation au retenu reçu. 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, remedeDe = () => null) {
|
||||
function retouche(charge, suivante) {
|
||||
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
|
||||
if (violations.length > 0) {
|
||||
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations }, remedeDe(violations));
|
||||
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, 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) {
|
||||
|
|
@ -480,9 +458,8 @@ 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, 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
|
||||
* 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
|
||||
* 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
|
||||
|
|
@ -521,25 +498,8 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
|
|||
const liste = etage.sieges[rangTable];
|
||||
let cible = siege;
|
||||
if (cible === null) {
|
||||
// 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;
|
||||
}
|
||||
cible = 1;
|
||||
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1;
|
||||
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
|
||||
}
|
||||
const occupant = liste[cible - 1] ?? null;
|
||||
|
|
@ -593,13 +553,6 @@ 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
|
||||
|
|
@ -724,9 +677,7 @@ 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. Une personne que sa
|
||||
* réservation tient à cette table refuse INVARIANT_VIOLE, remède le retrait
|
||||
* de cette réservation.
|
||||
* au-delà de sa capacité courante — refuse SANS_EFFET.
|
||||
*/
|
||||
function retirerDuTour(charge, { tour, participant } = {}) {
|
||||
exigerModifiable(charge);
|
||||
|
|
@ -745,7 +696,7 @@ function retirerDuTour(charge, { tour, participant } = {}) {
|
|||
oter(suivante.retenu, tour, participant);
|
||||
enReserve(suivante.retenu, tour, participant);
|
||||
return {
|
||||
charge: retouche(charge, suivante, (violations) => remedeDuRetrait(charge, participant, tour, violations)),
|
||||
charge: retouche(charge, suivante),
|
||||
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
|
||||
avertissements: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -728,31 +728,19 @@ 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, remède le retrait de la réservation', () => {
|
||||
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, sans écriture', () => {
|
||||
// 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)] }));
|
||||
const leve = refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||
proposition: 1,
|
||||
violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }],
|
||||
remede: { commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee: 'tour', tour: 1 } },
|
||||
remede: null,
|
||||
});
|
||||
// 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));
|
||||
|
|
@ -764,53 +752,6 @@ 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.
|
||||
|
|
@ -941,8 +882,6 @@ 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', () => {
|
||||
|
|
@ -955,12 +894,6 @@ 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,14 +10,9 @@
|
|||
// ← { 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, échec du script ou annulation. Terminé,
|
||||
// l'issue : résultat, erreur, faute du travailleur 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
|
||||
|
|
@ -37,14 +32,8 @@ 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 {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) ;
|
||||
* ErreurGeneration(code, details) ; faute du travailleur, son canal
|
||||
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
|
||||
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
|
||||
* avancement. Après l'issue, annuler() ne fait rien.
|
||||
*/
|
||||
|
|
@ -80,22 +69,11 @@ 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 ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null }));
|
||||
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
|
||||
terminer();
|
||||
};
|
||||
travailleur.postMessage({ type: 'lancer', configuration, reglages });
|
||||
|
|
|
|||
|
|
@ -158,51 +158,17 @@ 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 — 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 () => {
|
||||
test('le canal d’erreur du travailleur : rejet par une erreur sans code, le travailleur terminé', async () => {
|
||||
const { fabrique, travailleurs } = fabriqueFactice();
|
||||
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
|
||||
const [travailleur] = travailleurs;
|
||||
assert.equal(typeof travailleur.onerror, 'function');
|
||||
const evenement = {
|
||||
message: 'faute d’épreuve',
|
||||
traite: false,
|
||||
preventDefault() {
|
||||
this.traite = true;
|
||||
},
|
||||
};
|
||||
travailleur.onerror(evenement);
|
||||
travailleur.onerror({ message: 'faute d’épreuve' });
|
||||
const lue = await issue(execution.resultat);
|
||||
assert.equal(lue.etat, 'rejetee');
|
||||
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.ok(lue.erreur instanceof Error);
|
||||
assert.equal(lue.erreur.code, undefined);
|
||||
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.match(lue.erreur.message, /faute d’épreuve/);
|
||||
assert.equal(travailleur.terminaisons, 1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,17 +16,12 @@
|
|||
// 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. 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.
|
||||
// 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.
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
|
||||
|
||||
|
|
@ -47,19 +42,16 @@ export function brancherTravailleur(portee) {
|
|||
progressions += 1;
|
||||
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
|
||||
};
|
||||
let propositions;
|
||||
try {
|
||||
exigerReglages(data.reglages);
|
||||
const propositions = rechercher(data.configuration, data.reglages, { progression });
|
||||
portee.postMessage({ type: 'resultat', propositions });
|
||||
propositions = rechercher(data.configuration, data.reglages, { progression });
|
||||
} catch (erreur) {
|
||||
const convertie = versErreurGeneration(erreur);
|
||||
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 });
|
||||
}
|
||||
if (!(convertie instanceof ErreurGeneration)) throw convertie;
|
||||
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
|
||||
return;
|
||||
}
|
||||
portee.postMessage({ type: 'resultat', propositions });
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,18 +57,6 @@ 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,11 +36,9 @@ 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 : 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.
|
||||
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
||||
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js', 'src/interface/plan/depot.js'];
|
||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.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');
|
||||
|
|
@ -413,7 +411,6 @@ 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' } }],
|
||||
[
|
||||
|
|
@ -496,7 +493,6 @@ 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 }],
|
||||
|
|
@ -511,8 +507,6 @@ 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 } }],
|
||||
[
|
||||
|
|
@ -599,53 +593,6 @@ 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/);
|
||||
|
|
@ -941,7 +888,6 @@ 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'],
|
||||
|
|
@ -951,7 +897,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, 16);
|
||||
assert.equal(REFUS.length, 15);
|
||||
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,14 +62,6 @@ 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,8 +41,6 @@ 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,12 +74,6 @@ 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,25 +114,11 @@ 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, 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}.`
|
||||
);
|
||||
},
|
||||
({ 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.`,
|
||||
],
|
||||
[
|
||||
'INVARIANT_VIOLE',
|
||||
|
|
|
|||
|
|
@ -48,11 +48,6 @@ 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) ------------
|
||||
[
|
||||
|
|
@ -180,17 +175,18 @@ 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.'],
|
||||
// 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.`],
|
||||
['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.`],
|
||||
[
|
||||
'CAPACITE_CHANGEE',
|
||||
({ avant, maintenant, ...details }) => `${laTable(details)} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||
({ table, avant, maintenant }) =>
|
||||
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||
],
|
||||
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
|
||||
|
||||
|
|
|
|||
|
|
@ -138,20 +138,13 @@ function planEvalue(charge, instance, placement) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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) {
|
||||
const tenu = new Map();
|
||||
const siegeDe = new Map();
|
||||
const sansSiege = new Map();
|
||||
|
|
@ -170,17 +163,13 @@ export function placesTenues(couvrantes, places) {
|
|||
return { tenu, siegeDe, sansSiege };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
if (reservation !== null) {
|
||||
const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
|
||||
const remplissage = reservation.participant === occupant ? 'plein' : 'hachure';
|
||||
return { remplissage, marques: ['cadenasFerme', ...anneau] };
|
||||
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] };
|
||||
}
|
||||
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
|
||||
return { remplissage: 'normal', marques: [] };
|
||||
|
|
@ -195,11 +184,7 @@ function aspect(reservation, occupant, 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. 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,
|
||||
* réservées à la table passent avant les autres. 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
|
||||
|
|
@ -286,7 +271,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, occupant, titre) };
|
||||
return { siege, occupant, reservation, titre, ...aspect(reservation, titre) };
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -85,10 +85,8 @@ 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: structuredClone(PARFAIT), siegesAttribues });
|
||||
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues });
|
||||
|
||||
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
|
||||
// retenu quand retenu est vrai.
|
||||
|
|
@ -462,58 +460,6 @@ 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 });
|
||||
|
|
|
|||
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, examenNomme, invariantViole } from './avertissements.js';
|
||||
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
|
|
@ -245,14 +245,13 @@ 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. L'examen de l'état
|
||||
// nomme ses raisons de dérive sur la charge (examenNomme).
|
||||
// Ouvert ne change jamais, ses copies se font une fois.
|
||||
const parOuvert = new WeakMap();
|
||||
|
||||
function figesDe(ouvert) {
|
||||
let figes = parOuvert.get(ouvert);
|
||||
if (figes === undefined) {
|
||||
const copie = structuredClone({ charge: ouvert.charge, examen: examenNomme(ouvert.examen, ouvert.charge) });
|
||||
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen });
|
||||
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
|
||||
parOuvert.set(ouvert, figes);
|
||||
}
|
||||
|
|
@ -1246,13 +1245,6 @@ 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, personne: 'Epicéa' }];
|
||||
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8 }];
|
||||
assert.deepEqual(enRetenu.avertissements, [
|
||||
{ code: 'IMPORT_EN_RETENU', details: {} },
|
||||
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } },
|
||||
|
|
@ -1981,9 +1981,7 @@ 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.
|
||||
// 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));
|
||||
const laisser = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Compte les objets atteints depuis valeur, chacun une fois, en affirmant
|
||||
// que chacun est figé.
|
||||
|
|
@ -2455,14 +2453,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, personne: 'Théo Pervenche' }];
|
||||
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||
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, personne: 'Grisaille' }];
|
||||
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2 }];
|
||||
assert.deepEqual(autre.avertissements, [
|
||||
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } },
|
||||
]);
|
||||
|
|
@ -2734,51 +2732,6 @@ 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,8 +19,6 @@ 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';
|
||||
|
|
@ -157,25 +155,6 @@ 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":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":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":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"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}}
|
||||
{"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}}
|
||||
],
|
||||
"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":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":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":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"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":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":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"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}}
|
||||
{"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}}
|
||||
],
|
||||
"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":730,"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":0,"y":300}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"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":730,"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":0,"y":300}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}}
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}
|
||||
],
|
||||
"reservations": [],
|
||||
"titres": [],
|
||||
|
|
|
|||
|
|
@ -38,13 +38,10 @@ 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 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.
|
||||
* 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.
|
||||
*
|
||||
* @param {{x: number, y: number}} point cm
|
||||
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
|
||||
|
|
@ -58,7 +55,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) <= geometrie.rayonCible);
|
||||
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
|
||||
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
|
||||
if (dans(point, geometrie, geometrie.demiTaille)) return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,33 +17,6 @@ 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);
|
||||
|
|
@ -92,16 +65,9 @@ describe('l’emprise d’une carrée est un carré', () => {
|
|||
assert.equal(surLeSol(point, geometries), false);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('(140, 0) est le sol', () => {
|
||||
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);
|
||||
});
|
||||
|
|
@ -146,42 +112,20 @@ 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 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;
|
||||
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
|
||||
let milieux = 0;
|
||||
for (const g of geometries) {
|
||||
const n = g.chaises.length;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
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}`);
|
||||
}
|
||||
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
|
||||
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);
|
||||
points += 1;
|
||||
milieux += 1;
|
||||
}
|
||||
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
|
||||
assert.equal(surLeSol(g.centre, geometries), false);
|
||||
}
|
||||
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]);
|
||||
assert.equal(milieux, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom} : un point à 10 m de tout est le sol`, () => {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@
|
|||
// 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. 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';
|
||||
// porte aucun angle.
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeometrieTable en cm
|
||||
|
|
@ -21,8 +19,6 @@ import { constante } from './constantes.js';
|
|||
* @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
|
||||
|
|
@ -39,14 +35,10 @@ 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 : 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;
|
||||
/** 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;
|
||||
|
||||
// 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π.
|
||||
|
|
@ -63,37 +55,42 @@ 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 : la
|
||||
// géométrie d'une table se calcule sans valeur de repli.
|
||||
function exigerPlaces(places) {
|
||||
// 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) {
|
||||
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 / π). 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.
|
||||
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
|
||||
* diamètre saisi, qui l'emporte.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function diametre(places) {
|
||||
exigerPlaces(places);
|
||||
return Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
|
||||
export function diametre(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? 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.
|
||||
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function cote(places) {
|
||||
exigerPlaces(places);
|
||||
return Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||
export function cote(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||
}
|
||||
|
||||
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
|
||||
|
|
@ -141,12 +138,6 @@ 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
|
||||
|
|
@ -181,7 +172,6 @@ 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 },
|
||||
|
|
@ -189,13 +179,9 @@ export function geometrieTable(table, places) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param {GeometrieTable[]} geometries
|
||||
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
|
||||
|
|
@ -206,77 +192,35 @@ export function englobant(geometries) {
|
|||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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 à ⌈√(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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
* @param {Array<{x: number, y: number}>} centres
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
export function caseLibre(tables, nouvelle) {
|
||||
const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
|
||||
const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
|
||||
export function caseLibre(centres) {
|
||||
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
|
||||
for (let rang = 0; ; rang += 1) {
|
||||
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,
|
||||
);
|
||||
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);
|
||||
if (!prise) return candidate;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,22 +8,18 @@
|
|||
// 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,
|
||||
|
|
@ -46,48 +42,6 @@ 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);
|
||||
|
|
@ -95,8 +49,8 @@ describe('constantes', () => {
|
|||
assert.equal(RAYON_CHAISE, 22);
|
||||
assert.equal(RAYON_POIGNEE, 15);
|
||||
assert.equal(ECART_POIGNEE, 8);
|
||||
assert.equal(ECART_LISTE, 36);
|
||||
assert.equal(ECART_RANGEES, 300);
|
||||
assert.equal(ECART_LISTE, 10);
|
||||
assert.equal(PAS_GRILLE, 300);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -115,9 +69,9 @@ describe('diametre (§ 7.7)', () => {
|
|||
assert.equal(Math.round(diametre(10)), 191);
|
||||
});
|
||||
|
||||
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);
|
||||
test('un diamètre saisi l’emporte', () => {
|
||||
assert.equal(diametre(8, { saisi: 180 }), 180);
|
||||
assert.equal(diametre(2, { saisi: 50 }), 50);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -129,8 +83,8 @@ describe('cote', () => {
|
|||
assert.equal(cote(9), 180);
|
||||
});
|
||||
|
||||
test('une seule formule : une option ne change pas le côté', () => {
|
||||
assert.equal(cote(8, { saisi: 150 }), 120);
|
||||
test('un côté saisi l’emporte', () => {
|
||||
assert.equal(cote(8, { saisi: 150 }), 150);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +119,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 + 80, y: 600 - r }, 'origine de la liste');
|
||||
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste');
|
||||
});
|
||||
|
||||
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
|
||||
|
|
@ -209,10 +163,10 @@ describe('geometrieTable, carrée', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('emprise 104, poignée au-delà du coin, liste en (440, 540)', () => {
|
||||
test('emprise 104, poignée au-delà du coin, liste en (414, 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: 440, y: 540 }, 'origine de la liste');
|
||||
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste');
|
||||
});
|
||||
|
||||
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
|
||||
|
|
@ -284,34 +238,13 @@ 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', 'rayonCible', 'rayonEmprise'],
|
||||
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', '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']);
|
||||
|
|
@ -326,166 +259,57 @@ 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', () => {
|
||||
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
|
||||
assert.throws(() => diametre(0), RangeError);
|
||||
assert.throws(() => cote(1.5), RangeError);
|
||||
assert.throws(() => diametre(Number.NaN), RangeError);
|
||||
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
|
||||
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
|
||||
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('englobant : tout ce que le dessin pose de pointable', () => {
|
||||
describe('englobant', () => {
|
||||
test('null sans table', () => {
|
||||
assert.equal(englobant([]), null);
|
||||
});
|
||||
|
||||
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;
|
||||
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;
|
||||
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, -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);
|
||||
proche(e.minX, -204, 'minX');
|
||||
proche(e.minY, -54, 'minY');
|
||||
proche(e.maxX, 300 + r, 'maxX');
|
||||
proche(e.maxY, 600 + r, 'maxY');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
describe('caseLibre', () => {
|
||||
test('sans table, l’origine', () => {
|
||||
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
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('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 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 en (150, 0) prend les deux premières cases', () => {
|
||||
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
|
||||
});
|
||||
|
||||
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('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
|
||||
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, 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 });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@
|
|||
// 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 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 ».
|
||||
// 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.
|
||||
//
|
||||
// À 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
|
||||
|
|
@ -42,6 +40,7 @@
|
|||
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';
|
||||
|
|
@ -65,19 +64,11 @@
|
|||
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,
|
||||
|
|
@ -201,6 +192,7 @@
|
|||
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,22 +184,6 @@ 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);
|
||||
|
|
@ -476,11 +460,7 @@ describe('signalements et récupération (§ 8.1, § 8.8)', () => {
|
|||
[b, 'Merles A'],
|
||||
]) {
|
||||
expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 }));
|
||||
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);
|
||||
expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre);
|
||||
const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles'));
|
||||
expect(noeuds).toEqual([autre]);
|
||||
}
|
||||
|
|
@ -698,11 +678,7 @@ 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.
|
||||
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(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre);
|
||||
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.
|
||||
|
|
@ -826,10 +802,6 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -965,55 +937,6 @@ 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,9 +22,7 @@
|
|||
// 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). 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.
|
||||
// n'est ouvert pour l'exécuter (refusDeLaListe).
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { versionVisible } from '../apropos.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
|
@ -61,9 +59,6 @@
|
|||
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);
|
||||
|
|
@ -186,17 +181,13 @@
|
|||
|
||||
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();
|
||||
}
|
||||
|
|
@ -263,12 +254,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="creer"
|
||||
aria-busy={creationEnCours ? 'true' : undefined}
|
||||
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
|
||||
>
|
||||
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button>
|
||||
{#if refusCreation !== null && champRefuse === null}
|
||||
<Refus refus={refusCreation} />
|
||||
{/if}
|
||||
|
|
@ -361,10 +347,4 @@
|
|||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@
|
|||
//
|
||||
// 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';
|
||||
|
||||
|
|
@ -21,19 +17,6 @@
|
|||
|
||||
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`}>
|
||||
|
|
@ -42,13 +25,7 @@
|
|||
<ul>
|
||||
{#each demonstrations as { cle, nom } (cle)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-demonstration={cle}
|
||||
aria-busy={enCours === cle ? 'true' : undefined}
|
||||
aria-disabled={enCours === cle ? 'true' : undefined}
|
||||
onclick={() => choisir(cle)}>{nom}</button
|
||||
>
|
||||
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button>
|
||||
{#if refus !== null && refus.cle === cle}
|
||||
<Refus refus={refus.refus} />
|
||||
{/if}
|
||||
|
|
@ -104,10 +81,4 @@
|
|||
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,10 +10,7 @@
|
|||
// 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. 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.
|
||||
// opération réussie.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
|
@ -29,8 +26,6 @@
|
|||
// 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) {
|
||||
|
|
@ -51,18 +46,14 @@
|
|||
|
||||
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();
|
||||
|
|
@ -96,12 +87,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande={saisie.commande}
|
||||
aria-busy={validationEnCours ? 'true' : undefined}
|
||||
aria-disabled={validationEnCours ? 'true' : undefined}>{t(saisie.valider)}</button
|
||||
>
|
||||
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button>
|
||||
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
|
||||
{#if refus !== null && !surLeChamp}
|
||||
<Refus {refus} />
|
||||
|
|
@ -163,10 +149,4 @@
|
|||
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,24 +16,14 @@ 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 les deux formes
|
||||
* de la version, celles que reçoit App (produitVersion, versionWindows)
|
||||
* @param {{affichee: string, windows: string}} champs.version VERSION
|
||||
* @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,7 +6,6 @@
|
|||
// 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';
|
||||
|
|
@ -50,23 +49,6 @@ 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;
|
||||
|
|
@ -105,17 +87,3 @@ 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,8 +5,7 @@
|
|||
// 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, 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
|
||||
// du plan et le refus posé sur une place. 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.
|
||||
//
|
||||
|
|
@ -25,7 +24,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 vrai seulement sur la section du plan
|
||||
* @property {boolean} pleinEcran
|
||||
* @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,
|
||||
|
|
@ -39,8 +38,6 @@ 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 »
|
||||
|
|
@ -57,7 +54,6 @@ const DEPART = Object.freeze({
|
|||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
appartenance: '',
|
||||
});
|
||||
|
||||
const GENRES_CHOIX = new Set(['personne', 'reservation']);
|
||||
|
|
@ -102,19 +98,16 @@ function choixValide(choix) {
|
|||
* prendreImport: () => null|{texte: string, origine: string},
|
||||
* basculerListes: Function, basculerPleinEcran: Function,
|
||||
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
|
||||
* poserSurPlace: Function, garderAppartenance: Function}}
|
||||
* poserSurPlace: 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. 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.
|
||||
// l'une de ses propriétés.
|
||||
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);
|
||||
|
|
@ -196,12 +189,6 @@ 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,7 +23,6 @@ const DEPART = {
|
|||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
appartenance: '',
|
||||
};
|
||||
|
||||
// Un état et le relevé de ce que reçoit son abonné.
|
||||
|
|
@ -150,29 +149,6 @@ 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');
|
||||
|
|
@ -193,22 +169,6 @@ 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,8 +6,7 @@
|
|||
// 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),
|
||||
// confirmé en place quand actionRemede le demande — « Débloquer ».
|
||||
// puis son remède en Commande, par actionRemede (primitives/commande.js).
|
||||
// Un avertissement n'empêche rien.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
|
@ -63,13 +62,7 @@
|
|||
</ul>
|
||||
{/if}
|
||||
{#if action !== null}
|
||||
<Commande
|
||||
cle={action.cle}
|
||||
details={action.details}
|
||||
nom={action.nom}
|
||||
confirmer={action.confirmer}
|
||||
executer={action.executer}
|
||||
/>
|
||||
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -81,6 +74,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
margin-bottom: var(--espace-3);
|
||||
}
|
||||
|
||||
.avertissement {
|
||||
|
|
|
|||
|
|
@ -8,30 +8,10 @@
|
|||
// à 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
|
||||
// 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 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).
|
||||
//
|
||||
// La place fixe porte la commande que rend commandeFixe (modele.js) :
|
||||
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
|
||||
|
|
@ -49,7 +29,7 @@
|
|||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
|
||||
|
||||
let { children, tete = null, annonces = null } = $props();
|
||||
let { children, tete = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -58,17 +38,12 @@
|
|||
// 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} data-marque={MARQUE_DE_LA_FORME[forme]} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
|
||||
<div class="cadre" data-mode={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>
|
||||
|
|
@ -121,22 +96,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if tete !== null}
|
||||
<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 class="tete">
|
||||
{@render tete()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="surface">
|
||||
|
|
@ -240,81 +201,8 @@
|
|||
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 75%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--espace-3);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
// du support rompu. Le texte attendu vient de la même traduction que celle
|
||||
// de l'application ; les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { cdp } from 'vitest/browser';
|
||||
import { flushSync } from 'svelte';
|
||||
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
|
||||
import { INACTIVITE_MS } from '../../application/seance.js';
|
||||
|
|
@ -31,7 +30,7 @@ import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../s
|
|||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, molette } from '../../../test/navigateur/entrees.js';
|
||||
import { fixerCadre } 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';
|
||||
|
|
@ -88,38 +87,6 @@ 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}"]`);
|
||||
|
||||
|
|
@ -247,27 +214,6 @@ 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)', () => {
|
||||
|
|
@ -284,11 +230,6 @@ 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();
|
||||
|
|
@ -534,25 +475,6 @@ 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();
|
||||
|
|
@ -630,36 +552,6 @@ 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);
|
||||
|
|
@ -686,170 +578,6 @@ 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();
|
||||
|
|
@ -906,12 +634,7 @@ 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));
|
||||
// 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));
|
||||
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue }));
|
||||
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
|
||||
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
|
||||
});
|
||||
|
|
@ -943,43 +666,6 @@ 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();
|
||||
|
|
@ -1056,14 +742,6 @@ 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) {
|
||||
|
|
@ -1098,7 +776,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);
|
||||
issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']);
|
||||
expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull();
|
||||
message(m).querySelector('[data-commande="ecrireDocuments"]').click();
|
||||
await attendre(() => expect(m.seance.etat().supportRompu).toBeNull());
|
||||
const nouvelle = baseDe(m);
|
||||
|
|
@ -1120,7 +798,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
|
|||
const m = await neuf();
|
||||
await rompre(m, 'documents');
|
||||
expect(m.seance.etat().supportRompu.ailleurs).toBeNull();
|
||||
issuesVisibles(m, ['choisirDossier', 'abandonner']);
|
||||
expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull();
|
||||
const choisir = message(m).querySelector('[data-commande="choisirDossier"]');
|
||||
expect(choisir.textContent).toBe(t('communs.commande.choisirDossier'));
|
||||
choisir.click();
|
||||
|
|
@ -1132,7 +810,6 @@ 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'));
|
||||
|
|
@ -1149,7 +826,6 @@ 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,12 +18,6 @@
|
|||
// 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';
|
||||
|
|
@ -36,7 +30,6 @@
|
|||
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');
|
||||
|
|
@ -55,9 +48,6 @@
|
|||
]);
|
||||
|
||||
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()}
|
||||
|
|
@ -75,15 +65,12 @@
|
|||
</nav>
|
||||
{/snippet}
|
||||
|
||||
{#snippet annonces()}
|
||||
{#if $seance.supportRompu !== null}
|
||||
<SupportRompu />
|
||||
{/if}
|
||||
<Avertissements />
|
||||
{/snippet}
|
||||
|
||||
<div class="evenement" data-ecran="evenement">
|
||||
<CadreMode tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
|
||||
<CadreMode tete={$ui.pleinEcran ? null : tete}>
|
||||
{#if $seance.supportRompu !== null}
|
||||
<SupportRompu />
|
||||
{/if}
|
||||
<Avertissements />
|
||||
<Section />
|
||||
</CadreMode>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
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,19 +93,6 @@ 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
|
||||
|
|
@ -139,7 +126,7 @@ function derivesNommees({ ids = [], retenu = false }, examen) {
|
|||
*/
|
||||
export function avertissementsOrdonnes(etatSeance) {
|
||||
return etatSeance.avertissements.map(({ code, details }) => {
|
||||
const rendu = { code, details, remede: remedeDe(code, details) };
|
||||
const rendu = { code, details, remede: REMEDES.get(code) ?? null };
|
||||
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
|
||||
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
|
||||
return rendu;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ 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}"]`);
|
||||
|
|
@ -279,7 +278,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:not([type="checkbox"])')];
|
||||
const entrees = [...section(m).querySelectorAll('input')];
|
||||
expect(entrees.length).toBe(4);
|
||||
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
|
||||
for (const cle of CHAMPS) {
|
||||
|
|
@ -371,137 +370,6 @@ 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,14 +18,6 @@
|
|||
// 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';
|
||||
|
|
@ -53,36 +45,6 @@
|
|||
};
|
||||
|
||||
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>
|
||||
|
|
@ -112,24 +74,6 @@
|
|||
/>
|
||||
{/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}
|
||||
|
|
@ -178,32 +122,6 @@
|
|||
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,17 +85,12 @@
|
|||
}
|
||||
|
||||
// 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. 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.
|
||||
// confirmee : la réponse à la question d'une génération longue.
|
||||
async function lancer(confirmee) {
|
||||
refusGraine = null;
|
||||
issue = null;
|
||||
if (!confirmee) question = null;
|
||||
const saisie = graine;
|
||||
await seance.attendre();
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine: saisie });
|
||||
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
|
||||
const deGraine = refus.find(({ champ }) => champ === 'graine');
|
||||
if (deGraine !== undefined) {
|
||||
refusGraine = { code: deGraine.code, details: deGraine.details };
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ 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';
|
||||
|
|
@ -552,41 +551,6 @@ 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(
|
||||
|
|
@ -767,30 +731,6 @@ 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,14 +26,6 @@ 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,7 +81,6 @@ 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,24 +84,12 @@ 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) ; 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.
|
||||
// des données, non le geste (§ 5.8). 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.
|
||||
|
|
@ -168,17 +156,8 @@ 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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,7 @@
|
|||
<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 ; un
|
||||
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
|
||||
// réservations, et sa table et son siège à chaque tour 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.
|
||||
|
|
@ -116,8 +115,6 @@
|
|||
<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,37 +10,23 @@
|
|||
// 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, 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,
|
||||
// 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é.
|
||||
//
|
||||
// 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 { getContext, onDestroy, tick } from 'svelte';
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
|
||||
import { cleDeColonne, 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);
|
||||
|
||||
|
|
@ -49,20 +35,10 @@
|
|||
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
|
||||
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
|
||||
|
||||
let valeurs = $state({
|
||||
nom: '',
|
||||
prenom: '',
|
||||
appartenance: untrack(() => ui.etat().appartenance),
|
||||
courriel: '',
|
||||
titrePressenti: '',
|
||||
notes: '',
|
||||
});
|
||||
let valeurs = $state({ nom: '', prenom: '', 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`;
|
||||
|
|
@ -79,7 +55,6 @@
|
|||
|
||||
async function ajouter(evenement) {
|
||||
evenement.preventDefault();
|
||||
if (envoiEnCours) return;
|
||||
refusNom = null;
|
||||
refusAjout = null;
|
||||
const champs = {
|
||||
|
|
@ -90,7 +65,6 @@
|
|||
titrePressenti: valeurs.titrePressenti,
|
||||
notes: valeurs.notes,
|
||||
};
|
||||
envoiEnCours = true;
|
||||
try {
|
||||
await seance.executer('ajouterParticipant', { champs });
|
||||
} catch (erreur) {
|
||||
|
|
@ -99,25 +73,12 @@
|
|||
if (refus.code === 'NOM_REQUIS') refusNom = refus;
|
||||
else refusAjout = refus;
|
||||
return;
|
||||
} finally {
|
||||
envoiEnCours = false;
|
||||
}
|
||||
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;
|
||||
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' };
|
||||
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;
|
||||
}
|
||||
|
|
@ -125,7 +86,7 @@
|
|||
onDestroy(() => geste.liberer());
|
||||
</script>
|
||||
|
||||
<form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter} bind:this={formulaire}>
|
||||
<form aria-labelledby={`${uid}-titre`} onsubmit={ajouter}>
|
||||
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
|
||||
<div class="champs">
|
||||
{#each CHAMPS as cle (cle)}
|
||||
|
|
@ -161,8 +122,6 @@
|
|||
</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}
|
||||
|
|
@ -170,12 +129,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
<div class="issue">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="ajouter"
|
||||
aria-busy={envoiEnCours ? 'true' : undefined}
|
||||
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
|
||||
>
|
||||
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button>
|
||||
{#if refusAjout !== null}
|
||||
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
|
||||
{/if}
|
||||
|
|
@ -208,8 +162,7 @@
|
|||
max-width: 18rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
input {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
|
|
@ -224,7 +177,6 @@
|
|||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
|
|
@ -266,10 +218,4 @@
|
|||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@
|
|||
// 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. Maj+Entrée y insère un saut de
|
||||
// ligne, Entrée seule valide (entreeValide). Une validation qui change la valeur
|
||||
// retirerait de toute note retouchée. 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
|
||||
|
|
@ -22,9 +21,6 @@
|
|||
// 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 (§
|
||||
|
|
@ -35,18 +31,13 @@
|
|||
import Commande from '../primitives/Commande.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js';
|
||||
import { COLONNES, celluleSuivante, cleDeColonne } 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);
|
||||
|
||||
|
|
@ -180,7 +171,6 @@
|
|||
evenement.preventDefault();
|
||||
abandonner();
|
||||
} else if (touche === 'Enter') {
|
||||
if (!entreeValide({ maj: evenement.shiftKey, multiligne: edition.colonne === 'notes' })) return;
|
||||
evenement.preventDefault();
|
||||
valider();
|
||||
} else if (touche === 'Tab') {
|
||||
|
|
@ -337,9 +327,6 @@
|
|||
<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,15 +180,6 @@ 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));
|
||||
|
|
@ -208,23 +199,6 @@ 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)', () => {
|
||||
|
|
@ -253,53 +227,6 @@ 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;
|
||||
|
|
@ -335,28 +262,6 @@ 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;
|
||||
|
|
@ -505,34 +410,6 @@ 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) =>
|
||||
|
|
@ -791,34 +668,6 @@ 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,27 +179,20 @@ 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é. 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
|
||||
* = 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
|
||||
* 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}|{tour: number, horsRetenu: true}>|null}
|
||||
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: 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') {
|
||||
if (tour === 1) return null;
|
||||
etapes.push({ tour, horsRetenu: true });
|
||||
continue;
|
||||
}
|
||||
if (etat.source !== 'retenu') return null;
|
||||
let etape = null;
|
||||
for (const table of etat.tables) {
|
||||
const chaise = table.chaises.find(({ occupant }) => occupant === id);
|
||||
|
|
@ -215,18 +208,6 @@ 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,7 +14,6 @@ import {
|
|||
celluleSuivante,
|
||||
cleDeColonne,
|
||||
cleDeStatut,
|
||||
entreeValide,
|
||||
itineraire,
|
||||
lignesGrille,
|
||||
nomExport,
|
||||
|
|
@ -219,36 +218,17 @@ 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('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)', () => {
|
||||
test('source reservations ou proposition, à un tour : null', () => {
|
||||
const retenu = etat('retenu', true, [tableDe(4, [7])], []);
|
||||
for (const source of ['reservations', 'proposition']) {
|
||||
const etats = new Map([
|
||||
[1, retenu],
|
||||
[2, retenu],
|
||||
[3, etat('retenu', true, [tableDe(4, [5])], [7])],
|
||||
[4, etat(source, true, [tableDe(4, [7])], [])],
|
||||
[2, etat(source, true, [tableDe(4, [7])], [])],
|
||||
]);
|
||||
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,
|
||||
);
|
||||
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, 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);
|
||||
|
|
@ -323,18 +303,3 @@ 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,22 +10,10 @@
|
|||
// 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, evidence = null, source } = $props();
|
||||
let { tour, tours, zoomer, ajuster } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
|
|
@ -34,12 +22,6 @@
|
|||
</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))}>
|
||||
|
|
@ -57,14 +39,6 @@
|
|||
<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>
|
||||
|
|
@ -75,18 +49,6 @@
|
|||
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,7 +15,6 @@
|
|||
// (§ 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';
|
||||
|
|
@ -32,12 +31,11 @@ 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, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
|
||||
import { composantes, couleursPeintes, 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';
|
||||
|
||||
|
|
@ -73,10 +71,8 @@ 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. 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 } } = {}) {
|
||||
* en display: none, et conteneur.style.display = '' le montre. */
|
||||
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) {
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.dataset.surface = '';
|
||||
if (cache) conteneur.style.display = 'none';
|
||||
|
|
@ -91,7 +87,7 @@ function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false, props
|
|||
groupe.setAttribute('data-vue', '');
|
||||
groupe.setAttribute('transform', transformDe(vue));
|
||||
svg.append(groupe);
|
||||
const composant = mount(PlanDessin, { target: groupe, props, context: CONTEXTE });
|
||||
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE });
|
||||
flushSync();
|
||||
montes.push({ composant, conteneur });
|
||||
const boite = svg.getBoundingClientRect();
|
||||
|
|
@ -487,113 +483,6 @@ 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)', () => {
|
||||
|
|
@ -621,11 +510,6 @@ 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();
|
||||
|
||||
|
|
@ -753,43 +637,8 @@ 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', () => {
|
||||
// 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 charge = chargeDeTables([8, 8], { pas: 200 });
|
||||
const vue = { k: 1.5, tx: 300, ty: 350 };
|
||||
const modele = modeleDe(charge, { k: vue.k });
|
||||
const { svg, cadre, ecran } = monterPlan(modele, vue);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
// é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 et de la portée de la
|
||||
// réservation qui tient la personne. Caché, il ne porte
|
||||
// (gestes.js) tire de la cible de l'état des places. 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, molette, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { fixerCadre, glisser, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
|
||||
|
||||
|
|
@ -731,39 +731,6 @@ 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)', () => {
|
||||
|
|
@ -1042,54 +1009,6 @@ 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,8 +17,6 @@
|
|||
// 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'
|
||||
|
|
@ -76,7 +74,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, rang (rang)}
|
||||
{#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)}
|
||||
<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,15 +19,14 @@
|
|||
// 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 } from './vue_plan.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
let { participant, etatTour } = $props();
|
||||
let { participant } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -36,8 +35,8 @@
|
|||
|
||||
const charge = $derived($seance.charge);
|
||||
const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant));
|
||||
const etat = $derived(fiche === undefined ? null : etatTour);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(fiche === undefined ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
// La place que la personne occupe au tour montré, et sa chaise, ou null.
|
||||
const assise = $derived.by(() => {
|
||||
if (etat === null) return null;
|
||||
|
|
@ -52,10 +51,7 @@
|
|||
? null
|
||||
: commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }),
|
||||
);
|
||||
const enEvidence = $derived($ui.miseEnEvidence === participant);
|
||||
const actions = $derived(
|
||||
fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait, enEvidence }),
|
||||
);
|
||||
const actions = $derived(fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait }));
|
||||
|
||||
const dependances = { seance, ui };
|
||||
// Où poser un refus : sa place, sinon la place sélectionnée.
|
||||
|
|
@ -91,8 +87,6 @@
|
|||
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-conflit" x1="6" y1="0" x2="6" y2="12" />
|
||||
<line class="trait-trame" x1="6" y1="0" x2="6" y2="12" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
fill: var(--couleur-table-fond);
|
||||
}
|
||||
|
||||
.trait-conflit {
|
||||
.trait-trame {
|
||||
stroke: var(--couleur-conflit);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,19 +21,15 @@
|
|||
// — 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 ; etatTour, l'état des places
|
||||
// du tour affiché, que SectionPlan calcule, ou null sans événement, et le
|
||||
// passe au menu.
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { libellePropose } from '../../application/places.js';
|
||||
import { etatPlaces, 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 } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -43,8 +39,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 etat = $derived(place === null ? null : etatTour);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
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 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));
|
||||
|
|
@ -203,7 +199,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if menuDe !== null}
|
||||
<MenuPersonne participant={menuDe} {etatTour} />
|
||||
<MenuPersonne participant={menuDe} />
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -22,17 +22,14 @@
|
|||
// (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 ; etatTour, l'état des places
|
||||
// du tour affiché, que SectionPlan calcule, ou null sans événement.
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { comptesTitres } from '../../application/listes_travail.js';
|
||||
import { libellePropose } from '../../application/places.js';
|
||||
import { etatPlaces, 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 } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -42,7 +39,8 @@
|
|||
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 etat = $derived(table === null ? null : etatTour);
|
||||
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 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,12 +23,9 @@
|
|||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { flushSync } from 'svelte';
|
||||
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';
|
||||
|
|
@ -42,8 +39,6 @@ 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';
|
||||
|
|
@ -266,24 +261,16 @@ async function cliquerPoint({ x, y }) {
|
|||
await images(2);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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()) {
|
||||
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,
|
||||
});
|
||||
chaiseEl(m, table, siege).scrollIntoView({ block: 'center', inline: 'center' });
|
||||
await attendre(() => expect(atteinte()).toBe(true));
|
||||
}
|
||||
await cliquerPoint(pointChaise(m, table, siege));
|
||||
|
|
@ -297,34 +284,6 @@ 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();
|
||||
|
|
@ -506,10 +465,7 @@ 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}`);
|
||||
// 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);
|
||||
const voisine = tables.find(({ position }) => position.x === premiere.position.x + 300 && position.y === premiere.position.y);
|
||||
expect(voisine).toBeDefined();
|
||||
await touche('ArrowRight');
|
||||
flushSync();
|
||||
|
|
@ -921,163 +877,6 @@ 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)', () => {
|
||||
|
|
@ -1165,16 +964,15 @@ 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, personne: 'Iris Ombrelle', numero: 3 },
|
||||
});
|
||||
expect(m.seance.etat().avertissements).toContainEqual({ code: 'RESERVATION_NON_HONOREE', details: { participant: IRIS, table: 3, tour: 1 } });
|
||||
await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// 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 () => {
|
||||
const base = chargeDe({
|
||||
id: 'evt-apres-generation',
|
||||
places: [4, 4, 4],
|
||||
|
|
@ -1587,97 +1385,3 @@ 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,8 +33,6 @@
|
|||
//
|
||||
// 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
|
||||
|
|
@ -44,10 +42,9 @@
|
|||
// 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 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 :
|
||||
// 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 :
|
||||
// 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
|
||||
|
|
@ -89,12 +86,13 @@
|
|||
// 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, reservationTenue } from './depot.js';
|
||||
import { deposer } from './depot.js';
|
||||
import EchellePlan from './EchellePlan.svelte';
|
||||
import Fantome from './Fantome.svelte';
|
||||
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
|
||||
|
|
@ -106,14 +104,14 @@
|
|||
cadreDepuisRect,
|
||||
englobantDuTour,
|
||||
nomDeCharge,
|
||||
sourceMontree,
|
||||
tourAffiche,
|
||||
vueApresCadre,
|
||||
vueInitiale,
|
||||
zoomerBouton,
|
||||
zoomerMolette,
|
||||
} from './vue_plan.js';
|
||||
|
||||
let { diagnostic = null, etatTour } = $props();
|
||||
let { diagnostic = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -150,17 +148,12 @@
|
|||
|
||||
const charge = $derived($seance.charge);
|
||||
const tours = $derived(charge.evenement.tours);
|
||||
const etat = $derived(etatTour);
|
||||
const tour = $derived(etat.tour);
|
||||
const tour = $derived(tourAffiche($ui.tour, tours));
|
||||
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
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(
|
||||
|
|
@ -385,12 +378,6 @@
|
|||
// 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.
|
||||
|
|
@ -434,7 +421,6 @@
|
|||
});
|
||||
const surMolette = (evenement) => {
|
||||
evenement.preventDefault();
|
||||
if (gesteEnCours) return;
|
||||
poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement));
|
||||
};
|
||||
observateur.observe(element);
|
||||
|
|
@ -470,7 +456,6 @@
|
|||
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();
|
||||
|
|
@ -495,10 +480,7 @@
|
|||
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, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null),
|
||||
};
|
||||
fantome = { nom: nomDe(effet.participant), cle: cleFantome(etat.cible) };
|
||||
break;
|
||||
case 'montrerPlaces':
|
||||
poignee = { table: effet.table, places: effet.places };
|
||||
|
|
@ -696,7 +678,6 @@
|
|||
oublierImage();
|
||||
document.removeEventListener('keydown', surTouche, true);
|
||||
if (etatGeste.phase !== 'repos') seance.finirGeste();
|
||||
gesteEnCours = false;
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -712,7 +693,7 @@
|
|||
</script>
|
||||
|
||||
<div class="plan">
|
||||
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} {source} />
|
||||
<BarreVue {tour} {tours} {zoomer} {ajuster} />
|
||||
<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,11 +11,6 @@
|
|||
// 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
|
||||
|
|
@ -47,7 +42,6 @@
|
|||
.refus-place {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
|
@ -63,10 +57,6 @@
|
|||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.refus-place :global(button) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,10 @@
|
|||
// 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 ; etatTour, l'état des
|
||||
// places du tour affiché, que SectionPlan calcule, ou null sans événement.
|
||||
// Le panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext } from 'svelte';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
let { etatTour } = $props();
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -24,8 +22,8 @@
|
|||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
|
||||
const reserve = $derived(etatTour === null ? [] : etatTour.reserve);
|
||||
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 nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const choix = $derived($ui.choix);
|
||||
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;
|
||||
|
|
|
|||
|
|
@ -6,44 +6,32 @@
|
|||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 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 }));
|
||||
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id);
|
||||
// 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(charge === null ? null : diagnostiquerCharge(charge).diagnostic);
|
||||
const diagnostic = $derived($seance.charge === null ? null : diagnostiquerCharge($seance.charge).diagnostic);
|
||||
</script>
|
||||
|
||||
<section data-section="plan" class="section-plan">
|
||||
|
|
@ -53,7 +41,7 @@
|
|||
<div class="corps">
|
||||
{#if evenement !== null}
|
||||
{#key evenement}
|
||||
<Plan {diagnostic} {etatTour} />
|
||||
<Plan {diagnostic} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if !$ui.pleinEcran}
|
||||
|
|
@ -62,9 +50,9 @@
|
|||
<Diagnostic compact />
|
||||
</div>
|
||||
<ListesTravail />
|
||||
<Reserve {etatTour} />
|
||||
<PanneauPlace {etatTour} />
|
||||
<PanneauTable {etatTour} />
|
||||
<Reserve />
|
||||
<PanneauPlace />
|
||||
<PanneauTable />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,20 +13,13 @@
|
|||
// 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 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.
|
||||
// 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).
|
||||
//
|
||||
// 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). 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.
|
||||
// pastille porte le nombre de violations, signalée ou non (§ 7.5).
|
||||
//
|
||||
// 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,
|
||||
|
|
@ -46,7 +39,6 @@
|
|||
ECART_FOCUS,
|
||||
ECART_MISE_EN_EVIDENCE,
|
||||
RAYON_PASTILLE,
|
||||
RAYON_SOCLE_PASTILLE,
|
||||
TAILLE_TEXTE_PASTILLE_CM,
|
||||
TRACE_GEL,
|
||||
formesTable,
|
||||
|
|
@ -95,7 +87,6 @@
|
|||
{: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})" />
|
||||
|
|
@ -142,7 +133,6 @@
|
|||
{#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>
|
||||
|
|
@ -190,25 +180,11 @@
|
|||
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 écriture, 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 lecture, puis sur un plan bloqué', async () => {
|
||||
const m = await petite();
|
||||
const verifier = async (gestes) => {
|
||||
const avant = fichiers(m);
|
||||
|
|
@ -590,24 +590,15 @@ describe('le cadrage (§ 8.5, § 13.3)', () => {
|
|||
const lue = vueLue(m);
|
||||
expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue);
|
||||
};
|
||||
const zooms = async () => {
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
await verifier(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,16 +9,6 @@
|
|||
// 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];
|
||||
|
|
@ -33,38 +23,15 @@ 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 — le
|
||||
* fantôme le dit pendant le geste, « tous les tours » compris ; une
|
||||
* réservation (deplacerReservation), qui garde portée et tour ; une
|
||||
* autre reçoit une réservation de portée « tour » au tour affiché
|
||||
* (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
|
||||
* (reserver) ; sur le sol, la réservation glissée part
|
||||
* (retirerReservation), et rien pour une personne non réservée. 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
|
||||
|
|
@ -102,12 +69,11 @@ export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, sur
|
|||
return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } };
|
||||
}
|
||||
|
||||
const tenue = reservationTenue(etatPlaces, personne, depuis);
|
||||
const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis);
|
||||
const tenue = origine?.reservation?.participant === personne ? origine.reservation : null;
|
||||
if (cible === null) {
|
||||
if (tenue === null) return null;
|
||||
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 } } };
|
||||
return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
|
||||
}
|
||||
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, reservationTenue } from './depot.js';
|
||||
import { commandeDeDepot, deposer } from './depot.js';
|
||||
|
||||
const NOMS = [
|
||||
['Ombrelle', 'Iris'],
|
||||
|
|
@ -114,39 +114,6 @@ 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,34 +232,17 @@ 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, § 7.1) : sous la cible 'reservations', « pose une
|
||||
* réservation » pour une personne que rien ne tient, sinon le déplacement
|
||||
* de sa réservation et sa portée — « tous les tours » s'y dit ; « déplace
|
||||
* dans le placement retenu » sous 'retenu' ; la vue seule sous une cible
|
||||
* null. Une cible ou une portée hors du contrat lève : elle ne prend aucun
|
||||
* libellé par défaut.
|
||||
* personne (§ 5.8) : « 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.
|
||||
*
|
||||
* @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, portee = null) {
|
||||
export function cleFantome(cible) {
|
||||
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,23 +331,6 @@ 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,11 +19,6 @@ 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;
|
||||
|
||||
|
|
@ -473,16 +468,15 @@ 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 ; la mettre en
|
||||
* évidence, ou ôter sa mise en évidence quand elle l'est ; ouvrir la fiche.
|
||||
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la
|
||||
* fiche.
|
||||
*
|
||||
* @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)
|
||||
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne
|
||||
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin
|
||||
* @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci,
|
||||
* deplacerReservation, exclure, reintegrer, mettreEnEvidence, oterEvidence, ouvrirFiche
|
||||
* deplacerReservation, exclure, reintegrer, ouvrirFiche
|
||||
*/
|
||||
export function actionsPersonne({ exclu, chaise, retrait, enEvidence }) {
|
||||
export function actionsPersonne({ exclu, chaise, retrait }) {
|
||||
const assise = chaise !== null;
|
||||
const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
|
||||
return [
|
||||
|
|
@ -493,8 +487,6 @@ export function actionsPersonne({ exclu, chaise, retrait, enEvidence }) {
|
|||
['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 { PLANCHER_DIAMETRE, RAYON_POIGNEE, geometrieTable } from '../../geometrie/tables.js';
|
||||
import { geometrieTable } from '../../geometrie/tables.js';
|
||||
import { indexerPlan } from '../../moteur/configuration.js';
|
||||
import { conflitsParTable } from '../../moteur/conflits.js';
|
||||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
|
|
@ -26,13 +26,7 @@ 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,
|
||||
|
|
@ -714,36 +708,6 @@ 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 };
|
||||
|
|
@ -965,42 +929,25 @@ 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, mettre en évidence, ouvrir la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait, enEvidence: false }), [
|
||||
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 }), [
|
||||
'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, enEvidence: false }), [
|
||||
'deplacer',
|
||||
'echanger',
|
||||
'deplacerReservation',
|
||||
'exclure',
|
||||
'mettreEnEvidence',
|
||||
'ouvrirFiche',
|
||||
]);
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', '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)));
|
||||
}
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1058,11 +1005,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: {}, enEvidence: false }),
|
||||
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {}, enEvidence: true }),
|
||||
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }),
|
||||
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }),
|
||||
]);
|
||||
assert.equal(actions.size, 7);
|
||||
assert.equal(menu.size, 10);
|
||||
assert.equal(menu.size, 8);
|
||||
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,36 +127,3 @@ 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,7 +27,6 @@ import {
|
|||
cadreDepuisRect,
|
||||
englobantDuTour,
|
||||
nomDeCharge,
|
||||
sourceMontree,
|
||||
tourAffiche,
|
||||
toursOfferts,
|
||||
vueApresCadre,
|
||||
|
|
@ -250,47 +249,3 @@ 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,17 +7,7 @@
|
|||
// 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 : 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.
|
||||
// La machine est celle de commande.js.
|
||||
//
|
||||
// Un refus — une erreur qui porte un code — s'affiche à côté du bouton,
|
||||
// avec son remède, et le bouton reste actif : jamais un bouton grisé muet.
|
||||
|
|
@ -37,7 +27,6 @@
|
|||
const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) });
|
||||
|
||||
let armee = $state(false);
|
||||
let enCours = $state(false);
|
||||
let refus = $state(null);
|
||||
let minuterie = null;
|
||||
|
||||
|
|
@ -56,10 +45,9 @@
|
|||
armee = machine.armee;
|
||||
}
|
||||
|
||||
async function cliquer(evenement) {
|
||||
const { action } = machine.cliquer({ repete: evenement.detail > 1 });
|
||||
async function cliquer() {
|
||||
const { action } = machine.cliquer();
|
||||
armee = machine.armee;
|
||||
if (action === 'aucune') return;
|
||||
if (action === 'armer') {
|
||||
minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS);
|
||||
return;
|
||||
|
|
@ -67,36 +55,18 @@
|
|||
desarmer();
|
||||
refus = null;
|
||||
try {
|
||||
const rendu = executer();
|
||||
if (typeof rendu?.then === 'function') {
|
||||
enCours = true;
|
||||
await rendu;
|
||||
}
|
||||
await executer();
|
||||
} 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}
|
||||
aria-busy={enCours ? 'true' : undefined}
|
||||
aria-disabled={enCours ? 'true' : undefined}
|
||||
onkeydown={surTouche}
|
||||
onclick={cliquer}>{libelle}</button
|
||||
>
|
||||
<button type="button" data-commande={designation} onclick={cliquer}>{libelle}</button>
|
||||
{#if refus !== null}
|
||||
<Refus {refus} lever={() => (refus = null)} {gestes} />
|
||||
{/if}
|
||||
|
|
@ -121,12 +91,6 @@
|
|||
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,19 +8,7 @@
|
|||
// 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 (§ 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.
|
||||
// n'exécute une commande à confirmer en un clic.
|
||||
//
|
||||
// Un remède (contrat « Refus et avertissements ») est null, une commande
|
||||
// exécutable telle quelle, { commande, arguments }, ou un geste,
|
||||
|
|
@ -70,37 +58,24 @@ 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, 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.
|
||||
* @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}}
|
||||
* cliquer arme une commande à confirmer désarmée, et exécute sinon, en la
|
||||
* désarmant ; expirer la désarme.
|
||||
*/
|
||||
export function creerMachineCommande({ confirmer = null } = {}) {
|
||||
let armee = false;
|
||||
let enCours = false;
|
||||
return {
|
||||
get armee() {
|
||||
return armee;
|
||||
},
|
||||
get enCours() {
|
||||
return enCours;
|
||||
},
|
||||
cliquer({ repete = false } = {}) {
|
||||
if (enCours || repete) return { action: 'aucune' };
|
||||
cliquer() {
|
||||
if (confirmer !== null && !armee) {
|
||||
armee = true;
|
||||
return { action: 'armer' };
|
||||
}
|
||||
armee = false;
|
||||
enCours = true;
|
||||
return { action: 'executer' };
|
||||
},
|
||||
terminer() {
|
||||
enCours = false;
|
||||
},
|
||||
expirer() {
|
||||
armee = false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -65,12 +65,10 @@ describe('commande : la machine', () => {
|
|||
assert.equal(DELAI_CONFIRMATION_MS, 10_000);
|
||||
});
|
||||
|
||||
test('sans confirmer, chaque clic exécute une fois l’exécution précédente terminée, et la commande ne s’arme jamais', () => {
|
||||
test('sans confirmer, chaque clic exécute, 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);
|
||||
});
|
||||
|
||||
|
|
@ -81,55 +79,9 @@ 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,7 +12,6 @@
|
|||
// 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';
|
||||
|
|
@ -133,41 +132,6 @@ 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();
|
||||
|
|
@ -575,39 +539,6 @@ 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);
|
||||
|
|
@ -893,7 +824,6 @@ 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"]'));
|
||||
|
|
@ -906,32 +836,10 @@ 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.
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
async function echapEnRafale(fois) {
|
||||
for (let i = 0; i < fois; i += 1) await touche('Escape');
|
||||
// L'événement close part dans une tâche à lui, après la touche.
|
||||
await new Promise((fin) => setTimeout(fin, 0));
|
||||
await tick();
|
||||
}
|
||||
|
||||
|
|
@ -940,7 +848,6 @@ 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);
|
||||
}
|
||||
|
|
@ -950,10 +857,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(fenetre, fois);
|
||||
await echapEnRafale(fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
// Une seconde rafale, la fenêtre déjà rouverte une fois.
|
||||
await echapEnRafale(fenetre, fois);
|
||||
await echapEnRafale(fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
attente.resoudre();
|
||||
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
|
||||
|
|
@ -967,7 +874,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(fenetre, fois);
|
||||
await echapEnRafale(fois);
|
||||
attendreOccupee(montage, fenetre, appels);
|
||||
await montage.seance.passerEnLecture();
|
||||
attente.resoudre();
|
||||
|
|
@ -976,7 +883,6 @@ 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"]'));
|
||||
|
|
@ -1051,52 +957,6 @@ 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,42 +242,6 @@ 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 }));
|
||||
|
|
@ -390,12 +354,6 @@ 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);
|
||||
|
|
|
|||
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