From fca74b6672e201d801d45129d59c587aeefe898f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 17:51:49 -0400 Subject: [PATCH] [ADD] tests: spec coverage guards, delivery checks, spec corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New guards close the spec coverage gaps: only the configuration and the diagnostic read reservations in the engine, no second language in the sources, the engine and geometry never read the version, a test lives beside its module, fixtures never copy a shipped file, and the property library stays in a closed list. The delivery test pins the www output and the French language passed by the entry point. The spec names three test projects and the three shell files. Checked: 2778 node, 550 browser and 62 long tests from the index alone; the version check passes. --- FR --- [ADD] épreuves : gardes de couverture du spec, livrable, spec corrigé De nouvelles gardes ferment les manques de couverture : seules la configuration et le diagnostic lisent les réservations dans le moteur, aucun second langage dans les sources, le moteur et la géométrie ne lisent pas la version, une épreuve vit près de son module, aucune donnée d'épreuve ne copie un fichier livré, la bibliothèque de propriétés reste en liste fermée. Le livrable fige la sortie www et la langue française du point d'entrée. Le spec nomme trois projets et trois fichiers de coquille. Vérifié : 2778 node, 550 navigateur, 62 longues ; version conforme. Assisted-by: Claude Opus 5.5 --- scripts/essai_demarrage.js | 39 +- scripts/essai_demarrage.test.js | 24 + spec.md | 191 +++--- src/interface/transversal.navigateur.test.js | 18 +- test/arborescence.test.js | 607 +++++++++++++++++-- test/chargements.js | 55 +- test/chargements.test.js | 33 +- test/livrable.test.js | 112 +++- test/sources_interface.test.js | 5 + 9 files changed, 931 insertions(+), 153 deletions(-) diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js index 9fbeadb..363f414 100644 --- a/scripts/essai_demarrage.js +++ b/scripts/essai_demarrage.js @@ -57,6 +57,7 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync import { tmpdir } from 'node:os'; import path, { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { decrireSysteme } from '../electron/fichiers.js'; import { estSous } from '../src/stockage/dossier_travail.js'; import { VERSION } from '../src/version.genere.js'; import { titreAvecVersion } from '../src/interface/libelles.js'; @@ -114,21 +115,26 @@ export function environnementDEssai(env, { exe, home, temp }, chemins = path) { /** * Les données applicatives que la coquille lancée sous env retiendrait et * qui portent l'exécutable publié : vide quand le mode portable reste - * possible. La liste est celle du § 8.6 point 2, que decrireSysteme - * (electron/fichiers.js) compose : AppData, le LOCALAPPDATA de env, le - * temporaire ; estSous compare comme le système. AppData prend chaque - * valeur que app.getPath peut rendre : sous Windows, l'APPDATA de env et - * celle de poste, dossier connu qu'aucune variable ne déplace ; sous macOS, - * Library/Application Support sous l'un et l'autre HOME ; ailleurs, - * XDG_CONFIG_HOME ou .config sous HOME. Le temporaire est TMP, puis TEMP, - * sous Windows, TMPDIR, puis /tmp, ailleurs. L'ordre est celui de cette - * liste, sans doublon ; sans exécutable publié, rien ne le porte. + * possible. La liste est celle que compose decrireSysteme + * (electron/fichiers.js, § 8.6 point 2), appelée comme la coquille l'appelle, + * et estSous compare comme le système, selon la casse et le séparateur + * qu'elle décrit. L'essai ne sait en propre que ce que rend app.getPath : + * appData prend chaque valeur qu'il peut rendre — sous Windows, l'APPDATA de + * env et celle du poste, dossier connu qu'aucune variable ne déplace ; sous + * macOS, Library/Application Support sous l'un et l'autre HOME ; ailleurs, + * XDG_CONFIG_HOME ou .config sous HOME —, decrireSysteme étant appelée une + * fois pour chacune ; temp est TMP, puis TEMP, sous Windows, TMPDIR, puis + * /tmp, ailleurs. Un autre nom demandé à app.getPath lève : l'essai + * jugerait une liste à laquelle il manque un emplacement. Les dossiers se + * rendent dans l'ordre des appels, sans doublon ; sans exécutable publié, + * rien ne le porte. * @param {Record} env l'environnement de la coquille * @param {Record} poste l'environnement du poste * @param {string} plateforme process.platform + * @param {typeof decrireSysteme} [decrire] la description du poste par la coquille * @returns {string[]} */ -export function donneesApplicativesSurExe(env, poste, plateforme) { +export function donneesApplicativesSurExe(env, poste, plateforme, decrire = decrireSysteme) { const executable = env.PORTABLE_EXECUTABLE_DIR || null; if (executable === null) return []; const chemins = plateforme === 'win32' ? path.win32 : path.posix; @@ -140,10 +146,15 @@ export function donneesApplicativesSurExe(env, poste, plateforme) { ? [env.HOME, poste.HOME].map((maison) => sousMaison(maison, 'Library', 'Application Support')) : [env.XDG_CONFIG_HOME || sousMaison(env.HOME, '.config')]; const temp = plateforme === 'win32' ? env.TMP || env.TEMP : env.TMPDIR || '/tmp'; - const systeme = { insensibleCasse: plateforme === 'win32' || plateforme === 'darwin', separateur: chemins.sep }; - const portent = [...appData, env.LOCALAPPDATA, temp].filter( - (dossier) => typeof dossier === 'string' && dossier !== '' && estSous(executable, dossier, systeme), - ); + const portent = appData.flatMap((dossierAppData) => { + const connus = { appData: dossierAppData, temp, documents: '' }; + const cheminSysteme = (nom) => { + if (!Object.hasOwn(connus, nom)) throw new Error(`app.getPath('${nom}') : l'essai ne sait pas l'émuler`); + return connus[nom]; + }; + const { emplacements } = decrire({ env, plateforme, cheminSysteme, nomProduit: '' }); + return emplacements.donneesApplicatives.filter((dossier) => estSous(executable, dossier, emplacements)); + }); return [...new Set(portent)]; } diff --git a/scripts/essai_demarrage.test.js b/scripts/essai_demarrage.test.js index 72eccef..161d018 100644 --- a/scripts/essai_demarrage.test.js +++ b/scripts/essai_demarrage.test.js @@ -12,6 +12,7 @@ import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path, { dirname, join, relative, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { decrireSysteme } from '../electron/fichiers.js'; import { estSous } from '../src/stockage/dossier_travail.js'; import { describe, test } from '../test/lanceur.js'; import { donneesApplicativesSurExe, dossiersDEssai, environnementDEssai, estLanceDirectement } from './essai_demarrage.js'; @@ -126,6 +127,29 @@ describe('essai de démarrage : dossiers et environnement (§ 8.6)', () => { // Sans exécutable publié, rien ne le porte. assert.deepEqual(donneesApplicativesSurExe({ TMPDIR: '/' }, {}, 'linux'), []); }); + + test('la liste des données applicatives est celle que decrireSysteme compose : un emplacement qu’elle ajoute est jugé, et un app.getPath que l’essai ne sait pas émuler lève', () => { + const P = path.posix; + const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x'); + const dossiers = dossiersDEssai(R, P); + const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P); + const autre = P.join('/', 'racine-essai'); + // Une coquille qui range aussi GTT_DONNEES parmi ses données applicatives. + const avecUnDossier = (poste) => { + const decrit = decrireSysteme(poste); + const donneesApplicatives = [...decrit.emplacements.donneesApplicatives, poste.env.GTT_DONNEES]; + return { ...decrit, emplacements: { ...decrit.emplacements, donneesApplicatives } }; + }; + assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: autre }, {}, 'linux', avecUnDossier), [autre]); + assert.deepEqual(donneesApplicativesSurExe({ ...env, GTT_DONNEES: P.join('/', 'ailleurs') }, {}, 'linux', avecUnDossier), []); + // Une coquille qui lit un autre dossier du système : l'essai le refuse + // plutôt que de juger une liste incomplète. + const avecUserData = (poste) => { + poste.cheminSysteme('userData'); + return decrireSysteme(poste); + }; + assert.throws(() => donneesApplicativesSurExe(env, {}, 'linux', avecUserData), /userData/); + }); }); describe('essai de démarrage : lancement direct', () => { diff --git a/spec.md b/spec.md index bd0c749..6bb76aa 100644 --- a/spec.md +++ b/spec.md @@ -341,7 +341,9 @@ aperçoive**. Moyennant cet ajout, le moteur n'a **qu'un seul chemin de code** : la réservation est un prétraitement qui réduit les capacités, retire des -participants et annote les tables — jamais un second algorithme. +participants et annote les tables — jamais un second algorithme. Une garde de +l'arborescence le tient : dans le moteur, seuls la normalisation de la +configuration et le diagnostic lisent les réservations. ### 5.3 Les contraintes, activables une à une @@ -393,9 +395,10 @@ une lecture et non un jugement : | **manque** | plafond réalisé − rencontres (§ 5.5) — la grandeur qui compare deux propositions (§ 12.10) | | **écart d'itinéraire** | plafond a priori − plafond réalisé (§ 5.5, § 12.10) | -**Les agrégats portent sur tous les participants placés.** Le minimum se -calcule sur l'ensemble, ancrés compris, et **deux lignes secondaires** donnent -la même mesure restreinte aux **mobiles** et aux **ancrés**. Un participant partiellement fixé compte parmi les mobiles. +**Les agrégats portent sur tous les participants placés.** Le minimum se calcule +sur l'ensemble, ancrés compris, et **deux lignes secondaires** donnent la même +mesure restreinte aux **mobiles** et aux **ancrés**. Un participant +partiellement fixé compte parmi les mobiles. > Un ancré n'est pas structurellement désavantagé : à table de 8 avec un seul > ancré et R = 4, son plafond vaut 28, exactement celui d'un mobile visitant @@ -680,8 +683,8 @@ dit, pour que l'opérateur sache lequel des deux il fait. **Le placement se prépare, il ne suit pas la salle.** Retirer une personne du placement retenu libère sa chaise, et personne d'autre ne change de siège ni de table : la chaise reste vide jusqu'à ce qu'un geste y assoie quelqu'un, et un -geste n'assied que les personnes qu'il nomme. Quand les sièges sont attribués, le -fichier écrit cette chaise vide à sa place (§ 8.9). +geste n'assied que les personnes qu'il nomme. Quand les sièges sont attribués, +le fichier écrit cette chaise vide à sa place (§ 8.9). **Il n'existe aucune commande qui répartit les animateurs sur les tables.** L'opérateur désigne lui-même quelle personne anime quelle table (§ 2.1, @@ -738,8 +741,9 @@ proposition, une réservation, un titre, des réservations à convertir —, qu'aucune commande ne fait revenir et sur laquelle l'interface n'offre pas le geste ; d'un compteur au bout de sa règle, d'un plan qui n'est plus bloqué, d'un geste sans effet, et d'un refus du CSV relayé par l'import, qui se lève dans le -fichier. Un remède posé pour la forme enverrait l'opérateur vers un geste qui ne -change rien. +fichier ou dans l'association de ses colonnes — `NOM_NON_ASSOCIE` quand aucune +colonne n'est associée au nom. Un remède posé pour la forme enverrait +l'opérateur vers un geste qui ne change rien. ### 5.10 Le coût d'évaluation @@ -1088,9 +1092,10 @@ diamètre = max(70, places × 60 / π) en centimètres Soit 115 cm pour 6 places, 153 pour 8, 191 pour 10. **Le plancher n'est pas cosmétique** : en deçà de quatre convives — la formule atteint 70 cm à 3,67 places — ce n'est plus le périmètre qui borne la table mais le plateau, et la -formule seule dessinerait des guéridons. Les deux -valeurs sont des **réglages par défaut, non des normes vérifiées** ; un diamètre -saisi à la main l'emporte. +formule seule dessinerait des guéridons. Les deux valeurs sont des **réglages +par défaut, non des normes vérifiées** ; un diamètre saisi à la main l'emporte, +dès que le fichier d'état peut le porter : le format 1 n'a pas de champ pour +lui, et toute table s'y dessine selon la formule. **Les étiquettes ne sont pas à l'échelle, le mobilier l'est.** Une liste de dix noms couvre plusieurs mètres carrés fictifs. **Le plan à l'échelle du bâtiment @@ -1103,12 +1108,11 @@ table sans qu'aucune marque couvre la liste. Sa largeur est fixée dans le dessin, et un nom trop long s'y abrège (§ 7.4). **Aucune liste ne passe sur les chaises d'une voisine posée sur la grille** : une table créée prend la première case libre d'une grille dont les colonnes s'écartent d'une emprise, de deux -écarts et d'un bloc de noms, à un pas entier au moins, sur l'un des deux axes, de -toute table déjà posée, et les démonstrations (§ 15) sont rangées sur la même -grille. Le prix est la place : au -zoom ajusté, une grande salle descend sous le seuil qui retire les listes -(§ 17, point 6), et un grand événement peut s'ouvrir sans listes de noms, qui se -dessinent quand l'opérateur rapproche la vue. +écarts et d'un bloc de noms, à un pas entier au moins, sur l'un des deux axes, +de toute table déjà posée, et les démonstrations (§ 15) sont rangées sur la même +grille. Le prix est la place : au zoom ajusté, une grande salle descend sous le +seuil qui retire les listes (§ 17, point 6), et un grand événement peut s'ouvrir +sans listes de noms, qui se dessinent quand l'opérateur rapproche la vue. **Tant qu'aucune dimension de salle n'est saisie, le plan est exact dans ses tables et arbitraire dans son cadre.** Le logiciel porte alors, sur le plan et @@ -2918,11 +2922,12 @@ service. | `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux | | `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison | -**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne fournit -de plateforme de bureau que par une extension communautaire, et une plateforme -dont la mise à jour ne dépend pas du projet fige le moteur d'exécution embarqué : -l'exécutable livré porterait un Chromium que plus personne ne corrige. La coquille -du projet tient en deux fichiers — le processus principal et le script de +**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne +fournit de plateforme de bureau que par une extension communautaire, et une +plateforme dont la mise à jour ne dépend pas du projet fige le moteur +d'exécution embarqué : l'exécutable livré porterait un Chromium que plus +personne ne corrige. La coquille du projet tient en trois fichiers — le +processus principal, ses primitives de fichiers (`fichiers.js`) et le script de préchargement — et fixe elle-même sa version d'Electron, que la construction met à jour comme n'importe quelle dépendance. @@ -2957,11 +2962,13 @@ navigateur. Tout — le moteur, la géométrie, l'interface — est écrit en **JavaScript**. Aucune partie du calcul n'est dupliquée dans un second langage. -Cette propriété doit être **préservée délibérément**. Dès que la même -arithmétique existe en deux endroits, une suite de tests peut prouver qu'ils -s'accordent **sans jamais prouver qu'ils ont raison** : les deux dérivent -ensemble et les tests restent verts. Une seule implémentation rend ce mode de -défaillance impossible. +Cette propriété doit être **préservée délibérément**, et une garde de +l'arborescence la tient : sous `src/`, `electron/` et `scripts/`, rien d'autre +que du JavaScript, des composants Svelte, des feuilles de style, des données +JSON et les scripts d'installation nommés. Dès que la même arithmétique existe +en deux endroits, une suite de tests peut prouver qu'ils s'accordent **sans +jamais prouver qu'ils ont raison** : les deux dérivent ensemble et les tests +restent verts. Une seule implémentation rend ce mode de défaillance impossible. **Une exception, nommée, et une seule** : le calcul incrémental des indicateurs du § 5.10. Elle tient à une condition écrite — **le recalcul complet est la @@ -3028,9 +3035,9 @@ quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusem du rendu et placée plus bas que le moteur : elle n'importe ni le moteur, ni l'analyseur CSV, ni le générateur de démonstrations, ni l'application, ni l'interface, ni le stockage, et ne touche pas le navigateur. Elle est -**requise** : sa garde échoue quand son arbre ne fournit aucun module. Le dessin, -la désignation d'un siège et le dépôt partagent ses conversions et ses chaises -(§ 7.2, § 7.3). +**requise** : sa garde échoue quand son arbre ne fournit aucun module. Le +dessin, la désignation d'un siège et le dépôt partagent ses conversions et ses +chaises (§ 7.2, § 7.3). Les **deux frontières de plateforme** sont le système de fichiers et l'impression : une interface unique, deux implémentations — plus, pour le système @@ -3056,10 +3063,10 @@ ou d'une plateforme ; que `src/geometrie` importe en outre du moteur, de de Svelte ; et que `src/interface` importe autre chose que Svelte, ses propres modules, `src/application` et `src/geometrie` — elle atteint le CSV et le moteur par l'application, jamais en direct. Un type que la documentation d'un module -importe compte comme un import. Le projet `node` ne chargeant pas le greffon Svelte (§ 14.8), un test de -moteur qui importerait un composant échoue au chargement — mais cette garde-là -ne couvre que les composants, et un module qui touche `document` ne tomberait -qu'à l'exécution de la branche fautive. +importe compte comme un import. Le projet `node` ne chargeant pas le greffon +Svelte (§ 14.8), un test de moteur qui importerait un composant échoue au +chargement — mais cette garde-là ne couvre que les composants, et un module qui +touche `document` ne tomberait qu'à l'exécution de la branche fautive. ### 13.5 Ce que le SVG donne et que le canvas ferait payer @@ -3145,8 +3152,9 @@ suivante ; une règle qui fait échouer la construction tient. | **sa propre commande** | l'audit par mutation (§ 14.13) | avant une livraison, sous aucun budget | **La commande surveillée ne lance que le projet `node`.** Le lanceur en porte -deux (§ 14.8) ; lancés ensemble, ils démarrent un navigateur à chaque -modification, et le niveau navigateur reste à « avant chaque livraison ». +trois (§ 14.8) — `node`, `node-long` et `navigateur` ; lancés ensemble, ils +démarrent un navigateur et la série longue à chaque modification, et le niveau +navigateur reste à « avant chaque livraison ». **Le partage entre les deux séries `node` est écrit avant la première mesure**, et non après. Trois familles tombent naturellement dans la série surveillée sans @@ -3212,10 +3220,10 @@ l'**analyseur CSV**, qui reçoivent l'horloge et l'aléa en paramètre. Un test l'arborescence le refuse, **et échoue si son balayage ne trouve aucun fichier**. Son périmètre est nommé : `src/moteur`, `src/geometrie`, `src/demo`, `src/stockage` et `src/csv`, à l'exclusion du code d'épreuve, où un tirage sert -légitimement (§ 14.12). L'interdit ne porte pas sur -le reste de l'application : l'application lit l'horloge pour horodater les -entrées de l'historique, que le stockage écrit telles qu'il les reçoit, et le -banc de mesure du § 19.10 relève des temps par image. +légitimement (§ 14.12). L'interdit ne porte pas sur le reste de l'application : +l'application lit l'horloge pour horodater les entrées de l'historique, que le +stockage écrit telles qu'il les reçoit, et le banc de mesure du § 19.10 relève +des temps par image. **Ce n'est pas une règle d'hygiène.** L'interdit de `performance.now` dans le moteur est ce qui rend vraie la phrase « à graine et entrée égales, placement @@ -3230,22 +3238,25 @@ d'horloge à l'exécution (§ 18.5) : le moteur et le générateur ne la lisent ### 14.8 L'outillage des épreuves -**Un seul lanceur, deux projets : Vitest.** Le niveau `node` et le niveau -navigateur du § 14.4 sont deux configurations du même lanceur, pas deux outils. +**Un seul lanceur, trois projets : Vitest.** Les deux séries `node` et le niveau +navigateur du § 14.4 sont trois configurations du même lanceur, pas trois +outils. | projet | environnement | ce qu'il inclut | greffon Svelte | |---|---|---|---| | `node` | `node`, aucun DOM | `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`, `src/demo`, `src/pdf`, `src/application`, et les modules purs de `src/interface` | **absent** | +| `node-long` | `node`, aucun DOM | les épreuves lourdes du niveau `node:long` (§ 14.4), suffixe `.long.test.js`, hors de la série surveillée | **absent** | | `navigateur` | un vrai moteur de rendu, piloté par le mode navigateur de Vitest | les composants de `src/interface`, les épreuves de coordonnées et de style calculé | présent | -**Pourquoi Vitest.** Il lit la **configuration Vite qui construit le livrable** : un module importé par un test se résout exactement comme un module +**Pourquoi Vitest.** Il lit la **configuration Vite qui construit le +livrable** : un module importé par un test se résout exactement comme un module importé par l'application. Deux chaînes de résolution — l'une pour construire, l'autre pour éprouver — sont le mode de défaillance du § 13.2 transposé à -l'outillage : le test passe sur un fichier que l'application ne charge jamais, un -alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe de -dépendances**, donc une modification du moteur ne rejoue que les tests du moteur, -et son mode navigateur offre **la même API d'assertion** au niveau 2, de sorte -que l'oracle de géométrie du § 7.2 s'écrit une fois. +l'outillage : le test passe sur un fichier que l'application ne charge jamais, +un alias diverge, et rien ne le signale. Le lanceur observe en outre le **graphe +de dépendances**, donc une modification du moteur ne rejoue que les tests du +moteur, et son mode navigateur offre **la même API d'assertion** au niveau 2, de +sorte que l'oracle de géométrie du § 7.2 s'écrit une fois. **Les trois durées sont des cibles, mesurées la première semaine.** Le démarrage à froid, la relance sous surveillance et la série complète se mesurent sur un @@ -3300,12 +3311,15 @@ une donnée d'épreuve qui emprunte une racine d'organisation plausible le fait échouer. Un test vérifie cette disjonction, faute de quoi la garde du § 15.6 tombe au premier exemple écrit de bonne foi. -**La co-localisation se garde.** Un test de la construction affirme que le paquet -livré ne contient **aucun fichier de test ni aucun import du lanceur**. Le § 14.15 -pose que la suite n'est pas livrée ; avec des tests rangés dans `src/`, cette -propriété tient par la seule analyse statique de Vite, c'est-à-dire par -chance, jusqu'au jour où un module de production importe une -donnée d'épreuve « pour un cas par défaut ». +**La co-localisation se garde.** Un test de la construction affirme que le +paquet livré ne contient **aucun fichier de test ni aucun import du lanceur**. +Le § 14.15 pose que la suite n'est pas livrée ; avec des tests rangés dans +`src/`, cette propriété tient par la seule analyse statique de Vite, +c'est-à-dire par chance, jusqu'au jour où un module de production importe une +donnée d'épreuve « pour un cas par défaut ». Une garde de l'arborescence exige +aussi que chaque test de `src/` importe un module de son propre répertoire, hors +d'exceptions nommées avec leur raison, et qu'aucune donnée de `test/fixtures/` +n'égale, octet pour octet, un fichier livré. ### 14.10 La carte de ce qui s'éprouve @@ -3387,14 +3401,15 @@ sait pas fragmenter un SVG ; c'est du code qu'il faut éprouver, pas une règle style. **Au niveau navigateur**, la fonction pure est confrontée à -`getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après défilement -de la page, à un millième de pixel d'écran près (§ 7.2). **Les trois invariants du § 7.2 ont chacun leur test** : que le `` -racine ne porte pas d'attribut `viewBox` ; que son style calculé donne une bordure -et un remplissage intérieur nuls — `getBoundingClientRect` les inclut, donc -l'origine du contenu s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de -transformation CSS. Il refuse la feuille de style qui ajoute un jour -`transform: translateZ(0)` « pour la fluidité » : le plan répondrait faux partout, -et la cause serait cherchée dans le module de géométrie. +`getScreenCTM().inverse()` sur un rendu réel, à plusieurs `k` et après +défilement de la page, à un millième de pixel d'écran près (§ 7.2). **Les trois +invariants du § 7.2 ont chacun leur test** : que le `` racine ne porte pas +d'attribut `viewBox` ; que son style calculé donne une bordure et un remplissage +intérieur nuls — `getBoundingClientRect` les inclut, donc l'origine du contenu +s'en décale ; qu'aucun ancêtre, ni lui-même, ne porte de transformation CSS. Il +refuse la feuille de style qui ajoute un jour `transform: translateZ(0)` « pour +la fluidité » : le plan répondrait faux partout, et la cause serait cherchée +dans le module de géométrie. #### Le stockage @@ -3632,16 +3647,25 @@ produire une configuration à partir d'une graine. Une boucle sur une grille de graines et de tailles, appelant les deux vérificateurs, couvre l'essentiel sans dépendance nouvelle. -**Une bibliothèque de propriétés se justifie à deux endroits, et seulement deux** : -la formule du plafond et la détection du séparateur. Toutes deux prennent des -entrées petites et combinatoires, toutes deux bénéficient du **rétrécissement** — -quand une configuration à 120 participants échoue, l'outil rend un contre-exemple à -trois tables, là où la réduction manuelle coûte une heure. Leurs oracles ne sont -pas de même nature et le texte ne les confond pas : le plafond s'arbitre par -**énumération exhaustive** ; le séparateur s'arbitre par **construction** — un -tableau de champs engendré, rendu avec un séparateur connu, et la détection doit le -retrouver. Ailleurs, non : une propriété sur la recherche rejouerait la partie -lente et épuiserait le budget du § 14.14. +**Une bibliothèque de propriétés se justifie à deux endroits du calcul, et +seulement deux** : la formule du plafond et la détection du séparateur. Toutes +deux prennent des entrées petites et combinatoires, toutes deux bénéficient du +**rétrécissement** — quand une configuration à 120 participants échoue, l'outil +rend un contre-exemple à trois tables, là où la réduction manuelle coûte une +heure. Leurs oracles ne sont pas de même nature et le texte ne les confond pas : +le plafond s'arbitre par **énumération exhaustive** ; le séparateur s'arbitre +par **construction** — un tableau de champs engendré, rendu avec un séparateur +connu, et la détection doit le retrouver. Ailleurs, non : une propriété sur la +recherche rejouerait la partie lente et épuiserait le budget du § 14.14. + +**Le stockage l'emploie pour deux propriétés de plus, nommées.** L'aller-retour +des correctifs du journal — appliquer à une charge la différence qui mène à une +autre rend l'autre, sur des paires de charges tirées — et la restitution de +chaque instant d'une séance tirée, qui vit dans la série longue parce que ses +séances lancent des générations, à compte d'arrêt court. Leurs entrées sont des +suites d'opérations, où le **rétrécissement** sert comme au plafond : le +contre-exemple rendu tient en quelques opérations. La liste est fermée : une +garde de l'arborescence refuse la bibliothèque hors de ces quatre familles. **La bibliothèque tourne sur une graine fixée.** Une bibliothèque de propriétés retire par défaut une graine neuve à chaque exécution : le même code échoue une @@ -4298,10 +4322,12 @@ Tout le reste en dérive, par un script de construction : script puis compare — la même discipline qu'au § 15.5 pour les fichiers de démonstration (§ 13.1). -**Le module porte aussi la provenance de la construction** — livraison, séance de -développement, ou construction de documentation — posée par la commande qui -l'engendre, jamais lue dans l'horloge ni dans l'environnement. Sans elle, une -capture d'écran prise pendant le développement annonce un numéro déjà livré, et le +**Le module engendré ne porte que la version.** La **provenance de la +construction** — livraison, séance de développement, ou construction de +documentation — est le mode de construction que pose la commande qui construit, +celui de Vite, que l'application reçoit avec ses dépendances ; elle n'est jamais +lue dans l'horloge ni dans l'environnement du poste. Sans elle, une capture +d'écran prise pendant le développement annonce un numéro déjà livré, et le diagnostic porte sur un binaire que personne ne détient. ### 18.4 Le `CHANGELOG.md` @@ -4474,12 +4500,13 @@ fois lors du premier geste suivant un remplacement d'exécutable, ce qui coûte correctif et non du bruit à chaque geste. Il **n'est pas** la version de format, qui reste un nombre séparé, comparé selon ses propres règles. -**Pendant une construction de documentation, le numéro est remplacé par une marque -de provenance** — dans le bandeau, sur le premier écran, au pied des pages -imprimées et sur la feuille de contrôle des badges. La raison est au § 19.4 : sans cette marque, le texte relevé -de chaque capture d'écran change à chaque incrément de version, et toutes les -images se réécrivent. Le manifeste, lui, enregistre la **version réelle**, et le -contrôle de version de la livraison la compare (§ 19.7). +**Pendant une construction de documentation, le numéro est remplacé par une +marque de provenance** — dans le bandeau, sur le premier écran, au pied des +pages imprimées et sur la feuille de contrôle des badges. La raison est au +§ 19.4 : sans cette marque, le texte relevé de chaque capture d'écran change à +chaque incrément de version, et toutes les images se réécrivent. Le manifeste, +lui, enregistre la **version réelle**, et le contrôle de version de la livraison +la compare (§ 19.7). --- diff --git a/src/interface/transversal.navigateur.test.js b/src/interface/transversal.navigateur.test.js index 6a6e7eb..b2e7df8 100644 --- a/src/interface/transversal.navigateur.test.js +++ b/src/interface/transversal.navigateur.test.js @@ -1094,14 +1094,23 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => { }); test('contrastes : chaque texte tient 4,5:1 sur son fond peint, dans les deux thèmes posés par le réglage ; les couleurs relues diffèrent d’un thème à l’autre', () => { - expect(atteints().reduce((n, r) => n + r.clair.textes.mesures + r.sombre.textes.mesures, 0)).toBeGreaterThan(0); + // Un compte par thème : un relevé vide dans l'un passerait sous la somme + // des deux, et la comparaison des couleurs n'aurait rien à comparer. + for (const theme of THEMES) { + expect(atteints().reduce((n, r) => n + r[theme].textes.mesures, 0), `textes mesurés [${theme}]`).toBeGreaterThan(0); + } const fautes = []; + let comparees = 0; for (const r of atteints()) { for (const theme of THEMES) fautes.push(...r[theme].textes.fautes.map((f) => `${r.nom} [${theme}] — ${f}`)); for (const [cle, couleurs] of r.clair.textes.couleurs) { - if (r.sombre.textes.couleurs.get(cle) === couleurs) fautes.push(`${r.nom} — ${cle} : ${couleurs} dans les deux thèmes`); + const sombres = r.sombre.textes.couleurs.get(cle); + if (sombres === undefined) continue; + comparees += 1; + if (sombres === couleurs) fautes.push(`${r.nom} — ${cle} : ${couleurs} dans les deux thèmes`); } } + expect(comparees, 'couleurs relevées dans les deux thèmes').toBeGreaterThan(0); aucuneFaute([...new Set(fautes)], 'contrastes'); }); @@ -1124,8 +1133,9 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => { test('aucune atténuation : ni opacité partielle, ni filtre, ni fusion, ni masque sur un texte, une marque ou leur ascendance, dans les deux thèmes', () => { for (const theme of THEMES) { - const mesures = atteints().reduce((n, r) => n + r[theme].textes.mesures + r[theme].marques.mesures, 0); - expect(mesures, `textes et marques mesurés [${theme}]`).toBeGreaterThan(0); + for (const sorte of ['textes', 'marques']) { + expect(atteints().reduce((n, r) => n + r[theme][sorte].mesures, 0), `${sorte} mesurés [${theme}]`).toBeGreaterThan(0); + } } const fautes = []; for (const r of atteints()) { diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 98f459d..50dff80 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -3,27 +3,32 @@ // Gardes de l'arborescence, lues dans le texte des sources : le moteur, la // 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, 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) ; 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. +// 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, l'interface et ses composants compris, +// ne rejoint que ce que le § 13.4 lui permet, et ni le moteur, ni la +// géométrie, ni le générateur ne lisent la version engendrée (§ 14.7) ; 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) ; 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) ; dans le moteur, deux modules nommés +// lisent les réservations (§ 5.2) ; src/, electron/ et scripts/ n'ont qu'une +// langue (§ 13.2) ; une épreuve de src/ vit à côté de son module, et aucune +// donnée d'épreuve ne double un fichier livré (§ 14.9) ; fast-check ne se +// charge que dans les épreuves nommées (§ 14.12). 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, statSync, writeFileSync } from 'node:fs'; import { isBuiltin } from 'node:module'; @@ -498,7 +503,7 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // 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. +// couche, lus par leurs blocs de script et les import() de leur gabarit. // // 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 @@ -513,19 +518,32 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // 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. +// test/ sont refusés. Le générateur de démonstrations lit le moteur et le +// stockage, qui sérialise ses fichiers, mais ni l'application, ni +// l'interface, ni le navigateur. Le moteur, la géométrie et le générateur ne +// lisent pas la version engendrée (§ 14.7, § 18.5), à aucune profondeur : +// une démonstration ou un placement qui en dépendrait changerait à chaque +// livraison. +const VERSION_ENGENDREE = 'src/version.genere.js'; const COUCHES = [ { arbres: ['src/moteur'], requis: ['src/moteur'], - exterieures: ['src/interface', 'src/application', 'src/stockage'], + exterieures: ['src/interface', 'src/application', 'src/stockage', VERSION_ENGENDREE], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, { arbres: ['src/geometrie'], requis: ['src/geometrie'], - exterieures: ['src/interface', 'src/application', 'src/stockage', 'src/moteur', 'src/csv', 'src/demo'], + exterieures: ['src/interface', 'src/application', 'src/stockage', 'src/moteur', 'src/csv', 'src/demo', VERSION_ENGENDREE], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: [], + }, + { + arbres: ['src/demo'], + requis: ['src/demo'], + exterieures: ['src/interface', 'src/application', VERSION_ENGENDREE], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, @@ -700,7 +718,8 @@ function cibleDe(racine, fichier, specificateur) { 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. +// blocs de script et des import() de son gabarit pour un composant Svelte +// (scriptsDe), 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. @@ -1191,11 +1210,67 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { ]); }); - 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', () => { + test('ni le moteur, ni la géométrie, ni le générateur de démonstrations ne lisent la version engendrée (§ 14.7, § 18.5) ; l’interface et l’application la lisent ; le générateur n’importe ni l’interface, ni l’application, ni une plateforme, ni le navigateur', () => { + const fichiers = { + ...MODULES_VIDES, + 'src/version.genere.js': "export const VERSION = Object.freeze({ affichee: '', technique: '' });\n", + 'src/moteur/recherche.js': "import { VERSION } from '../version.genere.js';\n", + 'src/geometrie/sous/vue.js': "import { VERSION } from '../../version.genere.js';\n", + 'src/demo/catalogue.js': [ + "import { VERSION } from '../version.genere.js';", + "import { ErreurConfiguration } from '../moteur/erreurs.js';", + "import { serialiser } from '../stockage/canonique.js';", + "import { t } from '../application/traduction.js';", + "import { x } from '../interface/x.js';", + "import { mount } from 'svelte';", + "import { readFileSync } from 'node:fs';", + 'export const langue = navigator.language;', + ].join('\n'), + 'src/demo/livrees.js': "import { CATALOGUE } from './catalogue.js';\nimport petite from './livrees/petite.gtt.json' with { type: 'json' };\n", + 'src/moteur/erreurs.js': 'export class ErreurConfiguration extends Error {}\n', + 'src/stockage/canonique.js': "import { VERSION } from '../version.genere.js';\nexport const serialiser = () => VERSION;\n", + 'src/application/a.js': "import { VERSION } from '../version.genere.js';\n", + 'src/interface/App.svelte': "\n", + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/moteur/recherche.js:1 import ../version.genere.js', + 'src/geometrie/sous/vue.js:1 import ../../version.genere.js', + 'src/demo/catalogue.js:1 import ../version.genere.js', + 'src/demo/catalogue.js:4 import ../application/traduction.js', + 'src/demo/catalogue.js:5 import ../interface/x.js', + 'src/demo/catalogue.js:6 import svelte', + 'src/demo/catalogue.js:7 import node:fs', + 'src/demo/catalogue.js:8 navigator.', + 'src/demo/catalogue.js → src/stockage/canonique.js:1 import ../version.genere.js', + ]); + }); + + test('un import() du gabarit d’un composant se relève comme celui d’un bloc de script ; le mot « import » d’un nœud de texte non', () => { + const fichiers = { + ...MODULES_VIDES, + 'src/interface/APropos.svelte': [ + '', + "

import('../stockage/texte.js') reste du texte

", + "{#await import('../stockage/depot.js') then depot}{/await}", + '{#if apropos}', + " ", + '{/if}', + ].join('\n'), + 'src/interface/apropos.js': 'export const apropos = 1;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/interface/APropos.svelte:5 import ../stockage/depot.js', + 'src/interface/APropos.svelte:7 import ../moteur/recherche.js', + ]); + }); + + test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/demo, 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), []); 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']) { + for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv', 'src/interface']) { const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverFrontiere), @@ -2206,17 +2281,44 @@ function delaisEt(texte, code, motifs) { } // 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. +// projet, et des modules JavaScript de test/ qu'elles importent, à toute +// profondeur, par un chemin relatif ou absolu depuis la racine : les aides +// partagées de test/navigateur/ attendent et agissent pour elles. Un module +// de src/ ne se parcourt pas : c'est le code éprouvé, que ses propres gardes +// tiennent. Chaque assertion de classe ou délai fixe se lit « accès:ligne +// refus », l'accès étant la suite des fichiers qui y mène depuis une +// épreuve, comme dans releverEcritureDesEpreuves. 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 sousTest = join(racine, 'test') + sep; + const aParcourir = [...epreuves]; + const acces = new Map(epreuves.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'); - return delaisEt(texte, sansChaines(texte), [...ASSERTIONS_DE_CLASSE, ...DELAIS]).map( - ({ ligne, motif }) => `${relative(racine, fichier)}:${ligne} ${motif}`, + const code = sansChaines(texte); + for (const { indice, specificateur } of chargementsLitteraux(texte)) { + const cible = cibleDe(racine, fichier, specificateur); + if ( + estDuCode(code, indice) && + cible !== null && + cible.startsWith(sousTest) && + !acces.has(cible) && + EXTENSIONS_MODULE.has(extname(cible)) && + existsSync(cible) + ) { + acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`); + aParcourir.push(cible); + } + } + refus.push( + ...delaisEt(texte, code, [...ASSERTIONS_DE_CLASSE, ...DELAIS]).map(({ ligne, motif }) => `${acces.get(fichier)}:${ligne} ${motif}`), ); - }); + } + return refus; } describe('arborescence : écriture des épreuves (§ 14.8, § 14.2)', () => { @@ -2317,6 +2419,30 @@ describe('arborescence : écriture des épreuves (§ 14.8, § 14.2)', () => { ]); assert.throws(() => avecArbre({ 'src/a.test.js': '' }, releverEpreuvesNavigateur), /aucune épreuve du navigateur/); }); + + test('la garde des épreuves du navigateur suit leurs imports relatifs vers les modules de test/, à toute profondeur, et relève le refus avec son chemin d’accès ; un module de src/ ne se parcourt pas', () => { + const fichiers = { + 'src/interface/plan/Plan.navigateur.test.js': [ + "import { cliquer } from '../../../test/navigateur/aide.js';", + "import { horloge } from '../../application/horloge.js';", + "import { donnee } from '../../../test/fixtures/donnee.json' with { type: 'json' };", + ].join('\n'), + 'test/navigateur/aide.js': [ + "import { attendre } from './profond/attente.js';", + 'export const cliquer = async () => {', + ' await new Promise((r) => setTimeout(r, 300));', + '};', + ].join('\n'), + 'test/navigateur/profond/attente.js': "export const attendre = (e) => e.classList.contains('vu');\n", + 'src/application/horloge.js': 'export const horloge = () => setTimeout(() => {}, 10);\n', + 'test/fixtures/donnee.json': '{}\n', + 'test/navigateur/aide.navigateur.test.js': "import { cliquer } from './aide.js';\n", + }; + assert.deepEqual(avecArbre(fichiers, releverEpreuvesNavigateur), [ + 'src/interface/plan/Plan.navigateur.test.js → test/navigateur/aide.js:3 setTimeout', + 'src/interface/plan/Plan.navigateur.test.js → test/navigateur/aide.js → test/navigateur/profond/attente.js:1 classList.contains', + ]); + }); }); // Le projet de la série surveillée dans une configuration de Vitest. @@ -2742,3 +2868,416 @@ describe('arborescence : les motifs de surveillance (§ 14.14)', () => { assert.throws(() => releverSurveillance(RACINE, { watchTriggerPatterns: [], projects }), /aucun motif de surveillance/); }); }); + +// --- Un seul chemin, un seul langage, des épreuves à leur place ------------- + +// Le code d'un texte sans ses chaînes ni ses commentaires : sansChaines, +// puis chaque commentaire « // … » et « /* … */ » blanchi, fins de ligne +// gardées. Les chaînes déjà blanchies n'ouvrent plus aucun commentaire. +function codeSeul(texte) { + return sansChaines(texte).replace(/\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/g, (commentaire) => commentaire.replace(/[^\n]/g, ' ')); +} + +// Les seuls lecteurs des réservations dans le moteur (§ 5.2) : la +// réservation est un prétraitement qui réduit l'instance — capacités, +// mobiles, appartenances des ancrés —, jamais un second algorithme. +const LECTEURS_DES_RESERVATIONS = [ + { + fichier: 'src/moteur/configuration.js', + raison: 'normaliser fixe les réservations et réduit l’instance que la recherche résout', + }, + { + fichier: 'src/moteur/diagnostic.js', + raison: 'le diagnostic sonde la configuration sous une partie de ses réservations pour nommer la cause', + }, +]; + +// Une lecture des réservations : le nom reservations dans le code — +// propriété, déstructuration, paramètre —, ou une propriété nommée par une +// chaîne entre crochets. +const LECTURE_DES_RESERVATIONS = /\breservations\b/g; +const RESERVATIONS_ENTRE_CROCHETS = /\[\s*(['"`])reservations\1\s*\]/g; + +// Relevé des lectures des réservations dans les modules de src/moteur, à +// toute profondeur, épreuves exclues, hors des lecteurs admis : chacune +// « fichier:ligne reservations », ou « fichier:ligne ['reservations'] » pour +// la forme à crochets, par fichier puis par ligne ; puis « exception sans +// objet : fichier » pour un lecteur admis qui n'en lit aucune. Le nom se lit +// dans le code sans chaînes ni commentaires : un typedef JSDoc qui le nomme +// ne lit rien. Lève quand src/moteur ne fournit aucun module. +function releverLecteursDesReservations(racine, admis = LECTEURS_DES_RESERVATIONS) { + const modules = modulesDe(join(racine, 'src', 'moteur')); + assert.ok(modules.length > 0, 'src/moteur ne fournit aucun module'); + const lecteurs = new Set(); + const refus = []; + for (const fichier of modules) { + const nom = enBarres(racine, fichier); + const texte = readFileSync(fichier, 'utf8'); + const code = codeSeul(texte); + // Le crochet d'une lecture subsiste dans le code ; celui d'un + // commentaire est blanchi avec lui. + const trouves = [ + ...[...code.matchAll(LECTURE_DES_RESERVATIONS)].map(({ index }) => ({ indice: index, rang: 0, motif: 'reservations' })), + ...[...texte.matchAll(RESERVATIONS_ENTRE_CROCHETS)] + .filter(({ index }) => code[index] === '[') + .map(({ index }) => ({ indice: index, rang: 1, motif: "['reservations']" })), + ]; + if (trouves.length > 0) lecteurs.add(nom); + if (admis.some((e) => e.fichier === nom)) continue; + trouves.sort((a, b) => a.indice - b.indice || a.rang - b.rang); + refus.push(...trouves.map(({ indice, motif }) => `${nom}:${ligneDe(texte, indice)} ${motif}`)); + } + const sansObjet = admis.filter(({ fichier }) => !lecteurs.has(fichier)).map(({ fichier }) => `exception sans objet : ${fichier}`); + return [...refus, ...sansObjet]; +} + +// Les arbres du code livré et de son outillage, où ne vit qu'une langue +// (§ 13.2) : JavaScript, composants Svelte, feuilles de style, et des +// données JSON, qui ne calculent rien. +const ARBRES_D_UNE_LANGUE = ['src', 'electron', 'scripts']; +const EXTENSIONS_D_UNE_LANGUE = new Set(['.js', '.cjs', '.mjs', '.svelte', '.css', '.json']); + +// Les scripts d'installation et de construction, chacun à sa place : ils +// posent l'outillage ou le lancent, et ne refont aucun calcul du logiciel. +const SCRIPTS_NOMMES = [ + { fichier: 'scripts/construire_windows.sh', raison: 'construit l’exécutable Windows depuis Linux (§ 16)' }, + { fichier: 'scripts/verifier_systemes.sh', raison: 'éprouve l’installation sur chaque système, dans un conteneur' }, + { fichier: 'scripts/installation/avec_node.sh', raison: 'lance une commande avec le Node du projet' }, + { fichier: 'scripts/installation/commun.sh', raison: 'fonctions communes des scripts d’installation' }, + { fichier: 'scripts/installation/shell.nix', raison: 'environnement de développement sous NixOS' }, + { + fichier: 'scripts/oracle/pcg32_reference.sh', + raison: 'compile et lance la référence de PCG32, oracle pris hors du code éprouvé (§ 14.11)', + }, + { fichier: 'scripts/windows/avec_node.ps1', raison: 'lance une commande avec le Node du projet, sous Windows' }, + { fichier: 'scripts/windows/commun.ps1', raison: 'fonctions communes des scripts Windows' }, + { fichier: 'scripts/windows/installer.ps1', raison: 'installe le projet sous Windows' }, + { fichier: 'scripts/windows/lancer.ps1', raison: 'lance le projet sous Windows' }, +]; + +// Fichiers que le disque porte sans qu'ils soient du projet : un nom caché +// (.DS_Store, l'échange .x.js.swp de Vim), une sauvegarde d'éditeur (x.js~, +// #x.js#), un reste de fusion ou de correctif (x.js.orig, x.js.rej). Le relevé +// des langues est le seul à admettre une extension par sa liste plutôt qu'à +// choisir la sienne : sans ce filtre, il tombe sur un arbre conforme dès qu'un +// éditeur est ouvert, et la série surveillée le relance à chaque sauvegarde. +// La liste de git ne les écarte pas : ces noms ne sont pas ignorés par le +// dépôt, et --others les rend comme tout fichier nouveau. +const estArtefact = (nom) => nom.startsWith('.') || nom.endsWith('~') || /^#.*#$/.test(nom) || /\.(orig|rej)$/.test(nom); + +// Relevé des fichiers d'une autre langue sous src/, electron/ et scripts/, à +// toute profondeur, artefacts d'éditeur et de fusion écartés : chacun +// « chemin : extension », ou « chemin : sans extension », dans l'ordre des +// chemins, hors des extensions admises et des scripts nommés ; puis +// « exception sans objet : fichier » pour un script nommé absent. Lève quand +// ces arbres ne portent aucun fichier. +function releverLangages(racine, nommes = SCRIPTS_NOMMES) { + const fichiers = ARBRES_D_UNE_LANGUE.flatMap((arbre) => fichiersDe(join(racine, arbre))) + .filter((fichier) => !fichier.split(sep).includes('node_modules') && !estArtefact(basename(fichier))) + .map((fichier) => enBarres(racine, fichier)) + .sort(); + assert.ok(fichiers.length > 0, 'aucun fichier sous src/, electron/ et scripts/'); + const presents = new Set(fichiers); + const refus = fichiers + .filter((chemin) => !EXTENSIONS_D_UNE_LANGUE.has(extname(chemin)) && !nommes.some((e) => e.fichier === chemin)) + .map((chemin) => `${chemin} : ${extname(chemin) || 'sans extension'}`); + const sansObjet = nommes.filter(({ fichier }) => !presents.has(fichier)).map(({ fichier }) => `exception sans objet : ${fichier}`); + return [...refus, ...sansObjet]; +} + +// Les épreuves de src/ qui ne vivent pas à côté d'un module de leur +// répertoire, chacune avec sa raison. +const EPREUVES_SANS_VOISIN = [ + { + fichier: 'src/interface/accueil/Accueil.navigateur.test.js', + raison: 'l’accueil n’existe qu’au sein de l’application : l’épreuve monte App par test/navigateur/montage.js', + }, + { + fichier: 'src/interface/propositions/Propositions.navigateur.test.js', + raison: 'les propositions se lisent dans l’application montée, après génération : l’épreuve monte App', + }, + { + fichier: 'src/interface/plan/ecouteurs.test.js', + raison: 'garde des sources : elle lit les composants de son répertoire par le disque, sans les importer', + }, +]; + +// Relevé des épreuves de src/ éloignées de leur module (§ 14.9) : chaque +// *.test.js de src/, à toute profondeur, dont aucun chargement de code ne +// désigne un fichier existant de son propre répertoire qui ne soit pas une +// épreuve — module ou composant ; le suffixe de Vite se retire, et +// « ./travailleur.js?worker&inline » désigne le module voisin —, hors des +// exceptions nommées : chacune « épreuve : aucun module de répertoire », +// dans l'ordre des chemins ; puis « exception sans objet : fichier » pour +// une exception qui a désormais un voisin ou n'existe plus. Lève quand src/ +// ne porte aucune épreuve. +function releverEpreuvesEloignees(racine, admis = EPREUVES_SANS_VOISIN) { + const epreuves = fichiersDe(join(racine, 'src')) + .filter((fichier) => EPREUVE.test(fichier)) + .sort(); + assert.ok(epreuves.length > 0, 'aucune épreuve sous src/'); + const sansVoisin = epreuves.filter((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + const code = codeSeul(texte); + return !chargementsLitteraux(texte).some(({ indice, specificateur }) => { + const cible = cibleDe(racine, fichier, specificateur.replace(/\?.*$/, '')); + return ( + estDuCode(code, indice) && + cible !== null && + dirname(cible) === dirname(fichier) && + !EPREUVE.test(cible) && + existsSync(cible) + ); + }); + }); + const noms = sansVoisin.map((fichier) => enBarres(racine, fichier)); + const refus = noms + .filter((nom) => !admis.some((e) => e.fichier === nom)) + .map((nom) => `${nom} : aucun module de ${dirname(nom)}`); + const sansObjet = admis.filter(({ fichier }) => !noms.includes(fichier)).map(({ fichier }) => `exception sans objet : ${fichier}`); + return [...refus, ...sansObjet]; +} + +// Les fichiers livrés qu'une donnée d'épreuve ne double pas (§ 14.9) : les +// exemples du § 10.3 et les démonstrations livrées. +const ARBRES_LIVRES = ['exemples', 'src/demo/livrees']; + +// Relevé des données de test/fixtures/, à toute profondeur, égales octet +// pour octet à un fichier livré : chacune « donnée = fichier livré », dans +// l'ordre des chemins. Lève quand test/fixtures/ ou les arbres livrés ne +// portent aucun fichier. +function releverFixturesLivrees(racine) { + const fixtures = fichiersDe(join(racine, 'test', 'fixtures')).sort(); + const livres = ARBRES_LIVRES.flatMap((arbre) => fichiersDe(join(racine, arbre))).sort(); + assert.ok(fixtures.length > 0, 'test/fixtures/ ne porte aucune donnée'); + assert.ok(livres.length > 0, 'aucun fichier livré sous exemples/ ni src/demo/livrees/'); + const octets = new Map(livres.map((fichier) => [fichier, readFileSync(fichier)])); + return fixtures.flatMap((fixture) => { + const contenu = readFileSync(fixture); + return livres + .filter((livre) => octets.get(livre).equals(contenu)) + .map((livre) => `${enBarres(racine, fixture)} = ${enBarres(racine, livre)}`); + }); +} + +// Les seules épreuves qui chargent fast-check (§ 14.12) : la formule du +// plafond et la détection du séparateur, qui gagnent au rétrécissement ; et +// deux propriétés du stockage, sur des paires de charges et des séances +// tirées, dont le contre-exemple rétréci tient en quelques opérations. +const CHARGEURS_DE_FAST_CHECK = [ + { fichier: 'src/moteur/plafond.test.js', raison: 'la formule du plafond, arbitrée par énumération exhaustive' }, + { fichier: 'src/moteur/plafond.long.test.js', raison: 'la grande grille du plafond, sous la même graine écrite' }, + { fichier: 'src/csv/lecture.test.js', raison: 'la détection du séparateur, arbitrée par construction' }, + { + fichier: 'src/stockage/correctifs.test.js', + raison: 'l’aller-retour appliquer(a, difference(a, b)) sur des paires de charges tirées', + }, + { + fichier: 'src/stockage/journal.long.test.js', + raison: 'chaque instant d’une séance tirée se restitue tel qu’il s’est écrit', + }, +]; + +// Relevé des chargements de fast-check, sous-chemins compris, dans les +// fichiers JavaScript et les composants du projet, hors des épreuves +// nommées : chacun « fichier:ligne spécificateur », par fichier puis dans +// l'ordre du texte ; puis « exception sans objet : fichier » pour une +// épreuve nommée qui ne le charge pas. Un chargement écrit dans une chaîne, +// donnée d'épreuve, n'en est pas un. Lève quand le projet ne porte aucun +// module. +function releverFastCheck(racine, nommes = CHARGEURS_DE_FAST_CHECK) { + const fichiers = fichiersDuProjet(racine).filter( + (fichier) => EXTENSIONS_MODULE.has(extname(fichier)) || extname(fichier) === '.svelte', + ); + assert.ok(fichiers.length > 0, 'aucun module dans le projet'); + const chargeurs = new Set(); + const refus = []; + for (const fichier of fichiers) { + const nom = enBarres(racine, fichier); + const texte = readFileSync(fichier, 'utf8'); + const code = codeSansChaines(fichier, texte); + const trouves = chargementsLitteraux(texte).filter( + ({ indice, specificateur }) => estDuCode(code, indice) && nomDePaquet(specificateur) === 'fast-check', + ); + if (trouves.length > 0) chargeurs.add(nom); + if (nommes.some((e) => e.fichier === nom)) continue; + refus.push(...trouves.map(({ indice, specificateur }) => `${nom}:${ligneDe(texte, indice)} ${specificateur}`)); + } + const sansObjet = nommes.filter(({ fichier }) => !chargeurs.has(fichier)).map(({ fichier }) => `exception sans objet : ${fichier}`); + return [...refus, ...sansObjet]; +} + +describe('arborescence : un seul chemin de code pour les réservations (§ 5.2)', () => { + test('dans src/moteur, seuls configuration.js et diagnostic.js lisent les réservations', () => { + assert.deepEqual(releverLecteursDesReservations(RACINE), []); + }); + + test('la garde relève chaque lecture par sa ligne — propriété, déstructuration, crochet —, hors commentaires, chaînes et épreuves ; un lecteur admis qui ne lit plus est sans objet', () => { + const fichiers = { + 'src/moteur/configuration.js': 'export const normaliser = ({ reservations }) => reservations.length;\n', + 'src/moteur/diagnostic.js': 'export const sonder = (c) => c.reservations;\n', + 'src/moteur/recherche.js': [ + '// les reservations sont fixées avant la recherche', + 'export const rechercher = (configuration) => {', + ' const { tables, reservations } = configuration;', + " const motif = 'reservations';", + ' return configuration?.reservations ?? configuration["reservations"];', + '};', + ].join('\n'), + 'src/moteur/types.js': '/** @property {Reservation[]} reservations */\nexport const fixerReservations = 1;\n', + 'src/moteur/sous/profond.js': 'export const lire = (c) => c.reservations;\n', + 'src/moteur/recherche.test.js': 'const r = configuration.reservations;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverLecteursDesReservations), [ + 'src/moteur/recherche.js:3 reservations', + 'src/moteur/recherche.js:5 reservations', + "src/moteur/recherche.js:5 ['reservations']", + 'src/moteur/sous/profond.js:1 reservations', + ]); + const sansLecture = { ...fichiers, 'src/moteur/diagnostic.js': 'export const sonder = (c) => c.tables;\n' }; + assert.deepEqual(avecArbre(sansLecture, releverLecteursDesReservations).at(-1), 'exception sans objet : src/moteur/diagnostic.js'); + assert.throws(() => avecArbre({ 'src/moteur/a.test.js': '' }, releverLecteursDesReservations), /src\/moteur ne fournit aucun module/); + }); +}); + +describe('arborescence : une seule langue (§ 13.2)', () => { + test('sous src/, electron/ et scripts/, rien que du JavaScript, des composants Svelte, des feuilles de style, des données JSON et les scripts d’installation nommés', () => { + assert.deepEqual(releverLangages(RACINE), []); + }); + + test('la garde relève un second langage, à toute profondeur, et un fichier sans extension ; un script nommé hors de sa place l’est aussi ; un script nommé absent est sans objet', () => { + const fichiers = { + 'src/moteur/a.js': '', + 'src/moteur/b.mjs': '', + 'electron/c.cjs': '', + 'src/interface/D.svelte': '', + 'src/interface/jetons.css': '', + 'src/demo/livrees/e.gtt.json': '{}', + 'src/moteur/calcul.py': '', + 'src/geometrie/vue.ts': '', + 'src/moteur/sous/recherche.wasm': '', + 'electron/Makefile': '', + 'scripts/outil.rb': '', + 'scripts/construire_windows.sh': '', + 'src/installation/avec_node.sh': '', + 'test/aide.py': '', + 'node_modules/p/x.py': '', + }; + const releves = avecArbre(fichiers, releverLangages); + assert.deepEqual( + releves.filter((r) => !r.startsWith('exception sans objet')), + [ + 'electron/Makefile : sans extension', + 'scripts/outil.rb : .rb', + 'src/geometrie/vue.ts : .ts', + 'src/installation/avec_node.sh : .sh', + 'src/moteur/calcul.py : .py', + 'src/moteur/sous/recherche.wasm : .wasm', + ], + ); + assert.ok(releves.includes('exception sans objet : scripts/installation/avec_node.sh')); + assert.ok(!releves.includes('exception sans objet : scripts/construire_windows.sh')); + assert.throws(() => avecArbre({ 'test/a.js': '' }, releverLangages), /aucun fichier sous src\/, electron\/ et scripts\//); + }); + + test('les fichiers cachés et les restes d\u{2019}éditeur ou de fusion ne sont pas du projet : la garde les passe, et relève encore ce qui les côtoie', () => { + const fichiers = { + 'src/moteur/recherche.js': '', + 'src/moteur/.recherche.js.swp': '', + 'src/interface/.DS_Store': '', + 'src/moteur/recherche.js~': '', + 'src/moteur/recherche.js.orig': '', + 'electron/principal.cjs.rej': '', + 'scripts/#notes.txt#': '', + 'src/moteur/calcul.py': '', + 'src/moteur/orig': '', + 'src/moteur/calcul.py.bak': '', + }; + const releves = avecArbre(fichiers, releverLangages).filter((r) => !r.startsWith('exception sans objet')); + assert.deepEqual(releves, ['src/moteur/calcul.py : .py', 'src/moteur/calcul.py.bak : .bak', 'src/moteur/orig : sans extension']); + assert.throws( + () => avecArbre({ 'src/moteur/.a.js.swp': '', 'electron/a.cjs~': '' }, releverLangages), + /aucun fichier sous src\/, electron\/ et scripts\//, + ); + }); +}); + +describe('arborescence : où vivent les épreuves (§ 14.9, § 14.12)', () => { + test('chaque épreuve de src/ importe un module de son propre répertoire, hors des exceptions nommées', () => { + assert.deepEqual(releverEpreuvesEloignees(RACINE), []); + }); + + test('la garde relève une épreuve qui n’importe que d’ailleurs — d’un sous-répertoire, d’un parent, une autre épreuve voisine —, à toute profondeur ; un composant voisin compte ; une exception sans objet se relève', () => { + const fichiers = { + 'src/moteur/plafond.js': '', + 'src/moteur/plafond.test.js': "import { plafond } from './plafond.js';\n", + 'src/moteur/loin.test.js': "import { x } from '../csv/lecture.js';\nimport { y } from './sous/y.js';\n", + 'src/moteur/sous/y.js': '', + 'src/moteur/sous/y.long.test.js': "import { x } from '../plafond.js';\n", + 'src/moteur/voisine.test.js': "import './plafond.test.js';\n// import { plafond } from './plafond.js';\n", + 'src/csv/lecture.js': '', + 'src/interface/Plan.svelte': '', + 'src/interface/Plan.navigateur.test.js': "import Plan from './Plan.svelte';\n", + 'src/interface/travailleur.js': '', + 'src/interface/Travail.navigateur.test.js': "import Travailleur from './travailleur.js?worker&inline';\n", + 'src/interface/Ecran.navigateur.test.js': "import { monter } from '../../test/montage.js';\n", + 'test/a.test.js': "import { x } from '../src/csv/lecture.js';\n", + }; + const exceptions = [ + { fichier: 'src/interface/Ecran.navigateur.test.js', raison: 'monte l’application entière' }, + { fichier: 'src/interface/Absente.navigateur.test.js', raison: 'disparue' }, + ]; + assert.deepEqual(avecArbre(fichiers, (racine) => releverEpreuvesEloignees(racine, exceptions)), [ + 'src/moteur/loin.test.js : aucun module de src/moteur', + 'src/moteur/sous/y.long.test.js : aucun module de src/moteur/sous', + 'src/moteur/voisine.test.js : aucun module de src/moteur', + 'exception sans objet : src/interface/Absente.navigateur.test.js', + ]); + assert.throws(() => avecArbre({ 'src/a.js': '' }, releverEpreuvesEloignees), /aucune épreuve sous src\//); + }); + + test('aucune donnée de test/fixtures/ n’est, octet pour octet, un fichier d’exemples/ ou de src/demo/livrees/', () => { + assert.deepEqual(releverFixturesLivrees(RACINE), []); + }); + + test('la garde relève chaque donnée d’épreuve égale à un fichier livré, à toute profondeur, et rien pour un contenu voisin', () => { + const fichiers = { + 'exemples/participants.csv': 'nom\nIris Ombrelle\n', + 'src/demo/livrees/petite.gtt.json': '{"format":1}\n', + 'test/fixtures/csv/copie.csv': 'nom\nIris Ombrelle\n', + 'test/fixtures/plan/sous/petite.gtt.json': '{"format":1}\n', + 'test/fixtures/csv/voisine.csv': 'nom\nIris Ombrelle\r\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFixturesLivrees), [ + 'test/fixtures/csv/copie.csv = exemples/participants.csv', + 'test/fixtures/plan/sous/petite.gtt.json = src/demo/livrees/petite.gtt.json', + ]); + assert.throws(() => avecArbre({ 'exemples/a.csv': 'x' }, releverFixturesLivrees), /test\/fixtures\/ ne porte aucune donnée/); + assert.throws(() => avecArbre({ 'test/fixtures/a.csv': 'x' }, releverFixturesLivrees), /aucun fichier livré/); + }); + + test('fast-check ne se charge que dans les épreuves que le § 14.12 nomme', () => { + assert.deepEqual(releverFastCheck(RACINE), []); + }); + + test('la garde relève chaque chargement de fast-check hors de la liste, à toute profondeur, et non dans une chaîne ; un fichier nommé qui ne le charge plus est sans objet', () => { + const fichiers = { + 'src/moteur/plafond.test.js': "import fc from 'fast-check';\n", + 'src/moteur/recherche.test.js': "import fc from 'fast-check';\nconst arb = require('fast-check/lib/arbitrary');\n", + 'src/stockage/sous/depot.long.test.js': 'const fc = await import(`fast-check`);\n', + 'test/aide.js': "const texte = \"import fc from 'fast-check';\";\n", + 'node_modules/p/x.js': "import fc from 'fast-check';\n", + }; + const nommes = [ + { fichier: 'src/moteur/plafond.test.js', raison: 'la formule du plafond' }, + { fichier: 'src/csv/lecture.test.js', raison: 'la détection du séparateur' }, + ]; + assert.deepEqual(avecArbre(fichiers, (racine) => releverFastCheck(racine, nommes)), [ + 'src/moteur/recherche.test.js:1 fast-check', + 'src/moteur/recherche.test.js:2 fast-check/lib/arbitrary', + 'src/stockage/sous/depot.long.test.js:1 fast-check', + 'exception sans objet : src/csv/lecture.test.js', + ]); + assert.throws(() => avecArbre({ 'README.md': '' }, releverFastCheck), /aucun module dans le projet/); + }); +}); diff --git a/test/chargements.js b/test/chargements.js index fe2de15..2eed66f 100644 --- a/test/chargements.js +++ b/test/chargements.js @@ -5,8 +5,9 @@ // l'arborescence (test/arborescence.test.js) et du livrable // (test/livrable.test.js) : import et export statiques, import seul, import() // et require(). Un composant Svelte se lit par le texte de ses blocs de -// script, que rend scriptsDe. -// +// script et des import() de son gabarit, que rend scriptsDe. +import { parse } from 'svelte/compiler'; + // Le relevé lit le texte entier, commentaires et chaînes compris. Une // instruction à « from » se lit à partir de ce mot, que seuls import et // export font suivre d'une chaîne : un motif parti du mot-clé s'arrêterait à @@ -58,19 +59,51 @@ export function blocsScript(texte) { }); } -// 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. +// Les import() du gabarit d'un composant Svelte, en étendues { debut, fin } +// dans l'ordre du texte : chaque nœud ImportExpression de l'arbre que rend +// l'analyseur de Svelte (svelte/compiler) hors des blocs de script et de la +// feuille de style — un bloc {#await import(…)}, un attribut, une +// expression à toute profondeur. Le texte d'un nœud n'est pas une +// expression : « import » dans une phrase n'en donne aucun. null quand +// l'analyseur refuse le composant. +function importsDuGabarit(texte) { + let arbre; + try { + arbre = parse(texte, { modern: true }); + } catch { + return null; + } + const etendues = []; + const vus = new WeakSet(); + const parcourir = (noeud) => { + if (noeud === null || typeof noeud !== 'object' || vus.has(noeud)) return; + vus.add(noeud); + if (noeud.type === 'ImportExpression') etendues.push({ debut: noeud.start, fin: noeud.end }); + for (const valeur of Object.values(noeud)) parcourir(valeur); + }; + parcourir(arbre.fragment); + return etendues.sort((a, b) => a.debut - b.debut); +} + +// Le texte qu'un relevé de chargements lit dans un composant Svelte, à la +// longueur du composant : ses blocs de script et les import() de son +// gabarit gardés, tout autre caractère devenu 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 autres expressions du gabarit ne +// sont pas lus : le texte d'un nœud — « import » dans une phrase — ne charge +// aucun module. Un composant que l'analyseur de Svelte refuse se lit en +// entier, gabarit compris : le relevé y trouve plus, jamais moins, et la +// construction le refuse de toute façon. export function scriptsDe(texte) { + const gabarit = importsDuGabarit(texte); + if (gabarit === null) return texte; const blanc = (morceau) => morceau.replace(/[^\n]/g, ' '); + const gardes = [...blocsScript(texte), ...gabarit].sort((a, b) => a.debut - b.debut); 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; + for (const etendue of gardes) { + sortie += blanc(texte.slice(fin, etendue.debut)) + texte.slice(etendue.debut, etendue.fin); + fin = etendue.fin; } return sortie + blanc(texte.slice(fin)); } diff --git a/test/chargements.test.js b/test/chargements.test.js index 0bb6a32..24bb539 100644 --- a/test/chargements.test.js +++ b/test/chargements.test.js @@ -93,8 +93,8 @@ describe('chargements', () => { 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. + // « import » en texte, un import() en expression du gabarit, un style à + // @import, et une balise de script en commentaire HTML. const COMPOSANT = [ '', + "{#await import('../stockage/depot.js') then depot}{/await}", + '{#if ouvert}', + " ", + ' {#each [1] as n}', + "

{n}

", + ' {/each}', + '{/if}', + "

import('./texte.js')

", + ].join('\n'); + const scripts = scriptsDe(texte); + assert.equal(scripts.length, texte.length); + assert.deepEqual(lus(scripts), ['4 ../stockage/depot.js', '6 ./panneau.js']); + }); + + test('un composant que l’analyseur de Svelte refuse se lit en entier, gabarit compris : la garde relève plus, jamais moins', () => { + const texte = "

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

\n
\n