[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:
parent
82e57f47df
commit
f76c5dede5
6 changed files with 1727 additions and 124 deletions
|
|
@ -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
177
test/navigateur/entrees.js
Normal 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;
|
||||
}
|
||||
264
test/navigateur/entrees.navigateur.test.js
Normal file
264
test/navigateur/entrees.navigateur.test.js
Normal 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
593
test/navigateur/rendu.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
648
test/navigateur/rendu.navigateur.test.js
Normal file
648
test/navigateur/rendu.navigateur.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }] } } },
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue