[ADD] skeleton: Capacitor web app that shows its dated version
The engine to come must be proven against oracles under node in seconds, so the skeleton sets three test projects: node, watched; node-long; and a real Chromium that reads computed styles in both themes. The version lives in version.json alone; a check refuses eight divergences — technical form, generated module, changelog head, stray version strings — and a build guard keeps test files and fixture assets out of the bundle. Checked: on this commit alone, 73 node tests, 7 browser tests and the version check pass; the guard fails on a fixture joined as an asset. --- FR --- [ADD] squelette : application web Capacitor qui affiche sa version datée Le moteur à venir doit s'éprouver contre des oracles sous node en quelques secondes : le squelette pose trois projets d'épreuve — node, sous surveillance ; node-long ; et un vrai Chromium qui lit le style calculé dans les deux thèmes. La version vit dans version.json seul ; un contrôle refuse huit divergences — forme technique, module engendré, tête du changelog, chaînes de version égarées — et une garde de construction tient fichiers et données d'épreuve hors du paquet. Vérifié : sur ce commit seul, 73 épreuves node, 7 navigateur et le contrôle de version passent ; la garde échoue sur une donnée jointe. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
0bd3f08093
commit
9fc104d0e9
22 changed files with 5304 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
www/
|
||||
dist/
|
||||
dist-electron/
|
||||
android/
|
||||
ios/
|
||||
.vite/
|
||||
.vitest/
|
||||
coverage/
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Changelog — Gestion table tournante Libre
|
||||
|
||||
## 2026.10.05.01 — 5 octobre 2026
|
||||
|
||||
**Ce qui change pour vous**
|
||||
- L'application s'ouvre et affiche sa version dans un bandeau permanent et dans
|
||||
le titre de sa fenêtre.
|
||||
|
||||
**Version de format des fichiers** : aucune — cette version n'enregistre encore
|
||||
aucun événement.
|
||||
|
||||
**En remplaçant** : rien à faire, c'est la première version.
|
||||
5
capacitor.config.json
Normal file
5
capacitor.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"appId": "ca.technolibre.gestiontabletournante",
|
||||
"appName": "Gestion table tournante Libre",
|
||||
"webDir": "www"
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
|
||||
<title></title>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
2835
package-lock.json
generated
Normal file
2835
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "gestion-table-tournante-libre",
|
||||
"productName": "Gestion table tournante Libre",
|
||||
"version": "2026.1005.1",
|
||||
"description": "Placement tournant des participants d’un événement, hors ligne",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "TechnoLibre (http://www.technolibre.ca)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --mode developpement",
|
||||
"build": "vite build --mode developpement",
|
||||
"build:livraison": "node scripts/version.js controler && vite build --mode livraison",
|
||||
"build:documentation": "vite build --mode documentation",
|
||||
"test": "vitest run --project node",
|
||||
"test:surveille": "vitest --project node",
|
||||
"test:long": "vitest run --project node-long",
|
||||
"test:navigateur": "vitest run --project navigateur",
|
||||
"test:tout": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@vitest/browser-playwright": "^5.0.3",
|
||||
"fast-check": "^4.10.2",
|
||||
"playwright": "^1.63.0",
|
||||
"svelte": "^5.57.1",
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
724
scripts/version.js
Normal file
724
scripts/version.js
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La version datée (§ 18). version.json en est l'unique source ; ce script en
|
||||
// dérive tout le reste et contrôle que rien n'en diverge.
|
||||
//
|
||||
// Une version s'écrit sous quatre formes :
|
||||
// affichée AAAA.MM.JJ.NN, quatre champs à longueur fixe, rang de 01 à 99 ;
|
||||
// son ordre alphabétique est l'ordre chronologique ;
|
||||
// technique AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, la forme
|
||||
// qu'exigent package.json et electron-builder ;
|
||||
// Windows la forme technique suivie de « .0 » : les quatre champs de la
|
||||
// ressource de version de l'exécutable, de 16 bits chacun ;
|
||||
// nom gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe, les
|
||||
// points devenus soulignés, seul point restant celui de
|
||||
// l'extension.
|
||||
// Le jour étant inférieur à 100, MM×100+JJ se redécompose d'une seule façon :
|
||||
// la dérivation est monotone et bijective.
|
||||
//
|
||||
// En ligne de commande, la racine du projet est le parent du répertoire du
|
||||
// script :
|
||||
// node scripts/version.js engendrer réécrit src/version.genere.js, le
|
||||
// champ version de package.json et les
|
||||
// deux champs de version du paquet
|
||||
// racine de package-lock.json ;
|
||||
// node scripts/version.js controler applique les huit points du § 18.5 à
|
||||
// la date civile locale de la machine ;
|
||||
// imprime les échecs et sort en code 1,
|
||||
// ou sort en code 0.
|
||||
// Ce script n'appartient ni au moteur ni au générateur de démonstrations, et
|
||||
// l'interdit de lecture d'horloge du § 14.7 ne le vise pas : dateCivileLocale,
|
||||
// qu'appelle la commande « controler », lit l'horloge de la machine.
|
||||
import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Le nom du livrable (§ 18.2) ne s'écrit qu'ici : le produit, « _v », la
|
||||
// version dont les points deviennent des soulignés, l'extension. nomDuLivrable
|
||||
// sert à deriver pour une version et au gabarit pour les champs nommés ; le
|
||||
// point 7 compare chaque nom cité à ce calcul.
|
||||
const PRODUIT = 'gestion_table_tournante_libre';
|
||||
const PREFIXE_NOM = `${PRODUIT}_v`;
|
||||
const EXTENSION = '.exe';
|
||||
const nomDuLivrable = (version) => `${PREFIXE_NOM}${version.replaceAll('.', '_')}${EXTENSION}`;
|
||||
const GABARIT_NOM = nomDuLivrable('<AAAA>.<MM>.<JJ>.<NN>');
|
||||
const UN_JOUR_MS = 86_400_000;
|
||||
|
||||
const MOIS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
];
|
||||
|
||||
// Forme affichée AAAA.MM.JJ.NN. Le rang s'y lit aussi sur trois chiffres ou
|
||||
// plus sans zéro de tête : la forme est alors reconnue, et le refus nomme le
|
||||
// plafond du rang. Un rang « 001 » reste une faute de forme.
|
||||
const FORME_AFFICHEE = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d{2}|[1-9]\d{2,})$/;
|
||||
// Trois entiers sans zéro de tête ; MM×100+JJ s'écrit sur trois ou quatre
|
||||
// chiffres, le rang sur un ou deux.
|
||||
const FORME_TECHNIQUE = /^(\d{4})\.([1-9]\d{2,3})\.([1-9]\d?)$/;
|
||||
|
||||
const deuxChiffres = (n) => String(n).padStart(2, '0');
|
||||
|
||||
function estBissextile(annee) {
|
||||
return (annee % 4 === 0 && annee % 100 !== 0) || annee % 400 === 0;
|
||||
}
|
||||
|
||||
function joursDuMois(annee, mois) {
|
||||
if (mois === 2) return estBissextile(annee) ? 29 : 28;
|
||||
return mois === 4 || mois === 6 || mois === 9 || mois === 11 ? 30 : 31;
|
||||
}
|
||||
|
||||
// Lève quand { annee, mois, jour } n'est pas une date du calendrier
|
||||
// grégorien dont l'année s'écrit sur quatre chiffres sans zéro de tête — la
|
||||
// forme technique écrit l'année en entier, et un zéro de tête s'y perdrait.
|
||||
// « contexte » ouvre le message.
|
||||
function exigerDate({ annee, mois, jour }, contexte) {
|
||||
if (!Number.isInteger(annee) || annee < 1000 || annee > 9999) {
|
||||
throw new RangeError(`${contexte} : année ${annee} hors de 1000 à 9999`);
|
||||
}
|
||||
if (!Number.isInteger(mois) || mois < 1 || mois > 12) {
|
||||
throw new RangeError(`${contexte} : mois ${mois} hors de 1 à 12`);
|
||||
}
|
||||
const dernier = joursDuMois(annee, mois);
|
||||
if (!Number.isInteger(jour) || jour < 1 || jour > dernier) {
|
||||
throw new RangeError(
|
||||
`${contexte} : jour ${jour} hors de 1 à ${dernier} en ${MOIS[mois - 1]} ${annee}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formes dérivées d'une version affichée AAAA.MM.JJ.NN : { affichee,
|
||||
* technique, windows, nomFichier, annee, mois, jour, rang }, les quatre
|
||||
* derniers en entiers. Lève sur une forme non conforme, une date civile
|
||||
* invalide, un rang hors de 1 à 99.
|
||||
*/
|
||||
export function deriver(versionAffichee) {
|
||||
if (typeof versionAffichee !== 'string') {
|
||||
throw new TypeError(`version attendue sous forme de chaîne, reçu ${typeof versionAffichee}`);
|
||||
}
|
||||
const contexte = `version « ${versionAffichee} »`;
|
||||
const champs = FORME_AFFICHEE.exec(versionAffichee);
|
||||
if (champs === null) {
|
||||
throw new Error(
|
||||
`${contexte} : forme attendue AAAA.MM.JJ.NN, quatre champs à longueur fixe (§ 18.1)`,
|
||||
);
|
||||
}
|
||||
const [annee, mois, jour, rang] = champs.slice(1).map(Number);
|
||||
exigerDate({ annee, mois, jour }, contexte);
|
||||
if (rang > 99) throw new RangeError(`${contexte} : rang ${rang}, le rang plafonne à 99 (§ 18.1)`);
|
||||
if (rang < 1) throw new RangeError(`${contexte} : rang 00 hors de 01 à 99`);
|
||||
const technique = `${annee}.${mois * 100 + jour}.${rang}`;
|
||||
return {
|
||||
affichee: versionAffichee,
|
||||
technique,
|
||||
windows: `${technique}.0`,
|
||||
nomFichier: nomDuLivrable(versionAffichee),
|
||||
annee,
|
||||
mois,
|
||||
jour,
|
||||
rang,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version affichée d'une version technique AAAA.(MM×100+JJ).NN. Lève quand
|
||||
* la chaîne ne désigne pas exactement une version : forme non conforme, zéro
|
||||
* de tête, date civile invalide, rang hors de 1 à 99. Sur une chaîne
|
||||
* acceptée, deriver(redecomposer(t)).technique === t.
|
||||
*/
|
||||
export function redecomposer(versionTechnique) {
|
||||
if (typeof versionTechnique !== 'string') {
|
||||
throw new TypeError(
|
||||
`version technique attendue sous forme de chaîne, reçu ${typeof versionTechnique}`,
|
||||
);
|
||||
}
|
||||
const contexte = `version technique « ${versionTechnique} »`;
|
||||
const champs = FORME_TECHNIQUE.exec(versionTechnique);
|
||||
if (champs === null) {
|
||||
throw new Error(
|
||||
`${contexte} : forme attendue AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, rang de 1 à 99 (§ 18.1)`,
|
||||
);
|
||||
}
|
||||
const [annee, moisJour, rang] = champs.slice(1).map(Number);
|
||||
const mois = Math.floor(moisJour / 100);
|
||||
const jour = moisJour % 100;
|
||||
exigerDate({ annee, mois, jour }, contexte);
|
||||
return `${annee}.${deuxChiffres(mois)}.${deuxChiffres(jour)}.${deuxChiffres(rang)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date en toutes lettres du titre d'une section du changelog : « 9 mars
|
||||
* 2031 », « 1er janvier 2027 ». Lève sur une date hors du calendrier.
|
||||
*/
|
||||
export function dateEnLettres({ annee, mois, jour }) {
|
||||
exigerDate({ annee, mois, jour }, 'date');
|
||||
return `${jour === 1 ? '1er' : jour} ${MOIS[mois - 1]} ${annee}`;
|
||||
}
|
||||
|
||||
// --- Le module engendré et les recopies ------------------------------------
|
||||
|
||||
const MODULE = 'src/version.genere.js';
|
||||
const CONSEIL_ENGENDRER = 'lancer « node scripts/version.js engendrer »';
|
||||
|
||||
const EN_TETE_LICENCE = [
|
||||
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
|
||||
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Contenu exact de src/version.genere.js pour une version affichée :
|
||||
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
|
||||
* formes affichée et technique. Le module ne porte que la version ; la
|
||||
* provenance de la construction vient du mode de Vite.
|
||||
*/
|
||||
export function engendrerModule(versionAffichee) {
|
||||
const { affichee, technique } = deriver(versionAffichee);
|
||||
return [
|
||||
...EN_TETE_LICENCE,
|
||||
'',
|
||||
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
|
||||
'export const VERSION = Object.freeze({',
|
||||
` affichee: '${affichee}',`,
|
||||
` technique: '${technique}',`,
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// JSON écrit comme npm l'écrit : indentation de deux espaces, saut de ligne
|
||||
// final.
|
||||
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
|
||||
|
||||
// Contenu d'un fichier relatif à la racine, ou null s'il n'existe pas.
|
||||
function lireTexte(racine, chemin) {
|
||||
try {
|
||||
return readFileSync(join(racine, chemin), 'utf8');
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT') return null;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Valeur JSON d'un fichier relatif à la racine. Lève en nommant le fichier
|
||||
// quand il est illisible, et quand il manque, avec « raisonSiAbsent » pour
|
||||
// suite du message.
|
||||
function lireJson(racine, chemin, raisonSiAbsent) {
|
||||
const texte = lireTexte(racine, chemin);
|
||||
if (texte === null) throw new Error(`${chemin} absent : ${raisonSiAbsent}`);
|
||||
try {
|
||||
return JSON.parse(texte);
|
||||
} catch (erreur) {
|
||||
throw new Error(`${chemin} illisible : ${erreur.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Version affichée que porte version.json, non vérifiée. Lève sur un
|
||||
// fichier absent, illisible ou sans champ « version » textuel.
|
||||
function lireVersion(racine) {
|
||||
const donnees = lireJson(racine, 'version.json', "c'est l'unique source de la version (§ 18.3)");
|
||||
if (typeof donnees?.version !== 'string') {
|
||||
throw new Error('version.json : champ « version » absent ou non textuel');
|
||||
}
|
||||
return donnees.version;
|
||||
}
|
||||
|
||||
// Formes dérivées de la version de version.json. Chaque refus nomme
|
||||
// version.json.
|
||||
function versionDeLaSource(racine) {
|
||||
const affichee = lireVersion(racine);
|
||||
try {
|
||||
return deriver(affichee);
|
||||
} catch (erreur) {
|
||||
throw new Error(`version.json : ${erreur.message}`, { cause: erreur });
|
||||
}
|
||||
}
|
||||
|
||||
// Vrai pour un objet JSON, ni tableau ni null.
|
||||
const estObjet = (valeur) =>
|
||||
typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
||||
const RAISON_PAQUET = 'il porte la forme technique de la version (§ 18.3)';
|
||||
const RAISON_VERROU = `« npm install » le crée, puis ${CONSEIL_ENGENDRER}`;
|
||||
|
||||
/**
|
||||
* Réécrit, depuis version.json, le module engendré, le champ version de
|
||||
* package.json et les champs version et packages[""].version de
|
||||
* package-lock.json, en forme technique. Tout est lu et vérifié avant la
|
||||
* première écriture : une version non conforme, un package.json ou un verrou
|
||||
* absent font lever sans rien écrire. Un fichier déjà à jour n'est pas
|
||||
* réécrit. Rend les chemins réécrits, relatifs à la racine.
|
||||
*/
|
||||
export function engendrer(racine) {
|
||||
const { affichee, technique } = versionDeLaSource(racine);
|
||||
const paquet = lireJson(racine, 'package.json', RAISON_PAQUET);
|
||||
if (!estObjet(paquet)) throw new Error('package.json : un objet JSON est attendu');
|
||||
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
|
||||
if (!estObjet(verrou) || !estObjet(verrou.packages) || !estObjet(verrou.packages[''])) {
|
||||
throw new Error('package-lock.json : paquet racine packages[""] absent');
|
||||
}
|
||||
const cibles = [
|
||||
[MODULE, engendrerModule(affichee)],
|
||||
['package.json', enJson({ ...paquet, version: technique })],
|
||||
[
|
||||
'package-lock.json',
|
||||
enJson({
|
||||
...verrou,
|
||||
version: technique,
|
||||
packages: { ...verrou.packages, '': { ...verrou.packages[''], version: technique } },
|
||||
}),
|
||||
],
|
||||
];
|
||||
const reecrits = [];
|
||||
for (const [chemin, contenu] of cibles) {
|
||||
if (lireTexte(racine, chemin) === contenu) continue;
|
||||
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
|
||||
writeFileSync(join(racine, chemin), contenu);
|
||||
reecrits.push(chemin);
|
||||
}
|
||||
return reecrits;
|
||||
}
|
||||
|
||||
// --- Le contrôle du § 18.5 ------------------------------------------------
|
||||
|
||||
// Les deux lignes obligatoires de la section de tête du changelog (§ 18.4) :
|
||||
// le libellé en gras, les deux-points, puis une réponse.
|
||||
const LIGNES_OBLIGATOIRES = [
|
||||
['**Version de format des fichiers**', /^\*\*Version de format des fichiers\*\*\s*:\s*\S/],
|
||||
['**En remplaçant**', /^\*\*En remplaçant\*\*\s*:\s*\S/],
|
||||
];
|
||||
|
||||
// Périmètre du balayage du point 6, posé ici et non après coup : les fichiers
|
||||
// de la racine, et les arbres des sources, des scripts, de la coquille et des
|
||||
// épreuves. Seuls ces arbres sont parcourus : les dépendances et les sorties
|
||||
// de construction (www/, dist*/, android/) n'en font pas partie, et un
|
||||
// répertoire node_modules n'est jamais descendu. En sont exclus :
|
||||
// - version.json, package-lock.json, le module engendré et le changelog, où
|
||||
// la version se recopie par construction et que les points 1 à 3
|
||||
// contrôlent ;
|
||||
// - les documents (*.md), dont le point 7 contrôle les noms cités ;
|
||||
// - les épreuves de la dérivation, qui citent des versions par nécessité ;
|
||||
// - les données d'épreuve, sous test/fixtures/.
|
||||
// package.json reste balayé : la forme technique y est à sa place, la forme
|
||||
// affichée non.
|
||||
const ARBRES_BALAYES = ['src', 'scripts', 'electron', 'test'];
|
||||
const FICHIERS_EXCLUS = new Set([
|
||||
'version.json',
|
||||
'package-lock.json',
|
||||
MODULE,
|
||||
'CHANGELOG.md',
|
||||
'scripts/version.test.js',
|
||||
]);
|
||||
const REPERTOIRES_EXCLUS = new Set(['test/fixtures']);
|
||||
const DOCUMENT = /\.md$/i;
|
||||
const ARBRE_DES_SOURCES = 'src/';
|
||||
|
||||
// Une chaîne de chaque forme, bornée : ni chiffre ni point devant, aucun
|
||||
// chiffre derrière. La forme Windows contient la forme technique.
|
||||
const CHAINE_AFFICHEE = /(?<![\d.])\d{4}\.\d{2}\.\d{2}\.\d{2}(?!\d)/g;
|
||||
const CHAINE_TECHNIQUE = /(?<![\d.])\d{4}\.\d{3,4}\.\d{1,2}(?!\d)/g;
|
||||
|
||||
// Documents du point 7, à la racine.
|
||||
const DOCUMENTS_FIXES = ['README.md', 'CHANGELOG.md'];
|
||||
const GUIDE = /^GUIDE-.*\.md$/;
|
||||
const LITTERAL_NON_SUBSTITUE = /#_#/g;
|
||||
// Un nom d'exécutable cité : depuis le nom du produit jusqu'à la première
|
||||
// extension, sans blanc ni délimiteur de Markdown ou de code. La casse est
|
||||
// ignorée à la recherche, pas à la conformité. Le préfixe seul, sans
|
||||
// extension, désigne une famille de fichiers et n'est pas un nom.
|
||||
const NOM_CITE = new RegExp(
|
||||
`${RegExp.escape(PRODUIT)}[^\\s\`'"()[\\]{}|*]*?${RegExp.escape(EXTENSION)}(?!\\w)`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Vrai quand le nom cité est le gabarit, ou le nom que deriver calcule pour
|
||||
// la version dont il porte les chiffres, lus après le préfixe. Tout nom admis
|
||||
// sort ainsi de deriver, à l'octet.
|
||||
function nomConforme(nom) {
|
||||
if (nom === GABARIT_NOM) return true;
|
||||
const chiffres = nom.slice(PREFIXE_NOM.length).match(/\d+/g) ?? [];
|
||||
return essayer(() => deriver(chiffres.join('.')).nomFichier) === nom;
|
||||
}
|
||||
|
||||
// Résultat de « calcul », ou null s'il lève.
|
||||
function essayer(calcul) {
|
||||
try {
|
||||
return calcul();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ordre de deux versions dérivées, comparées champ par champ comme quatre
|
||||
// entiers : négatif quand a précède b.
|
||||
function comparerVersions(a, b) {
|
||||
return a.annee - b.annee || a.mois - b.mois || a.jour - b.jour || a.rang - b.rang;
|
||||
}
|
||||
|
||||
// Rang d'une date civile dans le calendrier, en jours : la différence de
|
||||
// deux rangs compte les jours qui séparent les deux dates.
|
||||
const numeroDeJour = ({ annee, mois, jour }) => Date.UTC(annee, mois - 1, jour) / UN_JOUR_MS;
|
||||
|
||||
// { annee, mois, jour } d'une date civile AAAA-MM-JJ ; lève sur toute autre
|
||||
// forme et sur une date hors du calendrier.
|
||||
function lireDateCivile(dateCivile) {
|
||||
const champs =
|
||||
typeof dateCivile === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateCivile) : null;
|
||||
if (champs === null) {
|
||||
throw new TypeError(`dateCivile attendue sous la forme AAAA-MM-JJ, reçu ${String(dateCivile)}`);
|
||||
}
|
||||
const [annee, mois, jour] = champs.slice(1).map(Number);
|
||||
exigerDate({ annee, mois, jour }, `dateCivile « ${dateCivile} »`);
|
||||
return { annee, mois, jour };
|
||||
}
|
||||
|
||||
// Sections du changelog : chaque titre « ## », avec son numéro de ligne, son
|
||||
// texte et les lignes de son corps. Un bloc de code, de la ligne qui l'ouvre
|
||||
// par ``` ou ~~~ à celle qui le ferme, n'apporte ni titre ni ligne de corps :
|
||||
// ce qu'il contient s'affiche comme du code, et une ligne obligatoire citée
|
||||
// là n'est pas écrite.
|
||||
function sectionsDuChangelog(texte) {
|
||||
const sections = [];
|
||||
let dansUnBloc = false;
|
||||
texte.split(/\r?\n/).forEach((ligne, i) => {
|
||||
if (/^\s*(```|~~~)/.test(ligne)) {
|
||||
dansUnBloc = !dansUnBloc;
|
||||
return;
|
||||
}
|
||||
if (dansUnBloc) return;
|
||||
const titre = /^## (.*)$/.exec(ligne);
|
||||
if (titre !== null) {
|
||||
sections.push({ ligne: i + 1, titre: titre[1].trim(), corps: [] });
|
||||
} else if (sections.length > 0) {
|
||||
sections[sections.length - 1].corps.push(ligne);
|
||||
}
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Chemins relatifs, en « / », des fichiers d'un arbre, sans descendre dans
|
||||
// node_modules ni dans un répertoire exclu. Un arbre absent n'ajoute rien.
|
||||
function parcourir(racine, arbre, chemins) {
|
||||
let entrees;
|
||||
try {
|
||||
entrees = readdirSync(join(racine, arbre), { withFileTypes: true });
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT' || erreur.code === 'ENOTDIR') return;
|
||||
throw erreur;
|
||||
}
|
||||
for (const entree of entrees) {
|
||||
const chemin = `${arbre}/${entree.name}`;
|
||||
if (entree.isDirectory()) {
|
||||
if (entree.name !== 'node_modules' && !REPERTOIRES_EXCLUS.has(chemin)) {
|
||||
parcourir(racine, chemin, chemins);
|
||||
}
|
||||
} else if (entree.isFile()) {
|
||||
chemins.push(chemin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fichiers de la racine, triés ; un répertoire ou un lien n'en est pas.
|
||||
function fichiersDeLaRacine(racine) {
|
||||
return readdirSync(racine, { withFileTypes: true })
|
||||
.filter((entree) => entree.isFile())
|
||||
.map((entree) => entree.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Fichiers du périmètre du point 6, triés.
|
||||
function fichiersBalayes(racine) {
|
||||
const chemins = fichiersDeLaRacine(racine);
|
||||
for (const arbre of ARBRES_BALAYES) parcourir(racine, arbre, chemins);
|
||||
return chemins.filter((chemin) => !FICHIERS_EXCLUS.has(chemin) && !DOCUMENT.test(chemin)).sort();
|
||||
}
|
||||
|
||||
// Chaque occurrence du motif dans le texte : { ligne, chaine }.
|
||||
function occurrences(texte, motif) {
|
||||
const trouvees = [];
|
||||
texte.split(/\r?\n/).forEach((ligne, i) => {
|
||||
for (const [chaine] of ligne.matchAll(motif)) trouvees.push({ ligne: i + 1, chaine });
|
||||
});
|
||||
return trouvees;
|
||||
}
|
||||
|
||||
// Point 1 : package.json et les deux champs du verrou portent la forme
|
||||
// technique dérivée de version.json, et elle se redécompose en la version
|
||||
// affichée.
|
||||
function controlerRecopies(racine, { affichee, technique }, echec) {
|
||||
try {
|
||||
const porte = lireJson(racine, 'package.json', RAISON_PAQUET)?.version;
|
||||
const retour = essayer(() => redecomposer(porte));
|
||||
if (porte !== technique || retour !== affichee) {
|
||||
const lue = retour === null ? '' : ` (soit ${retour})`;
|
||||
echec(
|
||||
1,
|
||||
`package.json porte la version ${JSON.stringify(porte)}${lue}, version.json donne ${affichee}, soit ${technique} : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
try {
|
||||
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
|
||||
const champs = [
|
||||
['version', verrou?.version],
|
||||
['packages[""].version', verrou?.packages?.['']?.version],
|
||||
];
|
||||
for (const [champ, porte] of champs) {
|
||||
if (porte !== technique) {
|
||||
echec(
|
||||
1,
|
||||
`package-lock.json, champ « ${champ} » : ${JSON.stringify(porte)}, version.json donne ${technique} : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Point 2 : le module engendré reproduit, caractère pour caractère, ce que
|
||||
// le script engendre ; l'échec cite la première ligne qui diffère.
|
||||
function controlerModule(racine, { affichee }, echec) {
|
||||
const present = lireTexte(racine, MODULE);
|
||||
if (present === null) {
|
||||
echec(2, `${MODULE} absent : ${CONSEIL_ENGENDRER}`);
|
||||
return;
|
||||
}
|
||||
const attendu = engendrerModule(affichee);
|
||||
if (present === attendu) return;
|
||||
const lignesPresentes = present.split('\n');
|
||||
const lignesAttendues = attendu.split('\n');
|
||||
const longueur = Math.max(lignesPresentes.length, lignesAttendues.length);
|
||||
let i = 0;
|
||||
while (i < longueur && lignesPresentes[i] === lignesAttendues[i]) i += 1;
|
||||
const citer = (ligne) => (ligne === undefined ? 'la fin du fichier' : `« ${ligne} »`);
|
||||
echec(
|
||||
2,
|
||||
`${MODULE}, ligne ${i + 1} : ${citer(lignesPresentes[i])} au lieu de ${citer(lignesAttendues[i])}, que le script engendre : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Point 3 : la section de tête porte la version courante, la date en lettres
|
||||
// que cette version engendre, et les deux lignes obligatoires. Sans version
|
||||
// valide, seules la forme du titre et les deux lignes se contrôlent.
|
||||
function controlerTete(sections, version, echec) {
|
||||
const tete = sections[0];
|
||||
if (tete === undefined) {
|
||||
echec(3, 'CHANGELOG.md : aucune section « ## » ; la section de tête porte la version courante');
|
||||
return;
|
||||
}
|
||||
const lieu = `CHANGELOG.md, ligne ${tete.ligne}`;
|
||||
const titre = /^(\S+) — (.+)$/.exec(tete.titre);
|
||||
if (titre === null) {
|
||||
echec(3, `${lieu} : le titre « ## ${tete.titre} » n'a pas la forme « ## <version> — <date en lettres> »`);
|
||||
} else if (version !== null) {
|
||||
const [, porte, date] = titre;
|
||||
const attendue = dateEnLettres(version);
|
||||
if (porte !== version.affichee) {
|
||||
echec(
|
||||
3,
|
||||
`${lieu} : la section de tête porte ${porte}, version.json porte ${version.affichee} ; la section de cette version s'écrit en tête (§ 18.4)`,
|
||||
);
|
||||
} else if (date !== attendue) {
|
||||
echec(3, `${lieu} : la date « ${date} » n'est pas « ${attendue} », que la version ${porte} engendre`);
|
||||
}
|
||||
}
|
||||
for (const [libelle, motif] of LIGNES_OBLIGATOIRES) {
|
||||
if (!tete.corps.some((ligne) => motif.test(ligne))) {
|
||||
echec(3, `${lieu} : la ligne obligatoire « ${libelle} : … » manque à la section de tête (§ 18.4)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point 4 : chaque titre de section s'ouvre sur une version, aucune version
|
||||
// n'est portée deux fois, et chaque section porte une version strictement
|
||||
// antérieure à celle de la section qui la précède.
|
||||
function controlerOrdre(sections, echec) {
|
||||
const vues = new Map();
|
||||
let precedente = null;
|
||||
for (const section of sections) {
|
||||
const version = essayer(() => deriver(section.titre.split(/\s/)[0]));
|
||||
if (version === null) {
|
||||
echec(
|
||||
4,
|
||||
`CHANGELOG.md, ligne ${section.ligne} : le titre « ## ${section.titre} » ne s'ouvre pas sur une version AAAA.MM.JJ.NN`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const deja = vues.get(version.affichee);
|
||||
if (deja !== undefined) {
|
||||
echec(4, `CHANGELOG.md, lignes ${deja} et ${section.ligne} : deux sections portent la version ${version.affichee}`);
|
||||
} else {
|
||||
vues.set(version.affichee, section.ligne);
|
||||
if (precedente !== null && comparerVersions(version, precedente.version) > 0) {
|
||||
echec(
|
||||
4,
|
||||
`CHANGELOG.md, ligne ${section.ligne} : la section ${version.affichee} suit la section ${precedente.version.affichee} (ligne ${precedente.ligne}) ; les versions vont en ordre strictement décroissant`,
|
||||
);
|
||||
}
|
||||
}
|
||||
precedente = { version, ligne: section.ligne };
|
||||
}
|
||||
}
|
||||
|
||||
// Point 5 : la date de la version ne dépasse pas de plus d'un jour la date
|
||||
// civile de la machine. La tolérance absorbe l'écart de fuseau.
|
||||
function controlerDate(version, civile, echec) {
|
||||
const avance = numeroDeJour(version) - numeroDeJour(civile);
|
||||
if (avance > 1) {
|
||||
echec(
|
||||
5,
|
||||
`version.json : la version ${version.affichee} est datée du ${dateEnLettres(version)}, ${avance} jours après la date civile de la machine, le ${dateEnLettres(civile)} ; la tolérance est d'un jour`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Point 6 : aucune chaîne de forme affichée hors de version.json, du module
|
||||
// engendré et du changelog ; aucune de forme technique hors de package.json
|
||||
// et du module engendré. Rend le nombre de fichiers de src/ examinés, que
|
||||
// contrôle le point 8. Un fichier binaire, qui porte un octet nul, n'est pas
|
||||
// examiné.
|
||||
function controlerFormes(racine, echec) {
|
||||
let sourcesExaminees = 0;
|
||||
for (const chemin of fichiersBalayes(racine)) {
|
||||
const texte = lireTexte(racine, chemin);
|
||||
if (texte === null || texte.includes('\u0000')) continue;
|
||||
if (chemin.startsWith(ARBRE_DES_SOURCES)) sourcesExaminees += 1;
|
||||
for (const { ligne, chaine } of occurrences(texte, CHAINE_AFFICHEE)) {
|
||||
echec(
|
||||
6,
|
||||
`${chemin}, ligne ${ligne} : « ${chaine} », forme affichée, hors de version.json, du module engendré et du changelog`,
|
||||
);
|
||||
}
|
||||
if (chemin === 'package.json') continue;
|
||||
for (const { ligne, chaine } of occurrences(texte, CHAINE_TECHNIQUE)) {
|
||||
echec(6, `${chemin}, ligne ${ligne} : « ${chaine} », forme technique, hors de package.json et du module engendré`);
|
||||
}
|
||||
}
|
||||
return sourcesExaminees;
|
||||
}
|
||||
|
||||
// Point 7 : aucun document ne porte le littéral #_# ni ne cite un nom
|
||||
// d'exécutable hors du gabarit du § 18.2. Rend le nombre de documents
|
||||
// examinés, que contrôle le point 8.
|
||||
function controlerDocuments(racine, echec) {
|
||||
const documents = fichiersDeLaRacine(racine).filter(
|
||||
(nom) => DOCUMENTS_FIXES.includes(nom) || GUIDE.test(nom),
|
||||
);
|
||||
for (const document of documents) {
|
||||
const texte = lireTexte(racine, document);
|
||||
for (const { ligne, chaine } of occurrences(texte, LITTERAL_NON_SUBSTITUE)) {
|
||||
echec(7, `${document}, ligne ${ligne} : littéral « ${chaine} », un gabarit non substitué`);
|
||||
}
|
||||
for (const { ligne, chaine } of occurrences(texte, NOM_CITE)) {
|
||||
if (!nomConforme(chaine)) {
|
||||
echec(7, `${document}, ligne ${ligne} : « ${chaine} » ne se conforme pas au gabarit ${GABARIT_NOM} (§ 18.2)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return documents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique à l'arbre « racine » les huit points du § 18.5, à la date civile
|
||||
* « dateCivile » (AAAA-MM-JJ) de la machine qui construit. Rend la liste des
|
||||
* échecs, chacun préfixé du numéro de son point (« 1. … ») et nommant
|
||||
* l'endroit qui diverge ; liste vide = conforme. Lève sur une dateCivile mal
|
||||
* formée.
|
||||
*/
|
||||
export function controler(racine, { dateCivile }) {
|
||||
const civile = lireDateCivile(dateCivile);
|
||||
const echecs = [];
|
||||
const echec = (point, texte) => echecs.push(`${point}. ${texte}`);
|
||||
|
||||
let version = null;
|
||||
try {
|
||||
version = versionDeLaSource(racine);
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
if (version !== null) {
|
||||
controlerRecopies(racine, version, echec);
|
||||
controlerModule(racine, version, echec);
|
||||
}
|
||||
const changelog = lireTexte(racine, 'CHANGELOG.md');
|
||||
if (changelog === null) {
|
||||
echec(3, "CHANGELOG.md absent : la section de la version courante s'écrit à la livraison (§ 18.4)");
|
||||
} else {
|
||||
const sections = sectionsDuChangelog(changelog);
|
||||
controlerTete(sections, version, echec);
|
||||
controlerOrdre(sections, echec);
|
||||
}
|
||||
if (version !== null) controlerDate(version, civile, echec);
|
||||
const sourcesExaminees = controlerFormes(racine, echec);
|
||||
const documentsExamines = controlerDocuments(racine, echec);
|
||||
if (sourcesExaminees === 0) {
|
||||
echec(8, `le balayage du point 6 n'examine aucun fichier sous ${ARBRE_DES_SOURCES} : zéro fichier examiné n'est pas zéro problème (§ 14.2)`);
|
||||
}
|
||||
if (documentsExamines === 0) {
|
||||
echec(8, 'le balayage du point 7 n\'examine aucun document (README.md, CHANGELOG.md, GUIDE-*.md)');
|
||||
}
|
||||
return echecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date civile locale d'un instant, sous la forme AAAA-MM-JJ ; par défaut,
|
||||
* celle de la machine au moment de l'appel.
|
||||
*/
|
||||
export function dateCivileLocale(instant = new Date()) {
|
||||
return `${instant.getFullYear()}-${deuxChiffres(instant.getMonth() + 1)}-${deuxChiffres(instant.getDate())}`;
|
||||
}
|
||||
|
||||
// --- Ligne de commande ------------------------------------------------------
|
||||
|
||||
const USAGE = 'usage : node scripts/version.js engendrer | controler';
|
||||
|
||||
// Exécute une commande sur l'arbre « racine » et rend le code de sortie : 0
|
||||
// réussi, 1 échec, 2 commande inconnue.
|
||||
function executer(commande, racine) {
|
||||
if (commande === 'engendrer') {
|
||||
try {
|
||||
const reecrits = engendrer(racine);
|
||||
const version = lireVersion(racine);
|
||||
console.log(
|
||||
reecrits.length === 0
|
||||
? `Version ${version} : les recopies sont à jour.`
|
||||
: `Version ${version} : réécrit ${reecrits.join(', ')}.`,
|
||||
);
|
||||
return 0;
|
||||
} catch (erreur) {
|
||||
console.error(`Version : ${erreur.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (commande === 'controler') {
|
||||
const echecs = controler(racine, { dateCivile: dateCivileLocale() });
|
||||
if (echecs.length === 0) {
|
||||
console.log(`Version ${lireVersion(racine)} : conforme aux huit points du § 18.5.`);
|
||||
return 0;
|
||||
}
|
||||
console.error(`Version : ${echecs.length} échec(s) au contrôle du § 18.5.`);
|
||||
for (const echec of echecs) console.error(echec);
|
||||
return 1;
|
||||
}
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Vrai quand ce fichier est le script que Node a lancé, et non un module
|
||||
// importé.
|
||||
function estLanceDirectement() {
|
||||
if (process.argv[1] === undefined) return false;
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (estLanceDirectement()) {
|
||||
process.exitCode = executer(process.argv[2], fileURLToPath(new URL('..', import.meta.url)));
|
||||
}
|
||||
858
scripts/version.test.js
Normal file
858
scripts/version.test.js
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
// © 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 : 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',
|
||||
'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() }), []);
|
||||
});
|
||||
});
|
||||
242
src/interface/App.navigateur.test.js
Normal file
242
src/interface/App.navigateur.test.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
|
||||
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
|
||||
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
|
||||
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
|
||||
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
|
||||
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
|
||||
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
||||
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
||||
// visibilité, de contraste et d'opacité passent par les deux thèmes : le
|
||||
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
|
||||
//
|
||||
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
||||
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
|
||||
// expect, et prend test et describe chez Vitest plutôt que dans
|
||||
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { cdp } from 'vitest/browser';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
|
||||
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
||||
const THEMES = ['light', 'dark'];
|
||||
|
||||
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
|
||||
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
|
||||
// couleur sRGB ; toute autre forme fait échouer l'épreuve plutôt que de la
|
||||
// laisser passer sans mesure.
|
||||
function composantes(couleur) {
|
||||
const m = /^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/.exec(couleur);
|
||||
if (m === null) {
|
||||
throw new Error(`couleur calculée illisible : ${couleur}`);
|
||||
}
|
||||
const alpha = m[4] === undefined ? 1 : Number(m[4]);
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
||||
}
|
||||
|
||||
// Luminance relative d'une couleur sRGB, au sens des WCAG.
|
||||
function luminance([r, g, b]) {
|
||||
const lineaire = (c) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
|
||||
}
|
||||
|
||||
function contraste(a, b) {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
|
||||
function ascendance(element) {
|
||||
const chaine = [];
|
||||
for (let n = element; n !== null; n = n.parentElement) {
|
||||
chaine.push(n);
|
||||
}
|
||||
return chaine;
|
||||
}
|
||||
|
||||
// Fond peint sous un élément : le premier fond non transparent de son
|
||||
// ascendance, ou null.
|
||||
function fondPeint(element) {
|
||||
for (const n of ascendance(element)) {
|
||||
const fond = composantes(getComputedStyle(n).backgroundColor);
|
||||
if (fond[3] !== 0) {
|
||||
return fond;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Éléments du sous-arbre qui portent eux-mêmes un nœud de texte non vide.
|
||||
function porteursDeTexte(racine) {
|
||||
return [racine, ...racine.querySelectorAll('*')].filter((el) =>
|
||||
[...el.childNodes].some(
|
||||
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Nom lisible d'un élément dans un message d'échec.
|
||||
function designation(element) {
|
||||
const classe = element.getAttribute('class');
|
||||
return classe ? `${element.localName}.${classe}` : element.localName;
|
||||
}
|
||||
|
||||
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est
|
||||
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
|
||||
// préférence propre du navigateur.
|
||||
async function sousTheme(theme, mesure) {
|
||||
await cdp().send('Emulation.setEmulatedMedia', {
|
||||
features: [{ name: 'prefers-color-scheme', value: theme }],
|
||||
});
|
||||
try {
|
||||
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
|
||||
theme === 'dark',
|
||||
);
|
||||
return mesure();
|
||||
} finally {
|
||||
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
|
||||
}
|
||||
}
|
||||
|
||||
let composant;
|
||||
|
||||
beforeEach(() => {
|
||||
composant = mount(App, { target: document.body });
|
||||
flushSync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount(composant);
|
||||
});
|
||||
|
||||
function bandeau() {
|
||||
return document.body.querySelector('header.bandeau');
|
||||
}
|
||||
|
||||
// Le bandeau, puis chacun des éléments qui y portent un texte.
|
||||
function bandeauEtTextes() {
|
||||
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
|
||||
}
|
||||
|
||||
describe("bandeau d'application", () => {
|
||||
test('existe et porte la version affichée', () => {
|
||||
expect(bandeau()).not.toBeNull();
|
||||
expect(bandeau().textContent).toContain(VERSION.affichee);
|
||||
});
|
||||
|
||||
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
|
||||
// Le texte, le titre et le style calculé se relisent sur un élément qui
|
||||
// ne peint rien. L'épreuve interroge donc la mise en page : chaque élément
|
||||
// est visible pour checkVisibility (display, visibility, opacité nulle,
|
||||
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
|
||||
// le point central de cette boîte atteint l'élément ou l'un de ses
|
||||
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
|
||||
// point à un autre. elementFromPoint suit l'atteinte du pointeur : un
|
||||
// élément en pointer-events: none n'y répond pas, et l'épreuve le refuse.
|
||||
for (const theme of THEMES) {
|
||||
await sousTheme(theme, () => {
|
||||
const elements = bandeauEtTextes();
|
||||
expect(elements.length).toBeGreaterThan(1);
|
||||
for (const element of elements) {
|
||||
const lieu = `${theme} : ${designation(element)}`;
|
||||
expect(
|
||||
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
|
||||
`${lieu} : checkVisibility`,
|
||||
).toBe(true);
|
||||
const boite = element.getBoundingClientRect();
|
||||
expect(boite.width, `${lieu} : largeur`).toBeGreaterThan(0);
|
||||
expect(boite.height, `${lieu} : hauteur`).toBeGreaterThan(0);
|
||||
expect(boite.left, `${lieu} : bord gauche`).toBeGreaterThanOrEqual(0);
|
||||
expect(boite.top, `${lieu} : bord haut`).toBeGreaterThanOrEqual(0);
|
||||
expect(boite.right, `${lieu} : bord droit`).toBeLessThanOrEqual(innerWidth);
|
||||
expect(boite.bottom, `${lieu} : bord bas`).toBeLessThanOrEqual(innerHeight);
|
||||
const atteint = document.elementFromPoint(
|
||||
boite.left + boite.width / 2,
|
||||
boite.top + boite.height / 2,
|
||||
);
|
||||
expect(
|
||||
element.contains(atteint),
|
||||
`${lieu} : le centre de sa boîte atteint ${atteint === null ? 'aucun élément' : designation(atteint)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('porte la provenance de la construction', () => {
|
||||
expect(import.meta.env.MODE).toBe('test');
|
||||
expect(bandeau().textContent).toContain('épreuve');
|
||||
});
|
||||
|
||||
test('le titre de la fenêtre porte le nom et la version affichée', () => {
|
||||
expect(document.title).toBe(
|
||||
`Gestion table tournante Libre — ${VERSION.affichee}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('le texte et le fond du bandeau ont une couleur calculée', () => {
|
||||
const style = getComputedStyle(bandeau());
|
||||
expect(style.color).not.toBe(TRANSPARENT);
|
||||
expect(style.backgroundColor).not.toBe(TRANSPARENT);
|
||||
});
|
||||
|
||||
test('chaque texte du bandeau tient 4,5:1 sur un fond opaque, dans les deux thèmes (§ 14.5)', async () => {
|
||||
const couleursParTheme = [];
|
||||
for (const theme of THEMES) {
|
||||
const couleurs = await sousTheme(theme, () => {
|
||||
const textes = porteursDeTexte(bandeau());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
for (const element of textes) {
|
||||
const lieu = `${theme} : ${designation(element)}`;
|
||||
const texte = composantes(getComputedStyle(element).color);
|
||||
const fond = fondPeint(element);
|
||||
expect(fond, lieu).not.toBeNull();
|
||||
expect(texte[3], lieu).toBe(1);
|
||||
expect(fond[3], lieu).toBe(1);
|
||||
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
return [document.body, bandeau()].map((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return `${style.color} sur ${style.backgroundColor}`;
|
||||
});
|
||||
});
|
||||
couleursParTheme.push(couleurs);
|
||||
}
|
||||
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
|
||||
// couleur, ferait mesurer deux fois le thème clair.
|
||||
expect(
|
||||
couleursParTheme[1],
|
||||
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
|
||||
).not.toEqual(couleursParTheme[0]);
|
||||
});
|
||||
|
||||
test("aucune opacité partielle, aucun filtre ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
|
||||
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
|
||||
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
|
||||
// l'ascendance est donc relu, jusqu'à la racine du document.
|
||||
for (const theme of THEMES) {
|
||||
await sousTheme(theme, () => {
|
||||
const textes = porteursDeTexte(bandeau());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
for (const element of textes) {
|
||||
for (const n of ascendance(element)) {
|
||||
const style = getComputedStyle(n);
|
||||
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
|
||||
expect(style.opacity, lieu).toBe('1');
|
||||
expect(style.filter, lieu).toBe('none');
|
||||
expect(style.backdropFilter, lieu).toBe('none');
|
||||
expect(style.mixBlendMode, lieu).toBe('normal');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
52
src/interface/App.svelte
Normal file
52
src/interface/App.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
|
||||
// porte le nom, la version affichée et la provenance de la construction ;
|
||||
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
|
||||
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
|
||||
// commande de construction.
|
||||
import './jetons.css';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||
|
||||
const titre = titreAvecVersion(VERSION.affichee);
|
||||
const provenance = libelleProvenance(import.meta.env.MODE);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{titre}</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="bandeau">
|
||||
<span class="titre">{titre}</span>
|
||||
<span class="provenance">{provenance}</span>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-family: var(--police-interface);
|
||||
}
|
||||
|
||||
.bandeau {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-4);
|
||||
border-bottom: 1px solid var(--couleur-bandeau-filet);
|
||||
background-color: var(--couleur-bandeau-fond);
|
||||
color: var(--couleur-bandeau-texte);
|
||||
font-size: var(--taille-texte-bandeau);
|
||||
}
|
||||
|
||||
.provenance {
|
||||
padding: 0 var(--espace-2);
|
||||
border: 1px solid var(--couleur-bandeau-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
</style>
|
||||
42
src/interface/jetons.css
Normal file
42
src/interface/jetons.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/* © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
|
||||
|
||||
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème
|
||||
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de
|
||||
couleur, et les composants ne lisent que les jetons (§ 13.3).
|
||||
|
||||
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte,
|
||||
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé
|
||||
est alors la couleur peinte (§ 14.5). */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--couleur-page-fond: #f6f7f9;
|
||||
--couleur-page-texte: #1c2128;
|
||||
--couleur-bandeau-fond: #1f3a5f;
|
||||
--couleur-bandeau-texte: #ffffff;
|
||||
--couleur-bandeau-filet: #8fb3d9;
|
||||
|
||||
--police-interface: system-ui, sans-serif;
|
||||
--taille-texte-bandeau: 0.9375rem;
|
||||
|
||||
--espace-1: 0.25rem;
|
||||
--espace-2: 0.5rem;
|
||||
--espace-3: 0.75rem;
|
||||
--espace-4: 1rem;
|
||||
|
||||
--rayon-1: 0.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--couleur-page-fond: #121519;
|
||||
--couleur-page-texte: #e3e6ea;
|
||||
--couleur-bandeau-fond: #1d2c3f;
|
||||
--couleur-bandeau-texte: #f2f4f7;
|
||||
--couleur-bandeau-filet: #5b7fa6;
|
||||
}
|
||||
}
|
||||
37
src/interface/libelles.js
Normal file
37
src/interface/libelles.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Table des chaînes visibles de l'interface. Un composant n'écrit aucune
|
||||
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction
|
||||
// remplace (§ 14.6).
|
||||
|
||||
const NOM_APPLICATION = 'Gestion table tournante Libre';
|
||||
|
||||
// Mode de construction de Vite → provenance affichée dans le bandeau
|
||||
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé
|
||||
// comme une propriété héritée d'Object ne rend pas une fonction.
|
||||
const PROVENANCES = new Map([
|
||||
['developpement', 'développement'],
|
||||
['livraison', 'livraison'],
|
||||
['documentation', 'documentation'],
|
||||
['test', 'épreuve'],
|
||||
]);
|
||||
|
||||
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
|
||||
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
|
||||
export function libelleProvenance(mode) {
|
||||
return PROVENANCES.get(mode) ?? mode;
|
||||
}
|
||||
|
||||
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
|
||||
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
||||
// identique à leur nom.
|
||||
export function aUnLibelle(mode) {
|
||||
return PROVENANCES.has(mode);
|
||||
}
|
||||
|
||||
// Nom de l'application suivi de la version affichée : texte du bandeau et
|
||||
// titre de la fenêtre (§ 18.6).
|
||||
export function titreAvecVersion(version) {
|
||||
return `${NOM_APPLICATION} — ${version}`;
|
||||
}
|
||||
50
src/interface/libelles.test.js
Normal file
50
src/interface/libelles.test.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Chaque mode que pose un script npm (« --mode … » dans package.json) a son
|
||||
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
|
||||
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
|
||||
// sur la provenance.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||
|
||||
// Modes nommés par les scripts de package.json, triés, sans doublon.
|
||||
function modesDesScripts() {
|
||||
const paquet = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const modes = new Set();
|
||||
for (const commande of Object.values(paquet.scripts)) {
|
||||
for (const [, mode] of commande.matchAll(/--mode[= ](\S+)/g)) {
|
||||
modes.add(mode);
|
||||
}
|
||||
}
|
||||
return [...modes].sort();
|
||||
}
|
||||
|
||||
describe('libellés', () => {
|
||||
test('libelleProvenance nomme les quatre modes de construction', () => {
|
||||
assert.equal(libelleProvenance('developpement'), 'développement');
|
||||
assert.equal(libelleProvenance('livraison'), 'livraison');
|
||||
assert.equal(libelleProvenance('documentation'), 'documentation');
|
||||
assert.equal(libelleProvenance('test'), 'épreuve');
|
||||
});
|
||||
|
||||
test('chaque mode que pose un script npm a un libellé de provenance', () => {
|
||||
const modes = modesDesScripts();
|
||||
assert.ok(modes.length > 0, 'aucun « --mode » dans les scripts de package.json');
|
||||
assert.deepEqual(modes.filter((mode) => !aUnLibelle(mode)), []);
|
||||
});
|
||||
|
||||
test('libelleProvenance rend un mode sans libellé sous son propre nom', () => {
|
||||
assert.equal(libelleProvenance('production'), 'production');
|
||||
assert.equal(libelleProvenance('constructor'), 'constructor');
|
||||
assert.equal(aUnLibelle('constructor'), false);
|
||||
});
|
||||
|
||||
test('titreAvecVersion joint le nom et la version par un tiret cadratin', () => {
|
||||
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
|
||||
});
|
||||
});
|
||||
8
src/main.js
Normal file
8
src/main.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Point d'entrée de la page : monte l'interface dans le corps du document.
|
||||
import { mount } from 'svelte';
|
||||
import App from './interface/App.svelte';
|
||||
|
||||
mount(App, { target: document.body });
|
||||
8
src/version.genere.js
Normal file
8
src/version.genere.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// © 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',
|
||||
});
|
||||
109
test/construction.long.test.js
Normal file
109
test/construction.long.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le paquet livré n'emporte rien de l'outillage d'épreuve (§ 14.9, § 14.15) :
|
||||
// ni fichier de test, ni le lanceur, ni donnée d'épreuve, ni paquet d'un outil
|
||||
// d'épreuve. Le test construit la page par l'API de Vite, avec la
|
||||
// configuration et le mode de la livraison, dans un répertoire temporaire ;
|
||||
// il relit les modules que contient le paquet, les noms d'origine de chaque
|
||||
// actif, puis le chemin et le contenu de chaque fichier écrit.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'vite';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Paquets qui ne servent qu'aux épreuves.
|
||||
const OUTILS_EPREUVE =
|
||||
/[\\/]node_modules[\\/](?:vitest|@vitest|playwright|playwright-core|fast-check)[\\/]/;
|
||||
|
||||
// Vrai quand un module vient de l'outillage d'épreuve : l'arbre test/
|
||||
// (lanceur, données d'épreuve), un fichier *.test.js rangé à côté de son
|
||||
// module, un paquet d'épreuve, ou un module de Node que Vite remplace pour le
|
||||
// navigateur, comme node:assert.
|
||||
function moduleDEpreuve(id) {
|
||||
return (
|
||||
id.startsWith(join(RACINE, 'test') + sep) ||
|
||||
/\.test\.[cm]?js(?:\?|$)/.test(id) ||
|
||||
OUTILS_EPREUVE.test(id) ||
|
||||
id.includes('__vite-browser-external')
|
||||
);
|
||||
}
|
||||
|
||||
// Traces de l'outillage dans le chemin ou le contenu d'un fichier écrit. Les
|
||||
// motifs portent l'extension : le domaine emploie le mot « lanceur » (lanceur
|
||||
// portable, § 8.6), et un code minifié peut écrire « x.test.call ».
|
||||
const TRACES_EPREUVE = [/vitest/i, /node:assert/, /lanceur\.js/, /\.test\.[cm]?js/];
|
||||
|
||||
// Construit la page comme la livraison : vite.config.js, mode livraison,
|
||||
// NODE_ENV=production. Vitest pose NODE_ENV=test, dont Vite déduirait une
|
||||
// construction de développement — composants compilés en mode dev, paquets
|
||||
// résolus sous la condition « development ».
|
||||
async function construireLivraison(sortie) {
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
try {
|
||||
return await build({
|
||||
root: RACINE,
|
||||
configFile: join(RACINE, 'vite.config.js'),
|
||||
mode: 'livraison',
|
||||
logLevel: 'error',
|
||||
// Sans incrustation, chaque actif sort en fichier et garde ses noms
|
||||
// d'origine ; incrusté en data:, il ne laisserait aucune trace lisible.
|
||||
build: { outDir: sortie, emptyOutDir: true, assetsInlineLimit: 0 },
|
||||
});
|
||||
} finally {
|
||||
if (nodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = nodeEnv;
|
||||
}
|
||||
}
|
||||
|
||||
describe('construction', () => {
|
||||
test("le paquet livré n'emporte aucun fichier ni module d'épreuve (§ 14.9)", async () => {
|
||||
const sortie = mkdtempSync(join(tmpdir(), 'construction-'));
|
||||
try {
|
||||
const resultat = await construireLivraison(sortie);
|
||||
const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap(
|
||||
(r) => r.output,
|
||||
);
|
||||
const modules = sorties
|
||||
.filter((s) => s.type === 'chunk')
|
||||
.flatMap((s) => s.moduleIds);
|
||||
assert.ok(
|
||||
modules.some((id) => id.startsWith(join(RACINE, 'src') + sep)),
|
||||
`aucun module de src/ parmi ${modules.length} modules examinés`,
|
||||
);
|
||||
assert.deepEqual(modules.filter(moduleDEpreuve), []);
|
||||
|
||||
// Un actif n'est pas un module : moduleIds ne le voit pas. Une donnée
|
||||
// jointe par new URL(…, import.meta.url) n'apparaît que là.
|
||||
const actifs = sorties
|
||||
.filter((s) => s.type === 'asset')
|
||||
.flatMap((s) => (s.originalFileNames ?? []).map((n) => resolve(RACINE, n)));
|
||||
assert.ok(actifs.length > 0, `aucun actif d'origine connue parmi ${sorties.length} sorties`);
|
||||
assert.deepEqual(actifs.filter(moduleDEpreuve), []);
|
||||
|
||||
const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(e.parentPath, e.name));
|
||||
assert.ok(
|
||||
fichiers.some((f) => f.endsWith('.js')),
|
||||
`aucun script parmi ${fichiers.length} fichiers écrits`,
|
||||
);
|
||||
const traces = fichiers.flatMap((f) => {
|
||||
const nom = relative(sortie, f);
|
||||
const contenu = readFileSync(f, 'utf8');
|
||||
return TRACES_EPREUVE.filter((m) => m.test(nom) || m.test(contenu)).map(
|
||||
(m) => `${nom} : ${m}`,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(traces, []);
|
||||
} finally {
|
||||
rmSync(sortie, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
7
test/lanceur.js
Normal file
7
test/lanceur.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Point d'import unique des primitives de test. Remplacer cette ligne par
|
||||
// « export { test, describe } from 'node:test'; » fait tourner la série node
|
||||
// sous « node --test » sans toucher aux fichiers de test (§ 14.8).
|
||||
export { test, describe } from 'vitest';
|
||||
146
test/licence.test.js
Normal file
146
test/licence.test.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Chaque fichier source du projet s'ouvre sur l'en-tête de licence : la ligne
|
||||
// du titulaire, puis celle de la licence, après un éventuel shebang ou la
|
||||
// déclaration <!doctype html>. Les deux lignes sont des commentaires du
|
||||
// langage du fichier ; le texte ne varie pas. package.json déclare la même
|
||||
// licence et le même titulaire, que reprennent les outils d'emballage.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { extname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const TITULAIRE = /^© \d{4} TechnoLibre \(http:\/\/www\.technolibre\.ca\)$/;
|
||||
const LICENCE = 'License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)';
|
||||
|
||||
// Marqueurs de commentaire, par extension de fichier source. Un commentaire
|
||||
// de ligne n'a pas de fermant.
|
||||
const COMMENTAIRE = new Map([
|
||||
['.js', { ouvrant: '//', fermant: null }],
|
||||
['.cjs', { ouvrant: '//', fermant: null }],
|
||||
['.mjs', { ouvrant: '//', fermant: null }],
|
||||
['.sh', { ouvrant: '#', fermant: null }],
|
||||
['.svelte', { ouvrant: '<!--', fermant: '-->' }],
|
||||
['.html', { ouvrant: '<!--', fermant: '-->' }],
|
||||
['.css', { ouvrant: '/*', fermant: '*/' }],
|
||||
]);
|
||||
|
||||
// Périmètre : les fichiers de la racine et les arbres des sources, des
|
||||
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
|
||||
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
|
||||
// données et non des sources. Le séparateur final borne l'exclusion au
|
||||
// répertoire test/fixtures/ : un module test/fixtures.js reste une source.
|
||||
const ARBRES = ['src', 'test', 'scripts', 'electron'];
|
||||
const DONNEES_EPREUVE = join(RACINE, 'test', 'fixtures') + sep;
|
||||
|
||||
function fichiersSources() {
|
||||
const chemins = readdirSync(RACINE, { withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(RACINE, e.name));
|
||||
for (const arbre of ARBRES) {
|
||||
const dossier = join(RACINE, arbre);
|
||||
let entrees;
|
||||
try {
|
||||
entrees = readdirSync(dossier, { withFileTypes: true, recursive: true });
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT') continue;
|
||||
throw erreur;
|
||||
}
|
||||
for (const e of entrees) {
|
||||
if (e.isFile()) chemins.push(join(e.parentPath, e.name));
|
||||
}
|
||||
}
|
||||
return chemins
|
||||
.filter((chemin) => COMMENTAIRE.has(extname(chemin)))
|
||||
.filter((chemin) => !chemin.startsWith(DONNEES_EPREUVE))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Vrai quand les deux lignes sont des commentaires, et que le commentaire ne
|
||||
// se prolonge pas au-delà de la seconde. Un commentaire de ligne ouvre
|
||||
// chacune d'elles. Un commentaire de bloc court sur les deux — ouvert sur la
|
||||
// première sans s'y fermer, fermé en fin de seconde — ou s'ouvre et se ferme
|
||||
// sur chacune. Une seconde ligne hors commentaire se lit comme du code : un
|
||||
// texte affiché avant le bandeau dans un composant, une règle de style
|
||||
// invalidée dans une feuille, une commande dans un script.
|
||||
function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
|
||||
const suite = seconde.trim();
|
||||
if (!premiere.startsWith(ouvrant)) return false;
|
||||
if (fermant === null) return suite.startsWith(ouvrant);
|
||||
const courant = !premiere.includes(fermant) && suite.endsWith(fermant);
|
||||
const repete =
|
||||
premiere.trimEnd().endsWith(fermant) &&
|
||||
suite.startsWith(ouvrant) &&
|
||||
suite.endsWith(fermant);
|
||||
return courant || repete;
|
||||
}
|
||||
|
||||
// Vrai quand le contenu s'ouvre sur l'en-tête, après un éventuel shebang ou
|
||||
// la déclaration <!doctype html> : deux lignes de commentaire du langage de
|
||||
// l'extension, dont le texte, débarrassé des marqueurs, est celui de
|
||||
// l'en-tête.
|
||||
function porteLEnTete(contenu, extension) {
|
||||
let lignes = contenu.split('\n');
|
||||
if (lignes[0].startsWith('#!')) lignes = lignes.slice(1);
|
||||
if (/^<!doctype html>$/i.test(lignes[0])) lignes = lignes.slice(1);
|
||||
const [premiere = '', seconde = ''] = lignes;
|
||||
const texte = (ligne) =>
|
||||
ligne
|
||||
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*)?\s*/, '')
|
||||
.replace(/\s*(?:-->|\*\/)?\s*$/, '');
|
||||
return (
|
||||
enCommentaire(premiere, seconde, COMMENTAIRE.get(extension)) &&
|
||||
TITULAIRE.test(texte(premiere)) &&
|
||||
texte(seconde) === LICENCE
|
||||
);
|
||||
}
|
||||
|
||||
describe('licence', () => {
|
||||
test("chaque fichier source s'ouvre sur l'en-tête de licence", () => {
|
||||
const fichiers = fichiersSources();
|
||||
assert.ok(fichiers.length > 0, 'aucun fichier source examiné');
|
||||
const sansEnTete = fichiers
|
||||
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), extname(chemin)))
|
||||
.map((chemin) => relative(RACINE, chemin));
|
||||
assert.deepEqual(sansEnTete, []);
|
||||
});
|
||||
|
||||
test("l'en-tête tient en deux lignes de commentaire du langage du fichier", () => {
|
||||
const titulaire = '© 2026 TechnoLibre (http://www.technolibre.ca)';
|
||||
// [extension, contenu, admis] : les formes admises ; puis des secondes
|
||||
// lignes laissées hors commentaire, que le langage lirait comme du code,
|
||||
// et un commentaire qui se prolonge au-delà de la seconde ligne.
|
||||
const cas = [
|
||||
['.js', `// ${titulaire}\n// ${LICENCE}\n`, true],
|
||||
['.sh', `#!/bin/sh\n# ${titulaire}\n# ${LICENCE}\n`, true],
|
||||
['.svelte', `<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
|
||||
['.svelte', `<!-- ${titulaire} -->\n<!-- ${LICENCE} -->\n`, true],
|
||||
['.html', `<!doctype html>\n<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
|
||||
['.css', `/* ${titulaire}\n ${LICENCE} */\n`, true],
|
||||
['.css', `/* ${titulaire} */\n/* ${LICENCE} */\n`, true],
|
||||
['.js', `// ${titulaire}\n${LICENCE}\n`, false],
|
||||
['.sh', `#!/bin/sh\n# ${titulaire}\n${LICENCE}\n`, false],
|
||||
['.svelte', `<!-- ${titulaire} -->\n${LICENCE}\n`, false],
|
||||
['.html', `<!doctype html>\n<!-- ${titulaire} -->\n${LICENCE}\n`, false],
|
||||
['.html', `<!-- ${titulaire}\n ${LICENCE}\n-->\n`, false],
|
||||
['.css', `/* ${titulaire} */\n${LICENCE}\n`, false],
|
||||
['.css', `/* ${titulaire} */\n * ${LICENCE} */\n`, false],
|
||||
];
|
||||
const ecarts = cas
|
||||
.filter(([extension, contenu, admis]) => porteLEnTete(contenu, extension) !== admis)
|
||||
.map(([extension, contenu, admis]) =>
|
||||
`${extension} ${admis ? 'refusé' : 'admis'} : ${JSON.stringify(contenu)}`,
|
||||
);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('package.json déclare la licence et le titulaire', () => {
|
||||
const paquet = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
|
||||
assert.equal(paquet.license, 'AGPL-3.0-or-later');
|
||||
assert.equal(paquet.author, 'TechnoLibre (http://www.technolibre.ca)');
|
||||
});
|
||||
});
|
||||
33
test/projets.test.js
Normal file
33
test/projets.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le projet node n'expose aucun DOM et ne compile aucun composant : un module
|
||||
// qui toucherait document ou window, ou qui importerait un composant Svelte,
|
||||
// y échoue au lieu de passer sur un environnement que la série node ne doit
|
||||
// pas fournir (§ 14.4, § 14.8).
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
describe('projet node', () => {
|
||||
test('globalThis.document vaut undefined', () => {
|
||||
assert.equal(globalThis.document, undefined);
|
||||
});
|
||||
|
||||
test('globalThis.window vaut undefined', () => {
|
||||
assert.equal(globalThis.window, undefined);
|
||||
});
|
||||
|
||||
test('un composant Svelte ne se charge pas', async () => {
|
||||
// Le composant existe, et son import échoue sur la lecture du source :
|
||||
// sans greffon Svelte, l'analyse des imports de Vite rejette la syntaxe
|
||||
// d'un .svelte, et sous « node --test » Node en refuse l'extension. Un
|
||||
// refus venu d'ailleurs, comme un module qui lirait window au chargement,
|
||||
// fait échouer l'épreuve au lieu de la satisfaire.
|
||||
assert.ok(existsSync(new URL('../src/interface/App.svelte', import.meta.url)));
|
||||
await assert.rejects(
|
||||
import('../src/interface/App.svelte'),
|
||||
/Failed to parse source for import analysis|Unknown file extension/,
|
||||
);
|
||||
});
|
||||
});
|
||||
1
version.json
Normal file
1
version.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "version": "2026.10.05.01" }
|
||||
18
vite.config.js
Normal file
18
vite.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Construit la page que servent la plateforme web de Capacitor et la coquille
|
||||
// Electron. La base relative fait commencer chaque chemin d'actif par « ./ » :
|
||||
// la coquille charge la page par file://, où un chemin absolu « /assets/… »
|
||||
// désigne la racine du disque.
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: 'www',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
63
vitest.config.js
Normal file
63
vitest.config.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Un seul lanceur, trois projets (§ 14.4, § 14.8).
|
||||
//
|
||||
// node fonctions pures, sans DOM et sans greffon Svelte : un module qui
|
||||
// importerait un composant échoue au chargement.
|
||||
// node-long épreuves lourdes, hors de la série surveillée. Leur durée croît
|
||||
// avec l'application : chacune dispose de deux minutes, là où le
|
||||
// délai par défaut de Vitest, 5 s, la ferait échouer par
|
||||
// dépassement et non sur un défaut. Ce délai arrête un test
|
||||
// bloqué ; il n'est pas le plafond de la série (§ 14.14).
|
||||
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
|
||||
// module s'y résout par les mêmes greffons et réglages que dans la
|
||||
// construction du livrable, sans seconde configuration à tenir
|
||||
// d'accord avec la première.
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Sous surveillance, une épreuve se relance quand un module de son graphe
|
||||
// d'imports change. Les fichiers des motifs se lisent hors de tout
|
||||
// graphe : le contrôle de version (§ 18.5) lit les premiers par le
|
||||
// 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.
|
||||
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'] },
|
||||
{ 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'] },
|
||||
],
|
||||
projects: [
|
||||
{ test: {
|
||||
name: 'node', environment: 'node',
|
||||
include: ['src/**/*.test.js', 'scripts/**/*.test.js', 'test/**/*.test.js'],
|
||||
exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'node_modules/**'] } },
|
||||
{ test: {
|
||||
name: 'node-long', environment: 'node',
|
||||
include: ['src/**/*.long.test.js', 'test/**/*.long.test.js'],
|
||||
testTimeout: 120_000 } },
|
||||
{ extends: './vite.config.js',
|
||||
test: {
|
||||
name: 'navigateur',
|
||||
include: ['src/**/*.navigateur.test.js'],
|
||||
browser: { enabled: true, headless: true, provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }] } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue