Spec 19.10 leaves three rendering thresholds to measurement: the frame time of the plan while zooming, the smallest seat target that stays readable, and the width of the witness string under the embedded font. Three bench files measure them in Chromium, frame time throttled four times, and write each measure with its own CPU factor into the record; seuils.js derives the threshold from the samples. No value enters the registry from here. Checked: red first; 3576 node tests green from the index; the bench project runs the three measures. --- FR --- [ADD] banc : seuils du temps par image, sièges lisibles, chaîne témoin Le § 19.10 laisse trois seuils de rendu à la mesure : le temps par image du plan pendant un zoom, la plus petite cible de siège qui reste lisible, et la largeur de la chaîne témoin sous la police embarquée. Trois fichiers du banc les mesurent dans Chromium, le temps par image ralenti quatre fois, et écrivent chaque mesure avec son propre facteur au relevé ; seuils.js tire le seuil des échantillons. Aucune valeur n'entre au registre d'ici. Vérifié : rouge d'abord ; 3576 node verts depuis l'index ; le projet du banc joue les trois mesures. Assisted-by: Claude Opus 5.5
190 lines
8.3 KiB
JavaScript
190 lines
8.3 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le nombre de sièges lisibles (§ 6.2, § 14.1, § 19.10), relevé par le
|
||
// banc : le plus grand nombre de places d'une table seule dont la liste de
|
||
// noms tient dans la hauteur du plan, au zoom ajusté, sur le cadre de
|
||
// 1 366 × 700. La liste a une ligne par siège, à la hauteur de ligne mesurée
|
||
// sur la police rendue ; elle part du haut de la table, à sa droite, et
|
||
// l'ajustement ne cadre que la table : passé un nombre de places, le bas de
|
||
// la liste sort du <svg data-plan>.
|
||
//
|
||
// Le prédicat monte App sur un événement d'une seule table de n places,
|
||
// ronde puis carrée, ouvre le plan, et compare le bas de la boîte de
|
||
// [data-liste] à celui du <svg data-plan>. plusGrandTenant (seuils.js) en
|
||
// tire le seuil par dichotomie sur [2, 200], monotonie relue aux voisins ;
|
||
// la valeur proposée est le plus petit des deux seuils. Deux contrôles
|
||
// disent que la mesure bascule : n = 2 tient, n = 200 ne tient pas.
|
||
//
|
||
// Les listes se dessinent à tout zoom : le registre est remplacé pour ce
|
||
// fichier, K_LISTES à 0 (vi.mock), toute autre constante inchangée. Le seuil
|
||
// de places est une géométrie, qui ne dépend pas du zoom où le plan retire
|
||
// ses listes, constante manuelle (Q12) ; sans le remplacement, une table de
|
||
// 200 places s'ajuste sous K_LISTES et n'a plus de liste à mesurer.
|
||
//
|
||
// La famille calculée des listes doit être Noto Sans, et sa face chargée
|
||
// (C8) : sinon la mesure se refuse, valeur proposée null et refus nommé, et
|
||
// l'épreuve échoue. Une mesure de texte prise sur une police de repli
|
||
// donnerait deux valeurs sur deux hôtes.
|
||
import { flushSync } from 'svelte';
|
||
import { beforeAll, describe, expect, test, vi } from 'vitest';
|
||
import { commands } from 'vitest/browser';
|
||
import { CONSTANTES, constante } from '../../src/geometrie/constantes.js';
|
||
import { creerCharge } from '../../src/stockage/document.js';
|
||
import { creerContexte, ouvrirEvenement } from '../../test/navigateur/ecrans.js';
|
||
import { controlerCadre, ecrireSortie } from './navigateur.js';
|
||
import { verifierReleve } from './releve.js';
|
||
import { plusGrandTenant } from './seuils.js';
|
||
|
||
vi.mock('../../src/geometrie/constantes.js', async (importOriginal) => {
|
||
const original = await importOriginal();
|
||
return { ...original, constante: (nom) => (nom === 'K_LISTES' ? 0 : original.constante(nom)) };
|
||
});
|
||
|
||
const FAMILLE = 'Noto Sans';
|
||
const NOM = 'SEUIL_SIEGES_LISIBLES';
|
||
const MIN = 2;
|
||
const MAX = 200;
|
||
const FORMES = Object.freeze(['ronde', 'carree']);
|
||
|
||
// Le nombre d'images qu'une liste a pour paraître, complète, après
|
||
// l'ouverture du plan.
|
||
const IMAGES_MAX = 120;
|
||
|
||
const sansGuillemets = (texte) => texte.trim().replace(/^(["'])(.*)\1$/, '$2');
|
||
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
|
||
/**
|
||
* Un événement d'une seule table de places sièges, de la forme donnée, au
|
||
* centre du dessin, sans participant ; un tour.
|
||
* @param {number} places
|
||
* @param {'ronde'|'carree'} forme
|
||
*/
|
||
function tableSeule(places, forme) {
|
||
const charge = creerCharge({ id: `banc-sieges-${forme}-${places}`, nom: 'Club des Merles', siegesParDefaut: 8, tours: 1 });
|
||
charge.tables = [{ id: 1, numero: 1, sieges: places, forme, position: { x: 0, y: 0 } }];
|
||
charge.prochainsIds.table = 2;
|
||
return charge;
|
||
}
|
||
|
||
// La liste de la table 1, complète : une ligne <tspan x y> par siège. Rend
|
||
// null tant qu'elle n'a pas toutes ses lignes.
|
||
function listeComplete(racine, places) {
|
||
const liste = racine.querySelector('[data-liste="1"]');
|
||
if (liste === null) return null;
|
||
return liste.querySelectorAll(':scope > tspan').length === places ? liste : null;
|
||
}
|
||
|
||
// La police des listes : la première famille calculée, et la face 400 de
|
||
// Noto Sans chargée dans le document.
|
||
function policeDe(liste) {
|
||
const premiere = sansGuillemets(getComputedStyle(liste).fontFamily.split(',')[0]);
|
||
const chargee = [...document.fonts].some((face) => sansGuillemets(face.family) === FAMILLE && face.weight === '400' && face.status === 'loaded');
|
||
return { premiere, chargee };
|
||
}
|
||
|
||
// Les relevés de chaque appel du prédicat, dans l'ordre des appels.
|
||
const releves = [];
|
||
const polices = [];
|
||
|
||
/**
|
||
* Le prédicat d'une forme : la liste d'une table seule de n places tient-elle
|
||
* dans la hauteur du plan, au zoom ajusté ? Monte, mesure, défait. Lève si la
|
||
* liste ne paraît pas complète : sans elle, la mesure ne juge rien.
|
||
* @param {'ronde'|'carree'} forme
|
||
*/
|
||
function tient(forme) {
|
||
return async (places) => {
|
||
const contexte = creerContexte();
|
||
try {
|
||
const m = await ouvrirEvenement(contexte, tableSeule(places, forme), { section: 'plan' });
|
||
let liste = null;
|
||
for (let image = 0; liste === null; image += 1) {
|
||
if (image >= IMAGES_MAX) throw new Error(`table ${forme} de ${places} places : liste absente ou incomplète au zoom ajusté`);
|
||
flushSync();
|
||
liste = listeComplete(m.racine, places);
|
||
if (liste === null) await uneImage();
|
||
}
|
||
await uneImage();
|
||
const basListe = liste.getBoundingClientRect().bottom;
|
||
const basPlan = m.racine.querySelector('svg[data-plan]').getBoundingClientRect().bottom;
|
||
polices.push(policeDe(liste));
|
||
const resultat = basListe <= basPlan;
|
||
releves.push({ forme, places, basListePx: basListe, basPlanPx: basPlan, tient: resultat });
|
||
return resultat;
|
||
} finally {
|
||
await contexte.defaire();
|
||
}
|
||
};
|
||
}
|
||
|
||
const seuils = {};
|
||
let mesure = null;
|
||
|
||
beforeAll(async () => {
|
||
await controlerCadre();
|
||
});
|
||
|
||
describe('le nombre de sièges lisibles (§ 6.2)', () => {
|
||
test('le registre de ce fichier porte K_LISTES à 0, les autres constantes inchangées', () => {
|
||
expect(constante('K_LISTES')).toBe(0);
|
||
expect(constante('SEUIL_SIEGES_LISIBLES')).toBe(CONSTANTES.find(({ nom }) => nom === 'SEUIL_SIEGES_LISIBLES').valeur);
|
||
});
|
||
|
||
for (const forme of FORMES) {
|
||
test(`table ${forme} : ${MIN} places tiennent, ${MAX} non — la mesure bascule`, async () => {
|
||
expect(await tient(forme)(MIN)).toBe(true);
|
||
expect(await tient(forme)(MAX)).toBe(false);
|
||
});
|
||
|
||
test(`table ${forme} : le plus grand nombre de places dont la liste tient, par dichotomie sur [${MIN}, ${MAX}]`, async () => {
|
||
seuils[forme] = await plusGrandTenant(tient(forme), MIN, MAX);
|
||
expect(seuils[forme]).toBeGreaterThanOrEqual(MIN);
|
||
expect(seuils[forme]).toBeLessThan(MAX);
|
||
});
|
||
}
|
||
|
||
test('chaque liste mesurée est rendue en Noto Sans, sa face chargée', () => {
|
||
expect(polices.length).toBeGreaterThan(0);
|
||
const police = polices.every(({ premiere, chargee }) => premiere === FAMILLE && chargee) ? FAMILLE : null;
|
||
const valeur = Math.min(...FORMES.map((forme) => seuils[forme] ?? Number.NaN));
|
||
const refus = police === null ? `police des listes ${polices.find(({ premiere }) => premiere !== FAMILLE)?.premiere ?? 'non chargée'} : la mesure se prend sur ${FAMILLE}` : null;
|
||
mesure = {
|
||
nom: NOM,
|
||
unite: 'places',
|
||
valeurProposee: refus === null && Number.isInteger(valeur) ? valeur : null,
|
||
critere:
|
||
`le plus grand nombre de places d’une table seule, ronde puis carrée, dont le bas de la liste de noms reste dans ` +
|
||
`la hauteur du <svg data-plan> au zoom ajusté ; le plus petit des deux, dichotomie sur [${MIN}, ${MAX}]`,
|
||
police,
|
||
ralentissementCpu: 1,
|
||
formes: FORMES.map((forme) => ({ forme, seuil: seuils[forme] ?? null })),
|
||
sondes: releves.map((releve) => ({ ...releve })),
|
||
refus,
|
||
};
|
||
expect(refus).toBeNull();
|
||
expect(mesure.valeurProposee).not.toBeNull();
|
||
});
|
||
|
||
test('la mesure s’écrit dans scripts/banc/sorties/sieges_lisibles.json, relue telle quelle, et se range dans un relevé', async () => {
|
||
expect(mesure).not.toBeNull();
|
||
const objet = { format: 1, mesures: [mesure] };
|
||
const releve = {
|
||
format: 1,
|
||
configuration: {
|
||
cadre: { largeur: innerWidth, hauteur: innerHeight },
|
||
facteurEchelle: devicePixelRatio,
|
||
navigateur: null,
|
||
node: null,
|
||
processeursLogiques: null,
|
||
ralentissementCpu: 1,
|
||
police: mesure.police,
|
||
},
|
||
mesures: [mesure],
|
||
};
|
||
expect(verifierReleve(releve, CONSTANTES)).toEqual([]);
|
||
await ecrireSortie('sieges_lisibles', objet);
|
||
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/sieges_lisibles.json'))).toEqual(objet);
|
||
expect(mesure.refus).toBeNull();
|
||
});
|
||
});
|