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' })), + ); + }); +});