diff --git a/scripts/banc/seuils.js b/scripts/banc/seuils.js new file mode 100644 index 0000000..9e0bbad --- /dev/null +++ b/scripts/banc/seuils.js @@ -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} predicat + * @param {number} min entier + * @param {number} max entier ≥ min + * @returns {Promise} 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 }; +} diff --git a/scripts/banc/seuils.test.js b/scripts/banc/seuils.test.js new file mode 100644 index 0000000..adea67c --- /dev/null +++ b/scripts/banc/seuils.test.js @@ -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); + }); +}); diff --git a/scripts/banc/sieges_lisibles.banc.js b/scripts/banc/sieges_lisibles.banc.js new file mode 100644 index 0000000..8b0b671 --- /dev/null +++ b/scripts/banc/sieges_lisibles.banc.js @@ -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 . +// +// 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 . 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 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 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(); + }); +}); diff --git a/scripts/banc/temoin.banc.js b/scripts/banc/temoin.banc.js new file mode 100644 index 0000000..0a0a2c4 --- /dev/null +++ b/scripts/banc/temoin.banc.js @@ -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 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 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(); + }); +}); diff --git a/scripts/banc/temps_image.banc.js b/scripts/banc/temps_image.banc.js new file mode 100644 index 0000000..f6366be --- /dev/null +++ b/scripts/banc/temps_image.banc.js @@ -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); + }); +});