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
226 lines
12 KiB
JavaScript
226 lines
12 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// La police embarquée (§ 11.1, § 14.15, § 19.4) : les deux fichiers de
|
||
// src/polices sont ceux que PROVENANCE déclare, octet pour octet ; chacun
|
||
// 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). 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';
|
||
import { cheminPolice, FICHIERS_POLICES, lirePolices } from './polices.js';
|
||
import { CHAINE_TEMOIN, lireProvenance, longueurTemoin } from './temoin.js';
|
||
|
||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||
const lire = (chemin) => readFileSync(`${RACINE}${chemin}`);
|
||
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
|
||
const provenance = () => lireProvenance(readFileSync(cheminPolice('PROVENANCE'), 'utf8'));
|
||
|
||
// Les blocs du latin que la police couvre en entier, bornes comprises, et
|
||
// les signes du français hors de ces blocs.
|
||
const BLOCS = [
|
||
['Latin-1', 0xa0, 0xff],
|
||
['Latin étendu A', 0x100, 0x17f],
|
||
['Latin étendu B', 0x180, 0x24f],
|
||
['Latin étendu additionnel', 0x1e00, 0x1eff],
|
||
];
|
||
const SIGNES = 'œŒæÆ’«»–—…fi';
|
||
|
||
// Les points de code à couvrir, dans l'ordre des blocs puis des signes.
|
||
function pointsACouvrir() {
|
||
const points = [];
|
||
for (const [, debut, fin] of BLOCS) for (let point = debut; point <= fin; point += 1) points.push(point);
|
||
for (const signe of SIGNES) points.push(signe.codePointAt(0));
|
||
return points;
|
||
}
|
||
|
||
describe('polices : les fichiers (§ 11.1, § 14.15)', () => {
|
||
test('PROVENANCE déclare la version amont et les deux fichiers, dont la taille et l’empreinte sont celles du disque', () => {
|
||
const { version, fichiers } = provenance();
|
||
assert.match(version ?? '', /^\d+\.\d+$/);
|
||
const polices = lirePolices();
|
||
const attendus = [
|
||
[FICHIERS_POLICES.regulier, polices.regulier],
|
||
[FICHIERS_POLICES.gras, polices.gras],
|
||
];
|
||
for (const [nom, octets] of attendus) {
|
||
const entree = fichiers.find((f) => f.nom === nom);
|
||
assert.ok(entree !== undefined, `${nom} absent de PROVENANCE`);
|
||
assert.equal(octets.length, entree.octets, nom);
|
||
assert.equal(sha256(octets), entree.sha256, nom);
|
||
}
|
||
});
|
||
|
||
test('chaque graisse est Noto Sans de la version déclarée, Regular puis Bold', () => {
|
||
const { version } = provenance();
|
||
const polices = lirePolices();
|
||
for (const [graisse, sousFamille] of [['regulier', 'Regular'], ['gras', 'Bold']]) {
|
||
const police = fontkit.create(polices[graisse]);
|
||
assert.equal(police.familyName, 'Noto Sans', graisse);
|
||
assert.equal(police.subfamilyName, sousFamille, graisse);
|
||
assert.ok(police.version.startsWith(`Version ${version};`), `${graisse} : ${police.version}`);
|
||
}
|
||
});
|
||
|
||
test('chaque graisse couvre Latin-1, les latins étendus A, B et additionnel, et « œŒæÆ’«»–—…fi »', () => {
|
||
const points = pointsACouvrir();
|
||
assert.equal(points.length, 96 + 128 + 208 + 256 + 11);
|
||
const polices = lirePolices();
|
||
for (const graisse of ['regulier', 'gras']) {
|
||
const police = fontkit.create(polices[graisse]);
|
||
const manquants = points
|
||
.filter((point) => police.glyphForCodePoint(point).id === 0)
|
||
.map((point) => `U+${point.toString(16).toUpperCase().padStart(4, '0')}`);
|
||
assert.deepEqual(manquants, [], graisse);
|
||
}
|
||
});
|
||
|
||
test('regulier.js et gras.js réexportent chacun leur fichier, incrusté par « ?inline »', () => {
|
||
for (const [module, nom] of [['regulier.js', FICHIERS_POLICES.regulier], ['gras.js', FICHIERS_POLICES.gras]]) {
|
||
const texte = readFileSync(cheminPolice(module), 'utf8');
|
||
// « fro[m] » : la garde des chargements (test/chargements.js) ne lit
|
||
// pas ce motif comme un import.
|
||
const reexports = [...texte.matchAll(/^export \{ default \} fro[m] '\.\/([^'?]+)\?inline';$/gm)].map((m) => m[1]);
|
||
assert.deepEqual(reexports, [nom], module);
|
||
}
|
||
});
|
||
|
||
test('la licence de la police est public/licences/NotoSans-OFL.txt, l’OFL.txt de l’archive amont, et TIERS.txt la nomme', () => {
|
||
const { fichiers } = provenance();
|
||
const ofl = fichiers.find((f) => f.nom === 'NotoSans-OFL.txt');
|
||
assert.ok(ofl !== undefined, 'NotoSans-OFL.txt absent de PROVENANCE');
|
||
assert.equal(ofl.amont, 'OFL.txt');
|
||
const texte = lire('public/licences/NotoSans-OFL.txt');
|
||
assert.equal(sha256(texte), ofl.sha256);
|
||
assert.match(texte.toString('utf8'), /SIL OPEN FONT LICENSE Version 1\.1/);
|
||
assert.match(lire('public/licences/TIERS.txt').toString('utf8'), /^Noto Sans .* — OFL-1\.1 — NotoSans-OFL\.txt$/m);
|
||
});
|
||
});
|
||
|
||
describe('polices : la chaîne témoin (§ 19.4, contrainte 26)', () => {
|
||
test('la chaîne témoin est celle du contrat', () => {
|
||
assert.equal(CHAINE_TEMOIN, 'Ôjgq Æ W — Benoît Œuvre fi');
|
||
});
|
||
|
||
test('sa longueur est la somme des avances, sans crénage ni ligature : la mise en forme de fontkit sans « kern » ni « liga » la redonne', () => {
|
||
const polices = lirePolices();
|
||
for (const graisse of ['regulier', 'gras']) {
|
||
const police = fontkit.create(polices[graisse]);
|
||
const brute = police.layout(CHAINE_TEMOIN, { kern: false, liga: false }).advanceWidth;
|
||
const avecCrenage = police.layout(CHAINE_TEMOIN, { liga: false }).advanceWidth;
|
||
assert.ok(Math.abs(longueurTemoin(polices[graisse], police.unitsPerEm) - brute) < 1e-9, graisse);
|
||
// Le crénage change la longueur : la somme ne le confond pas avec elle.
|
||
assert.notEqual(brute, avecCrenage, graisse);
|
||
}
|
||
});
|
||
|
||
test('la longueur croît avec le corps, en proportion ; le gras est plus long ; un corps hors domaine lève', () => {
|
||
const { regulier, gras } = lirePolices();
|
||
const a16 = longueurTemoin(regulier, 16);
|
||
assert.ok(Math.abs(longueurTemoin(regulier, 32) - 2 * a16) < 1e-9);
|
||
assert.ok(longueurTemoin(gras, 16) > a16);
|
||
assert.ok(longueurTemoin(regulier, 16, 'W') > longueurTemoin(regulier, 16, 'i'));
|
||
for (const corps of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '16']) {
|
||
assert.throws(() => longueurTemoin(regulier, corps), RangeError, String(corps));
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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"]);
|
||
});
|
||
});
|