gestion_table_tournante_libre/test/sources_interface.test.js
Mathieu Benoit fca74b6672 [ADD] tests: spec coverage guards, delivery checks, spec corrections
New guards close the spec coverage gaps: only the configuration and the
diagnostic read reservations in the engine, no second language in the
sources, the engine and geometry never read the version, a test lives
beside its module, fixtures never copy a shipped file, and the property
library stays in a closed list. The delivery test pins the www output and
the French language passed by the entry point. The spec names three test
projects and the three shell files. Checked: 2778 node, 550 browser and
62 long tests from the index alone; the version check passes.

--- FR ---

[ADD] épreuves : gardes de couverture du spec, livrable, spec corrigé

De nouvelles gardes ferment les manques de couverture : seules la
configuration et le diagnostic lisent les réservations dans le moteur,
aucun second langage dans les sources, le moteur et la géométrie ne lisent
pas la version, une épreuve vit près de son module, aucune donnée d'épreuve
ne copie un fichier livré, la bibliothèque de propriétés reste en liste
fermée. Le livrable fige la sortie www et la langue française du point
d'entrée. Le spec nomme trois projets et trois fichiers de coquille.
Vérifié : 2778 node, 550 navigateur, 62 longues ; version conforme.

Assisted-by: Claude Opus 5.5
2026-10-07 17:51:49 -04:00

1345 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Gardes des sources de l'interface, lues dans l'arbre d'analyse et non dans
// le texte : chaque composant passe par parse de svelte/compiler, chaque
// module JavaScript par le même analyseur, enveloppé dans un <script module>,
// chaque feuille .css par parseCss. Un commentaire n'est donc jamais relevé :
// la règle porte sur ce que le code fait, et un commentaire qui nomme un
// interdit le documente.
//
// Huit gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
// t qui n'existent pas, le contenu des tables de traduction (§ 5.5, § 7.6,
// § 7.7, § 12.8, § 12.10.8), les couleurs (§ 12.7, § 13.3, § 13.5), le
// balisage et les fenêtres du navigateur (§ 13.1, § 2.1), le texte comprimé et
// les animations (§ 7.4, § 19.2), le câblage des composants (§ 12.9, § 13.3),
// les sélecteurs que le compilateur élague (§ 13.3). Chaque garde rend un
// relevé « chemin:ligne motif « extrait » », trié, qui nomme d'un coup chaque
// fichier et chaque ligne fautifs ; elle lève sur un balayage vide (§ 14.2).
//
// Le périmètre est le code livré : les épreuves — *.test.js, dont
// *.navigateur.test.js — et test/ portent exprès ce que les gardes refusent,
// et n'en font pas partie. D'autres épreuves font tourner chaque garde sur un
// arbre temporaire qui porte, à plus d'un niveau, les formes refusées et des
// formes voisines admises.
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compile, parse, parseCss } from 'svelte/compiler';
import { TABLES_APPLICATION } from '../src/application/libelles.js';
import { creerTraduction } from '../src/application/traduction.js';
import { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// --- Lecture des sources ------------------------------------------------------
// Une épreuve, node ou navigateur : hors du périmètre de chaque garde.
const EPREUVE = /\.test\.[cm]?js$/;
// Chemins relatifs, en « / », des fichiers des arbres donnés dont le nom finit
// par l'une des extensions, épreuves exclues, triés. Un arbre absent n'en
// fournit aucun ; un répertoire node_modules n'est jamais retenu.
function sources(racine, arbres, extensions) {
const chemins = [];
for (const arbre of arbres) {
let entrees;
try {
entrees = readdirSync(join(racine, arbre), { recursive: true, withFileTypes: true });
} catch (erreur) {
if (erreur.code === 'ENOENT') continue;
throw erreur;
}
for (const entree of entrees) {
if (!entree.isFile()) continue;
const chemin = relative(racine, join(entree.parentPath, entree.name)).split(sep).join('/');
if (chemin.split('/').includes('node_modules') || EPREUVE.test(chemin)) continue;
if (extensions.some((extension) => chemin.endsWith(extension))) chemins.push(chemin);
}
}
return chemins.sort();
}
// Lève quand un balayage n'examine aucun fichier (§ 14.2).
function exigerNonVide(chemins, garde) {
assert.ok(chemins.length > 0, `${garde} : aucun fichier examiné`);
return chemins;
}
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
// Un extrait lisible d'un texte relevé : blancs réduits, quarante caractères
// au plus.
function extrait(texte) {
const court = String(texte).replace(/\s+/g, ' ').trim();
return `« ${court.length > 40 ? `${court.slice(0, 39)}\u{2026}` : court} »`;
}
// Un composant : son arbre moderne, ses positions comptées dans son texte.
function analyserComposant(racine, chemin) {
const texte = readFileSync(join(racine, chemin), 'utf8');
try {
return { texte, ast: parse(texte, { modern: true }) };
} catch (erreur) {
throw new Error(`${chemin} : analyse impossible (${erreur.code ?? erreur.name})`, { cause: erreur });
}
}
// Un module JavaScript, enveloppé dans un <script module> : le préfixe ne
// porte aucun saut de ligne, les numéros de ligne restent ceux du fichier.
function analyserModule(racine, chemin) {
const texte = `<script module>${readFileSync(join(racine, chemin), 'utf8')}</script>`;
try {
return { texte, ast: parse(texte, { modern: true }).module.content };
} catch (erreur) {
throw new Error(`${chemin} : analyse impossible (${erreur.code ?? erreur.name})`, { cause: erreur });
}
}
// Parcourt un arbre d'analyse en profondeur, dans l'ordre des champs : visite
// chaque nœud typé avec la liste de ses ancêtres typés, du plus lointain au
// plus proche. Les champs de service des analyseurs ne sont pas descendus.
const CHAMPS_IGNORES = new Set(['parent', 'loc', 'metadata', 'name_loc', 'range']);
function parcourir(noeud, visiter, ancetres = []) {
if (noeud === null || typeof noeud !== 'object') return;
if (Array.isArray(noeud)) {
for (const enfant of noeud) parcourir(enfant, visiter, ancetres);
return;
}
const type = typeof noeud.type === 'string';
if (type) visiter(noeud, ancetres);
const suivants = type ? [...ancetres, noeud] : ancetres;
for (const [champ, valeur] of Object.entries(noeud)) {
if (!CHAMPS_IGNORES.has(champ) && valeur !== null && typeof valeur === 'object') {
parcourir(valeur, visiter, suivants);
}
}
}
// Les parties JavaScript et le gabarit d'un composant, sans sa feuille.
const partiesCode = (ast) => [ast.module, ast.instance, ast.fragment];
// Les chaînes littérales qu'une expression peut rendre telle quelle : un
// littéral, les morceaux fixes d'un gabarit de chaîne, les deux branches
// d'une condition, les opérandes d'une concaténation et d'un « || » ou « ?? »,
// le second d'un « && », le dernier terme d'une séquence. Un appel, un
// membre, un identifiant ne rendent aucun littéral : t('cle') n'affiche pas
// sa clé. Rend [{ valeur, debut }].
function litterauxRendus(expression) {
if (expression === null || typeof expression !== 'object') return [];
switch (expression.type) {
case 'Literal':
return typeof expression.value === 'string' ? [{ valeur: expression.value, debut: expression.start }] : [];
case 'TemplateLiteral':
return [
{ valeur: expression.quasis.map((q) => q.value.cooked ?? q.value.raw).join(''), debut: expression.start },
...expression.expressions.flatMap(litterauxRendus),
];
case 'ConditionalExpression':
return [...litterauxRendus(expression.consequent), ...litterauxRendus(expression.alternate)];
case 'LogicalExpression':
return expression.operator === '&&'
? litterauxRendus(expression.right)
: [...litterauxRendus(expression.left), ...litterauxRendus(expression.right)];
case 'BinaryExpression':
return expression.operator === '+'
? [...litterauxRendus(expression.left), ...litterauxRendus(expression.right)]
: [];
case 'SequenceExpression':
return litterauxRendus(expression.expressions.at(-1));
default:
return [];
}
}
// Les morceaux de la valeur d'un attribut du gabarit : { statique, debut }
// pour un texte, { expression } pour une expression. Un attribut sans valeur
// n'en a aucun.
function morceauxAttribut(attribut) {
const valeur = attribut.value;
if (valeur === true || valeur === undefined || valeur === null) return [];
const morceaux = Array.isArray(valeur) ? valeur : [valeur];
return morceaux.map((m) =>
m.type === 'Text' ? { statique: m.data, debut: m.start } : { expression: m.expression, debut: m.start },
);
}
// Le plus proche ancêtre élément d'un nœud du gabarit, ou null.
const ELEMENTS = new Set(['RegularElement', 'SvelteElement', 'Component', 'SvelteComponent', 'SlotElement']);
function elementParent(ancetres) {
for (let i = ancetres.length - 1; i >= 0; i -= 1) if (ELEMENTS.has(ancetres[i].type)) return ancetres[i];
return null;
}
// Vrai quand le nœud est dans la valeur d'un attribut ou d'une directive.
const DANS_UN_ATTRIBUT = new Set(['Attribute', 'StyleDirective', 'SpreadAttribute', 'BindDirective', 'ClassDirective']);
const dansUnAttribut = (ancetres) => ancetres.some((a) => DANS_UN_ATTRIBUT.has(a.type));
// --- Garde 1 : les chaînes visibles en dur (§ 14.6) ---------------------------
const LETTRE = /\p{L}/u;
// Attributs qu'une technologie d'assistance annonce, ou que la page montre.
const ATTRIBUTS_ANNONCES = new Set(['aria-label', 'aria-roledescription', 'title', 'placeholder', 'alt']);
// Éléments du SVG dont le contenu est un texte : tout contenu statique non
// blanc y est une chaîne visible, ponctuation comprise.
const TEXTES_SVG = new Set(['title', 'desc', 'text', 'tspan']);
// Une chaîne entre guillemets d'une valeur CSS.
const CHAINE_CSS = /(["'])((?:\\.|(?!\1).)*)\1/g;
// Vrai quand un texte placé dans cet élément est une chaîne visible : une
// lettre partout, tout caractère non blanc dans un texte du SVG.
function texteVisible(texte, element) {
if (element !== null && element.type === 'RegularElement' && TEXTES_SVG.has(element.name)) {
return texte.trim() !== '';
}
return LETTRE.test(texte);
}
// Vrai quand une valeur de « content » porte une lettre dans une chaîne.
const contenuLettre = (valeur) => [...valeur.matchAll(CHAINE_CSS)].some(([, , chaine]) => LETTRE.test(chaine));
// Relevé des chaînes visibles écrites dans les composants de src/interface :
// texte statique, attribut annoncé, contenu d'un texte du SVG, déclaration
// « content » lettrée, littéral que rend une expression du gabarit.
function releverChainesEnDur(racine) {
const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'chaînes en dur');
const releves = [];
for (const chemin of chemins) {
const { texte, ast } = analyserComposant(racine, chemin);
const relever = (debut, motif, valeur) =>
releves.push({ chemin, ligne: ligneDe(texte, debut), texte: `${motif} ${extrait(valeur)}` });
parcourir(ast.fragment, (noeud, ancetres) => {
if (noeud.type === 'Text' && !dansUnAttribut(ancetres)) {
const element = elementParent(ancetres);
if (texteVisible(noeud.data, element)) relever(noeud.start, 'texte', noeud.data);
} else if (noeud.type === 'ExpressionTag' && !dansUnAttribut(ancetres)) {
const element = elementParent(ancetres);
for (const { valeur, debut } of litterauxRendus(noeud.expression)) {
if (texteVisible(valeur, element)) relever(debut, 'expression littérale', valeur);
}
} else if (noeud.type === 'Attribute' && ATTRIBUTS_ANNONCES.has(noeud.name)) {
for (const morceau of morceauxAttribut(noeud)) {
const valeurs = 'statique' in morceau
? [{ valeur: morceau.statique, debut: morceau.debut }]
: litterauxRendus(morceau.expression);
for (const { valeur, debut } of valeurs) {
if (LETTRE.test(valeur)) relever(debut, `attribut ${noeud.name}`, valeur);
}
}
} else if (noeud.type === 'Attribute' && noeud.name === 'style') {
for (const morceau of morceauxAttribut(noeud)) {
if ('statique' in morceau && /content\s*:/.test(morceau.statique) && contenuLettre(morceau.statique)) {
relever(morceau.debut, 'content', morceau.statique);
}
}
}
});
parcourir(ast.css, (noeud) => {
if (noeud.type === 'Declaration' && noeud.property === 'content' && contenuLettre(noeud.value)) {
relever(noeud.start, 'content', noeud.value);
}
});
}
return trier(releves);
}
// Relevé trié par chemin, puis par ligne, puis par texte ; rendu en chaînes.
function trier(releves) {
return releves
.sort((a, b) =>
a.chemin < b.chemin ? -1 : a.chemin > b.chemin ? 1 : a.ligne - b.ligne || (a.texte < b.texte ? -1 : a.texte > b.texte ? 1 : 0),
)
.map(({ chemin, ligne, texte }) => `${chemin}:${ligne} ${texte}`);
}
// --- Garde 2 : les clés littérales de t existent (§ 14.6, § 19.11) ------------
// Les clés de toutes les tables assemblées, celles qu'App donne à t.
const CLES = new Set(creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]).cles());
// La clé littérale du premier argument d'un appel de t — t(…) ou x.t(…) —, ou
// null : une clé composée à l'exécution échappe à cette garde.
function cleLitterale(appel) {
const appele = appel.callee;
const estT =
(appele.type === 'Identifier' && appele.name === 't') ||
(appele.type === 'MemberExpression' && !appele.computed && appele.property.name === 't');
if (!estT) return null;
const [premier] = appel.arguments;
if (premier?.type === 'Literal' && typeof premier.value === 'string') return premier.value;
if (premier?.type === 'TemplateLiteral' && premier.expressions.length === 0) return premier.quasis[0].value.cooked;
return null;
}
// Relevé des clés littérales passées à t, dans les composants et les modules
// de src/interface, qu'aucune table de cles ne porte. Lève quand aucun fichier
// n'est examiné, et quand aucune clé littérale ne l'est : un accesseur renommé
// ou une forme d'appel que la garde ne reconnaît plus la viderait sans bruit.
function releverClesAbsentes(racine, cles) {
const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte', '.js']), 'clés de t');
const releves = [];
let nbCles = 0;
for (const chemin of chemins) {
const { texte, ast } = chemin.endsWith('.svelte') ? analyserComposant(racine, chemin) : analyserModule(racine, chemin);
const parties = chemin.endsWith('.svelte') ? partiesCode(ast) : [ast];
parcourir(parties, (noeud) => {
if (noeud.type !== 'CallExpression') return;
const cle = cleLitterale(noeud);
if (cle === null) return;
nbCles += 1;
if (!cles.has(cle)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `clé absente ${extrait(cle)}` });
});
}
assert.ok(nbCles > 0, 'clés de t : aucune clé examinée');
return trier(releves);
}
// --- Garde 3 : le contenu des tables de traduction ----------------------------
// Un texte comparé sans égard à la casse ni aux accents ; l'apostrophe
// typographique se lit comme la droite.
const normaliser = (texte) =>
texte.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[\u{2019}\u{02BC}]/gu, "'");
// Mots que le logiciel n'écrit pas, cherchés dans le texte normalisé. Un
// « écart » nu n'existe pas au glossaire (§ 5.5) : seuls l'écart
// d'itinéraire et l'écart au plafond a priori y figurent ; « écarté », du
// verbe, n'en est pas un.
const MOTS_INTERDITS = [
['conforme', /conform/],
['écart hors du glossaire', /\becarts?\b(?!\s+(?:d'itineraire|au plafond a priori))/],
['satisfaction', /satisfaction/],
['écart-type', /\becarts?[\s-]+types?\b/],
['variance', /\bvariances?\b/],
['équité', /\bequite\b/],
['score', /\bscores?\b/],
['peut mieux faire', /\bpeut mieux faire\b/],
['manque minimal', /\bmanques?\s+minima/],
['récupérable', /recuperable/],
['évitable', /(?<!in)evitable/],
];
const PICTOGRAMME = /\p{Extended_Pictographic}/u;
// Les exceptions nommées aux mots refusés. Chacune admet une forme exacte du
// texte normalisé, pour un mot de MOTS_INTERDITS, dans les seules valeurs dont
// la clé répond à cles ; tout autre emploi du mot, dans la même valeur
// comprise, reste relevé. Une exception qu'aucune valeur ne sert se relève
// « sans objet ».
//
// Le diagnostic d'un manque énonce ce qui le rendrait évitable (§ 5.6) : ses
// remèdes disent qu'un changement « la rend » ou « la rendent évitable ».
// Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que
// le logiciel n'écrit pas.
const EXCEPTIONS_DE_CONTENU = [
{
mot: 'évitable',
cles: /^diagnostic\.remede\./,
forme: /\bla rend(?:ent)? evitable\b/g,
nom: 'diagnostic.remede.* « la rend(ent) évitable »',
},
];
// Une chaîne de forme clé — un code, LECTURE_SEULE, ou un libellé,
// plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
// détail, et ne l'affiche pas.
const FORME_CLE = /^(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)$/;
// Le texte d'une fonction de détails qu'un écran peut lire : ses chaînes
// littérales et les morceaux fixes de ses gabarits de chaîne, échappements
// décodés, joints par un blanc ; une chaîne de forme clé n'en est pas. Le nom
// d'une variable n'est pas un texte : une fonction qui nomme « ecart » une
// valeur intermédiaire n'écrit pas le mot. Un texte que l'analyseur refuse se
// lit en entier.
function texteDeFonction(fonction) {
const source = String(fonction);
let programme;
try {
programme = parse(`<script module>(${source});</script>`, { modern: true }).module.content;
} catch {
return source;
}
const morceaux = [];
parcourir(programme, (noeud) => {
if (noeud.type === 'Literal' && typeof noeud.value === 'string' && !FORME_CLE.test(noeud.value)) morceaux.push(noeud.value);
if (noeud.type === 'TemplateElement') morceaux.push(noeud.value.cooked ?? noeud.value.raw);
});
return morceaux.join(' ');
}
// Relevé « clé : motif « extrait » » des valeurs des tables : chaque chaîne,
// et le texte de chaque fonction de détails (texteDeFonction). localiser(cle),
// quand il est donné, rend { chemin, ligne } de la clé, qui préfixe le relevé
// et l'ordonne ; sans lieu, le relevé s'ordonne par clé. Une forme qu'une
// exception admet est effacée du texte avant que son mot y soit cherché ; suit
// une ligne « exception sans objet : nom » par exception qui n'a rien effacé,
// dans l'ordre de la liste.
function releverContenuTables(tables, localiser = () => null, exceptions = EXCEPTIONS_DE_CONTENU) {
const entrees = tables.flatMap((table) => [...table]);
assert.ok(entrees.length > 0, 'contenu des tables : aucune entrée examinée');
const releves = [];
const servies = new Set();
// Le texte normalisé d'une valeur, privé des formes que les exceptions du
// mot admettent pour cette clé.
const sansFormesAdmises = (normale, mot, cle) =>
exceptions
.filter((exception) => exception.mot === mot && exception.cles.test(cle))
.reduce((texte, exception) => {
const reste = texte.replace(exception.forme, ' ');
if (reste !== texte) servies.add(exception);
return reste;
}, normale);
for (const [cle, entree] of entrees) {
const valeur = typeof entree === 'function' ? texteDeFonction(entree) : entree;
const normale = normaliser(valeur);
const { chemin, ligne } = localiser(cle) ?? { chemin: '', ligne: 0 };
const relever = (motif) => releves.push({ chemin, ligne, texte: `${cle} : ${motif} ${extrait(valeur)}` });
for (const [mot, motif] of MOTS_INTERDITS) if (motif.test(sansFormesAdmises(normale, mot, cle))) relever(mot);
const pictogramme = valeur.match(PICTOGRAMME);
if (pictogramme !== null) {
relever(`pictogramme U+${pictogramme[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`);
}
}
const sansObjet = exceptions.filter((exception) => !servies.has(exception)).map(({ nom }) => `exception sans objet : ${nom}`);
return [...trier(releves).map((releve) => releve.replace(/^:0 /, '')), ...sansObjet];
}
// { chemin, ligne } de l'entrée [cle, …] dans les tables de libelles/ de
// l'application et des écrans, ou null.
function localisateur(racine) {
const fichiers = sources(racine, ['src/application/libelles', 'src/interface/libelles'], ['.js']).map((chemin) => ({
chemin,
texte: readFileSync(join(racine, chemin), 'utf8'),
}));
return (cle) => {
const motif = new RegExp(`\\[\\s*(['"\`])${RegExp.escape(cle)}\\1\\s*,`);
for (const { chemin, texte } of fichiers) {
const trouve = motif.exec(texte);
if (trouve !== null) return { chemin, ligne: ligneDe(texte, trouve.index) };
}
return null;
};
}
// --- Garde 4 : les couleurs (§ 12.7, § 13.3, § 13.5) --------------------------
const FEUILLE_DES_JETONS = 'src/interface/jetons.css';
const JETON_CONFLIT = '--couleur-conflit';
// Les couleurs nommées de CSS, et les couleurs du système : chacune est un
// littéral. transparent et currentColor ne portent aucune teinte et restent
// admis.
const COULEURS_NOMMEES = (
'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown ' +
'burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan ' +
'darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid ' +
'darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet ' +
'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ' +
'ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki ' +
'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow ' +
'lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray ' +
'lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine ' +
'mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise ' +
'mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab ' +
'orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru ' +
'pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown ' +
'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan ' +
'teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen ' +
'accentcolor accentcolortext activetext buttonborder buttonface buttontext canvas canvastext field ' +
'fieldtext graytext highlight highlighttext linktext mark marktext selecteditem selecteditemtext visitedtext'
).split(' ');
const COULEUR_NOMMEE = new RegExp(`(?<![\\w-])(?:${COULEURS_NOMMEES.join('|')})(?![\\w-])`, 'i');
const COULEUR_HEX = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/i;
const COULEUR_FONCTION = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\s*\(/i;
// Le littéral de couleur que porte une valeur CSS ou d'attribut, ou null. Les
// noms de propriétés personnalisées, les adresses url(…) et les chaînes
// entre guillemets sont retirés avant la recherche.
function couleurLitterale(valeur) {
const nette = valeur
.replace(/url\([^)]*\)/gi, ' ')
.replace(CHAINE_CSS, ' ')
.replace(/--[\w-]+/g, ' ');
for (const motif of [COULEUR_HEX, COULEUR_FONCTION, COULEUR_NOMMEE]) {
const trouve = nette.match(motif);
if (trouve !== null) return trouve[0];
}
return null;
}
// Une chaîne qui est en entier un littéral de couleur : un code hexadécimal,
// une fonction de couleur, un nom de couleur.
const COULEUR_ENTIERE = new RegExp(
`^(?:#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})|${COULEUR_FONCTION.source}.*\\)|${COULEURS_NOMMEES.join('|')})$`,
'is',
);
// Attributs du gabarit qui portent une couleur.
const ATTRIBUTS_COULEUR = new Set(['fill', 'stroke', 'color', 'stop-color', 'flood-color', 'lighting-color']);
// Les déclarations d'une feuille, chacune avec le texte du sélecteur de la
// règle la plus proche et l'arbre de ce sélecteur (null hors de toute règle) :
// { propriete, valeur, debut, selecteur, prelude }.
function declarations(css, texte) {
const trouvees = [];
parcourir(css, (noeud, ancetres) => {
if (noeud.type !== 'Declaration') return;
const regle = ancetres.findLast((a) => a.type === 'Rule');
const selecteur = regle === undefined ? '' : texte.slice(regle.prelude.start, regle.prelude.end);
const prelude = regle === undefined ? null : regle.prelude;
trouvees.push({ propriete: noeud.property, valeur: noeud.value, debut: noeud.start, selecteur, prelude });
});
return trouvees;
}
// Les déclarations d'un attribut style statique : « propriété: valeur » par
// point-virgule, positionnées au début du morceau.
const declarationsEnLigne = (statique, debut) =>
statique
.split(';')
.map((morceau) => /^\s*([\w-]+)\s*:(.*)$/s.exec(morceau))
.filter((d) => d !== null)
.map(([, propriete, valeur]) => ({ propriete, valeur, debut }));
// Une classe qui nomme l'état de conflit : « conflit », ou un nom en
// « -conflit » dont aucun segment ne le nie (« sans-conflit », « non-conflit »).
// « conflit-leve » ne finit pas par « -conflit » et ne le nomme pas.
const NEGATIONS = new Set(['sans', 'non', 'hors', 'pas', 'aucun']);
function classeDeConflit(nom) {
const segments = nom.toLowerCase().split('-');
return segments.at(-1) === 'conflit' && !segments.some((segment) => NEGATIONS.has(segment));
}
// Vrai quand une déclaration du jeton de conflit est l'une des règles de
// conflit du plan : dans un composant de src/interface/plan/, sous une règle
// dont CHAQUE sélecteur de la liste porte, dans l'un de ses sélecteurs
// composés, une classe de conflit. Seules les classes posées directement sur
// un composé comptent : celles qu'un :not(…), un :is(…) ou un :global(…)
// enveloppe sont des arguments et ne qualifient pas la règle.
function regleDeConflit(chemin, prelude) {
if (!chemin.startsWith('src/interface/plan/') || prelude?.type !== 'SelectorList') return false;
return prelude.children.every((complexe) =>
complexe.children.some((relatif) =>
relatif.selectors.some((simple) => simple.type === 'ClassSelector' && classeDeConflit(simple.name)),
),
);
}
// Relevé des couleurs : littéral de couleur dans une feuille, un attribut
// style ou de couleur, une directive style:, ou une chaîne du script d'un
// composant ; déclaration de jeton hors de la feuille des jetons ; lecture du
// jeton de conflit hors des règles de conflit du plan. Les composants de
// src/interface et les feuilles .css de src/ sont lus, la feuille des jetons
// exceptée.
function releverCouleurs(racine) {
const chemins = exigerNonVide(
sources(racine, ['src'], ['.svelte', '.css']).filter((c) => c !== FEUILLE_DES_JETONS && (c.endsWith('.css') || c.startsWith('src/interface/'))),
'couleurs',
);
const releves = [];
for (const chemin of chemins) {
let texte;
let decls;
const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif });
// Une déclaration, d'une feuille ou en ligne ; enLigne : hors de toute règle.
const examiner = ({ propriete, valeur, debut, selecteur, prelude }, enLigne) => {
const couleur = couleurLitterale(valeur);
if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${propriete}`);
if (propriete.startsWith('--')) relever(debut, `jeton déclaré ${propriete}`);
if (valeur.includes(JETON_CONFLIT) && (enLigne || !regleDeConflit(chemin, prelude))) {
relever(debut, `jeton de conflit hors d'une règle de conflit du plan, ${enLigne ? 'en ligne' : extrait(selecteur)}`);
}
};
if (chemin.endsWith('.css')) {
texte = readFileSync(join(racine, chemin), 'utf8');
decls = declarations(parseCss(texte), texte);
for (const d of decls) examiner(d, false);
} else {
const analyse = analyserComposant(racine, chemin);
texte = analyse.texte;
for (const d of declarations(analyse.ast.css, texte)) examiner(d, false);
parcourir(analyse.ast.fragment, (noeud) => {
if (noeud.type === 'Attribute' && noeud.name.startsWith('--')) relever(noeud.start, `jeton déclaré ${noeud.name}`);
if (noeud.type === 'StyleDirective') {
if (noeud.name.startsWith('--')) relever(noeud.start, `jeton déclaré ${noeud.name}`);
for (const morceau of morceauxAttribut(noeud)) {
const valeurs = 'statique' in morceau ? [morceau.statique] : litterauxRendus(morceau.expression).map((l) => l.valeur);
for (const valeur of valeurs) examiner({ propriete: noeud.name.replace(/^--.*/, 'style:'), valeur, debut: noeud.start, selecteur: '' }, true);
}
}
if (noeud.type !== 'Attribute') return;
for (const morceau of morceauxAttribut(noeud)) {
if (noeud.name === 'style' && 'statique' in morceau) {
for (const d of declarationsEnLigne(morceau.statique, morceau.debut)) examiner(d, true);
} else if (noeud.name === 'style' || ATTRIBUTS_COULEUR.has(noeud.name)) {
const valeurs = 'statique' in morceau
? [{ valeur: morceau.statique, debut: morceau.debut }]
: litterauxRendus(morceau.expression);
for (const { valeur, debut } of valeurs) {
const couleur = couleurLitterale(valeur);
if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${noeud.name}`);
if (valeur.includes(JETON_CONFLIT)) relever(debut, "jeton de conflit hors d'une règle de conflit du plan, en ligne");
}
}
}
});
// Une chaîne du script qui est en entier un littéral de couleur, ou qui
// nomme le jeton de conflit.
parcourir([analyse.ast.module, analyse.ast.instance], (noeud) => {
if (noeud.type !== 'Literal' || typeof noeud.value !== 'string') return;
const valeur = noeud.value.trim();
if (COULEUR_ENTIERE.test(valeur)) {
relever(noeud.start, `couleur ${extrait(valeur)} dans le script`);
}
if (valeur.includes(JETON_CONFLIT)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script");
});
}
}
return trier(releves);
}
// Relevé des jetons de couleur de la feuille des jetons qu'un des deux thèmes
// ne définit pas : chaque --couleur-…, où qu'il soit déclaré, l'est dans le
// bloc clair et dans le bloc sombre.
function releverThemes(racine) {
const texte = readFileSync(join(racine, FEUILLE_DES_JETONS), 'utf8');
const decls = declarations(parseCss(texte), texte).filter((d) => d.propriete.startsWith('--couleur-'));
assert.ok(decls.length > 0, 'thèmes : aucun jeton de couleur examiné');
const theme = (nom) => new RegExp(`\\[\\s*data-theme\\s*=\\s*(["'])${nom}\\1\\s*\\]`);
const blocs = { clair: theme('clair'), sombre: theme('sombre') };
const releves = [];
const premiere = new Map();
for (const d of decls) if (!premiere.has(d.propriete)) premiere.set(d.propriete, d.debut);
for (const [jeton, debut] of premiere) {
for (const [nom, motif] of Object.entries(blocs)) {
if (!decls.some((d) => d.propriete === jeton && motif.test(d.selecteur))) {
releves.push({ chemin: FEUILLE_DES_JETONS, ligne: ligneDe(texte, debut), texte: `${jeton} absent du thème ${nom}` });
}
}
}
return trier(releves);
}
// --- Garde 5 : balisage et fenêtres du navigateur (§ 13.1, § 2.1) -------------
const PROPRIETES_BALISAGE = new Set(['innerHTML', 'outerHTML', 'insertAdjacentHTML']);
const FENETRES = new Set(['alert', 'confirm', 'prompt']);
const GLOBAUX = new Set(['window', 'globalThis', 'self']);
// Le nom d'un membre non calculé, ou d'un membre calculé par un littéral.
const nomDeMembre = (membre) =>
membre.computed ? (membre.property.type === 'Literal' ? String(membre.property.value) : null) : membre.property.name;
// Relevé du balisage injecté, des fenêtres du navigateur, de beforeunload, et,
// sous src/interface, de la lecture de .message d'une erreur. Les composants
// et les modules de src/ sont lus.
function releverBalisageEtFenetres(racine) {
const chemins = exigerNonVide(sources(racine, ['src'], ['.svelte', '.js']), 'balisage et fenêtres');
const releves = [];
for (const chemin of chemins) {
const composant = chemin.endsWith('.svelte');
const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin);
const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif });
const interface_ = chemin.startsWith('src/interface/');
parcourir(composant ? partiesCode(ast) : [ast], (noeud) => {
switch (noeud.type) {
case 'HtmlTag':
relever(noeud.start, '{@html}');
break;
case 'MemberExpression': {
const nom = nomDeMembre(noeud);
if (PROPRIETES_BALISAGE.has(nom)) relever(noeud.start, nom);
if (noeud.object.type === 'Identifier' && noeud.object.name === 'document' && (nom === 'write' || nom === 'writeln')) {
relever(noeud.start, `document.${nom}`);
}
if (interface_ && nom === 'message') relever(noeud.start, '.message');
break;
}
case 'CallExpression': {
const appele = noeud.callee;
const nom =
appele.type === 'Identifier'
? appele.name
: appele.type === 'MemberExpression' && appele.object.type === 'Identifier' && GLOBAUX.has(appele.object.name)
? nomDeMembre(appele)
: null;
if (FENETRES.has(nom)) relever(noeud.start, `${nom}(`);
break;
}
case 'Literal':
if (typeof noeud.value === 'string' && /beforeunload/i.test(noeud.value)) relever(noeud.start, 'beforeunload');
break;
case 'TemplateElement':
if (/beforeunload/i.test(noeud.value.raw)) relever(noeud.start, 'beforeunload');
break;
case 'Identifier':
if (/beforeunload/i.test(noeud.name)) relever(noeud.start, 'beforeunload');
break;
case 'Attribute':
case 'OnDirective':
if (/beforeunload/i.test(noeud.name)) relever(noeud.start, 'beforeunload');
break;
default:
}
});
}
return trier(releves);
}
// --- Garde 6 : texte comprimé et animations (§ 7.4, § 19.2) ------------------
// Le motif qu'un nom ou une chaîne nomme, ou null : un mot entier, que ni
// lettre ni chiffre ne prolonge, sans égard à la casse — getComputedTextLength,
// la mesure du § 7.4, ne nomme pas textLength — ; un nom d'écouteur,
// ontransitionend, nomme son événement.
const nomme = (texte, motifs) =>
motifs.find((motif) => new RegExp(`(?<![\\p{L}\\p{N}])(?:on)?${motif}(?![\\p{L}\\p{N}])`, 'iu').test(texte)) ?? null;
// Les noms d'un arbre d'analyse — identifiants, attributs, directives,
// chaînes littérales — que les motifs nomment : [{ debut, motif }].
function nomsReleves(parties, motifs) {
const trouves = [];
parcourir(parties, (noeud) => {
let nom = null;
if (noeud.type === 'Identifier' || noeud.type === 'Attribute' || noeud.type === 'OnDirective') nom = noeud.name;
else if (noeud.type === 'Literal' && typeof noeud.value === 'string') nom = noeud.value;
else if (noeud.type === 'TemplateElement') nom = noeud.value.raw;
const motif = nom === null ? null : nomme(nom, motifs);
if (motif !== null) trouves.push({ debut: noeud.start, motif });
});
return trouves;
}
// Le mot qui ouvre une directive d'animation d'un composant — animate,
// transition, in, out —, ou null pour tout autre nœud.
function nomDeDirective(noeud) {
if (noeud.type === 'AnimateDirective') return 'animate';
if (noeud.type !== 'TransitionDirective') return null;
if (noeud.intro && !noeud.outro) return 'in';
if (noeud.outro && !noeud.intro) return 'out';
return 'transition';
}
// Déclarations d'animation admises : celles qui l'éteignent.
const ANIMATION_ETEINTE = /^\s*(?:none|0m?s)\s*$/i;
// Relevé de textLength et lengthAdjust sous src/interface et src/geometrie, de
// transitionend et animationend sous src/interface, et de toute transition
// ou animation de l'interface : déclaration transition…, animation… ou
// @keyframes d'une feuille, directive transition:, in:, out: ou animate:
// d'un composant. Un état qui attendrait la fin d'une transition ne
// s'atteindrait plus sous la feuille qui les annule (§ 19.2).
function releverTexteEtAnimation(racine) {
const chemins = exigerNonVide(
sources(racine, ['src/interface', 'src/geometrie'], ['.svelte', '.js', '.css']),
'texte et animation',
);
const releves = [];
for (const chemin of chemins) {
const interface_ = chemin.startsWith('src/interface/');
const motifs = interface_ ? ['textLength', 'lengthAdjust', 'transitionend', 'animationend'] : ['textLength', 'lengthAdjust'];
let texte;
let parties = [];
let css = null;
if (chemin.endsWith('.css')) {
texte = readFileSync(join(racine, chemin), 'utf8');
css = parseCss(texte);
} else if (chemin.endsWith('.svelte')) {
const analyse = analyserComposant(racine, chemin);
texte = analyse.texte;
parties = partiesCode(analyse.ast);
css = analyse.ast.css;
} else {
const analyse = analyserModule(racine, chemin);
texte = analyse.texte;
parties = [analyse.ast];
}
const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif });
for (const { debut, motif } of nomsReleves(parties, motifs)) relever(debut, motif);
if (!interface_) continue;
parcourir(parties, (noeud) => {
const directive = nomDeDirective(noeud);
if (directive !== null) relever(noeud.start, `directive ${directive}:${noeud.name}`);
});
parcourir(css, (noeud) => {
const anime = noeud.type === 'Declaration' && /^(?:transition|animation)(?:-|$)/.test(noeud.property);
if (anime && !ANIMATION_ETEINTE.test(noeud.value)) {
relever(noeud.start, `${noeud.property}: ${noeud.value}`);
}
if (noeud.type === 'Atrule' && /keyframes$/i.test(noeud.name)) relever(noeud.start, `@${noeud.name}`);
});
}
return trier(releves);
}
// --- Garde 7 : le câblage (§ 12.9, § 13.3) ------------------------------------
const TRIS_ET_REDUCTIONS = new Set(['sort', 'toSorted', 'reduce', 'reduceRight']);
// Les mesures de la table d'évaluation, que src/application/evaluation.js
// seul importe (§ 12.9).
const MESURES = new Set([
'mesurer',
'plafondsRealises',
'plafondsAPriori',
'troisChiffres',
'manques',
'ecartsAuPlafondAPriori',
'minimumAtteint',
]);
const EVALUATION = 'src/application/evaluation.js';
// Relevé des calculs écrits dans un composant de src/interface — Math., un tri,
// une réduction —, et des imports d'une mesure dans un module de
// src/application autre qu'evaluation.js : spécificateur d'import ou de
// réexportation, ou membre d'un espace de noms importé.
function releverCablage(racine) {
const composants = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'câblage des composants');
const modules = exigerNonVide(sources(racine, ['src/application'], ['.js']), 'imports des mesures');
const releves = [];
for (const chemin of composants) {
const { texte, ast } = analyserComposant(racine, chemin);
parcourir(partiesCode(ast), (noeud) => {
if (noeud.type === 'MemberExpression' && noeud.object.type === 'Identifier' && noeud.object.name === 'Math') {
releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `Math.${nomDeMembre(noeud) ?? '[…]'}` });
}
if (noeud.type === 'CallExpression' && noeud.callee.type === 'MemberExpression') {
const nom = nomDeMembre(noeud.callee);
if (TRIS_ET_REDUCTIONS.has(nom)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `.${nom}(` });
}
});
}
for (const chemin of modules) {
if (chemin === EVALUATION) continue;
const { texte, ast } = analyserModule(racine, chemin);
const espaces = new Set();
parcourir(ast, (noeud, ancetres) => {
if (noeud.type === 'ImportNamespaceSpecifier') espaces.add(noeud.local.name);
// Une réexportation porte la source de sa déclaration ; un
// « export { nom } » d'une valeur locale n'importe rien.
const reexporte = noeud.type === 'ExportSpecifier' && (ancetres.at(-1)?.source ?? null) !== null;
const importe = noeud.type === 'ImportSpecifier' ? noeud.imported : reexporte ? noeud.local : null;
const nom = importe === null ? null : (importe.name ?? importe.value);
if (MESURES.has(nom)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `import de ${nom}` });
});
parcourir(ast, (noeud) => {
if (
noeud.type === 'MemberExpression' &&
noeud.object.type === 'Identifier' &&
espaces.has(noeud.object.name) &&
MESURES.has(nomDeMembre(noeud))
) {
releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `import de ${nomDeMembre(noeud)}` });
}
});
}
return trier(releves);
}
// --- Garde 8 : les sélecteurs élagués (§ 13.3, § 14.2) ------------------------
// Relevé des avertissements css_unused_selector de chaque composant de
// src/interface, compilé par compile de svelte/compiler sans greffon : une
// règle que le compilateur élague ne peindrait jamais l'état qu'elle vise.
function releverSelecteursElagues(racine) {
const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'sélecteurs élagués');
const releves = [];
for (const chemin of chemins) {
const texte = readFileSync(join(racine, chemin), 'utf8');
const { warnings } = compile(texte, { filename: chemin, generate: false });
for (const avertissement of warnings) {
if (avertissement.code !== 'css_unused_selector') continue;
const debut = avertissement.start?.character ?? 0;
releves.push({
chemin,
ligne: avertissement.start?.line ?? ligneDe(texte, debut),
texte: `sélecteur élagué ${extrait(texte.slice(debut, avertissement.end?.character ?? debut))}`,
});
}
}
return trier(releves);
}
// --- Arbres d'épreuve ---------------------------------------------------------
// Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le
// passe à examiner, puis l'efface ; rend ce que rend examiner.
function avecArbre(fichiers, examiner) {
const racine = mkdtempSync(join(tmpdir(), 'sources-interface-'));
try {
for (const [chemin, contenu] of Object.entries(fichiers)) {
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
writeFileSync(join(racine, chemin), contenu);
}
return examiner(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
// Exige un relevé vide ; le message d'échec en donne chaque ligne.
function exigerVide(releves, garde) {
assert.ok(releves.length === 0, `${garde} : ${releves.length} relevé(s)\n${releves.join('\n')}`);
}
// Un composant d'épreuve : son script, son gabarit, sa feuille.
const composant = ({ script = '', gabarit = '', style = '' }) =>
`${script === '' ? '' : `<script>\n${script}\n</script>\n`}${gabarit}\n${style === '' ? '' : `<style>\n${style}\n</style>\n`}`;
// Une feuille des jetons minimale, ses deux thèmes complets.
const JETONS_EPREUVE = [
':root, :root[data-theme="clair"] { --couleur-a: #ffffff; --couleur-conflit: #b3261e; }',
":root[data-theme='sombre'] { --couleur-a: #000000; --couleur-conflit: #ff8a80; }",
'',
].join('\n');
describe('sources de l’interface : les chaînes visibles en dur (§ 14.6)', () => {
test('aucun composant de src/interface n’écrit de chaîne visible', () => {
exigerVide(releverChainesEnDur(RACINE), 'chaînes en dur');
});
test('la garde relève un bouton « Modifier » et chaque forme refusée, à toute profondeur, et pas leurs voisines', () => {
const fichiers = {
'src/interface/A.svelte': '<button>Modifier</button>\n',
'src/interface/plan/sous/B.svelte': composant({
script: " const t = (cle) => cle;\n let x = $state(true);",
gabarit: [
'<p title="Titre du plan" aria-label={x ? \'Oui\' : t(\'k.non\')}>{t(\'k.titre\')} — {42}</p>',
'<svg><text>…</text><tspan>{\'·\'}</tspan><desc> </desc><title>{t(\'k.svg\')}</title></svg>',
'<input placeholder="{x} places" alt={`Image ${x}`} aria-roledescription="table" />',
'<span>{`${x} places`}</span><span>{x ?? \'Aucun\'}</span><span>{x && \'Plein\'}</span>',
'<span class="vide" data-x="Nom">{x} / {x}</span><img alt="" />',
'<span style="content: \'Note\'">.</span>',
'<!-- Commentaire écrit en dur -->',
].join('\n'),
style: ' .vide::after { content: "Fin"; }\n .vide::before { content: counter(rang) ". "; }',
}),
'src/interface/C.test.js': "export const a = '<button>Modifier</button>';\n",
'src/interface/D.navigateur.test.js': "export const a = '<button>Modifier</button>';\n",
};
assert.deepEqual(avecArbre(fichiers, releverChainesEnDur), [
'src/interface/A.svelte:1 texte « Modifier »',
'src/interface/plan/sous/B.svelte:5 attribut aria-label « Oui »',
'src/interface/plan/sous/B.svelte:5 attribut title « Titre du plan »',
'src/interface/plan/sous/B.svelte:6 expression littérale « · »',
'src/interface/plan/sous/B.svelte:6 texte « … »',
'src/interface/plan/sous/B.svelte:7 attribut alt « Image »',
'src/interface/plan/sous/B.svelte:7 attribut aria-roledescription « table »',
'src/interface/plan/sous/B.svelte:7 attribut placeholder « places »',
'src/interface/plan/sous/B.svelte:8 expression littérale « Aucun »',
'src/interface/plan/sous/B.svelte:8 expression littérale « Plein »',
'src/interface/plan/sous/B.svelte:8 expression littérale « places »',
'src/interface/plan/sous/B.svelte:10 content « content: \'Note\' »',
'src/interface/plan/sous/B.svelte:13 content « "Fin" »',
]);
});
test('un arbre sans composant fait échouer la garde', () => {
assert.throws(
() => avecArbre({ 'src/interface/a.js': 'export const a = 1;\n' }, releverChainesEnDur),
/chaînes en dur : aucun fichier examiné/,
);
});
});
describe('sources de l’interface : les clés littérales de t existent (§ 14.6, § 19.11)', () => {
test('chaque clé littérale passée à t dans src/interface existe dans les tables assemblées', () => {
exigerVide(releverClesAbsentes(RACINE, CLES), 'clés de t');
});
test('la garde relève une clé absente, dans un composant comme dans un module, et pas une clé présente ni composée', () => {
const cles = new Set(['k.present']);
const fichiers = {
'src/interface/A.svelte': composant({
script: " import { getContext } from 'svelte';\n const t = getContext('t');\n const a = t('k.absente');",
gabarit: "<p>{t('k.present')}</p>\n<p>{t(`k.gabarit`)}</p>\n<p>{t(`k.${a}`)}</p>\n<p>{traduction.t(\"k.membre\")}</p>",
}),
'src/interface/plan/modele.js': [
'// t(\'k.commentaire\') ne compte pas.',
"export const cle = (t) => t('k.module', { n: 1 });",
"export const autre = (texte) => texte('k.texte');",
'',
].join('\n'),
'src/interface/plan/modele.test.js': "t('k.epreuve');\n",
};
assert.deepEqual(avecArbre(fichiers, (racine) => releverClesAbsentes(racine, cles)), [
'src/interface/A.svelte:4 clé absente « k.absente »',
'src/interface/A.svelte:7 clé absente « k.gabarit »',
'src/interface/A.svelte:9 clé absente « k.membre »',
'src/interface/plan/modele.js:2 clé absente « k.module »',
]);
});
test('un arbre sans source d’interface fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/application/a.js': '' }, (r) => releverClesAbsentes(r, CLES)), /aucun fichier examiné/);
});
test('des sources d’interface sans aucune clé littérale passée à t font échouer la garde', () => {
const fichiers = {
'src/interface/A.svelte': "<p>{tr('k.absente')}</p>\n",
'src/interface/B.svelte': composant({
script: " import { getContext } from 'svelte';\n const traduire = getContext('t');",
gabarit: "<p>{traduire('plan.x')}</p>\n<p>{t(cle)}</p>",
}),
'src/interface/c.js': "export const d = (t, n) => t(`k.${n}`);\n",
};
assert.throws(() => avecArbre(fichiers, (r) => releverClesAbsentes(r, CLES)), /clés de t : aucune clé examinée/);
});
});
describe('sources de l’interface : le contenu des tables de traduction (§ 5.5, § 7.6, § 7.7, § 12.8)', () => {
test('aucune valeur des tables assemblées ne porte un mot refusé ni un pictogramme', () => {
exigerVide(releverContenuTables([...TABLES_APPLICATION, ...TABLES_INTERFACE], localisateur(RACINE)), 'contenu des tables');
});
test('la garde relève chaque mot refusé, sans égard à la casse ni aux accents, dans une chaîne et dans une fonction', () => {
const refusees = new Map([
['a.conforme', 'Conforme'],
['a.nonConforme', 'Plan non conforme'],
['a.conformite', 'CONFORMITÉ'],
['a.ecart', ({ n }) => `Un écart de ${n}`],
['a.ecartSeul', (details, texte) => texte('a.ecart', details)],
['a.ecartType', 'Écart-type'],
['a.satisfaction', 'Taux de satisfaction'],
['a.variance', 'variance'],
['a.equite', 'Équité'],
['a.score', 'Score'],
['a.mieux', 'Peut mieux faire'],
['a.minimal', 'Manque minimal'],
['a.recuperable', 'récupérable'],
['a.evitable', 'Évitable'],
['a.emoji', ({ n }) => `${n} \u{1F512}`],
['a.copyright', '\u{00A9} Club des Merles'],
]);
const admises = new Map([
['b.itineraire', 'Écart d\u{2019}itinéraire'],
['b.itineraires', "écarts d'itinéraire maximal"],
['b.plafond', 'Écart au plafond a priori'],
['b.inevitable', 'Inévitable'],
['b.ecartee', 'Proposition écartée'],
['b.fleche', 'Tour 1 \u{2192} tour 2'],
['b.nom', 'Iris Ombrelle'],
['b.variable', ({ n }) => {
const ecart = n + 1;
return `Tour ${ecart}`;
}],
]);
assert.deepEqual(releverContenuTables([refusees, admises], () => null, []), [
"a.conforme : conforme « Conforme »",
'a.conformite : conforme « CONFORMITÉ »',
'a.copyright : pictogramme U+00A9 « © Club des Merles »',
'a.ecart : écart hors du glossaire « Un écart de »',
'a.ecartType : écart hors du glossaire « Écart-type »',
'a.ecartType : écart-type « Écart-type »',
'a.emoji : pictogramme U+1F512 « \u{1F512} »',
'a.equite : équité « Équité »',
'a.evitable : évitable « Évitable »',
'a.mieux : peut mieux faire « Peut mieux faire »',
'a.minimal : manque minimal « Manque minimal »',
'a.nonConforme : conforme « Plan non conforme »',
'a.recuperable : récupérable « récupérable »',
'a.satisfaction : satisfaction « Taux de satisfaction »',
'a.score : score « Score »',
'a.variance : variance « variance »',
]);
});
test('la garde nomme le fichier et la ligne de la clé fautive, et lève sur des tables vides', () => {
const fichiers = {
'src/interface/libelles/plan.js': "export const TABLE = new Map([\n ['plan.a', 'A'],\n [\n 'plan.b',\n 'Score',\n ],\n]);\n",
};
const releves = avecArbre(fichiers, (racine) =>
releverContenuTables([new Map([['plan.a', 'A'], ['plan.b', 'Score']])], localisateur(racine), []),
);
assert.deepEqual(releves, ['src/interface/libelles/plan.js:3 plan.b : score « Score »']);
assert.throws(() => releverContenuTables([new Map()]), /aucune entrée examinée/);
});
test('une exception nommée n’admet que sa forme, sous ses clés ; tout autre « évitable » se relève, et une exception que rien ne sert est sans objet', () => {
const tables = new Map([
['diagnostic.remede.un', '2 chaises de plus la rend évitable'],
['diagnostic.remede.deux', ({ n }) => (n === 1 ? 'une table la rend évitable' : `${n} tables la rendent évitable`)],
['diagnostic.remede.reste', 'la rend évitable, part évitable'],
['diagnostic.remede.autre', 'la place devient évitable'],
['diagnostic.titre', 'un tabouret la rend évitable'],
['plan.segment', 'la rend évitable'],
['diagnostic.remede.inevitable', 'inévitable'],
]);
assert.deepEqual(releverContenuTables([tables]), [
'diagnostic.remede.autre : évitable « la place devient évitable »',
'diagnostic.remede.reste : évitable « la rend évitable, part évitable »',
'diagnostic.titre : évitable « un tabouret la rend évitable »',
'plan.segment : évitable « la rend évitable »',
]);
const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]);
assert.deepEqual(releverContenuTables([sansRemede]), ['exception sans objet : diagnostic.remede.* « la rend(ent) évitable »']);
});
});
describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)', () => {
test('aucun littéral de couleur, aucun jeton déclaré hors de jetons.css, le jeton de conflit lu par les seules règles de conflit du plan', () => {
exigerVide(releverCouleurs(RACINE), 'couleurs');
});
test('chaque jeton de couleur est défini dans les deux thèmes', () => {
exigerVide(releverThemes(RACINE), 'thèmes');
});
test('la garde relève stroke: #c00 et chaque littéral, et pas un jeton ni une adresse de motif', () => {
const fichiers = {
'src/interface/jetons.css': JETONS_EPREUVE,
'src/interface/plan/A.svelte': composant({
gabarit: '<g class="table"><circle r="1" /></g>',
style: [
' .table { stroke: #c00; }',
' .table circle { fill: url(#hachure); stroke: var(--couleur-a); }',
' circle { color: rgb(1 2 3); background: Tomato; border-color: transparent currentColor; }',
' .table { outline-color: var(--couleur-a, white); --rang: 2; }',
].join('\n'),
}),
'src/interface/accueil/sous/B.svelte': composant({
script: " const couleur = '#ABCDEF';\n const code = 'orange-sanguine';",
gabarit: '<svg><rect fill="red" stroke={x ? \'hsl(0 0% 0%)\' : \'none\'} style="color: #fff; --ombre: 1px" style:fill="Canvas" /></svg>\n<Bouton --teinte="1" />',
}),
'src/interface/autre.css': '.x { color: #123456; }\n',
'src/interface/C.navigateur.test.js': "const c = 'stroke: #c00';\n",
};
assert.deepEqual(avecArbre(fichiers, releverCouleurs), [
'src/interface/accueil/sous/B.svelte:2 couleur « #ABCDEF » dans le script',
'src/interface/accueil/sous/B.svelte:5 couleur « #fff » dans color',
'src/interface/accueil/sous/B.svelte:5 couleur « Canvas » dans fill',
'src/interface/accueil/sous/B.svelte:5 couleur « hsl( » dans stroke',
'src/interface/accueil/sous/B.svelte:5 couleur « red » dans fill',
'src/interface/accueil/sous/B.svelte:5 jeton déclaré --ombre',
'src/interface/accueil/sous/B.svelte:6 jeton déclaré --teinte',
'src/interface/autre.css:1 couleur « #123456 » dans color',
'src/interface/plan/A.svelte:3 couleur « #c00 » dans stroke',
'src/interface/plan/A.svelte:5 couleur « Tomato » dans background',
'src/interface/plan/A.svelte:5 couleur « rgb( » dans color',
'src/interface/plan/A.svelte:6 couleur « white » dans outline-color',
'src/interface/plan/A.svelte:6 jeton déclaré --rang',
]);
});
test('la garde relève un bouton peint du jeton de conflit, et l’admet dans une règle de conflit du plan', () => {
const fichiers = {
'src/interface/jetons.css': JETONS_EPREUVE,
'src/interface/accueil/Bouton.svelte': composant({
gabarit: '<button class="danger">x</button>',
style: ' .danger { color: var(--couleur-conflit); }',
}),
'src/interface/plan/Table.svelte': composant({
gabarit: '<g class:conflit={true} class="table"><rect /></g><span style="color: var(--couleur-conflit)">1</span>',
style: ' .table.conflit rect { stroke: var(--couleur-conflit); }\n .table rect { fill: var(--couleur-conflit); }',
}),
'src/interface/plan/Trou.svelte': composant({
gabarit: '<g class="table b"><rect /></g><button class="sans-conflit">x</button><i class="non-conflit a"></i>',
style: [
' .table:not(.conflit) rect { fill: var(--couleur-conflit); }',
' .sans-conflit { color: var(--couleur-conflit); }',
' .non-conflit { color: var(--couleur-conflit); }',
' .a, .b.conflit { fill: var(--couleur-conflit); }',
' .conflit-leve { fill: var(--couleur-conflit); }',
' .b.conflit, .b .trait-conflit, .plateau-en-conflit { stroke: var(--couleur-conflit); }',
].join('\n'),
}),
};
assert.deepEqual(avecArbre(fichiers, releverCouleurs), [
"src/interface/accueil/Bouton.svelte:3 jeton de conflit hors d'une règle de conflit du plan, « .danger »",
"src/interface/plan/Table.svelte:1 jeton de conflit hors d'une règle de conflit du plan, en ligne",
"src/interface/plan/Table.svelte:4 jeton de conflit hors d'une règle de conflit du plan, « .table rect »",
"src/interface/plan/Trou.svelte:3 jeton de conflit hors d'une règle de conflit du plan, « .table:not(.conflit) rect »",
"src/interface/plan/Trou.svelte:4 jeton de conflit hors d'une règle de conflit du plan, « .sans-conflit »",
"src/interface/plan/Trou.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .non-conflit »",
"src/interface/plan/Trou.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .a, .b.conflit »",
"src/interface/plan/Trou.svelte:7 jeton de conflit hors d'une règle de conflit du plan, « .conflit-leve »",
]);
});
test('la garde des thèmes relève un jeton qu’un thème ne redéfinit pas', () => {
const fichiers = {
'src/interface/jetons.css': [
':root { --espace-1: 1px; }',
':root, :root[data-theme="clair"] { --couleur-sol: #fff; --couleur-gel: #00f; }',
":root[data-theme='sombre'] { --couleur-sol: #000; --couleur-fantome: #0f0; }",
'',
].join('\n'),
};
assert.deepEqual(avecArbre(fichiers, releverThemes), [
'src/interface/jetons.css:2 --couleur-gel absent du thème sombre',
'src/interface/jetons.css:3 --couleur-fantome absent du thème clair',
]);
});
test('un arbre sans composant ni feuille fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/interface/jetons.css': JETONS_EPREUVE }, releverCouleurs), /couleurs : aucun fichier examiné/);
});
test('une feuille des jetons sans jeton de couleur fait échouer la garde des thèmes', () => {
const fichiers = { 'src/interface/jetons.css': ':root, :root[data-theme="clair"] { --espace-1: 1px; }\n' };
assert.throws(() => avecArbre(fichiers, releverThemes), /thèmes : aucun jeton de couleur examiné/);
});
});
describe('sources de l’interface : balisage et fenêtres du navigateur (§ 13.1, § 2.1)', () => {
test('aucun balisage injecté, aucune fenêtre du navigateur, aucun beforeunload, aucun .message affiché', () => {
exigerVide(releverBalisageEtFenetres(RACINE), 'balisage et fenêtres');
});
test('la garde relève {@html nom} et chaque forme refusée, et pas leurs voisines ni un commentaire', () => {
const fichiers = {
'src/interface/A.svelte': composant({
script: " let nom = $state('');\n // aucun beforeunload, jamais alert(…) ni err.message",
gabarit: '<p>{@html nom}</p>\n<svelte:window onbeforeunload={() => {}} />',
}),
'src/interface/plan/sous/b.js': [
'export function f(el, erreur, seance) {',
" el.innerHTML = '';",
" el['outerHTML'] = '';",
" el.insertAdjacentHTML('beforeend', '');",
" document.write('x');",
" window.alert('x'); confirm('x'); globalThis.prompt('x');",
" addEventListener('beforeunload', f);",
' return erreur.message ?? erreur?.message;',
'}',
'export const voisins = (s) => [s.confirmer(), s.alert(), s.textContent, s.code, s.messages];',
'',
].join('\n'),
'src/application/c.js': "export const m = (e) => e.message;\nexport const h = (el) => { el.innerHTML = ''; };\n",
'src/interface/A.test.js': "window.confirm('x'); el.innerHTML = '';\n",
};
assert.deepEqual(avecArbre(fichiers, releverBalisageEtFenetres), [
'src/application/c.js:2 innerHTML',
'src/interface/A.svelte:5 {@html}',
'src/interface/A.svelte:6 beforeunload',
'src/interface/plan/sous/b.js:2 innerHTML',
'src/interface/plan/sous/b.js:3 outerHTML',
'src/interface/plan/sous/b.js:4 insertAdjacentHTML',
'src/interface/plan/sous/b.js:5 document.write',
'src/interface/plan/sous/b.js:6 alert(',
'src/interface/plan/sous/b.js:6 confirm(',
'src/interface/plan/sous/b.js:6 prompt(',
'src/interface/plan/sous/b.js:7 beforeunload',
'src/interface/plan/sous/b.js:8 .message',
'src/interface/plan/sous/b.js:8 .message',
]);
});
test('un arbre sans source fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/interface/a.css': '' }, releverBalisageEtFenetres), /aucun fichier examiné/);
});
});
describe('sources de l’interface : texte comprimé et animations (§ 7.4, § 19.2)', () => {
test('ni textLength ni lengthAdjust, ni transitionend ni animationend, ni transition ni animation', () => {
exigerVide(releverTexteEtAnimation(RACINE), 'texte et animation');
});
test('la garde relève chaque forme dans son périmètre, et pas hors de lui ni dans un commentaire', () => {
const fichiers = {
'src/interface/plan/A.svelte': composant({
script: " import { fade } from 'svelte/transition';\n // ni textLength ni transitionend",
gabarit: [
'<text textLength="10" lengthAdjust="spacing">{x}</text>',
'<div transition:fade ontransitionend={f}></div><p in:fade out:fade></p>',
].join('\n'),
style: ' div { transition: opacity 1s; animation: none; transition-duration: 0s; }\n @keyframes pulse { to { opacity: 1; } }',
}),
'src/geometrie/texte.js': [
"export const f = (el) => el.setAttribute('textLength', 1);",
"export const g = 'transitionend';",
'export const h = (el) => el.getComputedTextLength();',
"export const i = '[textLength], [lengthAdjust]';",
'',
].join('\n'),
'src/interface/b.js': "export const f = (el) => el.addEventListener('animationend', f);\n",
'src/application/c.js': "export const f = 'textLength';\n",
'src/interface/A.navigateur.test.js': "export const f = '[textLength], [lengthAdjust]';\n",
};
assert.deepEqual(avecArbre(fichiers, releverTexteEtAnimation), [
'src/geometrie/texte.js:1 textLength',
'src/geometrie/texte.js:4 textLength',
'src/interface/b.js:1 animationend',
'src/interface/plan/A.svelte:5 lengthAdjust',
'src/interface/plan/A.svelte:5 textLength',
'src/interface/plan/A.svelte:6 directive in:fade',
'src/interface/plan/A.svelte:6 directive out:fade',
'src/interface/plan/A.svelte:6 directive transition:fade',
'src/interface/plan/A.svelte:6 transitionend',
'src/interface/plan/A.svelte:8 transition: opacity 1s',
'src/interface/plan/A.svelte:9 @keyframes',
]);
});
test('un arbre sans source fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTexteEtAnimation), /aucun fichier examiné/);
});
});
describe('sources de l’interface : le câblage (§ 12.9, § 13.3)', () => {
test('aucun composant ne calcule, et seul evaluation.js importe les mesures', () => {
exigerVide(releverCablage(RACINE), 'câblage');
});
test('la garde relève Math., .sort( et .reduce( dans un composant, et l’import d’une mesure hors d’evaluation.js', () => {
const fichiers = {
'src/interface/plan/A.svelte': composant({
script: ' let liste = $state([]);\n const tries = $derived(liste.toSorted());\n // Math.max, liste.sort()',
gabarit: '<p>{Math.round(liste.length)}</p>\n<p>{liste.reduce((s, x) => s + x, 0)}</p>\n<p>{liste.sort().join()}</p>\n<p>{liste.filter(Boolean).length}</p>',
}),
'src/interface/b.js': 'export const f = (l) => Math.max(...l.sort());\n',
'src/application/evaluation.js': "import { mesurer, manques } from '../moteur/indicateurs.js';\nexport { mesurer };\n",
'src/application/generation.js': [
"import { troisChiffres as chiffres } from '../moteur/indicateurs.js';",
"import * as indicateurs from '../moteur/indicateurs.js';",
"export { minimumAtteint } from '../moteur/indicateurs.js';",
'export const f = (x) => indicateurs.plafondsRealises(x) + indicateurs.autre(x);',
"import { evaluer } from './evaluation.js';",
'',
].join('\n'),
'src/application/generation.test.js': "import { mesurer } from '../moteur/indicateurs.js';\n",
};
assert.deepEqual(avecArbre(fichiers, releverCablage), [
'src/application/generation.js:1 import de troisChiffres',
'src/application/generation.js:3 import de minimumAtteint',
'src/application/generation.js:4 import de plafondsRealises',
'src/interface/plan/A.svelte:3 .toSorted(',
'src/interface/plan/A.svelte:6 Math.round',
'src/interface/plan/A.svelte:7 .reduce(',
'src/interface/plan/A.svelte:8 .sort(',
]);
});
test('un arbre sans composant fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverCablage), /câblage des composants : aucun fichier examiné/);
});
});
describe('sources de l’interface : les sélecteurs élagués (§ 13.3, § 14.2)', () => {
test('aucun composant ne porte une règle que le compilateur élague', () => {
exigerVide(releverSelecteursElagues(RACINE), 'sélecteurs élagués');
});
test('la garde relève une règle .conflit qui vise un élément sans classe dynamique, et admet class:conflit', () => {
const fichiers = {
'src/interface/plan/sous/Table.svelte': composant({
gabarit: '<g class="table"><rect /></g>',
style: ' .table { stroke-width: 1; }\n .table.conflit rect { stroke-width: 3; }',
}),
'src/interface/plan/Table2.svelte': composant({
script: ' let { conflit } = $props();',
gabarit: '<g class="table" class:conflit><rect /></g>',
style: ' .table.conflit rect { stroke-width: 3; }',
}),
};
assert.deepEqual(avecArbre(fichiers, releverSelecteursElagues), [
'src/interface/plan/sous/Table.svelte:4 sélecteur élagué « .table.conflit rect »',
]);
});
test('un arbre sans composant fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/interface/a.js': '' }, releverSelecteursElagues), /aucun fichier examiné/);
});
});