gestion_table_tournante_libre/scripts/version.test.js
Mathieu Benoit 2bca543e7e [ADD] demo: delivered event files, example CSVs, payload fingerprint
The four demonstrations ship as event files written once by a script,
the source of truth of § 15.5: a test replays the generator and compares
SHA-256 fingerprints of the payload alone, naming the paths that differ,
so a new build version never forces a regeneration. The affiliation
profile is read from the delivered file. The two demonstration CSVs are
the export of those files; the edge-case CSV is written by hand, and
its windows-1252 variant derived. Version and forged-name guards skip
these generated files, as § 18.5 and § 15.6 allow.

Checked: node and long series; the script writes only its seven files.

--- FR ---

[ADD] démo : fichiers d'événement livrés, CSV d'exemple, empreinte

Les quatre démonstrations se livrent en fichiers d'événement qu'un
script écrit une fois, source de vérité du § 15.5 : une épreuve rejoue
le générateur et compare les empreintes SHA-256 de la seule charge, en
nommant les chemins qui diffèrent ; une nouvelle version n'impose donc
aucune régénération. Le profil d'appartenances se lit dans le fichier
livré. Les deux CSV de démonstration sont l'export de ces fichiers ;
celui des cas limites s'écrit à la main, sa variante windows-1252 en
dérive. Les gardes de version et de noms forgés passent ces fichiers.

Vérifié : séries node et longue ; le script n'écrit que ses sept fichiers.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:15 -04:00

867 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la version datée (§ 18). Ce fichier cite des versions en clair
// par nécessité : le balayage du point 6 du § 18.5 l'exclut, et lui seul.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import {
controler,
dateCivileLocale,
dateEnLettres,
deriver,
engendrer,
engendrerModule,
redecomposer,
} from './version.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const SCRIPT = fileURLToPath(new URL('./version.js', import.meta.url));
const UN_JOUR_MS = 86_400_000;
// Les jours d'une année, énumérés par le calendrier de Date.UTC : l'oracle ne
// partage rien avec la règle des mois et des années bissextiles du module
// éprouvé.
function joursDe(annee) {
const jours = [];
for (
let instant = Date.UTC(annee, 0, 1);
new Date(instant).getUTCFullYear() === annee;
instant += UN_JOUR_MS
) {
const date = new Date(instant);
jours.push({ annee, mois: date.getUTCMonth() + 1, jour: date.getUTCDate() });
}
return jours;
}
// Dernier jour du mois, lu dans le même calendrier : le jour 0 d'un mois est
// la veille de son premier jour.
function dernierJour(annee, mois) {
return new Date(Date.UTC(annee, mois, 0)).getUTCDate();
}
const deux = (n) => String(n).padStart(2, '0');
const forme = ({ annee, mois, jour }, rang) =>
`${annee}.${deux(mois)}.${deux(jour)}.${deux(rang)}`;
const champs = (technique) => technique.split('.').map(Number);
// Vrai quand a précède strictement b, comparés champ à champ comme des
// entiers.
function precede(a, b) {
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i];
}
return false;
}
const MOIS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
describe('version : dérivation (§ 18.1, § 18.2)', () => {
test('une version affichée donne ses formes technique et Windows et le nom du livrable', () => {
assert.deepEqual(deriver('2026.10.05.01'), {
affichee: '2026.10.05.01',
technique: '2026.1005.1',
windows: '2026.1005.1.0',
nomFichier: 'gestion_table_tournante_libre_v2026_10_05_01.exe',
annee: 2026,
mois: 10,
jour: 5,
rang: 1,
});
});
test('le mois garde son zéro de tête dans la forme affichée et le perd dans la technique', () => {
assert.equal(deriver('2026.01.05.01').technique, '2026.105.1');
assert.equal(redecomposer('2026.105.1'), '2026.01.05.01');
});
test('la forme technique croît avec la date, champ à champ', () => {
const suite = ['2026.01.05.01', '2026.10.05.01', '2026.12.31.01', '2027.01.05.01'].map(
(version) => champs(deriver(version).technique),
);
assert.deepEqual(suite, [
[2026, 105, 1],
[2026, 1005, 1],
[2026, 1231, 1],
[2027, 105, 1],
]);
for (let i = 1; i < suite.length; i += 1) {
assert.ok(precede(suite[i - 1], suite[i]), `${suite[i - 1]} < ${suite[i]}`);
}
assert.ok(
precede(champs(deriver('2026.10.05.01').technique), champs(deriver('2026.10.05.02').technique)),
);
});
test('aller-retour sur chaque jour de 2026 et de 2028, sur les 99 rangs', () => {
const jours = [...joursDe(2026), ...joursDe(2028)];
assert.equal(jours.length, 365 + 366);
const affichees = [];
const noms = [];
let veille = null;
jours.forEach((date, i) => {
const rang = 1 + (i % 99);
const version = forme(date, rang);
const derivee = deriver(version);
assert.equal(derivee.technique, `${date.annee}.${date.mois * 100 + date.jour}.${rang}`);
assert.equal(derivee.windows, `${derivee.technique}.0`);
assert.equal(
derivee.nomFichier,
`gestion_table_tournante_libre_v${version.replaceAll('.', '_')}.exe`,
);
assert.equal(redecomposer(derivee.technique), version);
// Chaque champ de la ressource de version Windows tient sur 16 bits.
for (const champ of champs(derivee.windows)) assert.ok(champ <= 0xffff, derivee.windows);
const jour = champs(derivee.technique).slice(0, 2);
if (veille !== null) assert.ok(precede(veille, jour), `${veille} < ${jour}`);
veille = jour;
affichees.push(forme(date, 1));
noms.push(deriver(forme(date, 1)).nomFichier);
});
// L'ordre alphabétique des formes affichées et des noms de fichiers est
// l'ordre chronologique.
assert.deepEqual([...affichees].sort(), affichees);
assert.deepEqual([...noms].sort(), noms);
});
test('le dernier jour de chaque mois passe, le lendemain est refusé', () => {
let mois = 0;
for (const annee of [2026, 2028]) {
for (let m = 1; m <= 12; m += 1) {
const dernier = dernierJour(annee, m);
assert.doesNotThrow(() => deriver(forme({ annee, mois: m, jour: dernier }, 1)));
assert.throws(() => deriver(forme({ annee, mois: m, jour: dernier + 1 }, 1)), /jour/);
assert.throws(() => deriver(forme({ annee, mois: m, jour: 0 }, 1)), /jour/);
mois += 1;
}
}
assert.equal(mois, 24);
assert.equal(dernierJour(2026, 2), 28);
assert.equal(dernierJour(2028, 2), 29);
});
test('refuse une forme non conforme, une date invalide, un rang hors de 01 à 99', () => {
const refus = [
['2026.1.5.1', /forme/],
['2026.13.01.01', /mois/],
['2026.00.10.01', /mois/],
['2026.02.30.01', /jour/],
['2026.10.05.00', /rang/],
['2026.10.05.100', /rang.*99/],
['2026.10.05.001', /forme/], // zéro de tête : une faute de forme, pas de plafond
['2026.13.45.100', /mois/], // la date se contrôle avant le plafond du rang
['2026.10.05.1', /forme/],
['2026.10.05', /forme/],
['2026.10.05.01 ', /forme/],
['v2026.10.05.01', /forme/],
['2026-10-05-01', /forme/],
['0999.10.05.01', /année/],
];
for (const [version, motif] of refus) {
assert.throws(() => deriver(version), motif, version);
}
assert.throws(() => deriver(20261005), TypeError);
});
test('accepte le rang 99', () => {
assert.equal(deriver('2026.10.05.99').technique, '2026.1005.99');
assert.equal(redecomposer('2026.1005.99'), '2026.10.05.99');
});
test('redecomposer refuse ce qui ne désigne pas exactement une version', () => {
const refus = [
['2026.0105.1', /forme/], // zéro de tête
['2026.1005.01', /forme/], // zéro de tête
['2026.1005.0', /forme/], // rang 0
['2026.1005.100', /forme/], // rang au-delà de 99
['2026.99.1', /forme/], // mois et jour sur deux chiffres
['2026.1005', /forme/], // deux champs
['2026.10.05.01', /forme/], // forme affichée
['26.1005.1', /forme/], // année sur deux chiffres
['2026.1305.1', /mois/], // mois 13
['2026.230.1', /jour/], // 30 février
['2026.1000.1', /jour/], // jour 0
];
for (const [technique, motif] of refus) {
assert.throws(() => redecomposer(technique), motif, technique);
}
assert.throws(() => redecomposer(2026.1005), TypeError);
});
});
describe('version : date en lettres (§ 18.4)', () => {
test('écrit le jour, le mois en lettres et l’année ; le premier du mois est « 1er »', () => {
assert.equal(dateEnLettres({ annee: 2026, mois: 10, jour: 5 }), '5 octobre 2026');
assert.equal(dateEnLettres({ annee: 2027, mois: 1, jour: 1 }), '1er janvier 2027');
assert.equal(dateEnLettres({ annee: 2028, mois: 2, jour: 29 }), '29 février 2028');
});
test('nomme les douze mois', () => {
assert.deepEqual(
MOIS.map((_, i) => dateEnLettres({ annee: 2026, mois: i + 1, jour: 2 })),
MOIS.map((nom) => `2 ${nom} 2026`),
);
});
test('refuse une date hors du calendrier', () => {
assert.throws(() => dateEnLettres({ annee: 2026, mois: 2, jour: 29 }), /jour/);
assert.throws(() => dateEnLettres({ annee: 2026, mois: 13, jour: 1 }), /mois/);
});
});
// --- Arbres d'épreuve -------------------------------------------------------
const VERSION = '2026.10.05.01';
const TECHNIQUE = '2026.1005.1';
const DATE = '2026-10-05';
// Le module engendré pour VERSION, octet pour octet.
const MODULE = [
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
'',
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
'export const VERSION = Object.freeze({',
" affichee: '2026.10.05.01',",
" technique: '2026.1005.1',",
'});',
'',
].join('\n');
const TITRE_CHANGELOG = '# Changelog — Gestion table tournante Libre';
// Une section de changelog complète : titre, une rubrique, les deux lignes
// obligatoires.
const section = (titre) =>
[
`## ${titre}`,
'',
'**Ce qui change pour vous**',
'- Le bandeau affiche la version.',
'',
'**Version de format des fichiers** : inchangée.',
'',
'**En remplaçant** : rien à faire.',
'',
].join('\n');
const changelog = (...titres) => [TITRE_CHANGELOG, '', ...titres.map(section)].join('\n');
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
function ecrire(racine, chemin, contenu) {
const complet = join(racine, chemin);
mkdirSync(dirname(complet), { recursive: true });
writeFileSync(complet, contenu);
}
const lire = (racine, chemin) => readFileSync(join(racine, chemin), 'utf8');
function modifierJson(racine, chemin, modification) {
const valeur = JSON.parse(lire(racine, chemin));
modification(valeur);
ecrire(racine, chemin, enJson(valeur));
}
// Arbre conforme à VERSION et à DATE : la source, ses trois recopies, un
// changelog, un README qui cite le gabarit du nom, une source et une
// configuration à la racine.
function arbreConforme() {
const racine = mkdtempSync(join(tmpdir(), 'version-'));
ecrire(racine, 'version.json', `{ "version": "${VERSION}" }\n`);
ecrire(racine, 'package.json', enJson({ name: 'essai', version: TECHNIQUE, type: 'module' }));
ecrire(
racine,
'package-lock.json',
enJson({
name: 'essai',
version: TECHNIQUE,
lockfileVersion: 3,
requires: true,
packages: { '': { name: 'essai', version: TECHNIQUE } },
}),
);
ecrire(racine, 'src/version.genere.js', MODULE);
ecrire(
racine,
'src/main.js',
"import { VERSION } from './version.genere.js';\nexport const titre = VERSION.affichee;\n",
);
ecrire(racine, 'vite.config.js', 'export default {};\n');
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 5 octobre 2026`));
ecrire(
racine,
'README.md',
"# Essai\n\nL'exécutable se nomme `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.\n",
);
return racine;
}
// Exécute l'épreuve sur un arbre conforme neuf, supprimé ensuite.
function surArbre(epreuve) {
const racine = arbreConforme();
try {
return epreuve(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
// Numéros de point des échecs, sans doublon, croissants. Un échec sans
// numéro fait échouer l'épreuve.
function points(echecs) {
const numeros = echecs.map((echec) => {
const numero = /^([1-8])\. /.exec(echec);
assert.ok(numero, `échec sans numéro de point : ${echec}`);
return Number(numero[1]);
});
return [...new Set(numeros)].sort((a, b) => a - b);
}
// Contrôle l'arbre et exige que les échecs nomment exactement les points
// attendus, et que chaque motif se lise dans l'un d'eux.
function exigerEchecs(racine, attendus, motifs, dateCivile = DATE) {
const echecs = controler(racine, { dateCivile });
const liste = echecs.join('\n');
assert.deepEqual(points(echecs), attendus, liste);
for (const motif of motifs) {
assert.ok(
echecs.some((echec) => motif.test(echec)),
`${motif} ne se lit dans aucun échec :\n${liste}`,
);
}
return echecs;
}
describe('version : contrôle du § 18.5, un défaut à la fois', () => {
test("l'arbre de référence est conforme", () =>
surArbre((racine) => {
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 1 : package.json porte une autre version technique', () =>
surArbre((racine) => {
modifierJson(racine, 'package.json', (paquet) => {
paquet.version = '2026.1006.1';
});
exigerEchecs(racine, [1], [/^1\. package\.json .*2026\.1006\.1.*2026\.1005\.1/]);
}));
test('point 1 : le champ version du verrou porte une autre version', () =>
surArbre((racine) => {
modifierJson(racine, 'package-lock.json', (verrou) => {
verrou.version = '2026.1004.1';
});
exigerEchecs(racine, [1], [/^1\. package-lock\.json, champ « version » .*2026\.1004\.1/]);
}));
test('point 1 : le champ packages[""].version du verrou porte une autre version', () =>
surArbre((racine) => {
modifierJson(racine, 'package-lock.json', (verrou) => {
verrou.packages[''].version = '2026.1004.1';
});
exigerEchecs(
racine,
[1],
[/^1\. package-lock\.json, champ « packages\[""\]\.version » .*2026\.1004\.1/],
);
}));
test('point 1 : le verrou manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'package-lock.json'));
exigerEchecs(racine, [1], [/^1\. package-lock\.json absent/]);
}));
test('point 1 : version.json manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'version.json'));
exigerEchecs(racine, [1], [/^1\. version\.json absent/]);
}));
test('point 1 : version.json porte une version non conforme', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
exigerEchecs(racine, [1], [/^1\. version\.json : .*rang 100.*99/]);
}));
test('point 2 : le module engendré est modifié à la main', () =>
surArbre((racine) => {
ecrire(racine, 'src/version.genere.js', MODULE.replace("'2026.1005.1'", "'2026.1005.2'"));
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js, ligne 7 /]);
}));
test('point 2 : le module engendré manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'src/version.genere.js'));
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js absent/]);
}));
test('point 3 : le titre de tête ne porte pas la version courante', () =>
surArbre((racine) => {
ecrire(racine, 'CHANGELOG.md', changelog('2026.10.04.01 — 4 octobre 2026'));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*2026\.10\.04\.01.*2026\.10\.05\.01/]);
}));
test('point 3 : la date en lettres du titre de tête est fausse', () =>
surArbre((racine) => {
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 6 octobre 2026`));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*6 octobre 2026.*5 octobre 2026/]);
}));
test('point 3 : la ligne de la version de format manque', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(racine, 'CHANGELOG.md', texte.replace('**Version de format des fichiers** : inchangée.\n', ''));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*Version de format des fichiers/]);
}));
test('point 3 : la ligne « En remplaçant » manque', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(racine, 'CHANGELOG.md', texte.replace('**En remplaçant** : rien à faire.\n', ''));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*En remplaçant/]);
}));
test('point 3 : le changelog manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'CHANGELOG.md'));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md absent/]);
}));
test('point 3 : une ligne obligatoire absente de la section de tête échoue, même portée par une section plus ancienne', () => {
const lignes = [
['**Version de format des fichiers** : inchangée.\n', /^3\. CHANGELOG\.md, ligne 3 : .*Version de format des fichiers/],
['**En remplaçant** : rien à faire.\n', /^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/],
];
for (const [ligne, motif] of lignes) {
surArbre((racine) => {
const texte = changelog(`${VERSION} — 5 octobre 2026`, '2026.10.04.01 — 4 octobre 2026');
// replace ne retire que la première occurrence : celle de la section de tête.
ecrire(racine, 'CHANGELOG.md', texte.replace(ligne, ''));
exigerEchecs(racine, [3], [motif]);
});
}
});
test('point 3 : une ligne obligatoire citée dans un bloc de code ne compte pas', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace('**En remplaçant** : rien à faire.\n', '```\n**En remplaçant** : rien à faire.\n```\n'),
);
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/]);
}));
test('points 3 et 4 : une section « À venir » en tête', () =>
surArbre((racine) => {
// La section « À venir » porte ses deux lignes obligatoires : seul son
// titre fait tomber le point 3.
ecrire(racine, 'CHANGELOG.md', changelog('À venir', `${VERSION} — 5 octobre 2026`));
exigerEchecs(racine, [3, 4], [
/^3\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » n'a pas la forme/,
/^4\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » ne s'ouvre pas sur une version/,
]);
}));
test('point 4 : une section « À venir » entre deux versions', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, 'À venir', '2026.10.04.01 — 4 octobre 2026'),
);
exigerEchecs(racine, [4], [
/^4\. CHANGELOG\.md, ligne 12 : le titre « ## À venir » ne s'ouvre pas sur une version/,
]);
}));
test('points 3 et 4 : un titre « ## » cité dans un bloc de code ne découpe pas la section', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(
'- Le bandeau affiche la version.\n',
'- Le bandeau affiche la version.\n\n```markdown\n## Notes de la soirée\n```\n',
),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('points 3 et 4 : un sous-titre « ### » ne découpe pas la section', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace('**Ce qui change pour vous**\n', '**Ce qui change pour vous**\n\n### Le bandeau\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 4 : deux sections portent la même version', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, `${VERSION} — 5 octobre 2026`),
);
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, lignes 3 et 12 : .*2026\.10\.05\.01/]);
}));
test('point 4 : une section plus récente suit une plus ancienne', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, '2026.10.06.01 — 6 octobre 2026'),
);
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, ligne 12 : .*2026\.10\.06\.01.*2026\.10\.05\.01/]);
}));
test('point 4 : des sections en ordre strictement décroissant sont admises', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(
`${VERSION} — 5 octobre 2026`,
'2026.10.04.02 — 4 octobre 2026',
'2026.10.04.01 — 4 octobre 2026',
'2025.12.31.01 — 31 décembre 2025',
),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test("point 5 : la version est datée de plus d'un jour après la date civile", () =>
surArbre((racine) => {
exigerEchecs(racine, [5], [/^5\. version\.json : .*5 octobre 2026.*3 octobre 2026/], '2026-10-03');
}));
test("point 5 : un jour d'avance sur la date civile est toléré", () =>
surArbre((racine) => {
assert.deepEqual(controler(racine, { dateCivile: '2026-10-04' }), []);
assert.deepEqual(controler(racine, { dateCivile: '2027-03-01' }), []);
}));
test('point 6 : une forme affichée dans src/ hors du module engendré', () =>
surArbre((racine) => {
ecrire(racine, 'src/interface/bandeau.js', "// Bandeau.\n\nexport const v = 'v2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 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");
ecrire(racine, 'scripts/construire.sh', '#!/bin/sh\nWINDOWS=2026.1005.1.0\n');
exigerEchecs(racine, [6], [
/^6\. vite\.config\.js, ligne 1 : « 2026\.1005\.1 », forme technique/,
/^6\. scripts\/construire\.sh, ligne 2 : « 2026\.1005\.1 », forme technique/,
]);
}));
test('point 6 : une forme affichée dans package.json', () =>
surArbre((racine) => {
modifierJson(racine, 'package.json', (paquet) => {
paquet.description = 'version 2026.10.05.01';
});
exigerEchecs(racine, [6], [/^6\. package\.json, ligne \d+ : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : les épreuves de test/ sont dans le périmètre', () =>
surArbre((racine) => {
ecrire(racine, 'test/bandeau.test.js', "export const attendu = '2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. test\/bandeau\.test\.js, ligne 1 /]);
}));
test('point 6 : la coquille electron/ est dans le périmètre', () =>
surArbre((racine) => {
ecrire(racine, 'electron/main.js', "const titre = 'Gestion table tournante Libre — 2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. electron\/main\.js, ligne 1 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : une version ne se lit pas dans un nombre plus long qui la contient', () =>
surArbre((racine) => {
// Un chiffre ou un point devant, un chiffre derrière : chaque ligne
// prolonge une version d'un côté, sous l'une des deux formes.
ecrire(
racine,
'src/donnees.js',
[
"export const a = '12026.10.05.01';",
"export const b = '1.2026.10.05.01';",
"export const c = '2026.10.05.012';",
"export const d = '12026.1005.1';",
"export const e = '1.2026.1005.1';",
"export const f = '2026.1005.123';",
'',
].join('\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test("point 6 : un fichier binaire, qui porte un octet nul, n'est pas lu comme du texte", () =>
surArbre((racine) => {
ecrire(racine, 'src/interface/icone.png', `PNG\u0000${VERSION} ${TECHNIQUE}\n`);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 6 : seul le répertoire des démonstrations livrées est exclu, pas un fichier qui porte son nom', () =>
surArbre((racine) => {
ecrire(racine, 'src/demo/livrees.js', "export const v = '2026.10.05.01';\n");
ecrire(racine, 'src/demo/livrees/sous/grande.gtt.json', '{"produitVersion":"2026.10.05.01"}\n');
const echecs = exigerEchecs(racine, [6], [/^6\. src\/demo\/livrees\.js, ligne 1 : « 2026\.10\.05\.01 », forme affichée/]);
assert.equal(echecs.length, 1, echecs.join('\n'));
}));
test('point 6 : données, guides, dépendances et sorties restent hors du périmètre', () =>
surArbre((racine) => {
const versions = `${VERSION} ${TECHNIQUE} ${TECHNIQUE}.0\n`;
for (const chemin of [
'scripts/version.test.js',
'test/fixtures/demonstration.json',
'src/demo/livrees/grande.gtt.json',
'GUIDE-USAGE.md',
'docs/notes.md',
'node_modules/paquet/index.js',
'src/node_modules/paquet/index.js',
'www/assets/index.js',
'dist/sortie.js',
'dist-electron/main.js',
'android/app/build.gradle',
]) {
ecrire(racine, chemin, versions);
}
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion ${versions}`);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 7 : un document porte le littéral #_#', () =>
surArbre((racine) => {
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion : #_#\n`);
exigerEchecs(racine, [7], [/^7\. README\.md, ligne 5 : .*#_#/]);
}));
test("point 7 : un document cite un nom d'exécutable hors du gabarit", () =>
surArbre((racine) => {
const fautifs = [
'gestion_table_tournante_libre_v2026.10.05.01.exe',
'gestion_table_tournante_libre_v2026_10_05_1.exe',
'gestion_table_tournante_libre_v2026_13_05_01.exe',
'gestion_table_tournante_libre_v<AAAA>.exe',
'gestion_table_tournante_libre.exe',
'Gestion_Table_Tournante_Libre_v2026_10_05_01.EXE',
];
ecrire(racine, 'GUIDE-WINDOWS.md', fautifs.map((nom) => `Lancer \`${nom}\`.\n`).join(''));
const echecs = exigerEchecs(
racine,
[7],
fautifs.map((nom, i) => new RegExp(`^7\\. GUIDE-WINDOWS\\.md, ligne ${i + 1} : « ${nom.replace(/[.<>]/g, '\\$&')} »`)),
);
assert.equal(echecs.length, fautifs.length, echecs.join('\n'));
}));
test('point 7 : le gabarit, un nom conforme et le préfixe seul sont admis', () =>
surArbre((racine) => {
ecrire(
racine,
'GUIDE-WINDOWS.md',
[
'Le fichier dont le nom commence par `gestion_table_tournante_libre_v`.',
'Gabarit : `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.',
'Par exemple gestion_table_tournante_libre_v2026_10_05_01.exe.',
'',
].join('\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 7 : le changelog est balayé', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(
'- Le bandeau affiche la version.\n',
'- Le bandeau affiche la version.\n- Lancer `gestion_table_tournante_libre.exe`.\n- Version #_#.\n',
),
);
const echecs = exigerEchecs(racine, [7], [
/^7\. CHANGELOG\.md, ligne 7 : « gestion_table_tournante_libre\.exe »/,
/^7\. CHANGELOG\.md, ligne 8 : littéral « #_# »/,
]);
assert.equal(echecs.length, 2, echecs.join('\n'));
}));
test('point 8 : un balayage qui ne trouve aucune source échoue', () =>
surArbre((racine) => {
rmSync(join(racine, 'src'), { recursive: true });
// Sans src/, le module engendré manque aussi : le point 2 tombe avec.
exigerEchecs(racine, [2, 8], [/^8\. .*src\//]);
}));
test('point 8 : un src/ qui ne porte que le module engendré ne compte aucune source', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'src/main.js'));
const echecs = exigerEchecs(racine, [8], [/^8\. le balayage du point 6 n'examine aucun fichier sous src\//]);
assert.equal(echecs.length, 1, echecs.join('\n'));
}));
test('point 8 : un balayage qui ne trouve aucun document échoue', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'README.md'));
unlinkSync(join(racine, 'CHANGELOG.md'));
// Sans changelog, le point 3 tombe avec.
exigerEchecs(racine, [3, 8], [/^8\. le balayage du point 7 n'examine aucun document/]);
}));
test("modifier version.json seul fait tomber les points 1, 2 et 3, chacun nommant l'endroit resté en arrière", () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
const echecs = exigerEchecs(racine, [1, 2, 3], [
/^1\. package\.json /,
/^1\. package-lock\.json, champ « version » /,
/^1\. package-lock\.json, champ « packages\[""\]\.version » /,
/^2\. src\/version\.genere\.js, ligne 6 /,
/^3\. CHANGELOG\.md, ligne 3 : /,
]);
assert.equal(echecs.length, 5, echecs.join('\n'));
engendrer(racine);
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : /]);
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(`${TITRE_CHANGELOG}\n\n`, `${TITRE_CHANGELOG}\n\n${section('2026.10.05.02 — 5 octobre 2026')}\n`),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('refuse une date civile mal formée', () => {
assert.throws(() => controler(RACINE, { dateCivile: '2026-10-5' }), TypeError);
assert.throws(() => controler(RACINE, { dateCivile: '2026-02-30' }), /jour/);
});
});
describe('version : engendrement', () => {
test("engendrerModule rend le module, l'en-tête de licence en tête", () => {
assert.equal(engendrerModule(VERSION), MODULE);
});
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
const paquet = JSON.parse(lire(racine, 'package.json'));
const verrou = JSON.parse(lire(racine, 'package-lock.json'));
const reecrits = engendrer(racine);
assert.deepEqual(reecrits, ['src/version.genere.js', 'package.json', 'package-lock.json']);
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.06.02'));
assert.equal(lire(racine, 'package.json'), enJson({ ...paquet, version: '2026.1006.2' }));
verrou.version = '2026.1006.2';
verrou.packages[''].version = '2026.1006.2';
assert.equal(lire(racine, 'package-lock.json'), enJson(verrou));
}));
test('sur un arbre à jour, engendrer ne réécrit rien', () =>
surArbre((racine) => {
const fichiers = ['src/version.genere.js', 'package.json', 'package-lock.json'];
const avant = fichiers.map((chemin) => lire(racine, chemin));
assert.deepEqual(engendrer(racine), []);
assert.deepEqual(fichiers.map((chemin) => lire(racine, chemin)), avant);
}));
test('engendrer lève quand le verrou manque, sans rien écrire', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
unlinkSync(join(racine, 'package-lock.json'));
assert.throws(() => engendrer(racine), /package-lock\.json absent.*npm install/);
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
assert.equal(JSON.parse(lire(racine, 'package.json')).version, TECHNIQUE);
}));
test('engendrer refuse le centième rang de la journée, sans rien écrire', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
assert.throws(() => engendrer(racine), /^Error: version\.json : .*99/);
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
}));
});
describe('version : date civile de la machine', () => {
test('dateCivileLocale écrit la date locale sous la forme AAAA-MM-JJ', () => {
assert.equal(dateCivileLocale(new Date(2026, 9, 5, 23, 59)), '2026-10-05');
assert.equal(dateCivileLocale(new Date(2027, 0, 1, 0, 0)), '2027-01-01');
});
});
describe('version : ligne de commande', () => {
// Le script copié dans l'arbre d'épreuve prend pour racine le parent de
// son répertoire, comme dans le projet.
function lancer(racine, ...arguments_) {
const script = join(racine, 'scripts', 'version.js');
mkdirSync(dirname(script), { recursive: true });
copyFileSync(SCRIPT, script);
return spawnSync(process.execPath, [script, ...arguments_], { encoding: 'utf8' });
}
test('controler sort en 0 sur un arbre conforme, en 1 en imprimant les échecs sinon', () =>
surArbre((racine) => {
const conforme = lancer(racine, 'controler');
assert.equal(conforme.status, 0, conforme.stderr);
modifierJson(racine, 'package.json', (paquet) => {
paquet.version = '2026.1006.1';
});
const fautif = lancer(racine, 'controler');
assert.equal(fautif.status, 1, fautif.stdout);
assert.match(fautif.stderr, /^1\. package\.json /m);
}));
test('engendrer réécrit les recopies, et sort en 1 quand le verrou manque', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
const reussi = lancer(racine, 'engendrer');
assert.equal(reussi.status, 0, reussi.stderr);
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.05.02'));
unlinkSync(join(racine, 'package-lock.json'));
const echoue = lancer(racine, 'engendrer');
assert.equal(echoue.status, 1, echoue.stdout);
assert.match(echoue.stderr, /package-lock\.json absent/);
}));
test("une commande inconnue sort en 2 et rappelle l'usage", () =>
surArbre((racine) => {
const inconnue = lancer(racine, 'publier');
assert.equal(inconnue.status, 2);
assert.match(inconnue.stderr, /engendrer.*controler/);
}));
});
describe('version : arbre réel', () => {
test("l'arbre réel du projet est conforme, à la date civile de la machine", () => {
assert.deepEqual(controler(RACINE, { dateCivile: dateCivileLocale() }), []);
});
});