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({