[ADD] tests: spec coverage guards, delivery checks, spec corrections

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

--- FR ---

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

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

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 17:51:49 -04:00
parent ddd587da87
commit fca74b6672
9 changed files with 931 additions and 153 deletions

View file

@ -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<string, string|undefined>} env l'environnement de la coquille
* @param {Record<string, string|undefined>} 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)];
}

View file

@ -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', () => {

191
spec.md
View file

@ -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 `<svg>`
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 `<svg>` 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).
---

View file

@ -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()) {

View file

@ -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': "<script>\n import { VERSION } from '../version.genere.js';\n</script>\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': [
'<script>',
" import { apropos } from './apropos.js';",
'</script>',
"<p>import('../stockage/texte.js') reste du texte</p>",
"{#await import('../stockage/depot.js') then depot}{/await}",
'{#if apropos}',
" <button onclick={() => import('../moteur/recherche.js')}>{apropos}</button>",
'{/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': '<p></p>\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/);
});
});

View file

@ -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));
}

View file

@ -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 = [
'<script module>',
" import { tirer } from './module.js';",
@ -113,13 +113,38 @@ describe('chargements : les blocs de script d’un composant Svelte', () => {
'</style>',
].join('\n');
test('le texte rendu garde la longueur et les lignes du composant ; seuls les blocs de script y restent', () => {
test('le texte rendu garde la longueur et les lignes du composant ; les blocs de script et les import() du gabarit 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(lus(scripts), ['2 ./module.js', '6 svelte', '7 ./Voisin.svelte', '12 ./expression.js']);
assert.deepEqual(scripts.split('\n')[5], " import { onMount } from 'svelte';");
assert.match(scripts.split('\n')[10], /^ *$/);
assert.equal(scripts.split('\n')[11], " import('./expression.js') ");
});
test('un import() du gabarit se lit à sa place, à toute profondeur — bloc {#await}, attribut, bloc imbriqué — ; le mot « import » d’un nœud de texte ou d’une chaîne ne se lit pas', () => {
const texte = [
'<script>',
" let ouvert = $state(false);",
'</script>',
"{#await import('../stockage/depot.js') then depot}{/await}",
'{#if ouvert}',
" <button onclick={() => import('./panneau.js')}>import(</button>",
' {#each [1] as n}',
" <p title={'import(\\'./chaine.js\\')'}>{n}</p>",
' {/each}',
'{/if}',
"<p>import('./texte.js')</p>",
].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 = "<p>{import('./a.js')}</p>\n<div>\n<script>\nimport { b } from './b.js';";
assert.deepEqual(lus(scriptsDe(texte)), ['1 ./a.js', '4 ./b.js']);
});
test('les étendues désignent le contenu de chaque bloc, balises exclues, et un bloc non fermé court jusqu’à la fin', () => {

View file

@ -3,15 +3,19 @@
// Le livrable tel qu'electron-builder.config.cjs le décrit : son nom (§ 18.2),
// sa forme (§ 16), ce qu'il emporte (§ 14.15) et l'identité inscrite dans sa
// ressource de version ; et le nom de l'application, un seul pour Electron,
// pour Capacitor et pour le titre de la fenêtre (§ 18.6). La configuration se
// charge ici comme electron-builder la charge, par require.
// ressource de version ; le nom de l'application, un seul pour Electron,
// pour Capacitor et pour le titre de la fenêtre (§ 18.6) ; le répertoire de
// la page construite, www/, que Vite, Capacitor, la coquille et
// electron-builder désignent ensemble (§ 13.1) ; la langue que la page passe
// à App, jamais la langue témoin (§ 14.6) ; et les démonstrations, composées
// dans src/main.js comme dans le montage des épreuves du navigateur. La
// configuration se charge ici comme electron-builder la charge, par require.
import assert from 'node:assert/strict';
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire, isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { deriver } from '../scripts/version.js';
import { titreAvecVersion } from '../src/interface/libelles.js';
import { VERSION } from '../src/version.genere.js';
@ -219,3 +223,103 @@ describe('livrable : identité', () => {
);
});
});
// Où vit la page construite, lu dans chaque fichier qui la désigne : la
// sortie de la construction de Vite (vite.config.js, chargé comme Vite le
// charge), le répertoire web de Capacitor, la page que la coquille charge
// par loadFile — la constante que l'appel nomme, une adresse relative à
// electron/main.js —, rendue relative à la racine, ou null quand l'appel ou
// sa constante manquent ; et si electron-builder emporte www/ en entier.
async function pageConstruite(racine) {
const { default: vite } = await import(pathToFileURL(join(racine, 'vite.config.js')).href);
const coquille = readFileSync(join(racine, 'electron', 'main.js'), 'utf8');
const appel = /\bloadFile\s*\(\s*([\p{L}_$][\p{L}\p{N}_$]*)\s*\)/u.exec(coquille);
const constante =
appel &&
new RegExp(
String.raw`\bconst\s+${appel[1]}\s*=\s*fileURLToPath\(\s*new\s+URL\(\s*(['"])([^'"]+)\1\s*,\s*import\.meta\.url\s*\)\s*\)`,
).exec(coquille);
return {
sortieDeVite: vite.build?.outDir,
repertoireWeb: lireJson(racine, 'capacitor.config.json').webDir,
pageDeLaCoquille: constante ? relative(racine, resolve(racine, 'electron', constante[2])).split(sep).join('/') : null,
emporte: charger(racine).files.includes('www/**'),
};
}
// La langue que l'appel mount(App, …) d'un texte passe à App : le texte de
// la valeur de sa clé langue, tel qu'écrit — « 'fr' » pour la chaîne —, ou
// null sans appel ou sans clé écrite « langue: ». L'appel court jusqu'à la
// ligne qui s'ouvre sur « }); ». Lève quand l'appel en porte deux.
function langueDeLaPage(texte) {
const debut = texte.search(/\bmount\s*\(\s*App\s*,/);
if (debut === -1) return null;
const fin = texte.indexOf('\n});', debut);
const appel = texte.slice(debut, fin === -1 ? texte.length : fin);
const langues = [...appel.matchAll(/\blangue\s*:\s*([^,\n}]+?)\s*(?=[,\n}])/g)].map(([, valeur]) => valeur);
if (langues.length > 1) throw new Error(`deux langues passées à App : ${langues.join(', ')}`);
return langues[0] ?? null;
}
// L'expression qui compose les démonstrations d'App dans un texte : la
// valeur de l'unique déclaration const dont l'initialiseur nomme LIVREES,
// blancs ramenés à un seul, retirés après une parenthèse, une accolade ou un
// crochet ouvrants et avant leurs fermants, virgule finale retirée. Lève
// quand le texte n'en porte aucune, ou plusieurs.
function compositionDesDemonstrations(texte) {
const compositions = [...texte.matchAll(/\bconst\s+[\p{L}_$][\p{L}\p{N}_$]*\s*=\s*([^;]*\bLIVREES\b[^;]*);/gu)].map(([, valeur]) =>
valeur
.replace(/\s+/g, ' ')
.replace(/,\s*([)}\]])/g, '$1')
.replace(/([({[])\s+/g, '$1')
.replace(/\s+([)}\]])/g, '$1')
.trim(),
);
if (compositions.length === 0) throw new Error('aucune composition de LIVREES');
if (compositions.length > 1) throw new Error('deux compositions de LIVREES');
return compositions[0];
}
describe('livrable : la page construite et ce que la coquille lui passe', () => {
test('la page construite vit dans www/ : la sortie de Vite, le répertoire web de Capacitor, la page que charge la coquille et ce qu’emporte electron-builder (§ 13.1)', async () => {
const page = await pageConstruite(RACINE);
assert.deepEqual(page, {
sortieDeVite: 'www',
repertoireWeb: 'www',
pageDeLaCoquille: 'www/index.html',
emporte: true,
});
});
test('src/main.js passe langue: \'fr\' à App : la construction livrée n’emploie jamais la langue témoin (§ 14.6)', () => {
assert.equal(langueDeLaPage(readFileSync(join(RACINE, 'src', 'main.js'), 'utf8')), "'fr'");
});
test('la langue lue est la seule que porte mount(App, …) : la langue témoin, une variable, une clé abrégée ou absente ne sont pas « fr »', () => {
const page = (ligne) => ["import App from './App.svelte';", 'mount(App, {', ' target: document.body,', ' props: {', ligne, ' demonstrations,', ' },', '});'].join('\n');
assert.equal(langueDeLaPage(page(" langue: 'fr',")), "'fr'");
assert.equal(langueDeLaPage(page(" langue: 'temoin',")), "'temoin'");
assert.equal(langueDeLaPage(page(' langue: LANGUE,')), 'LANGUE');
assert.equal(langueDeLaPage(page(' langue,')), null);
assert.equal(langueDeLaPage(page('')), null);
assert.equal(langueDeLaPage("const langue = 'fr';\nmount(Autre, { props: { langue: 'fr' } });\n"), null);
assert.throws(() => langueDeLaPage(page(" langue: 'fr',\n langue: 'temoin',")), /deux langues/);
});
test('les démonstrations que monterApp donne à App se composent comme celles de src/main.js', () => {
const deMain = compositionDesDemonstrations(readFileSync(join(RACINE, 'src', 'main.js'), 'utf8'));
const duMontage = compositionDesDemonstrations(readFileSync(join(RACINE, 'test', 'navigateur', 'montage.js'), 'utf8'));
assert.ok(deMain.startsWith('LIVREES.'), deMain);
assert.equal(duMontage, deMain);
});
test('la composition se lit à blancs et virgule finale près ; un filtre, un autre ordre ou deux compositions se distinguent', () => {
const une = 'const d = LIVREES.map(({ cle, texte }) => ({\n cle,\n nom: CATALOGUE.find((e) => e.cle === cle).nom,\n texte,\n}));\n';
const resserree = 'const demonstrations = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((e) => e.cle === cle).nom, texte }));\n';
assert.equal(compositionDesDemonstrations(une), compositionDesDemonstrations(resserree));
const filtree = une.replace('LIVREES.map', "LIVREES.filter(({ cle }) => cle !== 'grande').map");
assert.notEqual(compositionDesDemonstrations(filtree), compositionDesDemonstrations(une));
assert.throws(() => compositionDesDemonstrations('const d = [];\n'), /aucune composition de LIVREES/);
assert.throws(() => compositionDesDemonstrations(une + une), /deux compositions de LIVREES/);
});
});

View file

@ -1176,6 +1176,11 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
test('un arbre sans composant ni feuille fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/interface/jetons.css': JETONS_EPREUVE }, releverCouleurs), /couleurs : aucun fichier examiné/);
});
test('une feuille des jetons sans jeton de couleur fait échouer la garde des thèmes', () => {
const fichiers = { 'src/interface/jetons.css': ':root, :root[data-theme="clair"] { --espace-1: 1px; }\n' };
assert.throws(() => avecArbre(fichiers, releverThemes), /thèmes : aucun jeton de couleur examiné/);
});
});
describe('sources de l’interface : balisage et fenêtres du navigateur (§ 13.1, § 2.1)', () => {