From 67fd0bc9ffcff1a7a3e6d9a7cbc186041d169382 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 23:40:46 -0400 Subject: [PATCH] [ADD] application: end-to-end test of the operator's path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One node test walks § 2.1 through the session on the simulated disk: create, switch to writing, import the edge-case CSV as one entry, correct two people, generate with the engine, retain, exclude a person, reopen with the dérive named and the retained plan kept, undo before the generation, reopen. Every instant of the journal rebuilds the state written at that moment, no opening writes, and undo keeps the id counters (§ 8.9). Checked: 1424 node tests; five mutations of the real code each caught. --- FR --- [ADD] application : épreuve de bout en bout du parcours de l'opérateur Une épreuve node parcourt le § 2.1 par la séance, sur le disque simulé : créer, passer en écriture, importer le CSV des cas limites en une entrée, corriger deux personnes, générer avec le moteur, retenir, exclure une personne, rouvrir — dérive nommée, retenu gardé —, défaire jusqu'avant la génération, rouvrir. Chaque instant du journal reconstruit l'état écrit à ce moment, aucune ouverture n'écrit, et défaire garde les compteurs (§ 8.9). Vérifié : 1424 épreuves node ; cinq mutations du code réel, chacune prise. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- src/application/integration.test.js | 420 ++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 src/application/integration.test.js diff --git a/src/application/integration.test.js b/src/application/integration.test.js new file mode 100644 index 0000000..c307ecc --- /dev/null +++ b/src/application/integration.test.js @@ -0,0 +1,420 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuve d'intégration de l'application (§ 2.1, § 8.2, § 8.3, § 8.4, § 9, +// § 10.1) : le parcours de l'opérateur, joué par la séance sur le système de +// fichiers d'épreuve, avec une horloge, des identifiants et un planificateur +// factices. Créer ; passer en écriture ; importer le CSV d'exemple des cas +// limites en une seule entrée ; corriger deux participants ; générer par le +// moteur, à un compte d'arrêt réduit, et enregistrer la génération ; retenir +// une proposition ; exclure une personne qu'elle place ; rouvrir, la dérive +// nommée et la proposition gardée ; défaire jusqu'avant la génération ; +// rouvrir. +// +// Le parcours se déroule une fois, et chaque épreuve en lit une propriété : +// - chaque fichier d'état, saisi au moment où il s'écrit, trouve déjà au +// journal l'entrée de sa révision (le journal d'abord, § 8.2) ; +// - chaque instant du journal final, reconstruit, s'écrit octet pour octet +// comme l'état écrit à cette révision, et chaque révision a un état ; +// - le journal ne se tronque jamais : chaque texte relevé commence par le +// précédent (§ 8.3) ; +// - aucune ouverture n'écrit : ni primitive d'écriture comptée, ni octet du +// disque changé (§ 8.4). +// +// Chaque réouverture est un processus neuf : une autre instance du système +// d'épreuve sur le même disque, et une autre séance ; rien de la mémoire de +// la précédente ne passe. Aucune commande de l'itération ne crée de table : +// l'entrée qui pose les tables s'écrit par le dépôt, depuis l'Ouvert que la +// création laisse, comme le fera la commande qui les créera — le journal +// d'abord, l'état ensuite. Les noms viennent du CSV d'exemple livré et, pour +// les corrections, sont inventés ; la version vient du module engendré. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { describe, test } from '../../test/lanceur.js'; +import { apercevoir } from '../csv/apercu.js'; +import { decoder } from '../csv/encodage.js'; +import { exporterRefus } from '../csv/import.js'; +import { rechercher } from '../moteur/recherche.js'; +import { serialiser } from '../stockage/canonique.js'; +import { enregistrer } from '../stockage/depot.js'; +import { analyser, configurationDepuisCharge } from '../stockage/document.js'; +import { fil, lireJournal, reconstruire, versionDe } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; +import { VERSION } from '../version.genere.js'; +import { libelle } from './libelles.js'; +import { creerSeance } from './seance.js'; + +const V = VERSION.affichee; +const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' }; +const NOM = 'Soirée des Grillons'; +const CSV = new URL('../../exemples/participants_cas_limites.csv', import.meta.url); + +// Le moteur : trois propositions, d'une graine fixe, à un compte d'arrêt qui +// garde l'épreuve dans le budget de la série surveillée (§ 14.14). +const GENERATION = { graine: 4099, arret: 300, nombre: 3 }; + +// Quatre tables de quatre places, sur une ligne, pour douze personnes +// présentes : les places suffisent à chaque tour. +const TABLES = [1, 2, 3, 4].map((id) => ({ + id, + numero: id, + sieges: null, + forme: 'ronde', + position: { x: 250 * (id - 1), y: 0 }, +})); + +// --- Le montage ------------------------------------------------------------- + +const deux = (valeur) => String(valeur).padStart(2, '0'); + +// Horloge d'épreuve : une seconde de plus à chaque lecture, à partir de +// 18 h, à l'heure de l'Est ; une heure de lectures au plus. +function creerHorloge() { + let n = 0; + return { + horodatage() { + const secondes = n; + n += 1; + return `2033-11-12T18:${deux(Math.floor(secondes / 60))}:${deux(secondes % 60)}-05:00`; + }, + }; +} + +// Identifiants d'épreuve, numérotés dans l'ordre des tirages, préfixés par +// le processus. +function creerIdentifiants(prefixe) { + let n = 0; + return () => { + n += 1; + return `${prefixe}-${n}`; + }; +} + +// Planificateur d'épreuve : il garde les minuteries sans jamais les +// déclencher ; l'inactivité ne tombe donc pas pendant le parcours. +function creerPlanificateur() { + let suivant = 0; + const minuteries = new Map(); + return { + planifier(fn) { + suivant += 1; + minuteries.set(suivant, fn); + return suivant; + }, + annuler(id) { + minuteries.delete(id); + }, + }; +} + +// Le relevé du disque partagé : chaque état écrit, saisi à l'écriture, et +// chaque texte du journal relevé après un pas du parcours. +function creerReleve() { + return { etats: [], journaux: [], ouvertures: [] }; +} + +// Un processus : une instance du système d'épreuve sur le disque, et sa +// séance. L'écriture atomique de l'état est observée : au moment où l'état +// de révision r s'écrit, le relevé garde son texte et la dernière révision +// que porte alors le journal. +function demarrer(disque, releve, prefixe) { + const fs = creerFichiersSimules({ disque }); + const ecrireAtomique = fs.ecrireAtomique; + fs.ecrireAtomique = async (racine, chemin, texte) => { + if (!chemin.endsWith(SUFFIXES.etat)) return ecrireAtomique(racine, chemin, texte); + const base = chemin.slice(0, -SUFFIXES.etat.length); + const journal = lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal) ?? ''); + const derniere = journal.entrees.at(-1)?.revision ?? null; + await ecrireAtomique(racine, chemin, texte); + releve.etats.push({ revision: analyser(texte).entete.revision, texte, derniere }); + }; + const planificateur = creerPlanificateur(); + const seance = creerSeance({ + fs, + racine: DOC, + horloge: creerHorloge(), + identifiant: creerIdentifiants(prefixe), + produitVersion: V, + planifier: planificateur.planifier, + annuler: planificateur.annuler, + }); + return { fs, seance }; +} + +// Le texte de chaque fichier du disque, sous sa clé : ce qu'une ouverture +// ne doit pas changer. +const photographier = (disque) => JSON.stringify([...disque]); + +// Ouvre base par la séance du processus, et relève ce que l'ouverture a +// écrit : primitives d'écriture comptées, et disque changé ou non. +async function ouvrir({ fs, seance }, disque, base, releve) { + const ecritures = fs.compteurs.ecritures; + const lectures = fs.compteurs.lectures; + const avant = photographier(disque); + const rendu = await seance.ouvrir(base); + releve.ouvertures.push({ + base, + ecritures: fs.compteurs.ecritures - ecritures, + lu: fs.compteurs.lectures > lectures, + disqueInchange: photographier(disque) === avant, + mode: seance.mode, + }); + return rendu; +} + +// Le journal que porte le disque, en texte. +const texteDuJournal = ({ fs }, base) => fs.contenu('documents', base + SUFFIXES.journal); + +// Le participant de la charge par nom et prénom. +const personneDe = (charge, nom, prenom) => + charge.participants.find((personne) => personne.nom === nom && personne.prenom === prenom); + +// --- Le parcours ------------------------------------------------------------ + +async function derouler() { + const disque = new Map(); + const releve = creerReleve(); + const vu = {}; + let processus = demarrer(disque, releve, 'p1'); + processus.fs.deposerDossier('documents'); + const releverJournal = () => releve.journaux.push(texteDuJournal(processus, vu.base)); + + // 1. Créer, puis poser les tables. + const { base } = await processus.seance.creer({ nom: NOM, date: '2033-11-12', siegesParDefaut: 4, tours: 3 }); + vu.base = base; + releverJournal(); + const avecTables = { ...structuredClone(processus.seance.charge), tables: structuredClone(TABLES) }; + avecTables.prochainsIds.table = TABLES.length + 1; + await enregistrer(processus.seance.ouvert, avecTables, { + libelle: 'Ajouté les tables 1 à 4', + horodatage: '2033-11-12T17:59:00-05:00', + produitVersion: V, + sens: null, + retour: null, + }); + releverJournal(); + await ouvrir(processus, disque, base, releve); + + // 1b. Passer en écriture. + await processus.seance.passerEnEcriture(); + + // 2. Importer le CSV des cas limites, en une entrée. + const { texte } = decoder(readFileSync(CSV)); + const apercu = apercevoir(texte, { participants: processus.seance.charge.participants }); + vu.apercu = apercu; + const entreesAvantImport = lireJournal(texteDuJournal(processus, base)).entrees.length; + vu.import = await processus.seance.executer('importerParticipants', { apercu, mode: 'ajouter' }); + vu.entreesImport = lireJournal(texteDuJournal(processus, base)).entrees.length - entreesAvantImport; + vu.refus = decoder(exporterRefus(apercu, libelle, vu.import.resume.refusees)).texte; + releverJournal(); + + // 3. Corriger deux participants. + const brunoeil = personneDe(processus.seance.charge, 'Brunœil', null); + const givrecoeur = personneDe(processus.seance.charge, 'Givrecœur', 'Anselme'); + vu.corrections = [ + await processus.seance.executer('modifierParticipant', { id: brunoeil.id, champs: { prenom: 'Aurèle' } }), + await processus.seance.executer('modifierParticipant', { + id: givrecoeur.id, + champs: { appartenance: ' Cercle des Saules ' }, + }), + ]; + releverJournal(); + vu.revisionAvantGeneration = processus.seance.ouvert.revision; + vu.chargeAvantGeneration = structuredClone(processus.seance.charge); + + // 9. Générer, et enregistrer la génération. + const configuration = configurationDepuisCharge(processus.seance.charge); + const propositions = rechercher(configuration, GENERATION); + vu.generation = await processus.seance.executer('enregistrerGeneration', { + propositions, + configuration, + produitVersion: V, + }); + releverJournal(); + + // 10. Retenir une proposition. + vu.retenue = 2; + await processus.seance.executer('retenirProposition', { id: vu.retenue }); + vu.retenuAvant = structuredClone(processus.seance.charge.retenu); + vu.propositionsAvant = structuredClone(processus.seance.charge.propositions); + releverJournal(); + + // 3 encore, après la génération : exclure une personne que la proposition + // retenue place. + vu.exclue = personneDe(processus.seance.charge, 'Pommerel', 'Capucine').id; + vu.exclusion = await processus.seance.executer('exclureParticipant', { id: vu.exclue }); + releverJournal(); + vu.revisionExclusion = processus.seance.ouvert.revision; + await processus.seance.fermer(); + + // Rouvrir, dans un processus neuf. + processus = demarrer(disque, releve, 'p2'); + vu.reouverture = await ouvrir(processus, disque, base, releve); + vu.chargeRouverte = structuredClone(processus.seance.charge); + vu.examen = processus.seance.ouvert.examen; + + // Défaire jusqu'avant la génération : l'exclusion, la rétention, la + // génération. + await processus.seance.passerEnEcriture(); + vu.defaits = []; + for (let pas = 0; pas < 3; pas += 1) { + vu.defaits.push(await processus.seance.defaire()); + releverJournal(); + } + vu.chargeDefaite = structuredClone(processus.seance.charge); + await processus.seance.fermer(); + + // Rouvrir encore, dans un autre processus neuf. + processus = demarrer(disque, releve, 'p3'); + vu.seconde = await ouvrir(processus, disque, base, releve); + vu.chargeFinale = structuredClone(processus.seance.charge); + vu.retoursFinaux = processus.seance.retours; + vu.journalFinal = lireJournal(texteDuJournal(processus, base)); + return { releve, vu }; +} + +// Le parcours ne se déroule qu'une fois ; chaque épreuve attend le même. +let parcours = null; +const leParcours = () => { + parcours ??= derouler(); + return parcours; +}; + +// --- Les épreuves ----------------------------------------------------------- + +describe('intégration : le parcours de l’opérateur par la séance (§ 2.1)', () => { + test("l'import du CSV des cas limites est une seule entrée : le refus nommé et réexporté, l'exclusion, le doublon gardé, les appartenances fondues (§ 10.1)", async () => { + const { vu } = await leParcours(); + assert.equal(vu.entreesImport, 1); + assert.deepEqual( + vu.import.resume.refusees.map(({ ligne, code }) => [ligne, code]), + [[13, 'NOM_ABSENT']], + ); + assert.match(vu.refus, /;13;Le nom manque\.\r\n$/); + assert.equal(vu.import.resume.ajoutes, 13); + const charge = vu.chargeRouverte; + assert.equal(charge.participants.length, 13); + assert.equal(charge.participants.filter((p) => p.nom === 'Lanterneau' && p.prenom === 'Basile').length, 2); + assert.equal(personneDe(charge, 'Tourterel', 'Héloïse').exclu, true); + assert.equal(personneDe(charge, 'Ormelin', 'Faustine').appartenance, 'Société des Hérons'); + assert.equal(personneDe(charge, 'Fenouillet', 'Côme').notes, 'végétarien; sans noix, ni arachides'); + assert.equal(personneDe(charge, 'Coudraie', 'Apolline').notes, 'Arrive après le premier tour\nrepart avant le dessert'); + }); + + test('les deux corrections sont deux entrées, et le fichier rouvert les porte', async () => { + const { vu } = await leParcours(); + assert.deepEqual( + vu.corrections.map((rendu) => rendu.avertissements), + [[], []], + ); + assert.equal(personneDe(vu.chargeRouverte, 'Brunœil', 'Aurèle').appartenance, 'Cercle des Saules'); + assert.equal(personneDe(vu.chargeRouverte, 'Givrecœur', 'Anselme').appartenance, 'Cercle des Saules'); + const libelles = vu.journalFinal.entrees.map((entree) => entree.libelle); + assert.deepEqual(libelles.slice(3, 5), vu.corrections.map((rendu) => rendu.libelle)); + }); + + test('rouvrir après l’exclusion nomme la dérive de chaque proposition et du retenu, et les garde toutes (§ 9)', async () => { + const { vu } = await leParcours(); + const ids = vu.propositionsAvant.map(({ id }) => id); + assert.deepEqual(ids, [1, 2, 3]); + assert.deepEqual(vu.reouverture.avertissements, [{ code: 'DERIVE', details: { ids, retenu: true } }]); + const raison = { code: 'PARTICIPANT_EXCLU', participant: vu.exclue }; + assert.deepEqual(vu.examen.derives, ids.map((id) => ({ id, raisons: [raison] }))); + assert.deepEqual(vu.examen.retenu, { fautes: [], raisons: [raison] }); + assert.deepEqual(vu.examen.fautives, []); + assert.match(libelle('DERIVE', { ids, retenu: true }), /gardés, et rien n'est effacé/); + assert.deepEqual(vu.chargeRouverte.propositions, vu.propositionsAvant); + assert.deepEqual(vu.chargeRouverte.retenu, vu.retenuAvant); + assert.equal(vu.chargeRouverte.retenu.proposition, vu.retenue); + assert.equal(vu.chargeRouverte.evenement.etat, 'retenu'); + }); + + test('défaire trois fois revient avant la génération ; rouvert, rien ne dérive et tout se refait (§ 8.3)', async () => { + const { vu } = await leParcours(); + const exclue = vu.chargeRouverte.participants.find(({ id }) => id === vu.exclue); + assert.deepEqual( + vu.defaits.map((rendu) => rendu.libelle), + [ + `Défait : « Exclu ${exclue.prenom} ${exclue.nom} »`, + `Défait : « Retenu la proposition n° ${vu.retenue} »`, + `Défait : « ${vu.generation.libelle} »`, + ], + ); + // La charge restituée rend le contenu d'avant la génération, et ses + // compteurs ne reculent jamais (§ 8.9) : participants et tables + // reviennent à leur valeur, que la génération n'a pas changée ; celui des + // propositions reste au-delà de tout identifiant de la génération défaite, + // qu'une génération suivante ne redonnera pas. + const sansCompteurs = (charge) => ({ ...charge, prochainsIds: undefined }); + const avant = vu.chargeAvantGeneration.prochainsIds; + const dernierGenere = Math.max(...vu.propositionsAvant.map(({ id }) => id)); + for (const charge of [vu.chargeDefaite, vu.chargeFinale]) { + assert.deepEqual(sansCompteurs(charge), sansCompteurs(vu.chargeAvantGeneration)); + assert.deepEqual(charge.prochainsIds, { + participant: avant.participant, + table: avant.table, + proposition: dernierGenere + 1, + }); + } + assert.deepEqual(vu.seconde.avertissements, []); + assert.deepEqual(vu.retoursFinaux, { defaire: true, refaire: true }); + const { entrees } = vu.journalFinal; + const retours = entrees.slice(-3).map(({ sens, retour }) => [sens, retour]); + assert.deepEqual(retours, [ + ['defaire', vu.revisionExclusion - 1], + ['defaire', vu.revisionExclusion - 2], + ['defaire', vu.revisionAvantGeneration], + ]); + assert.equal(fil(vu.journalFinal).position, vu.revisionAvantGeneration); + const jalons = vu.journalFinal.jalons.map(({ revision, nom }) => [revision, nom]); + assert.ok( + jalons.some(([revision, nom]) => revision === vu.revisionAvantGeneration && nom === 'Avant la génération'), + JSON.stringify(jalons), + ); + }); +}); + +describe('intégration : les trois garanties d’un geste, sur le journal entier (§ 2.1, § 8.2, § 8.3, § 8.4)', () => { + test("chaque état s'écrit après l'entrée de sa révision, une fois par révision", async () => { + const { releve, vu } = await leParcours(); + assert.deepEqual( + releve.etats.map(({ revision, derniere }) => [revision, derniere]), + releve.etats.map(({ revision }) => [revision, revision]), + ); + const revisions = releve.etats.map(({ revision }) => revision); + assert.deepEqual( + revisions, + vu.journalFinal.entrees.map(({ revision }) => revision), + ); + }); + + test("chaque instant du journal, reconstruit, s'écrit octet pour octet comme l'état écrit à ce moment", async () => { + const { releve, vu } = await leParcours(); + const journal = vu.journalFinal; + assert.equal(journal.entrees.length, releve.etats.length); + assert.ok(journal.entrees.length >= 10, `${journal.entrees.length} entrées`); + for (const { revision, texte } of releve.etats) { + const produitVersion = versionDe(journal, revision); + const reconstruit = serialiser(reconstruire(journal, revision), { revision, produitVersion }); + assert.equal(reconstruit, texte, `révision ${revision}`); + } + }); + + test('le journal ne se tronque jamais : chaque texte relevé commence par le précédent', async () => { + const { releve } = await leParcours(); + assert.ok(releve.journaux.length >= 9); + releve.journaux.reduce((avant, apres, rang) => { + assert.ok(apres.length > avant.length && apres.startsWith(avant), `relevé ${rang}`); + return apres; + }); + }); + + test("aucune ouverture n'écrit : ni primitive d'écriture, ni octet du disque, et l'événement s'ouvre en lecture", async () => { + const { releve, vu } = await leParcours(); + assert.deepEqual( + releve.ouvertures, + [1, 2, 3].map(() => ({ base: vu.base, ecritures: 0, lu: true, disqueInchange: true, mode: 'lecture' })), + ); + }); +});