// © 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); }); });