From 88d35f5979aaaf11f468b86b911852991dc6f30a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 13:01:49 -0400 Subject: [PATCH] [IMP] interface: Noto Sans everywhere, faces loaded before mounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen must match the screenshots and the text the tests measure. The interface now uses the embedded Noto Sans for every element, form controls included, with a 1.3 line height. The entry point and the test mount wait for both faces before mounting, so no text measurer caches the fallback width; a face that fails to load is reported and the app mounts anyway. The screens the wider font overflowed have their own CSS adjusted. Checked: red first; 3216 node, 655 browser and 71 long tests green from the index; the Electron start-up check loads the font from file://. --- FR --- [IMP] interface : Noto Sans partout, faces chargées avant le montage L'écran doit ressembler aux captures et au texte que mesurent les épreuves. L'interface emploie désormais Noto Sans, embarquée, pour tout élément, contrôles de formulaire compris, avec un interligne de 1,3. Le point d'entrée et le montage d'épreuve attendent les deux faces avant de monter : aucun mesureur ne garde la largeur de repli ; une face qui ne se charge pas est signalée et l'application se monte quand même. Les écrans que la police plus large débordait ajustent leur propre CSS. Vérifié : rouge d'abord ; 3216 node, 655 navigateur et 71 longues vertes depuis l'index ; l'essai de démarrage charge la police par file://. Assisted-by: Claude Opus 5.5 --- scripts/essai_demarrage.js | 77 +++++- scripts/essai_demarrage.test.js | 57 ++++- src/interface/APropos.svelte | 1 - src/interface/App.svelte | 1 + src/interface/Police.navigateur.test.js | 270 ++++++++++++++++++++ src/interface/accueil/Demonstrations.svelte | 4 + src/interface/evenement/CadreMode.svelte | 14 +- src/interface/jetons.css | 50 +++- src/interface/participants/Fiche.svelte | 5 + src/main.js | 14 +- src/polices/chargement.js | 37 +++ src/polices/chargement.navigateur.test.js | 108 +++++++- test/construction.long.test.js | 26 +- test/navigateur/montage.js | 12 +- test/polices.test.js | 96 ++++++- vitest.config.js | 6 +- 16 files changed, 751 insertions(+), 27 deletions(-) create mode 100644 src/interface/Police.navigateur.test.js diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js index 363f414..54d7610 100644 --- a/scripts/essai_demarrage.js +++ b/scripts/essai_demarrage.js @@ -7,14 +7,18 @@ // qu'Electron démarre sur ce système, avec ses bibliothèques, y charge la // page, que l'application y monte, que la détermination du dossier de // travail retient le mode portable, qu'un travailleur de génération naît et -// rend son issue dans la page chargée sous file://, et que la sortie rend -// les verrous. +// rend son issue dans la page chargée sous file://, que la police de +// l'interface s'y charge, et que la sortie rend les verrous. // // node scripts/essai_demarrage.js // // La séance, dans l'ordre : // 1. le titre de la fenêtre vaut le nom suivi de la version affichée, le // bandeau porte la version affichée, le collecteur d'erreurs est posé ; +// dans la page chargée sous file://, les deux faces de Noto Sans sont +// chargées, le corps du document est en Noto Sans, et la chaîne témoin +// y a la longueur des avances de la police, dans +// TOLERANCE_CHAINE_TEMOIN (§ 19.4) ; // 2. la liste affiche le dossier de travail exe/data ; créer un événement, // qui s'ouvre, revenir à la liste, puis charger la petite // démonstration, qui s'ouvre ; @@ -58,9 +62,12 @@ import { tmpdir } from 'node:os'; import path, { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { decrireSysteme } from '../electron/fichiers.js'; +import { constante } from '../src/geometrie/constantes.js'; import { estSous } from '../src/stockage/dossier_travail.js'; import { VERSION } from '../src/version.genere.js'; import { titreAvecVersion } from '../src/interface/libelles.js'; +import { lirePolices } from '../test/polices.js'; +import { CHAINE_TEMOIN, longueurTemoin } from '../test/temoin.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const ATTENTE_MS = 60_000; @@ -71,6 +78,59 @@ const NOM_EVENEMENT = "Soirée d'essai"; const DEMONSTRATION = 'petite'; const ARRET = 2000; +// La police de l'interface, ses graisses, et le corps en px où la chaîne +// témoin se mesure. +const FAMILLE = 'Noto Sans'; +const GRAISSES = ['400', '700']; +const CORPS_TEMOIN = 100; + +/** + * Les fautes de la police d'interface relevée dans la page, [] quand elle + * est conforme, dans cet ordre : par graisse, 400 puis 700, une face de + * Noto Sans absente de document.fonts ou dont le statut n'est pas + * « loaded » ; le corps du document dont la première famille calculée n'est + * pas Noto Sans ; la chaîne témoin dont l'écart relatif à la longueur + * attendue passe la tolérance. Une famille se compare sans ses guillemets ; + * les faces des autres familles n'entrent pas en compte. + * @param {{faces: Array<{famille: string, graisse: string, style: string, etat: string}>, + * familleCorps: string, largeurTemoin: number}} releve + * @param {{longueurAttendue: number, tolerance: number}} temoin + * @returns {string[]} + */ +export function fautesDePolice({ faces, familleCorps, largeurTemoin }, { longueurAttendue, tolerance }) { + const sansGuillemets = (texte) => texte.trim().replace(/^(["'])(.*)\1$/, '$2'); + const fautes = []; + for (const graisse of GRAISSES) { + const siennes = faces.filter((face) => sansGuillemets(face.famille) === FAMILLE && String(face.graisse) === graisse); + if (siennes.length === 0) fautes.push(`face ${graisse} de ${FAMILLE} absente`); + for (const { etat } of siennes) if (etat !== 'loaded') fautes.push(`face ${graisse} de ${FAMILLE} : ${etat}`); + } + if (sansGuillemets(familleCorps.split(',')[0]) !== FAMILLE) fautes.push(`corps du document en ${familleCorps}`); + const ecart = Math.abs(largeurTemoin - longueurAttendue) / longueurAttendue; + if (!(ecart <= tolerance)) fautes.push(`chaîne témoin : ${largeurTemoin} px pour ${longueurAttendue} px`); + return fautes; +} + +// Relève dans la page les faces de document.fonts, la famille calculée du +// corps, et la longueur de la chaîne témoin au corps donné, crénage et +// ligatures coupés, sur un de la police de l'interface posé le temps +// de la mesure. +function releverPolice({ chaine, corps }) { + const faces = [...document.fonts].map((face) => ({ famille: face.family, graisse: face.weight, style: face.style, etat: face.status })); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const texte = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + texte.setAttribute( + 'style', + `font-family: var(--police-interface); font-size: ${corps}px; font-kerning: none; font-variant-ligatures: none`, + ); + texte.textContent = chaine; + svg.append(texte); + document.body.append(svg); + const largeurTemoin = texte.getComputedTextLength(); + svg.remove(); + return { faces, familleCorps: getComputedStyle(document.body).fontFamily, largeurTemoin }; +} + /** * Les trois dossiers distincts de l'essai, sous racine. * @param {string} racine @@ -223,6 +283,19 @@ async function seance(application, dossiers) { constater('bandeau portant la version affichée', bandeau.includes(VERSION.affichee), bandeau); const erreursAuDepart = await collecteur(); constater('collecteur posé et vide', Array.isArray(erreursAuDepart) && erreursAuDepart.length === 0, JSON.stringify(erreursAuDepart)); + // La police de l'interface, dans la page chargée sous file:// : la + // longueur attendue vient des avances de la police embarquée, lue ici. + constater('page chargée sous file://', fenetre.url().startsWith('file:'), fenetre.url()); + const police = await fenetre.evaluate(releverPolice, { chaine: CHAINE_TEMOIN, corps: CORPS_TEMOIN }); + const fautes = fautesDePolice(police, { + longueurAttendue: longueurTemoin(lirePolices().regulier, CORPS_TEMOIN), + tolerance: constante('TOLERANCE_CHAINE_TEMOIN'), + }); + constater( + 'police de l’interface chargée : Noto Sans 400 et 700, corps du document, chaîne témoin', + fautes.length === 0, + fautes.length === 0 ? `${police.largeurTemoin} px à ${CORPS_TEMOIN} px` : fautes.join(' ; '), + ); // 2. Le dossier affiché, un événement créé, la démonstration chargée. const affiche = (await fenetre.locator('[data-dossier-travail]').textContent()).trim(); diff --git a/scripts/essai_demarrage.test.js b/scripts/essai_demarrage.test.js index 24f17c8..15486b0 100644 --- a/scripts/essai_demarrage.test.js +++ b/scripts/essai_demarrage.test.js @@ -1,6 +1,11 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +// La police que l'essai relève dans la page chargée sous file:// (§ 19.4) : +// les deux faces de Noto Sans chargées, le corps du document en Noto Sans, +// et la chaîne témoin à la longueur des avances, dans la tolérance ; chaque +// écart se nomme. +// // L'environnement où l'essai de démarrage lance la coquille (§ 8.6) : trois // dossiers distincts sous la racine de l'essai, aucun sous un autre ; le // lanceur portable publie exe, le poste a home pour dossier personnel et @@ -15,7 +20,13 @@ import { fileURLToPath } from 'node:url'; import { decrireSysteme } from '../electron/fichiers.js'; import { estSous } from '../src/stockage/dossier_travail.js'; import { describe, test } from '../test/lanceur.js'; -import { donneesApplicativesSurExe, dossiersDEssai, environnementDEssai, estLanceDirectement } from './essai_demarrage.js'; +import { + donneesApplicativesSurExe, + dossiersDEssai, + environnementDEssai, + estLanceDirectement, + fautesDePolice, +} from './essai_demarrage.js'; const RACINE_ESSAI = join('/', 'racine-essai', 'gtt-essai-demarrage-x'); @@ -200,3 +211,47 @@ describe('essai de démarrage : lancement direct', () => { assert.equal(estLanceDirectement(undefined), false); }); }); + +describe('essai de démarrage : la police de la page (§ 19.4)', () => { + // Un relevé conforme : les deux faces chargées, une face d'une autre + // famille, le corps en Noto Sans, la chaîne témoin à 1 000 px. + const RELEVE = Object.freeze({ + faces: [ + { famille: 'Noto Sans', graisse: '400', style: 'normal', etat: 'loaded' }, + { famille: '"Noto Sans"', graisse: '700', style: 'normal', etat: 'loaded' }, + { famille: 'Famille Inventée', graisse: '400', style: 'normal', etat: 'unloaded' }, + ], + familleCorps: '"Noto Sans", system-ui, sans-serif', + largeurTemoin: 1000, + }); + const TEMOIN = Object.freeze({ longueurAttendue: 1000, tolerance: 0.01 }); + + test('un relevé conforme ne porte aucune faute ; un écart égal à la tolérance est admis', () => { + assert.deepEqual(fautesDePolice(RELEVE, TEMOIN), []); + assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 1010 }, TEMOIN), []); + assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 990 }, TEMOIN), []); + }); + + test('une face absente ou non chargée se nomme par sa graisse ; une autre famille n’y entre pas', () => { + const sans700 = RELEVE.faces.filter(({ graisse, famille }) => !(graisse === '700' && famille.includes('Noto'))); + assert.deepEqual(fautesDePolice({ ...RELEVE, faces: sans700 }, TEMOIN), ['face 700 de Noto Sans absente']); + const enErreur = RELEVE.faces.map((face) => (face.graisse === '400' && face.famille === 'Noto Sans' ? { ...face, etat: 'error' } : face)); + assert.deepEqual(fautesDePolice({ ...RELEVE, faces: enErreur }, TEMOIN), ['face 400 de Noto Sans : error']); + assert.deepEqual(fautesDePolice({ ...RELEVE, faces: [] }, TEMOIN), [ + 'face 400 de Noto Sans absente', + 'face 700 de Noto Sans absente', + ]); + }); + + test('un corps du document dans une autre famille se nomme, telle que la page la calcule', () => { + assert.deepEqual(fautesDePolice({ ...RELEVE, familleCorps: 'system-ui, sans-serif' }, TEMOIN), [ + 'corps du document en system-ui, sans-serif', + ]); + }); + + test('une chaîne témoin hors de la tolérance se nomme, mesurée et attendue', () => { + assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 1010.5 }, TEMOIN), ['chaîne témoin : 1010.5 px pour 1000 px']); + assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 960 }, TEMOIN), ['chaîne témoin : 960 px pour 1000 px']); + assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 0 }, TEMOIN), ['chaîne témoin : 0 px pour 1000 px']); + }); +}); diff --git a/src/interface/APropos.svelte b/src/interface/APropos.svelte index 336e7f4..46e56a3 100644 --- a/src/interface/APropos.svelte +++ b/src/interface/APropos.svelte @@ -44,7 +44,6 @@ padding: var(--espace-2) var(--espace-3); border: 1px solid var(--couleur-page-filet); border-radius: var(--rayon-1); - font-family: monospace; user-select: all; overflow-wrap: anywhere; } diff --git a/src/interface/App.svelte b/src/interface/App.svelte index 171c35e..849f11c 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -324,6 +324,7 @@ background-color: var(--couleur-page-fond); color: var(--couleur-page-texte); font-family: var(--police-interface); + line-height: var(--police-interligne); } .app { diff --git a/src/interface/Police.navigateur.test.js b/src/interface/Police.navigateur.test.js new file mode 100644 index 0000000..94e08cf --- /dev/null +++ b/src/interface/Police.navigateur.test.js @@ -0,0 +1,270 @@ +// © 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 +// 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); +}); diff --git a/src/interface/accueil/Demonstrations.svelte b/src/interface/accueil/Demonstrations.svelte index d5fc707..98ef1db 100644 --- a/src/interface/accueil/Demonstrations.svelte +++ b/src/interface/accueil/Demonstrations.svelte @@ -92,7 +92,11 @@ font-size: var(--taille-titre); } + /* Les démonstrations se rangent en ligne, et passent à la ligne quand la + largeur manque : en colonne, quatre entrées repoussent la dernière + au-dessous d'une fenêtre de 700 px de haut. */ ul { + flex-flow: row wrap; margin: 0; padding: 0; list-style: none; diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte index 0f58353..ad4cc36 100644 --- a/src/interface/evenement/CadreMode.svelte +++ b/src/interface/evenement/CadreMode.svelte @@ -255,8 +255,11 @@ et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête. La place prend une part fixe de la largeur, et le contenu le reste : la zone ne bouge pas d'une section à l'autre, quand le libellé de la - section ouverte, en gras, change la largeur de la navigation. Sous - 30rem de contenu, la place passe à la ligne. */ + section ouverte, en gras, change la largeur de la navigation. Sa part, + 36 %, tient en deux lignes de Noto Sans chacun des deux avertissements + d'un journal tronqué dans une fenêtre de 1 366 px : le second paraît + dans la hauteur de la tête. Sous 30rem de contenu, la place passe à la + ligne. */ .tete.cote { flex-flow: row wrap; align-items: stretch; @@ -279,7 +282,7 @@ .place { position: relative; - flex: 0 0 32%; + flex: 0 0 36%; min-width: 16rem; min-height: 3rem; border-left: 1px solid var(--couleur-page-filet); @@ -312,11 +315,14 @@ } /* La surface part des trois quarts du cadre, s'étend à ce qui reste, et - ne rétrécit, après les annonces, que pour la barre et la tête. */ + ne rétrécit, après les annonces, que pour la barre et la tête. Sa + hauteur ne dépend pas de son contenu : elle est un conteneur de taille, + dont une section borne un panneau à la hauteur visible (unités cqb). */ .surface { flex: 1 1 75%; min-height: 0; overflow: auto; padding: var(--espace-3); + container-type: size; } diff --git a/src/interface/jetons.css b/src/interface/jetons.css index 4545524..8ceb5c7 100644 --- a/src/interface/jetons.css +++ b/src/interface/jetons.css @@ -31,10 +31,56 @@ de l'encre forte du plan, --couleur-contour-fort. La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le - fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. */ + fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. + + La police de l'interface est la police embarquée, Noto Sans (§ 11.1, + § 19.4) : deux faces, 400 et 700, dont les fichiers sont ceux de + src/polices, que la production de la planche embarque aussi. La + construction les émet en fichiers à côté de la feuille, que la page lit + sous file:// comme sous http://. Les replis du système ne servent qu'à une + face qui ne se charge pas : src/main.js attend les deux faces avant de + monter l'application (attendrePolices, src/polices/chargement.js). + + Le corps porte la police (App) ; les contrôles de formulaire — button, + input, select, textarea — n'en héritent pas d'eux-mêmes : le moteur leur + donne sa propre police, de son propre corps. La règle sous les faces les + fait hériter de leur parent, police, corps et interligne ; le style d'un + composant, plus spécifique, règle ensuite le corps qu'il veut. + + L'interligne du texte courant est fixé, --police-interligne, et non + laissé à « normal », que chaque police calcule de ses propres métriques : + celui de Noto Sans vaut 1,362 corps, et une police de repli en donnerait + un autre. Fixé, il rend les mêmes hauteurs de ligne quelle que soit la + face rendue. À 1,3, une ligne de Noto Sans s'étend de 1,038 corps + au-dessus de sa ligne de base à 0,262 au-dessous : les capitales + accentuées (0,944) et les jambages (0,244) du latin y tiennent ; seules + les capitales à deux diacritiques superposés (jusqu'à 1,059) en + débordent. */ + +@font-face { + font-family: 'Noto Sans'; + font-style: normal; + font-weight: 400; + src: url('../polices/NotoSans-Regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'Noto Sans'; + font-style: normal; + font-weight: 700; + src: url('../polices/NotoSans-Bold.ttf') format('truetype'); +} + +button, +input, +select, +textarea { + font: inherit; +} :root { - --police-interface: system-ui, sans-serif; + --police-interface: 'Noto Sans', system-ui, sans-serif; + --police-interligne: 1.3; /* Échelle typographique, du plus petit au plus grand. */ --taille-texte-petit: 0.8125rem; diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte index 4af63e5..f8050c7 100644 --- a/src/interface/participants/Fiche.svelte +++ b/src/interface/participants/Fiche.svelte @@ -144,6 +144,11 @@ font-size: var(--taille-texte); line-height: 1.4; overflow-wrap: anywhere; + /* Jamais plus haute que la surface qui la porte (CadreMode) : ouverte + sous la grille, dans une fenêtre étroite, elle se montre en entier, + et son contenu défile en elle. */ + max-height: 100cqb; + overflow-y: auto; } .entete { diff --git a/src/main.js b/src/main.js index f048c06..ea9fb53 100644 --- a/src/main.js +++ b/src/main.js @@ -26,13 +26,19 @@ // La police embarquée (src/polices) n'est importée que d'ici et du montage // des épreuves : App reçoit chargerPolices, dont les deux import() différés // ne chargent les graisses qu'à la première production qui les demande. +// La même police est celle de l'interface, que jetons.css déclare : App ne +// se monte qu'une fois ses deux faces chargées (attendrePolices), sans quoi +// un mesureur de texte créé au premier rendu lirait la police de repli et +// garderait sa largeur. Une face qui ne se charge pas ne retient pas +// l'application : l'erreur va au collecteur (reportError), et App se monte +// sur les replis du système. import { mount } from 'svelte'; import { creerExecuteurTravailleur } from './application/executeur_travailleur.js'; import { creerHorlogeSysteme } from './application/horloge.js'; import { demonstrationsLivrees } from './demo/livrees.js'; import App from './interface/App.svelte'; import Travailleur from './interface/generation/travailleur.js?worker&inline'; -import { chargerPolices } from './polices/chargement.js'; +import { attendrePolices, chargerPolices } from './polices/chargement.js'; import { FORMAT } from './stockage/document.js'; import { creerFichiersElectron } from './stockage/fichiers_electron.js'; import { creerFichiersWeb } from './stockage/fichiers_web.js'; @@ -46,6 +52,12 @@ const pressePapiers = sousElectron ? { copier: async (texte) => void (await pont.copier(texte)) } : { copier: (texte) => navigator.clipboard.writeText(texte) }; +try { + await attendrePolices(document); +} catch (erreur) { + reportError(erreur); +} + mount(App, { target: document.body, props: { diff --git a/src/polices/chargement.js b/src/polices/chargement.js index d1f382e..7430409 100644 --- a/src/polices/chargement.js +++ b/src/polices/chargement.js @@ -14,6 +14,12 @@ // des épreuves du navigateur, qui le passent à App (Dependances.polices) ; // les épreuves node lisent les mêmes fichiers par le disque // (test/polices.js), node ne connaissant pas « ?inline ». +// +// La même police est celle de l'interface : jetons.css déclare ses deux +// faces sur les mêmes fichiers. attendrePolices attend qu'elles soient +// chargées dans le document ; src/main.js et monterApp l'attendent avant de +// monter App, car un mesureur de texte créé plus tôt (plan/mesure_texte.js) +// lirait la police de repli et garderait sa largeur. // Une URL data: en base64 : un type, des paramètres éventuels, « ;base64, », // puis l'alphabet base64 et son remplissage. @@ -50,3 +56,34 @@ export async function chargerPolices() { const [{ default: regulier }, { default: gras }] = await Promise.all([import('./regulier.js'), import('./gras.js')]); return { regulier: decoderDonnees(regulier), gras: decoderDonnees(gras) }; } + +/** Les faces de la police d'interface que attendrePolices charge, sous la + * forme d'une déclaration font : graisse, corps, famille. Le corps ne sert + * qu'à la syntaxe : une face se charge pour tout corps. */ +export const FACES_INTERFACE = Object.freeze(['400 16px "Noto Sans"', '700 16px "Noto Sans"']); + +/** + * Attend que les deux faces de la police d'interface, déclarées par + * jetons.css, soient chargées dans document : les deux demandes à + * document.fonts.load partent ensemble, et la promesse se résout quand + * chacune rend au moins une face, toutes au statut « loaded ». + * + * @param {Document} document + * @returns {Promise} + * @throws {TypeError} (rejet) un document sans ensemble de polices, sans + * rien demander + * @throws {Error} (rejet) une face qu'aucune feuille du document ne déclare + * — load ne rend alors aucune face —, ou rendue sans être chargée : + * le message nomme chaque face en faute ; un chargement que le + * navigateur refuse rejette avec son erreur, telle quelle + */ +export async function attendrePolices(document) { + if (typeof document?.fonts?.load !== 'function') { + throw new TypeError('attendrePolices : un document dont fonts.load est une fonction est attendu'); + } + const rendues = await Promise.all(FACES_INTERFACE.map((face) => document.fonts.load(face))); + const enFaute = FACES_INTERFACE.filter( + (_, rang) => rendues[rang].length === 0 || rendues[rang].some(({ status }) => status !== 'loaded'), + ); + if (enFaute.length > 0) throw new Error(`attendrePolices : face non chargée — ${enFaute.join(' ; ')}`); +} diff --git a/src/polices/chargement.navigateur.test.js b/src/polices/chargement.navigateur.test.js index 30e779f..fb4d8c1 100644 --- a/src/polices/chargement.navigateur.test.js +++ b/src/polices/chargement.navigateur.test.js @@ -6,9 +6,18 @@ // décodés de l'URL data: que « ?inline » incruste, chacun de la taille et // de l'empreinte que PROVENANCE déclare. Les octets nourrissent fontkit tels // quels, et la chaîne témoin y garde la longueur calculée sous node. +// +// L'attente des faces de la police d'interface, attendrePolices : sur un +// document simulé, les deux demandes partent ensemble et la promesse ne se +// résout que les deux faces chargées ; sur le document d'un cadre neuf, sans +// @font-face, avec une face introuvable, puis avec les deux fichiers de +// src/polices. Le module ne s'importe pas sous node, « ?inline » étant +// propre à Vite : ces épreuves vivent ici. import { describe, expect, test } from 'vitest'; import provenance from './PROVENANCE?raw'; -import { chargerPolices, decoderDonnees } from './chargement.js'; +import adresseGras from './NotoSans-Bold.ttf?url'; +import adresseRegulier from './NotoSans-Regular.ttf?url'; +import { attendrePolices, chargerPolices, decoderDonnees, FACES_INTERFACE } from './chargement.js'; import { CHAINE_TEMOIN, lireProvenance, longueurTemoin } from '../../test/temoin.js'; const hex = (tampon) => [...new Uint8Array(tampon)].map((o) => o.toString(16).padStart(2, '0')).join(''); @@ -47,3 +56,100 @@ describe('polices : le chargement dans la page', () => { } }); }); + +// Un document dont document.fonts.load rend, pour chaque descripteur, ce que +// reponse(descripteur) donne ; il relève les descripteurs demandés. +function documentSimule(reponse) { + const demandes = []; + return { + demandes, + fonts: { + load(descripteur) { + demandes.push(descripteur); + return reponse(descripteur); + }, + }, + }; +} + +const face = (status) => ({ family: 'Noto Sans', status }); + +// Le document d'un cadre neuf, ajouté au corps, avec les règles @font-face +// données ; fermer() retire le cadre. +function cadreNeuf(regles) { + const cadre = document.createElement('iframe'); + document.body.append(cadre); + const style = cadre.contentDocument.createElement('style'); + style.textContent = regles; + cadre.contentDocument.head.append(style); + return { document: cadre.contentDocument, fermer: () => cadre.remove() }; +} +const regle = (graisse, adresse) => + `@font-face { font-family: 'Noto Sans'; font-weight: ${graisse}; src: url('${new URL(adresse, document.baseURI).href}'); }`; + +describe('polices : attendrePolices', () => { + test('les faces attendues sont Noto Sans 400 et 700, dans cet ordre', () => { + expect(FACES_INTERFACE).toEqual(['400 16px "Noto Sans"', '700 16px "Noto Sans"']); + expect(Object.isFrozen(FACES_INTERFACE)).toBe(true); + }); + + test('demande les deux faces avant d’en attendre une, et se résout sans valeur quand chacune est chargée', async () => { + const resolutions = []; + const simule = documentSimule(() => new Promise((resoudre) => resolutions.push(() => resoudre([face('loaded')])))); + const attente = attendrePolices(simule); + expect(simule.demandes).toEqual([...FACES_INTERFACE]); + let reglee = false; + attente.then(() => { + reglee = true; + }); + resolutions[0](); + await Promise.resolve(); + await Promise.resolve(); + expect(reglee, 'résolue avant la seconde face').toBe(false); + resolutions[1](); + expect(await attente).toBeUndefined(); + }); + + test('une face qu’aucune feuille ne déclare, ou rendue sans être chargée, rejette en la nommant ; un refus du navigateur passe tel quel', async () => { + const sans700 = documentSimule((descripteur) => Promise.resolve(descripteur.startsWith('700') ? [] : [face('loaded')])); + await expect(attendrePolices(sans700)).rejects.toThrow(/face non chargée — 700 16px "Noto Sans"$/); + const enErreur = documentSimule((descripteur) => Promise.resolve([face(descripteur.startsWith('400') ? 'error' : 'loaded')])); + await expect(attendrePolices(enErreur)).rejects.toThrow(/face non chargée — 400 16px "Noto Sans"$/); + const refus = new Error('chargement refusé'); + await expect(attendrePolices(documentSimule(() => Promise.reject(refus)))).rejects.toBe(refus); + }); + + test('un document sans ensemble de polices lève TypeError', async () => { + for (const sansPolices of [null, undefined, {}, { fonts: {} }]) { + await expect(attendrePolices(sansPolices), String(sansPolices)).rejects.toThrow(TypeError); + } + }); + + test('dans un cadre neuf : sans @font-face, les deux faces se nomment ; une face introuvable rejette ; les fichiers de src/polices se chargent', async () => { + const vide = cadreNeuf(''); + try { + await expect(attendrePolices(vide.document)).rejects.toThrow( + /face non chargée — 400 16px "Noto Sans" ; 700 16px "Noto Sans"$/, + ); + } finally { + vide.fermer(); + } + const introuvable = cadreNeuf(`${regle(400, adresseRegulier)} ${regle(700, '/polices-introuvables/absente.ttf')}`); + try { + await expect(attendrePolices(introuvable.document)).rejects.toThrow(); + } finally { + introuvable.fermer(); + } + const complet = cadreNeuf(`${regle(400, adresseRegulier)} ${regle(700, adresseGras)}`); + try { + expect([...complet.document.fonts].map((f) => f.status)).toEqual(['unloaded', 'unloaded']); + await attendrePolices(complet.document); + expect([...complet.document.fonts].map((f) => [f.weight, f.status])).toEqual([ + ['400', 'loaded'], + ['700', 'loaded'], + ]); + } finally { + complet.fermer(); + } + }); +}); diff --git a/test/construction.long.test.js b/test/construction.long.test.js index aa9721e..9764d6e 100644 --- a/test/construction.long.test.js +++ b/test/construction.long.test.js @@ -18,7 +18,10 @@ // fontkit, quand la page les importe, tiennent chacun leur morceau, sous le // seuil ; aucun de ces morceaux n'est chargé ni préchargé au lancement ; les // avis de licence sont copiés dans www/licences/, et chaque paquet des -// morceaux construits y a le sien, sous la licence de son package.json. +// morceaux construits y a le sien, sous la licence de son package.json. Les +// deux fichiers de la police sont aussi émis dans www/, octet pour octet, +// et nommés par la feuille construite : jetons.css en fait la police de +// l'interface. import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; @@ -464,7 +467,7 @@ describe('construction : les sorties (§ 11.1, § 14.15)', () => { ); }); - test('la police voyage dans deux morceaux différés, octet pour octet ; aucun morceau des sorties n’est chargé ni préchargé au lancement ; les avis de licence sont dans www/licences/, et chaque paquet construit y a le sien', async () => { + test('la police voyage dans deux morceaux différés, octet pour octet, et ses deux fichiers sont émis dans www/, nommés par la feuille construite ; aucun morceau des sorties n’est chargé ni préchargé au lancement ; les avis de licence sont dans www/licences/, et chaque paquet construit y a le sien', async () => { const sortie = mkdtempSync(join(tmpdir(), 'construction-sorties-')); const travailleur = releveDuTravailleur(); try { @@ -553,24 +556,29 @@ describe('construction : les sorties (§ 11.1, § 14.15)', () => { .map(({ nom, version, licence }) => `${nom} ${version} — ${licence}`); assert.deepEqual(manquants, []); - // Une police émise en fichier est l'une des deux, octet pour octet ; une - // feuille qui en nomme une la trouve dans www/. + // La police de l'interface (jetons.css) : les deux fichiers de + // src/polices sont émis dans www/, chacun une fois, octet pour octet, + // et aucune autre police ; une feuille qui en nomme une la trouve dans + // www/, et les deux sont nommées. const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true }) .filter((e) => e.isFile()) .map((e) => join(e.parentPath, e.name)); - const sources = new Set( - ['NotoSans-Regular.ttf', 'NotoSans-Bold.ttf'].map((f) => sha256(readFileSync(join(RACINE, 'src', 'polices', f)))), - ); + const sources = ['NotoSans-Regular.ttf', 'NotoSans-Bold.ttf'].map((f) => sha256(readFileSync(join(RACINE, 'src', 'polices', f)))); + const polices = fichiers.filter((f) => extname(f) === '.ttf'); assert.deepEqual( - fichiers.filter((f) => extname(f) === '.ttf' && !sources.has(sha256(readFileSync(f)))).map((f) => relative(sortie, f)), - [], + polices.map((f) => sha256(readFileSync(f))).sort(), + [...sources].sort(), + `polices émises : ${polices.map((f) => relative(sortie, f)).join(', ') || 'aucune'}`, ); + const nommees = new Set(); for (const feuille of fichiers.filter((f) => extname(f) === '.css')) { for (const adresse of adressesChargees('css', readFileSync(feuille, 'utf8')).filter((a) => /\.ttf$/i.test(a))) { const cible = resolve(dirname(feuille), adresse); assert.ok(fichiers.includes(cible), `${relative(sortie, feuille)} : ${adresse} absent de la construction`); + nommees.add(cible); } } + assert.deepEqual([...nommees].sort(), [...polices].sort(), 'polices que les feuilles nomment'); } finally { rmSync(sortie, { recursive: true, force: true }); } diff --git a/test/navigateur/montage.js b/test/navigateur/montage.js index c7adda7..25790ab 100644 --- a/test/navigateur/montage.js +++ b/test/navigateur/montage.js @@ -20,13 +20,16 @@ // demonstrationsLivrees() : une seule composition, celle de src/demo. La // police est chargée comme src/main.js la charge, par chargerPolices : sous // le serveur de Vite des épreuves, « ?inline » rend les mêmes octets que -// dans la construction. +// dans la construction. Comme src/main.js, monterApp attend les deux faces +// de la police d'interface (attendrePolices) avant de monter App : un +// mesureur de texte créé au premier rendu lit la police embarquée, non son +// repli. Une face qui ne se charge pas fait échouer le montage. import { mount, unmount } from 'svelte'; import { creerExecuteurDirect } from '../../src/application/generation.js'; import { creerHorlogePilotee } from '../../src/application/horloge.js'; import { demonstrationsLivrees } from '../../src/demo/livrees.js'; import App from '../../src/interface/App.svelte'; -import { chargerPolices } from '../../src/polices/chargement.js'; +import { attendrePolices, chargerPolices } from '../../src/polices/chargement.js'; import { FORMAT } from '../../src/stockage/document.js'; import { VERSION } from '../../src/version.genere.js'; import { creerFichiersSimules } from '../fichiers_simules.js'; @@ -109,7 +112,8 @@ export function tracer(fs) { * @param {() => Promise<{regulier: Uint8Array, gras: Uint8Array}>} [options.polices] * sinon chargerPolices, celui de src/main.js * @returns {Promise} { app, racine, fs, disque, trace, horloge, - * presse, seance, facade, ui, erreurs(), demonter() } — seance est un + * presse, polices, seance, facade, ui, erreurs(), demonter() } — polices + * est le chargement de la police qu'App a reçu ; seance est un * accesseur de app.seance() ; presse.copies, les textes copiés ; * erreurs(), ce que le collecteur a reçu depuis ce montage ; demonter() * démonte App, retire le conteneur et rend à son data-theme d'avant @@ -128,6 +132,7 @@ export async function monterApp({ polices = chargerPolices, } = {}) { await installerCollecteur(); + await attendrePolices(document); montages += 1; const numero = montages; const premiereErreur = globalThis.__gttErreurs.length; @@ -187,6 +192,7 @@ export async function monterApp({ trace, horloge: horlogeMontage, presse, + polices, get seance() { return app.seance(); }, diff --git a/test/polices.test.js b/test/polices.test.js index 2ad4ca2..f3f1e59 100644 --- a/test/polices.test.js +++ b/test/polices.test.js @@ -6,10 +6,17 @@ // couvre le latin étendu du français et ses ligatures, lu par fontkit ; // regulier.js et gras.js nomment ces fichiers ; la licence voyage à côté, à // son seul emplacement, public/licences/. La longueur de la chaîne témoin se -// calcule des avances de la police, sans crénage (contrainte 26). +// calcule des avances de la police, sans crénage (contrainte 26). Le point +// d'entrée de la page et le montage des épreuves passent le chargement de la +// police à App, et attendent ses deux faces avant de la monter ; +// jetons.css déclare ces faces sur les mêmes fichiers que regulier.js et +// gras.js, et fait de Noto Sans la police de l'interface. Que la page les +// charge et les rende se lit dans le navigateur +// (src/interface/Police.navigateur.test.js). import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import fontkit from '@pdf-lib/fontkit'; import { describe, test } from './lanceur.js'; @@ -130,3 +137,90 @@ describe('polices : la chaîne témoin (§ 19.4, contrainte 26)', () => { } }); }); + +// Le texte d'un module du projet, lu par le disque : le point d'entrée de la +// page et le montage des épreuves ne se chargent pas sous node. +const texteDe = (chemin) => readFileSync(`${RACINE}${chemin}`, 'utf8'); + +// Le texte d'un module sans ses commentaires de ligne ni de bloc : un appel +// cité dans un commentaire ne compte pas. +const sansCommentaires = (texte) => texte.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); + +describe('polices : le montage d’App (§ 13.4, § 19.4)', () => { + const ENTREES = [ + { chemin: 'src/main.js', passe: /^\s*polices: chargerPolices,$/m }, + { chemin: 'test/navigateur/montage.js', passe: /^\s*polices,$/m }, + ]; + + test('src/main.js et le montage des épreuves importent chargerPolices et attendrePolices de src/polices/chargement.js, et passent le chargement à App', () => { + for (const { chemin, passe } of ENTREES) { + const code = sansCommentaires(texteDe(chemin)); + const imports = [...code.matchAll(/^import \{([^}]*)\} fro[m] '[./]*(?:src\/)?polices\/chargement\.js';$/gm)]; + assert.equal(imports.length, 1, `${chemin} : un import de polices/chargement.js`); + const noms = imports[0][1].split(',').map((nom) => nom.trim()).filter(Boolean).sort(); + assert.deepEqual(noms, ['attendrePolices', 'chargerPolices'], chemin); + const [, proprietes] = /\bmount\(App, \{[\s\S]*?\bprops: \{([\s\S]*?)\n\s*\},\n/.exec(code) ?? []; + assert.ok(proprietes !== undefined, `${chemin} : mount(App, { props: { … } })`); + assert.match(proprietes, passe, `${chemin} : polices passé à App`); + } + // Le montage prend chargerPolices par défaut, comme src/main.js. + assert.match(sansCommentaires(texteDe('test/navigateur/montage.js')), /^\s*polices = chargerPolices,$/m); + }); + + test('chacun attend attendrePolices(document) avant de monter App, une seule fois', () => { + for (const { chemin } of ENTREES) { + const code = sansCommentaires(texteDe(chemin)); + const attentes = [...code.matchAll(/\battendrePolices\(document\)/g)].map((m) => m.index); + const montages = [...code.matchAll(/\bmount\(App\b/g)].map((m) => m.index); + assert.equal(attentes.length, 1, `${chemin} : ${attentes.length} appels à attendrePolices(document)`); + assert.equal(montages.length, 1, `${chemin} : ${montages.length} montages d’App`); + assert.ok(attentes[0] < montages[0], `${chemin} : l’attente suit le montage`); + assert.match(code.slice(attentes[0] - 'await '.length, attentes[0]), /^await $/, `${chemin} : attendrePolices non attendue`); + } + }); +}); + +// Les règles @font-face d'une feuille, dans leur ordre : chacune rend ses +// descripteurs, nom en minuscules, valeur telle qu'écrite, commentaires ôtés. +function reglesFontFace(feuille) { + const sansCommentaires = feuille.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...sansCommentaires.matchAll(/@font-face\s*\{([^}]*)\}/g)].map(([, corps]) => + Object.fromEntries( + corps + .split(';') + .map((declaration) => declaration.trim()) + .filter(Boolean) + .map((declaration) => { + const deuxPoints = declaration.indexOf(':'); + return [declaration.slice(0, deuxPoints).trim().toLowerCase(), declaration.slice(deuxPoints + 1).trim()]; + }), + ), + ); +} + +describe('polices : la police de l’interface (§ 19.4, Q4)', () => { + const JETONS = 'src/interface/jetons.css'; + + test('jetons.css déclare Noto Sans en deux faces, 400 puis 700, de style normal, chacune sur le fichier que regulier.js ou gras.js incruste', () => { + const regles = reglesFontFace(lire(JETONS).toString('utf8')); + assert.equal(regles.length, 2, `${regles.length} règles @font-face`); + const attendues = [ + ['400', FICHIERS_POLICES.regulier], + ['700', FICHIERS_POLICES.gras], + ]; + for (const [rang, [graisse, nom]] of attendues.entries()) { + const regle = regles[rang]; + assert.equal(regle['font-family'].replace(/^(["'])(.*)\1$/, '$2'), 'Noto Sans', graisse); + assert.equal(regle['font-weight'], graisse); + assert.equal(regle['font-style'], 'normal', graisse); + const adresses = [...regle.src.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/g)].map((m) => m[2]); + assert.equal(adresses.length, 1, `${graisse} : ${regle.src}`); + assert.equal(join(dirname(`${RACINE}${JETONS}`), adresses[0]), cheminPolice(nom), graisse); + } + }); + + test('--police-interface nomme Noto Sans en tête, puis les replis du système', () => { + const declarations = [...lire(JETONS).toString('utf8').matchAll(/--police-interface:\s*([^;]+);/g)].map((m) => m[1].trim()); + assert.deepEqual(declarations, ["'Noto Sans', system-ui, sans-serif"]); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index eb9e3d4..c4e979a 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -46,8 +46,9 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([ // Les épreuves node qui lisent la feuille des jetons par le système de // fichiers — palette validée par calcul, var(--…) déclarés, couleurs des -// sources, en-tête, version affichée —, et la garde des motifs, qui balaie -// les feuilles de style de src/interface. +// sources, en-tête, version affichée, faces de la police de l'interface —, +// et la garde des motifs, qui balaie les feuilles de style de +// src/interface. const RELANCES_DES_JETONS = Object.freeze([ 'src/interface/palette.test.js', 'src/interface/marques.test.js', @@ -55,6 +56,7 @@ const RELANCES_DES_JETONS = Object.freeze([ 'test/licence.test.js', 'scripts/version.test.js', 'test/arborescence.test.js', + 'test/polices.test.js', ]); export default defineConfig({