[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
This commit is contained in:
Mathieu Benoit 2026-10-07 04:13:34 -04:00
parent 82e57f47df
commit f76c5dede5
6 changed files with 1727 additions and 124 deletions

View file

@ -10,7 +10,8 @@
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
// l'attribut data-theme de <html> choisit (§ 19.2). Le contraste est celui de
// l'attribut data-theme de <html> choisit (§ 19.2). Les mesures viennent du
// module partagé test/navigateur/rendu.js, dont le contraste est celui de
// palette.js, seule formule des WCAG du dépôt.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
@ -20,79 +21,22 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { contraste } from './palette.js';
import { VERSION } from '../version.genere.js';
import {
attenuations,
composantes,
contraste,
couleurTexte,
designation,
fondPeintSous,
porteursDeTexte,
sousTheme,
visible,
} from '../../test/navigateur/rendu.js';
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['clair', 'sombre'];
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
// couleur sRGB ; toute autre forme fait échouer l'épreuve plutôt que de la
// laisser passer sans mesure.
function composantes(couleur) {
const m = /^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/.exec(couleur);
if (m === null) {
throw new Error(`couleur calculée illisible : ${couleur}`);
}
const alpha = m[4] === undefined ? 1 : Number(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
function ascendance(element) {
const chaine = [];
for (let n = element; n !== null; n = n.parentElement) {
chaine.push(n);
}
return chaine;
}
// Fond peint sous un élément : le premier fond non transparent de son
// ascendance, ou null.
function fondPeint(element) {
for (const n of ascendance(element)) {
const fond = composantes(getComputedStyle(n).backgroundColor);
if (fond[3] !== 0) {
return fond;
}
}
return null;
}
// Éléments du sous-arbre qui portent eux-mêmes un nœud de texte non vide.
function porteursDeTexte(racine) {
return [racine, ...racine.querySelectorAll('*')].filter((el) =>
[...el.childNodes].some(
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '',
),
);
}
// Nom lisible d'un élément dans un message d'échec.
function designation(element) {
const classe = element.getAttribute('class');
return classe ? `${element.localName}.${classe}` : element.localName;
}
// Exécute mesure() sous le thème demandé, posé par data-theme sur <html>, et
// rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son
// absence, même quand mesure() lève : le test suivant part du même document.
async function sousTheme(theme, mesure) {
const racine = document.documentElement;
const avant = racine.getAttribute('data-theme');
racine.setAttribute('data-theme', theme);
try {
return await mesure();
} finally {
if (avant === null) {
racine.removeAttribute('data-theme');
} else {
racine.setAttribute('data-theme', avant);
}
}
}
let composant;
beforeEach(() => {
@ -108,9 +52,9 @@ function bandeau() {
return document.body.querySelector('header.bandeau');
}
// Le bandeau, puis chacun des éléments qui y portent un texte.
// Le bandeau, puis chacun de ses porteurs de texte.
function bandeauEtTextes() {
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
}
describe("bandeau d'application", () => {
@ -121,38 +65,17 @@ describe("bandeau d'application", () => {
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
// Le texte, le titre et le style calculé se relisent sur un élément qui
// ne peint rien. L'épreuve interroge donc la mise en page : chaque élément
// est visible pour checkVisibility (display, visibility, opacité nulle,
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
// le point central de cette boîte atteint l'élément ou l'un de ses
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
// point à un autre. elementFromPoint suit l'atteinte du pointeur : un
// élément en pointer-events: none n'y répond pas, et l'épreuve le refuse.
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
// échoue.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const elements = bandeauEtTextes();
expect(elements.length).toBeGreaterThan(1);
for (const element of elements) {
const lieu = `${theme} : ${designation(element)}`;
expect(
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
`${lieu} : checkVisibility`,
).toBe(true);
const boite = element.getBoundingClientRect();
expect(boite.width, `${lieu} : largeur`).toBeGreaterThan(0);
expect(boite.height, `${lieu} : hauteur`).toBeGreaterThan(0);
expect(boite.left, `${lieu} : bord gauche`).toBeGreaterThanOrEqual(0);
expect(boite.top, `${lieu} : bord haut`).toBeGreaterThanOrEqual(0);
expect(boite.right, `${lieu} : bord droit`).toBeLessThanOrEqual(innerWidth);
expect(boite.bottom, `${lieu} : bord bas`).toBeLessThanOrEqual(innerHeight);
const atteint = document.elementFromPoint(
boite.left + boite.width / 2,
boite.top + boite.height / 2,
);
expect(
element.contains(atteint),
`${lieu} : le centre de sa boîte atteint ${atteint === null ? 'aucun élément' : designation(atteint)}`,
).toBe(true);
const porteurs = bandeauEtTextes();
expect(porteurs.length).toBeGreaterThan(1);
for (const porteur of porteurs) {
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
}
});
}
@ -175,24 +98,25 @@ describe("bandeau d'application", () => {
expect(style.backgroundColor).not.toBe(TRANSPARENT);
});
test('chaque texte du bandeau tient 4,5:1 sur un fond opaque, dans les deux thèmes (§ 14.5)', async () => {
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes (§ 14.5)', async () => {
const couleursParTheme = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
const lieu = `${theme} : ${designation(element)}`;
const texte = composantes(getComputedStyle(element).color);
const fond = fondPeint(element);
expect(fond, lieu).not.toBeNull();
for (const porteur of textes) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
const fonds = fondPeintSous(porteur);
expect(fonds.length, `${lieu} : aucun fond opaque`).toBeGreaterThan(0);
expect(texte[3], lieu).toBe(1);
expect(fond[3], lieu).toBe(1);
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
for (const fond of fonds) {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
return [document.body, bandeau()].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
});
});
couleursParTheme.push(couleurs);
@ -205,23 +129,19 @@ describe("bandeau d'application", () => {
).not.toEqual(couleursParTheme[0]);
});
test("aucune opacité partielle, aucun filtre ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
// l'ascendance est donc relu, jusqu'à la racine du document.
// le pixel peint sans changer le style calculé du texte. attenuations
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
for (const n of ascendance(element)) {
const style = getComputedStyle(n);
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
expect(style.opacity, lieu).toBe('1');
expect(style.filter, lieu).toBe('none');
expect(style.backdropFilter, lieu).toBe('none');
expect(style.mixBlendMode, lieu).toBe('normal');
}
for (const porteur of textes) {
const fautes = attenuations(porteur).map(
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,
);
expect(fautes, `${theme} : ${designation(porteur)}`).toEqual([]);
}
});
}

177
test/navigateur/entrees.js Normal file
View file

@ -0,0 +1,177 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Entrées que Chromium traite comme réelles (§ 7.3), et le cadre d'affichage
// (§ 19.4). Les événements passent par le protocole de débogage de Chromium,
// Input.dispatch… sur la page qui porte le cadre de l'épreuve : ils
// traversent le test d'atteinte, la capture du pointeur et la conversion du
// toucher en pointeur comme ceux d'un périphérique, et portent isTrusted
// vrai. Un événement construit par new PointerEvent ne pose pas de capture.
//
// Les coordonnées reçues sont celles du cadre de l'épreuve — clientX et
// clientY de l'élément visé. CDP les attend dans la fenêtre de la page :
// chaque appel relit, d'un cadre à son parent, le rectangle du cadre et
// l'échelle qu'une transformation lui donne, et convertit au moment de
// l'envoi. Défiler la page de l'épreuve ne change rien : un point du cadre
// reste un point de sa fenêtre.
import { cdp, page } from 'vitest/browser';
// Boutons de la souris : le masque de buttons et le nom que CDP attend.
const BOUTONS = [
[1, 'left'],
[2, 'right'],
[4, 'middle'],
];
const GENRES = { souris: 'mouse', stylet: 'pen' };
const TYPES_POINTEUR = { presser: 'mousePressed', bouger: 'mouseMoved', relacher: 'mouseReleased' };
const TYPES_TOUCHER = { presser: 'touchStart', bouger: 'touchMove', relacher: 'touchEnd', annuler: 'touchCancel' };
// Touches nommées : key, code et code de touche virtuelle ; text pour celles
// qui saisissent un caractère.
const TOUCHES = {
Escape: { code: 'Escape', touche: 27 },
Enter: { code: 'Enter', touche: 13, texte: '\r' },
Tab: { code: 'Tab', touche: 9 },
' ': { code: 'Space', touche: 32, texte: ' ' },
Backspace: { code: 'Backspace', touche: 8 },
Delete: { code: 'Delete', touche: 46 },
Home: { code: 'Home', touche: 36 },
End: { code: 'End', touche: 35 },
PageUp: { code: 'PageUp', touche: 33 },
PageDown: { code: 'PageDown', touche: 34 },
ArrowLeft: { code: 'ArrowLeft', touche: 37 },
ArrowUp: { code: 'ArrowUp', touche: 38 },
ArrowRight: { code: 'ArrowRight', touche: 39 },
ArrowDown: { code: 'ArrowDown', touche: 40 },
};
// Boutons tenus enfoncés depuis le dernier presser, rendus par relacher.
let boutonsEnfonces = 0;
/** Point de la fenêtre de la page qui porte le cadre de l'épreuve, pour le
* point (x, y) de la fenêtre de ce cadre. D'un cadre à son parent : la
* boîte de l'<iframe> dans le parent, sa bordure et son retrait, et
* l'échelle que pose une transformation, rapport de la largeur rendue à la
* largeur de mise en page. */
function versPage(x, y) {
let px = x;
let py = y;
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const cadre = fenetre.frameElement;
const boite = cadre.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(cadre);
const echelle = cadre.offsetWidth > 0 ? boite.width / cadre.offsetWidth : 1;
px = boite.left + (cadre.clientLeft + parseFloat(style.paddingLeft) + px) * echelle;
py = boite.top + (cadre.clientTop + parseFloat(style.paddingTop) + py) * echelle;
}
return { x: px, y: py };
}
// Le bouton CDP d'un masque : le premier enfoncé, 'none' sans aucun.
function bouton(masque) {
return BOUTONS.find(([bit]) => (masque & bit) !== 0)?.[1] ?? 'none';
}
/** Un événement de pointeur réel au point (x, y) du cadre. type : 'presser',
* 'bouger' ou 'relacher'. boutons, le masque de buttons : par défaut, 1 au
* presser, les boutons tenus pour bouger et relacher. genre : 'souris' ou
* 'stylet' ; le doigt passe par toucher. */
export async function pointer(type, x, y, { boutons, genre = 'souris' } = {}) {
const typeCdp = TYPES_POINTEUR[type];
if (typeCdp === undefined) throw new Error(`pointer : type inconnu « ${type} »`);
const pointerType = GENRES[genre];
if (pointerType === undefined) throw new Error(`pointer : genre inconnu « ${genre} »`);
const masque = boutons ?? (type === 'presser' ? 1 : boutonsEnfonces);
const { x: px, y: py } = versPage(x, y);
const evenement = { type: typeCdp, x: px, y: py, pointerType, button: bouton(masque), buttons: masque };
if (type === 'presser') {
evenement.clickCount = 1;
boutonsEnfonces = masque;
} else if (type === 'relacher') {
evenement.clickCount = 1;
evenement.buttons = 0;
boutonsEnfonces = 0;
}
await cdp().send('Input.dispatchMouseEvent', evenement);
}
/** Presse au point depuis, avance en pas mouvements égaux jusqu'au point vers,
* et relâche au point vers. Chaque envoi attend que Chromium ait traité
* l'événement : chaque pas livre son propre pointermove, que la page ne
* reçoit pas regroupé avec le suivant. */
export async function glisser(depuis, vers, { pas = 10 } = {}) {
await pointer('presser', depuis.x, depuis.y);
for (let i = 1; i <= pas; i += 1) {
const t = i / pas;
await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t);
}
await pointer('relacher', vers.x, vers.y);
}
/** Un cran de molette au point (x, y) du cadre, deltaY en pixels. */
export async function molette(x, y, deltaY) {
const { x: px, y: py } = versPage(x, y);
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: px, y: py, deltaX: 0, deltaY });
}
/** Un événement tactile réel. type : 'presser', 'bouger', 'relacher' ou
* 'annuler' ; points : [{x, y, id?}] dans le cadre. CDP veut au moins un
* point pour presser et bouger, aucun pour relacher et annuler, qui lèvent
* les doigts posés. */
export async function toucher(type, points) {
const typeCdp = TYPES_TOUCHER[type];
if (typeCdp === undefined) throw new Error(`toucher : type inconnu « ${type} »`);
const levee = type === 'relacher' || type === 'annuler';
if (levee !== (points.length === 0)) {
throw new Error(`toucher : ${type} veut ${levee ? 'aucun point' : 'au moins un point'}, reçu ${points.length}`);
}
const touchPoints = points.map(({ x, y, id = 0 }) => ({ ...versPage(x, y), id }));
await cdp().send('Input.dispatchTouchEvent', { type: typeCdp, touchPoints });
}
/** Presse puis relâche une touche nommée par sa valeur de key ('Escape',
* 'Enter', 'ArrowDown'…) ou un caractère imprimable. Les événements vont au
* document qui a le focus. Une touche inconnue lève en la nommant. */
export async function touche(cle) {
const connue = TOUCHES[cle];
const caractere = connue === undefined && [...cle].length === 1;
if (connue === undefined && !caractere) throw new Error(`touche inconnue : « ${cle} »`);
const { code, touche: virtuelle, texte } = connue ?? { code: '', touche: 0, texte: cle };
const commun = { key: cle, code, windowsVirtualKeyCode: virtuelle, nativeVirtualKeyCode: virtuelle };
await cdp().send('Input.dispatchKeyEvent', {
type: texte === undefined ? 'rawKeyDown' : 'keyDown',
...commun,
...(texte === undefined ? {} : { text: texte, unmodifiedText: texte }),
});
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
}
// innerWidth et innerHeight du cadre. Les lire force la mise en page du
// parent : la taille que poser vient de donner à l'<iframe> s'y lit déjà.
function relire() {
return { largeur: innerWidth, hauteur: innerHeight };
}
/** Pose le cadre d'affichage à largeur × hauteur (§ 19.4). poser(largeur,
* hauteur) le dimensionne — par défaut page.viewport de vitest/browser —,
* innerWidth et innerHeight se relisent, puis, s'ils s'écartent des valeurs
* visées, poser reçoit les valeurs visées augmentées de l'écart relevé, et
* le cadre se relit. Un écart restant lève en nommant les valeurs visées et
* obtenues. Rend { largeur, hauteur } relues. */
export async function fixerCadre(largeur, hauteur, { poser = (l, h) => page.viewport(l, h) } = {}) {
await poser(largeur, hauteur);
let lu = relire();
if (lu.largeur !== largeur || lu.hauteur !== hauteur) {
await poser(largeur + (largeur - lu.largeur), hauteur + (hauteur - lu.hauteur));
lu = relire();
}
if (lu.largeur !== largeur || lu.hauteur !== hauteur) {
throw new Error(
`cadre visé ${largeur} × ${hauteur}, obtenu ${lu.largeur} × ${lu.hauteur} (innerWidth × innerHeight)`,
);
}
return lu;
}

View file

@ -0,0 +1,264 @@
// © 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/);
});
});

593
test/navigateur/rendu.js Normal file
View file

@ -0,0 +1,593 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Aides de rendu des épreuves du navigateur (§ 14.2, § 14.5). Elles lisent ce
// que le moteur de rendu a résolu — style calculé, boîte, élément qu'atteint
// un point — et jamais la présence d'une classe. Elles ne lisent aucun pixel :
// le contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// peintes que sans opacité partielle, filtre, masque ni mode de fusion, ce
// qu'attenuations relève sur toute l'ascendance d'un texte.
//
// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255,
// alpha de 0 à 1, la forme de palette.js.
//
// Un porteur de texte est { element, pseudo } : un élément qui porte lui-même
// un nœud de texte non vide (pseudo null), ou l'un de ses pseudo-éléments
// ::before et ::after dont content produit un texte. Les aides qui reçoivent
// un porteur acceptent aussi un élément nu, lu comme { element, pseudo: null }.
//
// Ce que les aides ne décèlent pas. Le centre de la boîte tient lieu de
// l'élément : un élément dont ce centre tombe hors de sa peinture — un anneau,
// un trait coudé — est dit hors d'atteinte (« n'atteint pas »). La lecture
// s'ancre à la première entrée d'elementsFromPoint que la cible contient : un
// descendant hissé au-dessus d'un recouvrement l'y ancre, et ce recouvrement
// échappe à visible et se lit comme fond peint dessous. elementsFromPoint ne
// rend jamais un pseudo-élément : sa peinture se lit sur son hôte. Un
// pseudo-élément à texte fait couvrir toute la boîte de l'hôte, un
// pseudo-élément sans texte ne couvre rien, et aucun fond de pseudo-élément
// ne se lit sous le texte d'un autre élément. Une boîte HTML se juge
// entière : son fond, son texte ou son image couvrent tout point de la boîte,
// coin arrondi ou zone sans glyphe compris. isPointInFill et isPointInStroke
// ignorent clip-path : une forme rognée au point y compte comme peinte.
/** Le rapport de contraste des WCAG, seule implémentation du dépôt. */
export { contraste } from '../../src/interface/palette.js';
const PSEUDOS = ['::before', '::after'];
// Éléments dont le texte ne se peint jamais : code, feuille, gabarit, et les
// textes de description d'un SVG, lus par l'accessibilité seule.
const TEXTES_NON_PEINTS = new Set(['script', 'style', 'template', 'noscript', 'title', 'desc', 'metadata']);
// Conteneurs SVG dont les formes ne se peignent qu'à travers une référence :
// une forme qu'ils portent n'est jamais « dessous » un élément du dessin.
const CONTENEURS_DE_REFERENCE = 'defs, pattern, clipPath, mask, marker, symbol, linearGradient, radialGradient';
// Valeurs calculées qui laissent la couleur peinte égale à la couleur résolue.
const NEUTRES = [
['opacity', '1'],
['fill-opacity', '1'],
['stroke-opacity', '1'],
['filter', 'none'],
['backdrop-filter', 'none'],
['mask-image', 'none'],
['mix-blend-mode', 'normal'],
];
/** Composantes [r, g, b, a] d'une couleur calculée. Chromium rend une couleur
* sRGB sous la forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » ; toute autre
* forme lève, plutôt que de laisser passer une épreuve sans mesure. */
export function composantes(couleur) {
const m = /^rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)$/.exec(couleur);
if (m === null) {
throw new Error(`couleur calculée illisible : « ${couleur} »`);
}
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
}
/** L'élément, puis chacun de ses ancêtres jusqu'à la racine du document ;
* parentElement relie aussi un élément SVG à son parent. */
export function ascendance(element) {
const chaine = [];
for (let n = element; n !== null; n = n.parentElement) {
chaine.push(n);
}
return chaine;
}
// { element, pseudo } d'un porteur ou d'un élément nu.
function porteur(cible) {
return cible instanceof Element ? { element: cible, pseudo: null } : cible;
}
/** Nom lisible d'un élément ou d'un porteur dans un message d'échec : la
* balise, chaque attribut data-… dans l'ordre de l'élément, chaque classe,
* puis le pseudo-élément. */
export function designation(cible) {
const { element, pseudo } = porteur(cible);
const donnees = [...element.attributes]
.filter((a) => a.name.startsWith('data-'))
.map((a) => (a.value === '' ? `[${a.name}]` : `[${a.name}="${a.value}"]`))
.join('');
const classes = [...element.classList].map((c) => `.${c}`).join('');
return `${element.localName}${donnees}${classes}${pseudo ?? ''}`;
}
// Le content calculé d'un pseudo-élément produit-il un texte ? « none » et
// « normal » n'en créent pas ; une chaîne littérale vide ou faite de blancs
// crée une boîte sans texte à lire. Toute autre valeur — compteur, attr(),
// image — compte comme texte.
function contenuTextuel(element, pseudo) {
const contenu = getComputedStyle(element, pseudo).content;
if (contenu === 'none' || contenu === 'normal') return false;
const litteral = /^"((?:[^"\\]|\\.)*)"$/.exec(contenu) ?? /^'((?:[^'\\]|\\.)*)'$/.exec(contenu);
return litteral === null || litteral[1].trim() !== '';
}
// L'élément porte-t-il lui-même un nœud de texte non vide ?
function porteTexte(element) {
return [...element.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '');
}
/** Porteurs de texte du sous-arbre de racine, racine comprise, dans l'ordre
* du document : pour chaque élément HTML, son ::before à content, l'élément
* s'il porte un nœud de texte non vide, puis son ::after à content. Un
* élément SVG ne porte pas de pseudo-élément ; les éléments dont le texte ne
* se peint jamais (script, style, title…) sont écartés. */
export function porteursDeTexte(racine) {
const porteurs = [];
for (const element of [racine, ...racine.querySelectorAll('*')]) {
if (TEXTES_NON_PEINTS.has(element.localName)) continue;
const html = element instanceof HTMLElement;
if (html && contenuTextuel(element, '::before')) porteurs.push({ element, pseudo: '::before' });
if (porteTexte(element)) porteurs.push({ element, pseudo: null });
if (html && contenuTextuel(element, '::after')) porteurs.push({ element, pseudo: '::after' });
}
return porteurs;
}
/** Couleur du texte d'un porteur : color d'un élément HTML ou d'un
* pseudo-élément, fill d'un texte SVG. Un fill qui n'est pas une couleur —
* none, un motif — lève par composantes. */
export function couleurTexte(cible) {
const { element, pseudo } = porteur(cible);
const style = getComputedStyle(element, pseudo);
return composantes(element instanceof SVGElement ? style.fill : style.color);
}
// Une couleur peint-elle quelque chose ? Un alpha nul ne peint rien.
const peint = (couleur) => couleur[3] !== 0;
// Ajoute les couleurs à la liste, sans doublon, dans leur ordre.
function ajouter(liste, couleurs) {
for (const c of couleurs) {
if (!liste.some((d) => d.join() === c.join())) liste.push(c);
}
return liste;
}
const DEGRADES = new Set(['linearGradient', 'radialGradient']);
// Un serveur de peinture porte-t-il lui-même ce qu'il peint : des <stop> pour
// un dégradé, des éléments enfants pour un motif ?
function porteSonContenu(serveur) {
return DEGRADES.has(serveur.localName)
? serveur.querySelector(':scope > stop') !== null
: serveur.firstElementChild !== null;
}
// Élément que désigne une peinture url(#id), lue dans le document de element,
// ou null pour une couleur. Un dégradé sans <stop> ou un motif sans enfant
// hérite ceux du serveur que désigne son href (ou xlink:href) : la chaîne se
// suit jusqu'au premier serveur qui porte son contenu, ou jusqu'au dernier,
// qui ne peint alors rien. Une référence vers un élément absent, ou une
// chaîne qui boucle, lève.
function serveurDePeinture(valeur, element) {
const reference = /^url\("?#([^")]+)"?\)/.exec(valeur);
if (reference === null) return null;
const doc = element.ownerDocument;
let serveur = doc.getElementById(reference[1]);
if (serveur === null) {
throw new Error(`peinture ${valeur} : aucun élément #${reference[1]}`);
}
const vus = new Set([serveur]);
while (!porteSonContenu(serveur)) {
const lien = serveur.getAttribute('href') ?? serveur.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (lien === null || !lien.startsWith('#')) break;
const suivant = doc.getElementById(lien.slice(1));
if (suivant === null) throw new Error(`peinture ${valeur} : #${serveur.id} hérite de ${lien}, absent`);
if (vus.has(suivant)) throw new Error(`peinture ${valeur} : la chaîne href de #${serveur.id} boucle`);
vus.add(suivant);
serveur = suivant;
}
return serveur;
}
// Éléments d'un motif qui peignent : formes et textes, hors display none. Un
// groupe <g> porte un fill calculé, noir par défaut, qu'il ne peint pas ; il
// est écarté, ses formes sont lues.
function peintresDuMotif(motif) {
return [...motif.querySelectorAll('*')].filter(
(n) =>
(n instanceof SVGGeometryElement || n instanceof SVGTextContentElement) &&
n.checkVisibility({ visibilityProperty: true }),
);
}
// Couleurs que peint une valeur calculée de fill ou de stroke : none ne peint
// rien ; url(#id) désigne un motif, dont les formes et les textes peignent par
// leurs propres fill et stroke, ou un dégradé, qui peint ses stop-color ;
// sinon, une couleur. Une référence vers un élément d'un autre genre lève.
function couleursDeLaPeinture(valeur, element) {
if (valeur === 'none') return [];
const serveur = serveurDePeinture(valeur, element);
if (serveur === null) {
const couleur = composantes(valeur);
return peint(couleur) ? [couleur] : [];
}
if (serveur.localName === 'pattern') {
const couleurs = [];
for (const peintre of peintresDuMotif(serveur)) {
const { fill, stroke } = couleursPeintes(peintre);
ajouter(couleurs, [...fill, ...stroke]);
}
return couleurs;
}
if (DEGRADES.has(serveur.localName)) {
return ajouter(
[],
[...serveur.querySelectorAll('stop')]
.map((arret) => composantes(getComputedStyle(arret).stopColor))
.filter(peint),
);
}
throw new Error(`peinture ${valeur} : #${serveur.id} est un <${serveur.localName}>`);
}
/** fill et stroke calculés d'une forme SVG, chacun en liste de couleurs, sans
* doublon : vide pour none ou une couleur transparente ; un motif url(#…)
* rend les couleurs que peignent ses enfants, un dégradé ses arrêts. */
export function couleursPeintes(element) {
const style = getComputedStyle(element);
return {
fill: couleursDeLaPeinture(style.fill, element),
stroke: couleursDeLaPeinture(style.stroke, element),
};
}
// Fond propre d'un élément ou d'un pseudo-élément, s'il est opaque, sinon
// null. Une image de fond se peint par-dessus la couleur de fond, et ses
// couleurs ne se lisent pas dans le style calculé : elle lève en nommant son
// porteur, plutôt que de laisser mesurer une couleur que l'écran ne montre pas.
function fondOpaque(element, pseudo = null) {
const style = getComputedStyle(element, pseudo);
if (style.backgroundImage !== 'none') {
throw new Error(`fond ${designation({ element, pseudo })} : background-image ${style.backgroundImage} ne se mesure pas`);
}
const fond = composantes(style.backgroundColor);
return fond[3] === 1 ? fond : null;
}
// Propriétés qui composent un élément avec ce qui est dessous : non neutres
// sur un fond ou sur l'un de ses ancêtres, sa couleur calculée n'est plus
// celle de l'écran.
const COMPOSITION = ['opacity', 'filter', 'mask-image', 'mix-blend-mode'];
const NEUTRE = new Map(NEUTRES);
// Fautes { element, pseudo, propriete, valeur } des propriétés nommées dont la
// valeur calculée sur element n'est pas neutre.
function fautesSur(element, proprietes) {
const style = getComputedStyle(element);
return proprietes
.filter((propriete) => style.getPropertyValue(propriete) !== NEUTRE.get(propriete))
.map((propriete) => ({ element, pseudo: null, propriete, valeur: style.getPropertyValue(propriete) }));
}
// Fautes de composition d'un fond : sur lui et sur chacun de ses ancêtres qui
// ne contiennent pas le texte mesuré. Un ancêtre commun compose le texte et
// son fond ensemble ; attenuations(texte) le relève déjà.
function voiles(fond, texte) {
return ascendance(fond)
.filter((n) => !n.contains(texte))
.flatMap((n) => fautesSur(n, COMPOSITION));
}
// Fautes qui rendent translucide la peinture fill ou stroke d'un élément SVG :
// les voiles de l'élément, son fill-opacity ou stroke-opacity — hérités, ils se
// lisent sur l'élément même —, une couleur d'alpha inférieur à 1 ; pour un
// motif, les mêmes fautes sur chaque forme qui y peint ; pour un dégradé, un
// arrêt translucide.
function translucidite(element, quoi, texte) {
const style = getComputedStyle(element);
const valeur = style.getPropertyValue(quoi);
const fautes = [...voiles(element, texte), ...fautesSur(element, [`${quoi}-opacity`])];
const serveur = serveurDePeinture(valeur, element);
if (serveur === null) {
if (composantes(valeur)[3] !== 1) fautes.push({ element, pseudo: null, propriete: quoi, valeur });
} else if (serveur.localName === 'pattern') {
for (const peintre of peintresDuMotif(serveur)) {
const { fill, stroke } = couleursPeintes(peintre);
if (fill.length > 0) fautes.push(...translucidite(peintre, 'fill', texte));
if (stroke.length > 0) fautes.push(...translucidite(peintre, 'stroke', texte));
}
} else if (DEGRADES.has(serveur.localName)) {
for (const arret of serveur.querySelectorAll('stop')) {
const { stopColor, stopOpacity } = getComputedStyle(arret);
if (composantes(stopColor)[3] !== 1) fautes.push({ element: arret, pseudo: null, propriete: 'stop-color', valeur: stopColor });
if (stopOpacity !== '1') fautes.push({ element: arret, pseudo: null, propriete: 'stop-opacity', valeur: stopOpacity });
}
}
return fautes;
}
// Lève quand le fond retenu n'est pas opaque à l'écran, en nommant le fond
// et chaque faute : une couleur composée avec ce qui est dessous ne se mesure
// pas sur sa valeur calculée (§ 14.5).
function exigerOpaque(fond, fautes, texte) {
if (fautes.length === 0) return;
const liste = fautes.map((f) => `${designation(f)} ${f.propriete}: ${f.valeur}`).join(' ; ');
throw new Error(`fond ${designation(fond)} translucide sous ${designation(texte)} : ${liste}`);
}
// Peinture, 'stroke' ou 'fill', qu'une forme SVG pose au point donné en
// coordonnées de la fenêtre, ou null quand elle n'y peint rien : forme en
// display none, cachée ou d'opacité nulle, elle ou un ancêtre
// (checkVisibility) ; peinture absente, transparente ou d'opacité nulle ;
// point hors du trait et du remplissage. Le point passe en coordonnées
// propres de la forme par l'inverse de getScreenCTM, que le moteur de rendu
// tient pour exact (§ 7.2) ; isPointInStroke et isPointInFill ignorent
// pointer-events. Le trait se peint sur le remplissage et se teste d'abord.
function peintureAuPoint(forme, centre) {
if (!forme.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
const point = centre.matrixTransform(forme.getScreenCTM().inverse());
const couleurs = couleursPeintes(forme);
const style = getComputedStyle(forme);
for (const quoi of ['stroke', 'fill']) {
if (couleurs[quoi].length === 0 || Number(style.getPropertyValue(`${quoi}-opacity`)) === 0) continue;
if (quoi === 'stroke' ? forme.isPointInStroke(point) : forme.isPointInFill(point)) return quoi;
}
return null;
}
// Couleurs qu'une forme SVG peint au point, ou null quand elle n'y peint
// rien ; lève quand la peinture retenue est translucide à l'écran.
function fondDeForme(forme, centre, texte) {
const quoi = peintureAuPoint(forme, centre);
if (quoi === null) return null;
exigerOpaque(forme, translucidite(forme, quoi, texte), texte);
return couleursPeintes(forme)[quoi];
}
// Un <use> peint une copie de l'élément qu'il désigne, que les aides ne
// lisent pas : celui qui peint au point lève en se nommant, plutôt que de
// laisser rendre ce qui est sous lui.
function refuserUse(use, texte) {
return new Error(`fond sous ${designation(texte)} : ${designation(use)} peint une copie par référence, que l'aide ne lit pas`);
}
// Le <use> peint-il au point ? Sa boîte, dans son propre repère, contient le
// point, et il n'est ni caché ni d'opacité nulle.
function useAuPoint(use, centre) {
if (!use.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
const point = centre.matrixTransform(use.getScreenCTM().inverse());
const b = use.getBBox();
return point.x >= b.x && point.x <= b.x + b.width && point.y >= b.y && point.y <= b.y + b.height;
}
// Centre de la boîte d'un élément, en coordonnées de la fenêtre.
function centreDe(element) {
const boite = element.getBoundingClientRect();
return new DOMPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
}
// Feuille qui rend chaque élément atteignable le temps d'une lecture :
// elementsFromPoint suit l'atteinte du pointeur, et un élément en
// pointer-events: none n'en est pas moins peint.
const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }';
// Éléments présents au point centre, en coordonnées de la fenêtre, du plus
// haut au plus bas dans l'ordre de peinture — cascade, positionnement et
// dessins SVG compris —, pointer-events ignoré. Un point hors de la fenêtre
// n'atteint rien et lève, au nom de texte.
function elementsAuPoint(doc, centre, texte) {
const fenetre = doc.defaultView;
if (centre.x < 0 || centre.y < 0 || centre.x >= fenetre.innerWidth || centre.y >= fenetre.innerHeight) {
throw new Error(`fond sous ${designation(texte)} : centre (${centre.x}, ${centre.y}) hors de la fenêtre`);
}
const feuille = doc.createElement('style');
feuille.textContent = ATTEIGNABLE;
doc.head.append(feuille);
try {
return doc.elementsFromPoint(centre.x, centre.y);
} finally {
feuille.remove();
}
}
// Premier fond opaque peint au point centre sous racine. Parmi les éléments
// présents au point, seuls comptent la première entrée que racine contient et
// celles qui la suivent : ce qui la précède se peint au-dessus d'elle. Les
// descendants de racine sont écartés, racine elle-même non — le fond CSS d'un
// <svg> extérieur se lit. Un élément d'opacité nulle, lui ou un ancêtre, ne
// peint rien et se saute. Une forme SVG rend sa peinture au point, un <use>
// lève, les autres éléments internes d'un SVG ne peignent pas de fond ; un
// élément HTML ou un <svg> extérieur rend son fond opaque. Le fond retenu doit
// être opaque à l'écran, sinon lève ; une racine que le point n'atteint pas
// lève aussi. Rend [] quand rien d'opaque n'est dessous.
function fondAuPoint(centre, racine, texte) {
const atteints = elementsAuPoint(racine.ownerDocument, centre, texte);
const debut = atteints.findIndex((n) => racine.contains(n));
if (debut === -1) {
throw new Error(`fond sous ${designation(texte)} : le centre (${centre.x}, ${centre.y}) n'atteint pas ${designation(racine)}`);
}
for (const n of atteints.slice(debut)) {
if (n !== racine && racine.contains(n)) continue;
if (!n.checkVisibility({ opacityProperty: true })) continue;
if (n instanceof SVGGeometryElement) {
const couleurs = fondDeForme(n, centre, texte);
if (couleurs !== null) return couleurs;
continue;
}
if (n instanceof SVGUseElement) throw refuserUse(n, texte);
if (n instanceof SVGElement && n.ownerSVGElement !== null) continue;
const fond = fondOpaque(n);
if (fond !== null) {
exigerOpaque(n, voiles(n, texte), texte);
return [fond];
}
}
return [];
}
// Le <svg> le plus extérieur qui contient element.
function svgExterieur(element) {
let svg = element.ownerSVGElement ?? element;
while (svg.ownerSVGElement !== null) svg = svg.ownerSVGElement;
return svg;
}
// Couleurs peintes sous un élément SVG, au centre de sa boîte : les formes
// et les <use> qui le précèdent dans l'ordre du document, hors de lui et de
// ses descendants et hors des conteneurs de référence, se peignent avant lui ;
// de la dernière à la première, la première forme qui peint au point rend ses
// couleurs, un <use> qui peint au point lève. Sans rien dessous dans le
// dessin, ce qui est peint au même point sous le <svg> extérieur, son propre
// fond CSS compris.
function fondSvg(element) {
const centre = centreDe(element);
const svg = svgExterieur(element);
const formes = [...svg.querySelectorAll('*')].filter(
(n) =>
(n instanceof SVGGeometryElement || n instanceof SVGUseElement) &&
n !== element &&
!element.contains(n) &&
n.closest(CONTENEURS_DE_REFERENCE) === null &&
(n.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
);
for (const forme of formes.reverse()) {
if (forme instanceof SVGUseElement) {
if (useAuPoint(forme, centre)) throw refuserUse(forme, element);
continue;
}
const couleurs = fondDeForme(forme, centre, element);
if (couleurs !== null) return couleurs;
}
return fondAuPoint(centre, svg, element);
}
/** Couleurs peintes sous le contenu d'un porteur ou d'un élément, au centre
* de sa boîte. Un élément HTML : son propre fond s'il est opaque — son texte
* se peint dessus —, sinon le premier fond opaque peint dessous à ce point,
* ses descendants exceptés : un élément HTML frère ou cousin, ou le trait ou
* le remplissage d'une forme SVG ; ce qui se peint au-dessus de lui ne compte
* pas, en pointer-events: none ou non. Un pseudo-élément : son fond opaque,
* sinon celui de son hôte. Un élément SVG, lui et ses descendants exceptés :
* le trait ou le remplissage de la forme qui le précède dessous — les
* couleurs d'un motif, ou d'un dégradé, href suivi —, ou ce qui est peint
* sous le dessin, fond CSS du <svg> extérieur compris. Une forme ou un fond
* qui ne peint rien — caché, opacité nulle, peinture d'opacité nulle — est
* sauté. Rend [] quand rien d'opaque n'est dessous. Lève quand le fond retenu
* est translucide à l'écran (opacity, fill-opacity, stroke-opacity, alpha,
* filter, masque, fusion, sur lui ou ses ancêtres propres), quand une image
* de fond ou un <use> se peint entre le texte et le fond retenu, quand le
* centre est hors de la fenêtre, ou quand il n'atteint pas l'élément. */
export function fondPeintSous(cible) {
const { element, pseudo } = porteur(cible);
if (pseudo !== null) {
const fond = fondOpaque(element, pseudo);
return fond !== null ? [fond] : fondPeintSous(element);
}
if (element instanceof SVGElement && element.ownerSVGElement !== null) return fondSvg(element);
const fond = fondOpaque(element);
if (fond !== null) return [fond];
return fondAuPoint(centreDe(element), element, element);
}
// Fautes de neutralité d'un style : chaque propriété de NEUTRES dont la
// valeur calculée n'est pas la valeur neutre.
function fautesDe(element, pseudo) {
const style = getComputedStyle(element, pseudo);
return NEUTRES.filter(([propriete, neutre]) => style.getPropertyValue(propriete) !== neutre).map(
([propriete]) => ({ element, pseudo, propriete, valeur: style.getPropertyValue(propriete) }),
);
}
/** Propriétés qui font différer le pixel peint de la couleur calculée :
* opacity, fill-opacity et stroke-opacity différents de 1 ; filter,
* backdrop-filter et mask-image différents de none ; mix-blend-mode différent
* de normal. Relevées sur le pseudo-élément, puis sur l'élément et chacun de
* ses ancêtres : opacity et filter ne s'héritent pas, et changent le pixel
* sans changer le style calculé du texte. fill-opacity s'hérite : posé sur un
* groupe, il se relève sur le groupe et sur chaque niveau qui en hérite.
* → [{ element, pseudo, propriete, valeur }], vide quand tout est neutre. */
export function attenuations(cible) {
const { element, pseudo } = porteur(cible);
return [
...(pseudo !== null ? fautesDe(element, pseudo) : []),
...ascendance(element).flatMap((n) => fautesDe(n, null)),
];
}
// Éléments dont le contenu se peint sans qu'un style le dise : image, média,
// dessin, cadre, champ.
const REMPLACES = new Set(['img', 'video', 'canvas', 'iframe', 'embed', 'object', 'input', 'textarea', 'select']);
// Un élément présent au point y peint-il quelque chose qui couvre ce qui est
// dessous ? Non s'il est caché ou d'opacité nulle, lui ou un ancêtre. Une
// forme SVG : une peinture au point (peintureAuPoint). Un autre élément
// interne d'un SVG — texte, <use>, image — : oui, son contenu est au point.
// Un élément HTML ou un <svg> extérieur : un fond d'alpha non nul, une image
// de fond, un nœud de texte propre, un pseudo-élément à texte, ou un élément
// remplacé ; une boîte sans rien de cela, qui ne sert qu'à la mise en page,
// ne couvre rien. Bordure, ombre et contour ne se lisent pas.
function peintAuPoint(n, centre) {
if (!n.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
if (n instanceof SVGGeometryElement) return peintureAuPoint(n, centre) !== null;
if (n instanceof SVGElement && n.ownerSVGElement !== null) return true;
const style = getComputedStyle(n);
return (
composantes(style.backgroundColor)[3] !== 0 ||
style.backgroundImage !== 'none' ||
porteTexte(n) ||
(n instanceof HTMLElement && PSEUDOS.some((pseudo) => contenuTextuel(n, pseudo))) ||
REMPLACES.has(n.localName)
);
}
/** true quand la cible se voit dans la fenêtre, sinon la raison, en texte.
* Un élément : checkVisibility (display, visibility, opacité nulle,
* content-visibility), une boîte non vide qui tient dans la fenêtre, puis
* l'occlusion au centre de cette boîte. Les éléments présents au point se
* lisent pointer-events ignoré — un élément en pointer-events: none se voit,
* et un recouvrement en pointer-events: none cache ce qu'il couvre. Le centre
* doit atteindre l'élément ou l'un de ses descendants — un élément rogné par
* un ancêtre le laisse à un autre —, et aucun élément peint au-dessus ne doit
* y peindre (peintAuPoint) : une boîte de mise en page vide, une zone
* d'atteinte d'opacité nulle ne cachent rien. Un pseudo-élément : son hôte,
* puis son display, sa visibility et son content. */
export function visible(cible) {
const { element, pseudo } = porteur(cible);
if (pseudo !== null) {
const hote = visible(element);
if (hote !== true) return `hôte : ${hote}`;
const style = getComputedStyle(element, pseudo);
if (style.display === 'none') return `${pseudo} : display none`;
if (style.visibility !== 'visible') return `${pseudo} : visibility ${style.visibility}`;
if (!contenuTextuel(element, pseudo)) return `${pseudo} : content ${style.content}`;
return true;
}
if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
return 'checkVisibility faux';
}
const b = element.getBoundingClientRect();
if (!(b.width > 0 && b.height > 0)) return `boîte vide : ${b.width} × ${b.height}`;
if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) {
return `hors de la fenêtre ${innerWidth} × ${innerHeight} : boîte (${b.left}, ${b.top}) – (${b.right}, ${b.bottom})`;
}
const centre = centreDe(element);
const atteints = elementsAuPoint(element.ownerDocument, centre, element);
const rang = atteints.findIndex((n) => element.contains(n));
if (rang === -1) return `le centre de sa boîte n'atteint pas ${designation(element)} : rogné, ou hors de son dessin`;
const couvrant = atteints.slice(0, rang).find((n) => peintAuPoint(n, centre));
if (couvrant !== undefined) return `le centre de sa boîte est couvert par ${designation(couvrant)}`;
return true;
}
/** Pose data-theme ('clair'|'sombre') sur <html>, attend mesure(), puis rend
* à l'attribut sa valeur d'avant, ou son absence, même quand mesure lève :
* l'épreuve suivante part du même document. Rend la valeur de mesure. */
export async function sousTheme(theme, mesure) {
const racine = document.documentElement;
const avant = racine.getAttribute('data-theme');
racine.setAttribute('data-theme', theme);
try {
return await mesure();
} finally {
if (avant === null) {
racine.removeAttribute('data-theme');
} else {
racine.setAttribute('data-theme', avant);
}
}
}

View file

@ -0,0 +1,648 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des aides de rendu (§ 14.2, § 14.5) dans un vrai moteur de rendu.
// Chaque épreuve monte un petit fragment HTML ou SVG aux couleurs inventées,
// lit ce que l'aide en rend et le confronte à ce que le fragment déclare. Le
// projet navigateur emploie expect, test et describe de Vitest.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import {
ascendance,
attenuations,
composantes,
contraste,
couleursPeintes,
couleurTexte,
designation,
fondPeintSous,
porteursDeTexte,
sousTheme,
visible,
} from './rendu.js';
import { contraste as contrastePalette } from '../../src/interface/palette.js';
const SVG = 'http://www.w3.org/2000/svg';
let scene;
let feuille;
beforeEach(() => {
scene = document.createElement('div');
scene.setAttribute('data-scene', '');
document.body.append(scene);
});
afterEach(() => {
scene.remove();
feuille?.remove();
feuille = undefined;
});
// Monte le fragment dans la scène et rend la scène.
function monter(html) {
scene.innerHTML = html;
return scene;
}
// Pose une feuille de style dans <head> : un pseudo-élément ne se déclare
// que par une règle.
function regles(texte) {
feuille = document.createElement('style');
feuille.textContent = texte;
document.head.append(feuille);
}
const $ = (selecteur) => scene.querySelector(selecteur);
describe('composantes', () => {
test('lit rgb(…) et rgba(…)', () => {
expect(composantes('rgb(12, 34, 56)')).toEqual([12, 34, 56, 1]);
expect(composantes('rgba(12, 34, 56, 0.5)')).toEqual([12, 34, 56, 0.5]);
expect(composantes('rgba(0, 0, 0, 0)')).toEqual([0, 0, 0, 0]);
});
test('lève sur une forme inconnue', () => {
for (const forme of ['hsl(0 0% 0%)', 'none', 'url("#motif")', 'rouge', '']) {
expect(() => composantes(forme), forme).toThrow(/couleur/);
}
});
test('contraste est celui de palette.js', () => {
expect(contraste).toBe(contrastePalette);
});
});
describe('ascendance et designation', () => {
test("l'ascendance d'un élément SVG remonte jusqu'à <html>", () => {
monter(`<svg xmlns="${SVG}"><g><text x="5" y="15">Iris</text></g></svg>`);
const chaine = ascendance($('text'));
expect(chaine.map((n) => n.localName).slice(0, 4)).toEqual(['text', 'g', 'svg', 'div']);
expect(chaine.at(-1)).toBe(document.documentElement);
});
test('designation nomme la balise, les attributs data-… et les classes', () => {
monter('<span class="nom gras" data-participant="7" data-actif>Théo</span><b>x</b>');
expect(designation($('span'))).toBe('span[data-participant="7"][data-actif].nom.gras');
expect(designation($('b'))).toBe('b');
expect(designation({ element: $('span'), pseudo: '::before' })).toBe(
'span[data-participant="7"][data-actif].nom.gras::before',
);
});
});
describe('porteursDeTexte', () => {
test('rend les éléments à texte non vide, HTML et SVG, dans leur ordre', () => {
monter(
`<p>Ondine <em>Lacasse</em></p><div> <span> </span></div>` +
`<svg xmlns="${SVG}"><title>bulle</title><text x="0" y="10">Grisaille</text></svg>`,
);
const porteurs = porteursDeTexte(scene);
expect(porteurs.length).toBe(3);
expect(porteurs.map((p) => p.element.localName)).toEqual(['p', 'em', 'text']);
expect(porteurs.every((p) => p.pseudo === null)).toBe(true);
});
test('rend un ::before à content comme porteur, pas un ::after à content vide', () => {
regles(
'[data-scene] .epreuve-marque::before { content: "→ "; }' +
'[data-scene] .epreuve-marque::after { content: ""; }',
);
monter('<span class="epreuve-marque"></span>');
expect(porteursDeTexte(scene)).toEqual([{ element: $('span'), pseudo: '::before' }]);
});
test("un pseudo-élément dont content n'est qu'un blanc n'est pas un porteur", () => {
regles(
'[data-scene] .epreuve-blanc::before { content: " "; }' +
'[data-scene] .epreuve-blanc::after { content: "\\a0"; }',
);
monter('<span class="epreuve-blanc">a</span>');
expect(porteursDeTexte(scene)).toEqual([{ element: $('span'), pseudo: null }]);
});
});
describe('couleurTexte', () => {
test("lit color d'un élément HTML et fill d'un <text> SVG", () => {
monter(
`<span style="color: rgb(40, 50, 60)">Iris</span>` +
`<svg xmlns="${SVG}"><text x="0" y="10" fill="rgb(70, 80, 90)" style="color: rgb(1, 1, 1)">Ombrelle</text></svg>`,
);
expect(couleurTexte({ element: $('span'), pseudo: null })).toEqual([40, 50, 60, 1]);
expect(couleurTexte({ element: $('text'), pseudo: null })).toEqual([70, 80, 90, 1]);
expect(couleurTexte($('span'))).toEqual([40, 50, 60, 1]);
});
});
describe('couleursPeintes', () => {
test('un motif de hachure rend les deux couleurs que peignent ses enfants', () => {
monter(
`<svg xmlns="${SVG}" width="80" height="80"><defs>` +
`<pattern id="epreuve-hachure" width="8" height="8" patternUnits="userSpaceOnUse">` +
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/>` +
`</pattern></defs>` +
`<rect data-forme width="80" height="80" fill="url(#epreuve-hachure)" stroke="rgb(1, 2, 3)"/></svg>`,
);
expect(couleursPeintes($('[data-forme]'))).toEqual({
fill: [
[250, 240, 200, 1],
[20, 30, 90, 1],
],
stroke: [[1, 2, 3, 1]],
});
});
test('un remplissage plein rend sa couleur, none et transparent ne rendent rien', () => {
monter(
`<svg xmlns="${SVG}"><circle data-a r="5" cx="5" cy="5" fill="rgb(9, 8, 7)"/>` +
`<circle data-b r="5" cx="5" cy="5" fill="none" stroke="transparent"/></svg>`,
);
expect(couleursPeintes($('[data-a]'))).toEqual({ fill: [[9, 8, 7, 1]], stroke: [] });
expect(couleursPeintes($('[data-b]'))).toEqual({ fill: [], stroke: [] });
});
test('une hachure groupée dans un <g> : les couleurs de ses formes, pas le noir du groupe', () => {
monter(
`<svg xmlns="${SVG}" width="80" height="80"><defs>` +
`<pattern id="epreuve-hachure-groupee" width="8" height="8" patternUnits="userSpaceOnUse"><g>` +
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/>` +
`<rect width="2" height="2" fill="rgb(7, 7, 7)" style="display: none"/>` +
`</g></pattern></defs>` +
`<rect data-forme width="80" height="80" fill="url(#epreuve-hachure-groupee)"/></svg>`,
);
expect(couleursPeintes($('[data-forme]')).fill).toEqual([
[250, 240, 200, 1],
[20, 30, 90, 1],
]);
});
});
describe('fondPeintSous', () => {
test("un cercle posé sur un rectangle rempli : le remplissage du rectangle", () => {
monter(
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100">` +
`<rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
`<circle data-pastille cx="100" cy="50" r="20" fill="rgb(10, 10, 10)"/></svg></div>`,
);
expect(fondPeintSous($('[data-pastille]'))).toEqual([[200, 220, 240, 1]]);
});
test('un texte SVG sur un rectangle hachuré : chaque couleur du motif', () => {
monter(
`<svg xmlns="${SVG}" width="200" height="100"><defs>` +
`<pattern id="epreuve-hachure-fond" width="8" height="8" patternUnits="userSpaceOnUse">` +
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/></pattern></defs>` +
`<rect x="10" y="10" width="180" height="80" fill="url(#epreuve-hachure-fond)"/>` +
`<text x="40" y="55" fill="rgb(0, 0, 0)">Pervenche</text></svg>`,
);
expect(fondPeintSous({ element: $('text'), pseudo: null })).toEqual([
[250, 240, 200, 1],
[20, 30, 90, 1],
]);
});
test('seules comptent les formes qui précèdent la cible : un rectangle peint après elle ne la porte pas', () => {
monter(
`<svg xmlns="${SVG}" width="200" height="100">` +
`<rect data-a width="200" height="100" fill="rgb(200, 220, 240)"/>` +
`<circle data-pastille cx="100" cy="50" r="20" fill="rgb(10, 10, 10)"/>` +
`<rect data-b width="200" height="100" fill="rgb(120, 40, 60)"/></svg>`,
);
expect(fondPeintSous($('[data-pastille]'))).toEqual([[200, 220, 240, 1]]);
});
// Chaque cas rend translucide la forme posée sur le rectangle noir ; la
// couleur peinte n'est plus la couleur calculée, et l'aide lève en nommant
// la forme plutôt que de rendre un blanc que l'écran ne montre pas.
const TRANSLUCIDES = [
['fill-opacity', 'fill="rgb(255, 255, 255)" fill-opacity="0.2"', '', /fill-opacity/],
['opacity', 'fill="rgb(255, 255, 255)" opacity="0.2"', '', /opacity/],
['un alpha de couleur', 'fill="rgba(255, 255, 255, 0.2)"', '', /fill/],
["l'opacity d'un groupe", 'fill="rgb(255, 255, 255)"', 'opacity="0.2"', /opacity/],
];
for (const [nom, attributs, groupe, attendu] of TRANSLUCIDES) {
test(`une forme translucide (${nom}) sur une forme opaque : lève en nommant la forme`, () => {
monter(
`<svg xmlns="${SVG}" width="200" height="100">` +
`<rect width="200" height="100" fill="rgb(0, 0, 0)"/>` +
`<g ${groupe}><rect data-voile width="200" height="100" ${attributs}/></g>` +
`<text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg>`,
);
expect(() => fondPeintSous($('text'))).toThrow(attendu);
expect(() => fondPeintSous($('text'))).toThrow(/rect\[data-voile\]/);
});
}
test("un motif dont une forme est translucide, un dégradé à arrêt translucide : lève en les nommant", () => {
monter(
`<svg xmlns="${SVG}" width="200" height="200"><defs>` +
`<pattern id="epreuve-motif-voile" width="8" height="8" patternUnits="userSpaceOnUse">` +
`<rect data-maille width="8" height="8" fill="rgb(250, 240, 200)" fill-opacity="0.5"/></pattern>` +
`<linearGradient id="epreuve-degrade-voile"><stop offset="0" stop-color="rgb(250, 240, 200)"/>` +
`<stop offset="1" stop-color="rgb(20, 30, 90)" stop-opacity="0.5"/></linearGradient></defs>` +
`<rect width="200" height="100" fill="url(#epreuve-motif-voile)"/>` +
`<rect y="100" width="200" height="100" fill="url(#epreuve-degrade-voile)"/>` +
`<text data-haut x="60" y="55" fill="rgb(0, 0, 0)">Iris</text>` +
`<text data-bas x="60" y="155" fill="rgb(0, 0, 0)">Théo</text></svg>`,
);
expect(() => fondPeintSous($('[data-haut]'))).toThrow(/rect\[data-maille\] fill-opacity: 0\.5/);
expect(() => fondPeintSous($('[data-bas]'))).toThrow(/stop stop-opacity: 0\.5/);
});
test('un élément SVG sans forme dessous : le premier fond HTML opaque', () => {
monter(
`<div style="background: rgb(230, 240, 250)"><div><svg xmlns="${SVG}" width="200" height="100">` +
`<rect width="40" height="40" fill="rgb(1, 1, 1)"/>` +
`<text x="100" y="60">Grisaille</text></svg></div></div>`,
);
expect(fondPeintSous($('text'))).toEqual([[230, 240, 250, 1]]);
});
test("un texte HTML sans fond : le premier fond opaque de son ascendance", () => {
monter(
'<div style="background: rgb(250, 250, 240)"><p style="background: rgba(0, 0, 0, 0.5)">' +
'<span>Club des Merles</span></p></div>',
);
expect(fondPeintSous($('span'))).toEqual([[250, 250, 240, 1]]);
});
test('un texte HTML posé sur un rect SVG frère : le remplissage du rect, pas le fond de la surface', () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
`<svg xmlns="${SVG}" width="200" height="100" style="display: block"><rect width="200" height="100" fill="rgb(0, 0, 0)"/></svg>` +
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
);
expect(fondPeintSous($('span'))).toEqual([[0, 0, 0, 1]]);
});
test('un texte HTML sur un div frère absolu : le fond du frère, même en pointer-events: none', () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
'<div data-plateau style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div>' +
'<span style="position: absolute; left: 10px; top: 10px; pointer-events: none">Iris</span></div>',
);
expect(fondPeintSous($('span'))).toEqual([[0, 0, 0, 1]]);
});
test('un texte HTML sur un frère opaque mais à opacity 0,2 : lève en nommant le frère', () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
'<div data-plateau style="position: absolute; inset: 0; background: rgb(0, 0, 0); opacity: 0.2"></div>' +
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
);
expect(() => fondPeintSous($('span'))).toThrow(/div\[data-plateau\].*opacity/);
});
test('un texte HTML hors de la fenêtre : lève, rien ne se lit sous un point hors champ', () => {
monter('<span style="position: fixed; left: -500px; top: 0">Iris</span>');
expect(() => fondPeintSous($('span'))).toThrow(/fenêtre/);
});
test("le fond CSS du <svg> extérieur, sous un texte SVG sans forme dessous", () => {
monter(
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100" ` +
`style="display: block; background: rgb(0, 0, 0)"><text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg></div>`,
);
expect(fondPeintSous($('text'))).toEqual([[0, 0, 0, 1]]);
});
test("un recouvrement peint après un texte HTML, en pointer-events: none, n'est pas dessous", () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
'<span style="position: absolute; left: 10px; top: 10px">Iris</span>' +
'<div data-voile style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div></div>',
);
expect(fondPeintSous($('span'))).toEqual([[255, 255, 255, 1]]);
});
test("un recouvrement HTML peint après le dessin n'est pas sous un texte SVG", () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
`<svg xmlns="${SVG}" width="200" height="100" style="display: block"><text x="60" y="55">Iris</text></svg>` +
'<div data-voile style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div></div>',
);
expect(fondPeintSous($('text'))).toEqual([[255, 255, 255, 1]]);
});
test("un descendant à fond opaque qui couvre le centre d'un texte HTML n'est pas dessous", () => {
monter(
'<div style="background: rgb(255, 255, 255)"><p style="position: relative">Iris' +
'<b style="position: absolute; inset: 0; background: rgb(0, 0, 0)"></b></p></div>',
);
expect(fondPeintSous($('p'))).toEqual([[255, 255, 255, 1]]);
});
test("une forme peinte après un texte SVG sans forme dessous n'est pas dessous", () => {
monter(
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100" style="display: block">` +
`<text x="60" y="55">Iris</text><rect width="200" height="100" fill="rgb(0, 0, 0)"/></svg></div>`,
);
expect(fondPeintSous($('text'))).toEqual([[255, 255, 255, 1]]);
});
test("un texte que son centre n'atteint pas : lève en le nommant", () => {
monter('<div style="background: rgb(255, 255, 255)"><span data-cache style="visibility: hidden">Iris</span></div>');
expect(() => fondPeintSous($('span'))).toThrow(/span\[data-cache\].*n'atteint pas/);
});
// Chaque cas pose, entre le rectangle clair et le texte, une forme qui ne
// peint rien au centre : l'aide la saute et rend le rectangle clair.
const SANS_PEINTURE = [
['opacity 0', '<rect width="200" height="100" fill="rgb(0, 0, 0)" opacity="0"/>'],
['fill-opacity 0', '<rect width="200" height="100" fill="rgb(0, 0, 0)" fill-opacity="0"/>'],
['visibility hidden', '<rect width="200" height="100" fill="rgb(0, 0, 0)" visibility="hidden"/>'],
['maille d’un <pattern>', '<pattern id="epreuve-maille" width="200" height="100" patternUnits="userSpaceOnUse"><rect width="200" height="100" fill="rgb(0, 0, 0)"/></pattern>'],
];
for (const [nom, forme] of SANS_PEINTURE) {
test(`une forme qui ne peint rien au centre (${nom}) n'est pas dessous`, () => {
monter(
`<svg xmlns="${SVG}" width="200" height="100"><rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
`${forme}<text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg>`,
);
expect(fondPeintSous($('text'))).toEqual([[200, 220, 240, 1]]);
});
}
test("un frère HTML à opacity 0 sous un texte HTML ne peint rien", () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
'<div style="position: absolute; inset: 0; background: rgb(0, 0, 0); opacity: 0"></div>' +
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
);
expect(fondPeintSous($('span'))).toEqual([[255, 255, 255, 1]]);
});
test('une forme peinte par <use> sous un texte SVG : lève en nommant le <use>', () => {
monter(
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100">` +
`<defs><rect id="epreuve-plateau" width="200" height="100" fill="rgb(0, 0, 0)"/></defs>` +
`<use data-plateau href="#epreuve-plateau"/><text x="60" y="55" fill="rgb(255, 255, 255)">Iris</text></svg></div>`,
);
expect(() => fondPeintSous($('text'))).toThrow(/use\[data-plateau\]/);
});
test('un texte HTML posé sur un <use> : lève en nommant le <use>', () => {
monter(
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
`<svg xmlns="${SVG}" width="200" height="100" style="display: block">` +
`<defs><rect id="epreuve-plateau-html" width="200" height="100" fill="rgb(0, 0, 0)"/></defs>` +
`<use data-plateau href="#epreuve-plateau-html"/></svg>` +
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
);
expect(() => fondPeintSous($('span'))).toThrow(/use\[data-plateau\]/);
});
test('un dégradé et un motif qui héritent leurs arrêts ou leurs formes par href', () => {
monter(
`<svg xmlns="${SVG}" width="200" height="200"><defs>` +
`<linearGradient id="epreuve-g0"><stop stop-color="rgb(0, 0, 0)"/><stop offset="1" stop-color="rgb(10, 10, 10)"/></linearGradient>` +
`<linearGradient id="epreuve-g1" href="#epreuve-g0"/>` +
`<pattern id="epreuve-p0" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="rgb(20, 20, 20)"/></pattern>` +
`<pattern id="epreuve-p1" href="#epreuve-p0"/></defs>` +
`<rect width="200" height="200" fill="rgb(255, 255, 255)"/>` +
`<rect width="200" height="100" fill="url(#epreuve-g1)"/>` +
`<rect y="100" width="200" height="100" fill="url(#epreuve-p1)"/>` +
`<text data-haut x="60" y="55">Iris</text><text data-bas x="60" y="155">Théo</text></svg>`,
);
expect(fondPeintSous($('[data-haut]'))).toEqual([
[0, 0, 0, 1],
[10, 10, 10, 1],
]);
expect(fondPeintSous($('[data-bas]'))).toEqual([[20, 20, 20, 1]]);
});
test('une image de fond sur le chemin du fond retenu, ou sur la cible : lève en nommant son porteur', () => {
monter(
'<div style="background: rgb(255, 255, 255)"><p data-degrade style="background-image: linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))">' +
'<span>Iris</span></p>' +
'<button style="background: rgb(30, 60, 90) linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))">Retenir</button></div>',
);
expect(() => fondPeintSous($('span'))).toThrow(/p\[data-degrade\].*background-image/);
expect(() => fondPeintSous($('button'))).toThrow(/button.*background-image/);
});
test('un bouton à fond opaque : son propre fond', () => {
monter(
'<div style="background: rgb(250, 250, 240)">' +
'<button style="background: rgb(30, 60, 90); color: rgb(255, 255, 255)">Retenir</button></div>',
);
expect(fondPeintSous($('button'))).toEqual([[30, 60, 90, 1]]);
});
test("sans aucun fond opaque, la liste est vide", async () => {
// L'épreuve retire le fond de <html> et de <body> le temps de la mesure.
const avant = [document.documentElement.style.background, document.body.style.background];
document.documentElement.style.background = 'transparent';
document.body.style.background = 'transparent';
try {
monter('<span>Iris</span>');
expect(fondPeintSous($('span'))).toEqual([]);
} finally {
[document.documentElement.style.background, document.body.style.background] = avant;
}
});
});
describe('pseudo-éléments', () => {
test('couleurTexte, fondPeintSous et attenuations lisent le ::before, pas son hôte', () => {
regles(
'[data-scene] .epreuve-avant::before { content: "★"; color: rgb(10, 20, 30); opacity: 0.6; }' +
'[data-scene] .epreuve-propre::before { content: "☆"; color: rgb(10, 20, 30); background: rgb(90, 80, 70); }',
);
monter(
'<span class="epreuve-avant" style="background: rgb(240, 230, 220); color: rgb(5, 5, 5)"></span>' +
'<span class="epreuve-propre" style="background: rgb(240, 230, 220)"></span>',
);
const [avant, propre] = porteursDeTexte(scene);
expect(avant).toEqual({ element: $('.epreuve-avant'), pseudo: '::before' });
expect(couleurTexte(avant)).toEqual([10, 20, 30, 1]);
expect(fondPeintSous(avant)).toEqual([[240, 230, 220, 1]]);
expect(fondPeintSous(propre)).toEqual([[90, 80, 70, 1]]);
expect(attenuations(avant)).toEqual([
{ element: $('.epreuve-avant'), pseudo: '::before', propriete: 'opacity', valeur: '0.6' },
]);
expect(attenuations(propre)).toEqual([]);
});
});
describe('attenuations', () => {
test('un texte sans atténuation : aucune faute', () => {
monter('<p><span>Ondine</span></p>');
expect(attenuations($('span'))).toEqual([]);
});
// Chaque cas pose une propriété sur un ancêtre ou sur le texte, et attend
// la faute sur l'élément qui la porte.
const CAS = [
['opacity', 'opacity: 0.6', '0.6'],
['filter', 'filter: grayscale(1)', 'grayscale(1)'],
['backdrop-filter', 'backdrop-filter: blur(2px)', 'blur(2px)'],
['mask-image', 'mask-image: linear-gradient(black, black)', 'linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))'],
['mix-blend-mode', 'mix-blend-mode: multiply', 'multiply'],
];
for (const [propriete, declaration, valeur] of CAS) {
test(`relève ${propriete} posé sur un ancêtre`, () => {
monter(`<section style="${declaration}"><p><span>Iris</span></p></section>`);
expect(attenuations($('span'))).toEqual([
{ element: $('section'), pseudo: null, propriete, valeur },
]);
});
}
test("relève un fill-opacity de 0,5 sur le groupe d'un texte SVG, et sur ce qui en hérite", () => {
monter(`<svg xmlns="${SVG}"><g fill-opacity="0.5"><text x="0" y="10">Théo</text></g></svg>`);
const fautes = attenuations($('text'));
expect(fautes).toContainEqual({ element: $('g'), pseudo: null, propriete: 'fill-opacity', valeur: '0.5' });
expect(fautes.every((f) => f.propriete === 'fill-opacity')).toBe(true);
});
test('relève un stroke-opacity', () => {
monter(`<svg xmlns="${SVG}"><circle cx="5" cy="5" r="4" stroke="rgb(0, 0, 0)" stroke-opacity="0.4"/></svg>`);
expect(attenuations($('circle'))).toContainEqual({
element: $('circle'),
pseudo: null,
propriete: 'stroke-opacity',
valeur: '0.4',
});
});
});
describe('visible', () => {
test('un élément visible, puis caché, recouvert, hors de la fenêtre', () => {
monter(
'<div style="position: relative; width: 200px; height: 40px">' +
'<span data-a>Iris</span>' +
'<span data-b style="display: none">Théo</span>' +
'<span data-c style="position: absolute; left: 120px; top: 0">Ondine</span>' +
'<span data-d style="position: absolute; left: 0; top: 0; width: 200px; height: 40px; background: rgb(1, 1, 1)"></span>' +
'<span data-e style="position: fixed; left: -500px; top: 0">Grisaille</span>' +
'</div>',
);
expect(visible($('[data-a]'))).toMatch(/centre/);
expect(visible($('[data-b]'))).toMatch(/checkVisibility/);
expect(visible($('[data-c]'))).toMatch(/centre/);
expect(visible($('[data-e]'))).toMatch(/fenêtre/);
$('[data-d]').remove();
expect(visible($('[data-a]'))).toBe(true);
expect(visible($('[data-c]'))).toBe(true);
});
test('un élément en pointer-events: none se voit, comme la pastille du plan', () => {
monter(
`<svg xmlns="${SVG}" width="200" height="100"><rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
`<text data-pastille x="60" y="55" style="pointer-events: none">3</text></svg>`,
);
expect(visible($('text'))).toBe(true);
});
test("un recouvrement opaque en pointer-events: none cache le texte dessous, et se nomme", () => {
monter(
'<div style="position: relative; width: 200px; height: 40px">' +
'<span>Iris</span>' +
'<div data-fantome style="position: absolute; inset: 0; background: rgb(1, 1, 1); pointer-events: none"></div></div>',
);
expect(visible($('span'))).toMatch(/couvert par div\[data-fantome\]/);
});
// Recouvrements qui ne peignent rien au centre : ils ne cachent pas le texte.
const TRANSPARENTS = [
['une boîte HTML sans fond ni texte', '<div style="position: absolute; inset: 0"></div>'],
['une boîte HTML à opacity 0', '<div style="position: absolute; inset: 0; background: rgb(1, 1, 1); opacity: 0"></div>'],
[
'une zone d’atteinte SVG à fill-opacity 0',
`<svg xmlns="${SVG}" width="200" height="40" style="position: absolute; left: 0; top: 0"><rect width="200" height="40" fill="rgb(1, 1, 1)" fill-opacity="0"/></svg>`,
],
];
for (const [nom, recouvrement] of TRANSPARENTS) {
test(`${nom} posée sur le texte ne le cache pas`, () => {
monter(`<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>${recouvrement}</div>`);
expect(visible($('span'))).toBe(true);
});
}
// Recouvrements qui peignent au centre : chacun cache le texte.
const OPAQUES = [
['une boîte HTML qui porte un texte', '<div style="position: absolute; inset: 0">Théo</div>'],
[
'une boîte HTML à image de fond',
'<div style="position: absolute; inset: 0; background-image: linear-gradient(rgb(1, 1, 1), rgb(1, 1, 1))"></div>',
],
['une image sans fond', '<img alt="" style="position: absolute; left: 0; top: 0; width: 200px; height: 40px">'],
[
'une forme SVG remplie',
`<svg xmlns="${SVG}" width="200" height="40" style="position: absolute; left: 0; top: 0"><rect width="200" height="40" fill="rgb(1, 1, 1)"/></svg>`,
],
];
for (const [nom, recouvrement] of OPAQUES) {
test(`${nom} posée sur le texte le cache`, () => {
monter(`<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>${recouvrement}</div>`);
expect(visible($('span'))).toMatch(/couvert/);
});
}
test('une boîte sans fond dont un pseudo-élément porte un texte, posée sur le texte, le cache', () => {
regles('[data-scene] .epreuve-etiquette::before { content: "Théo"; }');
monter(
'<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>' +
'<div class="epreuve-etiquette" style="position: absolute; inset: 0"></div></div>',
);
expect(visible($('span'))).toMatch(/couvert par div\.epreuve-etiquette/);
});
test("un élément rogné par son ancêtre au centre de sa boîte : le centre ne l'atteint pas", () => {
monter(
'<div style="position: relative; width: 100px; height: 40px; overflow: hidden">' +
'<span style="position: absolute; left: 60px; top: 0; width: 100px">Iris</span></div>',
);
expect(visible($('span'))).toMatch(/n'atteint pas/);
});
test("un pseudo-élément : son hôte, son display et son content", () => {
regles(
'[data-scene] .epreuve-vu::before { content: "★"; }' +
'[data-scene] .epreuve-cache::before { content: "★"; display: none; }',
);
monter('<span class="epreuve-vu">Iris</span> <span class="epreuve-cache">Théo</span>');
expect(visible({ element: $('.epreuve-vu'), pseudo: '::before' })).toBe(true);
expect(visible({ element: $('.epreuve-cache'), pseudo: '::before' })).toMatch(/display/);
expect(visible({ element: $('.epreuve-vu'), pseudo: '::after' })).toMatch(/content/);
});
});
describe('sousTheme', () => {
const racine = document.documentElement;
test("pose le thème pendant la mesure, puis rend 'clair' qu'il portait", async () => {
racine.setAttribute('data-theme', 'clair');
try {
const lu = await sousTheme('sombre', () => racine.getAttribute('data-theme'));
expect(lu).toBe('sombre');
expect(racine.getAttribute('data-theme')).toBe('clair');
} finally {
racine.removeAttribute('data-theme');
}
});
test("laisse l'attribut absent quand il l'était", async () => {
racine.removeAttribute('data-theme');
await sousTheme('sombre', () => {
expect(racine.getAttribute('data-theme')).toBe('sombre');
});
expect(racine.hasAttribute('data-theme')).toBe(false);
});
test('rend la valeur d’avant, ou son absence, même quand la mesure lève', async () => {
racine.removeAttribute('data-theme');
await expect(sousTheme('sombre', () => { throw new Error('mesure manquée'); })).rejects.toThrow('mesure manquée');
expect(racine.hasAttribute('data-theme')).toBe(false);
racine.setAttribute('data-theme', 'clair');
try {
await expect(sousTheme('sombre', async () => { throw new Error('mesure manquée'); })).rejects.toThrow('mesure manquée');
expect(racine.getAttribute('data-theme')).toBe('clair');
} finally {
racine.removeAttribute('data-theme');
}
});
});

View file

@ -13,7 +13,8 @@
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
// module s'y résout par les mêmes greffons et réglages que dans la
// construction du livrable, sans seconde configuration à tenir
// d'accord avec la première.
// d'accord avec la première. Il joue les épreuves de src/ et
// celles de l'outillage partagé de test/navigateur/.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { optionsChromium } from './test/chromium.js';
@ -95,7 +96,7 @@ export default defineConfig({
{ extends: './vite.config.js',
test: {
name: 'navigateur',
include: ['src/**/*.navigateur.test.js'],
include: ['src/**/*.navigateur.test.js', 'test/**/*.navigateur.test.js'],
browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)),
instances: [{ browser: 'chromium' }] } } },
],