gestion_table_tournante_libre/scripts/banc/temps_image.banc.js

239 lines
10 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le temps par image pendant un déplacement de vue (§ 19.10, § 17 point 6),
// relevé par le banc sur la grande démonstration et ses 33 tables avec
// listes. Une garde, pas un arbitre (Q12 a) : K_LISTES est une constante
// manuelle, un seuil de lisibilité, et le coût des listes ne dépend pas du
// zoom ; le banc vérifie que, listes dessinées à tout zoom, un glissement
// tient CRITERE_TEMPS_IMAGE_MS au p95 sur la machine de référence — le poste,
// processeur ralenti ×4 (Q13 b).
//
// Les listes se dessinent à tout zoom : le registre est remplacé pour ce
// fichier, K_LISTES à 0 (vi.mock), toute autre constante inchangée. La
// première épreuve constate que le remplacement atteint le dessin : au zoom
// ajusté, sous la valeur du registre, les 33 listes sont là.
//
// Le déroulé : le plan ouvert au zoom ajusté, puis cran de molette après
// cran (×1,1) au centre du plan jusqu'à k ≥ 1. À chaque k, sans
// ralentissement puis ralenti ×4, trois glissements de 60 pas sur le sol du
// plan, de part et d'autre d'un point libre de toute table ; le p95 retenu
// est la médiane des trois. Chaque pas doit changer le transform du groupe
// [data-vue] en quelques images : sinon le geste n'a rien dessiné, et la
// mesure lève. Les 33 [data-liste] sont relues à chaque k.
//
// La sortie temps_image.json porte la courbe — chaque point nomme son
// ralentissement —, le critère, le seuil que seuilDepuisCourbe lit sur la
// courbe à ×4, et le verdict : « tenu » quand le p95 à ×4 tient le critère à
// tout k. Le verdict est une donnée du relevé : l'épreuve ne tombe que sur
// une mesure qui ne voit pas ce qu'elle mesure.
import { flushSync } from 'svelte';
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { commands } from 'vitest/browser';
import { CONSTANTES, constante } from '../../src/geometrie/constantes.js';
import { vueDepuisTransform } from '../../src/geometrie/vue.js';
import { texteLivre } from '../../src/demo/livrees.js';
import { analyser } from '../../src/stockage/document.js';
import { creerContexte, ouvrirEvenement } from '../../test/navigateur/ecrans.js';
import { molette, pointer } from '../../test/navigateur/entrees.js';
import { controlerCadre, ecrireSortie, mesurerImages, ralentirCpu } from './navigateur.js';
import { verifierReleve } from './releve.js';
import { CRITERE_TEMPS_IMAGE_MS, seuilDepuisCourbe } from './seuils.js';
import { statistiques } from './statistiques.js';
vi.mock('../../src/geometrie/constantes.js', async (importOriginal) => {
const original = await importOriginal();
return { ...original, constante: (nom) => (nom === 'K_LISTES' ? 0 : original.constante(nom)) };
});
const NOM = 'TEMPS_IMAGE_LISTES';
const TABLES = 33;
const PAS = 60;
const PAS_PX = 5;
const GLISSEMENTS = 3;
const RALENTISSEMENTS = Object.freeze([1, 4]);
const RALENTISSEMENT_REFERENCE = 4;
const K_FIN = 1;
// Le nombre d'images qu'un pas a pour changer la vue.
const IMAGES_PAR_PAS = 10;
// L'écart, en px, entre deux points d'essai dans la recherche d'un point du sol.
const MAILLE_PX = 20;
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
const mediane = (valeurs) => statistiques(valeurs).medianeMs;
let contexte = null;
let montage = null;
const courbe = [];
let mesure = null;
const groupe = () => montage.racine.querySelector('[data-vue]');
const transformation = () => groupe().getAttribute('transform');
const kCourant = () => vueDepuisTransform(transformation()).k;
const plan = () => montage.racine.querySelector('svg[data-plan]');
const listes = () => montage.racine.querySelectorAll('[data-liste]').length;
// Ce qu'un appui saisirait au point : une table, une chaise, une poignée
// ou une pastille, sinon le sol du plan. Les listes ne reçoivent pas le
// pointeur.
function surLeSol(x, y) {
const cible = document.elementFromPoint(x, y);
return cible !== null && plan().contains(cible) && cible.closest('[data-table], [data-siege], [data-poignee], [data-pastille]') === null;
}
/**
* Un point du sol du plan, le plus proche du centre de sa boîte, dont le
* point à x + 150 et l'aller-retour restent dans le plan. Lève si la maille
* n'en trouve aucun.
* @returns {{x: number, y: number}}
*/
function pointDuSol() {
const boite = plan().getBoundingClientRect();
const cx = boite.left + boite.width / 2;
const cy = boite.top + boite.height / 2;
const candidats = [];
for (let y = boite.top + MAILLE_PX; y < boite.bottom - MAILLE_PX; y += MAILLE_PX) {
for (let x = boite.left + MAILLE_PX; x + PAS * PAS_PX < boite.right - MAILLE_PX; x += MAILLE_PX) {
candidats.push({ x, y, d: Math.hypot(x - cx, y - cy) });
}
}
candidats.sort((a, b) => a.d - b.d || a.y - b.y || a.x - b.x);
const trouve = candidats.find(({ x, y }) => surLeSol(x, y));
if (trouve === undefined) throw new Error(`aucun point du sol dans le plan à k ${kCourant()}`);
return { x: trouve.x, y: trouve.y };
}
/**
* Un glissement de PAS pas de PAS_PX px, horizontal, depuis depart, sens 1
* vers la droite, -1 vers la gauche. Chaque pas attend que le transform du
* groupe [data-vue] change, au plus IMAGES_PAR_PAS images, et lève sinon.
*/
async function glissement(depart, sens) {
await pointer('presser', depart.x, depart.y);
try {
for (let i = 1; i <= PAS; i += 1) {
const avant = transformation();
await pointer('bouger', depart.x + sens * i * PAS_PX, depart.y);
let images = 0;
while (transformation() === avant) {
if (images >= IMAGES_PAR_PAS) throw new Error(`le pas ${i} du glissement n’a pas déplacé la vue à k ${kCourant()}`);
await uneImage();
images += 1;
}
}
} finally {
await pointer('relacher', depart.x + sens * PAS * PAS_PX, depart.y);
}
}
// Un cran de molette avant au centre du plan ; attend que la vue change.
async function cran() {
const boite = plan().getBoundingClientRect();
const avant = transformation();
await molette(boite.left + boite.width / 2, boite.top + boite.height / 2, -100);
for (let images = 0; transformation() === avant; images += 1) {
if (images >= IMAGES_PAR_PAS) throw new Error('le cran de molette n’a pas zoomé');
flushSync();
await uneImage();
}
}
// Le point de la courbe à k et à un ralentissement : trois glissements, un
// aller, un retour, un aller, chacun relevé par mesurerImages ; p95Ms est la
// médiane de leurs p95. Un dernier retour, sans ralentissement ni mesure,
// ramène la vue.
async function mesurerPoint(k, ralentissementCpu) {
const depart = pointDuSol();
const arrivee = { x: depart.x + PAS * PAS_PX, y: depart.y };
await ralentirCpu(ralentissementCpu);
const glissements = [];
try {
for (let rang = 0; rang < GLISSEMENTS; rang += 1) {
const aller = rang % 2 === 0;
glissements.push(statistiques(await mesurerImages(() => glissement(aller ? depart : arrivee, aller ? 1 : -1))));
}
} finally {
await ralentirCpu(1);
}
// Un retour non mesuré : la vue revient où elle était, et la suite de la
// courbe glisse sur le même morceau du plan.
await glissement(arrivee, -1);
return { k, ralentissementCpu, p95Ms: mediane(glissements.map(({ p95Ms }) => p95Ms)), glissements };
}
beforeAll(async () => {
await controlerCadre();
contexte = creerContexte();
montage = await ouvrirEvenement(contexte, analyser(texteLivre('grande')).charge, { section: 'plan' });
});
afterEach(async () => {
await ralentirCpu(1);
});
afterAll(async () => {
await contexte?.defaire();
});
describe('le temps par image d’un glissement, listes à tout zoom (§ 19.10, § 17 point 6)', () => {
test(`le registre de ce fichier porte K_LISTES à 0 : au zoom ajusté, sous la valeur du registre, les ${TABLES} listes sont là`, () => {
expect(constante('K_LISTES')).toBe(0);
const registre = CONSTANTES.find(({ nom }) => nom === 'K_LISTES').valeur;
expect(kCourant()).toBeLessThan(registre);
expect(montage.racine.querySelectorAll('[data-table]').length).toBe(TABLES);
expect(listes()).toBe(TABLES);
});
test(`de l’ajusté à k ≥ ${K_FIN}, cran après cran : ${GLISSEMENTS} glissements de ${PAS} pas à ×1 et ×4, chaque pas déplace la vue, ${TABLES} listes à chaque k`, async () => {
for (;;) {
const k = kCourant();
expect(listes(), `k ${k}`).toBe(TABLES);
for (const ralentissementCpu of RALENTISSEMENTS) courbe.push(await mesurerPoint(k, ralentissementCpu));
if (k >= K_FIN) break;
await cran();
}
expect(courbe.length).toBeGreaterThanOrEqual(2 * RALENTISSEMENTS.length);
for (const { glissements } of courbe) {
for (const { images } of glissements) expect(images).toBeGreaterThanOrEqual(PAS);
}
});
test('la courbe à ×4 donne le seuil et le verdict ; la mesure s’écrit dans scripts/banc/sorties/temps_image.json, relue telle quelle, et se range dans un relevé', async () => {
const reference = courbe.filter(({ ralentissementCpu }) => ralentissementCpu === RALENTISSEMENT_REFERENCE);
const seuil = seuilDepuisCourbe(reference, CRITERE_TEMPS_IMAGE_MS);
mesure = {
nom: NOM,
unite: 'ms',
valeurProposee: null,
critere:
`p95 du temps par image d’un glissement de ${PAS} pas, médiane de ${GLISSEMENTS} glissements, grande démonstration, ` +
`${TABLES} listes dessinées à tout k ; tenu quand il vaut au plus ${CRITERE_TEMPS_IMAGE_MS} ms à ×${RALENTISSEMENT_REFERENCE}, ` +
`à tout k de l’ajusté à ${K_FIN}`,
critereMs: CRITERE_TEMPS_IMAGE_MS,
ralentissementCpu: RALENTISSEMENT_REFERENCE,
seuil,
pireP95Ms: Math.max(...reference.map(({ p95Ms }) => p95Ms)),
courbe,
verdict: seuil.tenuPartout ? 'tenu' : 'nonTenu',
};
const objet = { format: 1, mesures: [mesure] };
const releve = {
format: 1,
configuration: {
cadre: { largeur: innerWidth, hauteur: innerHeight },
facteurEchelle: devicePixelRatio,
navigateur: null,
node: null,
processeursLogiques: null,
ralentissementCpu: RALENTISSEMENT_REFERENCE,
police: 'Noto Sans',
},
mesures: [mesure],
};
expect(verifierReleve(releve, CONSTANTES)).toEqual([]);
await ecrireSortie('temps_image', objet);
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/temps_image.json'))).toEqual(objet);
});
});