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

271 lines
13 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La police de l'interface dans un vrai moteur de rendu (§ 14.1, § 14.15,
// § 19.4) : jetons.css déclare Noto Sans en deux faces, 400 et 700, dont
// chaque fichier est, octet pour octet, celui que le chargement de la police
// rend à App ; --police-interface la nomme en tête ; le corps du document la
// rend, et la chaîne témoin y prend la longueur que donnent les avances de
// la police, dans TOLERANCE_CHAINE_TEMOIN.
//
// Un mesureur du plan (plan/mesure_texte.js) garde chaque largeur qu'il a
// lue : créé avant le chargement des faces, il rend la largeur de la police
// de repli, et la rend encore une fois les faces chargées — le constat se
// fait dans un cadre neuf, dont le document n'a encore rien chargé. D'où
// l'attente : monterApp, comme src/main.js, ne monte App qu'une fois les deux
// faces chargées, et un mesureur créé au premier rendu lit la police
// embarquée. Cette épreuve vient la première du fichier, et constate
// qu'aucune face n'est chargée avant son montage : après elle, le document
// les garde chargées.
//
// Les mesures de texte coupent le crénage et les ligatures
// (font-kerning: none, font-variant-ligatures: none) : la longueur attendue,
// longueurTemoin (test/temoin.js), est la somme des avances, sans l'un ni
// l'autre.
//
// La police du corps ne suffit pas : le moteur donne aux contrôles de
// formulaire — button, input, select, textarea — leur propre police, qui
// n'hérite pas du corps. La dernière épreuve parcourt chaque écran de
// test/navigateur/ecrans.js et lit la première famille calculée de chaque
// porteur de texte et de chaque contrôle.
import { describe, expect, test } from 'vitest';
import { constante } from '../geometrie/constantes.js';
import { CHAINE_TEMOIN, longueurTemoin } from '../../test/temoin.js';
import { monterApp } from '../../test/navigateur/montage.js';
import { creerMesureur } from './plan/mesure_texte.js';
import { ECRANS, creerContexte } from '../../test/navigateur/ecrans.js';
import { fixerCadre } from '../../test/navigateur/entrees.js';
import { designation, porteursDeTexte } from '../../test/navigateur/rendu.js';
import SOURCE_APP from './App.svelte?raw';
const SVG = 'http://www.w3.org/2000/svg';
const FAMILLE = 'Noto Sans';
const TOLERANCE = constante('TOLERANCE_CHAINE_TEMOIN');
const CORPS = 100;
// Le nom d'une famille sans ses guillemets.
const sansGuillemets = (texte) => texte.trim().replace(/^(["'])(.*)\1$/, '$2');
// L'écart relatif d'une mesure à sa valeur attendue.
const ecart = (mesure, attendue) => Math.abs(mesure - attendue) / attendue;
// Les faces de la famille dans l'ensemble des polices du document, par
// graisse croissante.
const facesDuDocument = () =>
[...document.fonts].filter((face) => sansGuillemets(face.family) === FAMILLE).sort((a, b) => Number(a.weight) - Number(b.weight));
// Les règles @font-face de la famille dans les feuilles du document, dans
// l'ordre des feuilles puis des règles : { graisse, style, adresse }, l'adresse
// absolue du fichier que nomme src.
function reglesDuDocument() {
const regles = [];
for (const feuille of document.styleSheets) {
for (const regle of feuille.cssRules) {
if (!(regle instanceof CSSFontFaceRule)) continue;
if (sansGuillemets(regle.style.getPropertyValue('font-family')) !== FAMILLE) continue;
const adresses = [...regle.style.getPropertyValue('src').matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/g)].map((m) => m[2]);
regles.push({
graisse: regle.style.getPropertyValue('font-weight'),
style: regle.style.getPropertyValue('font-style'),
adresses: adresses.map((a) => new URL(a, feuille.href ?? document.baseURI).href),
});
}
}
return regles;
}
// La largeur de la chaîne témoin lue par un mesureur neuf, sur un <text>
// posé dans parent, au corps donné, crénage et ligatures coupés ; famille :
// la valeur de font-family, la police de l'interface par défaut.
function largeurTemoin(parent, { corps = CORPS, graisse = 400, famille = 'var(--police-interface)' } = {}) {
const proprietaire = parent.ownerDocument;
const svg = proprietaire.createElementNS(SVG, 'svg');
const texte = proprietaire.createElementNS(SVG, 'text');
texte.setAttribute(
'style',
`font-family: ${famille}; font-size: ${corps}px; font-weight: ${graisse}; font-kerning: none; font-variant-ligatures: none`,
);
svg.append(texte);
parent.append(svg);
try {
return creerMesureur(texte)(CHAINE_TEMOIN);
} finally {
svg.remove();
}
}
const hex = (tampon) => [...new Uint8Array(tampon)].map((o) => o.toString(16).padStart(2, '0')).join('');
const empreinte = async (octets) => hex(await crypto.subtle.digest('SHA-256', octets));
describe('police : le montage attend les faces (§ 14.1, § 19.4)', () => {
test('monterApp ne monte App qu’une fois les deux faces chargées : un mesureur créé au premier rendu lit la chaîne témoin à la longueur des avances', async () => {
const faces = facesDuDocument();
expect(faces.map((face) => face.weight)).toEqual(['400', '700']);
// Aucune face n'est chargée avant ce montage : une épreuve qui les
// chargerait plus tôt ôterait à celle-ci tout pouvoir d'échouer.
expect(faces.map((face) => face.status)).toEqual(['unloaded', 'unloaded']);
// Le premier rendu : le premier lot de mutations où le bandeau d'App
// paraît dans le conteneur du montage. Le rappel suit le montage, avant
// que la page ne rende la main au chargement d'une police.
let premierRendu = null;
const observateur = new MutationObserver(() => {
if (premierRendu !== null) return;
const bandeau = document.querySelector('[data-montage] [data-bandeau]');
if (bandeau === null) return;
const conteneur = bandeau.closest('[data-montage]');
premierRendu = { statuts: facesDuDocument().map((face) => face.status), largeur: largeurTemoin(conteneur) };
});
observateur.observe(document.body, { childList: true, subtree: true });
let m;
try {
m = await monterApp();
} finally {
observateur.disconnect();
}
try {
expect(premierRendu).not.toBeNull();
const { regulier } = await m.polices();
const attendue = longueurTemoin(regulier, CORPS);
expect(ecart(premierRendu.largeur, attendue), `${premierRendu.largeur} px pour ${attendue} px`).toBeLessThanOrEqual(TOLERANCE);
expect(premierRendu.statuts).toEqual(['loaded', 'loaded']);
} finally {
m.demonter();
}
});
test('constat, dans un cadre neuf : un mesureur créé avant le chargement rend la largeur de repli, et la garde une fois la face chargée', async () => {
const m = await monterApp();
const cadre = document.createElement('iframe');
try {
const { regulier } = await m.polices();
const attendue = longueurTemoin(regulier, CORPS);
const [regle] = reglesDuDocument().filter(({ graisse }) => graisse === '400');
expect(regle?.adresses.length).toBe(1);
document.body.append(cadre);
const neuf = cadre.contentDocument;
const style = neuf.createElement('style');
style.textContent = `@font-face { font-family: '${FAMILLE}'; font-weight: 400; src: url('${regle.adresses[0]}'); }`;
neuf.head.append(style);
const famille = `'${FAMILLE}', serif`;
const svg = neuf.createElementNS(SVG, 'svg');
const texte = neuf.createElementNS(SVG, 'text');
texte.setAttribute('style', `font-family: ${famille}; font-size: ${CORPS}px; font-kerning: none; font-variant-ligatures: none`);
svg.append(texte);
neuf.body.append(svg);
const mesurer = creerMesureur(texte);
const avant = mesurer(CHAINE_TEMOIN);
expect(ecart(avant, attendue), `${avant} px avant le chargement, ${attendue} px attendus`).toBeGreaterThan(TOLERANCE);
const chargees = await neuf.fonts.load(`400 ${CORPS}px '${FAMILLE}'`);
expect(chargees.map((face) => face.status)).toEqual(['loaded']);
// Le même mesureur rend la largeur gardée ; un mesureur neuf lit la face.
expect(mesurer(CHAINE_TEMOIN)).toBe(avant);
const apres = largeurTemoin(neuf.body, { famille });
expect(ecart(apres, attendue), `${apres} px une fois chargée, ${attendue} px attendus`).toBeLessThanOrEqual(TOLERANCE);
} finally {
cadre.remove();
m.demonter();
}
});
});
describe('police : Noto Sans est la police de l’interface (§ 14.15, § 19.4, Q4)', () => {
test('jetons.css déclare deux faces, 400 et 700, de style normal, dont chaque fichier est celui que le chargement de la police rend à App', async () => {
const m = await monterApp();
try {
const regles = reglesDuDocument();
expect(regles.map(({ graisse, style }) => [graisse, style])).toEqual([
['400', 'normal'],
['700', 'normal'],
]);
const polices = await m.polices();
for (const [{ adresses }, graisse] of [[regles[0], 'regulier'], [regles[1], 'gras']]) {
expect(adresses.length, graisse).toBe(1);
const reponse = await fetch(adresses[0]);
expect(reponse.ok, `${adresses[0]} : ${reponse.status}`).toBe(true);
expect(await empreinte(await reponse.arrayBuffer()), graisse).toBe(await empreinte(polices[graisse]));
}
} finally {
m.demonter();
}
});
test('--police-interface nomme Noto Sans en tête, puis les replis du système ; le corps du document la rend, et document.fonts la tient pour chargée', async () => {
const m = await monterApp();
try {
const jeton = getComputedStyle(document.documentElement).getPropertyValue('--police-interface');
expect(jeton.split(',').map(sansGuillemets)).toEqual([FAMILLE, 'system-ui', 'sans-serif']);
const famille = getComputedStyle(document.body).fontFamily;
expect(sansGuillemets(famille.split(',')[0])).toBe(FAMILLE);
expect(document.fonts.check(`16px '${FAMILLE}'`)).toBe(true);
expect(document.fonts.check(`700 16px '${FAMILLE}'`)).toBe(true);
expect(facesDuDocument().map((face) => face.status)).toEqual(['loaded', 'loaded']);
} finally {
m.demonter();
}
});
test('la chaîne témoin, à 100 px, crénage et ligatures coupés, a la longueur des avances de chaque graisse, dans TOLERANCE_CHAINE_TEMOIN', async () => {
const m = await monterApp();
try {
const polices = await m.polices();
for (const [graisse, poids] of [['regulier', 400], ['gras', 700]]) {
const attendue = longueurTemoin(polices[graisse], CORPS);
const mesuree = largeurTemoin(m.racine, { graisse: poids });
expect(ecart(mesuree, attendue), `${graisse} : ${mesuree} px pour ${attendue} px`).toBeLessThanOrEqual(TOLERANCE);
}
} finally {
m.demonter();
}
});
test('App déstructure le chargement de la police parmi ses dépendances', () => {
const [, liste] = /const \{([\s\S]*?)\} = untrack\(\(\) => dependances\);/.exec(SOURCE_APP) ?? [];
expect(liste, 'la déstructuration des dépendances d’App').toBeDefined();
const noms = liste.replace(/\/\/[^\n]*/g, '').split(',').map((nom) => nom.trim().split(/\s*=/)[0]).filter(Boolean);
expect(noms).toContain('polices');
});
});
// Les contrôles de formulaire, dont le texte — valeur, option, libellé —
// n'est pas toujours un nœud de texte de l'élément.
const CONTROLES = 'button, input, select, textarea';
// La première famille calculée d'un porteur ou d'un élément nu, sans ses
// guillemets.
function premiereFamille(cible) {
const { element, pseudo } = cible instanceof Element ? { element: cible, pseudo: null } : cible;
return sansGuillemets(getComputedStyle(element, pseudo).fontFamily.split(',')[0]);
}
describe('police : chaque écran rend son texte en Noto Sans (Q4)', () => {
test('sur chaque écran de test/navigateur/ecrans.js, chaque porteur de texte et chaque contrôle de formulaire a Noto Sans pour première famille calculée', async () => {
await fixerCadre(1366, 700);
const fautes = [];
let lus = 0;
for (const ecran of ECRANS) {
const contexte = creerContexte({ langue: 'fr' });
try {
const racine = await ecran.atteindre(contexte);
const cibles = [...porteursDeTexte(racine), ...racine.querySelectorAll(CONTROLES)];
expect(cibles.length, `${ecran.nom} : porteurs de texte`).toBeGreaterThan(0);
const vues = new Set();
for (const cible of cibles) {
lus += 1;
const famille = premiereFamille(cible);
if (famille === FAMILLE) continue;
const faute = `${ecran.nom} : ${designation(cible)} en ${famille}`;
if (!vues.has(faute)) fautes.push(faute);
vues.add(faute);
}
} finally {
await contexte.defaire();
}
}
expect(lus).toBeGreaterThan(ECRANS.length);
expect(fautes).toEqual([]);
}, 900_000);
});