gestion_table_tournante_libre/test/navigateur/entrees.navigateur.test.js
Mathieu Benoit f76c5dede5 [ADD] tests: browser tooling — reachable entries, real colours, contrast
The interface tests must judge what an operator sees, not what the markup
claims: a class can sit on an element that paints nothing (§ 14.2).
test/navigateur/ gives them shared helpers: entering the app as a user
does, reading the colour actually painted under a text — the topmost
opaque background below it, an outer <svg> background included — and
deciding visibility by geometry, style and occlusion rather than by
hit-testing, so overlays in pointer-events: none stay visible. The header
names what the helpers do not detect.
Checked: 1776 node and 127 browser tests.

--- FR ---

[ADD] épreuves : outillage du navigateur — entrées, couleurs, contraste

Les épreuves de l'interface doivent juger ce que voit l'opérateur, non ce
que prétend le balisage : une classe peut se poser sur un élément qui ne
peint rien (§ 14.2). test/navigateur/ leur donne des aides communes :
entrer dans l'application comme un utilisateur, lire la couleur réellement
peinte sous un texte — le premier fond opaque sous lui, celui d'un <svg>
extérieur compris — et juger la visibilité par la géométrie, le style et
l'occultation plutôt que par l'atteinte du pointeur. L'en-tête nomme ce
que les aides ne détectent pas.
Vérifié : 1776 node et 127 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 04:13:34 -04:00

264 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des entrées réelles (§ 7.3) et du cadre d'affichage (§ 19.4).
// Les entrées passent par CDP : Chromium les traite comme celles d'un
// périphérique, chaque événement relevé porte isTrusted vrai, et la capture
// du pointeur se pose — ce qu'un événement construit par la page n'obtient
// pas. Les coordonnées sont celles du cadre de l'épreuve.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { fixerCadre, glisser, molette, pointer, touche, toucher } from './entrees.js';
const SVG = 'http://www.w3.org/2000/svg';
let scene;
let cadreInitial;
beforeAll(() => {
cadreInitial = { largeur: innerWidth, hauteur: innerHeight };
});
afterAll(async () => {
await page.viewport(cadreInitial.largeur, cadreInitial.hauteur);
});
beforeEach(() => {
scrollTo(0, 0);
scene = document.createElement('div');
document.body.append(scene);
});
afterEach(() => {
scene.remove();
scrollTo(0, 0);
});
// Un plan d'épreuve : un <svg> en touch-action: none, posé à 30 px du bord,
// qui porte deux rectangles côte à côte.
function monterPlan() {
scene.innerHTML =
`<svg xmlns="${SVG}" data-plan width="300" height="160" ` +
'style="display: block; margin: 30px; touch-action: none">' +
'<rect data-a x="0" y="0" width="150" height="160" fill="rgb(200, 220, 240)"/>' +
'<rect data-b x="150" y="0" width="150" height="160" fill="rgb(240, 220, 200)"/></svg>';
return scene.querySelector('[data-plan]');
}
function centre(element) {
const boite = element.getBoundingClientRect();
return { x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 };
}
// Relève les événements des types donnés sur la cible, dans leur ordre.
function relever(cible, types) {
const releves = [];
for (const type of types) {
cible.addEventListener(type, (e) => releves.push(e));
}
return releves;
}
describe('pointeur', () => {
test('un presser réel permet setPointerCapture sur la racine <svg>', async () => {
const plan = monterPlan();
let capture = null;
plan.addEventListener('pointerdown', (e) => {
plan.setPointerCapture(e.pointerId);
capture = { isTrusted: e.isTrusted, tenue: plan.hasPointerCapture(e.pointerId) };
});
const { x, y } = centre(plan.querySelector('[data-a]'));
await pointer('presser', x, y);
await pointer('relacher', x, y);
expect(capture).toEqual({ isTrusted: true, tenue: true });
});
test('un pointerdown construit par la page ne permet pas la capture', () => {
const plan = monterPlan();
let capture = null;
plan.addEventListener('pointerdown', (e) => {
try {
plan.setPointerCapture(e.pointerId);
} catch {
// Un identifiant qu'aucun pointeur actif ne porte lève NotFoundError.
}
capture = { isTrusted: e.isTrusted, tenue: plan.hasPointerCapture(e.pointerId) };
});
const { x, y } = centre(plan.querySelector('[data-a]'));
plan.querySelector('[data-a]').dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, clientX: x, clientY: y, buttons: 1 }),
);
expect(capture).toEqual({ isTrusted: false, tenue: false });
});
for (const defile of [0, 400]) {
test(`le centre d'un élément, pointé, l'atteint comme cible — page défilée de ${defile} px`, async () => {
const plan = monterPlan();
// Un espace sous le plan rend la page défilable, puis le plan descend
// sous la marge défilée.
plan.style.marginTop = `${30 + defile}px`;
plan.style.marginBottom = '2000px';
scrollTo(0, defile);
expect(scrollY).toBe(defile);
const releves = relever(plan, ['pointerdown', 'pointerup']);
for (const cible of [plan.querySelector('[data-a]'), plan.querySelector('[data-b]')]) {
const { x, y } = centre(cible);
await pointer('presser', x, y);
await pointer('relacher', x, y);
}
expect(releves.map((e) => [e.type, e.target.getAttribute('data-a') !== null ? 'a' : 'b', e.isTrusted])).toEqual([
['pointerdown', 'a', true],
['pointerup', 'a', true],
['pointerdown', 'b', true],
['pointerup', 'b', true],
]);
const { x, y } = centre(plan.querySelector('[data-b]'));
expect(releves[2].clientX).toBeCloseTo(x, 0);
expect(releves[2].clientY).toBeCloseTo(y, 0);
});
}
test("le cadre de l'épreuve décalé et réduit dans sa page : le centre visé reste atteint", async () => {
// Le cadre de l'épreuve est une <iframe> de la page que pilote CDP ;
// l'épreuve la déplace, la réduit et lui pose une bordure et un retrait
// le temps de pointer, puis lui rend son style : les coordonnées du cadre
// se convertissent à chaque appel, bordure et retrait compris.
const iframe = window.frameElement;
expect(iframe).not.toBeNull();
const avant = iframe.getAttribute('style');
iframe.style.transformOrigin = '0 0';
iframe.style.transform = 'translate(37px, 23px) scale(0.5)';
iframe.style.border = '5px solid';
iframe.style.padding = '7px';
try {
const plan = monterPlan();
const releves = relever(plan, ['pointerdown', 'pointerup']);
const { x, y } = centre(plan.querySelector('[data-b]'));
await pointer('presser', x, y);
await pointer('relacher', x, y);
expect(releves.map((e) => [e.type, e.target.getAttribute('data-b') !== null])).toEqual([
['pointerdown', true],
['pointerup', true],
]);
expect(releves[0].clientX).toBeCloseTo(x, 0);
expect(releves[0].clientY).toBeCloseTo(y, 0);
} finally {
if (avant === null) iframe.removeAttribute('style');
else iframe.setAttribute('style', avant);
}
});
test('le genre stylet livre pointerType pen, et boutons choisit le bouton pressé', async () => {
const plan = monterPlan();
plan.addEventListener('contextmenu', (e) => e.preventDefault());
const releves = relever(plan, ['pointerdown', 'pointerup']);
const { x, y } = centre(plan.querySelector('[data-a]'));
await pointer('presser', x, y, { genre: 'stylet' });
await pointer('relacher', x, y, { genre: 'stylet' });
await pointer('presser', x, y, { boutons: 2 });
await pointer('relacher', x, y);
expect(releves.map((e) => [e.type, e.pointerType, e.button, e.buttons])).toEqual([
['pointerdown', 'pen', 0, 1],
['pointerup', 'pen', 0, 0],
['pointerdown', 'mouse', 2, 2],
['pointerup', 'mouse', 2, 0],
]);
});
test('molette livre un wheel au point, deltaY intact', async () => {
const plan = monterPlan();
const releves = relever(plan, ['wheel']);
plan.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });
const { x, y } = centre(plan.querySelector('[data-b]'));
await molette(x, y, 125);
await molette(x, y, -40);
expect(releves.length).toBe(2);
expect(releves.map((e) => [e.deltaY, e.isTrusted])).toEqual([
[125, true],
[-40, true],
]);
expect(releves[0].target.getAttribute('data-b')).not.toBeNull();
expect(releves[0].clientX).toBeCloseTo(x, 0);
expect(releves[0].clientY).toBeCloseTo(y, 0);
});
test("glisser sous capture livre un pointermove par pas, croissants, puis un pointerup au point d'arrivée", async () => {
const plan = monterPlan();
plan.addEventListener('pointerdown', (e) => plan.setPointerCapture(e.pointerId));
const releves = relever(plan, ['pointerdown', 'pointermove', 'pointerup']);
const depuis = centre(plan.querySelector('[data-a]'));
// L'arrivée sort du plan : la capture garde les événements sur la racine.
const vers = { x: depuis.x + 360, y: depuis.y + 40 };
await glisser(depuis, vers, { pas: 6 });
const mouvements = releves.filter((e) => e.type === 'pointermove');
expect(releves[0].type).toBe('pointerdown');
expect(mouvements.length).toBe(6);
for (let i = 1; i < mouvements.length; i += 1) {
expect(mouvements[i].clientX).toBeGreaterThan(mouvements[i - 1].clientX);
}
const fin = releves.at(-1);
expect(fin.type).toBe('pointerup');
expect(fin.clientX).toBeCloseTo(vers.x, 0);
expect(fin.clientY).toBeCloseTo(vers.y, 0);
expect(releves.every((e) => e.isTrusted)).toBe(true);
expect(plan.hasPointerCapture(releves[0].pointerId)).toBe(false);
});
test('toucher sur un élément en touch-action: none livre pointerdown puis pointerup, de type touch', async () => {
const plan = monterPlan();
const releves = relever(plan, ['pointerdown', 'pointerup', 'pointercancel']);
const { x, y } = centre(plan.querySelector('[data-b]'));
await toucher('presser', [{ x, y }]);
// L'intervalle d'une pression longue se pilote ici par l'horloge de
// l'application : aucune attente réelle entre les deux.
await toucher('relacher', []);
expect(releves.map((e) => [e.type, e.pointerType, e.isTrusted])).toEqual([
['pointerdown', 'touch', true],
['pointerup', 'touch', true],
]);
expect(releves[0].target.getAttribute('data-b')).not.toBeNull();
});
});
describe('clavier', () => {
test("touche('Escape') livre un keydown de key 'Escape'", async () => {
const releves = relever(document, ['keydown', 'keyup']);
await touche('Escape');
expect(releves.map((e) => [e.type, e.key, e.isTrusted])).toEqual([
['keydown', 'Escape', true],
['keyup', 'Escape', true],
]);
});
test('une touche inconnue lève en la nommant', async () => {
await expect(touche('Hyperespace')).rejects.toThrow(/Hyperespace/);
});
});
describe('le cadre (§ 19.4)', () => {
test('fixerCadre(1366, 700) pose innerWidth et innerHeight', async () => {
await fixerCadre(1366, 700);
expect([innerWidth, innerHeight]).toEqual([1366, 700]);
});
test('un poser qui retranche 17 px à la hauteur, comme une barre, est corrigé', async () => {
await fixerCadre(1000, 600);
const poses = [];
const poser = async (largeur, hauteur) => {
poses.push([largeur, hauteur]);
await page.viewport(largeur, hauteur - 17);
};
await fixerCadre(1366, 700, { poser });
expect([innerWidth, innerHeight]).toEqual([1366, 700]);
expect(poses).toEqual([
[1366, 700],
[1366, 717],
]);
});
test('un poser sans effet lève en nommant la hauteur visée et la hauteur relue', async () => {
await fixerCadre(1000, 600);
const poser = async () => {};
await expect(fixerCadre(1366, 700, { poser })).rejects.toThrow(/1366 × 700.*1000 × 600/);
});
});