gestion_table_tournante_libre/src/application/integration.test.js

421 lines
18 KiB
JavaScript
Raw Normal View History

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