From 4b442ded141b07d9e2d0eaa8890c1c25187f9f15 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 13:21:21 -0400 Subject: [PATCH] [ADD] tests: guards for layers, clock, sources, colours, watch patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards scan the tree file by file and line by line, fail on an empty scan, and are each shown red on a temporary tree. They cover the interface import graph, the Electron bridge, one clock, refused dependencies, how tests are written, hard-coded strings, translation keys, forbidden words, colours and the conflict hue, markup, pruned selectors and watch patterns. Coverage keeps the engine integration test on a small machine. Fixes: delays in tests, the conflict hatch class; « la rend évitable » is a named exception (§ 5.6). Checked: 2587 node, 465 browser tests, from the index alone. --- FR --- [ADD] épreuves : gardes des couches, horloge, sources, couleurs, motifs Les gardes relèvent par fichier et par ligne, échouent sur un balayage vide et sont chacune montrées rouges sur un arbre temporaire. Elles couvrent le graphe d'imports de l'interface, le pont d'Electron, l'horloge unique, les dépendances refusées, l'écriture des épreuves, les chaînes en dur, les clés de traduction, les mots interdits, les couleurs et la teinte de conflit, le balisage, les sélecteurs élagués et les motifs de surveillance. Corrigés : délais d'épreuves, classe de la trame de conflit ; « la rend évitable » est une exception nommée (§ 5.6). Vérifié : 2587 node, 465 navigateur. Assisted-by: Claude Opus 5.5 --- scripts/version.test.js | 10 + src/application/seance.test.js | 4 +- src/interface/plan/Motifs.svelte | 4 +- .../primitives/primitives.navigateur.test.js | 36 +- src/stockage/reglages_locaux.test.js | 6 +- test/arborescence.test.js | 1330 +++++++++++++++- test/chargements.js | 38 +- test/chargements.test.js | 55 +- test/machine.js | 13 +- test/machine.test.js | 64 +- test/sources_interface.test.js | 1340 +++++++++++++++++ vitest.config.js | 60 +- 12 files changed, 2886 insertions(+), 74 deletions(-) create mode 100644 test/sources_interface.test.js diff --git a/scripts/version.test.js b/scripts/version.test.js index d772ab4..718135d 100644 --- a/scripts/version.test.js +++ b/scripts/version.test.js @@ -574,6 +574,16 @@ describe('version : contrôle du § 18.5, un défaut à la fois', () => { exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]); })); + test('point 6 : un composant de src/interface qui porte la version affichée en dur', () => + surArbre((racine) => { + ecrire( + racine, + 'src/interface/Bandeau.svelte', + "\n\n{version}\nv2026.10.05.01\n", + ); + exigerEchecs(racine, [6], [/^6\. src\/interface\/Bandeau\.svelte, ligne 6 : « 2026\.10\.05\.01 », forme affichée/]); + })); + test('point 6 : une forme technique hors de package.json et du module engendré', () => surArbre((racine) => { ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n"); diff --git a/src/application/seance.test.js b/src/application/seance.test.js index f049488..7ff7834 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -1981,7 +1981,9 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => { // Laisse passer les tâches en attente : l'écrivain différé des réglages // écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse. -const laisser = () => new Promise((resolve) => setTimeout(resolve, 0)); +// setImmediate rappelle une fois vidées les microtâches, où s'enchaînent +// les écritures du système de fichiers d'épreuve ; il n'attend aucune durée. +const laisser = () => new Promise((resolve) => setImmediate(resolve)); // Compte les objets atteints depuis valeur, chacun une fois, en affirmant // que chacun est figé. diff --git a/src/interface/plan/Motifs.svelte b/src/interface/plan/Motifs.svelte index 2288fe5..c6178ae 100644 --- a/src/interface/plan/Motifs.svelte +++ b/src/interface/plan/Motifs.svelte @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@ fill: var(--couleur-table-fond); } - .trait-trame { + .trait-conflit { stroke: var(--couleur-conflit); stroke-width: 3; } diff --git a/src/interface/primitives/primitives.navigateur.test.js b/src/interface/primitives/primitives.navigateur.test.js index 82f1891..0f597bf 100644 --- a/src/interface/primitives/primitives.navigateur.test.js +++ b/src/interface/primitives/primitives.navigateur.test.js @@ -836,10 +836,32 @@ describe('fenêtre à confirmer (§ 2.1)', () => { // entre elles : la seconde Échap ferme le sans que cancel puisse // l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt, // modale et toujours occupée ; la fin de l'action seule décide. - async function echapEnRafale(fois) { - for (let i = 0; i < fois; i += 1) await touche('Escape'); - // L'événement close part dans une tâche à lui, après la touche. - await new Promise((fin) => setTimeout(fin, 0)); + // + // L'événement close part dans une tâche à lui, après la touche : la rafale + // compte les fermetures — l'attribut open retiré, que MutationObserver + // signale dès la touche traitée — et les événements close reçus, et attend + // que chaque fermeture ait eu le sien. Au moins une Échap ferme la fenêtre. + async function echapEnRafale(fenetre, fois) { + let fermetures = 0; + let evenements = 0; + const observateur = new MutationObserver((releves) => { + fermetures += releves.filter(({ oldValue }) => oldValue !== null).length; + }); + const surClose = () => { + evenements += 1; + }; + observateur.observe(fenetre, { attributes: true, attributeFilter: ['open'], attributeOldValue: true }); + fenetre.addEventListener('close', surClose); + try { + for (let i = 0; i < fois; i += 1) await touche('Escape'); + await vi.waitFor(() => { + expect(fermetures).toBeGreaterThan(0); + expect(evenements).toBe(fermetures); + }); + } finally { + observateur.disconnect(); + fenetre.removeEventListener('close', surClose); + } await tick(); } @@ -857,10 +879,10 @@ describe('fenêtre à confirmer (§ 2.1)', () => { const montage = await preparer(); const { fenetre, appels, attente } = monterConfirmationDifferee(montage); await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); - await echapEnRafale(fois); + await echapEnRafale(fenetre, fois); attendreOccupee(montage, fenetre, appels); // Une seconde rafale, la fenêtre déjà rouverte une fois. - await echapEnRafale(fois); + await echapEnRafale(fenetre, fois); attendreOccupee(montage, fenetre, appels); attente.resoudre(); await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0)); @@ -874,7 +896,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => { const montage = await preparer(); const { fenetre, appels, attente } = monterConfirmationDifferee(montage); await cliquer(fenetre.querySelector('[data-commande="confirmer"]')); - await echapEnRafale(fois); + await echapEnRafale(fenetre, fois); attendreOccupee(montage, fenetre, appels); await montage.seance.passerEnLecture(); attente.resoudre(); diff --git a/src/stockage/reglages_locaux.test.js b/src/stockage/reglages_locaux.test.js index 2f6bb99..a8685ee 100644 --- a/src/stockage/reglages_locaux.test.js +++ b/src/stockage/reglages_locaux.test.js @@ -71,8 +71,10 @@ function planificateur() { }; } -// Laisse s'écouler les promesses en attente et les rejets non traités. -const attendre = () => new Promise((resolve) => setTimeout(resolve, 0)); +// Laisse s'écouler les promesses en attente et les rejets non traités : +// setImmediate rappelle une fois vidées les microtâches, et après l'annonce +// des rejets que rien ne traite ; il n'attend aucune durée. +const attendre = () => new Promise((resolve) => setImmediate(resolve)); // Exécute travail en relevant chaque rejet de promesse que rien ne traite ; // rend ce relevé. diff --git a/test/arborescence.test.js b/test/arborescence.test.js index d13ef42..98f459d 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -5,25 +5,33 @@ // géométrie du plan, le générateur de démonstrations, le stockage et // l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un // objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de -// chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet -// importé est déclaré dans devDependencies ; une valeur du contrat de données +// chaque couche, l'interface et ses composants compris, ne rejoint que ce que +// le § 13.4 lui permet ; seuls deux lecteurs nommés touchent le pont de la +// coquille (§ 13.1) ; hors de l'horloge, l'interface et l'application +// n'arment aucune minuterie et ne lisent ni l'heure, ni l'aléa, ni la langue +// (§ 19.4) ; chaque paquet importé est déclaré dans devDependencies, et aucun +// paquet refusé ne l'est (§ 13.3, § 14.8) ; une valeur du contrat de données // n'a qu'une définition, celle de configuration.js ; la mesure de la // couverture joue la série node, publie chaque module et tient ses seuils -// durs sur des modules qui existent (§ 14.13). Chaque relevé lit le -// texte entier, commentaires et chaînes compris, hors celui des accès au -// navigateur, qui saute les chaînes littérales : un commentaire qui nomme un -// appel refusé fait échouer la garde comme l'appel lui-même. Chaque garde -// refuse de passer sur un balayage vide (§ 14.2), et d'autres épreuves la -// font tourner sur des arbres temporaires qui portent, à plus d'un niveau de -// profondeur, les formes qu'elle refuse et des formes voisines qu'elle admet. +// durs sur des modules qui existent (§ 14.13) ; les épreuves s'écrivent +// selon le § 14.8, la série surveillée n'attend ni n'écrit hors d'un +// temporaire, et ses motifs de surveillance ne retirent aucune relance +// (§ 14.14). Chaque relevé lit le texte entier, commentaires et chaînes +// compris, hors ceux qui disent le contraire — les accès au navigateur et au +// pont, et les relevés des épreuves, sautent les chaînes littérales : un +// commentaire qui nomme un appel refusé fait échouer la garde comme l'appel +// lui-même. Chaque garde déclare son périmètre, refuse de passer sur un +// balayage vide (§ 14.2), et d'autres épreuves la font tourner sur des +// arbres temporaires qui portent, à plus d'un niveau de profondeur, les +// formes qu'elle refuse et des formes voisines qu'elle admet. import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path'; +import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import configuration from '../vitest.config.js'; -import { chargements } from './chargements.js'; +import { blocsScript, chargements, scriptsDe } from './chargements.js'; import { describe, test } from './lanceur.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); @@ -195,6 +203,7 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) { // d'épreuve y ajoute ce qu'elle éprouve, ou en retire un arbre (sansArbre) // pour éprouver le refus d'un balayage vide. const MODULES_VIDES = Object.freeze({ + 'src/interface/a.js': '', 'src/moteur/a.js': '', 'src/geometrie/a.js': '', 'src/demo/a.js': '', @@ -480,13 +489,16 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // La frontière de chaque couche (§ 13.4), dans l'ordre du relevé. arbres : // ceux de ses modules, dont requis nomme ceux qui doivent en fournir au moins -// un — une couche encore à écrire peut manquer. exterieures : les couches de -// src/ qu'elle n'importe pas. paquets : ceux qu'elle refuse, outre les -// modules natifs de Node, que la page n'a pas et qu'aucune couche ne charge. -// navigateur : null quand la couche y accède librement ; sinon, les fichiers -// admis à y accéder, chacun dans son seul texte. Aucun module du parcours -// n'importe un fichier admis : il ne passe qu'en paramètre, de la main de -// l'application. +// un — une couche encore à écrire peut manquer. exterieures : les arbres, +// relatifs à la racine, qu'elle n'importe pas. frontieres : les arbres +// qu'elle importe sans que le parcours les suive, leur propre garde les +// tenant. paquets : ceux qu'elle refuse, outre les modules natifs de Node, +// que la page n'a pas et qu'aucune couche ne charge. navigateur : null quand +// la couche y accède librement ; sinon, les fichiers admis à y accéder, +// chacun dans son seul texte. Aucun module du parcours n'importe un fichier +// admis : il ne passe qu'en paramètre, de la main de l'application. +// composants : vrai quand ses composants Svelte sont des modules de la +// couche, lus par leurs blocs de script. // // Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce // qui les commande. La géométrie est une couche à part, plus bas que le @@ -496,43 +508,57 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron. // L'analyseur CSV lit le modèle du stockage. L'application câble le moteur, // le stockage, le CSV et les systèmes de fichiers des plateformes, sous -// l'interface. +// l'interface. L'interface n'importe que svelte, ses propres modules, +// l'application et la géométrie : elle atteint le CSV par +// src/application/csv.js, et le moteur par la table d'évaluation, sans que +// sa garde le lui reproche, l'application le faisant par construction ; +// importés en direct, le stockage, le CSV, le moteur, les démonstrations et +// test/ sont refusés. const COUCHES = [ { arbres: ['src/moteur'], requis: ['src/moteur'], - exterieures: ['interface', 'application', 'stockage'], + exterieures: ['src/interface', 'src/application', 'src/stockage'], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, { arbres: ['src/geometrie'], requis: ['src/geometrie'], - exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'], + exterieures: ['src/interface', 'src/application', 'src/stockage', 'src/moteur', 'src/csv', 'src/demo'], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, { arbres: ['src/stockage'], requis: ['src/stockage'], - exterieures: ['interface', 'application', 'csv'], + exterieures: ['src/interface', 'src/application', 'src/csv'], paquets: PAQUETS_DE_PLATEFORME, navigateur: ['src/stockage/fichiers_web.js', 'src/stockage/fichiers_electron.js'], }, { arbres: ['src/csv'], requis: ['src/csv'], - exterieures: ['interface', 'application'], + exterieures: ['src/interface', 'src/application'], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, { arbres: ['src/application'], requis: [], - exterieures: ['interface'], + exterieures: ['src/interface'], paquets: /^svelte$/, navigateur: null, }, + { + arbres: ['src/interface'], + requis: ['src/interface'], + exterieures: ['src/stockage', 'src/csv', 'src/moteur', 'src/demo', 'test'], + frontieres: ['src/application', 'src/geometrie'], + paquets: /^(?!svelte$)/, + navigateur: null, + composants: true, + }, ]; // Un accès au navigateur est un nom de document, de window, de navigator, de @@ -670,33 +696,52 @@ function cibleDe(racine, fichier, specificateur) { return null; } +// Composants Svelte d'un arbre, à toute profondeur, triés. +const composantsDe = (dossier) => fichiersDe(dossier).filter((fichier) => extname(fichier) === '.svelte').sort(); + +// Le texte qu'un relevé de chargements lit dans un fichier : celui de ses +// blocs de script pour un composant Svelte, le texte entier sinon. +const texteDesChargements = (fichier, texte) => (extname(fichier) === '.svelte' ? scriptsDe(texte) : texte); + // Relevé des refus de la frontière d'une couche dans son graphe d'imports. // Le parcours part des modules de ses arbres, à toute profondeur, épreuves -// exclues. Il suit chaque chemin relatif, ou absolu depuis la racine, vers un -// module JavaScript existant, chaque module une fois. Un type que la JSDoc -// importe par import(…) compte comme un import. Un fichier d'une couche -// extérieure n'est pas parcouru : l'importer est déjà un refus, comme -// importer un fichier admis au navigateur. Un chemin sans extension en est un -// aussi : Node ne le résout pas (§ 14.8), et le parcours ne suit pas le -// module que Vite y trouverait. Un module natif de Node, que reconnaît -// isBuiltin, est refusé : la page où la couche s'exécute n'en a aucun. Un -// accès au navigateur n'est admis que dans le texte d'un fichier admis, parti -// de lui-même. Chaque refus se lit « accès:ligne refus », où l'accès est la -// suite des modules qui mène d'un module de départ à celui qui refuse. Lève -// quand un arbre requis ne fournit aucun module. -function releverCouche(racine, { arbres, requis, exterieures, paquets, navigateur }) { +// exclues, et de leurs composants Svelte pour une couche à composants. Il +// suit chaque chemin relatif, ou absolu depuis la racine, vers un module +// JavaScript existant — ou un composant, pour une couche à composants —, +// chaque fichier une fois. Un type que la JSDoc importe par import(…) compte +// comme un import. Un fichier d'une couche extérieure n'est pas parcouru : +// l'importer est déjà un refus, comme importer un fichier admis au +// navigateur. Un fichier d'une frontière admise n'est ni refusé ni parcouru. +// Un chemin sans extension est un refus : Node ne le résout pas (§ 14.8), et +// le parcours ne suit pas le module que Vite y trouverait. Un module natif de +// Node, que reconnaît isBuiltin, est refusé : la page où la couche s'exécute +// n'en a aucun. Un accès au navigateur n'est admis que dans le texte d'un +// fichier admis, parti de lui-même. Chaque refus se lit « accès:ligne +// refus », où l'accès est la suite des fichiers qui mène d'un fichier de +// départ à celui qui refuse. Lève quand un arbre requis ne fournit aucun +// module, ni composant pour une couche à composants. +function releverCouche(racine, { arbres, requis, exterieures, frontieres = [], paquets, navigateur, composants = false }) { + const departs = (arbre) => [ + ...modulesDe(join(racine, arbre)), + ...(composants ? composantsDe(join(racine, arbre)).filter((f) => !EPREUVE.test(f)) : []), + ]; for (const arbre of requis) { - assert.ok(modulesDe(join(racine, arbre)).length > 0, `${arbre} ne fournit aucun module`); + assert.ok(departs(arbre).length > 0, `${arbre} ne fournit aucun module`); } - const couches = exterieures.map((couche) => join(racine, 'src', couche)); - const exterieur = (chemin) => couches.some((c) => chemin === c || chemin.startsWith(c + sep)); + const dans = (arbresDonnes) => { + const chemins = arbresDonnes.map((arbre) => join(racine, arbre)); + return (chemin) => chemins.some((c) => chemin === c || chemin.startsWith(c + sep)); + }; + const exterieur = dans(exterieures); + const frontiere = dans(frontieres); + const parcourable = (chemin) => EXTENSIONS_MODULE.has(extname(chemin)) || (composants && extname(chemin) === '.svelte'); const admis = new Set((navigateur ?? []).map((fichier) => join(racine, fichier))); - const aParcourir = arbres.flatMap((arbre) => modulesDe(join(racine, arbre))); + const aParcourir = arbres.flatMap(departs); const acces = new Map(aParcourir.map((fichier) => [fichier, relative(racine, fichier)])); const refus = []; for (let i = 0; i < aParcourir.length; i += 1) { const fichier = aParcourir[i]; - const texte = readFileSync(fichier, 'utf8'); + const texte = texteDesChargements(fichier, readFileSync(fichier, 'utf8')); const trouves = navigateur === null || admis.has(fichier) ? [] @@ -714,7 +759,7 @@ function releverCouche(racine, { arbres, requis, exterieures, paquets, navigateu trouves.push({ indice, motif: `import ${specificateur}` }); } else if (extname(cible) === '') { trouves.push({ indice, motif: `import ${specificateur} sans extension` }); - } else if (!acces.has(cible) && EXTENSIONS_MODULE.has(extname(cible)) && existsSync(cible)) { + } else if (!frontiere(cible) && !acces.has(cible) && parcourable(cible) && existsSync(cible)) { acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`); aParcourir.push(cible); } @@ -1064,9 +1109,93 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { ]); }); - test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage ou un src/csv sans module fait échouer la garde', () => { + test("src/interface n'importe que svelte, ses propres modules, l'application et la géométrie ; elle lit les blocs de script de ses composants, et ne parcourt pas les frontières admises", () => { + const fichiers = { + ...MODULES_VIDES, + // Les trois composants de l'arbre d'épreuve du plan : deux bibliothèques + // de dessin, le stockage en direct, puis l'application qui importe le + // moteur, admise. + 'src/interface/plan/Zoom.svelte': [ + '', + '', + ].join('\n'), + 'src/interface/accueil/Liste.svelte': [ + '', + ].join('\n'), + 'src/interface/generation/Lancer.svelte': [ + '', + "

import {'{'} faux {'}'} from '../../csv/texte.js';

", + ].join('\n'), + 'src/application/generation.js': "import { rechercher } from '../moteur/recherche.js';\nimport { decouper } from '../csv/lecture.js';\n", + 'src/moteur/recherche.js': 'export const rechercher = () => [];\n', + 'src/csv/lecture.js': 'export const decouper = () => [];\n', + // L'interface atteint le CSV par l'application, non en direct. + 'src/interface/import/modele.js': [ + "import { exporter } from '../../application/csv.js';", + "import { decouper } from '../../csv/lecture.js';", + "import { centre } from '../../geometrie/vue.js';", + "import Fiche from './Fiche.svelte';", + "import { voisin } from './voisin';", + "/** @param {import('../../stockage/types.js').Charge} charge */", + ].join('\n'), + 'src/application/csv.js': "import { decouper } from '../csv/lecture.js';\n", + // La géométrie n'est pas parcourue depuis l'interface : sa propre garde + // relève son import du moteur. + 'src/geometrie/vue.js': "import { rechercher } from '../moteur/recherche.js';\n", + // Un composant importé se parcourt, bloc de module compris ; une + // épreuve de l'interface ne se parcourt pas. + 'src/interface/import/Fiche.svelte': [ + '', + '', + ].join('\n'), + 'src/interface/import/modele.test.js': `import { lireDepot } from '../../stockage/depot.js';\nimport ${JSON.stringify('jsdom')};\n`, + 'src/interface/import/Import.navigateur.test.js': "import { page } from 'vitest/browser';\n", + // Un module hors des couches se parcourt depuis l'interface, et un + // composant qu'il importe aussi. + 'src/interface/a.js': "import { outil } from '../commun/outil.js';\n", + 'src/commun/outil.js': "import Calque from './Calque.svelte';\nexport const outil = Calque;\n", + 'src/commun/Calque.svelte': "\n", + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/geometrie/vue.js:1 import ../moteur/recherche.js', + 'src/interface/import/modele.js:2 import ../../csv/lecture.js', + 'src/interface/import/modele.js:5 import ./voisin sans extension', + 'src/interface/import/modele.js:6 import ../../stockage/types.js', + 'src/interface/accueil/Liste.svelte:2 import ../../stockage/depot.js', + 'src/interface/import/Fiche.svelte:2 import ../../demo/prng.js', + 'src/interface/import/Fiche.svelte:5 import node:fs', + 'src/interface/import/Fiche.svelte:6 import ../../../test/aide.js', + 'src/interface/import/Fiche.svelte:7 import ../../moteur/indicateurs.js', + 'src/interface/import/Fiche.svelte:8 import electron', + 'src/interface/plan/Zoom.svelte:2 import d3-zoom', + 'src/interface/plan/Zoom.svelte:3 import konva', + 'src/interface/a.js → src/commun/outil.js → src/commun/Calque.svelte:2 import ../stockage/noms.js', + ]); + }); + + test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage, un src/csv ou un src/interface sans module fait échouer la garde ; un composant seul suffit à l’interface', () => { assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []); - for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) { + const composantSeul = { ...sansArbre(MODULES_VIDES, 'src/interface'), 'src/interface/App.svelte': '

\n' }; + assert.deepEqual(avecArbre(composantSeul, releverFrontiere), []); + for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv', 'src/interface']) { const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverFrontiere), @@ -1504,3 +1633,1112 @@ describe('arborescence : la couverture (§ 14.13)', () => { } }); }); + +// Fichiers du projet, en chemins absolus triés : la racine et ses arbres, à +// toute profondeur, hors des dépendances — node_modules, à toute +// profondeur — et, à la racine, du dépôt git, des sorties de construction et +// de mesure : www, dist-electron, coverage et le cache .vitest. +const HORS_DU_PROJET_A_LA_RACINE = new Set(['.git', 'www', 'dist-electron', 'coverage', '.vitest']); +function fichiersDuProjet(racine) { + const fichiers = []; + const parcourir = (dossier) => { + for (const entree of readdirSync(dossier, { withFileTypes: true })) { + const chemin = join(dossier, entree.name); + const exclu = entree.name === 'node_modules' || (dossier === racine && HORS_DU_PROJET_A_LA_RACINE.has(entree.name)); + if (entree.isDirectory() && !exclu) parcourir(chemin); + else if (entree.isFile()) fichiers.push(chemin); + } + }; + parcourir(racine); + return fichiers.sort(); +} + +// Chemin relatif à la racine, en barres obliques : la forme que lisent les +// motifs de vitest.config.js, sur tout système. +const enBarres = (racine, fichier) => relative(racine, fichier).split(sep).join('/'); + +// Le texte d'un fichier où chaque chaîne littérale de code est blanchie +// (sansChaines), indices et lignes gardés. Un composant Svelte n'a de code +// que dans ses blocs de script : chacun est blanchi à part, et le balisage +// reste tel quel. +function codeSansChaines(fichier, texte) { + if (extname(fichier) !== '.svelte') return sansChaines(texte); + let sortie = ''; + let fin = 0; + for (const { debut, fin: finBloc } of blocsScript(texte)) { + sortie += texte.slice(fin, debut) + sansChaines(texte.slice(debut, finBloc)); + fin = finBloc; + } + return sortie + texte.slice(fin); +} + +// Vrai quand le chargement relevé à cet indice est du code : son mot-clé — +// from, import ou require — subsiste dans le texte sans ses chaînes. Une +// instruction écrite dans une chaîne, donnée d'épreuve, n'en est pas un. +const estDuCode = (code, indice) => LETTRE_DE_MOT.test(code[indice] ?? ''); + +// Relevé « fichier:ligne motif » des motifs donnés dans un texte, par ligne +// puis dans l'ordre des motifs ; chaque motif est [nom, expression à g]. +const releverMotifs = (nom, texte, motifs) => + motifs + .flatMap(([motif, expression], rang) => + [...texte.matchAll(expression)].map(({ index }) => ({ ligne: ligneDe(texte, index), rang, motif })), + ) + .sort((a, b) => a.ligne - b.ligne || a.rang - b.rang) + .map(({ ligne, motif }) => `${nom}:${ligne} ${motif}`); + +// Le pont de la coquille (§ 13.1) : la page n'atteint le processus principal +// que par window.gtt, et deux lecteurs nommés le lisent. Un accès se relève +// par un nom de l'objet global — window, globalThis, self — suivi de +// « .gtt », de « ?.gtt » ou de « ['gtt'] », ou par une déstructuration qui +// en tire gtt. Le relevé à point lit le code sans ses chaînes : le nom d'un +// fichier « ….gtt.json » est une chaîne ; celui à crochet lit le texte +// entier, la chaîne y étant le nom même. +const ACCES_AU_PONT = [ + ['.gtt', /\b(window|globalThis|self)\s*\??\.\s*gtt\b/g, 'code'], + ["['gtt']", /\b(window|globalThis|self)\s*(?:\?\.)?\s*\[\s*(['"`])gtt\2\s*\]/g, 'texte'], + ['{ gtt }', /\{[^{}]*\bgtt\b[^{}]*\}\s*=\s*(window|globalThis|self)\b/g, 'code'], +]; + +// Les deux lecteurs du pont, chacun par son fichier : le relevé admet tout +// accès de son texte. +const LECTEURS_DU_PONT = [ + { + fichier: join('src', 'main.js'), + raison: 'choisit la plateforme : construit le système de fichiers et le presse-papiers de la coquille', + }, + { + fichier: join('src', 'stockage', 'fichiers_electron.js'), + raison: 'son paramètre par défaut lit window.gtt.fichiers ; nul ne l’importe hors de src/main.js', + }, +]; + +// Relevé « fichier:ligne accès » des accès au pont dans les modules et les +// composants de src/, épreuves exclues, hors des lecteurs nommés ; par +// fichier, par ligne, puis dans l'ordre de ACCES_AU_PONT. Suit une ligne +// « lecteur sans objet : fichier » par lecteur dont le texte n'accède pas au +// pont. Lève quand src/ ne fournit aucun fichier. +function releverPont(racine, lecteurs = LECTEURS_DU_PONT) { + const fichiers = fichiersDe(join(racine, 'src')) + .filter((f) => (EXTENSIONS_MODULE.has(extname(f)) || extname(f) === '.svelte') && !EPREUVE.test(f)) + .sort(); + assert.ok(fichiers.length > 0, 'src ne fournit aucun fichier'); + const servis = new Set(); + const releve = fichiers.flatMap((fichier) => { + const nom = relative(racine, fichier); + const texte = readFileSync(fichier, 'utf8'); + const lus = { code: codeSansChaines(fichier, texte), texte }; + const trouves = ACCES_AU_PONT.flatMap(([forme, motif, lu], rang) => + [...lus[lu].matchAll(motif)].map(({ index, 1: objet }) => ({ + ligne: ligneDe(texte, index), + rang, + acces: forme === '{ gtt }' ? `{ gtt } = ${objet}` : `${objet}${forme}`, + })), + ); + if (lecteurs.some((l) => l.fichier === nom)) { + if (trouves.length > 0) servis.add(nom); + return []; + } + return trouves + .sort((a, b) => a.ligne - b.ligne || a.rang - b.rang) + .map(({ ligne, acces }) => `${nom}:${ligne} ${acces}`); + }); + const sansObjet = lecteurs.filter(({ fichier }) => !servis.has(fichier)).map(({ fichier }) => `lecteur sans objet : ${fichier}`); + return [...releve, ...sansObjet]; +} + +describe('arborescence : le pont de la coquille (§ 13.1)', () => { + test('dans src/, seuls src/main.js et src/stockage/fichiers_electron.js lisent window.gtt', () => { + assert.deepEqual(releverPont(RACINE), []); + }); + + test('la garde relève chaque accès par sa ligne, composants compris, hors épreuves et hors chaînes ; un lecteur qui ne lit plus le pont est sans objet', () => { + const fichiers = { + 'src/interface/primitives/Copier.svelte': [ + '', + '', + ].join('\n'), + 'src/application/presse.js': [ + "export const a = globalThis?.gtt ?? self.gtt;", + "export const b = window['gtt'].fichiers;", + 'const { gtt } = window;', + "export const c = 'window.gtt' + `${'self.gtt'}`;", + '// window.gtt se lit aussi dans un commentaire', + 'export const d = fenetre.gtt + windowgtt;', + ].join('\n'), + 'src/interface/App.navigateur.test.js': 'window.gtt = pont;\n', + 'src/application/presse.test.js': 'export const e = window.gtt;\n', + 'src/main.js': 'const pont = window.gtt;\n', + 'src/stockage/fichiers_electron.js': 'export const creer = (pont) => pont;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverPont), [ + 'src/application/presse.js:1 globalThis.gtt', + 'src/application/presse.js:1 self.gtt', + "src/application/presse.js:2 window['gtt']", + 'src/application/presse.js:3 { gtt } = window', + 'src/application/presse.js:5 window.gtt', + 'src/interface/primitives/Copier.svelte:2 window.gtt', + 'src/interface/primitives/Copier.svelte:5 window.gtt', + `lecteur sans objet : ${join('src', 'stockage', 'fichiers_electron.js')}`, + ]); + assert.throws(() => avecArbre({ 'test/a.js': '' }, releverPont), /src ne fournit aucun fichier/); + }); +}); + +// Les minuteries que seule l'horloge de l'application arme (§ 19.4). +// requestAnimationFrame, qui regroupe les mouvements par image et ne mesure +// rien, n'en est pas une. +const MINUTERIES = [ + ['setTimeout', /\bsetTimeout\b/g], + ['setInterval', /\bsetInterval\b/g], + ['requestIdleCallback', /\brequestIdleCallback\b/g], +]; + +// Les arbres où l'heure ne se lit que par src/application/horloge.js, et le +// point d'entrée de la page, qui la construit. +const ARBRES_D_UNE_HORLOGE = ['src/interface', 'src/application']; +const ENTREE_DE_LA_PAGE = join('src', 'main.js'); +const HORLOGE = join('src', 'application', 'horloge.js'); + +// Sources admises hors de l'horloge, chacune par son fichier et le nom de +// l'appel tel que le relevé le nomme : elle admet chaque occurrence de cet +// appel dans ce fichier, et rien d'autre. +const SOURCES_ADMISES_HORS_HORLOGE = [ + { + fichier: ENTREE_DE_LA_PAGE, + appel: 'crypto.randomUUID', + raison: "les identifiants d'événement, que src/main.js passe à App en propriété", + }, +]; + +// Relevé « fichier:ligne appel » des minuteries et des sources non +// reproductibles (SOURCES_NON_REPRODUCTIBLES : heure, aléa, tirages de +// crypto, tri et mise en forme selon la langue) dans les modules et les +// composants de src/interface et de src/application, épreuves exclues, et +// dans src/main.js ; horloge.js est hors du relevé. Le texte se lit entier, +// commentaires et chaînes compris, comme celui du déterminisme. Par fichier, +// par ligne, puis dans l'ordre de MINUTERIES et de SOURCES_NON_REPRODUCTIBLES ; +// suit une ligne « exception sans objet : fichier appel » par exception qui +// n'admet rien. Lève quand l'un des arbres ne fournit aucun fichier. +function releverHorloge(racine, admis = SOURCES_ADMISES_HORS_HORLOGE) { + const fichiers = ARBRES_D_UNE_HORLOGE.flatMap((arbre) => { + const trouves = [...modulesDe(join(racine, arbre)), ...composantsDe(join(racine, arbre)).filter((f) => !EPREUVE.test(f))]; + assert.ok(trouves.length > 0, `${arbre} ne fournit aucun fichier`); + return trouves; + }); + if (existsSync(join(racine, ENTREE_DE_LA_PAGE))) fichiers.push(join(racine, ENTREE_DE_LA_PAGE)); + const servies = new Set(); + const releve = fichiers + .filter((fichier) => relative(racine, fichier) !== HORLOGE) + .sort() + .flatMap((fichier) => { + const nom = relative(racine, fichier); + return releverMotifs(nom, readFileSync(fichier, 'utf8'), [...MINUTERIES, ...SOURCES_NON_REPRODUCTIBLES]).filter((ligne) => { + const exception = admis.find((e) => e.fichier === nom && ligne.endsWith(` ${e.appel}`)); + if (exception !== undefined) servies.add(exception); + return exception === undefined; + }); + }); + const sansObjet = admis + .filter((exception) => !servies.has(exception)) + .map(({ fichier, appel }) => `exception sans objet : ${fichier} ${appel}`); + return [...releve, ...sansObjet]; +} + +describe('arborescence : une horloge (§ 19.4, § 14.7)', () => { + test("hors d'horloge.js, ni src/interface, ni src/application, ni src/main.js n'arment une minuterie ni ne lisent l'heure, l'aléa ou la langue ; src/main.js tire les identifiants d'événement", () => { + assert.deepEqual(releverHorloge(RACINE), []); + }); + + test('la garde relève chaque appel par sa ligne, composants compris, à toute profondeur, hors épreuves et hors horloge.js ; requestAnimationFrame est admis ; une exception sans objet se relève', () => { + const fichiers = { + 'src/interface/plan/Pression.svelte': [ + '', + '

{new Date().getFullYear()}

', + ].join('\n'), + 'src/interface/plan/vue_plan.js': [ + 'export const a = setInterval(f, 10) + requestIdleCallback(f);', + "export const b = noms.sort((x, y) => x.localeCompare(y, 'fr'));", + 'export const c = new Intl.NumberFormat().format(n) + n.toLocaleString();', + 'export const d = Date.now() - performance.now();', + 'export const e = cancelAnimationFrame(image) + Date.UTC(2000, 0, 1);', + ].join('\n'), + 'src/application/sous/tirage.js': 'export const f = Math.random() + crypto.randomUUID();\n', + 'src/application/horloge.js': 'export const g = () => setTimeout(f, 0) + new Date() + performance.now();\n', + 'src/application/seance.test.js': 'const laisser = () => new Promise((r) => setTimeout(r, 0));\n', + 'src/interface/App.navigateur.test.js': 'await new Promise((r) => setTimeout(r, 10));\n', + 'src/main.js': 'const id = () => crypto.randomUUID();\nconst t = Date.now();\n', + }; + assert.deepEqual(avecArbre(fichiers, releverHorloge), [ + 'src/application/sous/tirage.js:1 Math.random', + 'src/application/sous/tirage.js:1 crypto.randomUUID', + 'src/interface/plan/Pression.svelte:3 setTimeout', + 'src/interface/plan/Pression.svelte:6 new Date', + 'src/interface/plan/vue_plan.js:1 setInterval', + 'src/interface/plan/vue_plan.js:1 requestIdleCallback', + 'src/interface/plan/vue_plan.js:2 localeCompare', + 'src/interface/plan/vue_plan.js:3 toLocale…', + 'src/interface/plan/vue_plan.js:3 Intl', + 'src/interface/plan/vue_plan.js:4 Date.now', + 'src/interface/plan/vue_plan.js:4 performance.now', + `${join('src', 'main.js')}:2 Date.now`, + ]); + const sansTirage = { ...fichiers, 'src/main.js': 'const t = 0;\n' }; + assert.deepEqual(avecArbre(sansTirage, releverHorloge).at(-1), `exception sans objet : ${join('src', 'main.js')} crypto.randomUUID`); + for (const arbre of ARBRES_D_UNE_HORLOGE) { + const sansFichier = { ...sansArbre(fichiers, arbre), [`${arbre}/a.test.js`]: '' }; + assert.throws(() => avecArbre(sansFichier, releverHorloge), new RegExp(`${arbre} ne fournit aucun fichier`), arbre); + } + }); +}); + +// Paquets refusés (§ 13.3, § 14.8), chacun par le motif de son nom : un outil +// qui déduit les styles en lisant les sources élague les classes d'état +// composées à l'exécution ; D3 garde la transformation du zoom hors du +// modèle ; jsdom et ses voisins n'ont pas de mise en page, et le style +// calculé y devient une formalité ; les autres lanceurs et bibliothèques +// d'assertion doublent Vitest et node:assert/strict. +const PAQUETS_REFUSES = [ + /^tailwindcss$/, /^@tailwindcss\//, /^unocss$/, /^windicss$/, /^purgecss$/, /^@fullhuman\/postcss-purgecss$/, + /^d3$/, /^d3-/, + /^jsdom$/, /^happy-dom$/, /^@testing-library\//, + /^jest$/, /^mocha$/, /^chai$/, /^sinon$/, /^nyc$/, /^karma$/, /^jasmine$/, /^qunit$/, +]; + +// Les champs de package.json qui déclarent un paquet. +const CHAMPS_DE_PAQUETS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + +// Configurations, à la racine, d'un outil qui lit les sources pour en +// déduire les styles. +const CONFIGURATION_REFUSEE = /^(?:tailwind|uno|postcss)\.config\.[^/]+$/; + +// Ce que vite.config.js charge : Vite et le greffon Svelte, rien d'autre. +const CHARGEMENTS_DE_VITE = new Set(['vite', '@sveltejs/vite-plugin-svelte']); + +// Éléments d'une liste écrite entre crochets, séparés par les virgules de +// premier niveau, blancs de bord retirés, vides exclus. +function elementsDeListe(liste) { + const elements = []; + let profondeur = 0; + let debut = 0; + for (let i = 0; i <= liste.length; i += 1) { + const c = liste[i]; + if (c === '(' || c === '[' || c === '{') profondeur += 1; + else if (c === ')' || c === ']' || c === '}') profondeur -= 1; + else if (i === liste.length || (c === ',' && profondeur === 0)) { + elements.push(liste.slice(debut, i).trim()); + debut = i + 1; + } + } + return elements.filter((element) => element !== ''); +} + +// Relevé des dépendances refusées : chaque paquet refusé de package.json, +// champ par champ dans l'ordre de CHAMPS_DE_PAQUETS, « package.json champ +// paquet » ; chaque configuration refusée de la racine, « fichier à la +// racine » ; puis, dans vite.config.js, chaque chargement autre que Vite et +// le greffon Svelte, « vite.config.js:ligne import x », et chaque greffon +// autre qu'un appel à svelte, « vite.config.js:ligne greffon x ». Lève quand +// package.json ne déclare aucun paquet, ou que vite.config.js manque. +function releverDependancesRefusees(racine) { + const paquet = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8')); + const declares = CHAMPS_DE_PAQUETS.flatMap((champ) => Object.keys(paquet[champ] ?? {}).sort().map((nom) => ({ champ, nom }))); + assert.ok(declares.length > 0, 'package.json ne déclare aucun paquet'); + const refus = declares + .filter(({ nom }) => PAQUETS_REFUSES.some((motif) => motif.test(nom))) + .map(({ champ, nom }) => `package.json ${champ} ${nom}`); + const configurations = readdirSync(racine, { withFileTypes: true }) + .filter((entree) => entree.isFile() && CONFIGURATION_REFUSEE.test(entree.name)) + .map((entree) => entree.name) + .sort(); + refus.push(...configurations.map((nom) => `${nom} à la racine`)); + const texte = readFileSync(join(racine, 'vite.config.js'), 'utf8'); + const code = sansChaines(texte); + for (const { indice, specificateur, argument } of chargements(texte)) { + if (estDuCode(code, indice) && !CHARGEMENTS_DE_VITE.has(specificateur)) { + refus.push(`vite.config.js:${ligneDe(texte, indice)} import ${specificateur ?? argument}`); + } + } + const greffons = /\bplugins\s*:\s*\[/.exec(code); + if (greffons === null) { + refus.push('vite.config.js : aucun greffon Svelte'); + } else { + const debut = greffons.index + greffons[0].length; + let fin = debut; + for (let profondeur = 1; fin < code.length && profondeur > 0; fin += 1) { + if (code[fin] === '[') profondeur += 1; + if (code[fin] === ']') profondeur -= 1; + } + const liste = elementsDeListe(code.slice(debut, fin - 1)); + if (!liste.some((element) => /^svelte\s*\(/.test(element))) refus.push('vite.config.js : aucun greffon Svelte'); + for (const element of liste.filter((e) => !/^svelte\s*\(/.test(e))) { + refus.push(`vite.config.js:${ligneDe(code, code.indexOf(element, debut))} greffon ${element.replace(/\s+/g, ' ')}`); + } + } + return refus; +} + +describe('arborescence : dépendances refusées (§ 13.3, § 14.8)', () => { + test('ni outil qui lit les sources pour les styles, ni D3, ni jsdom, ni autre lanceur ; vite.config.js ne charge que le greffon Svelte', () => { + assert.deepEqual(releverDependancesRefusees(RACINE), []); + }); + + test('la garde relève un paquet refusé dans chaque champ, une configuration refusée à la racine, et un greffon ou un chargement de plus dans vite.config.js', () => { + const vite = [ + "import { defineConfig } from 'vite';", + "import { svelte } from '@sveltejs/vite-plugin-svelte';", + 'export default defineConfig({ base: \'./\', plugins: [svelte()] });', + ].join('\n'); + const conforme = { + 'package.json': JSON.stringify({ devDependencies: { svelte: '^5', vite: '^8', vitest: '^5' } }), + 'vite.config.js': vite, + 'src/postcss.config.js': '', + }; + assert.deepEqual(avecArbre(conforme, releverDependancesRefusees), []); + const avec = (devDependencies) => ({ ...conforme, 'package.json': JSON.stringify({ devDependencies }) }); + assert.deepEqual(avecArbre(avec({ svelte: '^5', tailwindcss: '^4' }), releverDependancesRefusees), [ + 'package.json devDependencies tailwindcss', + ]); + assert.deepEqual(avecArbre(avec({ svelte: '^5', jsdom: '^26' }), releverDependancesRefusees), [ + 'package.json devDependencies jsdom', + ]); + const tout = { + ...conforme, + 'package.json': JSON.stringify({ + dependencies: { d3: '^7', 'd3-zoom': '^3' }, + devDependencies: { + '@tailwindcss/vite': '^4', unocss: '^0', windicss: '^3', purgecss: '^7', '@fullhuman/postcss-purgecss': '^7', + 'happy-dom': '^18', '@testing-library/svelte': '^5', jest: '^30', mocha: '^11', chai: '^5', sinon: '^21', + nyc: '^17', karma: '^6', jasmine: '^5', qunit: '^2', svelte: '^5', d3x: '^1', 'jest-voisin': '^1', + }, + optionalDependencies: { 'd3-selection': '^3' }, + peerDependencies: { tailwindcss: '^4' }, + }), + 'tailwind.config.js': '', + 'uno.config.ts': '', + 'postcss.config.cjs': '', + 'vite.config.js': [ + "import { defineConfig } from 'vite';", + "import { svelte } from '@sveltejs/vite-plugin-svelte';", + `import tailwind from ${JSON.stringify('@tailwindcss/vite')};`, + "import { greffon } from './greffon.js';", + 'export default defineConfig({', + ' plugins: [', + ' svelte({ compilerOptions: {} }),', + ' tailwind(),', + ' ],', + '});', + ].join('\n'), + }; + assert.deepEqual(avecArbre(tout, releverDependancesRefusees), [ + 'package.json dependencies d3', + 'package.json dependencies d3-zoom', + 'package.json devDependencies @fullhuman/postcss-purgecss', + 'package.json devDependencies @tailwindcss/vite', + 'package.json devDependencies @testing-library/svelte', + 'package.json devDependencies chai', + 'package.json devDependencies happy-dom', + 'package.json devDependencies jasmine', + 'package.json devDependencies jest', + 'package.json devDependencies karma', + 'package.json devDependencies mocha', + 'package.json devDependencies nyc', + 'package.json devDependencies purgecss', + 'package.json devDependencies qunit', + 'package.json devDependencies sinon', + 'package.json devDependencies unocss', + 'package.json devDependencies windicss', + 'package.json optionalDependencies d3-selection', + 'package.json peerDependencies tailwindcss', + 'postcss.config.cjs à la racine', + 'tailwind.config.js à la racine', + 'uno.config.ts à la racine', + 'vite.config.js:3 import @tailwindcss/vite', + 'vite.config.js:4 import ./greffon.js', + 'vite.config.js:8 greffon tailwind()', + ]); + const sansSvelte = { ...conforme, 'vite.config.js': "import { defineConfig } from 'vite';\nexport default defineConfig({});\n" }; + assert.deepEqual(avecArbre(sansSvelte, releverDependancesRefusees), ['vite.config.js : aucun greffon Svelte']); + assert.throws(() => avecArbre({ ...conforme, 'package.json': '{}' }, releverDependancesRefusees), /package.json ne déclare aucun paquet/); + }); +}); + +// Le lanceur d'épreuves : vitest, ses sous-chemins et ses paquets @vitest/. +const LANCEUR_D_EPREUVES = /^(?:vitest(?:\/|$)|@vitest\/)/; +const EPREUVE_NAVIGATEUR = /\.navigateur\.test\.[cm]?js$/; + +// Ce que le texte sans chaînes d'une épreuve node ne porte pas (§ 14.8) : les +// assertions de Vitest, que node:assert/strict remplace, et l'objet env +// d'import.meta, que Node ne fournit pas. +const ECRITURE_NODE = [ + ['expect', /\bexpect\s*(?:\?\.)?\s*\(/g], + ['import.meta.env', /\bimport\s*\.\s*meta\s*\??\.\s*env\b/g], +]; + +// Les chargements du lanceur admis, chacun par son fichier et son +// spécificateur. test/lanceur.js réexporte test et describe, le point que +// change le repli sous node --test (§ 14.8). vitest.config.js n'est pas une +// épreuve : c'est la configuration du lanceur, que lisent les gardes qui +// l'éprouvent — motifs de surveillance, séries selon la machine, rapporteur, +// options de Chromium — et que les épreuves chargent telle que le lanceur la +// charge ; sous node --test, ces gardes mêmes la lisent. +const LANCEUR_ADMIS = [ + { + fichier: join('test', 'lanceur.js'), + specificateur: 'vitest', + raison: 'réexporte test et describe de vitest ; remplacer sa ligne fait tourner la série sous node --test', + }, + { + fichier: 'vitest.config.js', + specificateur: 'vitest/config', + raison: 'defineConfig, de la configuration du lanceur que les gardes chargent', + }, + { + fichier: 'vitest.config.js', + specificateur: '@vitest/browser-playwright', + raison: 'le pilote du projet navigateur, de la configuration du lanceur que les gardes chargent', + }, +]; + +// Relevé de l'écriture des épreuves node et node-long (§ 14.8) : chaque +// *.test.js du projet hors des épreuves du navigateur, et chaque module +// JavaScript qu'ils importent, à toute profondeur, par un chemin relatif ou +// absolu depuis la racine — le parcours ne suit ni un composant ni un autre +// fichier. Sur le texte sans chaînes, il relève les assertions de Vitest et +// l'objet env d'import.meta ; parmi les chargements dont le mot-clé +// subsiste dans ce texte, le lanceur d'épreuves, un spécificateur à suffixe +// de Vite — « ?raw » comme tout autre — et un chemin sans extension. Chaque +// refus se lit « accès:ligne refus », l'accès étant la suite des fichiers +// qui y mène depuis une épreuve, comme dans releverCouche ; suit une ligne +// « exception sans objet : fichier spécificateur » par chargement admis +// qu'aucun fichier parcouru ne fait. Lève quand le projet ne porte aucune +// épreuve node. +function releverEcritureDesEpreuves(racine, admis = LANCEUR_ADMIS) { + const epreuves = fichiersDuProjet(racine).filter((f) => EPREUVE.test(f) && !EPREUVE_NAVIGATEUR.test(f)); + assert.ok(epreuves.length > 0, 'aucune épreuve node'); + const aParcourir = [...epreuves]; + const acces = new Map(epreuves.map((fichier) => [fichier, relative(racine, fichier)])); + const servies = new Set(); + const refus = []; + for (let i = 0; i < aParcourir.length; i += 1) { + const fichier = aParcourir[i]; + const nom = relative(racine, fichier); + const texte = readFileSync(fichier, 'utf8'); + const code = sansChaines(texte); + const trouves = ECRITURE_NODE.flatMap(([motif, expression]) => + [...code.matchAll(expression)].map(({ index }) => ({ indice: index, motif })), + ); + for (const { indice, specificateur } of chargementsLitteraux(texte)) { + if (!estDuCode(code, indice)) continue; + const cible = cibleDe(racine, fichier, specificateur); + if (LANCEUR_D_EPREUVES.test(specificateur)) { + const exception = admis.find((e) => e.fichier === nom && e.specificateur === specificateur); + if (exception === undefined) trouves.push({ indice, motif: `import ${specificateur}` }); + else servies.add(exception); + } else if (specificateur.includes('?')) { + trouves.push({ indice, motif: `import ${specificateur} : suffixe de Vite` }); + } else if (cible !== null && extname(cible) === '') { + trouves.push({ indice, motif: `import ${specificateur} sans extension` }); + } else if ( + cible !== null && + !acces.has(cible) && + EXTENSIONS_MODULE.has(extname(cible)) && + !cible.split(sep).includes('node_modules') && + existsSync(cible) + ) { + acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`); + aParcourir.push(cible); + } + } + trouves.sort((a, b) => a.indice - b.indice); + refus.push(...trouves.map(({ indice, motif }) => `${acces.get(fichier)}:${ligneDe(texte, indice)} ${motif}`)); + } + const sansObjet = admis + .filter((e) => !servies.has(e)) + .map(({ fichier, specificateur }) => `exception sans objet : ${fichier} ${specificateur}`); + return [...refus, ...sansObjet]; +} + +// Ce qu'une épreuve du navigateur n'asserte pas : la présence d'une classe, +// qui ne dit rien de ce qui est peint (§ 14.2). +const ASSERTIONS_DE_CLASSE = [ + ['classList.contains', /\bclassList\s*\??\.\s*contains\b/g], + ['toHaveClass', /\btoHaveClass\b/g], + ['className', /\bclassName\b/g], +]; + +// Les délais fixes, chacun par son appel : une épreuve attend un état, un +// événement ou une image, jamais une durée (§ 14.14, § 19.3). Le module des +// minuteurs de Node se relève à son chargement. Un setTimeout de zéro est un +// délai : Node le porte à une milliseconde. setImmediate n'en est pas un : il +// ne prend aucune durée, et rappelle une fois vidées les microtâches en +// attente — une épreuve node l'attend pour que les promesses lancées sans +// être rendues s'achèvent avant ses assertions. +const DELAIS = [ + ['setTimeout', /\bsetTimeout\s*(?:\?\.)?\s*\(/g], + ['setInterval', /\bsetInterval\s*(?:\?\.)?\s*\(/g], + ['waitForTimeout', /\bwaitForTimeout\s*(?:\?\.)?\s*\(/g], + ['sleep', /\bsleep\s*(?:\?\.)?\s*\(/g], +]; +const MINUTEURS_DE_NODE = /^(?:node:)?timers(?:\/promises)?$/; + +// Ce que relèvent, dans un texte et son code sans chaînes, les motifs donnés +// et les chargements du module des minuteurs dont le mot-clé subsiste dans +// le code : chacun { ligne, motif }, par ligne, puis dans l'ordre des +// motifs, le chargement après eux. +function delaisEt(texte, code, motifs) { + const minuteurs = chargementsLitteraux(texte) + .filter(({ indice, specificateur }) => estDuCode(code, indice) && MINUTEURS_DE_NODE.test(specificateur)) + .map(({ indice, specificateur }) => ({ ligne: ligneDe(texte, indice), rang: motifs.length, motif: `import ${specificateur}` })); + return [ + ...motifs.flatMap(([motif, expression], rang) => + [...code.matchAll(expression)].map(({ index }) => ({ ligne: ligneDe(texte, index), rang, motif })), + ), + ...minuteurs, + ].sort((a, b) => a.ligne - b.ligne || a.rang - b.rang); +} + +// Relevé des épreuves du navigateur, chaque *.navigateur.test.js du +// projet : une assertion de classe ou un délai fixe, « fichier:ligne +// refus ». Lève quand le projet n'en porte aucune. +function releverEpreuvesNavigateur(racine) { + const epreuves = fichiersDuProjet(racine).filter((f) => EPREUVE_NAVIGATEUR.test(f)); + assert.ok(epreuves.length > 0, 'aucune épreuve du navigateur'); + return epreuves.flatMap((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + return delaisEt(texte, sansChaines(texte), [...ASSERTIONS_DE_CLASSE, ...DELAIS]).map( + ({ ligne, motif }) => `${relative(racine, fichier)}:${ligne} ${motif}`, + ); + }); +} + +describe('arborescence : écriture des épreuves (§ 14.8, § 14.2)', () => { + test('les épreuves node, et les modules qu’elles importent, n’emploient ni les assertions de Vitest, ni le lanceur hors de test/lanceur.js, ni syntaxe propre à Vite, ni chemin sans extension', () => { + assert.deepEqual(releverEcritureDesEpreuves(RACINE), []); + }); + + test('les épreuves du navigateur n’assertent aucune classe et n’attendent aucun délai fixe', () => { + assert.deepEqual(releverEpreuvesNavigateur(RACINE), []); + }); + + test('la garde des épreuves node relève chaque refus par sa ligne, dans les épreuves et les modules qu’elles importent, et non dans une chaîne ; un chargement admis ne vaut que pour son fichier et son spécificateur ; une exception sans objet se relève', () => { + const fichiers = { + 'test/lanceur.js': "export { test, describe } from 'vitest';\nexport { vi } from 'vitest/node';\n", + 'test/config.test.js': "import configuration from '../vitest.config.js';\n", + 'vitest.config.js': [ + "import { defineConfig } from 'vitest/config';", + "import { playwright } from '@vitest/browser-playwright';", + "import { createVitest } from 'vitest/node';", + "import { expect } from 'vitest';", + ].join('\n'), + 'src/moteur/a.test.js': [ + "import { test } from '../../test/lanceur.js';", + "import { expect } from 'vitest';", + "import { describe } from 'vitest/node';", + `import { page } from ${"'"}@vitest/browser/context${"'"};`, + "import gabarit from './gabarit.txt?raw';", + "import { aide } from './aide';", + "import { outil } from './outil.js';", + 'expect(1).toBe(1);', + 'const mode = import.meta.env.MODE;', + "const donnee = \"} from 'vitest';\" + 'expect(2)' + `import.meta.env`;", + '// import.meta.env se relève aussi dans un commentaire', + 'const voisin = attendu.expected + importMeta.env + expectation;', + ].join('\n'), + 'src/moteur/outil.js': "import { profond } from './sous/profond.js';\nimport Composant from './Composant.svelte';\n", + 'src/moteur/sous/profond.js': 'export const profond = import.meta.env.DEV;\n', + 'src/moteur/Composant.svelte': '\n', + 'src/moteur/sous/b.long.test.js': "const v = await import('vitest');\n", + 'src/interface/A.navigateur.test.js': "import { expect } from 'vitest';\nexpect(1).toBe(1);\n", + 'node_modules/p/x.test.js': "import { expect } from 'vitest';\n", + 'www/y.test.js': "import { expect } from 'vitest';\n", + }; + assert.deepEqual(avecArbre(fichiers, releverEcritureDesEpreuves), [ + 'src/moteur/a.test.js:2 import vitest', + 'src/moteur/a.test.js:3 import vitest/node', + 'src/moteur/a.test.js:4 import @vitest/browser/context', + 'src/moteur/a.test.js:5 import ./gabarit.txt?raw : suffixe de Vite', + 'src/moteur/a.test.js:6 import ./aide sans extension', + 'src/moteur/a.test.js:8 expect', + 'src/moteur/a.test.js:9 import.meta.env', + 'src/moteur/a.test.js:11 import.meta.env', + 'src/moteur/sous/b.long.test.js:1 import vitest', + 'src/moteur/a.test.js → test/lanceur.js:2 import vitest/node', + 'test/config.test.js → vitest.config.js:3 import vitest/node', + 'test/config.test.js → vitest.config.js:4 import vitest', + 'src/moteur/a.test.js → src/moteur/outil.js → src/moteur/sous/profond.js:1 import.meta.env', + ]); + const sansLanceur = { + ...fichiers, + 'test/lanceur.js': "export { test, describe } from 'node:test';\n", + 'test/config.test.js': "import { test } from './lanceur.js';\n", + }; + assert.deepEqual(avecArbre(sansLanceur, releverEcritureDesEpreuves).slice(-3), [ + `exception sans objet : ${join('test', 'lanceur.js')} vitest`, + 'exception sans objet : vitest.config.js vitest/config', + 'exception sans objet : vitest.config.js @vitest/browser-playwright', + ]); + assert.throws(() => avecArbre({ 'src/a.js': '', 'src/A.navigateur.test.js': '' }, releverEcritureDesEpreuves), /aucune épreuve node/); + }); + + test('la garde des épreuves du navigateur relève une assertion de classe et un délai fixe par leur ligne, et non dans une chaîne', () => { + const fichiers = { + 'src/interface/plan/Plan.navigateur.test.js': [ + "expect(table.classList.contains('conflit')).toBe(true);", + "await expect.element(table).toHaveClass('conflit');", + "expect(table.className).toContain('conflit');", + 'await new Promise((fin) => setTimeout(fin, 50));', + 'await page.waitForTimeout(100);', + 'await sleep(20);', + "import { setTimeout as attendre } from 'node:timers/promises';", + "const mot = 'setTimeout(f) className';", + 'await new Promise(requestAnimationFrame);', + 'const style = getComputedStyle(table).backgroundColor;', + ].join('\n'), + 'test/navigateur/entrees.navigateur.test.js': 'setInterval(f, 10);\n', + 'src/interface/plan/gestes.test.js': 'await new Promise((fin) => setTimeout(fin, 50));\n', + }; + assert.deepEqual(avecArbre(fichiers, releverEpreuvesNavigateur), [ + 'src/interface/plan/Plan.navigateur.test.js:1 classList.contains', + 'src/interface/plan/Plan.navigateur.test.js:2 toHaveClass', + 'src/interface/plan/Plan.navigateur.test.js:3 className', + 'src/interface/plan/Plan.navigateur.test.js:4 setTimeout', + 'src/interface/plan/Plan.navigateur.test.js:5 waitForTimeout', + 'src/interface/plan/Plan.navigateur.test.js:6 sleep', + 'src/interface/plan/Plan.navigateur.test.js:7 import node:timers/promises', + 'test/navigateur/entrees.navigateur.test.js:1 setInterval', + ]); + assert.throws(() => avecArbre({ 'src/a.test.js': '' }, releverEpreuvesNavigateur), /aucune épreuve du navigateur/); + }); +}); + +// Le projet de la série surveillée dans une configuration de Vitest. +const projetSurveille = (projets) => projets.find(({ test: { name } }) => name === 'node').test; + +// Épreuves d'un projet de Vitest, en chemins absolus triés : les fichiers du +// projet que l'un de ses motifs include désigne et qu'aucun motif exclude +// n'écarte, lus relatifs à la racine. +function epreuvesDuProjet(racine, { include = [], exclude = [] }) { + const epreuves = fichiersDuProjet(racine).filter((fichier) => { + const chemin = enBarres(racine, fichier); + return repondA(chemin, include) && !repondA(chemin, exclude); + }); + assert.ok(epreuves.length > 0, 'la série surveillée ne porte aucune épreuve'); + return epreuves; +} + +// Ce que la série surveillée n'appelle pas : la géométrie du rendu, qu'aucun +// environnement node n'implémente (§ 14.4). +const GEOMETRIE_DU_RENDU = [ + ['getBBox', /\bgetBBox\s*(?:\?\.)?\s*\(/g], + ['getScreenCTM', /\bgetScreenCTM\s*(?:\?\.)?\s*\(/g], + ['getComputedTextLength', /\bgetComputedTextLength\s*(?:\?\.)?\s*\(/g], +]; + +// Les fonctions d'écriture de node:fs et de node:fs/promises, et leurs formes +// synchrones. Un import du module entier — par défaut, par espace de noms, par +// require ou import() sans déstructuration, ou de son objet promises — les +// porte toutes. +const MODULE_FS = /^(?:node:)?fs(?:\/promises)?$/; +const ECRITURES_FS = new Set( + ['writeFile', 'appendFile', 'mkdir', 'rm', 'rename', 'copyFile', 'cp', 'symlink', 'unlink', 'createWriteStream'].flatMap( + (nom) => [nom, `${nom}Sync`], + ), +); +const TEMPORAIRE = /\bmkdtemp(?:Sync)?\s*(?:\?\.)?\s*\(/; + +// Noms d'une liste « a, b as c » ou « a, b: c », tels que le module les +// exporte. +const nomsImportes = (liste) => + liste + .split(',') + .map((nom) => nom.trim().split(/\s+as\s+|\s*:\s*/)[0]) + .filter((nom) => nom !== ''); + +// Imports d'écriture de node:fs d'un texte, chacun { indice, ecrit } : ecrit +// nomme les fonctions d'écriture importées par leur nom, ou « entier » pour +// le module entier. Un chargement dont le mot-clé ne subsiste pas dans le +// texte sans chaînes n'en est pas un, ni l'import du module seul. +function importsDEcriture(texte, code) { + return chargementsLitteraux(texte).flatMap(({ indice, specificateur }) => { + if (!MODULE_FS.test(specificateur) || !estDuCode(code, indice)) return []; + let noms; + if (code.startsWith('from', indice)) { + const clause = code.slice(code.lastIndexOf('import', indice) + 'import'.length, indice); + const accolades = /\{([^}]*)\}/.exec(clause); + const reste = clause.replace(/\{[^}]*\}/, '').replace(/[\s,]/g, ''); + noms = accolades !== null && reste === '' ? nomsImportes(accolades[1]) : null; + } else if (/^import\s+['"]/.test(texte.slice(indice))) { + return []; + } else { + const avant = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+)?$/.exec(code.slice(0, indice)); + noms = avant === null ? null : nomsImportes(avant[1]); + } + if (noms?.some((nom) => nom === 'promises' || nom === 'default')) noms = null; + if (noms === null) return [{ indice, ecrit: `${specificateur} entier` }]; + const ecrits = noms.filter((nom) => ECRITURES_FS.has(nom)); + return ecrits.length === 0 ? [] : [{ indice, ecrit: `${specificateur} ${ecrits.join(', ')}` }]; + }); +} + +// La commande surveillée : vitest sur le seul projet node (§ 14.4). +const COMMANDES_SURVEILLEES = new Set(['vitest --project node', 'vitest --project=node']); + +// Relevé de la série surveillée (§ 14.4, § 14.14), sur les épreuves du +// projet node donné : par épreuve, puis par ligne, un délai fixe, un appel de +// la géométrie du rendu, et un import d'écriture de node:fs dans un fichier +// qui n'appelle ni mkdtemp ni mkdtempSync, « fichier:ligne écrit sans +// mkdtemp : module fonctions » ; puis le script test:surveille de +// package.json quand il lance autre chose que vitest sur le projet node. La +// garde voit l'import, non le chemin : l'écriture hors du temporaire d'un +// fichier qui en crée un reste à la revue. Lève quand la série ne porte +// aucune épreuve. +function releverSerieSurveillee(racine, projet) { + const refus = epreuvesDuProjet(racine, projet).flatMap((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + const code = sansChaines(texte); + const ecritures = TEMPORAIRE.test(code) + ? [] + : importsDEcriture(texte, code).map(({ indice, ecrit }) => ({ + ligne: ligneDe(texte, indice), + motif: `écrit sans mkdtemp : ${ecrit}`, + })); + return [...ecritures, ...delaisEt(texte, code, [...DELAIS, ...GEOMETRIE_DU_RENDU])] + .sort((a, b) => a.ligne - b.ligne) + .map(({ ligne, motif }) => `${relative(racine, fichier)}:${ligne} ${motif}`); + }); + const { scripts = {} } = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8')); + const surveille = (scripts['test:surveille'] ?? '').trim().split(/\s+/).join(' '); + if (!COMMANDES_SURVEILLEES.has(surveille)) { + refus.push(`package.json test:surveille : « ${surveille} » ne lance pas vitest --project node seul`); + } + return refus; +} + +describe('arborescence : la série surveillée (§ 14.4, § 14.14)', () => { + test("aucune épreuve de la série surveillée n'attend un délai, n'appelle la géométrie du rendu, ni n'importe une écriture de node:fs sans créer de temporaire ; test:surveille ne lance que le projet node", () => { + assert.deepEqual(releverSerieSurveillee(RACINE, projetSurveille(configuration.test.projects)), []); + }); + + test('la garde relève chaque refus par sa ligne, dans les seules épreuves du projet, et le script surveillé qui lance autre chose', () => { + const projet = { + include: ['src/**/*.test.js', 'test/**/*.test.js'], + exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'src/moteur/lourde.test.js'], + }; + const fichiers = { + 'package.json': JSON.stringify({ scripts: { 'test:surveille': 'vitest --project node' } }), + 'src/geometrie/vue.test.js': [ + "import { writeFileSync, readFileSync as lire } from 'node:fs';", + 'await new Promise((fin) => setTimeout(fin, 0));', + 'const boite = element.getBBox();', + 'const ctm = svg?.getScreenCTM?.();', + "// au navigateur, getComputedTextLength mesure le texte ; ce commentaire ne l'appelle pas", + "const longueur = texte.getComputedTextLength() + 'getBBox()'.length;", + "import { setTimeout as attendre } from 'node:timers/promises';", + 'await new Promise((fin) => setImmediate(fin));', + ].join('\n'), + 'test/ecrit.test.js': [ + "import fs from 'node:fs';", + "import * as fsp from 'node:fs/promises';", + "import { promises } from 'fs';", + "const { rmSync, existsSync } = require('node:fs');", + "const module = await import('node:fs');", + "import { mkdir as creer, cpSync } from 'fs/promises';", + "import 'node:fs';", + "const donnee = \"import { writeFileSync } from 'node:fs';\";", + ].join('\n'), + 'test/temporaire.test.js': [ + "import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';", + "const racine = mkdtempSync('arbre-');", + ].join('\n'), + 'test/lecture.test.js': "import { readFileSync, existsSync } from 'node:fs';\nconst { statSync } = require('fs');\n", + 'src/moteur/lourde.test.js': 'await new Promise((fin) => setTimeout(fin, 0));\n', + 'src/moteur/a.long.test.js': "import { writeFileSync } from 'node:fs';\n", + 'src/interface/A.navigateur.test.js': 'element.getBBox();\n', + 'src/stockage/ecrire.js': "import { writeFileSync } from 'node:fs';\n", + }; + assert.deepEqual(avecArbre(fichiers, (racine) => releverSerieSurveillee(racine, projet)), [ + 'src/geometrie/vue.test.js:1 écrit sans mkdtemp : node:fs writeFileSync', + 'src/geometrie/vue.test.js:2 setTimeout', + 'src/geometrie/vue.test.js:3 getBBox', + 'src/geometrie/vue.test.js:4 getScreenCTM', + 'src/geometrie/vue.test.js:6 getComputedTextLength', + 'src/geometrie/vue.test.js:7 import node:timers/promises', + 'test/ecrit.test.js:1 écrit sans mkdtemp : node:fs entier', + 'test/ecrit.test.js:2 écrit sans mkdtemp : node:fs/promises entier', + 'test/ecrit.test.js:3 écrit sans mkdtemp : fs entier', + 'test/ecrit.test.js:4 écrit sans mkdtemp : node:fs rmSync', + 'test/ecrit.test.js:5 écrit sans mkdtemp : node:fs entier', + 'test/ecrit.test.js:6 écrit sans mkdtemp : fs/promises mkdir, cpSync', + ]); + for (const script of ['vitest', 'vitest --project node --project navigateur', 'vitest run --project node']) { + const autre = { ...fichiers, 'package.json': JSON.stringify({ scripts: { 'test:surveille': script } }) }; + assert.deepEqual( + avecArbre(autre, (racine) => releverSerieSurveillee(racine, projet)).at(-1), + `package.json test:surveille : « ${script} » ne lance pas vitest --project node seul`, + script, + ); + } + assert.throws( + () => avecArbre({ 'package.json': '{}', 'src/a.long.test.js': '' }, (racine) => releverSerieSurveillee(racine, projet)), + /la série surveillée ne porte aucune épreuve/, + ); + }); +}); + +// Ce que relance au moins un changement de composant de src/interface, et +// d'une feuille de style de src/interface, quoi que lise le texte des +// épreuves : les gardes des sources de l'interface, des couches et de la +// palette, et celles dont le texte ne lie pas la lecture au fichier — le +// contrôle de version balaie tout l'arbre src sans filtre d'extension, +// test/projets.test.js importe App pour en voir l'échec sous node, quel que +// soit le composant changé. Les épreuves qui lisent ces fichiers par le +// disque s'y ajoutent (lecteursParLeDisque). +const RELANCES_REQUISES_D_UN_COMPOSANT = [ + 'test/arborescence.test.js', + 'test/sources_interface.test.js', + 'scripts/version.test.js', + 'test/projets.test.js', +]; +const RELANCES_REQUISES_D_UNE_FEUILLE = [ + 'src/interface/palette.test.js', + 'test/sources_interface.test.js', + 'scripts/version.test.js', +]; + +// Une table de libellés : un module d'un dossier libelles/ de l'application +// ou de l'interface. Les épreuves l'importent ; le graphe suffit. +const TABLE_DE_LIBELLES = /^src\/(?:application|interface)\/libelles\/[^/]+\.[cm]?js$/; + +// Fichiers que chaque épreuve donnée importe, à toute profondeur, par un +// chargement dont le mot-clé subsiste dans le texte sans chaînes et dont le +// chemin, relatif ou absolu depuis la racine, désigne un fichier existant — +// suffixe de Vite retiré. Le parcours suit les modules JavaScript hors de +// node_modules ; un composant ou une feuille de style est importé sans être +// parcouru, comme Vitest sous node, sans greffon Svelte. Rend une Map +// fichier relatif → épreuves relatives qui l'importent. +function importateurs(racine, epreuves) { + const voisins = new Map(); + const cibles = (fichier) => { + if (!voisins.has(fichier)) { + const texte = readFileSync(fichier, 'utf8'); + const code = sansChaines(texte); + voisins.set( + fichier, + chargementsLitteraux(texte) + .filter(({ indice }) => estDuCode(code, indice)) + .map(({ specificateur }) => cibleDe(racine, fichier, specificateur.replace(/\?.*$/, ''))) + .filter((cible) => cible !== null && statSync(cible, { throwIfNoEntry: false })?.isFile()), + ); + } + return voisins.get(fichier); + }; + const parFichier = new Map(); + for (const epreuve of epreuves) { + const vus = new Set([epreuve]); + const aParcourir = [epreuve]; + for (let i = 0; i < aParcourir.length; i += 1) { + for (const cible of cibles(aParcourir[i])) { + if (vus.has(cible)) continue; + vus.add(cible); + const chemin = enBarres(racine, cible); + parFichier.set(chemin, [...(parFichier.get(chemin) ?? []), enBarres(racine, epreuve)]); + if (EXTENSIONS_MODULE.has(extname(cible)) && !cible.split(sep).includes('node_modules')) aParcourir.push(cible); + } + } + } + return parFichier; +} + +// Contenus des chaînes littérales d'un texte, lus aux places que sansChaines +// blanchit : entre deux délimiteurs, ou entre un délimiteur et la +// substitution d'un gabarit. Une place que sansChaines garde telle quelle — +// un commentaire entre deux apostrophes — ne rend que des blancs, écartés. +function chainesDe(texte) { + const code = sansChaines(texte); + return [...code.matchAll(/[`'"}]( +)(?=[`'"]|\$\{)/g)] + .map(({ 1: blancs, index }) => texte.slice(index + 1, index + 1 + blancs.length)) + .filter((contenu) => contenu.trim() !== ''); +} + +// Un appel qui lit un fichier, et un appel qui balaie un dossier, dans le +// texte sans chaînes d'un module. +const LECTURE_D_UN_FICHIER = /\b(?:readFileSync|readFile)\s*\(/; +const BALAYAGE_D_UN_DOSSIER = /\b(?:readdirSync|readdir|globSync|glob)\s*\(/; + +// Les épreuves qui lisent un fichier par le disque, hors de tout graphe +// d'imports. Une épreuve le lit quand elle-même, ou l'un des modules +// JavaScript du projet qu'elle importe à toute profondeur (parFichier, que +// rend importateurs) : lit un fichier et porte une chaîne littérale qui finit +// sur son nom (« ./jetons.css », « src/interface/jetons.css ») ; ou balaie +// un dossier et nomme son extension, en chaîne littérale exacte (« .css ») +// ou dans une expression régulière (« \.css », « \.(svelte|css) »). Le +// relevé est textuel : un nom de fichier dans les données d'une épreuve +// compte comme une lecture, d'où des noms inventés dans ces données ; un +// balayage sans filtre d'extension, comme celui du contrôle de version, ne se +// lie à aucun fichier et se déclare dans les relances requises. Rend une +// fonction : chemin relatif → Set des épreuves relatives qui le lisent. +function lecteursParLeDisque(racine, epreuves, parFichier) { + const chargeurs = new Map(epreuves.map((epreuve) => [enBarres(racine, epreuve), [enBarres(racine, epreuve)]])); + for (const [chemin, qui] of parFichier) { + if (!EXTENSIONS_MODULE.has(extname(chemin)) || chemin.split('/').includes('node_modules')) continue; + chargeurs.set(chemin, [...new Set([...(chargeurs.get(chemin) ?? []), ...qui])]); + } + const modules = [...chargeurs].map(([chemin, qui]) => { + const texte = readFileSync(join(racine, chemin), 'utf8'); + const code = sansChaines(texte); + return { qui, code, chaines: chainesDe(texte), lit: LECTURE_D_UN_FICHIER.test(code), balaie: BALAYAGE_D_UN_DOSSIER.test(code) }; + }); + return (fichier) => { + const nom = basename(fichier); + const extension = extname(fichier); + const enExpression = new RegExp(String.raw`(? + (lit && chaines.some((contenu) => contenu === nom || contenu.endsWith(`/${nom}`))) || + (balaie && (chaines.includes(extension) || enExpression.test(code))), + ); + return new Set(lisent.flatMap(({ qui }) => qui)); + }; +} + +// Relevé des motifs de surveillance (§ 14.14) d'une configuration de Vitest +// — { watchTriggerPatterns, projects } — sur l'arbre donné. Un fichier qui +// répond à un motif ne relance que les épreuves que le motif nomme, et plus +// celles qui l'importent : par motif, dans l'ordre de la liste, chaque +// fichier du projet qui y répond et qu'importe une épreuve de la série +// surveillée que le motif ne relance pas, « motif n /…/ : fichier, qu'importe +// épreuve, ne la relance pas » ; chaque table de libellés qu'il nomme ; +// chaque épreuve qu'il relance et que l'arbre n'a pas. Puis les relances +// requises des composants (.svelte) de src/interface, et de ses feuilles de +// style (.css) : pour chaque fichier, celles de RELANCES_REQUISES_D_UN_COMPOSANT +// ou de RELANCES_REQUISES_D_UNE_FEUILLE, et chaque épreuve de la série +// surveillée qui le lit par le disque (lecteursParLeDisque). Par épreuve, +// les requises d'abord, puis les autres triées : « aucun des n composants de +// src/interface ne relance épreuve » quand tous la requièrent et qu'aucun ne +// la relance, sinon « fichiers ne relance pas épreuve ». Lève quand la +// configuration ne porte aucun motif, ou que src/interface n'a aucun +// composant ou aucune feuille de style. +function releverSurveillance(racine, { watchTriggerPatterns: motifs = [], projects }) { + assert.ok(motifs.length > 0, 'aucun motif de surveillance'); + const fichiers = fichiersDuProjet(racine).map((fichier) => enBarres(racine, fichier)); + const presents = new Set(fichiers); + const epreuves = epreuvesDuProjet(racine, projetSurveille(projects)); + const parFichier = importateurs(racine, epreuves); + const repond = ({ pattern }, chemin) => new RegExp(pattern.source, pattern.flags.replace('g', '')).test(chemin); + const relances = (motif, chemin) => [motif.testsToRun(chemin) ?? []].flat(); + const refus = []; + motifs.forEach((motif, rang) => { + const nom = `motif ${rang + 1} ${motif.pattern}`; + const absentes = new Set(); + for (const chemin of fichiers.filter((f) => repond(motif, f))) { + const relancees = relances(motif, chemin); + if (TABLE_DE_LIBELLES.test(chemin) && !EPREUVE.test(chemin)) refus.push(`${nom} : nomme la table de libellés ${chemin}`); + for (const epreuve of [...(parFichier.get(chemin) ?? [])].sort()) { + if (!relancees.includes(epreuve)) refus.push(`${nom} : ${chemin}, qu'importe ${epreuve}, ne la relance pas`); + } + for (const epreuve of relancees) if (!presents.has(epreuve)) absentes.add(epreuve); + } + refus.push(...[...absentes].sort().map((epreuve) => `${nom} : relance ${epreuve}, absente de l'arbre`)); + }); + const relanceesPar = (chemin) => new Set(motifs.filter((m) => repond(m, chemin)).flatMap((m) => relances(m, chemin))); + const lecteurs = lecteursParLeDisque(racine, epreuves, parFichier); + const deSrcInterface = (extension) => fichiers.filter((f) => f.startsWith('src/interface/') && extname(f) === extension); + const familles = [ + { membres: deSrcInterface('.svelte'), requises: RELANCES_REQUISES_D_UN_COMPOSANT, vide: 'aucun composant', tous: 'composants' }, + { membres: deSrcInterface('.css'), requises: RELANCES_REQUISES_D_UNE_FEUILLE, vide: 'aucune feuille de style', tous: null }, + ]; + for (const { membres, requises, vide, tous } of familles) { + assert.ok(membres.length > 0, `src/interface ne fournit ${vide}`); + const exigees = new Map(membres.map((f) => [f, new Set([...requises, ...lecteurs(f)])])); + const autres = [...new Set([...exigees.values()].flatMap((s) => [...s]))].filter((e) => !requises.includes(e)).sort(); + for (const epreuve of [...requises, ...autres]) { + const concernes = membres.filter((f) => exigees.get(f).has(epreuve)); + const manquent = concernes.filter((f) => !relanceesPar(f).has(epreuve)); + if (manquent.length === 0) continue; + if (tous !== null && manquent.length === membres.length && membres.length > 1) { + refus.push(`aucun des ${membres.length} ${tous} de src/interface ne relance ${epreuve}`); + } else { + refus.push(`${manquent.join(', ')} ne relance pas ${epreuve}`); + } + } + } + return refus; +} + +describe('arborescence : les motifs de surveillance (§ 14.14)', () => { + test("un motif ne nomme aucun fichier qu'une épreuve importe sans qu'il la relance, ni une table de libellés ; les composants et les feuilles de style relancent les gardes requises et chaque épreuve qui les lit par le disque", () => { + assert.deepEqual(releverSurveillance(RACINE, configuration.test), []); + }); + + test('la garde relève un fichier importé que son motif ne relance pas, une table de libellés nommée, une épreuve absente, chaque relance requise qui manque et chaque lecteur par le disque non relancé', () => { + const projects = [{ test: { name: 'node', include: ['src/**/*.test.js', 'test/**/*.test.js'], exclude: ['**/*.navigateur.test.js'] } }]; + const fichiers = { + 'src/interface/App.svelte': '

\n', + 'src/interface/plan/Plan.svelte': '

\n', + 'src/interface/teintes.css': ':root {}\n', + 'src/interface/libelles/plan.js': 'export const TABLE = new Map();\n', + 'src/interface/palette.js': "import { TABLE } from './libelles/plan.js';\n", + 'src/interface/palette.test.js': "import { TABLE } from './palette.js';\nconst donnee = \"import x from './modele.js';\";\n", + 'src/interface/modele.js': 'export const x = 1;\n', + 'src/interface/App.navigateur.test.js': "import App from './App.svelte';\n", + // Lit la feuille par son nom. + 'src/interface/nuances.test.js': "import { readFileSync } from 'node:fs';\nconst feuille = readFileSync(new URL('./teintes.css', import.meta.url), 'utf8');\n", + // Balaie composants et feuilles par le module qu'elle importe. + 'test/entetes.test.js': "import { lireTout } from './balayage.js';\nlireTout();\n", + 'test/balayage.js': "import { readdirSync } from 'node:fs';\nexport const lireTout = () => readdirSync('src', { recursive: true }).filter((nom) => /\\.(svelte|css)$/.test(nom));\n", + // Nomme la feuille sans la lire, et lit une donnée qui la cite sans finir sur son nom. + 'src/interface/nomme.test.js': "const chemin = './teintes.css';\n", + 'src/interface/cite.test.js': "import { readFileSync } from 'node:fs';\nconst donnee = \" @import 'teintes.css';\";\nreadFileSync(donnee);\n", + 'test/projets.test.js': "const App = await import('../src/interface/App.svelte?inline');\n", + 'test/arborescence.test.js': '', + 'scripts/version.test.js': '', + }; + const motifs = [ + { pattern: /\.svelte$/, testsToRun: () => ['test/arborescence.test.js', 'scripts/version.test.js'] }, + { pattern: /(^|\/)src\/interface\/libelles\/[^/]+$/, testsToRun: () => 'src/interface/palette.test.js' }, + { pattern: /(^|\/)src\/interface\/(teintes\.css|modele\.js)$/, testsToRun: () => ['test/sources_interface.test.js'] }, + ]; + assert.deepEqual(avecArbre(fichiers, (racine) => releverSurveillance(racine, { watchTriggerPatterns: motifs, projects })), [ + "motif 1 /\\.svelte$/ : src/interface/App.svelte, qu'importe test/projets.test.js, ne la relance pas", + 'motif 2 /(^|\\/)src\\/interface\\/libelles\\/[^/]+$/ : nomme la table de libellés src/interface/libelles/plan.js', + "motif 3 /(^|\\/)src\\/interface\\/(teintes\\.css|modele\\.js)$/ : relance test/sources_interface.test.js, absente de l'arbre", + 'aucun des 2 composants de src/interface ne relance test/sources_interface.test.js', + 'aucun des 2 composants de src/interface ne relance test/projets.test.js', + 'aucun des 2 composants de src/interface ne relance test/entetes.test.js', + 'src/interface/teintes.css ne relance pas src/interface/palette.test.js', + 'src/interface/teintes.css ne relance pas scripts/version.test.js', + 'src/interface/teintes.css ne relance pas src/interface/nuances.test.js', + 'src/interface/teintes.css ne relance pas test/entetes.test.js', + ]); + const conformes = [ + { pattern: /(^|\/)src\/interface\/.*\.svelte$/, testsToRun: () => [...RELANCES_REQUISES_D_UN_COMPOSANT, 'test/entetes.test.js'] }, + { pattern: /(^|\/)src\/interface\/teintes\.css$/, + testsToRun: () => [...RELANCES_REQUISES_D_UNE_FEUILLE, 'src/interface/nuances.test.js', 'test/entetes.test.js'] }, + { pattern: /(^|\/)src\/interface\/plan\/Plan\.svelte$/, testsToRun: () => [] }, + ]; + const complets = { ...fichiers, 'test/sources_interface.test.js': '' }; + assert.deepEqual(avecArbre(complets, (racine) => releverSurveillance(racine, { watchTriggerPatterns: conformes, projects })), []); + const partiel = [ + { pattern: /App\.svelte$/, testsToRun: () => [...RELANCES_REQUISES_D_UN_COMPOSANT, 'test/entetes.test.js'] }, + conformes[1], + ]; + assert.deepEqual( + avecArbre(complets, (racine) => releverSurveillance(racine, { watchTriggerPatterns: partiel, projects })), + [...RELANCES_REQUISES_D_UN_COMPOSANT, 'test/entetes.test.js'].map((requise) => `src/interface/plan/Plan.svelte ne relance pas ${requise}`), + ); + const { 'src/interface/teintes.css': _feuille, ...sansFeuille } = complets; + assert.throws( + () => avecArbre(sansFeuille, (racine) => releverSurveillance(racine, { watchTriggerPatterns: conformes, projects })), + /src\/interface ne fournit aucune feuille de style/, + ); + assert.throws(() => releverSurveillance(RACINE, { watchTriggerPatterns: [], projects }), /aucun motif de surveillance/); + }); +}); diff --git a/test/chargements.js b/test/chargements.js index e3a8379..fe2de15 100644 --- a/test/chargements.js +++ b/test/chargements.js @@ -4,7 +4,8 @@ // Analyseur des chargements qu'un texte demande, seul lu par les gardes de // l'arborescence (test/arborescence.test.js) et du livrable // (test/livrable.test.js) : import et export statiques, import seul, import() -// et require(). +// et require(). Un composant Svelte se lit par le texte de ses blocs de +// script, que rend scriptsDe. // // Le relevé lit le texte entier, commentaires et chaînes compris. Une // instruction à « from » se lit à partir de ce mot, que seuls import et @@ -38,3 +39,38 @@ export function chargements(texte) { ) .sort((a, b) => a.indice - b.indice); } + +// Un bloc de script d'un composant Svelte : une balise , ou la fin +// du texte quand le bloc ne se ferme pas. Un commentaire HTML +// est reconnu pour être sauté : une balise qui y figure n'ouvre aucun bloc. +const BLOC_OU_COMMENTAIRE = /|$)|]*)?>(?[\s\S]*?)(?:<\/script\s*>|$)/g; + +// Étendues { debut, fin } du contenu des blocs de script d'un composant +// Svelte, balises exclues, dans l'ordre du texte : debut est l'indice du +// premier caractère du contenu, fin celui qui suit son dernier. +export function blocsScript(texte) { + return [...texte.matchAll(BLOC_OU_COMMENTAIRE)] + .filter(({ groups }) => groups.contenu !== undefined) + .map(({ 0: lu, index, groups }) => { + const debut = index + lu.indexOf('>') + 1; + return { debut, fin: debut + groups.contenu.length }; + }); +} + +// Le texte des blocs de script d'un composant Svelte, à la longueur du +// composant : chaque caractère hors de ces blocs devient une espace, fins de +// ligne gardées, si bien que les indices et les numéros de ligne restent +// ceux du composant. Le balisage, le style et les expressions du gabarit ne +// sont pas lus : un composant ne charge un module que dans un bloc de script, +// et le texte d'un nœud — « import » dans une phrase — n'en charge aucun. +export function scriptsDe(texte) { + const blanc = (morceau) => morceau.replace(/[^\n]/g, ' '); + let sortie = ''; + let fin = 0; + for (const bloc of blocsScript(texte)) { + sortie += blanc(texte.slice(fin, bloc.debut)) + texte.slice(bloc.debut, bloc.fin); + fin = bloc.fin; + } + return sortie + blanc(texte.slice(fin)); +} diff --git a/test/chargements.test.js b/test/chargements.test.js index 74372bc..0bb6a32 100644 --- a/test/chargements.test.js +++ b/test/chargements.test.js @@ -3,11 +3,12 @@ // L'analyseur des chargements, test/chargements.js, que lisent les gardes de // l'arborescence et du livrable. Chaque épreuve donne un texte et les -// chargements qu'il demande, dans l'ordre du texte. Les spécificateurs nus +// chargements qu'il demande, dans l'ordre du texte, ou un composant Svelte et +// le texte de ses blocs de script. Les spécificateurs nus // des données sont des paquets de devDependencies ou des modules de Node : // la garde des dépendances lit aussi ce fichier. import assert from 'node:assert/strict'; -import { chargements } from './chargements.js'; +import { blocsScript, chargements, scriptsDe } from './chargements.js'; import { describe, test } from './lanceur.js'; // Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné. @@ -89,3 +90,53 @@ describe('chargements', () => { assert.deepEqual(lus(texte), []); }); }); + +describe('chargements : les blocs de script d’un composant Svelte', () => { + // Un composant à deux blocs, dont l'un sous attribut, du balisage qui écrit + // « import » en texte et en expression, un style à @import, et une balise + // de script en commentaire HTML. + const COMPOSANT = [ + '', + '', + '', + '', + "", + "

import { faux } from './texte.js';

", + "

{import('./expression.js')}

", + '', + ].join('\n'); + + test('le texte rendu garde la longueur et les lignes du composant ; seuls les blocs de script y restent', () => { + const scripts = scriptsDe(COMPOSANT); + assert.equal(scripts.length, COMPOSANT.length); + assert.equal(scripts.split('\n').length, COMPOSANT.split('\n').length); + assert.deepEqual(lus(scripts), ['2 ./module.js', '6 svelte', '7 ./Voisin.svelte']); + assert.deepEqual(scripts.split('\n')[5], " import { onMount } from 'svelte';"); + assert.match(scripts.split('\n')[10], /^ *$/); + }); + + test('les étendues désignent le contenu de chaque bloc, balises exclues, et un bloc non fermé court jusqu’à la fin', () => { + const blocs = blocsScript(COMPOSANT); + assert.equal(blocs.length, 2); + assert.deepEqual( + blocs.map(({ debut, fin }) => COMPOSANT.slice(debut, fin)), + ["\n import { tirer } from './module.js';\n", "\n import { onMount } from 'svelte';\n import Voisin from './Voisin.svelte';\n"], + ); + const ouvert = "

\n`; + 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', /(?(${source});`, { 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(`(? { + 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(`(? { + 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 === '' ? '' : `\n`}${gabarit}\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': '\n', + 'src/interface/plan/sous/B.svelte': composant({ + script: " const t = (cle) => cle;\n let x = $state(true);", + gabarit: [ + '

{t(\'k.titre\')} — {42}

', + '…{\'·\'} {t(\'k.svg\')}', + '', + '{`${x} places`}{x ?? \'Aucun\'}{x && \'Plein\'}', + '{x} / {x}', + '.', + '', + ].join('\n'), + style: ' .vide::after { content: "Fin"; }\n .vide::before { content: counter(rang) ". "; }', + }), + 'src/interface/C.test.js': "export const a = '';\n", + 'src/interface/D.navigateur.test.js': "export const a = '';\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: "

{t('k.present')}

\n

{t(`k.gabarit`)}

\n

{t(`k.${a}`)}

\n

{traduction.t(\"k.membre\")}

", + }), + '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': "

{tr('k.absente')}

\n", + 'src/interface/B.svelte': composant({ + script: " import { getContext } from 'svelte';\n const traduire = getContext('t');", + gabarit: "

{traduire('plan.x')}

\n

{t(cle)}

", + }), + '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: '', + 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: '\n', + }), + '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: '', + style: ' .danger { color: var(--couleur-conflit); }', + }), + 'src/interface/plan/Table.svelte': composant({ + gabarit: '1', + style: ' .table.conflit rect { stroke: var(--couleur-conflit); }\n .table rect { fill: var(--couleur-conflit); }', + }), + 'src/interface/plan/Trou.svelte': composant({ + gabarit: '', + 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é/); + }); +}); + +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: '

{@html nom}

\n {}} />', + }), + '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: [ + '{x}', + '

', + ].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: '

{Math.round(liste.length)}

\n

{liste.reduce((s, x) => s + x, 0)}

\n

{liste.sort().join()}

\n

{liste.filter(Boolean).length}

', + }), + '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: '', + style: ' .table { stroke-width: 1; }\n .table.conflit rect { stroke-width: 3; }', + }), + 'src/interface/plan/Table2.svelte': composant({ + script: ' let { conflit } = $props();', + gabarit: '', + 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é/); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index d13f2ef..58650b6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -22,10 +22,39 @@ import { coeursDisponibles, reglagesSeries } from './test/machine.js'; // Les séries node suivent le nombre de cœurs de la machine (test/machine.js) : // en deçà de quatre, des threads sur tous les cœurs, et les épreuves lourdes -// de la série surveillée passent dans la série longue (§ 14.14). -const SERIES = reglagesSeries(coeursDisponibles()); +// de la série surveillée passent dans la série longue (§ 14.14). Sous la +// mesure de la couverture, que process.argv annonce par --coverage, elles +// restent dans node, où se mesurent les seuils qu'elles couvrent. +const SERIES = reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') }); const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers }; +// Les épreuves node qui lisent les composants de src/interface par le +// système de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du +// plan, version affichée, graphe des couches —, et celle qui importe +// App.svelte pour en voir l'échec sous node. +const RELANCES_D_UN_COMPOSANT = Object.freeze([ + 'test/arborescence.test.js', + 'test/sources_interface.test.js', + 'scripts/version.test.js', + 'test/projets.test.js', + 'test/licence.test.js', + 'src/interface/palette.test.js', + 'src/interface/plan/ecouteurs.test.js', +]); + +// 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. +const RELANCES_DES_JETONS = Object.freeze([ + 'src/interface/palette.test.js', + 'src/interface/marques.test.js', + 'test/sources_interface.test.js', + 'test/licence.test.js', + 'scripts/version.test.js', + 'test/arborescence.test.js', +]); + export default defineConfig({ test: { // test/rapporteur.js imprime, après le bilan par défaut, la durée de @@ -65,23 +94,36 @@ export default defineConfig({ // système de fichiers ; la coquille s'exécute dans un processus à part, // la configuration d'electron-builder se charge par un require natif et // lit capacitor.config.json, et le script de construction se lance - // depuis une copie. Chaque motif rattache ses fichiers aux épreuves qui - // les lisent, et au contrôle de version quand son point 6 les balaie. Un - // fichier qui répond à un motif ne relance que les épreuves nommées, et - // plus celles qui l'importeraient : un motif ne nomme donc aucun fichier - // qu'une épreuve importe. L'option se lit au niveau du lanceur : posée - // dans un projet, elle est ignorée sans avertissement. + // depuis une copie. Les composants de src/interface et la feuille des + // jetons se lisent comme du texte par les gardes qui les balaient ; + // sous node, sans greffon Svelte, aucun graphe ne les parcourt. Chaque + // motif rattache ses fichiers aux épreuves qui les lisent, et au + // contrôle de version quand son point 6 les balaie. Un fichier qui + // répond à un motif ne relance que les épreuves nommées, et plus celles + // qui l'importeraient : un motif relance donc toute épreuve qui importe + // l'un de ses fichiers — test/fichiers_electron.test.js importe + // electron/fichiers.js, test/projets.test.js importe App.svelte —, et ne + // nomme aucune table de libellés, que le graphe suffit à suivre. + // test/arborescence.test.js garde cette règle, et relève toute épreuve + // de la série node dont le texte lit un composant ou une feuille de + // style de src/interface sans que son motif la relance. L'option se lit au + // niveau du lanceur : posée dans un projet, elle est ignorée sans + // avertissement. watchTriggerPatterns: [ { pattern: /(^|\/)(version\.json|package-lock\.json|CHANGELOG\.md|README\.md|GUIDE-[^/]*\.md)$/, testsToRun: () => 'scripts/version.test.js' }, { pattern: /(^|\/)electron\/[^/]+$/, - testsToRun: () => ['test/coquille.test.js', 'test/livrable.test.js', 'scripts/version.test.js'] }, + testsToRun: () => ['test/coquille.test.js', 'test/fichiers_electron.test.js', 'test/livrable.test.js', 'scripts/version.test.js'] }, { pattern: /(^|\/)test\/electron_factice\.js$/, testsToRun: () => ['test/coquille.test.js', 'scripts/version.test.js'] }, { pattern: /(^|\/)(electron-builder\.config\.cjs|capacitor\.config\.json)$/, testsToRun: () => ['test/livrable.test.js', 'scripts/construire_windows.test.js', 'scripts/version.test.js'] }, { pattern: /(^|\/)scripts\/construire_windows\.sh$/, testsToRun: () => ['scripts/construire_windows.test.js', 'scripts/version.test.js'] }, + { pattern: /(^|\/)src\/interface\/.+\.svelte$/, + testsToRun: () => [...RELANCES_D_UN_COMPOSANT] }, + { pattern: /(^|\/)src\/interface\/jetons\.css$/, + testsToRun: () => [...RELANCES_DES_JETONS] }, ], projects: [ { test: {