Compare commits
2 commits
481f6a6bae
...
67fd0bc9ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 67fd0bc9ff | |||
| a1cac443bb |
3 changed files with 445 additions and 3 deletions
420
src/application/integration.test.js
Normal file
420
src/application/integration.test.js
Normal file
|
|
@ -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' })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -416,6 +416,19 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La charge restituée, ses compteurs d'identifiants portés au plus grand
|
||||||
|
// de leur valeur restituée et de leur valeur courante : un retour rend le
|
||||||
|
// contenu d'un instant, jamais un identifiant déjà donné (§ 8.9) — une
|
||||||
|
// génération après un défaire numérote au-delà des propositions défaites,
|
||||||
|
// et le retenu d'un fil abandonné garde la proposition qu'il désignait.
|
||||||
|
function avecCompteursCourants(charge, courante) {
|
||||||
|
const prochainsIds = {};
|
||||||
|
for (const cle of ['participant', 'table', 'proposition']) {
|
||||||
|
prochainsIds[cle] = Math.max(charge.prochainsIds[cle], courante.prochainsIds[cle]);
|
||||||
|
}
|
||||||
|
return { ...charge, prochainsIds };
|
||||||
|
}
|
||||||
|
|
||||||
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
|
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
|
||||||
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
|
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
|
||||||
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
|
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
|
||||||
|
|
@ -431,7 +444,7 @@ export function creerSeance({
|
||||||
if (cibleRefaire === null) throw new ErreurSeance('RIEN_A_REFAIRE');
|
if (cibleRefaire === null) throw new ErreurSeance('RIEN_A_REFAIRE');
|
||||||
cible = cibleRefaire;
|
cible = cibleRefaire;
|
||||||
}
|
}
|
||||||
const charge = restituer(ouvert, cible);
|
const charge = avecCompteursCourants(restituer(ouvert, cible), ouvert.charge);
|
||||||
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
|
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
|
||||||
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
|
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
|
||||||
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
|
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
|
||||||
|
|
|
||||||
|
|
@ -641,8 +641,17 @@ describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
|
||||||
[7, 'revenir', 2, 'Retour à « Ajouté Ancolie »'],
|
[7, 'revenir', 2, 'Retour à « Ajouté Ancolie »'],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 2));
|
// L'instant 2 revient avec son contenu, et ses compteurs restent où trois
|
||||||
assert.deepEqual(reconstruire(journal, 7), reconstruire(journal, 2));
|
// ajouts les ont portés : aucun identifiant donné ne se redonne (§ 8.9).
|
||||||
|
const instant2 = reconstruire(journal, 2);
|
||||||
|
const attendu = { ...instant2, prochainsIds: { ...instant2.prochainsIds, participant: 4 } };
|
||||||
|
assert.deepEqual(etatDe(fs).charge, attendu);
|
||||||
|
assert.deepEqual(reconstruire(journal, 7), attendu);
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Digitale' } });
|
||||||
|
assert.deepEqual(
|
||||||
|
seance.charge.participants.map(({ id, nom }) => [id, nom]),
|
||||||
|
[[1, 'Ancolie'], [4, 'Digitale']],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refaire n'existe qu'après des retours, tant qu'aucun geste ne les suit", async () => {
|
test("refaire n'existe qu'après des retours, tant qu'aucun geste ne les suit", async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue