From f76c5dede5afa1563e5052e84b022f6edefc8021 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 04:13:34 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20tests:=20browser=20tooling=20=E2=80=94?= =?UTF-8?q?=20reachable=20entries,=20real=20colours,=20contrast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 --- src/interface/App.navigateur.test.js | 164 ++---- test/navigateur/entrees.js | 177 ++++++ test/navigateur/entrees.navigateur.test.js | 264 +++++++++ test/navigateur/rendu.js | 593 +++++++++++++++++++ test/navigateur/rendu.navigateur.test.js | 648 +++++++++++++++++++++ vitest.config.js | 5 +- 6 files changed, 1727 insertions(+), 124 deletions(-) create mode 100644 test/navigateur/entrees.js create mode 100644 test/navigateur/entrees.navigateur.test.js create mode 100644 test/navigateur/rendu.js create mode 100644 test/navigateur/rendu.navigateur.test.js diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index 162f7e8..9ec9233 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -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 choisit (§ 19.2). Le contraste est celui de +// l'attribut data-theme de 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 , 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([]); } }); } diff --git a/test/navigateur/entrees.js b/test/navigateur/entrees.js new file mode 100644 index 0000000..e70820d --- /dev/null +++ b/test/navigateur/entrees.js @@ -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' : 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'à ", () => { + monter(`Iris`); + 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('Théox'); + 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( + `

Ondine Lacasse

` + + `bulleGrisaille`, + ); + 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(''); + 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('a'); + expect(porteursDeTexte(scene)).toEqual([{ element: $('span'), pseudo: null }]); + }); +}); + +describe('couleurTexte', () => { + test("lit color d'un élément HTML et fill d'un SVG", () => { + monter( + `Iris` + + `Ombrelle`, + ); + 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( + `` + + `` + + `` + + `` + + `` + + ``, + ); + 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( + `` + + ``, + ); + 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 : les couleurs de ses formes, pas le noir du groupe', () => { + monter( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + 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( + `
` + + `` + + `
`, + ); + expect(fondPeintSous($('[data-pastille]'))).toEqual([[200, 220, 240, 1]]); + }); + + test('un texte SVG sur un rectangle hachuré : chaque couleur du motif', () => { + monter( + `` + + `` + + `` + + `` + + `` + + `Pervenche`, + ); + 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( + `` + + `` + + `` + + ``, + ); + 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( + `` + + `` + + `` + + `Iris`, + ); + 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( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `Iris` + + `Théo`, + ); + 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( + `
` + + `` + + `Grisaille
`, + ); + expect(fondPeintSous($('text'))).toEqual([[230, 240, 250, 1]]); + }); + + test("un texte HTML sans fond : le premier fond opaque de son ascendance", () => { + monter( + '

' + + 'Club des Merles

', + ); + 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( + '
' + + `` + + 'Iris
', + ); + 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( + '
' + + '
' + + 'Iris
', + ); + 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( + '
' + + '
' + + 'Iris
', + ); + 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('Iris'); + expect(() => fondPeintSous($('span'))).toThrow(/fenêtre/); + }); + + test("le fond CSS du extérieur, sous un texte SVG sans forme dessous", () => { + monter( + `
Iris
`, + ); + 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( + '
' + + 'Iris' + + '
', + ); + 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( + '
' + + `Iris` + + '
', + ); + 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( + '

Iris' + + '

', + ); + 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( + `
` + + `Iris
`, + ); + expect(fondPeintSous($('text'))).toEqual([[255, 255, 255, 1]]); + }); + + test("un texte que son centre n'atteint pas : lève en le nommant", () => { + monter('
Iris
'); + 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', ''], + ['fill-opacity 0', ''], + ['visibility hidden', ''], + ['maille d’un ', ''], + ]; + for (const [nom, forme] of SANS_PEINTURE) { + test(`une forme qui ne peint rien au centre (${nom}) n'est pas dessous`, () => { + monter( + `` + + `${forme}Iris`, + ); + expect(fondPeintSous($('text'))).toEqual([[200, 220, 240, 1]]); + }); + } + + test("un frère HTML à opacity 0 sous un texte HTML ne peint rien", () => { + monter( + '
' + + '
' + + 'Iris
', + ); + expect(fondPeintSous($('span'))).toEqual([[255, 255, 255, 1]]); + }); + + test('une forme peinte par sous un texte SVG : lève en nommant le ', () => { + monter( + `
` + + `` + + `Iris
`, + ); + expect(() => fondPeintSous($('text'))).toThrow(/use\[data-plateau\]/); + }); + + test('un texte HTML posé sur un : lève en nommant le ', () => { + monter( + '
' + + `` + + `` + + `` + + 'Iris
', + ); + 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( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `IrisThéo`, + ); + 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( + '

' + + 'Iris

' + + '
', + ); + 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( + '
' + + '
', + ); + expect(fondPeintSous($('button'))).toEqual([[30, 60, 90, 1]]); + }); + + test("sans aucun fond opaque, la liste est vide", async () => { + // L'épreuve retire le fond de et de 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('Iris'); + 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( + '' + + '', + ); + 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('

Ondine

'); + 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(`

Iris

`); + 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(`Théo`); + 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(``); + 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( + '
' + + 'Iris' + + 'Théo' + + 'Ondine' + + '' + + 'Grisaille' + + '
', + ); + 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( + `` + + `3`, + ); + expect(visible($('text'))).toBe(true); + }); + + test("un recouvrement opaque en pointer-events: none cache le texte dessous, et se nomme", () => { + monter( + '
' + + 'Iris' + + '
', + ); + 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', '
'], + ['une boîte HTML à opacity 0', '
'], + [ + 'une zone d’atteinte SVG à fill-opacity 0', + ``, + ], + ]; + for (const [nom, recouvrement] of TRANSPARENTS) { + test(`${nom} posée sur le texte ne le cache pas`, () => { + monter(`
Iris${recouvrement}
`); + expect(visible($('span'))).toBe(true); + }); + } + + // Recouvrements qui peignent au centre : chacun cache le texte. + const OPAQUES = [ + ['une boîte HTML qui porte un texte', '
Théo
'], + [ + 'une boîte HTML à image de fond', + '
', + ], + ['une image sans fond', ''], + [ + 'une forme SVG remplie', + ``, + ], + ]; + for (const [nom, recouvrement] of OPAQUES) { + test(`${nom} posée sur le texte le cache`, () => { + monter(`
Iris${recouvrement}
`); + 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( + '
Iris' + + '
', + ); + 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( + '
' + + 'Iris
', + ); + 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('Iris Théo'); + 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'); + } + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 97bb0d2..d13f2ef 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -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' }] } } }, ],