gestion_table_tournante_libre/scripts/engendrer_livrables.js

352 lines
15 KiB
JavaScript
Raw Normal View History

[ADD] demo: delivered event files, example CSVs, payload fingerprint The four demonstrations ship as event files written once by a script, the source of truth of § 15.5: a test replays the generator and compares SHA-256 fingerprints of the payload alone, naming the paths that differ, so a new build version never forces a regeneration. The affiliation profile is read from the delivered file. The two demonstration CSVs are the export of those files; the edge-case CSV is written by hand, and its windows-1252 variant derived. Version and forged-name guards skip these generated files, as § 18.5 and § 15.6 allow. Checked: node and long series; the script writes only its seven files. --- FR --- [ADD] démo : fichiers d'événement livrés, CSV d'exemple, empreinte Les quatre démonstrations se livrent en fichiers d'événement qu'un script écrit une fois, source de vérité du § 15.5 : une épreuve rejoue le générateur et compare les empreintes SHA-256 de la seule charge, en nommant les chemins qui diffèrent ; une nouvelle version n'impose donc aucune régénération. Le profil d'appartenances se lit dans le fichier livré. Les deux CSV de démonstration sont l'export de ces fichiers ; celui des cas limites s'écrit à la main, sa variante windows-1252 en dérive. Les gardes de version et de noms forgés passent ces fichiers. Vérifié : séries node et longue ; le script n'écrit que ses sept fichiers. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:15 -04:00
// © 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));