gestion_table_tournante_libre/src/interface/App.navigateur.test.js

243 lines
9.6 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// 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 : le
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
// expect, et prend test et describe chez Vitest plutôt que dans
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { VERSION } from '../version.genere.js';
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['light', 'dark'];
// 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];
}
// Luminance relative d'une couleur sRGB, au sens des WCAG.
function luminance([r, g, b]) {
const lineaire = (c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
}
function contraste(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// 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é et rend sa valeur. L'émulation est
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
// préférence propre du navigateur.
async function sousTheme(theme, mesure) {
await cdp().send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-color-scheme', value: theme }],
});
try {
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
theme === 'dark',
);
return mesure();
} finally {
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
}
}
let composant;
beforeEach(() => {
composant = mount(App, { target: document.body });
flushSync();
});
afterEach(() => {
unmount(composant);
});
function bandeau() {
return document.body.querySelector('header.bandeau');
}
// Le bandeau, puis chacun des éléments qui y portent un texte.
function bandeauEtTextes() {
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
}
describe("bandeau d'application", () => {
test('existe et porte la version affichée', () => {
expect(bandeau()).not.toBeNull();
expect(bandeau().textContent).toContain(VERSION.affichee);
});
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.
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);
}
});
}
});
test('porte la provenance de la construction', () => {
expect(import.meta.env.MODE).toBe('test');
expect(bandeau().textContent).toContain('épreuve');
});
test('le titre de la fenêtre porte le nom et la version affichée', () => {
expect(document.title).toBe(
`Gestion table tournante Libre — ${VERSION.affichee}`,
);
});
test('le texte et le fond du bandeau ont une couleur calculée', () => {
const style = getComputedStyle(bandeau());
expect(style.color).not.toBe(TRANSPARENT);
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 () => {
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();
expect(texte[3], lieu).toBe(1);
expect(fond[3], lieu).toBe(1);
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
}
return [document.body, bandeau()].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
});
});
couleursParTheme.push(couleurs);
}
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
// couleur, ferait mesurer deux fois le thème clair.
expect(
couleursParTheme[1],
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
).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 () => {
// 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.
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');
}
}
});
}
});
});