[ADD] tests: guards for layers, clock, sources, colours, watch patterns

Guards scan the tree file by file and line by line, fail on an empty scan,
and are each shown red on a temporary tree. They cover the interface import
graph, the Electron bridge, one clock, refused dependencies, how tests are
written, hard-coded strings, translation keys, forbidden words, colours and
the conflict hue, markup, pruned selectors and watch patterns. Coverage
keeps the engine integration test on a small machine. Fixes: delays in
tests, the conflict hatch class; « la rend évitable » is a named exception
(§ 5.6). Checked: 2587 node, 465 browser tests, from the index alone.

--- FR ---

[ADD] épreuves : gardes des couches, horloge, sources, couleurs, motifs

Les gardes relèvent par fichier et par ligne, échouent sur un balayage vide
et sont chacune montrées rouges sur un arbre temporaire. Elles couvrent le
graphe d'imports de l'interface, le pont d'Electron, l'horloge unique, les
dépendances refusées, l'écriture des épreuves, les chaînes en dur, les clés
de traduction, les mots interdits, les couleurs et la teinte de conflit, le
balisage, les sélecteurs élagués et les motifs de surveillance. Corrigés :
délais d'épreuves, classe de la trame de conflit ; « la rend évitable » est
une exception nommée (§ 5.6). Vérifié : 2587 node, 465 navigateur.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 13:21:21 -04:00
parent 811cd16b76
commit 4b442ded14
12 changed files with 2886 additions and 74 deletions

View file

@ -574,6 +574,16 @@ describe('version : contrôle du § 18.5, un défaut à la fois', () => {
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : un composant de src/interface qui porte la version affichée en dur', () =>
surArbre((racine) => {
ecrire(
racine,
'src/interface/Bandeau.svelte',
"<script>\n let { version } = $props();\n</script>\n\n<span class=\"version\">{version}</span>\n<span>v2026.10.05.01</span>\n",
);
exigerEchecs(racine, [6], [/^6\. src\/interface\/Bandeau\.svelte, ligne 6 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : une forme technique hors de package.json et du module engendré', () =>
surArbre((racine) => {
ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n");

View file

@ -1981,7 +1981,9 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => {
// Laisse passer les tâches en attente : l'écrivain différé des réglages
// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse.
const laisser = () => new Promise((resolve) => setTimeout(resolve, 0));
// setImmediate rappelle une fois vidées les microtâches, où s'enchaînent
// les écritures du système de fichiers d'épreuve ; il n'attend aucune durée.
const laisser = () => new Promise((resolve) => setImmediate(resolve));
// Compte les objets atteints depuis valeur, chacun une fois, en affirmant
// que chacun est figé.

View file

@ -25,7 +25,7 @@
</pattern>
<pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)">
<rect class="fond-table" width="12" height="12" />
<line class="trait-trame" x1="6" y1="0" x2="6" y2="12" />
<line class="trait-conflit" x1="6" y1="0" x2="6" y2="12" />
</pattern>
</defs>
@ -43,7 +43,7 @@
fill: var(--couleur-table-fond);
}
.trait-trame {
.trait-conflit {
stroke: var(--couleur-conflit);
stroke-width: 3;
}

View file

@ -836,10 +836,32 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
// entre elles : la seconde Échap ferme le <dialog> sans que cancel puisse
// l'empêcher. La fenêtre fermée pendant l'attente se rouvre aussitôt,
// modale et toujours occupée ; la fin de l'action seule décide.
async function echapEnRafale(fois) {
for (let i = 0; i < fois; i += 1) await touche('Escape');
// L'événement close part dans une tâche à lui, après la touche.
await new Promise((fin) => setTimeout(fin, 0));
//
// L'événement close part dans une tâche à lui, après la touche : la rafale
// compte les fermetures — l'attribut open retiré, que MutationObserver
// signale dès la touche traitée — et les événements close reçus, et attend
// que chaque fermeture ait eu le sien. Au moins une Échap ferme la fenêtre.
async function echapEnRafale(fenetre, fois) {
let fermetures = 0;
let evenements = 0;
const observateur = new MutationObserver((releves) => {
fermetures += releves.filter(({ oldValue }) => oldValue !== null).length;
});
const surClose = () => {
evenements += 1;
};
observateur.observe(fenetre, { attributes: true, attributeFilter: ['open'], attributeOldValue: true });
fenetre.addEventListener('close', surClose);
try {
for (let i = 0; i < fois; i += 1) await touche('Escape');
await vi.waitFor(() => {
expect(fermetures).toBeGreaterThan(0);
expect(evenements).toBe(fermetures);
});
} finally {
observateur.disconnect();
fenetre.removeEventListener('close', surClose);
}
await tick();
}
@ -857,10 +879,10 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fois);
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
// Une seconde rafale, la fenêtre déjà rouverte une fois.
await echapEnRafale(fois);
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
attente.resoudre();
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(0));
@ -874,7 +896,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const montage = await preparer();
const { fenetre, appels, attente } = monterConfirmationDifferee(montage);
await cliquer(fenetre.querySelector('[data-commande="confirmer"]'));
await echapEnRafale(fois);
await echapEnRafale(fenetre, fois);
attendreOccupee(montage, fenetre, appels);
await montage.seance.passerEnLecture();
attente.resoudre();

View file

@ -71,8 +71,10 @@ function planificateur() {
};
}
// Laisse s'écouler les promesses en attente et les rejets non traités.
const attendre = () => new Promise((resolve) => setTimeout(resolve, 0));
// Laisse s'écouler les promesses en attente et les rejets non traités :
// setImmediate rappelle une fois vidées les microtâches, et après l'annonce
// des rejets que rien ne traite ; il n'attend aucune durée.
const attendre = () => new Promise((resolve) => setImmediate(resolve));
// Exécute travail en relevant chaque rejet de promesse que rien ne traite ;
// rend ce relevé.

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,8 @@
// Analyseur des chargements qu'un texte demande, seul lu par les gardes de
// l'arborescence (test/arborescence.test.js) et du livrable
// (test/livrable.test.js) : import et export statiques, import seul, import()
// et require().
// et require(). Un composant Svelte se lit par le texte de ses blocs de
// script, que rend scriptsDe.
//
// Le relevé lit le texte entier, commentaires et chaînes compris. Une
// instruction à « from » se lit à partir de ce mot, que seuls import et
@ -38,3 +39,38 @@ export function chargements(texte) {
)
.sort((a, b) => a.indice - b.indice);
}
// Un bloc de script d'un composant Svelte : une balise <script …>, attributs
// compris — ainsi <script module> —, son contenu, puis </script>, ou la fin
// du texte quand le bloc ne se ferme pas. Un commentaire HTML <!-- … -->
// est reconnu pour être sauté : une balise qui y figure n'ouvre aucun bloc.
const BLOC_OU_COMMENTAIRE = /<!--[\s\S]*?(?:-->|$)|<script(?:\s[^>]*)?>(?<contenu>[\s\S]*?)(?:<\/script\s*>|$)/g;
// Étendues { debut, fin } du contenu des blocs de script d'un composant
// Svelte, balises exclues, dans l'ordre du texte : debut est l'indice du
// premier caractère du contenu, fin celui qui suit son dernier.
export function blocsScript(texte) {
return [...texte.matchAll(BLOC_OU_COMMENTAIRE)]
.filter(({ groups }) => groups.contenu !== undefined)
.map(({ 0: lu, index, groups }) => {
const debut = index + lu.indexOf('>') + 1;
return { debut, fin: debut + groups.contenu.length };
});
}
// Le texte des blocs de script d'un composant Svelte, à la longueur du
// composant : chaque caractère hors de ces blocs devient une espace, fins de
// ligne gardées, si bien que les indices et les numéros de ligne restent
// ceux du composant. Le balisage, le style et les expressions du gabarit ne
// sont pas lus : un composant ne charge un module que dans un bloc de script,
// et le texte d'un nœud — « import » dans une phrase — n'en charge aucun.
export function scriptsDe(texte) {
const blanc = (morceau) => morceau.replace(/[^\n]/g, ' ');
let sortie = '';
let fin = 0;
for (const bloc of blocsScript(texte)) {
sortie += blanc(texte.slice(fin, bloc.debut)) + texte.slice(bloc.debut, bloc.fin);
fin = bloc.fin;
}
return sortie + blanc(texte.slice(fin));
}

View file

@ -3,11 +3,12 @@
// L'analyseur des chargements, test/chargements.js, que lisent les gardes de
// l'arborescence et du livrable. Chaque épreuve donne un texte et les
// chargements qu'il demande, dans l'ordre du texte. Les spécificateurs nus
// chargements qu'il demande, dans l'ordre du texte, ou un composant Svelte et
// le texte de ses blocs de script. Les spécificateurs nus
// des données sont des paquets de devDependencies ou des modules de Node :
// la garde des dépendances lit aussi ce fichier.
import assert from 'node:assert/strict';
import { chargements } from './chargements.js';
import { blocsScript, chargements, scriptsDe } from './chargements.js';
import { describe, test } from './lanceur.js';
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
@ -89,3 +90,53 @@ describe('chargements', () => {
assert.deepEqual(lus(texte), []);
});
});
describe('chargements : les blocs de script d’un composant Svelte', () => {
// Un composant à deux blocs, dont l'un sous attribut, du balisage qui écrit
// « import » en texte et en expression, un style à @import, et une balise
// de script en commentaire HTML.
const COMPOSANT = [
'<script module>',
" import { tirer } from './module.js';",
'</script>',
'',
'<script>',
" import { onMount } from 'svelte';",
" import Voisin from './Voisin.svelte';",
'</script>',
'',
"<!-- <script>import { cache } from './commentaire.js';</script> -->",
"<p>import { faux } from './texte.js';</p>",
"<p>{import('./expression.js')}</p>",
'<style>',
" @import 'jetons.css';",
'</style>',
].join('\n');
test('le texte rendu garde la longueur et les lignes du composant ; seuls les blocs de script y restent', () => {
const scripts = scriptsDe(COMPOSANT);
assert.equal(scripts.length, COMPOSANT.length);
assert.equal(scripts.split('\n').length, COMPOSANT.split('\n').length);
assert.deepEqual(lus(scripts), ['2 ./module.js', '6 svelte', '7 ./Voisin.svelte']);
assert.deepEqual(scripts.split('\n')[5], " import { onMount } from 'svelte';");
assert.match(scripts.split('\n')[10], /^ *$/);
});
test('les étendues désignent le contenu de chaque bloc, balises exclues, et un bloc non fermé court jusqu’à la fin', () => {
const blocs = blocsScript(COMPOSANT);
assert.equal(blocs.length, 2);
assert.deepEqual(
blocs.map(({ debut, fin }) => COMPOSANT.slice(debut, fin)),
["\n import { tirer } from './module.js';\n", "\n import { onMount } from 'svelte';\n import Voisin from './Voisin.svelte';\n"],
);
const ouvert = "<p></p>\n<script lang=\"js\">\nimport { a } from './a.js';";
assert.deepEqual(lus(scriptsDe(ouvert)), ['3 ./a.js']);
});
test('un composant sans bloc de script ne charge rien ; une balise voisine n’ouvre aucun bloc', () => {
for (const texte of ["<p>import { x } from './x.js';</p>", "<scripts>import { x } from './x.js';</scripts>"]) {
assert.deepEqual(blocsScript(texte), [], texte);
assert.deepEqual(lus(scriptsDe(texte)), [], texte);
}
});
});

View file

@ -30,19 +30,28 @@ export const LOURDES = Object.freeze(['src/moteur/integration.test.js']);
/**
* Réglages des projets node et node-long pour un nombre de cœurs.
*
* Sous la mesure de la couverture, aucune épreuve ne quitte la série node :
* les seuils de branches d'indicateurs.js et de plafond.js se mesurent avec
* l'épreuve de bout en bout, sur une petite machine comme sur une grande. La
* mesure ne relance rien sous surveillance ; le budget de relance n'y joue
* pas. Le reste des réglages ne change pas.
*
* @param {number} coeurs entier ≥ 1
* @param {{couverture?: boolean}} [options] couverture vraie quand le
* lanceur mesure la couverture (--coverage)
* @returns {{petiteMachine: boolean, coeurs: number, pool: 'forks'|'threads',
* maxWorkers: number|undefined, horsSerieSurveillee: string[]}}
* maxWorkers undefined laisse au lanceur son défaut
*/
export function reglagesSeries(coeurs) {
export function reglagesSeries(coeurs, { couverture = false } = {}) {
if (!Number.isInteger(coeurs) || coeurs < 1) {
throw new RangeError(`nombre de cœurs ${String(coeurs)} : entier ≥ 1 attendu`);
}
if (coeurs >= SEUIL_COEURS) {
return { petiteMachine: false, coeurs, pool: 'forks', maxWorkers: undefined, horsSerieSurveillee: [] };
}
return { petiteMachine: true, coeurs, pool: 'threads', maxWorkers: coeurs, horsSerieSurveillee: [...LOURDES] };
const horsSerieSurveillee = couverture ? [] : [...LOURDES];
return { petiteMachine: true, coeurs, pool: 'threads', maxWorkers: coeurs, horsSerieSurveillee };
}
// Cœurs que ce processus peut occuper. availableParallelism suit l'affinité

View file

@ -1,9 +1,12 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les réglages des séries node selon le nombre de cœurs (§ 14.14), et leur
// Les réglages des séries node selon le nombre de cœurs (§ 14.14), sous la
// mesure de la couverture comme hors d'elle (§ 14.4, § 14.13), et leur
// application dans vitest.config.js.
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { availableParallelism } from 'node:os';
import { join } from 'node:path';
@ -40,6 +43,18 @@ describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => {
}
});
test('sous la mesure de la couverture, aucune épreuve ne quitte la série node, sur une petite machine comme sur une grande ; le reste des réglages ne change pas', () => {
for (const coeurs of [1, 2, 3, SEUIL_COEURS, 16]) {
assert.deepEqual(
reglagesSeries(coeurs, { couverture: true }),
{ ...reglagesSeries(coeurs), horsSerieSurveillee: [] },
`${coeurs} cœurs`,
);
assert.deepEqual(reglagesSeries(coeurs, { couverture: false }), reglagesSeries(coeurs), `${coeurs} cœurs`);
}
assert.deepEqual(reglagesSeries(2).horsSerieSurveillee, [...LOURDES]);
});
test('un nombre de cœurs qui n’est pas un entier ≥ 1 lève RangeError', () => {
for (const coeurs of [0, -1, 2.5, Number.NaN, '4', undefined]) {
assert.throws(() => reglagesSeries(coeurs), RangeError, String(coeurs));
@ -76,7 +91,9 @@ describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => {
});
describe('machine : vitest.config.js applique les réglages de cette machine', () => {
const reglages = reglagesSeries(coeursDisponibles());
// Les réglages de ce processus : ses cœurs, et la mesure de la couverture
// quand ses arguments portent --coverage, la règle de vitest.config.js.
const reglages = reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') });
test('les projets node et node-long prennent le pool et le nombre de processus retenus', () => {
for (const nom of ['node', 'node-long']) {
@ -102,3 +119,46 @@ describe('machine : vitest.config.js applique les réglages de cette machine', (
assert.equal(navigateur.maxWorkers, undefined);
});
});
// Projets node et node-long de vitest.config.js, chargé dans un processus à
// part dont la machine n'a que deux cœurs — availableParallelism remplacé
// avant tout import — et dont process.argv porte les arguments donnés, comme
// celui de npm run couverture porte --coverage. Rend la promesse de
// { node, long } : exclude du premier, include du second.
async function projetsSurDeuxCoeurs(argumentsDuLanceur) {
const deuxCoeurs = [
"import os from 'node:os';",
"import { syncBuiltinESMExports } from 'node:module';",
'os.availableParallelism = () => 2;',
'syncBuiltinESMExports();',
].join('\n');
const lire = [
"const { default: configuration } = await import('./vitest.config.js');",
'const projet = (nom) => configuration.test.projects.find(({ test: { name } }) => name === nom).test;',
"console.log(JSON.stringify({ node: projet('node').exclude, long: projet('node-long').include }));",
].join('\n');
const { stdout } = await promisify(execFile)(
process.execPath,
['--import', `data:text/javascript,${encodeURIComponent(deuxCoeurs)}`, '--input-type=module', '-e', lire, '--', ...argumentsDuLanceur],
{ cwd: RACINE, encoding: 'utf8' },
);
return JSON.parse(stdout);
}
describe('machine : vitest.config.js garde la série node entière sous la mesure de la couverture (§ 14.13)', () => {
test('sur deux cœurs, --coverage garde les épreuves lourdes dans node, où se mesurent les seuils d’indicateurs.js et de plafond.js ; sans lui, elles passent dans node-long', async () => {
// Les deux processus partent ensemble : la durée de l'épreuve est celle
// d'un seul.
const [mesure, surveillee] = await Promise.all([
projetsSurDeuxCoeurs(['--project', 'node', '--coverage']),
projetsSurDeuxCoeurs(['--project', 'node']),
]);
assert.ok(LOURDES.length > 0, 'aucune épreuve lourde nommée');
for (const fichier of LOURDES) {
assert.ok(!mesure.node.includes(fichier), `${fichier} sort de node sous --coverage`);
assert.ok(!mesure.long.includes(fichier), `${fichier} entre dans node-long sous --coverage`);
assert.ok(surveillee.node.includes(fichier), `${fichier} reste dans node sur deux cœurs`);
assert.ok(surveillee.long.includes(fichier), `${fichier} manque à node-long sur deux cœurs`);
}
});
});

File diff suppressed because it is too large Load diff

View file

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