The four demonstrations ship as event files written once by a script, the source of truth of § 15.5: a test replays the generator and compares SHA-256 fingerprints of the payload alone, naming the paths that differ, so a new build version never forces a regeneration. The affiliation profile is read from the delivered file. The two demonstration CSVs are the export of those files; the edge-case CSV is written by hand, and its windows-1252 variant derived. Version and forged-name guards skip these generated files, as § 18.5 and § 15.6 allow. Checked: node and long series; the script writes only its seven files. --- FR --- [ADD] démo : fichiers d'événement livrés, CSV d'exemple, empreinte Les quatre démonstrations se livrent en fichiers d'événement qu'un script écrit une fois, source de vérité du § 15.5 : une épreuve rejoue le générateur et compare les empreintes SHA-256 de la seule charge, en nommant les chemins qui diffèrent ; une nouvelle version n'impose donc aucune régénération. Le profil d'appartenances se lit dans le fichier livré. Les deux CSV de démonstration sont l'export de ces fichiers ; celui des cas limites s'écrit à la main, sa variante windows-1252 en dérive. Les gardes de version et de noms forgés passent ces fichiers. Vérifié : séries node et longue ; le script n'écrit que ses sept fichiers. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
351 lines
15 KiB
JavaScript
351 lines
15 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Les fichiers livrés que le catalogue des démonstrations détermine (§ 10.3,
|
||
// § 15.5) : le fichier d'état de chaque démonstration, sous
|
||
// src/demo/livrees/ ; l'export CSV de la liste des participants de la petite
|
||
// et de la grande, et la variante windows-1252 du CSV des cas limites, sous
|
||
// exemples/. Le CSV des cas limites s'écrit à la main : le script le lit et
|
||
// ne l'écrit jamais.
|
||
//
|
||
// node scripts/engendrer_livrables.js [destination]
|
||
//
|
||
// La destination, la racine du projet par défaut, reçoit les sept fichiers
|
||
// sous leurs chemins relatifs, et rien n'est écrit ailleurs ; un chemin
|
||
// relatif se lit depuis le répertoire courant. Tout se calcule avant la
|
||
// première écriture. Le fichier livré est la source de vérité (§ 15.5,
|
||
// point 5) : src/demo/livrees.long.test.js rejoue ce script et compare
|
||
// l'empreinte de chaque charge à celle du fichier livré. Lancer le script
|
||
// est donc le geste conscient qui change une démonstration : il imprime,
|
||
// pour chaque fichier, s'il est créé, inchangé ou réécrit, et l'empreinte de
|
||
// la charge de chaque fichier d'état. Ni horloge ni tirage n'y entrent.
|
||
//
|
||
// Une démonstration livrée est la configuration de son entrée du catalogue,
|
||
// mise en charge (§ 4, § 8.8) :
|
||
// - l'événement demo:<clé>, au nom du catalogue, sans date, en brouillon et
|
||
// sans filiation ;
|
||
// - les sièges par défaut : la capacité la plus fréquente des tables, la
|
||
// plus grande à égalité ; une table d'une autre capacité porte la sienne
|
||
// en surcharge (§ 6.1) ;
|
||
// - les tables rondes, rangées en grille dans l'ordre de la configuration,
|
||
// rangée par rangée, leurs centres espacés de 300 cm ; la grille a la plus
|
||
// petite largeur dont le carré contient toutes les tables, et seule sa
|
||
// dernière rangée est incomplète ;
|
||
// - les contraintes de la configuration, les sièges non attribués, la
|
||
// génération par défaut de la recherche ;
|
||
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
|
||
// tous les tours, ce siège titré « animation », et le titre pressenti
|
||
// « animation » porté par l'animateur ;
|
||
// - aucune proposition : le placement n'est pas figé (§ 15.5, point 6).
|
||
// Son fichier d'état s'écrit à la révision 1, sans journal, sous la version
|
||
// de la construction qui écrit (§ 18.6).
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { decoder } from '../src/csv/encodage.js';
|
||
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
|
||
import { decouper } from '../src/csv/lecture.js';
|
||
import { CATALOGUE } from '../src/demo/catalogue.js';
|
||
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
|
||
import { analyser, creerCharge } from '../src/stockage/document.js';
|
||
import { VERSION } from '../src/version.genere.js';
|
||
|
||
const RACINE = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||
|
||
// Chemins des livrables, relatifs à la racine, séparés par « / ».
|
||
const DOSSIER_LIVREES = 'src/demo/livrees';
|
||
const CAS_LIMITES = 'exemples/participants_cas_limites.csv';
|
||
const CAS_LIMITES_CP1252 = 'exemples/participants_cas_limites_cp1252.csv';
|
||
const csvDeDemonstration = (cle) => `exemples/participants_demo_${cle}.csv`;
|
||
|
||
// Démonstrations dont la liste des participants est livrée en CSV, dans
|
||
// l'ordre du § 10.3.
|
||
const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
|
||
|
||
// Séparateur de la variante windows-1252 (§ 10.3).
|
||
const VIRGULE = ',';
|
||
|
||
// Écart entre les centres de deux tables voisines de la grille, en cm.
|
||
const ECART_CM = 300;
|
||
|
||
// Le siège de l'animateur, et le titre qu'il porte.
|
||
const SIEGE_ANIMATEUR = 1;
|
||
const ANIMATION = 'animation';
|
||
|
||
const USAGE = 'usage : node scripts/engendrer_livrables.js [destination]';
|
||
|
||
// Capacité la plus fréquente parmi les tables, la plus grande à égalité ; le
|
||
// résultat ne dépend pas de l'ordre des tables.
|
||
function capaciteParDefaut(tables) {
|
||
const effectifs = new Map();
|
||
for (const { capacite } of tables) effectifs.set(capacite, (effectifs.get(capacite) ?? 0) + 1);
|
||
let defaut = null;
|
||
for (const [capacite, effectif] of effectifs) {
|
||
const effectifDuDefaut = effectifs.get(defaut) ?? 0;
|
||
if (effectif > effectifDuDefaut || (effectif === effectifDuDefaut && capacite > defaut)) defaut = capacite;
|
||
}
|
||
return defaut;
|
||
}
|
||
|
||
// Largeur de la grille de nombre tables : la plus petite largeur c telle
|
||
// que c × c ≥ nombre.
|
||
function colonnesDe(nombre) {
|
||
let colonnes = 1;
|
||
while (colonnes * colonnes < nombre) colonnes += 1;
|
||
return colonnes;
|
||
}
|
||
|
||
// Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste
|
||
// vide.
|
||
const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0);
|
||
|
||
// Animateur de chaque table réservée, par identifiant de table, dans l'ordre
|
||
// des réservations. Lève quand une réservation ne vaut pas pour tous les
|
||
// tours, ou qu'une table en reçoit deux : le siège 1 ne porte qu'un
|
||
// animateur, et le script n'écrit que ce que le catalogue produit.
|
||
function animateursDe(reservations, cle) {
|
||
const animateurs = new Map();
|
||
for (const { participant, table, portee } of reservations) {
|
||
if (portee !== 'tous' || animateurs.has(table)) {
|
||
throw new Error(
|
||
`démonstration ${cle} : la table ${table} reçoit au plus un animateur, réservé pour tous les tours`,
|
||
);
|
||
}
|
||
animateurs.set(table, participant);
|
||
}
|
||
return animateurs;
|
||
}
|
||
|
||
/**
|
||
* Charge de la démonstration d'une entrée du catalogue, telle que l'en-tête
|
||
* du module la décrit. La configuration est construite une fois ; la charge
|
||
* rendue est faite d'objets neufs.
|
||
*
|
||
* @param {{cle: string, nom: string, construire: () => import('../src/moteur/types.js').Configuration}} entree
|
||
* @returns {import('../src/stockage/types.js').Charge}
|
||
* @throws {Error} quand une réservation ne vaut pas pour tous les tours, ou
|
||
* qu'une table en reçoit deux
|
||
*/
|
||
export function chargeDeDemonstration({ cle, nom, construire }) {
|
||
const { participants, tables, tours, reservations, contraintes } = construire();
|
||
const siegesParDefaut = capaciteParDefaut(tables);
|
||
const neuve = creerCharge({ id: `demo:${cle}`, nom, date: null, siegesParDefaut, tours });
|
||
const animateurs = animateursDe(reservations, cle);
|
||
const reserves = new Set(animateurs.values());
|
||
const colonnes = colonnesDe(tables.length);
|
||
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
|
||
return {
|
||
...neuve,
|
||
reglages: { ...neuve.reglages, separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances },
|
||
prochainsIds: { participant: plusGrandId(participants) + 1, table: plusGrandId(tables) + 1, proposition: 1 },
|
||
participants: participants.map((participant) => ({
|
||
id: participant.id,
|
||
nom: participant.nom,
|
||
prenom: participant.prenom ?? null,
|
||
appartenance: participant.appartenance,
|
||
courriel: null,
|
||
titrePressenti: reserves.has(participant.id) ? ANIMATION : null,
|
||
notes: null,
|
||
exclu: participant.exclu === true,
|
||
})),
|
||
tables: tables.map(({ id, numero, capacite }, rang) => ({
|
||
id,
|
||
numero,
|
||
sieges: capacite === siegesParDefaut ? null : capacite,
|
||
forme: 'ronde',
|
||
position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM },
|
||
})),
|
||
reservations: [...animateurs].map(([table, participant]) => ({
|
||
participant,
|
||
table,
|
||
siege: SIEGE_ANIMATEUR,
|
||
portee: 'tous',
|
||
tour: null,
|
||
})),
|
||
titres: [...animateurs.keys()].map((table) => ({ table, siege: SIEGE_ANIMATEUR, libelle: ANIMATION })),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Texte du fichier d'état livré d'une entrée du catalogue : sa charge, en
|
||
* forme canonique, à la révision 1, sous la version de la construction qui
|
||
* écrit.
|
||
*
|
||
* @param {{cle: string, nom: string, construire: Function}} entree
|
||
* @param {{produitVersion?: string}} [options] version affichée de la
|
||
* construction, VERSION.affichee par défaut
|
||
* @returns {string}
|
||
*/
|
||
export function texteDeDemonstration(entree, { produitVersion = VERSION.affichee } = {}) {
|
||
return serialiser(chargeDeDemonstration(entree), { revision: 1, produitVersion });
|
||
}
|
||
|
||
/**
|
||
* Empreinte d'une charge (§ 15.5, point 5) : SHA-256 de son texte canonique
|
||
* seul, en 64 chiffres hexadécimaux minuscules. L'en-tête n'y entre pas : la
|
||
* version de la construction qui écrit ne la change pas.
|
||
*
|
||
* @param {import('../src/stockage/types.js').Charge} charge
|
||
* @returns {string}
|
||
*/
|
||
export function empreinte(charge) {
|
||
return createHash('sha256').update(serialiserCharge(charge)).digest('hex');
|
||
}
|
||
|
||
// Octet windows-1252 de chaque caractère que ce codage représente, lu dans
|
||
// le décodeur windows-1252 lui-même, octet par octet : l'encodage en est
|
||
// l'inverse exact. 00 à 7F donnent l'ASCII, A0 à FF les mêmes points de
|
||
// code, 80 à 9F les signes propres à windows-1252, dont « € », « œ » et
|
||
// l'apostrophe typographique.
|
||
const DECODEUR_1252 = new TextDecoder('windows-1252');
|
||
const OCTET_1252 = new Map(
|
||
Array.from({ length: 256 }, (_, octet) => [DECODEUR_1252.decode(Uint8Array.of(octet)), octet]),
|
||
);
|
||
|
||
/**
|
||
* Octets windows-1252 d'un texte, un par caractère, sans marque.
|
||
*
|
||
* @param {string} texte
|
||
* @returns {Uint8Array}
|
||
* @throws {RangeError} pour un caractère que windows-1252 ne représente pas,
|
||
* nommé par son point de code et son rang dans le texte, en unités UTF-16
|
||
*/
|
||
export function enWindows1252(texte) {
|
||
const octets = [];
|
||
let rang = 0;
|
||
for (const caractere of texte) {
|
||
const octet = OCTET_1252.get(caractere);
|
||
if (octet === undefined) {
|
||
const point = caractere.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
|
||
throw new RangeError(`U+${point}, au rang ${rang} : caractère absent de windows-1252`);
|
||
}
|
||
octets.push(octet);
|
||
rang += caractere.length;
|
||
}
|
||
return Uint8Array.from(octets);
|
||
}
|
||
|
||
/**
|
||
* Variante windows-1252 du CSV des cas limites (§ 10.3) : ses
|
||
* enregistrements, découpés sous « ; », réécrits sous « , » par l'écriture
|
||
* unique des CSV, puis encodés en windows-1252, sans marque. Chaque champ
|
||
* garde son texte ; un champ qui porte la virgule, un guillemet ou une fin
|
||
* de ligne s'écrit cité.
|
||
*
|
||
* @param {Uint8Array} octets le fichier écrit à la main, que decoder lit
|
||
* @returns {Uint8Array}
|
||
* @throws {Error} quand un guillemet reste ouvert à la fin du fichier
|
||
*/
|
||
export function varianteWindows1252(octets) {
|
||
const { enregistrements, guillemetOuvert } = decouper(decoder(octets).texte, SEPARATEUR);
|
||
if (guillemetOuvert) throw new Error(`${CAS_LIMITES} : un guillemet reste ouvert à la fin du fichier`);
|
||
// ecrireCsv écrit de l'UTF-8 avec marque, que le décodeur UTF-8 retire.
|
||
return enWindows1252(new TextDecoder().decode(ecrireCsv(enregistrements, VIRGULE)));
|
||
}
|
||
|
||
/**
|
||
* Les sept livrables, calculés sans rien écrire : { chemin, octets,
|
||
* empreinte } ; le chemin est relatif à la racine, séparé par « / » ;
|
||
* l'empreinte, celle de la charge d'un fichier d'état, null pour un CSV. Dans
|
||
* l'ordre : le fichier d'état de chaque démonstration, dans l'ordre du
|
||
* catalogue ; l'export de la liste des participants de la petite, puis de la
|
||
* grande, chacun lu dans le texte du fichier d'état de sa démonstration ; la
|
||
* variante windows-1252 du CSV des cas limites.
|
||
*
|
||
* @param {{casLimites: Uint8Array, produitVersion?: string}} options
|
||
* casLimites : les octets du CSV des cas limites, écrit à la main
|
||
* @returns {Array<{chemin: string, octets: Uint8Array, empreinte: string|null}>}
|
||
*/
|
||
export function livrables({ casLimites, produitVersion = VERSION.affichee }) {
|
||
const etats = CATALOGUE.map((entree) => {
|
||
const texte = texteDeDemonstration(entree, { produitVersion });
|
||
return { cle: entree.cle, texte, charge: analyser(texte).charge };
|
||
});
|
||
const fichiers = etats.map(({ cle, texte, charge }) => ({
|
||
chemin: `${DOSSIER_LIVREES}/${cle}.gtt.json`,
|
||
octets: new TextEncoder().encode(texte),
|
||
empreinte: empreinte(charge),
|
||
}));
|
||
for (const cle of DEMONSTRATIONS_EN_CSV) {
|
||
const etat = etats.find((candidat) => candidat.cle === cle);
|
||
if (etat === undefined) throw new Error(`aucune démonstration ${cle} au catalogue`);
|
||
fichiers.push({
|
||
chemin: csvDeDemonstration(cle),
|
||
octets: exporterParticipants(etat.charge.participants),
|
||
empreinte: null,
|
||
});
|
||
}
|
||
fichiers.push({ chemin: CAS_LIMITES_CP1252, octets: varianteWindows1252(casLimites), empreinte: null });
|
||
return fichiers;
|
||
}
|
||
|
||
// Octets d'un fichier, ou null quand il n'existe pas.
|
||
function lireSiPresent(chemin) {
|
||
try {
|
||
return readFileSync(chemin);
|
||
} catch (erreur) {
|
||
if (erreur.code === 'ENOENT') return null;
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Écrit les sept livrables sous destination, après les avoir tous calculés,
|
||
* et rien ailleurs. Le CSV des cas limites se lit sous source. Rend chaque
|
||
* livrable avec son état : « créé » quand le fichier n'existait pas,
|
||
* « inchangé » quand il portait déjà ces octets, « réécrit » sinon.
|
||
*
|
||
* @param {string} destination racine qui reçoit les fichiers
|
||
* @param {{source?: string, produitVersion?: string}} [options] source : la
|
||
* racine du projet par défaut
|
||
* @returns {Array<{chemin: string, octets: Uint8Array, empreinte: string|null, etat: string}>}
|
||
*/
|
||
export function ecrireLivrables(destination, { source = RACINE, produitVersion } = {}) {
|
||
const casLimites = readFileSync(join(source, ...CAS_LIMITES.split('/')));
|
||
return livrables({ casLimites, produitVersion }).map((fichier) => {
|
||
const cible = join(destination, ...fichier.chemin.split('/'));
|
||
const avant = lireSiPresent(cible);
|
||
mkdirSync(dirname(cible), { recursive: true });
|
||
writeFileSync(cible, fichier.octets);
|
||
const etat = avant === null ? 'créé' : Buffer.compare(avant, fichier.octets) === 0 ? 'inchangé' : 'réécrit';
|
||
return { ...fichier, etat };
|
||
});
|
||
}
|
||
|
||
// Ligne de commande : une destination au plus, la racine par défaut. Sort à
|
||
// 0 après l'écriture, à 1 quand un livrable ne se calcule ou ne s'écrit pas,
|
||
// à 2 sur un usage fautif.
|
||
function executer(arguments_) {
|
||
if (arguments_.length > 1 || arguments_.some((argument) => argument.startsWith('-'))) {
|
||
console.error(USAGE);
|
||
return 2;
|
||
}
|
||
const destination = arguments_.length === 1 ? resolve(arguments_[0]) : RACINE;
|
||
let ecrits;
|
||
try {
|
||
ecrits = ecrireLivrables(destination);
|
||
} catch (erreur) {
|
||
console.error(`Livrables : ${erreur.message}`);
|
||
return 1;
|
||
}
|
||
console.log(`Livrables écrits sous ${destination}, construction ${VERSION.affichee} :`);
|
||
for (const { chemin, octets, empreinte: hachage, etat } of ecrits) {
|
||
const suite = hachage === null ? '' : `, empreinte de la charge ${hachage}`;
|
||
console.log(` ${etat.padEnd(9)}${chemin} (${octets.length} octets${suite})`);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// 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.slice(2));
|