[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/
|
||||
.vitest/
|
||||
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
|
||||
$(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
|
||||
build: ## Construit la page dans www/
|
||||
$(AVEC_NODE) npm run build
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"test:long": "vitest run --project node-long",
|
||||
"test:navigateur": "vitest run --project navigateur",
|
||||
"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": {
|
||||
"@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
|
||||
// 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
|
||||
// propriétés —, et celle qui importe App.svelte pour en voir l'échec sous
|
||||
// node.
|
||||
// propriétés —, celle qui importe App.svelte pour en voir l'échec sous
|
||||
// node, et la garde du banc, qui balaie l'arbre à côté des motifs de ce
|
||||
// fichier.
|
||||
const RELANCES_D_UN_COMPOSANT = Object.freeze([
|
||||
'test/arborescence.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/plan/ecouteurs.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
|
||||
|
|
|
|||
Loading…
Reference in a new issue