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