gestion_table_tournante_libre/scripts/banc/navigateur.js
Mathieu Benoit 079b158587 [ADD] bench: headless Chromium harness, statistics and make banc entry
Rendering thresholds must be measured in a real browser, apart from the
test series that guard behaviour. A separate Vitest project runs
scripts/banc/**/*.banc.js in headless Chromium at 1366 x 700, scale 1,
without frame-rate limit, CPU throttling set per run; the statistics module
gives medians and spreads over repeated samples. Outputs go to an ignored
folder. make banc and npm run banc name the entry; its orchestrator comes
with the bench measures.
Checked: red first; 3410 node tests green from the index; the bench project
runs its harness.

--- FR ---

[ADD] banc : harnais Chromium sans tête, statistiques, entrée make banc

Les seuils de rendu se mesurent dans un vrai navigateur, à part des séries
qui gardent le comportement. Un projet Vitest distinct joue
scripts/banc/**/*.banc.js dans Chromium sans tête à 1366 × 700, facteur 1,
sans limite d'images, le ralentissement du processeur réglé par lancement ;
le module de statistiques rend médianes et dispersions sur des mesures
répétées. Les sorties vont dans un dossier ignoré. make banc et npm run banc
nomment l'entrée ; son orchestrateur vient avec les mesures du banc.
Vérifié : rouge d'abord ; 3410 node verts depuis l'index ; le projet du
banc joue son harnais.

Assisted-by: Claude Opus 5.5
2026-10-09 13:03:37 -04:00

206 lines
8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les outils de page du banc (§ 19.10), pour les épreuves *.banc.js que
// joue vitest.banc.config.js, sous Chromium sans cadence d'images : le
// contrôle du cadre, le ralentissement du processeur, la mesure des
// intervalles entre images pendant un geste, les deux contrôles qui disent
// si cette mesure voit ce qu'elle mesure, et l'écriture d'une sortie.
//
// Une horloge réelle se lit ici, et seulement pour mesurer : l'instant que
// requestAnimationFrame passe à son rappel, et performance.now pour tenir
// une image occupée. Ce qui s'écrit ne porte aucun instant, seulement des
// durées.
import { inject } from 'vitest';
import { cdp, commands } from 'vitest/browser';
import { fixerCadre } from '../../test/navigateur/entrees.js';
import { statistiques } from './statistiques.js';
/** Le p95 sous lequel un plan vide prouve que la cadence n'est pas bridée :
* sous la marche de 16,7 ms d'une cadence calée à 60 images par seconde. */
export const SEUIL_CADENCE_MS = 16;
/** La durée d'une image chargée exprès, que la mesure doit voir. */
export const CHARGE_MS = 50;
// Le dossier des sorties, relatif à la racine du projet, où commands.writeFile
// résout un chemin ; ignoré par git. Un nom de sortie n'y désigne qu'un
// fichier de ce dossier.
const DOSSIER_SORTIES = 'scripts/banc/sorties';
const NOM_SORTIE = /^[a-z0-9_]+$/;
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
/**
* Pose le cadre du banc, que vitest.banc.config.js fournit par
* inject('cadreBanc'), et vérifie qu'il est rendu tel quel : innerWidth et
* innerHeight (fixerCadre), un facteur d'échelle de 1, et aucune mise à
* l'échelle du cadre de l'épreuve par la page qui le porte, d'un cadre à son
* parent : le rapport entre la largeur rendue de chaque <iframe> et sa
* largeur de mise en page vaut 1. Lève en nommant l'écart.
*
* @param {{lireFacteur?: () => number}} [options] lireFacteur rend le
* facteur d'échelle de la page, par défaut devicePixelRatio
* @returns {Promise<{largeur: number, hauteur: number, facteurEchelle: number}>}
*/
export async function controlerCadre({ lireFacteur = () => devicePixelRatio } = {}) {
const cadre = inject('cadreBanc');
if (cadre === undefined) throw new Error('cadre du banc absent : lancer par vitest.banc.config.js');
const { largeur, hauteur } = await fixerCadre(cadre.largeur, cadre.hauteur);
const facteurEchelle = lireFacteur();
if (facteurEchelle !== 1) throw new Error(`facteur d’échelle ${facteurEchelle}, attendu 1`);
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const element = fenetre.frameElement;
const echelle = element.getBoundingClientRect().width / element.offsetWidth;
if (echelle !== 1) throw new Error(`cadre mis à l’échelle ${echelle} par la page qui le porte`);
}
return { largeur, hauteur, facteurEchelle };
}
/**
* Ralentit le processeur de la page d'un facteur (CDP
* Emulation.setCPUThrottlingRate) ; 1 rend la vitesse normale. Le
* ralentissement dure jusqu'au prochain appel.
*
* @param {number} facteur un nombre fini ≥ 1
*/
export async function ralentirCpu(facteur) {
if (typeof facteur !== 'number' || !Number.isFinite(facteur) || facteur < 1) {
throw new RangeError(`ralentissement refusé : ${String(facteur)}`);
}
await cdp().send('Emulation.setCPUThrottlingRate', { rate: facteur });
}
// Un témoin d'un pixel, hors du flux et des gestes, que la mesure déplace
// d'un pixel à chaque image. Sans cadence, Chromium ne dessine au plus vite
// qu'une image qui change quelque chose : une image où rien ne change
// revient à sa cadence de repos, près de 17 ms, que rien ne distingue d'une
// cadence bridée ni d'une image lente. Entre deux pas d'un glissement par
// CDP, où la vue ne bouge pas, chaque image en serait une. Le témoin fait de
// chaque image une image dessinée, pour un coût de peinture d'un pixel.
function creerTemoin() {
const temoin = document.createElement('div');
temoin.setAttribute('aria-hidden', 'true');
temoin.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none;background:#000';
document.body.append(temoin);
let pair = false;
return {
bouger() {
pair = !pair;
temoin.style.transform = pair ? 'translateX(1px)' : 'none';
},
retirer() {
temoin.remove();
},
};
}
/**
* Les intervalles entre images pendant un geste, en millisecondes : l'écart
* entre les instants que requestAnimationFrame passe à deux images
* successives, de la première image qui suit l'appel à la première qui suit
* la fin du geste. Chaque image déplace le témoin d'un pixel, qui la fait
* dessiner. Un geste qui lève fait lever la mesure, le relevé arrêté et le
* témoin retiré.
*
* @param {() => Promise<void>} geste
* @returns {Promise<number[]>}
*/
export async function mesurerImages(geste) {
const instants = [];
const temoin = creerTemoin();
let suivre = true;
const noter = (instant) => {
instants.push(instant);
if (!suivre) return;
temoin.bouger();
requestAnimationFrame(noter);
};
requestAnimationFrame(noter);
try {
await geste();
} finally {
suivre = false;
// Le dernier rappel de noter, armé avant celui-ci, passe d'abord.
await uneImage();
temoin.retirer();
}
return instants.slice(1).map((instant, i) => instant - instants[i]);
}
/**
* Occupe chaque image de dureeMs : un rappel d'image qui attend, sans rendre
* la main, que performance.now ait avancé d'autant, puis se réarme. Rend la
* fonction qui l'arrête.
*
* @param {number} dureeMs
* @returns {() => void}
*/
export function chargerImages(dureeMs) {
let actif = true;
const charger = () => {
if (!actif) return;
const fin = performance.now() + dureeMs;
while (performance.now() < fin) {
// l'image reste occupée
}
requestAnimationFrame(charger);
};
requestAnimationFrame(charger);
return () => {
actif = false;
};
}
/**
* Le contrôle de cadence, sur un geste qui ne charge rien — un plan vide :
* rend les statistiques de ses intervalles, ou lève « cadence bridée » quand
* leur p95 atteint SEUIL_CADENCE_MS.
*
* @param {() => Promise<void>} geste
*/
export async function controlerCadence(geste) {
const stats = statistiques(await mesurerImages(geste));
if (stats.p95Ms >= SEUIL_CADENCE_MS) {
throw new Error(`cadence bridée : p95 ${stats.p95Ms.toFixed(2)} ms sur un plan vide, attendu sous ${SEUIL_CADENCE_MS} ms`);
}
return stats;
}
/**
* Le contrôle de charge : chaque image occupée de CHARGE_MS par charger
* (chargerImages par défaut, que les épreuves du harnais remplacent pour
* montrer le contrôle tomber) pendant le geste ; rend les statistiques, ou
* lève quand leur p95 reste sous CHARGE_MS — la mesure ne verrait pas une
* image lente.
*
* @param {() => Promise<void>} geste
* @param {{charger?: (dureeMs: number) => () => void}} [options]
*/
export async function controlerCharge(geste, { charger = chargerImages } = {}) {
const arreter = charger(CHARGE_MS);
let stats;
try {
stats = statistiques(await mesurerImages(geste));
} finally {
arreter();
}
if (stats.p95Ms < CHARGE_MS) {
throw new Error(`image chargée de ${CHARGE_MS} ms vue à ${stats.p95Ms.toFixed(2)} ms : la mesure ne voit pas la charge`);
}
return stats;
}
/**
* Écrit objet en JSON, indenté de deux espaces, dans
* scripts/banc/sorties/<nom>.json, par la commande writeFile de Vitest, qui
* crée le dossier au besoin. L'ordre des clés est celui de l'objet.
*
* @param {string} nom lettres minuscules sans accent, chiffres et « _ »
* @param {unknown} objet
* @throws {RangeError} sur un autre nom, avant toute écriture
*/
export async function ecrireSortie(nom, objet) {
if (typeof nom !== 'string' || !NOM_SORTIE.test(nom)) throw new RangeError(`nom de sortie refusé : « ${String(nom)} »`);
await commands.writeFile(`${DOSSIER_SORTIES}/${nom}.json`, `${JSON.stringify(objet, null, 2)}\n`);
}