diff --git a/src/stockage/depot.long.test.js b/src/stockage/depot.long.test.js new file mode 100644 index 0000000..bab9177 --- /dev/null +++ b/src/stockage/depot.long.test.js @@ -0,0 +1,548 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le coût d'un enregistrement complet (§ 8.2, § 8.9, § 14.4) : la durée d'un +// geste qu'enregistrer (depot.js) écrit sur un vrai système de fichiers — le +// journal, le .precedent, l'état — pour la grande démonstration livrée +// portant vingt propositions, dont une retenue. Deux gestes se mesurent, +// chacun sous son seuil : le geste ordinaire, dont la ligne s'ajoute en fin +// de journal, et le geste dont l'entrée fait passer le journal au-delà de +// PLAFOND_ENTREES, qui le réécrit en entier, élagué. Le système de fichiers +// est celui de la plateforme electron : la page (fichiers_electron.js) devant +// les primitives de la coquille (electron/fichiers.js), sous un dossier +// temporaire neuf, retiré à la fin de l'épreuve ; seul l'IPC d'Electron y est +// remplacé, par une copie des arguments et des réponses. +// +// La charge mesurée se construit une fois pour le fichier (chargeMesuree) : +// le moteur produit ses vingt plans, et une épreuve affirme que l'analyse +// l'admet et que la forme canonique la réécrit telle quelle. Chaque geste +// mesuré retouche le placement retenu — deux personnes changent de table à +// un tour — ; son entrée porte un correctif qui touche deux listes de table, +// ou l'instantané de la charge à la cadence du journal. Après chaque geste, +// hors de la mesure, l'état et le .precedent sur le disque sont, octet pour +// octet, ceux que le geste écrit ; à la fin, le journal relu porte une +// entrée par révision et restitue la charge de révisions choisies : la durée +// retenue vient toujours d'un geste qui a écrit tout ce qu'écrit un +// enregistrement. Un geste qui n'écrirait plus les +// propositions, plus le .precedent ou plus le journal élagué mesurerait +// autre chose, et passerait sous le seuil (§ 14.2). +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PREFIXE_CANAL, PRIMITIVES, creerFichiers, decrireSysteme, servir } from '../../electron/fichiers.js'; +import { describe, test } from '../../test/lanceur.js'; +import { texteLivre } from '../demo/livrees.js'; +import { regenerer } from '../moteur/recherche.js'; +import { VERSION } from '../version.genere.js'; +import { canoniser, serialiser } from './canonique.js'; +import { creer, enregistrer, ouvrir } from './depot.js'; +import { analyser, configurationDepuisCharge } from './document.js'; +import { creerFichiersElectron } from './fichiers_electron.js'; +import { + INTERVALLE_INSTANTANE, + PLAFOND_ENTREES, + PLANCHER_ENTREES, + estInstantane, + ligneEntree, + lireJournal, + reconstruire, +} from './journal.js'; +import { SUFFIXES } from './noms.js'; +import { examiner, versFichier } from './placements.js'; + +const V = VERSION.affichee; +const NOM_PRODUIT = 'Gestion table tournante Libre'; + +// Les propositions de la charge mesurée, la vingtaine du § 8.9 sur la grande +// démonstration, aux graines 1 à PROPOSITIONS. +const PROPOSITIONS = 20; + +// Le compte d'arrêt de chaque proposition, réduit à côté de celui des +// réglages : la taille d'un placement ne dépend que de sa forme, non de sa +// qualité, et la construction de la charge reste brève. +const ARRET_REDUIT = 2_000; + +// L'échantillonnage, contre une charge passagère de la machine. Après un +// geste de chauffe, chaque épreuve mesure son nombre de base de gestes, et +// son seuil porte sur le plus court : le minimum écarte ce qu'une machine +// occupée ajoute à un geste, pourvu qu'un geste au moins s'exécute quand la +// charge se relâche. Tant que le plus court dépasse le seuil, l'épreuve +// mesure d'autres gestes, jusqu'à son plafond de gestes ou jusqu'à ce que +// FENETRE_MESURE_MS se soient écoulées depuis le premier geste mesuré, ce +// qui vient d'abord. Une charge plus courte que la fenêtre laisse ainsi +// passer un geste sous le seuil, et l'épreuve passe ; une régression, qui +// allonge chaque geste, le laisse au-delà à chaque geste, et l'épreuve +// échoue au plafond. Sous la charge que couvre MARGE, le nombre de base +// suffit : l'épreuve ne s'allonge qu'au-delà. +// +// GESTES_MESURES, le nombre de base des gestes ordinaires : vingt tiennent +// sous la charge que couvre MARGE, là où cinq n'y tiennent pas. +// GESTES_AU_PLUS, cinq fois autant, borne les gestes ordinaires mesurés. +const GESTES_MESURES = 20; +const GESTES_AU_PLUS = 100; + +// ESSAIS_ELAGAGE, le nombre de base des essais du geste qui élague : cinq +// tiennent sous la charge que couvre MARGE. Chaque essai pose un journal +// plein, d'environ 2 Mo, et relit celui qu'il écrit. ESSAIS_ELAGAGE_AU_PLUS, +// cinq fois autant, borne les essais mesurés. +const ESSAIS_ELAGAGE = 5; +const ESSAIS_ELAGAGE_AU_PLUS = 25; + +// La fenêtre de mesure, en millisecondes, au mur, vérifications hors mesure +// comprises : la charge passagère que l'échantillonnage écarte dure moins +// qu'elle. Elle borne aussi la durée d'une épreuve qui échoue : une mesure +// s'arrête au premier geste qui finit au-delà, et la fenêtre vaut un sixième +// du plafond de la série node-long (test/rapporteur.js). +const FENETRE_MESURE_MS = 10_000; + +// Les seuils du coût d'un enregistrement complet (§ 8.2, § 14.1), en +// millisecondes, chacun au minimum de ses gestes mesurés, qui garde le coût +// du geste. Le § 8.2 réécrit l'état entier à chaque geste : c'est ce coût +// qui gouverne la forme du fichier (§ 8.9). +// +// SEUIL_MS borne le geste ordinaire : correctif depuis l'entrée qui précède, +// état sérialisé puis relu, ligne du journal ajoutée et vidée sur le disque, +// .precedent puis état réécrits par écriture atomique. L'entrée qui porte un +// instantané, une sur INTERVALLE_INSTANTANE, coûte autant : canoniser la +// charge y remplace le calcul du correctif, et sa ligne s'ajoute de même. +// SEUIL_ELAGAGE_MS borne le geste dont l'entrée fait passer le journal +// au-delà de PLAFOND_ENTREES : le journal se réécrit en entier — son texte +// recomposé, lu par l'élagage, puis relu une fois élagué — par une écriture +// atomique de la taille d'une dizaine d'instantanés de la charge, avant le +// .precedent et l'état. Le plafond atteint, ce geste revient environ une +// fois par centaine de gestes : l'élagage garde un peu plus de +// PLANCHER_ENTREES entrées. Les autres réécritures entières du journal — +// une entrée de reprise, des lignes écartées à la lecture, un journal qui +// manque (depot.js) — réparent un événement sorti du cours ordinaire des +// gestes, et ces épreuves ne les mesurent pas. +// +// REFERENCE_MS et REFERENCE_ELAGAGE_MS sont les minimums que relèvent ces +// épreuves, le fichier lancé seul, médiane de plusieurs lancements arrondie +// à la milliseconde supérieure, sur un processeur x86-64 de bureau de +// génération Zen 3, seize cœurs virtuels sous KVM, le dossier temporaire en +// mémoire (tmpfs), sous Linux et Node 26. +// MARGE, le facteur qui tire chaque seuil de sa référence, couvre, sur la +// machine des références, quatre séries node-long lancées ensemble — +// autant que de conteneurs simultanés par défaut sous make +// verifier_systemes (GTT_PARALLELES) —, chacune son dossier temporaire sur +// disque, où vider sur le disque s'ajoute au calcul. Sous cette charge, le +// plus court des GESTES_MESURES gestes ordinaires monte jusqu'à trois fois +// REFERENCE_MS, et le plus court des ESSAIS_ELAGAGE essais jusqu'à trois +// fois REFERENCE_ELAGAGE_MS : il reste un tiers de marge. MARGE ne couvre +// pas ce qu'un autre conteneur de make verifier_systemes peut exécuter en +// même temps — make test, npm ci, Chromium —, ni davantage de conteneurs à +// la fois, ni une machine plus lente d'un tiers sous la même charge : contre +// une telle charge, quand elle passe, l'échantillonnage mesure d'autres +// gestes jusqu'à son plafond. Elle reste assez basse pour qu'un état dont +// les placements pèsent dix fois plus, comme sous leur forme nommée (§ 8.9), +// dépasse chacun des deux seuils. +// +// Propriétaire (§ 14.1) : la série node-long, où ces épreuves relèvent la +// mesure à chaque exécution et la nomment quand elle dépasse son seuil. Le +// relevé sur la plateforme livrée — l'exécutable Windows, sous un antivirus +// qui inspecte chaque fichier temporaire — appartient au niveau manuel, +// avant livraison : tant qu'il n'a pas eu lieu, ces seuils sont des défauts +// de travail relevés sous Linux. +const REFERENCE_MS = 20; +const REFERENCE_ELAGAGE_MS = 87; +const MARGE = 4; +const SEUIL_MS = REFERENCE_MS * MARGE; +const SEUIL_ELAGAGE_MS = REFERENCE_ELAGAGE_MS * MARGE; + +// Durée affichée, une décimale, virgule décimale. +const ms = (duree) => `${duree.toFixed(1).replace('.', ',')} ms`; + +const croissant = (a, b) => a - b; + +// Un nombre sur deux chiffres. +const deux = (n) => String(n).padStart(2, '0'); + +// Horodatage d'affichage de l'instant n, une minute par instant depuis 8 h ; +// il n'entre dans aucune mesure. +const heure = (n) => `2031-11-08T${deux(8 + Math.floor(n / 60))}:${deux(n % 60)}:00-05:00`; + +const CREATION = { libelle: "Création de l'événement", nomJalon: 'Création', horodatage: heure(0), produitVersion: V }; +const geste = (n) => ({ libelle: `Retouche ${n} du placement retenu`, horodatage: heure(n), produitVersion: V }); + +// Le retenu repris d'une proposition, dont l'identifiant devient l'origine ; +// son placement est une copie, qu'une retouche modifie sans toucher la +// proposition. +function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) { + return { + proposition: id, + siegesAttribues, + tables: [...tables], + capacites: [...capacites], + tours, + participants: [...participants], + placement: structuredClone(placement), + }; +} + +// La grande démonstration livrée, portant PROPOSITIONS propositions que le +// moteur produit aux graines 1 à PROPOSITIONS sous ARRET_REDUIT, numérotées +// depuis le compteur de la charge, qui passe au-delà de la dernière ; la +// première est retenue, et l'événement dans l'état retenu. +function construire() { + const charge = analyser(texteLivre('grande')).charge; + const configuration = configurationDepuisCharge(charge); + const { historique } = charge.reglages.generation; + const decalage = charge.prochainsIds.proposition - 1; + charge.propositions = Array.from({ length: PROPOSITIONS }, (_, rang) => { + const graine = rang + 1; + const plan = regenerer(configuration, { graine, arret: ARRET_REDUIT, historique }); + return versFichier({ id: graine, graine, arret: ARRET_REDUIT, historique, plan }, configuration, { + produitVersion: V, + attribuerSieges: charge.reglages.attribuerSieges, + decalage, + }); + }); + charge.prochainsIds.proposition = decalage + PROPOSITIONS + 1; + charge.retenu = retenuDe(charge.propositions[0]); + charge.evenement.etat = 'retenu'; + return charge; +} + +// La charge mesurée, construite au premier appel et partagée par les +// épreuves du fichier, qui ne la modifient pas. +let construite = null; +const chargeMesuree = () => (construite ??= construire()); + +// La charge reçue, son placement retenu retouché au tour d'index tour : la +// première personne sans réservation de la première table et celle de la +// deuxième changent de table, comme une retouche à la main (§ 5.8). Une +// personne réservée ne s'échange pas, ce que le geste refuserait (§ 5.9). +// Les sièges n'étant pas attribués, chaque liste reste triée par +// identifiant (§ 8.9). +function echanger(charge, tour) { + const apres = structuredClone(charge); + const reservees = new Set(charge.reservations.map(({ participant }) => participant)); + const [premiere, deuxieme] = apres.retenu.placement[tour].sieges; + const a = premiere.find((id) => !reservees.has(id)); + const b = deuxieme.find((id) => !reservees.has(id)); + assert.ok(a !== undefined && b !== undefined, `tour ${tour + 1} : aucune personne à échanger`); + premiere.splice(premiere.indexOf(a), 1, b); + deuxieme.splice(deuxieme.indexOf(b), 1, a); + premiere.sort(croissant); + deuxieme.sort(croissant); + return apres; +} + +// Les textes d'un événement dont le journal est plein, à PLAFOND_ENTREES +// entrées : le journal que creer a écrit pour la charge a — ouverture, +// entrée 1 en instantané, jalon de la création —, suivi des entrées 2 à +// PLAFOND_ENTREES, écrites ligne à ligne plutôt que par autant de gestes +// enregistrés. Les charges alternent : a aux révisions impaires, b, son +// retenu retouché au premier tour, aux paires. Chaque ligne a la forme que +// ligneEntree donne à son passage — instantané à la cadence du journal +// (estInstantane), correctif de a vers b ou de b vers a sinon —, calculée +// une fois par sorte de passage, sous le libellé et l'horodatage de sa +// révision : les clés remplacées gardent leur place, et chaque ligne est +// celle qu'écrirait ligneEntree. Rend le journal, l'état à la dernière +// révision, le .precedent à celle d'avant, et chargeDe, la charge d'une +// révision. +function evenementPlein(journalCree, a) { + const b = echanger(a, 0); + const chargeDe = (revision) => (revision % 2 === 1 ? a : b); + const formes = new Map(); + const lignes = []; + for (let revision = 2; revision <= PLAFOND_ENTREES; revision += 1) { + const { libelle, horodatage } = geste(revision); + const instantane = estInstantane(revision, 1); + const sorte = `${instantane} ${revision % 2}`; + if (!formes.has(sorte)) { + const ligne = ligneEntree({ + revision, + libelle, + horodatage, + avant: chargeDe(revision - 1), + apres: chargeDe(revision), + instantane, + }); + formes.set(sorte, JSON.parse(ligne)); + } + lignes.push(JSON.stringify({ ...formes.get(sorte), revision, libelle, horodatage })); + } + const etat = (revision) => serialiser(chargeDe(revision), { revision, produitVersion: V }); + return { + journal: `${journalCree}${lignes.join('\n')}\n`, + etat: etat(PLAFOND_ENTREES), + precedent: etat(PLAFOND_ENTREES - 1), + chargeDe, + }; +} + +// Les dialogues et l'explorateur ne servent pas à un enregistrement : un +// appel lèverait TypeError. +const SANS_DIALOGUE = Object.freeze({}); + +// Le système de fichiers de la plateforme electron sur dossier : la page +// (creerFichiersElectron) devant les primitives de la coquille +// (creerFichiers), que servir offre canal par canal, comme le processus +// principal. L'IPC d'Electron y est remplacé par une copie structurée des +// arguments et de la réponse, comme il les copie. Les Documents et l'AppData +// du poste sont des dossiers de dossier ; rend le système et la racine +// documents, créée. +async function systemeSur(dossier) { + const systeme = decrireSysteme({ + env: {}, + plateforme: process.platform, + cheminSysteme: (nom) => (nom === 'temp' ? tmpdir() : join(dossier, nom)), + nomProduit: NOM_PRODUIT, + }); + const gestionnaires = new Map(); + servir(creerFichiers({ ...systeme, dialog: SANS_DIALOGUE, shell: SANS_DIALOGUE }), (canal, gestionnaire) => + gestionnaires.set(canal, gestionnaire), + ); + const pont = Object.fromEntries( + PRIMITIVES.map((primitive) => { + const gestionnaire = gestionnaires.get(`${PREFIXE_CANAL}${primitive}`); + const appeler = async (...parametres) => + structuredClone(await gestionnaire({ sender: null }, ...structuredClone(parametres))); + return [primitive, appeler]; + }), + ); + const fs = creerFichiersElectron(pont); + const { documents } = await fs.racines(); + mkdirSync(documents.chemin, { recursive: true }); + return { fs, racine: documents }; +} + +// Chemin, sur le disque, du fichier de suffixe de l'événement base sous +// racine. +const cheminDe = (racine, base, suffixe) => join(racine.chemin, `${base}${suffixe}`); + +// Texte du fichier chemin, ou null quand il manque. +const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : null); + +// Révision de l'en-tête d'un texte d'état, ou null sans texte. +const revisionDe = (texte) => (texte === null ? null : analyser(texte).entete.revision); + +// Affirme que le disque porte ce qu'écrit le geste de la révision revision : +// l'état au texte etat, le .precedent au texte precedent, celui de l'état +// d'avant le geste. Les révisions des en-têtes se comparent d'abord, null +// pour un fichier qui manque : un fichier que le geste n'a pas écrit se +// nomme par la révision qu'il garde, avant toute différence de deux textes. +function affirmerEcrits(racine, base, revision, { etat, precedent }) { + const ecrits = { + etat: lireSiPresent(cheminDe(racine, base, SUFFIXES.etat)), + precedent: lireSiPresent(cheminDe(racine, base, SUFFIXES.precedent)), + }; + assert.deepEqual( + { etat: revisionDe(ecrits.etat), precedent: revisionDe(ecrits.precedent) }, + { etat: revision, precedent: revision - 1 }, + `révisions de l'état et du .precedent sur le disque après le geste de la révision ${revision}`, + ); + assert.ok(ecrits.etat === etat, `l'état sur le disque n'est pas celui qu'écrit le geste de la révision ${revision}`); + assert.ok( + ecrits.precedent === precedent, + `le .precedent sur le disque n'est pas l'état d'avant le geste de la révision ${revision}`, + ); +} + +// La mesure échantillonnée d'un geste (GESTES_MESURES) : faire exécute un +// geste et ses vérifications, et rend la durée du seul geste. Un premier +// appel chauffe, sans compter ; suivent base gestes mesurés, puis d'autres +// tant que le plus court dépasse seuil, jusqu'à auPlus gestes mesurés ou la +// fin de FENETRE_MESURE_MS, comptée depuis le premier. Rend les durées, dans +// l'ordre des gestes, et arret, ce qui a clos une mesure restée au-delà du +// seuil. +async function mesurer(faire, { base, auPlus, seuil }) { + await faire(); + const durees = []; + const debut = performance.now(); + const ouverte = () => performance.now() - debut < FENETRE_MESURE_MS; + while (durees.length < base || (Math.min(...durees) > seuil && durees.length < auPlus && ouverte())) { + durees.push(await faire()); + } + const arret = + durees.length >= auPlus + ? `plafond de ${auPlus} mesures atteint` + : `fenêtre de ${FENETRE_MESURE_MS / 1000} s écoulée`; + return { durees, arret }; +} + +// Le message d'un seuil dépassé : le minimum, le nombre de mesures, leur +// médiane et la plus longue, ce qui a clos la mesure, le seuil, et la +// référence dont MARGE le tire. +function depasse(quoi, { durees, arret }, seuil, reference) { + const triees = [...durees].sort(croissant); + return ( + `${quoi} s'enregistre en ${ms(triees[0])} au minimum de ${durees.length} mesures ` + + `(médiane ${ms(triees[Math.floor((triees.length - 1) / 2)])}, la plus longue ${ms(triees.at(-1))} ; ` + + `${arret}), au-delà du seuil de ${ms(seuil)} (§ 8.2) : ${reference}, MARGE ${MARGE}` + ); +} + +describe("dépôt : le coût d'un enregistrement complet, sur un vrai système de fichiers (§ 8.2)", () => { + test("la charge mesurée est la grande démonstration portant vingt propositions du moteur, la première retenue : l'analyse l'admet, la forme canonique la réécrit octet pour octet, et aucune proposition n'est fautive ni périmée", () => { + const texte = serialiser(chargeMesuree(), { revision: 1, produitVersion: V }); + const lu = analyser(texte); + const { participants, propositions, retenu } = lu.entete.comptes; + assert.deepEqual( + { participants, propositions, retenu }, + { participants: 260, propositions: PROPOSITIONS, retenu: 1 }, + ); + assert.deepEqual( + lu.charge.propositions.map(({ graine }) => graine), + Array.from({ length: PROPOSITIONS }, (_, rang) => rang + 1), + ); + assert.equal(serialiser(lu.charge, lu.entete), texte); + const { fautives, derives, retenu: examenRetenu } = examiner(lu.charge); + assert.deepEqual( + { fautives, derives, retenu: examenRetenu }, + { fautives: [], derives: [], retenu: { fautes: [], raisons: [] } }, + ); + }); + + test(`un geste qui retouche le placement retenu s'enregistre — journal, .precedent, état — en au plus ${ms(SEUIL_MS)}, au minimum de ${GESTES_MESURES} à ${GESTES_AU_PLUS} mesures après un geste de chauffe ; chaque geste laisse sur le disque l'état et le .precedent qu'il écrit, et l'événement rouvert est celui du dernier geste, dont le journal porte chaque révision`, async () => { + const dossier = mkdtempSync(join(tmpdir(), 'gtt-cout-')); + try { + const { fs, racine } = await systemeSur(dossier); + let ouvert = await creer(fs, racine, chargeMesuree(), CREATION); + // La charge de chaque révision, depuis la création, et le texte de + // l'état à la dernière. Chaque geste retouche le tour qui suit celui du + // geste d'avant. + const charges = [chargeMesuree()]; + let texteEtat = serialiser(chargeMesuree(), { revision: 1, produitVersion: V }); + const faire = async () => { + const revision = charges.length + 1; + const avant = charges.at(-1); + const apres = echanger(avant, (revision - 2) % avant.evenement.tours); + const debut = performance.now(); + ouvert = await enregistrer(ouvert, apres, geste(revision)); + const duree = performance.now() - debut; + const texteApres = serialiser(apres, { revision, produitVersion: V }); + assert.equal(ouvert.revision, revision); + affirmerEcrits(racine, ouvert.base, revision, { etat: texteApres, precedent: texteEtat }); + charges.push(apres); + texteEtat = texteApres; + return duree; + }; + const mesure = await mesurer(faire, { base: GESTES_MESURES, auPlus: GESTES_AU_PLUS, seuil: SEUIL_MS }); + + // L'événement se rouvre sans rien rattraper ni signaler, à la charge + // du dernier geste ; l'état sur le disque porte les vingt propositions + // et le retenu ; le journal porte une entrée par révision. + const revision = charges.length; + const rouvert = await ouvrir(fs, racine, ouvert.base); + assert.deepEqual(rouvert.avertissements, []); + assert.equal(rouvert.revision, revision); + assert.deepEqual(rouvert.charge, canoniser(charges.at(-1))); + const etat = analyser(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.etat), 'utf8')); + const { propositions, retenu } = etat.entete.comptes; + assert.deepEqual({ propositions, retenu }, { propositions: PROPOSITIONS, retenu: 1 }); + const journal = lireJournal(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.journal), 'utf8')); + assert.deepEqual( + journal.entrees.map((entree) => entree.revision), + Array.from({ length: revision }, (_, rang) => rang + 1), + ); + // Le journal restitue la charge de chaque révision jusqu'au dernier des + // GESTES_MESURES gestes de base, puis celle de la dernière. + // Reconstruire une révision rejoue chaque correctif depuis l'instantané + // qui la précède : le coût de relire chaque révision croît comme le + // carré du nombre de gestes entre deux instantanés. Les gestes de base, + // en nombre fixe, se relisent donc tous, et les gestes de plus de + // l'échantillonnage par le dernier. Un correctif faux se voit à sa + // révision : un geste ultérieur qui récrit les mêmes listes de table + // l'efface des suivantes. + const relues = Array.from({ length: GESTES_MESURES + 2 }, (_, rang) => rang + 1); + if (revision > relues.length) relues.push(revision); + for (const relue of relues) { + assert.deepEqual(reconstruire(journal, relue), canoniser(charges[relue - 1]), `révision ${relue}`); + } + + assert.ok( + Math.min(...mesure.durees) <= SEUIL_MS, + depasse('un geste sur la grande démonstration', mesure, SEUIL_MS, `REFERENCE_MS ${REFERENCE_MS} ms`), + ); + } finally { + rmSync(dossier, { recursive: true, force: true }); + } + }); + + test(`le geste dont l'entrée fait passer le journal au-delà de ${PLAFOND_ENTREES} entrées le réécrit élagué — journal, .precedent, état — en au plus ${ms(SEUIL_ELAGAGE_MS)}, au minimum de ${ESSAIS_ELAGAGE} à ${ESSAIS_ELAGAGE_AU_PLUS} essais après un essai de chauffe, chacun sur une copie neuve du journal plein ; le journal écrit part de l'instantané de la coupe et restitue le geste`, async () => { + const dossier = mkdtempSync(join(tmpdir(), 'gtt-elagage-')); + try { + const { fs, racine } = await systemeSur(dossier); + const { base } = await creer(fs, racine, chargeMesuree(), CREATION); + const chemin = (suffixe) => cheminDe(racine, base, suffixe); + const plein = evenementPlein(readFileSync(chemin(SUFFIXES.journal), 'utf8'), chargeMesuree()); + const poser = () => { + writeFileSync(chemin(SUFFIXES.journal), plein.journal); + writeFileSync(chemin(SUFFIXES.precedent), plein.precedent); + writeFileSync(chemin(SUFFIXES.etat), plein.etat); + }; + + // L'événement plein s'ouvre sans rien signaler, son journal à + // PLAFOND_ENTREES entrées. enregistrer ne modifie pas l'Ouvert reçu : + // chaque essai part du même, sur le journal plein reposé, la chauffe + // comprise. + poser(); + const ouvert = await ouvrir(fs, racine, base); + assert.deepEqual( + { avertissements: ouvert.avertissements, revision: ouvert.revision, entrees: ouvert.journal.entrees.length }, + { avertissements: [], revision: PLAFOND_ENTREES, entrees: PLAFOND_ENTREES }, + ); + + // La coupe de l'élagage : l'instantané le plus récent — révision 1, + // puis toutes les INTERVALLE_INSTANTANE — d'où le journal garde au + // moins PLANCHER_ENTREES entrées jusqu'au geste. + const revision = PLAFOND_ENTREES + 1; + const coupe = 1 + Math.floor((revision - PLANCHER_ENTREES) / INTERVALLE_INSTANTANE) * INTERVALLE_INSTANTANE; + const apres = echanger(plein.chargeDe(PLAFOND_ENTREES), 1); + const texteApres = serialiser(apres, { revision, produitVersion: V }); + let essai = 0; + let journal = null; + const faire = async () => { + poser(); + const debut = performance.now(); + await enregistrer(ouvert, apres, geste(revision)); + const duree = performance.now() - debut; + affirmerEcrits(racine, base, revision, { etat: texteApres, precedent: plein.etat }); + journal = lireJournal(readFileSync(chemin(SUFFIXES.journal), 'utf8')); + assert.deepEqual( + { + premiere: journal.entrees[0].revision, + entrees: journal.entrees.length, + jalons: journal.jalons.length, + ecartees: journal.ecartees, + }, + { premiere: coupe, entrees: revision - coupe + 1, jalons: 0, ecartees: 0 }, + `journal écrit par l'essai ${essai}`, + ); + assert.deepEqual(reconstruire(journal, revision), canoniser(apres)); + essai += 1; + return duree; + }; + const mesure = await mesurer(faire, { + base: ESSAIS_ELAGAGE, + auPlus: ESSAIS_ELAGAGE_AU_PLUS, + seuil: SEUIL_ELAGAGE_MS, + }); + + // Le journal élagué restitue encore l'instant d'avant le geste, et + // l'événement se rouvre sur le geste sans rien rattraper ni signaler. + assert.deepEqual(reconstruire(journal, PLAFOND_ENTREES), canoniser(plein.chargeDe(PLAFOND_ENTREES))); + const rouvert = await ouvrir(fs, racine, base); + assert.deepEqual( + { avertissements: rouvert.avertissements, revision: rouvert.revision }, + { avertissements: [], revision }, + ); + assert.deepEqual(rouvert.charge, canoniser(apres)); + + assert.ok( + Math.min(...mesure.durees) <= SEUIL_ELAGAGE_MS, + depasse( + 'le geste qui élague le journal plein', + mesure, + SEUIL_ELAGAGE_MS, + `REFERENCE_ELAGAGE_MS ${REFERENCE_ELAGAGE_MS} ms`, + ), + ); + } finally { + rmSync(dossier, { recursive: true, force: true }); + } + }); +});