[ADD] bench: frame time, readable seats and witness string thresholds
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
This commit is contained in:
parent
8fb80324e7
commit
0b2b2dc303
5 changed files with 1040 additions and 0 deletions
189
scripts/banc/seuils.js
Normal file
189
scripts/banc/seuils.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les seuils du banc de mesure (§ 19.10, § 19.4, § 6.2), tirés de mesures
|
||||
// déjà prises : le plus grand entier qui tient un prédicat, par dichotomie ;
|
||||
// le plus petit zoom au-delà duquel une courbe de temps par image tient son
|
||||
// critère ; la tolérance de la chaîne témoin. Module pur, sans import ni
|
||||
// horloge : il se charge sous node, où ses épreuves le jouent, comme dans la
|
||||
// page du banc, où les *.banc.js lui passent leurs mesures.
|
||||
|
||||
/** Le temps par image qu'un glissement de la grande démonstration doit tenir,
|
||||
* au p95, sur la machine de référence (Q13 b) : le choix de l'utilisateur.
|
||||
* Il juge une garde du banc, pas une constante : il ne vit pas au registre. */
|
||||
export const CRITERE_TEMPS_IMAGE_MS = 33;
|
||||
|
||||
// Le nombre de mesures sous lequel une gigue ne se lit pas (§ 19.4).
|
||||
const MESURES_MIN = 10;
|
||||
|
||||
const estEntier = (valeur) => Number.isSafeInteger(valeur);
|
||||
|
||||
// La réponse du prédicat en n, qui doit être un booléen.
|
||||
async function lire(predicat, n) {
|
||||
const reponse = await predicat(n);
|
||||
if (typeof reponse !== 'boolean') throw new TypeError(`plusGrandTenant : booléen attendu du prédicat en ${n}, reçu ${String(reponse)}`);
|
||||
return reponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plus grand entier n de [min, max] où predicat(n) tient, pour un
|
||||
* prédicat décroissant — vrai jusqu'à un seuil, faux au-delà. Le prédicat se
|
||||
* lit aux deux bornes, puis par dichotomie entre le dernier n qui tient et le
|
||||
* premier qui ne tient pas ; enfin le résultat r et ses deux voisins se
|
||||
* relisent : r − 1 et r doivent tenir, r + 1 non, quand ils sont dans
|
||||
* l'intervalle. Une relecture qui dément la dichotomie — un trou sous le
|
||||
* seuil, une mesure qui change d'avis — lève en nommant l'entier fautif.
|
||||
* Une monotonie rompue loin du résultat ne se voit pas : la dichotomie ne
|
||||
* lit qu'une dizaine de points.
|
||||
*
|
||||
* @param {(n: number) => boolean|Promise<boolean>} predicat
|
||||
* @param {number} min entier
|
||||
* @param {number} max entier ≥ min
|
||||
* @returns {Promise<number|null>} max quand il tient ; null quand ni min
|
||||
* ni max ne tiennent
|
||||
* @throws {TypeError} un prédicat qui n'est pas une fonction ou ne rend pas
|
||||
* un booléen
|
||||
* @throws {RangeError} des bornes qui ne sont pas des entiers ordonnés,
|
||||
* avant tout appel ; un prédicat faux en min et vrai en max ; une
|
||||
* relecture qui dément la dichotomie
|
||||
*/
|
||||
export async function plusGrandTenant(predicat, min, max) {
|
||||
if (typeof predicat !== 'function') throw new TypeError('plusGrandTenant : un prédicat est attendu');
|
||||
if (!estEntier(min) || !estEntier(max) || min > max) {
|
||||
throw new RangeError(`plusGrandTenant : bornes entières min ≤ max attendues, reçu ${String(min)} et ${String(max)}`);
|
||||
}
|
||||
const tientMin = await lire(predicat, min);
|
||||
const tientMax = min === max ? tientMin : await lire(predicat, max);
|
||||
if (!tientMin && tientMax) throw new RangeError(`plusGrandTenant : prédicat non monotone, faux en ${min} et vrai en ${max}`);
|
||||
if (!tientMin) return null;
|
||||
let resultat = max;
|
||||
if (!tientMax) {
|
||||
// Invariant : bas tient, haut ne tient pas.
|
||||
let bas = min;
|
||||
let haut = max;
|
||||
while (haut - bas > 1) {
|
||||
const milieu = bas + Math.floor((haut - bas) / 2);
|
||||
if (await lire(predicat, milieu)) bas = milieu;
|
||||
else haut = milieu;
|
||||
}
|
||||
resultat = bas;
|
||||
}
|
||||
const attendus = [
|
||||
[resultat - 1, true],
|
||||
[resultat, true],
|
||||
[resultat + 1, false],
|
||||
];
|
||||
for (const [n, attendu] of attendus) {
|
||||
if (n < min || n > max) continue;
|
||||
if ((await lire(predicat, n)) !== attendu) {
|
||||
throw new RangeError(`plusGrandTenant : prédicat non monotone, ${attendu ? 'faux' : 'vrai'} en ${n} à la relecture, seuil lu ${resultat}`);
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
const estPositifFini = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur) && valeur > 0;
|
||||
const estPositifOuNul = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur) && valeur >= 0;
|
||||
|
||||
/**
|
||||
* Le plus petit k de la courbe au-delà duquel tout k de la courbe tient le
|
||||
* critère : son p95 vaut au plus critereMs. Les points se rangent par k
|
||||
* croissant ; le seuil est le point qui suit le dernier échec, ou le premier
|
||||
* point quand aucun n'échoue. Une courbe qui échoue à son plus grand k n'a
|
||||
* pas de seuil.
|
||||
*
|
||||
* @param {Array<{k: number, p95Ms: number}>} courbe k en px/cm, fini > 0 et
|
||||
* sans doublon ; p95Ms fini ≥ 0. La courbe reçue n'est pas modifiée ; ses
|
||||
* autres champs sont ignorés.
|
||||
* @param {number} critereMs fini > 0
|
||||
* @returns {{k: number|null, tenuPartout: boolean, jamaisTenu: boolean}}
|
||||
* @throws {TypeError} sur autre chose qu'une liste
|
||||
* @throws {RangeError} une courbe vide — elle ne prouve rien —, un point
|
||||
* malformé, un k en double, un critère qui n'est pas un nombre fini > 0
|
||||
*/
|
||||
export function seuilDepuisCourbe(courbe, critereMs) {
|
||||
if (!Array.isArray(courbe)) throw new TypeError('seuilDepuisCourbe : une courbe est attendue');
|
||||
if (courbe.length === 0) throw new RangeError('seuilDepuisCourbe : courbe vide');
|
||||
if (!estPositifFini(critereMs)) throw new RangeError(`seuilDepuisCourbe : critère fini > 0 attendu, reçu ${String(critereMs)}`);
|
||||
for (const point of courbe) {
|
||||
if (!estPositifFini(point?.k) || !estPositifOuNul(point?.p95Ms)) {
|
||||
throw new RangeError(`seuilDepuisCourbe : point refusé, k ${String(point?.k)} et p95 ${String(point?.p95Ms)}`);
|
||||
}
|
||||
}
|
||||
const points = [...courbe].sort((a, b) => a.k - b.k);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
if (points[i].k === points[i - 1].k) throw new RangeError(`seuilDepuisCourbe : k ${points[i].k} en double`);
|
||||
}
|
||||
const tient = points.map(({ p95Ms }) => p95Ms <= critereMs);
|
||||
const dernierEchec = tient.lastIndexOf(false);
|
||||
return {
|
||||
k: dernierEchec === points.length - 1 ? null : points[dernierEchec + 1].k,
|
||||
tenuPartout: dernierEchec === -1,
|
||||
jamaisTenu: !tient.includes(true),
|
||||
};
|
||||
}
|
||||
|
||||
// La médiane d'une liste non vide, la moyenne des deux du milieu pour une
|
||||
// longueur paire.
|
||||
function mediane(valeurs) {
|
||||
const triees = [...valeurs].sort((a, b) => a - b);
|
||||
const milieu = Math.floor(triees.length / 2);
|
||||
return triees.length % 2 === 1 ? triees[milieu] : (triees[milieu - 1] + triees[milieu]) / 2;
|
||||
}
|
||||
|
||||
// Une valeur > 0 arrondie par excès à deux chiffres significatifs : le
|
||||
// registre l'écrit telle quelle, et elle ne passe jamais sous la valeur
|
||||
// reçue. toPrecision ôte le bruit du flottant que le produit laisse.
|
||||
function parExces(valeur) {
|
||||
const pas = 10 ** (Math.floor(Math.log10(valeur)) - 1);
|
||||
let crans = Math.ceil(valeur / pas - 1e-9);
|
||||
if (Number((crans * pas).toPrecision(2)) < valeur) crans += 1;
|
||||
return Number((crans * pas).toPrecision(2));
|
||||
}
|
||||
|
||||
// Les mesures d'une série : au moins MESURES_MIN longueurs finies > 0.
|
||||
function exigerMesures(nom, mesures) {
|
||||
if (!Array.isArray(mesures) || mesures.length < MESURES_MIN || !mesures.every(estPositifFini)) {
|
||||
throw new RangeError(`toleranceTemoin : ${nom}, au moins ${MESURES_MIN} longueurs finies > 0 attendues`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* La tolérance relative de la chaîne témoin (§ 19.4) : le plus grand du
|
||||
* quart de l'écart relatif qu'introduit le repli et de trois fois la gigue
|
||||
* de la police embarquée, arrondi par excès à deux chiffres significatifs.
|
||||
* Chaque écart se rapporte à la longueur attendue, celle que donnent les
|
||||
* avances de la police :
|
||||
* - ecartRepli : |médiane(repliPx) − attenduePx| / attenduePx ;
|
||||
* - gigue : (max − min) des mesures de la police embarquée / attenduePx ;
|
||||
* - biais : |médiane(embarqueePx) − attenduePx| / attenduePx.
|
||||
* La tolérance se refuse — valeur null, refus qui dit pourquoi — quand elle
|
||||
* n'écarte pas le repli (valeur ≥ ecartRepli) ou qu'elle rejetterait la
|
||||
* police embarquée elle-même (biais > valeur) : la mesure ne voit alors pas
|
||||
* ce qu'elle doit voir.
|
||||
*
|
||||
* @param {{attenduePx: number, embarqueePx: number[], repliPx: number[]}} mesures
|
||||
* au moins dix longueurs rendues de chaque police, en px
|
||||
* @returns {{valeur: number|null, ecartRepli: number, gigue: number, biais: number, refus: string|null}}
|
||||
* @throws {RangeError} une longueur attendue qui n'est pas finie > 0, moins
|
||||
* de dix mesures, une mesure qui n'est pas finie > 0
|
||||
*/
|
||||
export function toleranceTemoin({ attenduePx, embarqueePx, repliPx }) {
|
||||
if (!estPositifFini(attenduePx)) throw new RangeError(`toleranceTemoin : longueur attendue finie > 0, reçu ${String(attenduePx)}`);
|
||||
exigerMesures('police embarquée', embarqueePx);
|
||||
exigerMesures('repli', repliPx);
|
||||
const relatif = (ecart) => Number((ecart / attenduePx).toPrecision(12));
|
||||
const ecartRepli = relatif(Math.abs(mediane(repliPx) - attenduePx));
|
||||
const gigue = relatif(Math.max(...embarqueePx) - Math.min(...embarqueePx));
|
||||
const biais = relatif(Math.abs(mediane(embarqueePx) - attenduePx));
|
||||
const brute = Math.max(ecartRepli / 4, 3 * gigue);
|
||||
const mesure = { ecartRepli, gigue, biais };
|
||||
if (brute === 0 || parExces(brute) >= ecartRepli) {
|
||||
return { valeur: null, ...mesure, refus: 'la tolérance n’écarte pas le repli : le témoin ne distingue pas les deux polices' };
|
||||
}
|
||||
const valeur = parExces(brute);
|
||||
if (biais > valeur) {
|
||||
return { valeur: null, ...mesure, refus: 'la police embarquée s’écarte de ses avances au-delà de la tolérance : ce n’est pas elle qui est rendue' };
|
||||
}
|
||||
return { valeur, ...mesure, refus: null };
|
||||
}
|
||||
257
scripts/banc/seuils.test.js
Normal file
257
scripts/banc/seuils.test.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des seuils du banc (§ 19.10, § 19.4, § 6.2) : la dichotomie du
|
||||
// plus grand tenant sur des prédicats écrits, dont le seuil se lit à la main ;
|
||||
// le seuil d'une courbe de temps par image sur des courbes écrites ; la
|
||||
// tolérance de la chaîne témoin sur des longueurs écrites. Les prédicats
|
||||
// comptent leurs appels : une dichotomie qui balaierait tout l'intervalle se
|
||||
// verrait.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CRITERE_TEMPS_IMAGE_MS, plusGrandTenant, seuilDepuisCourbe, toleranceTemoin } from './seuils.js';
|
||||
|
||||
// Un prédicat qui note chaque n reçu, dans l'ordre des appels.
|
||||
function compte(predicat) {
|
||||
const appels = [];
|
||||
const enveloppe = (n) => {
|
||||
appels.push(n);
|
||||
return predicat(n);
|
||||
};
|
||||
return { enveloppe, appels };
|
||||
}
|
||||
|
||||
describe('le critère du temps par image', () => {
|
||||
test('33 ms, le choix de l’utilisateur, écrit ici et non au registre', () => {
|
||||
assert.equal(CRITERE_TEMPS_IMAGE_MS, 33);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plusGrandTenant : la dichotomie, la monotonie vérifiée', () => {
|
||||
test('un seuil connu, 17 sur [2, 200] : rendu tel quel, en treize appels au plus', async () => {
|
||||
const { enveloppe, appels } = compte((n) => n <= 17);
|
||||
assert.equal(await plusGrandTenant(enveloppe, 2, 200), 17);
|
||||
assert.ok(appels.length <= 13, `${appels.length} appels : ${appels.join(' ')}`);
|
||||
assert.deepEqual(appels.slice(-3), [16, 17, 18], 'le résultat et ses deux voisins sont relus');
|
||||
});
|
||||
|
||||
test('un prédicat asynchrone vaut un prédicat synchrone', async () => {
|
||||
assert.equal(await plusGrandTenant(async (n) => n <= 41, 2, 200), 41);
|
||||
});
|
||||
|
||||
test('chaque seuil de [2, 40] se retrouve, bornes comprises', async () => {
|
||||
for (let seuil = 2; seuil <= 40; seuil += 1) {
|
||||
assert.equal(await plusGrandTenant((n) => n <= seuil, 2, 40), seuil, String(seuil));
|
||||
}
|
||||
});
|
||||
|
||||
test('tenu partout : le maximum', async () => {
|
||||
assert.equal(await plusGrandTenant(() => true, 2, 200), 200);
|
||||
});
|
||||
|
||||
test('jamais vrai : null', async () => {
|
||||
assert.equal(await plusGrandTenant(() => false, 2, 200), null);
|
||||
});
|
||||
|
||||
test('un intervalle d’un seul entier : lui, ou null', async () => {
|
||||
assert.equal(await plusGrandTenant(() => true, 5, 5), 5);
|
||||
assert.equal(await plusGrandTenant(() => false, 5, 5), null);
|
||||
});
|
||||
|
||||
test('non monotone sous le résultat : un trou à 19 sous un seuil de 20 lève en nommant le voisin', async () => {
|
||||
const trou = (n) => n <= 20 && n !== 19;
|
||||
await assert.rejects(plusGrandTenant(trou, 1, 40), { name: 'RangeError', message: /non monotone.*19/ });
|
||||
});
|
||||
|
||||
test('non monotone au-dessus du résultat : un prédicat qui change d’avis à la relecture lève', async () => {
|
||||
// Le premier appel en 21 répond « tient », le second « ne tient pas » :
|
||||
// la dichotomie le croit tenu, la relecture du voisin le dément.
|
||||
const vus = new Map();
|
||||
const instable = (n) => {
|
||||
const fois = (vus.get(n) ?? 0) + 1;
|
||||
vus.set(n, fois);
|
||||
return n <= 20 || (n === 21 && fois === 1);
|
||||
};
|
||||
await assert.rejects(plusGrandTenant(instable, 1, 40), { name: 'RangeError', message: /non monotone/ });
|
||||
});
|
||||
|
||||
test('croissant plutôt que décroissant : faux au minimum, vrai au maximum, lève', async () => {
|
||||
await assert.rejects(plusGrandTenant((n) => n >= 10, 2, 200), { name: 'RangeError', message: /non monotone/ });
|
||||
});
|
||||
|
||||
test('un prédicat qui rend autre chose qu’un booléen est refusé', async () => {
|
||||
await assert.rejects(plusGrandTenant(() => 1, 2, 10), TypeError);
|
||||
await assert.rejects(plusGrandTenant(async () => undefined, 2, 10), TypeError);
|
||||
});
|
||||
|
||||
test('des bornes qui ne sont pas des entiers, ou inversées, sont refusées avant tout appel', async () => {
|
||||
const { enveloppe, appels } = compte(() => true);
|
||||
for (const [min, max] of [[2.5, 10], [2, Number.POSITIVE_INFINITY], [10, 2], ['2', 10]]) {
|
||||
await assert.rejects(plusGrandTenant(enveloppe, min, max), RangeError, `${min}, ${max}`);
|
||||
}
|
||||
await assert.rejects(plusGrandTenant(null, 2, 10), TypeError);
|
||||
assert.deepEqual(appels, []);
|
||||
});
|
||||
});
|
||||
|
||||
// Une courbe de temps par image : k en px/cm, p95 en ms.
|
||||
const courbe = (points) => points.map(([k, p95Ms]) => ({ k, p95Ms }));
|
||||
|
||||
describe('seuilDepuisCourbe : le plus petit k au-delà duquel tout k de la courbe tient', () => {
|
||||
test('monotone : le premier k qui tient', () => {
|
||||
const c = courbe([[0.2, 48], [0.3, 40], [0.4, 33], [0.5, 25], [0.6, 20]]);
|
||||
assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.4, tenuPartout: false, jamaisTenu: false });
|
||||
});
|
||||
|
||||
test('le critère tient à égalité : un p95 de 33 ms tient 33 ms', () => {
|
||||
assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 33.0001], [0.3, 33]]), 33), { k: 0.3, tenuPartout: false, jamaisTenu: false });
|
||||
});
|
||||
|
||||
test('non monotone : le seuil est au-delà du dernier échec, non au premier tenant', () => {
|
||||
const c = courbe([[0.2, 40], [0.3, 30], [0.4, 36], [0.5, 31], [0.6, 28]]);
|
||||
assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.5, tenuPartout: false, jamaisTenu: false });
|
||||
});
|
||||
|
||||
test('l’ordre de la courbe reçue est sans effet, et elle n’est pas modifiée', () => {
|
||||
const c = courbe([[0.6, 28], [0.3, 30], [0.5, 31], [0.2, 40], [0.4, 36]]);
|
||||
const copie = structuredClone(c);
|
||||
assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.5, tenuPartout: false, jamaisTenu: false });
|
||||
assert.deepEqual(c, copie);
|
||||
});
|
||||
|
||||
test('tout tenu : le plus petit k de la courbe, tenuPartout', () => {
|
||||
const c = courbe([[0.4, 20], [0.18, 30], [1, 12]]);
|
||||
assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.18, tenuPartout: true, jamaisTenu: false });
|
||||
});
|
||||
|
||||
test('rien tenu : k null, jamaisTenu', () => {
|
||||
assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 40], [0.5, 34]]), 33), { k: null, tenuPartout: false, jamaisTenu: true });
|
||||
});
|
||||
|
||||
test('un échec au plus grand k : k null, sans être jamais tenu', () => {
|
||||
assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 20], [0.5, 34]]), 33), { k: null, tenuPartout: false, jamaisTenu: false });
|
||||
});
|
||||
|
||||
test('une courbe vide est refusée : elle ne prouve rien', () => {
|
||||
assert.throws(() => seuilDepuisCourbe([], 33), RangeError);
|
||||
});
|
||||
|
||||
test('une courbe malformée, un k en double, un critère invalide sont refusés', () => {
|
||||
assert.throws(() => seuilDepuisCourbe(null, 33), TypeError);
|
||||
assert.throws(() => seuilDepuisCourbe(courbe([[0.2, Number.NaN]]), 33), RangeError);
|
||||
assert.throws(() => seuilDepuisCourbe(courbe([[-1, 20]]), 33), RangeError);
|
||||
assert.throws(() => seuilDepuisCourbe(courbe([[0.2, 20], [0.2, 40]]), 33), { name: 'RangeError', message: /0\.2/ });
|
||||
assert.throws(() => seuilDepuisCourbe(courbe([[0.2, 20]]), 0), RangeError);
|
||||
assert.throws(() => seuilDepuisCourbe([{ k: 0.2 }], 33), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
// Dix mesures identiques d'une longueur.
|
||||
const dix = (valeur) => Array.from({ length: 10 }, () => valeur);
|
||||
|
||||
describe('toleranceTemoin : le quart de l’écart du repli, au moins trois fois la gigue', () => {
|
||||
test('sans gigue : le quart de l’écart relatif du repli, arrondi par excès à deux chiffres', () => {
|
||||
// Écart du repli : 40 / 1000 = 0,04 ; son quart, 0,01.
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1040) });
|
||||
assert.deepEqual(t, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0, refus: null });
|
||||
});
|
||||
|
||||
test('un repli plus court compte comme un plus long : l’écart est en valeur absolue', () => {
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(950) });
|
||||
assert.equal(t.ecartRepli, 0.05);
|
||||
assert.equal(t.valeur, 0.013);
|
||||
});
|
||||
|
||||
test('une gigue qui dépasse le quart de l’écart l’emporte : trois fois elle', () => {
|
||||
// Gigue : (1004 − 1000) / 1000 = 0,004 ; trois fois, 0,012 > 0,01.
|
||||
const embarqueePx = [1000, 1004, 1002, 1001, 1003, 1000, 1002, 1001, 1004, 1003];
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1040) });
|
||||
assert.equal(t.gigue, 0.004);
|
||||
assert.equal(t.valeur, 0.012);
|
||||
assert.equal(t.refus, null);
|
||||
});
|
||||
|
||||
test('la valeur arrondie ne passe jamais sous le plus grand des deux termes', () => {
|
||||
const t = toleranceTemoin({ attenduePx: 977, embarqueePx: dix(977), repliPx: dix(1021.3) });
|
||||
assert.ok(t.valeur >= t.ecartRepli / 4, `${t.valeur} sous ${t.ecartRepli / 4}`);
|
||||
assert.equal(String(t.valeur).replace(/^0\.0*/, '').length <= 2, true, String(t.valeur));
|
||||
});
|
||||
|
||||
test('un repli que la gigue couvre est refusé : la tolérance laisserait passer le repli', () => {
|
||||
const embarqueePx = [1000, 1010, 1000, 1010, 1000, 1010, 1000, 1010, 1000, 1010];
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1020) });
|
||||
assert.equal(t.valeur, null);
|
||||
assert.match(t.refus, /repli/);
|
||||
});
|
||||
|
||||
test('un repli rendu à la longueur de la police embarquée est refusé', () => {
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1000) });
|
||||
assert.equal(t.valeur, null);
|
||||
assert.match(t.refus, /repli/);
|
||||
});
|
||||
|
||||
test('une police embarquée rendue loin de ses avances est refusée : ce n’est pas elle que l’on mesure', () => {
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1060), repliPx: dix(1200) });
|
||||
assert.equal(t.biais, 0.06);
|
||||
assert.equal(t.valeur, null);
|
||||
assert.match(t.refus, /embarquée/);
|
||||
});
|
||||
|
||||
test('neuf mesures sont refusées, dix acceptées, pour chacune des deux polices', () => {
|
||||
const neuf = (valeur) => dix(valeur).slice(1);
|
||||
assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: neuf(1000), repliPx: dix(1040) }), { name: 'RangeError', message: /embarquée.*10/ });
|
||||
assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: neuf(1040) }), { name: 'RangeError', message: /repli.*10/ });
|
||||
assert.equal(toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1040) }).valeur, 0.01);
|
||||
});
|
||||
|
||||
test('le biais se lit sur la médiane de la police embarquée : ni son premier élément, ni son min, ni son max', () => {
|
||||
// Triées : 1000 1000 1001 1001 1001 | 1003 1003 1003 1003 1009 ; la
|
||||
// médiane paire vaut (1001 + 1003) / 2 = 1002, soit un biais de 0,002.
|
||||
// Le premier élément donnerait 0,001, le min 0, le max 0,009, le seul
|
||||
// élément de droite du milieu 0,003.
|
||||
const embarqueePx = [1001, 1009, 1000, 1003, 1001, 1003, 1000, 1003, 1001, 1003];
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1040) });
|
||||
assert.deepEqual(t, { valeur: 0.027, ecartRepli: 0.04, gigue: 0.009, biais: 0.002, refus: null });
|
||||
});
|
||||
|
||||
test('l’écart du repli se lit sur sa médiane : une valeur aberrante ne le déplace pas', () => {
|
||||
// Triées : 1020 1030 1030 1030 1030 | 1050 1050 1050 1060 2000 ; médiane
|
||||
// (1030 + 1050) / 2 = 1040, soit 0,04. Le premier élément donnerait 0,03,
|
||||
// le min 0,02, le max 1, le seul élément de droite du milieu 0,05.
|
||||
const repliPx = [1030, 1050, 1020, 1050, 1030, 1060, 1030, 1050, 2000, 1030];
|
||||
const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx });
|
||||
assert.deepEqual(t, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0, refus: null });
|
||||
});
|
||||
|
||||
test('une série de longueur impaire prend l’élément du milieu', () => {
|
||||
// Triées : 1020 1030 1030 1030 1030 1040 1050 1050 1050 1060 2000 ; la
|
||||
// médiane est 1040, et non (1030 + 1040) / 2.
|
||||
const repliPx = [1030, 1050, 1020, 1050, 1030, 1040, 1060, 1030, 1050, 2000, 1030];
|
||||
assert.equal(toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx }).ecartRepli, 0.04);
|
||||
});
|
||||
|
||||
test('un biais égal à la tolérance est admis, un biais au-dessus refusé', () => {
|
||||
const egal = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1010), repliPx: dix(1040) });
|
||||
assert.deepEqual(egal, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0.01, refus: null });
|
||||
const dessus = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1011), repliPx: dix(1040) });
|
||||
assert.equal(dessus.valeur, null);
|
||||
assert.match(dessus.refus, /embarquée/);
|
||||
});
|
||||
|
||||
test('une tolérance égale à l’écart du repli est refusée, une tolérance juste en dessous admise', () => {
|
||||
// Gigue : 0,02 ; trois fois, 0,06, l'écart même du repli rendu à 1060.
|
||||
const alterne = Array.from({ length: 10 }, (_, i) => (i % 2 === 0 ? 1000 : 1020));
|
||||
const egal = toleranceTemoin({ attenduePx: 1000, embarqueePx: alterne, repliPx: dix(1060) });
|
||||
assert.equal(egal.ecartRepli, 0.06);
|
||||
assert.equal(egal.valeur, null);
|
||||
assert.match(egal.refus, /repli/);
|
||||
const dessous = toleranceTemoin({ attenduePx: 1000, embarqueePx: alterne, repliPx: dix(1061) });
|
||||
assert.deepEqual(dessous, { valeur: 0.06, ecartRepli: 0.061, gigue: 0.02, biais: 0.01, refus: null });
|
||||
});
|
||||
|
||||
test('moins de dix mesures, une longueur non finie ou nulle sont refusées', () => {
|
||||
assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: [1000, 1000], repliPx: dix(1040) }), RangeError);
|
||||
assert.throws(() => toleranceTemoin({ attenduePx: 0, embarqueePx: dix(1000), repliPx: dix(1040) }), RangeError);
|
||||
assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: [...dix(1040).slice(1), Number.NaN] }), RangeError);
|
||||
});
|
||||
});
|
||||
190
scripts/banc/sieges_lisibles.banc.js
Normal file
190
scripts/banc/sieges_lisibles.banc.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// © 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();
|
||||
});
|
||||
});
|
||||
166
scripts/banc/temoin.banc.js
Normal file
166
scripts/banc/temoin.banc.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La tolérance de la chaîne témoin (§ 19.4, § 14.1), relevée par le banc :
|
||||
// la longueur rendue de CHAINE_TEMOIN, par getComputedTextLength, dix fois
|
||||
// sur la police embarquée, puis dix fois sur la famille de repli forcée — la
|
||||
// police de l'interface sans Noto Sans, ce que le moteur rend quand la face
|
||||
// manque. La longueur attendue se calcule des avances de la police
|
||||
// (longueurTemoin, fontkit), jamais saisie. toleranceTemoin (seuils.js) en
|
||||
// tire la valeur proposée : le quart de l'écart du repli, au moins trois fois
|
||||
// la gigue des dix mesures de la police embarquée.
|
||||
//
|
||||
// Chaque mesure se prend sur un <text> neuf, décalé d'un dixième de pixel
|
||||
// de la précédente : la gigue couvre le placement du texte au sous-pixel,
|
||||
// non la seule relecture d'une longueur gardée. Crénage et ligatures sont
|
||||
// coupés (font-kerning: none, font-variant-ligatures: none) : la longueur
|
||||
// attendue est la somme des avances, sans l'un ni l'autre. Le corps est
|
||||
// celui du texte courant de l'interface, 16 px, où l'arrondi des avances
|
||||
// pèse le plus.
|
||||
//
|
||||
// La police embarquée doit être chargée et la première famille de la
|
||||
// police de l'interface : sinon la mesure se refuse (C8). Une tolérance
|
||||
// refusée s'écrit tout de même, valeur proposée null et refus nommé, et
|
||||
// l'épreuve échoue : le relevé le dit, et le passage tombe.
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { commands } from 'vitest/browser';
|
||||
import { CONSTANTES } from '../../src/geometrie/constantes.js';
|
||||
import { creerContexte } from '../../test/navigateur/ecrans.js';
|
||||
import { CHAINE_TEMOIN, longueurTemoin } from '../../test/temoin.js';
|
||||
import { controlerCadre, ecrireSortie } from './navigateur.js';
|
||||
import { verifierReleve } from './releve.js';
|
||||
import { toleranceTemoin } from './seuils.js';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
const FAMILLE = 'Noto Sans';
|
||||
const CORPS_PX = 16;
|
||||
const MESURES = 10;
|
||||
const NOM = 'TOLERANCE_CHAINE_TEMOIN';
|
||||
|
||||
// Le nom d'une famille sans ses guillemets.
|
||||
const sansGuillemets = (texte) => texte.trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
|
||||
let contexte = null;
|
||||
let octets = null;
|
||||
let mesure = null;
|
||||
|
||||
/**
|
||||
* Les familles de la police de l'interface, dans l'ordre de
|
||||
* --police-interface, sans guillemets.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function famillesInterface() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--police-interface').split(',').map(sansGuillemets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dix longueurs rendues de la chaîne témoin, en px, sur la famille donnée
|
||||
* (une valeur de font-family), en graisse 400, au corps CORPS_PX : chacune
|
||||
* sur un <text> neuf, posé à x = rang / 10 px, retiré après sa lecture.
|
||||
* @param {string} famille
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function longueursRendues(famille) {
|
||||
const svg = document.createElementNS(SVG, 'svg');
|
||||
svg.setAttribute('width', '800');
|
||||
svg.setAttribute('height', '40');
|
||||
document.body.append(svg);
|
||||
try {
|
||||
return Array.from({ length: MESURES }, (_, rang) => {
|
||||
const texte = document.createElementNS(SVG, 'text');
|
||||
texte.setAttribute('x', String(rang / 10));
|
||||
texte.setAttribute('y', '30');
|
||||
texte.setAttribute(
|
||||
'style',
|
||||
`font-family: ${famille}; font-size: ${CORPS_PX}px; font-weight: 400; font-kerning: none; font-variant-ligatures: none`,
|
||||
);
|
||||
texte.textContent = CHAINE_TEMOIN;
|
||||
svg.append(texte);
|
||||
const longueur = texte.getComputedTextLength();
|
||||
texte.remove();
|
||||
return longueur;
|
||||
});
|
||||
} finally {
|
||||
svg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai quand la face 400 de la police embarquée est chargée dans le document. */
|
||||
const faceChargee = () =>
|
||||
[...document.fonts].some((face) => sansGuillemets(face.family) === FAMILLE && face.weight === '400' && face.status === 'loaded');
|
||||
|
||||
beforeAll(async () => {
|
||||
await controlerCadre();
|
||||
contexte = creerContexte();
|
||||
const m = await contexte.monter();
|
||||
({ regulier: octets } = await m.polices());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await contexte?.defaire();
|
||||
});
|
||||
|
||||
describe('la tolérance de la chaîne témoin (§ 19.4)', () => {
|
||||
test('la police embarquée est chargée et nommée en tête de la police de l’interface ; le repli forcé est le reste de la liste', () => {
|
||||
const familles = famillesInterface();
|
||||
expect(familles[0]).toBe(FAMILLE);
|
||||
expect(familles.length).toBeGreaterThan(1);
|
||||
expect(familles.slice(1)).not.toContain(FAMILLE);
|
||||
expect(faceChargee()).toBe(true);
|
||||
});
|
||||
|
||||
test('dix mesures sur chaque police ; la tolérance proposée écarte le repli et admet la police embarquée', async () => {
|
||||
const familles = famillesInterface();
|
||||
const repli = familles.slice(1).join(', ');
|
||||
const attenduePx = longueurTemoin(octets, CORPS_PX);
|
||||
const embarqueePx = longueursRendues(`'${FAMILLE}', ${repli}`);
|
||||
const repliPx = longueursRendues(repli);
|
||||
const police = familles[0] === FAMILLE && faceChargee() ? FAMILLE : null;
|
||||
const tolerance = toleranceTemoin({ attenduePx, embarqueePx, repliPx });
|
||||
const refus = police === null ? `police ${familles[0]} : la mesure se prend sur ${FAMILLE}` : tolerance.refus;
|
||||
mesure = {
|
||||
nom: NOM,
|
||||
unite: 'rapport',
|
||||
valeurProposee: refus === null ? tolerance.valeur : null,
|
||||
critere:
|
||||
`le quart de l’écart relatif du repli (${repli}) à la longueur des avances de ${FAMILLE}, et au moins trois fois la ` +
|
||||
`gigue de ${MESURES} mesures ; chaîne témoin à ${CORPS_PX} px, graisse 400, crénage et ligatures coupés`,
|
||||
police,
|
||||
ralentissementCpu: 1,
|
||||
corpsPx: CORPS_PX,
|
||||
attenduePx,
|
||||
embarqueePx,
|
||||
repliPx,
|
||||
ecartRepli: tolerance.ecartRepli,
|
||||
gigue: tolerance.gigue,
|
||||
biais: tolerance.biais,
|
||||
refus,
|
||||
};
|
||||
expect(refus).toBeNull();
|
||||
expect(mesure.valeurProposee).toBeGreaterThan(0);
|
||||
expect(mesure.valeurProposee).toBeLessThan(tolerance.ecartRepli);
|
||||
expect(mesure.valeurProposee).toBeGreaterThanOrEqual(tolerance.biais);
|
||||
});
|
||||
|
||||
test('la mesure s’écrit dans scripts/banc/sorties/temoin.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('temoin', objet);
|
||||
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/temoin.json'))).toEqual(objet);
|
||||
expect(mesure.refus).toBeNull();
|
||||
});
|
||||
});
|
||||
238
scripts/banc/temps_image.banc.js
Normal file
238
scripts/banc/temps_image.banc.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le temps par image pendant un déplacement de vue (§ 19.10, § 17 point 6),
|
||||
// relevé par le banc sur la grande démonstration et ses 33 tables avec
|
||||
// listes. Une garde, pas un arbitre (Q12 a) : K_LISTES est une constante
|
||||
// manuelle, un seuil de lisibilité, et le coût des listes ne dépend pas du
|
||||
// zoom ; le banc vérifie que, listes dessinées à tout zoom, un glissement
|
||||
// tient CRITERE_TEMPS_IMAGE_MS au p95 sur la machine de référence — le poste,
|
||||
// processeur ralenti ×4 (Q13 b).
|
||||
//
|
||||
// Les listes se dessinent à tout zoom : le registre est remplacé pour ce
|
||||
// fichier, K_LISTES à 0 (vi.mock), toute autre constante inchangée. La
|
||||
// première épreuve constate que le remplacement atteint le dessin : au zoom
|
||||
// ajusté, sous la valeur du registre, les 33 listes sont là.
|
||||
//
|
||||
// Le déroulé : le plan ouvert au zoom ajusté, puis cran de molette après
|
||||
// cran (×1,1) au centre du plan jusqu'à k ≥ 1. À chaque k, sans
|
||||
// ralentissement puis ralenti ×4, trois glissements de 60 pas sur le sol du
|
||||
// plan, de part et d'autre d'un point libre de toute table ; le p95 retenu
|
||||
// est la médiane des trois. Chaque pas doit changer le transform du groupe
|
||||
// [data-vue] en quelques images : sinon le geste n'a rien dessiné, et la
|
||||
// mesure lève. Les 33 [data-liste] sont relues à chaque k.
|
||||
//
|
||||
// La sortie temps_image.json porte la courbe — chaque point nomme son
|
||||
// ralentissement —, le critère, le seuil que seuilDepuisCourbe lit sur la
|
||||
// courbe à ×4, et le verdict : « tenu » quand le p95 à ×4 tient le critère à
|
||||
// tout k. Le verdict est une donnée du relevé : l'épreuve ne tombe que sur
|
||||
// une mesure qui ne voit pas ce qu'elle mesure.
|
||||
import { flushSync } from 'svelte';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
import { commands } from 'vitest/browser';
|
||||
import { CONSTANTES, constante } from '../../src/geometrie/constantes.js';
|
||||
import { vueDepuisTransform } from '../../src/geometrie/vue.js';
|
||||
import { texteLivre } from '../../src/demo/livrees.js';
|
||||
import { analyser } from '../../src/stockage/document.js';
|
||||
import { creerContexte, ouvrirEvenement } from '../../test/navigateur/ecrans.js';
|
||||
import { molette, pointer } from '../../test/navigateur/entrees.js';
|
||||
import { controlerCadre, ecrireSortie, mesurerImages, ralentirCpu } from './navigateur.js';
|
||||
import { verifierReleve } from './releve.js';
|
||||
import { CRITERE_TEMPS_IMAGE_MS, seuilDepuisCourbe } from './seuils.js';
|
||||
import { statistiques } from './statistiques.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 NOM = 'TEMPS_IMAGE_LISTES';
|
||||
const TABLES = 33;
|
||||
const PAS = 60;
|
||||
const PAS_PX = 5;
|
||||
const GLISSEMENTS = 3;
|
||||
const RALENTISSEMENTS = Object.freeze([1, 4]);
|
||||
const RALENTISSEMENT_REFERENCE = 4;
|
||||
const K_FIN = 1;
|
||||
|
||||
// Le nombre d'images qu'un pas a pour changer la vue.
|
||||
const IMAGES_PAR_PAS = 10;
|
||||
// L'écart, en px, entre deux points d'essai dans la recherche d'un point du sol.
|
||||
const MAILLE_PX = 20;
|
||||
|
||||
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
const mediane = (valeurs) => statistiques(valeurs).medianeMs;
|
||||
|
||||
let contexte = null;
|
||||
let montage = null;
|
||||
const courbe = [];
|
||||
let mesure = null;
|
||||
|
||||
const groupe = () => montage.racine.querySelector('[data-vue]');
|
||||
const transformation = () => groupe().getAttribute('transform');
|
||||
const kCourant = () => vueDepuisTransform(transformation()).k;
|
||||
const plan = () => montage.racine.querySelector('svg[data-plan]');
|
||||
const listes = () => montage.racine.querySelectorAll('[data-liste]').length;
|
||||
|
||||
// Ce qu'un appui saisirait au point : une table, une chaise, une poignée
|
||||
// ou une pastille, sinon le sol du plan. Les listes ne reçoivent pas le
|
||||
// pointeur.
|
||||
function surLeSol(x, y) {
|
||||
const cible = document.elementFromPoint(x, y);
|
||||
return cible !== null && plan().contains(cible) && cible.closest('[data-table], [data-siege], [data-poignee], [data-pastille]') === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un point du sol du plan, le plus proche du centre de sa boîte, dont le
|
||||
* point à x + 150 et l'aller-retour restent dans le plan. Lève si la maille
|
||||
* n'en trouve aucun.
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
function pointDuSol() {
|
||||
const boite = plan().getBoundingClientRect();
|
||||
const cx = boite.left + boite.width / 2;
|
||||
const cy = boite.top + boite.height / 2;
|
||||
const candidats = [];
|
||||
for (let y = boite.top + MAILLE_PX; y < boite.bottom - MAILLE_PX; y += MAILLE_PX) {
|
||||
for (let x = boite.left + MAILLE_PX; x + PAS * PAS_PX < boite.right - MAILLE_PX; x += MAILLE_PX) {
|
||||
candidats.push({ x, y, d: Math.hypot(x - cx, y - cy) });
|
||||
}
|
||||
}
|
||||
candidats.sort((a, b) => a.d - b.d || a.y - b.y || a.x - b.x);
|
||||
const trouve = candidats.find(({ x, y }) => surLeSol(x, y));
|
||||
if (trouve === undefined) throw new Error(`aucun point du sol dans le plan à k ${kCourant()}`);
|
||||
return { x: trouve.x, y: trouve.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Un glissement de PAS pas de PAS_PX px, horizontal, depuis depart, sens 1
|
||||
* vers la droite, -1 vers la gauche. Chaque pas attend que le transform du
|
||||
* groupe [data-vue] change, au plus IMAGES_PAR_PAS images, et lève sinon.
|
||||
*/
|
||||
async function glissement(depart, sens) {
|
||||
await pointer('presser', depart.x, depart.y);
|
||||
try {
|
||||
for (let i = 1; i <= PAS; i += 1) {
|
||||
const avant = transformation();
|
||||
await pointer('bouger', depart.x + sens * i * PAS_PX, depart.y);
|
||||
let images = 0;
|
||||
while (transformation() === avant) {
|
||||
if (images >= IMAGES_PAR_PAS) throw new Error(`le pas ${i} du glissement n’a pas déplacé la vue à k ${kCourant()}`);
|
||||
await uneImage();
|
||||
images += 1;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await pointer('relacher', depart.x + sens * PAS * PAS_PX, depart.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Un cran de molette avant au centre du plan ; attend que la vue change.
|
||||
async function cran() {
|
||||
const boite = plan().getBoundingClientRect();
|
||||
const avant = transformation();
|
||||
await molette(boite.left + boite.width / 2, boite.top + boite.height / 2, -100);
|
||||
for (let images = 0; transformation() === avant; images += 1) {
|
||||
if (images >= IMAGES_PAR_PAS) throw new Error('le cran de molette n’a pas zoomé');
|
||||
flushSync();
|
||||
await uneImage();
|
||||
}
|
||||
}
|
||||
|
||||
// Le point de la courbe à k et à un ralentissement : trois glissements, un
|
||||
// aller, un retour, un aller, chacun relevé par mesurerImages ; p95Ms est la
|
||||
// médiane de leurs p95. Un dernier retour, sans ralentissement ni mesure,
|
||||
// ramène la vue.
|
||||
async function mesurerPoint(k, ralentissementCpu) {
|
||||
const depart = pointDuSol();
|
||||
const arrivee = { x: depart.x + PAS * PAS_PX, y: depart.y };
|
||||
await ralentirCpu(ralentissementCpu);
|
||||
const glissements = [];
|
||||
try {
|
||||
for (let rang = 0; rang < GLISSEMENTS; rang += 1) {
|
||||
const aller = rang % 2 === 0;
|
||||
glissements.push(statistiques(await mesurerImages(() => glissement(aller ? depart : arrivee, aller ? 1 : -1))));
|
||||
}
|
||||
} finally {
|
||||
await ralentirCpu(1);
|
||||
}
|
||||
// Un retour non mesuré : la vue revient où elle était, et la suite de la
|
||||
// courbe glisse sur le même morceau du plan.
|
||||
await glissement(arrivee, -1);
|
||||
return { k, ralentissementCpu, p95Ms: mediane(glissements.map(({ p95Ms }) => p95Ms)), glissements };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await controlerCadre();
|
||||
contexte = creerContexte();
|
||||
montage = await ouvrirEvenement(contexte, analyser(texteLivre('grande')).charge, { section: 'plan' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ralentirCpu(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await contexte?.defaire();
|
||||
});
|
||||
|
||||
describe('le temps par image d’un glissement, listes à tout zoom (§ 19.10, § 17 point 6)', () => {
|
||||
test(`le registre de ce fichier porte K_LISTES à 0 : au zoom ajusté, sous la valeur du registre, les ${TABLES} listes sont là`, () => {
|
||||
expect(constante('K_LISTES')).toBe(0);
|
||||
const registre = CONSTANTES.find(({ nom }) => nom === 'K_LISTES').valeur;
|
||||
expect(kCourant()).toBeLessThan(registre);
|
||||
expect(montage.racine.querySelectorAll('[data-table]').length).toBe(TABLES);
|
||||
expect(listes()).toBe(TABLES);
|
||||
});
|
||||
|
||||
test(`de l’ajusté à k ≥ ${K_FIN}, cran après cran : ${GLISSEMENTS} glissements de ${PAS} pas à ×1 et ×4, chaque pas déplace la vue, ${TABLES} listes à chaque k`, async () => {
|
||||
for (;;) {
|
||||
const k = kCourant();
|
||||
expect(listes(), `k ${k}`).toBe(TABLES);
|
||||
for (const ralentissementCpu of RALENTISSEMENTS) courbe.push(await mesurerPoint(k, ralentissementCpu));
|
||||
if (k >= K_FIN) break;
|
||||
await cran();
|
||||
}
|
||||
expect(courbe.length).toBeGreaterThanOrEqual(2 * RALENTISSEMENTS.length);
|
||||
for (const { glissements } of courbe) {
|
||||
for (const { images } of glissements) expect(images).toBeGreaterThanOrEqual(PAS);
|
||||
}
|
||||
});
|
||||
|
||||
test('la courbe à ×4 donne le seuil et le verdict ; la mesure s’écrit dans scripts/banc/sorties/temps_image.json, relue telle quelle, et se range dans un relevé', async () => {
|
||||
const reference = courbe.filter(({ ralentissementCpu }) => ralentissementCpu === RALENTISSEMENT_REFERENCE);
|
||||
const seuil = seuilDepuisCourbe(reference, CRITERE_TEMPS_IMAGE_MS);
|
||||
mesure = {
|
||||
nom: NOM,
|
||||
unite: 'ms',
|
||||
valeurProposee: null,
|
||||
critere:
|
||||
`p95 du temps par image d’un glissement de ${PAS} pas, médiane de ${GLISSEMENTS} glissements, grande démonstration, ` +
|
||||
`${TABLES} listes dessinées à tout k ; tenu quand il vaut au plus ${CRITERE_TEMPS_IMAGE_MS} ms à ×${RALENTISSEMENT_REFERENCE}, ` +
|
||||
`à tout k de l’ajusté à ${K_FIN}`,
|
||||
critereMs: CRITERE_TEMPS_IMAGE_MS,
|
||||
ralentissementCpu: RALENTISSEMENT_REFERENCE,
|
||||
seuil,
|
||||
pireP95Ms: Math.max(...reference.map(({ p95Ms }) => p95Ms)),
|
||||
courbe,
|
||||
verdict: seuil.tenuPartout ? 'tenu' : 'nonTenu',
|
||||
};
|
||||
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: RALENTISSEMENT_REFERENCE,
|
||||
police: 'Noto Sans',
|
||||
},
|
||||
mesures: [mesure],
|
||||
};
|
||||
expect(verifierReleve(releve, CONSTANTES)).toEqual([]);
|
||||
await ecrireSortie('temps_image', objet);
|
||||
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/temps_image.json'))).toEqual(objet);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue