[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
This commit is contained in:
parent
307ee526fc
commit
079b158587
11 changed files with 812 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,3 +7,4 @@ ios/
|
||||||
.vite/
|
.vite/
|
||||||
.vitest/
|
.vitest/
|
||||||
coverage/
|
coverage/
|
||||||
|
scripts/banc/sorties/
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -68,6 +68,10 @@ test_tout: ## Joue les trois séries
|
||||||
couverture: ## Mesure la couverture de la série node, module par module ; seuils durs du § 14.13
|
couverture: ## Mesure la couverture de la série node, module par module ; seuils durs du § 14.13
|
||||||
$(AVEC_NODE) npm run couverture
|
$(AVEC_NODE) npm run couverture
|
||||||
|
|
||||||
|
.PHONY: banc
|
||||||
|
banc: ## Relève les constantes du banc de mesure (§ 19.10), avant une livraison
|
||||||
|
$(AVEC_NODE) npm run banc
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Construit la page dans www/
|
build: ## Construit la page dans www/
|
||||||
$(AVEC_NODE) npm run build
|
$(AVEC_NODE) npm run build
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"test:long": "vitest run --project node-long",
|
"test:long": "vitest run --project node-long",
|
||||||
"test:navigateur": "vitest run --project navigateur",
|
"test:navigateur": "vitest run --project navigateur",
|
||||||
"test:tout": "vitest run",
|
"test:tout": "vitest run",
|
||||||
"couverture": "vitest run --project node --coverage --reporter=default"
|
"couverture": "vitest run --project node --coverage --reporter=default",
|
||||||
|
"banc": "node scripts/banc/lancer.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^8.5.2",
|
"@capacitor/cli": "^8.5.2",
|
||||||
|
|
|
||||||
266
scripts/banc/banc.test.js
Normal file
266
scripts/banc/banc.test.js
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Gardes du banc de mesure (§ 19.10, § 14.4, § 14.8). Le banc vit hors des
|
||||||
|
// trois séries de vitest.config.js : il se lance par sa propre commande, sur
|
||||||
|
// sa propre configuration, vitest.banc.config.js, qui ôte au Chromium sa
|
||||||
|
// cadence d'images. Une épreuve du banc que l'une des séries jouerait la
|
||||||
|
// mesurerait à cadence bridée, et alourdirait une série qui se lance à
|
||||||
|
// chaque modification ; une épreuve que le projet banc n'inclut pas ne se
|
||||||
|
// joue nulle part. Les gardes lisent par import vitest.config.js et le
|
||||||
|
// projet banc tel que scripts/banc/projet.js le décrit — vitest.banc.config.js
|
||||||
|
// charge Vitest, qu'une épreuve node ne charge pas (§ 14.8) —, le Makefile et
|
||||||
|
// package.json par le disque.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, matchesGlob, posix, relative, sep } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { chargements } from '../../test/chargements.js';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import configuration from '../../vitest.config.js';
|
||||||
|
import { optionsBanc, projetBanc } from './projet.js';
|
||||||
|
|
||||||
|
const RACINE = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
|
|
||||||
|
// Ce fichier, relatif à la racine. Ses données d'épreuve écrivent en
|
||||||
|
// chaînes des chargements des outils du banc, que le relevé des chargements
|
||||||
|
// lit comme les autres : le parcours de l'arbre réel le laisse de côté.
|
||||||
|
const CE_FICHIER = relative(RACINE, fileURLToPath(import.meta.url)).split(sep).join('/');
|
||||||
|
|
||||||
|
// Les outils de page du banc : un module qui les charge mesure dans la page.
|
||||||
|
const OUTILS_DU_BANC = 'scripts/banc/navigateur.js';
|
||||||
|
|
||||||
|
// Les drapeaux qui ôtent à Chromium sa cadence d'images.
|
||||||
|
const DRAPEAUX_CADENCE = ['--disable-frame-rate-limit', '--disable-gpu-vsync'];
|
||||||
|
|
||||||
|
// Ce que le parcours de l'arbre saute : les dépendances, à toute
|
||||||
|
// profondeur, et, à la racine, le dépôt et les sorties de construction et de
|
||||||
|
// mesure.
|
||||||
|
const HORS_DU_PARCOURS_A_LA_RACINE = new Set(['.git', 'www', 'dist-electron', 'coverage', '.vitest']);
|
||||||
|
const MODULE = /\.[cm]?js$/;
|
||||||
|
|
||||||
|
// Les modules JavaScript du projet, { chemin, texte }, chemins relatifs à la
|
||||||
|
// racine en barres obliques, triés.
|
||||||
|
function modulesDuProjet(racine) {
|
||||||
|
const modules = [];
|
||||||
|
const parcourir = (dossier) => {
|
||||||
|
for (const entree of readdirSync(dossier, { withFileTypes: true })) {
|
||||||
|
const chemin = join(dossier, entree.name);
|
||||||
|
const exclu = entree.name === 'node_modules' || (dossier === racine && HORS_DU_PARCOURS_A_LA_RACINE.has(entree.name));
|
||||||
|
if (entree.isDirectory() && !exclu) parcourir(chemin);
|
||||||
|
else if (entree.isFile() && MODULE.test(entree.name)) {
|
||||||
|
modules.push({ chemin: relative(racine, chemin).split(sep).join('/'), texte: readFileSync(chemin, 'utf8') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parcourir(racine);
|
||||||
|
return modules.sort((a, b) => (a.chemin < b.chemin ? -1 : a.chemin > b.chemin ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le module que vise un spécificateur, relatif à la racine, en barres
|
||||||
|
// obliques, suffixe de Vite (« ?… ») retiré : relatif au fichier, ou absolu
|
||||||
|
// depuis la racine comme Vite les résout ; null pour un paquet.
|
||||||
|
function cible(chemin, specificateur) {
|
||||||
|
const sansSuffixe = specificateur.replace(/\?.*$/, '');
|
||||||
|
if (sansSuffixe.startsWith('/')) return posix.normalize(sansSuffixe.slice(1));
|
||||||
|
if (sansSuffixe.startsWith('.')) return posix.normalize(posix.join(posix.dirname(chemin), sansSuffixe));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une épreuve que Vitest peut jouer : un fichier *.banc.js, que le projet
|
||||||
|
// banc inclut, ou *.test.js, que les séries incluent.
|
||||||
|
const EPREUVE = /\.(?:banc|test)\.[cm]?js$/;
|
||||||
|
|
||||||
|
// Les épreuves du banc parmi des modules { chemin, texte } : un fichier
|
||||||
|
// *.banc.js, ou une épreuve *.test.js qui charge les outils de page du banc,
|
||||||
|
// directement ou à travers des modules de la liste, à toute profondeur. Un
|
||||||
|
// module qui n'est pas une épreuve n'en est jamais une : une aide du banc
|
||||||
|
// qui charge les outils ne se joue que par une épreuve qui la charge.
|
||||||
|
function epreuvesDuBanc(modules) {
|
||||||
|
const textes = new Map(modules.map(({ chemin, texte }) => [chemin, texte]));
|
||||||
|
// Les modules que chaque module charge, lus une fois.
|
||||||
|
const lus = new Map();
|
||||||
|
const voisins = (chemin) => {
|
||||||
|
if (!lus.has(chemin)) {
|
||||||
|
lus.set(
|
||||||
|
chemin,
|
||||||
|
chargements(textes.get(chemin))
|
||||||
|
.filter(({ specificateur }) => specificateur !== undefined)
|
||||||
|
.map(({ specificateur }) => cible(chemin, specificateur))
|
||||||
|
.filter((vise) => vise !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lus.get(chemin);
|
||||||
|
};
|
||||||
|
const atteintLesOutils = (depart) => {
|
||||||
|
const vus = new Set([depart]);
|
||||||
|
const aParcourir = [depart];
|
||||||
|
for (let i = 0; i < aParcourir.length; i += 1) {
|
||||||
|
for (const vise of voisins(aParcourir[i])) {
|
||||||
|
if (vise === OUTILS_DU_BANC) return true;
|
||||||
|
if (vus.has(vise) || !textes.has(vise)) continue;
|
||||||
|
vus.add(vise);
|
||||||
|
aParcourir.push(vise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return modules.filter(
|
||||||
|
({ chemin }) => chemin.endsWith('.banc.js') || (EPREUVE.test(chemin) && atteintLesOutils(chemin)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repondA = (chemin, motifs = []) => motifs.some((motif) => matchesGlob(chemin, motif));
|
||||||
|
const inclus = (chemin, { include = [], exclude = [] }) => repondA(chemin, include) && !repondA(chemin, exclude);
|
||||||
|
|
||||||
|
// Relevé des épreuves du banc mal rangées, dans l'ordre des modules reçus :
|
||||||
|
// « chemin : jouée par le projet nom » pour chaque projet des séries qui
|
||||||
|
// l'inclut, puis « chemin : hors du projet banc » quand le projet banc ne
|
||||||
|
// l'inclut pas. projets et banc : les objets test des projets de Vitest.
|
||||||
|
// Lève quand les modules ne portent aucune épreuve du banc (§ 14.2).
|
||||||
|
function releverBancs(modules, { projets, banc }) {
|
||||||
|
const bancs = epreuvesDuBanc(modules);
|
||||||
|
assert.ok(bancs.length > 0, 'aucune épreuve du banc');
|
||||||
|
return bancs.flatMap(({ chemin }) => [
|
||||||
|
...projets.filter((projet) => inclus(chemin, projet)).map(({ name }) => `${chemin} : jouée par le projet ${name}`),
|
||||||
|
...(inclus(chemin, banc) ? [] : [`${chemin} : hors du projet banc`]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La cible nommée d'un Makefile : sa ligne de documentation (« ## … »), ses
|
||||||
|
// commandes (les lignes de recette qui la suivent, tabulation retirée) et sa
|
||||||
|
// déclaration .PHONY ; null quand le Makefile ne la porte pas.
|
||||||
|
function cibleDuMakefile(texte, nom) {
|
||||||
|
const lignes = texte.split('\n');
|
||||||
|
const rang = lignes.findIndex((ligne) => ligne.startsWith(`${nom}:`));
|
||||||
|
if (rang === -1) return null;
|
||||||
|
const documentation = /##\s*(.*)$/.exec(lignes[rang])?.[1] ?? null;
|
||||||
|
const commandes = [];
|
||||||
|
for (let i = rang + 1; i < lignes.length && lignes[i].startsWith('\t'); i += 1) commandes.push(lignes[i].slice(1));
|
||||||
|
const factice = lignes.some((ligne) => /^\.PHONY:/.test(ligne) && ligne.slice('.PHONY:'.length).trim().split(/\s+/).includes(nom));
|
||||||
|
return { documentation, commandes, factice };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projetsDesSeries = () => configuration.test.projects.map(({ test: projet }) => projet);
|
||||||
|
|
||||||
|
describe('banc : hors des trois séries (§ 14.4, § 14.8)', () => {
|
||||||
|
test('aucune épreuve du banc ne répond aux motifs des trois projets de vitest.config.js, et le projet banc les inclut toutes', () => {
|
||||||
|
const modules = modulesDuProjet(RACINE).filter(({ chemin }) => chemin !== CE_FICHIER);
|
||||||
|
assert.ok(epreuvesDuBanc(modules).some(({ chemin }) => chemin === 'scripts/banc/harnais.banc.js'));
|
||||||
|
assert.deepEqual(projetsDesSeries().map(({ name }) => name), ['node', 'node-long', 'navigateur']);
|
||||||
|
assert.deepEqual(releverBancs(modules, { projets: projetsDesSeries(), banc: projetBanc() }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la garde relève une épreuve du banc renommée en épreuve du navigateur, une autre chargée par une épreuve de src/, directement ou par une aide, un *.banc.js hors de scripts/banc, et une série qui inclurait le banc ; une aide du banc qui n’est pas une épreuve passe', () => {
|
||||||
|
const outils = "import { mesurerImages } from './navigateur.js';\n";
|
||||||
|
const modules = [
|
||||||
|
{ chemin: 'scripts/banc/navigateur.js', texte: "import { cdp } from 'vitest/browser';\n" },
|
||||||
|
{ chemin: 'scripts/banc/statistiques.test.js', texte: "import { statistiques } from './statistiques.js';\n" },
|
||||||
|
{ chemin: 'scripts/banc/defilement.banc.js', texte: outils },
|
||||||
|
{ chemin: 'scripts/banc/defilement.navigateur.test.js', texte: outils },
|
||||||
|
{ chemin: 'scripts/banc/gestes.js', texte: `${outils}export const mesurer = (geste) => mesurerImages(geste);\n` },
|
||||||
|
{ chemin: 'scripts/banc/pivots.js', texte: "export { mesurer } from './gestes.js?brut';\n" },
|
||||||
|
{ chemin: 'scripts/banc/zoom/crans.navigateur.test.js', texte: "import { mesurerImages } from '/scripts/banc/navigateur.js';\n" },
|
||||||
|
{ chemin: 'src/interface/plan/Pivot.navigateur.test.js', texte: "import { mesurer } from '../../../scripts/banc/pivots.js';\n" },
|
||||||
|
{ chemin: 'src/interface/plan/Zoom.navigateur.test.js', texte: "const o = await import('../../../scripts/banc/navigateur.js');\n" },
|
||||||
|
{ chemin: 'test/navigateur/listes.banc.js', texte: '' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
epreuvesDuBanc(modules).map(({ chemin }) => chemin),
|
||||||
|
[
|
||||||
|
'scripts/banc/defilement.banc.js',
|
||||||
|
'scripts/banc/defilement.navigateur.test.js',
|
||||||
|
'scripts/banc/zoom/crans.navigateur.test.js',
|
||||||
|
'src/interface/plan/Pivot.navigateur.test.js',
|
||||||
|
'src/interface/plan/Zoom.navigateur.test.js',
|
||||||
|
'test/navigateur/listes.banc.js',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(releverBancs(modules, { projets: projetsDesSeries(), banc: projetBanc() }), [
|
||||||
|
'scripts/banc/defilement.navigateur.test.js : hors du projet banc',
|
||||||
|
'scripts/banc/zoom/crans.navigateur.test.js : hors du projet banc',
|
||||||
|
'src/interface/plan/Pivot.navigateur.test.js : jouée par le projet navigateur',
|
||||||
|
'src/interface/plan/Pivot.navigateur.test.js : hors du projet banc',
|
||||||
|
'src/interface/plan/Zoom.navigateur.test.js : jouée par le projet navigateur',
|
||||||
|
'src/interface/plan/Zoom.navigateur.test.js : hors du projet banc',
|
||||||
|
'test/navigateur/listes.banc.js : hors du projet banc',
|
||||||
|
]);
|
||||||
|
const serieTropLarge = { name: 'node', include: ['scripts/**/*.js'], exclude: ['**/*.navigateur.test.js'] };
|
||||||
|
assert.deepEqual(releverBancs(modules.slice(0, 3), { projets: [serieTropLarge], banc: projetBanc() }), [
|
||||||
|
'scripts/banc/defilement.banc.js : jouée par le projet node',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la garde refuse de passer sans épreuve du banc', () => {
|
||||||
|
const modules = [{ chemin: 'scripts/banc/navigateur.js', texte: "import { cdp } from 'vitest/browser';\n" }];
|
||||||
|
assert.throws(() => releverBancs(modules, { projets: projetsDesSeries(), banc: projetBanc() }), /aucune épreuve du banc/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('banc : sa configuration (§ 19.10)', () => {
|
||||||
|
test('le projet banc inclut scripts/banc/**/*.banc.js, et chaque appel rend un objet neuf', () => {
|
||||||
|
const projet = projetBanc();
|
||||||
|
assert.equal(projet.name, 'banc');
|
||||||
|
assert.deepEqual(projet.include, ['scripts/banc/**/*.banc.js']);
|
||||||
|
projet.include.push('ailleurs/**/*.js');
|
||||||
|
projet.provide.cadreBanc.largeur = 1;
|
||||||
|
assert.deepEqual(projetBanc().include, ['scripts/banc/**/*.banc.js']);
|
||||||
|
assert.equal(projetBanc().provide.cadreBanc.largeur, 1366);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chromium sans tête, cadence non bridée, cadre 1 366 × 700 au facteur 1, fichiers l’un après l’autre, délai large', () => {
|
||||||
|
const { browser, fileParallelism, testTimeout, hookTimeout, provide } = projetBanc();
|
||||||
|
assert.equal(browser.enabled, true);
|
||||||
|
assert.equal(browser.headless, true);
|
||||||
|
assert.deepEqual(browser.instances, [{ browser: 'chromium' }]);
|
||||||
|
assert.deepEqual(browser.viewport, { width: 1366, height: 700 });
|
||||||
|
const { launchOptions, contextOptions } = optionsBanc({ LANG: 'fr_CA.UTF-8' });
|
||||||
|
assert.deepEqual(launchOptions.args.slice(-2), DRAPEAUX_CADENCE);
|
||||||
|
assert.deepEqual(contextOptions, { viewport: { width: 1366, height: 700 }, deviceScaleFactor: 1 });
|
||||||
|
assert.deepEqual(provide, { cadreBanc: { largeur: 1366, hauteur: 700 } });
|
||||||
|
assert.equal(fileParallelism, false);
|
||||||
|
assert.ok(testTimeout >= 600_000, `testTimeout ${testTimeout}`);
|
||||||
|
assert.ok(hookTimeout >= 600_000, `hookTimeout ${hookTimeout}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optionsBanc ajoute les drapeaux de cadence aux options de test/chromium.js, sans les perdre', () => {
|
||||||
|
assert.deepEqual(optionsBanc({ LANG: 'fr_CA.UTF-8' }), {
|
||||||
|
launchOptions: { args: DRAPEAUX_CADENCE },
|
||||||
|
contextOptions: { viewport: { width: 1366, height: 700 }, deviceScaleFactor: 1 },
|
||||||
|
});
|
||||||
|
const env = { LANG: 'C', GTT_CHROMIUM: '/opt/chromium-epreuve/chrome' };
|
||||||
|
const { launchOptions } = optionsBanc(env);
|
||||||
|
assert.equal(launchOptions.executablePath, '/opt/chromium-epreuve/chrome');
|
||||||
|
assert.equal(launchOptions.env.LANG, 'C.UTF-8');
|
||||||
|
assert.deepEqual(launchOptions.args, DRAPEAUX_CADENCE);
|
||||||
|
assert.deepEqual(env, { LANG: 'C', GTT_CHROMIUM: '/opt/chromium-epreuve/chrome' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('banc : sa commande', () => {
|
||||||
|
const { scripts = {} } = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
|
||||||
|
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
|
||||||
|
|
||||||
|
test('npm run banc lance scripts/banc/lancer.js, et aucun autre script de package.json ne touche au banc', () => {
|
||||||
|
assert.equal(scripts.banc, 'node scripts/banc/lancer.js');
|
||||||
|
const autres = Object.entries(scripts).filter(([nom, commande]) => nom !== 'banc' && /banc/.test(commande));
|
||||||
|
assert.deepEqual(autres, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('make banc lance npm run banc, cible factice documentée', () => {
|
||||||
|
assert.deepEqual(cibleDuMakefile(makefile, 'banc'), {
|
||||||
|
documentation: 'Relève les constantes du banc de mesure (§ 19.10), avant une livraison',
|
||||||
|
commandes: ['$(AVEC_NODE) npm run banc'],
|
||||||
|
factice: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la lecture du Makefile relève une cible absente, une recette autre, une cible non factice', () => {
|
||||||
|
assert.equal(cibleDuMakefile('test:\n\t$(AVEC_NODE) npm test\n', 'banc'), null);
|
||||||
|
assert.deepEqual(cibleDuMakefile('.PHONY: test\nbanc: ## Mesure\n\tnode scripts/banc/lancer.js\n\nx:\n\techo\n', 'banc'), {
|
||||||
|
documentation: 'Mesure',
|
||||||
|
commandes: ['node scripts/banc/lancer.js'],
|
||||||
|
factice: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
134
scripts/banc/harnais.banc.js
Normal file
134
scripts/banc/harnais.banc.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le harnais du banc (§ 19.10, § 14.2) : avant de croire un temps par image,
|
||||||
|
// la mesure doit pouvoir le voir. Sur un plan vide, que rien ne charge, un
|
||||||
|
// glissement réel de 60 pas donne un p95 sous 16 ms — sinon la cadence
|
||||||
|
// d'images est bridée, et chaque intervalle se lit en marches de 16,7 ms.
|
||||||
|
// Une image chargée exprès de 50 ms se voit à 50 ms ou plus. Les deux
|
||||||
|
// contrôles se jouent sans ralentissement et sous le ralentissement de
|
||||||
|
// référence, ×4 (Q13 b). Chacun est montré capable de tomber : une cadence
|
||||||
|
// bridée, simulée par des images chargées de 20 ms, fait tomber le premier ;
|
||||||
|
// une charge qui ne s'applique pas fait tomber le second.
|
||||||
|
//
|
||||||
|
// Le cadre se vérifie d'abord : 1 366 × 700, facteur 1, sans mise à
|
||||||
|
// l'échelle par l'orchestrateur. La dernière épreuve écrit les statistiques
|
||||||
|
// relevées dans scripts/banc/sorties/harnais.json et les relit. Le contrôle
|
||||||
|
// du cadre est montré capable de tomber : sur un facteur d'échelle de 2, lu
|
||||||
|
// par le lecteur qu'il accepte, et sur le cadre de l'épreuve réellement mis
|
||||||
|
// à l'échelle 0,5 par sa page.
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||||
|
import { commands } from 'vitest/browser';
|
||||||
|
import { creerCharge } from '../../src/stockage/document.js';
|
||||||
|
import { centre, creerContexte, ouvrirEvenement } from '../../test/navigateur/ecrans.js';
|
||||||
|
import { glisser } from '../../test/navigateur/entrees.js';
|
||||||
|
import {
|
||||||
|
CHARGE_MS,
|
||||||
|
SEUIL_CADENCE_MS,
|
||||||
|
chargerImages,
|
||||||
|
controlerCadence,
|
||||||
|
controlerCadre,
|
||||||
|
controlerCharge,
|
||||||
|
ecrireSortie,
|
||||||
|
ralentirCpu,
|
||||||
|
} from './navigateur.js';
|
||||||
|
|
||||||
|
// Le nombre de pas d'un glissement mesuré (§ 19.10).
|
||||||
|
const PAS = 60;
|
||||||
|
|
||||||
|
// Le ralentissement de référence du banc (Q13 b).
|
||||||
|
const RALENTISSEMENTS = [1, 4];
|
||||||
|
|
||||||
|
// Un événement sans table : le plan vide.
|
||||||
|
const PLAN_VIDE = creerCharge({ id: 'banc-plan-vide', nom: 'Club des Merles', siegesParDefaut: 8, tours: 2 });
|
||||||
|
|
||||||
|
let contexte = null;
|
||||||
|
let montage = null;
|
||||||
|
const releves = [];
|
||||||
|
|
||||||
|
// La transformation de la vue du plan, que chaque pas d'un glissement change.
|
||||||
|
const transformation = () => montage.racine.querySelector('[data-vue]').getAttribute('transform');
|
||||||
|
|
||||||
|
// Un glissement de PAS pas sur le fond du plan, de part et d'autre de son
|
||||||
|
// centre ; lève si la vue n'a pas bougé : le geste n'aurait rien dessiné.
|
||||||
|
async function glissement() {
|
||||||
|
const avant = transformation();
|
||||||
|
const { x, y } = await centre(montage.racine.querySelector('[data-plan]'));
|
||||||
|
await glisser({ x: x - 150, y }, { x: x + 150, y }, { pas: PAS });
|
||||||
|
if (transformation() === avant) throw new Error('le glissement n’a pas déplacé la vue');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
contexte = creerContexte();
|
||||||
|
releves.push({ controle: 'cadre', ...(await controlerCadre()) });
|
||||||
|
montage = await ouvrirEvenement(contexte, PLAN_VIDE, { section: 'plan' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ralentirCpu(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await contexte?.defaire();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('harnais du banc', () => {
|
||||||
|
for (const ralentissement of RALENTISSEMENTS) {
|
||||||
|
test(`ralenti ×${ralentissement} : un plan vide glissé de ${PAS} pas donne un p95 sous ${SEUIL_CADENCE_MS} ms`, async () => {
|
||||||
|
await ralentirCpu(ralentissement);
|
||||||
|
const stats = await controlerCadence(glissement);
|
||||||
|
releves.push({ controle: 'cadence', ralentissement, ...stats });
|
||||||
|
expect(stats.p95Ms).toBeLessThan(SEUIL_CADENCE_MS);
|
||||||
|
expect(stats.images).toBeGreaterThanOrEqual(PAS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`ralenti ×${ralentissement} : une image chargée de ${CHARGE_MS} ms se voit à ${CHARGE_MS} ms ou plus`, async () => {
|
||||||
|
await ralentirCpu(ralentissement);
|
||||||
|
const stats = await controlerCharge(glissement);
|
||||||
|
releves.push({ controle: 'charge', ralentissement, ...stats });
|
||||||
|
expect(stats.p95Ms).toBeGreaterThanOrEqual(CHARGE_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('le contrôle de cadence tombe sur une cadence bridée, simulée par des images chargées de 20 ms', async () => {
|
||||||
|
const arreter = chargerImages(20);
|
||||||
|
try {
|
||||||
|
await expect(controlerCadence(glissement)).rejects.toThrow(/^cadence bridée : p95 \d/);
|
||||||
|
} finally {
|
||||||
|
arreter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le contrôle de charge tombe quand la charge ne s’applique pas', async () => {
|
||||||
|
await expect(controlerCharge(glissement, { charger: () => () => {} })).rejects.toThrow(/^image chargée de 50 ms vue à \d/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le contrôle du cadre tombe sur un facteur d’échelle autre que 1', async () => {
|
||||||
|
await expect(controlerCadre({ lireFacteur: () => 2 })).rejects.toThrow(/^facteur d’échelle 2, attendu 1$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le contrôle du cadre tombe sur un cadre que la page qui le porte met à l’échelle', async () => {
|
||||||
|
const element = window.frameElement;
|
||||||
|
const avant = element.style.transform;
|
||||||
|
element.style.transform = 'scale(0.5)';
|
||||||
|
try {
|
||||||
|
await expect(controlerCadre()).rejects.toThrow(/^cadre mis à l’échelle 0\.5 par la page qui le porte$/);
|
||||||
|
} finally {
|
||||||
|
element.style.transform = avant;
|
||||||
|
}
|
||||||
|
await expect(controlerCadre()).resolves.toEqual({ largeur: 1366, hauteur: 700, facteurEchelle: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ecrireSortie refuse un nom hors de [a-z0-9_]', async () => {
|
||||||
|
for (const nom of ['', '../harnais', 'harnais.json', 'Harnais', 'temps image']) {
|
||||||
|
await expect(ecrireSortie(nom, {})).rejects.toThrow(RangeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les relevés du harnais s’écrivent dans scripts/banc/sorties/harnais.json, relus tels quels', async () => {
|
||||||
|
expect(releves.map(({ controle }) => controle)).toEqual(['cadre', 'cadence', 'charge', 'cadence', 'charge']);
|
||||||
|
const objet = { format: 1, releves };
|
||||||
|
await ecrireSortie('harnais', objet);
|
||||||
|
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/harnais.json'))).toEqual(objet);
|
||||||
|
});
|
||||||
|
});
|
||||||
206
scripts/banc/navigateur.js
Normal file
206
scripts/banc/navigateur.js
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
// © 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`);
|
||||||
|
}
|
||||||
66
scripts/banc/projet.js
Normal file
66
scripts/banc/projet.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le projet banc de Vitest (§ 19.10), décrit sans rien charger de Vitest :
|
||||||
|
// vitest.banc.config.js l'enveloppe de defineConfig et lui donne son
|
||||||
|
// fournisseur Playwright, les gardes du banc (banc.test.js) le lisent sous
|
||||||
|
// node, où une épreuve ne charge pas le lanceur hors de test/lanceur.js
|
||||||
|
// (§ 14.8).
|
||||||
|
//
|
||||||
|
// Chromium reçoit les options des épreuves du navigateur (test/chromium.js),
|
||||||
|
// plus deux drapeaux qui lui ôtent sa cadence d'images : sans eux, chaque
|
||||||
|
// intervalle entre images se cale sur 16,7 ms, et un temps par image se lit
|
||||||
|
// en marches de cette hauteur. Le harnais (harnais.banc.js) vérifie que la
|
||||||
|
// cadence n'est pas bridée avant de mesurer.
|
||||||
|
//
|
||||||
|
// Le cadre d'affichage est celui des captures, 1 366 × 700 au facteur 1
|
||||||
|
// (§ 19.4, § 19.10) : la page de Playwright a ces dimensions, le cadre de
|
||||||
|
// l'épreuve aussi, et l'orchestrateur ne le met pas à l'échelle. La page
|
||||||
|
// lit le cadre par inject('cadreBanc'), qui vient d'ici seul.
|
||||||
|
//
|
||||||
|
// Les fichiers se jouent l'un après l'autre : deux mesures en même temps se
|
||||||
|
// disputeraient les processeurs. Une épreuve du banc dispose d'une
|
||||||
|
// demi-heure, un balayage de zoom à deux ralentissements y compris.
|
||||||
|
import { optionsChromium } from '../../test/chromium.js';
|
||||||
|
|
||||||
|
const CADRE = Object.freeze({ largeur: 1366, hauteur: 700 });
|
||||||
|
const DRAPEAUX_CADENCE = Object.freeze(['--disable-frame-rate-limit', '--disable-gpu-vsync']);
|
||||||
|
const DELAI_MS = 1_800_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les options du fournisseur Playwright pour le banc : celles de
|
||||||
|
* optionsChromium(env), les drapeaux de cadence ajoutés à la fin des
|
||||||
|
* arguments de lancement, et un contexte de navigation au cadre du banc, au
|
||||||
|
* facteur d'échelle 1. env n'est pas modifié.
|
||||||
|
*
|
||||||
|
* @param {Record<string, string|undefined>} env
|
||||||
|
*/
|
||||||
|
export function optionsBanc(env) {
|
||||||
|
const { launchOptions = {}, ...autres } = optionsChromium(env);
|
||||||
|
return {
|
||||||
|
...autres,
|
||||||
|
launchOptions: { ...launchOptions, args: [...(launchOptions.args ?? []), ...DRAPEAUX_CADENCE] },
|
||||||
|
contextOptions: { viewport: { width: CADRE.largeur, height: CADRE.hauteur }, deviceScaleFactor: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'objet test du projet banc, hors du fournisseur du navigateur, que
|
||||||
|
* vitest.banc.config.js ajoute à browser. Un objet neuf à chaque appel.
|
||||||
|
*/
|
||||||
|
export function projetBanc() {
|
||||||
|
return {
|
||||||
|
name: 'banc',
|
||||||
|
include: ['scripts/banc/**/*.banc.js'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: DELAI_MS,
|
||||||
|
hookTimeout: DELAI_MS,
|
||||||
|
provide: { cadreBanc: { ...CADRE } },
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
instances: [{ browser: 'chromium' }],
|
||||||
|
viewport: { width: CADRE.largeur, height: CADRE.hauteur },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
37
scripts/banc/statistiques.js
Normal file
37
scripts/banc/statistiques.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les statistiques du temps par image (§ 19.10) : une liste d'intervalles
|
||||||
|
// entre images, en millisecondes, rend son nombre, sa médiane, son p95 et
|
||||||
|
// son maximum. Module pur, sans import : il se charge sous node comme dans la
|
||||||
|
// page du banc, où mesurerImages (navigateur.js) relève les intervalles.
|
||||||
|
//
|
||||||
|
// Le p95 est celui du rang le plus proche : l'intervalle de rang ⌈0,95 n⌉
|
||||||
|
// dans la liste croissante, un intervalle mesuré et non une interpolation
|
||||||
|
// entre deux.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number[]} intervallesMs au moins un ; chacun un nombre fini ≥ 0
|
||||||
|
* @returns {{images: number, medianeMs: number, p95Ms: number, maxMs: number}}
|
||||||
|
* images : le nombre d'intervalles, une image chacun ; medianeMs : celui du
|
||||||
|
* milieu de la liste triée, ou la moyenne des deux du milieu pour une
|
||||||
|
* longueur paire. La liste reçue n'est pas modifiée.
|
||||||
|
* @throws {TypeError} sur autre chose qu'une liste
|
||||||
|
* @throws {RangeError} sur une liste vide, ou un intervalle qui n'est pas un
|
||||||
|
* nombre fini positif ou nul, nommé
|
||||||
|
*/
|
||||||
|
export function statistiques(intervallesMs) {
|
||||||
|
if (!Array.isArray(intervallesMs)) throw new TypeError('statistiques : une liste d’intervalles est attendue');
|
||||||
|
if (intervallesMs.length === 0) throw new RangeError('statistiques : aucun intervalle');
|
||||||
|
for (const valeur of intervallesMs) {
|
||||||
|
if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur < 0) {
|
||||||
|
throw new RangeError(`statistiques : intervalle refusé, ${String(valeur)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const triees = [...intervallesMs].sort((a, b) => a - b);
|
||||||
|
const n = triees.length;
|
||||||
|
const milieu = Math.floor(n / 2);
|
||||||
|
const medianeMs = n % 2 === 1 ? triees[milieu] : (triees[milieu - 1] + triees[milieu]) / 2;
|
||||||
|
const rang95 = Math.ceil((95 * n) / 100);
|
||||||
|
return { images: n, medianeMs, p95Ms: triees[rang95 - 1], maxMs: triees[n - 1] };
|
||||||
|
}
|
||||||
63
scripts/banc/statistiques.test.js
Normal file
63
scripts/banc/statistiques.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des statistiques du temps par image (§ 19.10) : des listes
|
||||||
|
// écrites, dont chaque statistique se lit à la main sur la liste triée.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { statistiques } from './statistiques.js';
|
||||||
|
|
||||||
|
// Vingt intervalles dans le désordre. Triés : 6 7 7 8 8 8 9 9 9 10 | 11 11 12
|
||||||
|
// 12 13 14 15 33 41 50. La médiane est la moyenne des deux du milieu, 10 et
|
||||||
|
// 11 ; le p95, au rang ⌈0,95 × 20⌉ = 19, vaut 41 — une interpolation
|
||||||
|
// rendrait 41,45, le rang 20 rendrait 50.
|
||||||
|
const VINGT = Object.freeze([12, 9, 33, 7, 8, 41, 10, 6, 15, 11, 9, 7, 50, 8, 13, 11, 12, 9, 14, 8]);
|
||||||
|
|
||||||
|
describe('statistiques du temps par image', () => {
|
||||||
|
test('vingt intervalles connus : nombre d’images, médiane, p95 au rang le plus proche, maximum', () => {
|
||||||
|
assert.deepEqual(statistiques(VINGT), { images: 20, medianeMs: 10.5, p95Ms: 41, maxMs: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une longueur impaire : la médiane est l’intervalle du milieu', () => {
|
||||||
|
assert.deepEqual(statistiques([5, 1, 3]), { images: 3, medianeMs: 3, p95Ms: 5, maxMs: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cent intervalles de 1 à 100 : le p95 est le 95e, la médiane la moyenne du 50e et du 51e', () => {
|
||||||
|
const cent = Array.from({ length: 100 }, (_, i) => 100 - i);
|
||||||
|
assert.deepEqual(statistiques(cent), { images: 100, medianeMs: 50.5, p95Ms: 95, maxMs: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 0,95 × 11 = 10,45 : le rang ⌈10,45⌉ = 11 et l'arrondi au plus proche,
|
||||||
|
// 10, désignent deux intervalles distincts ; aux longueurs 1, 3, 20 et 100
|
||||||
|
// des autres épreuves, ils désignent le même.
|
||||||
|
test('onze intervalles de 1 à 11 : le p95 est au rang ⌈10,45⌉ = 11, et non à l’arrondi 10', () => {
|
||||||
|
const onze = [4, 11, 7, 1, 9, 2, 10, 5, 8, 3, 6];
|
||||||
|
assert.deepEqual(statistiques(onze), { images: 11, medianeMs: 6, p95Ms: 11, maxMs: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un seul intervalle : chaque statistique le vaut', () => {
|
||||||
|
assert.deepEqual(statistiques([16.7]), { images: 1, medianeMs: 16.7, p95Ms: 16.7, maxMs: 16.7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la liste reçue reste dans son ordre', () => {
|
||||||
|
const recue = [...VINGT];
|
||||||
|
statistiques(recue);
|
||||||
|
assert.deepEqual(recue, VINGT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une liste vide est refusée', () => {
|
||||||
|
assert.throws(() => statistiques([]), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un intervalle négatif, non fini ou qui n’est pas un nombre est refusé en le nommant', () => {
|
||||||
|
assert.throws(() => statistiques([3, -1]), { name: 'RangeError', message: /-1/ });
|
||||||
|
assert.throws(() => statistiques([3, Number.NaN]), { name: 'RangeError', message: /NaN/ });
|
||||||
|
assert.throws(() => statistiques([3, Number.POSITIVE_INFINITY]), { name: 'RangeError', message: /Infinity/ });
|
||||||
|
assert.throws(() => statistiques([3, '4']), { name: 'RangeError', message: /4/ });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autre chose qu’une liste est refusé', () => {
|
||||||
|
assert.throws(() => statistiques(null), TypeError);
|
||||||
|
assert.throws(() => statistiques(new Set([1, 2])), TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
vitest.banc.config.js
Normal file
29
vitest.banc.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le banc de mesure (§ 19.10), un projet de Vitest hors des trois séries de
|
||||||
|
// vitest.config.js (§ 14.4, § 14.8) : il ne se lance que par sa propre
|
||||||
|
// commande, npm run banc, qui passe cette configuration à Vitest. Comme le
|
||||||
|
// projet navigateur, il étend vite.config.js : un module s'y résout comme
|
||||||
|
// dans le livrable. Le projet lui-même — fichiers joués, cadre, cadence,
|
||||||
|
// délais — se décrit dans scripts/banc/projet.js ; ce fichier n'y ajoute que
|
||||||
|
// le fournisseur Playwright. Le rapporteur des plafonds de la série
|
||||||
|
// surveillée (test/rapporteur.js) n'a pas cours ici : le banc n'a pas de
|
||||||
|
// budget de durée.
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import { optionsBanc, projetBanc } from './scripts/banc/projet.js';
|
||||||
|
|
||||||
|
const projet = projetBanc();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
reporters: ['default'],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
extends: './vite.config.js',
|
||||||
|
test: { ...projet, browser: { ...projet.browser, provider: playwright(optionsBanc(process.env)) } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -31,8 +31,9 @@ const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
|
||||||
// Les épreuves node qui lisent les composants de src/interface par le
|
// Les épreuves node qui lisent les composants de src/interface par le
|
||||||
// système de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du
|
// système de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du
|
||||||
// plan, version affichée, graphe des couches, version qu'App reçoit par ses
|
// plan, version affichée, graphe des couches, version qu'App reçoit par ses
|
||||||
// propriétés —, et celle qui importe App.svelte pour en voir l'échec sous
|
// propriétés —, celle qui importe App.svelte pour en voir l'échec sous
|
||||||
// node.
|
// node, et la garde du banc, qui balaie l'arbre à côté des motifs de ce
|
||||||
|
// fichier.
|
||||||
const RELANCES_D_UN_COMPOSANT = Object.freeze([
|
const RELANCES_D_UN_COMPOSANT = Object.freeze([
|
||||||
'test/arborescence.test.js',
|
'test/arborescence.test.js',
|
||||||
'test/sources_interface.test.js',
|
'test/sources_interface.test.js',
|
||||||
|
|
@ -42,6 +43,7 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([
|
||||||
'src/interface/palette.test.js',
|
'src/interface/palette.test.js',
|
||||||
'src/interface/plan/ecouteurs.test.js',
|
'src/interface/plan/ecouteurs.test.js',
|
||||||
'src/interface/apropos.test.js',
|
'src/interface/apropos.test.js',
|
||||||
|
'scripts/banc/banc.test.js',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Les épreuves node qui lisent la feuille des jetons par le système de
|
// Les épreuves node qui lisent la feuille des jetons par le système de
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue