gestion_table_tournante_libre/test/polices.test.js

227 lines
12 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La police 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"]);
});
});