[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions

The whole-iteration review found ways to lose or corrupt data. A state
saved in another encoding opened with its accents turned to U+FFFD and was
then overwritten; decoding is now strict, the state opens read-only, and a
gesture from its secours first moves it to the trash. A journal decodes line
by line, so one bad byte drops only the lines after it. A vanished journal
is rewritten whole, never appended to. Deleting refuses an event another
session holds; switching to writing rereads the disk; an import that
changes exclu says which reservations it suspends.
Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %.

--- FR ---

[FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions

La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données.
Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en
U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture
seule, et un geste depuis son secours le range d'abord à la corbeille. Le
journal se décode ligne par ligne : un octet fautif n'écarte que la suite.
Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse
un événement qu'une autre séance tient ; passer en écriture relit le disque ;
un import qui change exclu dit quelles réservations il suspend.
Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 01:20:42 -04:00
parent 67fd0bc9ff
commit 0bc48fa1ae
25 changed files with 2838 additions and 329 deletions

View file

@ -29,8 +29,10 @@
// //
// Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier, // Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier,
// cause}, une lecture que le système refuse autrement que sur un absent — un // cause}, une lecture que le système refuse autrement que sur un absent — un
// dossier illisible n'est pas un dossier vide —, et NON_DISPONIBLE {cause}, // dossier illisible n'est pas un dossier vide —, ou un fichier dont les
// l'explorateur du système qui ne s'ouvre pas. // octets ne sont pas de l'UTF-8, cause UTF8_INVALIDE, ses détails portant
// alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE
// {cause}, l'explorateur du système qui ne s'ouvre pas.
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { realpath as realpathRappel } from 'node:fs'; import { realpath as realpathRappel } from 'node:fs';
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises'; import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
@ -104,9 +106,18 @@ const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u;
const ABSENCE = new Set(['ENOENT', 'ENOTDIR']); const ABSENCE = new Set(['ENOENT', 'ENOTDIR']);
const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']); const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']);
// Décodage strict d'un fichier lu : un octet hors UTF-8 lève au lieu de se
// lire U+FFFD, et la marque d'ordre d'octets reste dans le texte.
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// Le remplaçant JSON d'un détail : un Uint8Array devient « N octets », comme
// dans le message d'une ErreurStockage.
const sansOctets = (cle, valeur) => (valeur instanceof Uint8Array ? `${valeur.length} octets` : valeur);
/** /**
* Un échec que l'interface nomme : le code et les détails d'une * Un échec que l'interface nomme : le code et les détails d'une
* ErreurStockage (src/stockage/types.js en donne la table). * ErreurStockage (src/stockage/types.js en donne la table). Le message écrit
* les détails en JSON, un détail en octets par son seul nombre d'octets.
*/ */
export class RefusFichier extends Error { export class RefusFichier extends Error {
/** /**
@ -114,7 +125,7 @@ export class RefusFichier extends Error {
* @param {Object} details * @param {Object} details
*/ */
constructor(code, details) { constructor(code, details) {
super(`${code} ${JSON.stringify(details)}`); super(`${code} ${JSON.stringify(details, sansOctets)}`);
this.code = code; this.code = code;
this.details = details; this.details = details;
} }
@ -598,12 +609,19 @@ export function creerFichiers({
async lireTexte(racine, chemin) { async lireTexte(racine, chemin) {
const loc = localiser(racine, chemin); const loc = localiser(racine, chemin);
let octets;
try { try {
await confiner(loc); await confiner(loc);
return (await fs.readFile(loc.absolu)).toString('utf8'); octets = await fs.readFile(loc.absolu);
} catch (erreur) { } catch (erreur) {
throw echecLecture(loc, erreur); throw echecLecture(loc, erreur);
} }
try {
return UTF8_STRICT.decode(octets);
} catch {
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE' };
throw new RefusFichier('LECTURE', { ...details, octets: new Uint8Array(octets) });
}
}, },
// Une cible qui est un dossier est refusée avant d'écrire : le // Une cible qui est un dossier est refusée avant d'écrire : le

View file

@ -29,7 +29,7 @@ import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { serialiserCharge } from '../stockage/canonique.js'; import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js'; import { ErreurStockage } from '../stockage/erreurs.js';
import { versFichier } from '../stockage/placements.js'; import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
/** /**
@ -266,6 +266,25 @@ function supprimerParticipant(charge, { id }) {
}; };
} }
// Les avertissements de chaque personne qu'un import exclut ou réintègre
// (§ 4.4) : celle que la charge suivante porte sous le même identifiant,
// exclu changé, et qui tient une réservation. Chacune a le bilan que
// donnerait sa commande seule, exclureParticipant ou reintegrerParticipant,
// appliquée à la charge d'avant : RESERVATIONS_SUSPENDUES ou
// RESERVATIONS_RETABLIES, par identifiant croissant.
function avertissementsDExclusion(avant, suivante) {
const exclusDApres = new Map(suivante.participants.map(({ id, exclu }) => [id, exclu]));
return avant.participants
.filter(({ id, exclu }) => exclusDApres.has(id) && exclusDApres.get(id) !== exclu)
.sort((a, b) => a.id - b.id)
.map((participant) => {
const seule = avecParticipant(avant, participant.id, (copie) => ({ ...copie, exclu: !copie.exclu }));
const code = participant.exclu ? 'RESERVATIONS_RETABLIES' : 'RESERVATIONS_SUSPENDUES';
return avertissement(code, bilanExclusion(avant, seule, participant));
})
.filter(({ details }) => details.reservations > 0);
}
/** /**
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le * importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu * mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
@ -274,6 +293,13 @@ function supprimerParticipant(charge, { id }) {
* l'état : sans avertissement en brouillon et proposé ; en retenu, * l'état : sans avertissement en brouillon et proposé ; en retenu,
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le * l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE. * signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
* Une personne que l'import exclut ou réintègre le dit ensuite comme sa
* commande le dirait (avertissementsDExclusion).
*
* Un import dont la charge ne change pas, mais qui refuse des lignes, n'est
* pas sans effet : ses refus sont à lire et à réexporter. Il rend la charge
* reçue, un libellé null — aucune entrée ne s'écrit —, aucun avertissement,
* et son résumé.
*/ */
function importerParticipants(charge, { apercu, mode }) { function importerParticipants(charge, { apercu, mode }) {
exigerModifiable(charge); exigerModifiable(charge);
@ -284,11 +310,17 @@ function importerParticipants(charge, { apercu, mode }) {
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null }); if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
throw erreur; throw erreur;
} }
const { ajoutes, misAJour, retires, derive } = resultat.resume; const { ajoutes, misAJour, retires, derive, refusees } = resultat.resume;
if (refusees.length > 0 && serialiserCharge(resultat.charge) === serialiserCharge(charge)) {
return { charge, libelle: null, avertissements: [], resume: resultat.resume };
}
return { return {
charge: resultat.charge, charge: resultat.charge,
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }), libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
avertissements: derive ? [avertissement('IMPORT_EN_RETENU', {})] : [], avertissements: [
...(derive ? [avertissement('IMPORT_EN_RETENU', {})] : []),
...avertissementsDExclusion(charge, resultat.charge),
],
resume: resultat.resume, resume: resultat.resume,
}; };
} }
@ -418,11 +450,14 @@ export const COMMANDES = new Map([
* Applique la commande nom à la charge : ce qu'elle rend, avertissements [] * Applique la commande nom à la charge : ce qu'elle rend, avertissements []
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la * quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET. * charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
* Un rendu au libellé null ne change rien non plus, mais porte ce qu'il y a
* à lire — l'import dont seules des lignes refusées auraient changé la
* liste : il passe tel quel, et rien ne s'écrit.
* *
* @param {string} nom * @param {string} nom
* @param {Object} charge * @param {Object} charge
* @param {Object} [args] * @param {Object} [args]
* @returns {{charge: Object, libelle: string, avertissements: Array<{code: string, details: Object}>}} * @returns {{charge: Object, libelle: string|null, avertissements: Array<{code: string, details: Object}>}}
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET * @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
* @throws {TypeError} une commande inconnue, un argument de forme fausse * @throws {TypeError} une commande inconnue, un argument de forme fausse
*/ */
@ -430,10 +465,45 @@ export function appliquerCommande(nom, charge, args = {}) {
const commande = COMMANDES.get(nom); const commande = COMMANDES.get(nom);
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`); if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
const rendu = commande(charge, args ?? {}); const rendu = commande(charge, args ?? {});
if (rendu.libelle === null) return { ...rendu, avertissements: rendu.avertissements ?? [] };
if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom }); if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
return { ...rendu, avertissements: rendu.avertissements ?? [] }; return { ...rendu, avertissements: rendu.avertissements ?? [] };
} }
/**
* La forme nommée d'un placement de la charge (§ 8.9, point 4), pour la
* lecture et le signalement d'un défaut : une lecture, ni commande ni
* entrée. cible désigne { proposition: id } ou { retenu: true }. Rend
* { lignes, fautes } : fautes, celles que le placement porte (fautes) ;
* lignes, sa forme nommée (formeNommee), une ligne par identifiant écrit,
* ou null quand sa forme est fausse (faute FORME), qui ne se lit pas en
* lignes. Une proposition fautive n'est plus dans la charge qu'examiner
* rend ; le retenu y reste, fautif ou non. La charge n'est pas modifiée.
*
* @param {Object} charge
* @param {{proposition: number}|{retenu: true}} cible
* @returns {{lignes: Array<Object>|null, fautes: Array<Object>}}
* @throws {ErreurCommande} PROPOSITION_INCONNUE {id} ; AUCUN_RETENU, remède
* la section des propositions
* @throws {TypeError} une cible d'une autre forme
*/
export function formeNommeeDe(charge, cible) {
const cles = cible !== null && typeof cible === 'object' ? clesRangees(cible) : null;
let placement;
if (cles?.length === 1 && cles[0] === 'proposition' && Number.isSafeInteger(cible.proposition)) {
placement = charge.propositions.find(({ id }) => id === cible.proposition);
if (placement === undefined) throw refus('PROPOSITION_INCONNUE', { id: cible.proposition });
} else if (cles?.length === 1 && cles[0] === 'retenu' && cible.retenu === true) {
placement = charge.retenu;
if (placement === null) throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
} else {
throw new TypeError(`cible : {proposition: id} ou {retenu: true} attendu, reçu ${JSON.stringify(cible)}`);
}
const lues = fautes(placement);
const lisible = !lues.some(({ code }) => code === 'FORME');
return { lignes: lisible ? formeNommee(placement, charge) : null, fautes: lues };
}
/** /**
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque * La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
* champ se contrôle selon sa règle avant que l'identifiant ne se tire : * champ se contrôle selon sa règle avant que l'identifiant ne se tire :

View file

@ -12,9 +12,9 @@ import { apercevoir } from '../csv/apercu.js';
import { bilanRemplacement } from '../csv/import.js'; import { bilanRemplacement } from '../csv/import.js';
import { rechercher } from '../moteur/recherche.js'; import { rechercher } from '../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, planDepuisFichier } from '../stockage/placements.js'; import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve } from './commandes.js'; import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
const V1 = VERSION.affichee; const V1 = VERSION.affichee;
@ -118,7 +118,7 @@ describe('commandes : le registre (§ 8.2)', () => {
test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => { test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => {
const charge = chargeEssai(); const charge = chargeEssai();
const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants }); const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants, mode: 'mettreAJour' });
refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', { refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', {
commande: 'importerParticipants', commande: 'importerParticipants',
remede: null, remede: null,
@ -256,7 +256,7 @@ describe("commandes : l'import (§ 10.1)", () => {
test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => { test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => {
const avant = chargeEssai(); const avant = chargeEssai();
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants }); const apercu = apercevoir(CSV_DEUX, { participants: avant.participants, mode: 'remplacer' });
const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' }); const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' });
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés'); assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
assert.deepEqual(remplace.charge.reservations, []); assert.deepEqual(remplace.charge.reservations, []);
@ -264,7 +264,7 @@ describe("commandes : l'import (§ 10.1)", () => {
// intervertirait ne rendrait pas. // intervertirait ne rendrait pas.
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n'; const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
const misAJour = executer('importerParticipants', avant, { const misAJour = executer('importerParticipants', avant, {
apercu: apercevoir(csv, { participants: avant.participants }), apercu: apercevoir(csv, { participants: avant.participants, mode: 'mettreAJour' }),
mode: 'mettreAJour', mode: 'mettreAJour',
}); });
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]); assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]);
@ -277,7 +277,7 @@ describe("commandes : l'import (§ 10.1)", () => {
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge; const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
const retenue = chargeRiche(); const retenue = chargeRiche();
assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']); assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']);
const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants }), mode }); const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants, mode }), mode });
assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []); assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []);
assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []); assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []);
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) { for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
@ -285,7 +285,10 @@ describe("commandes : l'import (§ 10.1)", () => {
} }
// Une mise à jour sans personne nouvelle avertit aussi : le placement // Une mise à jour sans personne nouvelle avertit aussi : le placement
// retenu date de la liste d'avant. // retenu date de la liste d'avant.
const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', { participants: retenue.participants }); const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', {
participants: retenue.participants,
mode: 'mettreAJour',
});
const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' }); const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' });
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]); assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
assert.deepEqual(misAJour.avertissements, enRetenu); assert.deepEqual(misAJour.avertissements, enRetenu);
@ -313,6 +316,104 @@ describe("commandes : l'import (§ 10.1)", () => {
}); });
}); });
describe("commandes : l'import qui exclut, réintègre ou ne change rien (§ 4.4, § 10.1)", () => {
// La charge d'essai, où Ondine Lacasse tient aussi une réservation, au
// tour 1, sans siège.
function deuxReservees() {
const charge = chargeEssai();
charge.reservations.push({ participant: 4, table: 2, siege: null, portee: 'tour', tour: 1 });
return charge;
}
const importer = (charge, csv) =>
appliquerCommande('importerParticipants', charge, {
apercu: apercevoir(csv, { participants: charge.participants, mode: 'mettreAJour' }),
mode: 'mettreAJour',
});
test("une mise à jour qui exclut des personnes réservées le dit pour chacune, comme exclure (§ 4.4)", () => {
const avant = figer(deuxReservees());
const rendu = importer(avant, 'nom;prenom;exclu\nOmbrelle;Iris;oui\nLacasse;Ondine;oui\nPervenche;Théo;oui\n');
assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 3 modifiés');
assert.deepEqual(rendu.avertissements, [
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 4, personne: 'Ondine Lacasse', reservations: 1, titres: 0 } },
]);
// Le même bilan que la commande d'exclusion, personne par personne.
assert.deepEqual(rendu.avertissements[0], executer('exclureParticipant', avant, { id: 1 }).avertissements[0]);
assert.deepEqual(rendu.avertissements[1], executer('exclureParticipant', avant, { id: 4 }).avertissements[0]);
});
test('une mise à jour qui réintègre une personne réservée le dit, comme réintégrer ; en retenu, après la dérive', () => {
const exclue = executer('exclureParticipant', chargeEssai(), { id: 1 }).charge;
const rendu = importer(exclue, 'nom;prenom;exclu\nOmbrelle;Iris;non\n');
const retablies = { code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } };
assert.deepEqual(rendu.avertissements, [retablies]);
assert.equal(rendu.charge.participants[0].exclu, false);
const retenue = chargeRiche();
const enRetenu = importer(retenue, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n');
assert.deepEqual(enRetenu.avertissements.map(({ code }) => code), ['IMPORT_EN_RETENU', 'RESERVATIONS_SUSPENDUES']);
});
test("une mise à jour dont les lignes valides ne changent rien, mais qui refuse des lignes, n'est pas SANS_EFFET : elle rend son résumé, sans entrée", () => {
const avant = chargeEssai();
avant.participants.push(personne(5, 'Lacasse', 'Ondine'));
avant.prochainsIds.participant = 6;
figer(avant);
const rendu = importer(avant, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n');
assert.equal(rendu.libelle, null);
assert.deepEqual(rendu.avertissements, []);
assert.equal(rendu.charge, avant);
assert.deepEqual(rendu.resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [
[2, 'PLUSIEURS_CORRESPONDENT', [4, 5]],
]);
assert.deepEqual([rendu.resume.ajoutes, rendu.resume.misAJour, rendu.resume.inchanges], [0, 0, 1]);
});
});
describe('commandes : la forme nommée, une lecture (§ 8.9, point 4)', () => {
test("rend la forme nommée d'une proposition ou du retenu, avec ses fautes, sans toucher la charge", () => {
const charge = figer(chargeRiche());
assert.deepEqual(formeNommeeDe(charge, { proposition: 2 }), {
lignes: formeNommee(charge.propositions[1], charge),
fautes: [],
});
const retenu = formeNommeeDe(charge, { retenu: true });
assert.deepEqual(retenu, { lignes: formeNommee(charge.retenu, charge), fautes: [] });
assert.ok(retenu.lignes.length > 0);
assert.ok(retenu.lignes.every(({ nom }) => typeof nom === 'string'));
});
test("un retenu incohérent se lit quand même : une ligne par identifiant écrit, et ses fautes à côté", () => {
const charge = chargeRiche();
const assis = charge.retenu.placement[0].sieges[0][0];
charge.retenu.placement[0].reserve = [...charge.retenu.placement[0].reserve, assis].sort((a, b) => a - b);
const coherent = chargeRiche();
const lue = formeNommeeDe(charge, { retenu: true });
assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]);
assert.deepEqual(lue.fautes, fautes(charge.retenu));
assert.equal(lue.lignes.length, formeNommee(coherent.retenu, coherent).length + 1);
assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2);
});
test("un placement dont la forme est fausse n'a pas de lignes : ses fautes seules le disent", () => {
const charge = chargeRiche();
charge.retenu.placement = 'illisible';
const lue = formeNommeeDe(charge, { retenu: true });
assert.equal(lue.lignes, null);
assert.deepEqual(lue.fautes.map(({ code }) => code), ['FORME']);
});
test("une proposition inconnue, un retenu absent sont refusés ; une cible d'une autre forme est une faute du code", () => {
refuse(() => formeNommeeDe(chargeRiche(), { proposition: 9 }), 'PROPOSITION_INCONNUE', { id: 9, remede: null });
refuse(() => formeNommeeDe(chargeEssai(), { retenu: true }), 'AUCUN_RETENU', {
remede: { geste: 'ouvrirSection', section: 'propositions' },
});
for (const cible of [{}, { retenu: false }, { proposition: '2' }, { proposition: 2, retenu: true }, null]) {
assert.throws(() => formeNommeeDe(chargeRiche(), cible), TypeError, JSON.stringify(cible));
}
});
});
describe('commandes : les états du plan (§ 9)', () => { describe('commandes : les états du plan (§ 9)', () => {
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu. // Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
function chargeA(etat, contenu) { function chargeA(etat, contenu) {

View file

@ -6,8 +6,10 @@
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une // raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction // entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais // compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
// un code brut. Une valeur codée d'un détail — raison, état, champ, mode, // un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
// échec — que la table ne connaît pas lève aussi, au lieu de se taire. Une // que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la
// nomme, par son texte quand la table en porte un, telle quelle sinon. Une
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas // cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
// un code de l'application : les textes la citent telle quelle, entre // un code de l'application : les textes la citent telle quelle, entre
// parenthèses ; une cause que la table porte s'y lit par son texte. // parenthèses ; une cause que la table porte s'y lit par son texte.
@ -104,10 +106,25 @@ const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chem
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.'; const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`; const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
// Une raison codée en une phrase : son texte quand la table en porte un et
// que les détails reçus lui suffisent ; sinon une phrase générique qui la
// cite telle quelle. Un texte qui attend ses propres détails lève ou rend
// « undefined » sans eux : la phrase générique le remplace alors.
function phraseDeRaison(raison, details = {}) {
const generique = `La raison donnée est ${cite(String(raison))}.`;
if (!TEXTES.has(raison)) return generique;
try {
const texte = texteDe(raison, details);
return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique;
} catch {
return generique;
}
}
// La raison d'un fichier d'état illisible, en une phrase. // La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible({ raison, ...details }) { function raisonIllisible({ raison, ...details }) {
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique."; if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
return texteDe(raison, details); return phraseDeRaison(raison, details);
} }
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme. // Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
@ -195,7 +212,14 @@ const TEXTES = new Map([
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') { if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`; return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
} }
throw new RangeError(`libelle : raison de lecture seule inconnue ${JSON.stringify(raison)}`); if (raison === 'ENCODAGE') {
return (
`Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` +
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
"range ce fichier dans la corbeille avant d'écrire l'état."
);
}
return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
}, },
], ],
[ [
@ -230,6 +254,10 @@ const TEXTES = new Map([
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`; return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
}, },
], ],
[
'ENCODAGE',
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
],
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ---- // --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
[ [
@ -289,12 +317,24 @@ const TEXTES = new Map([
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` + `${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
'à partir de la première ligne illisible.', 'à partir de la première ligne illisible.',
], ],
[
'JOURNAL_RECREE',
() =>
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
'tel que la séance le connaissait.',
],
[ [
'JOURNAL_FORMAT_PLUS_RECENT', 'JOURNAL_FORMAT_PLUS_RECENT',
({ format }) => ({ format }) =>
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` + `L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
"il ne se lit pas, et l'événement reste en lecture seule.", "il ne se lit pas, et l'événement reste en lecture seule.",
], ],
[
'JOURNAL_ILLISIBLE',
({ raison }) =>
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` +
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
],
[ [
'JOURNAL_ETRANGER', 'JOURNAL_ETRANGER',
({ evenement }) => ({ evenement }) =>
@ -307,6 +347,12 @@ const TEXTES = new Map([
? `Une proposition incohérente est écartée (${numeros(ids)}).` ? `Une proposition incohérente est écartée (${numeros(ids)}).`
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`, : `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
], ],
[
'PROPOSITIONS_MANQUANTES',
({ n }) =>
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
'dans le fichier : celles qu\'il porte sont gardées.',
],
[ [
'DERIVE', 'DERIVE',
(details) => { (details) => {
@ -353,11 +399,13 @@ const TEXTES = new Map([
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " + "Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.', 'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
], ],
// dossier, quand il est donné, nomme le dossier où la séance écrit
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
[ [
'SUPPORT_AMOVIBLE', 'SUPPORT_AMOVIBLE',
() => ({ dossier }) =>
'Le dossier de travail est sur un support amovible : ' + `${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
'retirer le support pendant une écriture peut la laisser incomplète.', 'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
], ],
[ [
'REGLAGES_ILLISIBLES', 'REGLAGES_ILLISIBLES',
@ -455,6 +503,27 @@ const TEXTES = new Map([
}, },
], ],
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."], ['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
// Quitter l'événement d'un support rompu : la séance tient la seule copie
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
// dernier geste n'est qu'à l'historique de ce support.
[
'SUPPORT_ROMPU_NON_SAUVE',
({ echec, dossier }) => {
const geste =
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
return (
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
);
},
],
[
'EVENEMENT_CHANGE',
({ revision }) =>
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
],
[ [
'VERROU_REPRIS', 'VERROU_REPRIS',
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`, ({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
@ -580,7 +649,8 @@ export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
* @param {Object} [details] * @param {Object} [details]
* @returns {string} * @returns {string}
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails * @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
* — raison, état, champ, mode, échec — que la table ne connaît pas * — état, champ, mode, échec — que la table ne connaît pas ; jamais
* une raison, qu'une phrase générique nomme
*/ */
export function libelle(code, details = {}) { export function libelle(code, details = {}) {
return texteDe(code, details); return texteDe(code, details);

View file

@ -147,6 +147,17 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']); assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']);
}); });
test("un motif de lecture seule ou d'illisibilité ne fait jamais lever le texte ni n'y laisse « undefined », quel que soit le code qui le porte", () => {
assert.ok(CODES.length > 0, 'table vide');
for (const code of ['LECTURE_SEULE', 'ETAT_ILLISIBLE', 'JOURNAL_ILLISIBLE']) {
for (const raison of [...CODES, 'RAISON_INVENTEE']) {
const texte = libelle(code, { raison, base: 'Soirée des Lucioles', chemin: null });
assert.equal(typeof texte, 'string', `${code} / ${raison}`);
assert.ok(!texte.includes('undefined'), `${code} / ${raison} : ${texte}`);
}
}
});
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => { test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
const { parArbre, tables } = releverArbre(); const { parArbre, tables } = releverArbre();
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`); for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
@ -204,6 +215,7 @@ const EXEMPLES = new Map([
['REFERENCE', { chemin: 'charge.reservations[0].table' }], ['REFERENCE', { chemin: 'charge.reservations[0].table' }],
['FORMAT_INCONNU', { chemin: 'entete.format' }], ['FORMAT_INCONNU', { chemin: 'entete.format' }],
['FORMAT_PLUS_RECENT', { format: 2 }], ['FORMAT_PLUS_RECENT', { format: 2 }],
['ENCODAGE', {}],
// Fautes d'un placement // Fautes d'un placement
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }], ['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
['DOUBLON', { tour: 1, participant: 3 }], ['DOUBLON', { tour: 1, participant: 3 }],
@ -225,9 +237,12 @@ const EXEMPLES = new Map([
['JOURNAL_EN_AVANCE_DE', { n: 3 }], ['JOURNAL_EN_AVANCE_DE', { n: 3 }],
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }], ['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
['JOURNAL_ECARTEES', { n: 64 }], ['JOURNAL_ECARTEES', { n: 64 }],
['JOURNAL_RECREE', {}],
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }], ['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
['JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' }],
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }], ['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }], ['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
['PROPOSITIONS_MANQUANTES', { n: 2 }],
['DERIVE', { ids: [1, 2], retenu: true }], ['DERIVE', { ids: [1, 2], retenu: true }],
// Dossier de travail et réglages locaux // Dossier de travail et réglages locaux
['NON_PUBLIE', {}], ['NON_PUBLIE', {}],
@ -265,6 +280,8 @@ const EXEMPLES = new Map([
['BLOQUE', {}], ['BLOQUE', {}],
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }], ['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['SUPPORT_INTACT', {}], ['SUPPORT_INTACT', {}],
['SUPPORT_ROMPU_NON_SAUVE', { echec: 'ETAT_NON_ECRIT', chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EIO' }],
['EVENEMENT_CHANGE', { revision: 7 }],
['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }], ['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }],
['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }], ['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
['RIEN_A_DEFAIRE', {}], ['RIEN_A_DEFAIRE', {}],
@ -385,6 +402,19 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
} }
}); });
test("le support amovible nomme le dossier quand il est donné ; l'événement changé nomme la révision adoptée", () => {
assert.doesNotMatch(libelle('SUPPORT_AMOVIBLE', {}), /«/);
assert.match(libelle('SUPPORT_AMOVIBLE', { dossier: 'F:\\soirees' }), /« F:\\soirees »/);
assert.match(libelle('EVENEMENT_CHANGE', { revision: 7 }), /\b7\b/);
});
test("quitter un événement au support rompu nomme « Écrire ailleurs », et ce qui serait perdu", () => {
const details = { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' };
for (const echec of ['ECRITURE', 'ETAT_NON_ECRIT', 'JALON_NON_ECRIT', 'VERROU_NON_ECRIT']) {
assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', { echec, ...details }), /« Écrire ailleurs »/, echec);
}
});
test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => { test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => {
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/); assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/); assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/);
@ -405,8 +435,39 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } }; const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } };
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison); assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
} }
assert.throws(() => libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null }), RangeError); const encodage = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'ENCODAGE', chemin: null, secours: { precedent: 4, journal: null } });
assert.throws(() => libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' }), RangeError); assert.ok(encodage.includes(libelle('ENCODAGE')), encodage);
});
test("une raison qu'un texte ne connaît pas ne lève jamais : une phrase générique la nomme, par son texte quand la table en porte un, telle quelle sinon", () => {
const inventee = libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null });
assert.match(inventee, /« RAISON_INVENTEE »/);
const seule = libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' });
assert.match(seule, /« RAISON_INVENTEE »/);
assert.match(seule, /lecture seule/);
const parSonTexte = libelle('LECTURE_SEULE', { raison: 'VIDE' });
assert.ok(parSonTexte.includes(libelle('VIDE')), parSonTexte);
assert.doesNotMatch(parSonTexte, /VIDE/);
assert.match(libelle('JOURNAL_ILLISIBLE', { raison: 'RAISON_INVENTEE' }), /« RAISON_INVENTEE »/);
for (const texte of [inventee, seule, parSonTexte]) for (const trace of TRACES) assert.doesNotMatch(texte, trace);
});
test("un état aux octets hors UTF-8 se dit par son encodage : en lecture seule, il nomme le secours qui le met de côté ; le journal illisible, l'historique laissé tel quel", () => {
const seule = libelle('LECTURE_SEULE', { raison: 'ENCODAGE' });
assert.ok(seule.includes(libelle('ENCODAGE')), seule);
assert.match(seule, /secours/);
assert.match(seule, /corbeille/);
assert.notEqual(seule, libelle('LECTURE_SEULE', { raison: 'FORMAT' }));
const journal = libelle('JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' });
assert.ok(journal.includes(libelle('ENCODAGE')), journal);
assert.match(libelle('JOURNAL_RECREE'), /historique/);
});
test('les propositions manquantes se disent par leur nombre, au singulier comme au pluriel, sans jamais les énumérer', () => {
assert.match(libelle('PROPOSITIONS_MANQUANTES', { n: 1 }), /^1 proposition que l'en-tête annonce manque/);
const enorme = libelle('PROPOSITIONS_MANQUANTES', { n: 2 ** 40 });
assert.match(enorme, new RegExp(`^${2 ** 40} propositions que l'en-tête annonce manquent`));
assert.ok(enorme.length < 200, enorme);
}); });
test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => { test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => {

View file

@ -18,11 +18,25 @@
// Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire : // Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire :
// un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un // un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un
// plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture, // plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture,
// MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Sur un plan bloqué, débloquer // MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Un état aux octets hors
// UTF-8 ne s'ouvre qu'en secours, dont le premier geste le met de côté dans
// la corbeille (§ 8.8). Sur un plan bloqué, débloquer
// est le seul geste admis : il prend lui-même le verrou, et laisse la séance // est le seul geste admis : il prend lui-même le verrou, et laisse la séance
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en // en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
// lecture et rend le verrou. // lecture et rend le verrou.
// //
// Le verrou pris — passer en écriture, débloquer —, la séance relit
// l'événement sur le disque : une autre séance a pu l'écrire tant que
// celle-ci le lisait. Un Ouvert de secours se relit par l'état dès que
// l'état a changé depuis l'ouverture ; un .precedent, par le journal dès
// que le journal a reçu une entrée ; et par sa source sinon. Ce qu'elle
// relit devient son Ouvert, et EVENEMENT_CHANGE {revision} le dit quand la
// révision ou la charge diffèrent : une séance n'écrit jamais par-dessus le
// travail d'une autre (§ 8.2, § 8.8). Un événement qui ne se relit plus rend
// le verrou, et le refus de la lecture remonte ; un état devenu illisible par
// ses octets hors UTF-8 se refuse comme un format plus récent,
// LECTURE_SEULE {raison: 'ENCODAGE'}.
//
// Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle // Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle
// d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt // d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt
// l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte // l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte
@ -31,7 +45,14 @@
// l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé // l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé
// ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà // ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà
// l'événement, celui de la séance compris : deux copies d'un même // l'événement, celui de la séance compris : deux copies d'un même
// identifiant ne se côtoient jamais dans un dossier (§ 8.6). // identifiant ne se côtoient jamais dans un dossier (§ 8.6). Tant que le
// support est rompu, l'Ouvert de la séance est la seule copie qu'écrire
// ailleurs peut sauver : ouvrir, ouvrir un secours, créer et fermer se
// refusent SUPPORT_ROMPU_NON_SAUVE, sauf appelés avec {abandonner: true}.
//
// Les avertissements de la séance sont ceux de l'Ouvert courant — ceux de
// l'ouverture, puis ceux que le dépôt recalcule après chaque écriture —,
// suivis de ceux du dernier geste.
// //
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre, // Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a // dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
@ -51,7 +72,7 @@ import {
} from '../stockage/depot.js'; } from '../stockage/depot.js';
import { ErreurStockage } from '../stockage/erreurs.js'; import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js'; import { fil, reconstruire } from '../stockage/journal.js';
import { appliquerCommande, chargeNeuve } from './commandes.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */ /** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
@ -80,6 +101,10 @@ const avertissement = (code, details) => ({ code, details });
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque'; const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
// La révision de la dernière entrée du journal de l'Ouvert, null sans
// journal.
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
// Le libellé de l'entrée de révision donnée. // Le libellé de l'entrée de révision donnée.
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle; const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
@ -122,6 +147,9 @@ export function creerSeance({
const libelleReprise = libelle('ENTREE_REPRISE'); const libelleReprise = libelle('ENTREE_REPRISE');
// L'état de la séance. ouvert : l'Ouvert courant, null sans événement ; // L'état de la séance. ouvert : l'Ouvert courant, null sans événement ;
// source : d'où il se relit, null pour l'état, 'precedent' ou 'journal'
// pour un secours que nul geste n'a encore écrit en état — 'journal' aussi
// après un jalon qui pose l'entrée de reprise sans écrire l'état ;
// verrouTenu : la séance tient le verrou de cet événement ; refusSupport : // verrouTenu : la séance tient le verrou de cet événement ; refusSupport :
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ; // l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier // verrouAutre : le verrou d'une autre séance qui a refusé le dernier
@ -130,12 +158,12 @@ export function creerSeance({
const etat = { const etat = {
racine, racine,
ouvert: null, ouvert: null,
source: null,
mode: 'lecture', mode: 'lecture',
verrouTenu: false, verrouTenu: false,
supportRompu: false, supportRompu: false,
refusSupport: null, refusSupport: null,
verrouAutre: null, verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [], avertissementsGeste: [],
gestes: 0, gestes: 0,
calculs: 0, calculs: 0,
@ -233,6 +261,72 @@ export function creerSeance({
} }
} }
// L'Ouvert que le disque porte pour l'événement de l'Ouvert reçu, et la
// source d'où il se relira : { frais, source }. Sans source, par l'état
// (ouvrir). Un Ouvert de secours se relit par l'état quand celui-ci se lit
// et que son texte n'est plus celui de l'ouverture : une écriture l'a
// remplacé, et la source revient à l'état. Sinon — l'état illisible, ou
// lisible et inchangé, que l'opérateur a écarté pour son secours (§ 8.9,
// point 3) —, par sa source (ouvrirSecours) ; un .precedent dont le journal
// lisible a reçu une entrée depuis cède au dernier instant du journal, qui
// porte cette entrée, et la source devient 'journal'. Un journal qui ne se
// lit plus — d'un format plus récent, mal encodé — laisse le .precedent,
// lecture seule comprise. Un refus autre qu'ETAT_ILLISIBLE remonte. Sans
// source, un état dont les octets ne sont plus de l'UTF-8 garde la séance
// en lecture, comme un format plus récent : LECTURE_SEULE {raison:
// 'ENCODAGE'} ; l'opérateur l'écarte en ouvrant un secours.
async function relire(ouvert) {
const { fs: systeme, racine: dossier, base, texteEtat } = ouvert;
if (etat.source === null) {
try {
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
} catch (erreur) {
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
if (illisible && erreur.details.raison === 'ENCODAGE') {
throw new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE' });
}
throw erreur;
}
}
try {
const parEtat = await ouvrirSurDisque(systeme, dossier, base);
if (parEtat.texteEtat !== texteEtat) return { frais: parEtat, source: null };
} catch (erreur) {
if (!(erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE')) throw erreur;
}
const parSource = await ouvrirSecoursSurDisque(systeme, dossier, base, etat.source);
const derniere = derniereEntree(parSource);
if (etat.source === 'precedent' && derniere !== null && derniere !== derniereEntree(ouvert)) {
return { frais: await ouvrirSecoursSurDisque(systeme, dossier, base, 'journal'), source: 'journal' };
}
return { frais: parSource, source: etat.source };
}
// Relit l'événement de l'Ouvert, le verrou de la séance pris (relire). Ce
// que la lecture rend devient l'Ouvert de la séance, journal et jalons
// compris ; rend [EVENEMENT_CHANGE {revision}] quand sa révision ou sa
// charge diffèrent de celles de l'Ouvert, [] sinon. Une entrée d'une autre
// séance change toujours la révision relue : l'état qu'elle a écrit, ou le
// dernier instant du journal qu'un .precedent lui cède (relire). Une
// lecture refusée rend le verrou — ses avertissements deviennent ceux du
// dernier geste —, et son refus remonte : la séance reste en lecture sur
// l'Ouvert reçu.
async function relireSousVerrou(ouvert) {
let relu;
try {
relu = await relire(ouvert);
} catch (erreur) {
etat.avertissementsGeste = await rendreVerrou(ouvert);
throw erreur;
}
const { frais, source } = relu;
etat.ouvert = frais;
etat.source = source;
const change =
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
}
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le // Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
// retour explicite annule en plus le geste ouvert. Les avertissements du // retour explicite annule en plus le geste ouvert. Les avertissements du
// rendu s'ajoutent à ceux du dernier geste. // rendu s'ajoutent à ceux du dernier geste.
@ -256,6 +350,15 @@ export function creerSeance({
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport }); if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
} }
// Quitter l'événement — ouvrir, créer, fermer — quand le support est
// rompu perdrait la seule copie qu'écrire ailleurs peut sauver : refusé,
// sauf sur abandon explicite.
function exigerSauve({ abandonner = false } = {}) {
if (etat.supportRompu && abandonner !== true) {
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport });
}
}
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans // L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
// l'ordre de l'en-tête du module. // l'ordre de l'en-tête du module.
function exigerEcriture() { function exigerEcriture() {
@ -311,6 +414,7 @@ export function creerSeance({
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun })); if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
const entree = { libelle: texte, produitVersion, sens, retour, ...commun }; const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree)); ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
etat.source = null;
const suite = [...avertissements]; const suite = [...avertissements];
const etatDuPlan = ouvert.charge.evenement.etat; const etatDuPlan = ouvert.charge.evenement.etat;
if (etatDuPlan !== depart.charge.evenement.etat) { if (etatDuPlan !== depart.charge.evenement.etat) {
@ -340,19 +444,37 @@ export function creerSeance({
} }
// Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La // Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La
// commande d'abord, qui ne touche rien ; puis le verrou, que la séance // commande d'abord, qui ne touche rien ; puis, en lecture, le verrou, que
// prend en lecture ; puis l'entrée et le jalon de l'état rendu. La séance // la séance prend, et la relecture de l'événement (relireSousVerrou) ; la
// reste en écriture. // commande s'applique alors à ce qui est relu. Celui-ci d'un format plus
// récent, ou que la commande refuse — une autre séance l'a débloqué —,
// rend le verrou, la séance restant en lecture sur ce qu'elle a relu, et
// le refus remonte. Sinon l'entrée et le jalon de l'état rendu s'écrivent,
// et la séance reste en écriture.
async function debloquerPlan(ouvert, args) { async function debloquerPlan(ouvert, args) {
exigerSupport(); exigerSupport();
const resultat = appliquerCommande('debloquer', ouvert.charge, args); let resultat = appliquerCommande('debloquer', ouvert.charge, args);
const avertissements = etat.mode === 'ecriture' ? [] : await prendreVerrou(ouvert); let depart = ouvert;
const avertissements = [];
if (etat.mode !== 'ecriture') {
avertissements.push(...(await prendreVerrou(ouvert)));
avertissements.push(...(await relireSousVerrou(ouvert)));
depart = etat.ouvert;
try {
if (depart.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
resultat = appliquerCommande('debloquer', depart.charge, args);
} catch (erreur) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
throw erreur;
}
}
etat.mode = 'ecriture'; etat.mode = 'ecriture';
return ecrireGeste(ouvert, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] }); return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
} }
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la // Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
// commande ajoute. // commande ajoute. Un rendu au libellé null n'écrit rien : la séance rend
// son résumé, le libellé null.
async function geste(nom, args) { async function geste(nom, args) {
const ouvert = exigerOuvert(); const ouvert = exigerOuvert();
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) { if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
@ -360,6 +482,10 @@ export function creerSeance({
} }
exigerEcriture(); exigerEcriture();
const resultat = appliquerCommande(nom, ouvert.charge, args); const resultat = appliquerCommande(nom, ouvert.charge, args);
if (resultat.libelle === null) {
etat.avertissementsGeste = [...resultat.avertissements];
return { libelle: null, resume: resultat.resume };
}
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null; const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant }); const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume }; return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
@ -461,12 +587,12 @@ export function creerSeance({
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert); const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
Object.assign(etat, { Object.assign(etat, {
ouvert: null, ouvert: null,
source: null,
mode: 'lecture', mode: 'lecture',
verrouTenu: false, verrouTenu: false,
supportRompu: false, supportRompu: false,
refusSupport: null, refusSupport: null,
verrouAutre: null, verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [], avertissementsGeste: [],
gestes: 0, gestes: 0,
calculs: 0, calculs: 0,
@ -475,12 +601,13 @@ export function creerSeance({
} }
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir // Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
// fermé le précédent. // fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
async function ouvrirAvec(lire) { async function ouvrirAvec(lire, source, quitter) {
exigerSauve(quitter);
await fermerEvenement(); await fermerEvenement();
const ouvert = await lire(); const ouvert = await lire();
etat.ouvert = ouvert; etat.ouvert = ouvert;
etat.avertissementsOuverture = ouvert.avertissements; etat.source = source;
return { base: ouvert.base, avertissements: ouvert.avertissements }; return { base: ouvert.base, avertissements: ouvert.avertissements };
} }
@ -489,8 +616,10 @@ export function creerSeance({
// l'événement ouvert reste, son mode et son verrou compris ; puis le // l'événement ouvert reste, son mode et son verrou compris ; puis le
// précédent se ferme, et s'écrivent l'entrée de création et le jalon // précédent se ferme, et s'écrivent l'entrée de création et le jalon
// « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état // « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état
// que le support refuse d'écrire, le journal écrit, rompt le support. // que le support refuse d'écrire, le journal écrit, rompt le support. Le
async function creerEvenement(champs) { // support de l'événement ouvert rompu, refusé sauf abandon (exigerSauve).
async function creerEvenement(champs, quitter) {
exigerSauve(quitter);
const { charge, libelle: texte } = chargeNeuve(champs, identifiant); const { charge, libelle: texte } = chargeNeuve(champs, identifiant);
await fermerEvenement(); await fermerEvenement();
const options = { const options = {
@ -505,12 +634,14 @@ export function creerSeance({
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert); if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
throw erreur; throw erreur;
} }
etat.avertissementsOuverture = etat.ouvert.avertissements;
return { base: etat.ouvert.base }; return { base: etat.ouvert.base };
} }
// --- Les modes (§ 8.4, § 8.8) ------------------------------------------------------- // --- Les modes (§ 8.4, § 8.8) -------------------------------------------------------
// Le verrou pris, l'événement se relit (relireSousVerrou) ; relu d'un
// format plus récent, ou bloqué par une autre séance, il rend le verrou,
// et la séance reste en lecture sur ce qu'elle a relu, le refus levé.
async function passerEnEcriture() { async function passerEnEcriture() {
const ouvert = exigerOuvert(); const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' }); if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
@ -518,6 +649,15 @@ export function creerSeance({
if (etat.mode === 'ecriture') return { avertissements: [] }; if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport(); exigerSupport();
const avertissements = await prendreVerrou(ouvert); const avertissements = await prendreVerrou(ouvert);
avertissements.push(...(await relireSousVerrou(ouvert)));
const relu = etat.ouvert;
let refus = null;
if (relu.formatPlusRecent) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
else if (estBloque(relu)) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (refus !== null) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
throw refus;
}
etat.mode = 'ecriture'; etat.mode = 'ecriture';
etat.avertissementsGeste = avertissements; etat.avertissementsGeste = avertissements;
armer(); armer();
@ -533,14 +673,20 @@ export function creerSeance({
} }
// Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en // Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en
// NFC, ses blancs de bord retirés. // NFC, ses blancs de bord retirés. Un jalon qui pose d'abord l'entrée de
// reprise avance la révision de l'Ouvert sans écrire l'état : le dernier
// instant du journal porte désormais la charge de la séance, et la source
// de la relecture devient 'journal'.
async function nommer(nom) { async function nommer(nom) {
try { try {
const ouvert = exigerEcriture(); const ouvert = exigerEcriture();
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`); if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
const texte = nom.normalize('NFC').trim(); const texte = nom.normalize('NFC').trim();
if (texte === '') throw new ErreurSeance('NOM_REQUIS'); if (texte === '') throw new ErreurSeance('NOM_REQUIS');
await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise })); const nomme = await ecrire(ouvert, (o) =>
nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise }),
);
if (nomme.revision !== ouvert.revision) etat.source = 'journal';
} finally { } finally {
await apresGeste(); await apresGeste();
} }
@ -573,7 +719,10 @@ export function creerSeance({
// transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en // transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en
// lecture, le support rompu par ce refus, le verrou rendu là où il était ; // lecture, le support rompu par ce refus, le verrou rendu là où il était ;
// un nouvel essai y trouve ce journal, et se refuse. Tout autre échec // un nouvel essai y trouve ce journal, et se refuse. Tout autre échec
// laisse tout en l'état, le support toujours rompu. // laisse tout en l'état, le support toujours rompu. Réussi, il demande le
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
// annonce la garantie affaiblie d'un support amovible, comme la règle du
// dossier de travail au démarrage (§ 8.8).
async function ecrireAilleurs(nouvelle) { async function ecrireAilleurs(nouvelle) {
const ouvert = exigerOuvert(); const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' }); if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
@ -595,7 +744,7 @@ export function creerSeance({
throw erreur; throw erreur;
} }
const avertissements = await rendreVerrou(ouvert); const avertissements = await rendreVerrou(ouvert);
Object.assign(etat, { ouvert: ailleurs, racine: nouvelle, supportRompu: false, refusSupport: null }); Object.assign(etat, { ouvert: ailleurs, source: null, racine: nouvelle, supportRompu: false, refusSupport: null });
if (etat.mode === 'ecriture') { if (etat.mode === 'ecriture') {
try { try {
avertissements.push(...(await prendreVerrou(ailleurs))); avertissements.push(...(await prendreVerrou(ailleurs)));
@ -605,11 +754,26 @@ export function creerSeance({
avertissements.push(avertissement(erreur.code, erreur.details)); avertissements.push(avertissement(erreur.code, erreur.details));
} }
} }
if ((await typeDeSupport(nouvelle)) === 'amovible') {
avertissements.push(avertissement('SUPPORT_AMOVIBLE', { dossier: nouvelle.chemin }));
}
etat.avertissementsGeste = avertissements; etat.avertissementsGeste = avertissements;
armer(); armer();
return { base: ailleurs.base, avertissements }; return { base: ailleurs.base, avertissements };
} }
// Le type du support de la racine (§ 8.8) ; 'inconnu' pour toute faute du
// système qui le demande — un refus du stockage, une réponse du pont
// illisible : l'événement y est déjà écrit et la séance transportée, et le
// type ne sert qu'à un avertissement.
async function typeDeSupport(dossier) {
try {
return await fs.typeSupport(dossier);
} catch {
return 'inconnu';
}
}
return { return {
/** L'identifiant de la séance, qui signe son verrou. */ /** L'identifiant de la séance, qui signe son verrou. */
get id() { get id() {
@ -642,9 +806,9 @@ export function creerSeance({
get verrouAutre() { get verrouAutre() {
return etat.verrouAutre; return etat.verrouAutre;
}, },
/** Ceux de l'ouverture, puis ceux du dernier geste. */ /** Ceux de l'Ouvert courant, recalculés à chaque écriture, puis ceux du dernier geste. */
get avertissements() { get avertissements() {
return [...etat.avertissementsOuverture, ...etat.avertissementsGeste]; return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
}, },
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */ /** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
get retours() { get retours() {
@ -659,11 +823,17 @@ export function creerSeance({
return etat.calculs > 0; return etat.calculs > 0;
}, },
ouvrir: (base) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base))), // quitter.abandonner : vrai pour quitter l'événement d'un support rompu
ouvrirSecours: (base, source) => // (exigerSauve).
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source))), ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
creer: (champs) => enFile(() => creerEvenement(champs)), ouvrirSecours: (base, source, quitter) =>
fermer: () => enFile(async () => ({ avertissements: await fermerEvenement() })), enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source), source, quitter)),
creer: (champs, quitter) => enFile(() => creerEvenement(champs, quitter)),
fermer: (quitter) =>
enFile(async () => {
exigerSauve(quitter);
return { avertissements: await fermerEvenement() };
}),
passerEnEcriture: () => enFile(passerEnEcriture), passerEnEcriture: () => enFile(passerEnEcriture),
passerEnLecture: () => enFile(passerEnLecture), passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))), executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
@ -673,6 +843,11 @@ export function creerSeance({
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))), revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
nommer: (nom) => enFile(() => nommer(nom)), nommer: (nom) => enFile(() => nommer(nom)),
ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)), ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)),
/**
* La forme nommée d'une proposition ou du retenu (formeNommeeDe) : une
* lecture, admise dans tout mode, qui n'écrit rien.
*/
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ; // Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
// ils s'emboîtent, et un fini de trop ne compte pas. // ils s'emboîtent, et un fini de trop ne compte pas.

View file

@ -572,7 +572,9 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants }); const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' }); const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]); assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]);
assert.deepEqual(seance.avertissements, enRetenu.avertissements); // La séance montre aussi la dérive que l'Ouvert écrit porte désormais.
assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...enRetenu.avertissements]);
assert.deepEqual(seance.ouvert.avertissements.map(({ code }) => code), ['DERIVE']);
}); });
test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => { test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => {
@ -609,6 +611,499 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
}); });
}); });
describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § 9, § 10.1)", () => {
// L'événement d'essai, ouvert depuis le disque, en écriture.
async function essaiEnEcriture() {
const fs = creerFichiersSimules();
await deposerEssai(fs);
const montage = monter({ fs });
await ouvrirSansEcrire(montage);
await montage.seance.passerEnEcriture();
return montage;
}
const mettreAJour = (seance, csv) =>
seance.executer('importerParticipants', {
apercu: apercevoir(csv, { participants: seance.charge.participants, mode: 'mettreAJour' }),
mode: 'mettreAJour',
});
test("un import qui exclut l'animatrice réservée dit ses réservations suspendues et son titre non pourvu, comme exclure (§ 4.4)", async () => {
const { seance } = await essaiEnEcriture();
const rendu = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n');
const suspendues = { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 };
assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 1 modifié');
assert.deepEqual(rendu.avertissements, [{ code: 'RESERVATIONS_SUSPENDUES', details: suspendues }]);
assert.equal(seance.charge.participants[0].exclu, true);
const retour = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;non\n');
assert.deepEqual(retour.avertissements, [{ code: 'RESERVATIONS_RETABLIES', details: suspendues }]);
});
test("un import dont les lignes valides ne changent rien, mais qui refuse des lignes, n'écrit rien et rend son résumé", async () => {
const { fs, seance } = await essaiEnEcriture();
await seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine' } });
const avant = fs.compteurs.ecritures;
const entrees = journalDe(fs).entrees.length;
const rendu = await mettreAJour(seance, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n');
assert.equal(rendu.libelle, null);
assert.deepEqual(rendu.avertissements, []);
assert.deepEqual(rendu.resume.refusees.map(({ ligne, code }) => [ligne, code]), [[2, 'PLUSIEURS_CORRESPONDENT']]);
assert.equal(fs.compteurs.ecritures, avant);
assert.equal(journalDe(fs).entrees.length, entrees);
assert.deepEqual(seance.avertissements, []);
});
test("après un geste, la séance montre les avertissements de l'Ouvert écrit : la dérive paraît et s'en va (§ 9)", async () => {
const { seance } = await essaiEnEcriture();
await seance.executer('enregistrerGeneration', generer(seance.charge));
await seance.executer('retenirProposition', { id: 1 });
assert.deepEqual(seance.avertissements, []);
const exclusion = await seance.executer('exclureParticipant', { id: 2 });
assert.deepEqual(exclusion.avertissements, []);
assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE']);
assert.deepEqual(seance.avertissements, seance.ouvert.avertissements);
assert.equal(seance.avertissements[0].details.retenu, true);
await seance.executer('reintegrerParticipant', { id: 2 });
assert.deepEqual(seance.avertissements, []);
});
test("le journal incomplet de l'ouverture ne s'affiche plus une fois la reprise écrite", async () => {
const fs = creerFichiersSimules();
await deposerEssai(fs);
const plus = chargeEssai();
plus.participants.push(personne(5, 'Ancolie'));
plus.prochainsIds.participant = 6;
fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 }));
const montage = monter({ fs });
const { seance } = montage;
await ouvrirSansEcrire(montage);
const incomplet = [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }];
assert.deepEqual(seance.avertissements, incomplet);
await seance.passerEnEcriture();
assert.deepEqual(seance.avertissements, incomplet);
await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } });
assert.deepEqual(seance.avertissements, []);
});
});
describe('séance : la forme nommée se lit sans rien écrire (§ 8.9, point 4)', () => {
test("la forme nommée d'une proposition ou du retenu se lit en lecture, sans entrée ni écriture", async () => {
const fs = creerFichiersSimules();
await deposerEssai(fs);
const montage = monter({ fs });
const { seance } = montage;
await ouvrirSansEcrire(montage);
await seance.passerEnEcriture();
await seance.executer('enregistrerGeneration', generer(seance.charge));
await seance.executer('retenirProposition', { id: 2 });
await seance.passerEnLecture();
const avant = fs.compteurs.ecritures;
const entrees = journalDe(fs).entrees.length;
const proposition = await seance.formeNommee({ proposition: 1 });
assert.deepEqual(proposition.fautes, []);
assert.deepEqual(
proposition.lignes.filter(({ tour }) => tour === 1).map(({ nom }) => nom).sort(),
['Grisaille', 'Lacasse', 'Ombrelle', 'Pervenche'],
);
const retenu = await seance.formeNommee({ retenu: true });
assert.equal(retenu.lignes.length, 8);
await refus(seance.formeNommee({ proposition: 7 }), 'PROPOSITION_INCONNUE');
assert.equal(fs.compteurs.ecritures, avant);
assert.equal(journalDe(fs).entrees.length, entrees);
assert.equal(seance.mode, 'lecture');
});
test("un retenu fautif sur le disque se lit sous forme nommée par la séance : ses lignes et ses fautes (RETENU_FAUTIF, § 8.9)", async () => {
const fs = creerFichiersSimules();
await deposerEssai(fs);
const montage = monter({ fs });
const { seance } = montage;
await ouvrirSansEcrire(montage);
await seance.passerEnEcriture();
await seance.executer('enregistrerGeneration', generer(seance.charge));
await seance.executer('retenirProposition', { id: 1 });
await seance.passerEnLecture();
const { entete, charge } = etatDe(fs);
const premier = charge.retenu.placement[0];
const assis = premier.sieges.find((liste) => liste.length > 0)[0];
premier.reserve = [...premier.reserve, assis].sort((a, b) => a - b);
fs.deposer('documents', ETAT, serialiser(charge, { revision: entete.revision, produitVersion: V1 }));
await ouvrirSansEcrire(montage);
assert.equal(seance.avertissements[0].code, 'RETENU_FAUTIF');
const lue = await seance.formeNommee({ retenu: true });
assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]);
assert.equal(lue.lignes.length, 9);
assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2);
});
test('sans événement ouvert, la forme nommée est refusée AUCUN_EVENEMENT', async () => {
await refus(monter().seance.formeNommee({ retenu: true }), 'AUCUN_EVENEMENT');
});
});
describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () => {
// Deux processus d'un même poste sur un même disque : la première séance
// crée l'événement d'essai, puis la seconde l'ouvre en lecture.
async function deuxSeances() {
const disque = new Map();
const premiere = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
await premiere.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
const seconde = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' });
await ouvrirSansEcrire(seconde);
return { premiere: premiere.seance, seconde: seconde.seance, fs: seconde.fs, fsPremiere: premiere.fs, disque };
}
test("passer en écriture relit l'événement : le travail de l'autre séance est adopté, EVENEMENT_CHANGE le dit, et le geste suivant s'écrit à sa suite", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
await premiere.passerEnLecture();
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }];
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
assert.deepEqual([seconde.mode, verrouDe(fs).seance], ['ecriture', seconde.id]);
assert.deepEqual(noms(seconde.charge), ['Xavier']);
assert.deepEqual(seconde.avertissements, change);
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
const journal = journalDe(fs);
assert.deepEqual(libellesDe(journal), [CREATION, 'Ajouté Xavier', 'Ajouté Yvette']);
assert.equal(journal.ecartees, 0);
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 3));
});
test("sans geste de l'autre séance, rien n'avertit ; son jalon est adopté avec le journal relu", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.nommer('Avant les invitations');
await premiere.passerEnLecture();
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: [] });
assert.deepEqual(seconde.ouvert.journal.jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
assert.deepEqual(journalDe(fs).jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
});
test("un événement qui a quitté le disque entre-temps, renommé ailleurs : le passage en écriture est refusé, le verrou rendu, rien ne se recrée", async () => {
const { seconde, fs, disque } = await deuxSeances();
const autre = creerFichiersSimules({ disque });
for (const fichier of [ETAT, JOURNAL]) await autre.supprimer(DOC, fichier);
await refus(seconde.passerEnEcriture(), 'ABSENT');
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
assert.deepEqual((await fs.lister(DOC, '')).map(({ nom }) => nom), []);
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }), 'MODE_LECTURE');
});
test("un plan que l'autre séance a bloqué entre-temps : adopté, EVENEMENT_CHANGE le dit, et le passage est refusé BLOQUE, le verrou rendu", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('changerEtat', { etat: 'bloque' });
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'BLOQUE' });
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'bloque']);
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]);
});
test("débloquer relit l'événement sous le verrou : le déblocage s'écrit à la suite du travail de l'autre séance", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('changerEtat', { etat: 'bloque' });
await seconde.ouvrir(BASE);
await premiere.debloquer();
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
await premiere.executer('changerEtat', { etat: 'bloque' });
assert.equal(premiere.mode, 'lecture');
const rendu = await seconde.debloquer();
assert.deepEqual(rendu, {
libelle: 'Plan débloqué, état « Brouillon »',
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
});
assert.equal(seconde.mode, 'ecriture');
const journal = journalDe(fs);
assert.deepEqual(journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4, 5, 6]);
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier']);
assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon');
});
test("débloquer un plan que l'autre séance a débloqué entre-temps : refusé PLAN_NON_BLOQUE, adopté, le verrou rendu", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('changerEtat', { etat: 'bloque' });
await seconde.ouvrir(BASE);
await premiere.debloquer();
await premiere.passerEnLecture();
const entrees = journalDe(fs).entrees.length;
await refus(seconde.debloquer(), 'PLAN_NON_BLOQUE');
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'brouillon']);
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }]);
assert.equal(journalDe(fs).entrees.length, entrees);
});
test("un secours ouvert se relit par la même source : passer en écriture ne bute pas sur l'état illisible", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
await seance.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, '');
await seance.ouvrirSecours(BASE, 'journal');
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
await seance.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
await seance.passerEnLecture();
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
});
// L'événement de la première séance porte Aubépine puis Bourrache, son
// état devient illisible, et chacune des deux ouvre son .precedent : la
// révision 2, Aubépine seule.
async function surPrecedent() {
const deux = await deuxSeances();
const { premiere, seconde, fs } = deux;
await premiere.passerEnEcriture();
for (const nom of ['Aubépine', 'Bourrache']) await premiere.executer('ajouterParticipant', { champs: { nom } });
await premiere.passerEnLecture();
fs.deposer('documents', ETAT, '');
for (const seance of [premiere, seconde]) await seance.ouvrirSecours(BASE, 'precedent');
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [2, ['Aubépine']]);
return deux;
}
for (const gestes of [['Cerfeuil'], ['Cerfeuil', 'Dictame']]) {
test(`un secours .precedent se relit par l'état qu'une autre séance a écrit depuis (${gestes.length} geste·s) : adopté, EVENEMENT_CHANGE le dit, rien ne s'efface`, async () => {
const { premiere, seconde, fs } = await surPrecedent();
await premiere.passerEnEcriture();
for (const nom of gestes) await premiere.executer('ajouterParticipant', { champs: { nom } });
await premiere.passerEnLecture();
const { revision } = etatDe(fs).entete;
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision } }];
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [revision, ['Aubépine', ...gestes]]);
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', ...gestes, 'Estragon']);
assert.equal(journalDe(fs).ecartees, 0);
});
}
test("un secours .precedent dont l'état reste illisible, mais dont le journal a reçu le geste d'une autre séance : le dernier instant du journal est adopté, EVENEMENT_CHANGE le dit", async () => {
const { premiere, seconde, fs, fsPremiere } = await surPrecedent();
await premiere.passerEnEcriture();
await premiere.nommer('Point');
fsPremiere.pannes.renommage('documents');
await refus(premiere.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ETAT_NON_ECRIT');
await premiere.passerEnLecture();
assert.equal(fs.contenu('documents', ETAT), '');
assert.deepEqual(await seconde.passerEnEcriture(), {
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
});
assert.deepEqual(noms(seconde.charge), ['Aubépine', 'Cerfeuil']);
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil', 'Estragon']);
});
test("un secours .precedent dont l'état redevient lisible entre-temps se relit par l'état, sans entrée nouvelle au journal : EVENEMENT_CHANGE le dit", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
const lisible = fs.contenu('documents', ETAT);
fs.deposer('documents', ETAT, '');
await seance.ouvrirSecours(BASE, 'precedent');
fs.deposer('documents', ETAT, lisible);
assert.deepEqual(await seance.passerEnEcriture(), {
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }],
});
assert.deepEqual(noms(seance.charge), ['Aubépine', 'Bourrache']);
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Bourrache', 'Cerfeuil']);
});
test("un secours .precedent ouvert devant un état lisible se relit par le .precedent tant que l'état ne change pas : le choix de l'opérateur reste (§ 8.9, point 3)", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
await seance.ouvrirSecours(BASE, 'precedent');
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [2, ['Aubépine']]);
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil']);
});
test("après un geste sur un secours .precedent, la séance se relit par l'état : redevenu illisible, il refuse le passage en écriture, sans revenir au .precedent", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, '');
await seance.ouvrirSecours(BASE, 'precedent');
await seance.passerEnEcriture();
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, '');
await refus(seance.passerEnEcriture(), 'ETAT_ILLISIBLE');
assert.deepEqual([seance.mode, verrouDe(fs), noms(seance.charge)], ['lecture', null, ['Aubépine', 'Cerfeuil']]);
});
test("après avoir écrit ailleurs un secours .precedent, la séance s'y relit par l'état, que le dossier nouveau porte seul", async () => {
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
fs.deposerDossier('portable');
const montage = monter({ fs });
const { seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, '');
await seance.ouvrirSecours(BASE, 'precedent');
await seance.passerEnEcriture();
fs.pannes.ecriture('documents');
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ECRITURE');
await seance.ecrireAilleurs(PORTABLE);
await seance.passerEnLecture();
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
assert.deepEqual([seance.mode, noms(seance.charge)], ['ecriture', ['Aubépine']]);
});
test("le jalon d'une séance seule sur un secours .precedent pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE ni recul de révision", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, '');
await seance.ouvrirSecours(BASE, 'precedent');
await seance.passerEnEcriture();
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
await seance.passerEnLecture();
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, ['Aubépine']]);
const codes = seance.avertissements.map(({ code }) => code);
assert.ok(!codes.includes('JOURNAL_EN_AVANCE_DE'), codes.join());
});
test("le jalon d'une séance seule sur un état que le journal devance de deux entrées pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE", async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
const premier = fs.contenu('documents', ETAT);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
fs.deposer('documents', ETAT, premier);
await ouvrirSansEcrire(montage);
await seance.passerEnEcriture();
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
await seance.passerEnLecture();
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, []]);
});
// L'événement réécrit par une construction plus récente : l'état, ou la
// première ligne du journal, déclarent le format 2.
const plusRecent = {
etat: (fs) => fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).replace('"format":1', '"format":2')),
journal: (fs) => fs.deposer('documents', JOURNAL, fs.contenu('documents', JOURNAL).replace('"format":1', '"format":2')),
};
for (const [fichier, designe] of [['etat', "l'état"], ['journal', 'le journal']]) {
test(`${designe} qu'une construction plus récente a réécrit entre-temps : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
const { seconde, fs } = await deuxSeances();
const entrees = journalDe(fs).entrees.length;
plusRecent[fichier](fs);
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', { raison: 'FORMAT' });
if (fichier === 'etat') assert.equal(journalDe(fs).entrees.length, entrees);
});
test(`un secours .precedent dont ${designe} est réécrit entre-temps par une construction plus récente : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
const { seconde, fs } = await surPrecedent();
if (fichier === 'etat') {
const precedent = fs.contenu('documents', `${BASE}${SUFFIXES.precedent}`);
fs.deposer('documents', ETAT, precedent.replace('"format":1', '"format":2'));
} else {
plusRecent.journal(fs);
}
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
});
test(`un plan bloqué dont ${designe} est réécrit entre-temps par une construction plus récente : débloquer est refusé LECTURE_SEULE FORMAT, le verrou rendu, sans entrée`, async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('changerEtat', { etat: 'bloque' });
await seconde.ouvrir(BASE);
const texte = fs.contenu('documents', JOURNAL);
plusRecent[fichier](fs);
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'FORMAT' });
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
if (fichier === 'etat') assert.equal(fs.contenu('documents', JOURNAL), texte);
});
}
// L'état réécrit en octets hors UTF-8 : son premier « é » remplacé par
// l'octet E9 d'un éditeur réglé en windows-1252. Rend ces octets.
function etatMalEncode(fs) {
const octets = [...new TextEncoder().encode(fs.contenu('documents', ETAT))];
const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9);
octets.splice(rang, 2, 0xe9);
fs.deposerOctets('documents', ETAT, new Uint8Array(octets));
return new Uint8Array(octets);
}
// Les noms des fichiers de documents et le texte du journal : ce qu'un
// refus laisse tel quel.
const empreinte = async (fs) => [(await fs.lister(DOC, '')).map(({ nom }) => nom), fs.contenu('documents', JOURNAL)];
test("l'état réécrit entre-temps en octets hors UTF-8 : passer en écriture est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, comme un format plus récent, le verrou rendu et rien de l'événement écrit ; le refus a son texte", async () => {
const { seconde, fs } = await deuxSeances();
const octets = etatMalEncode(fs);
const avant = await empreinte(fs);
const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
assert.deepEqual(await empreinte(fs), avant);
assert.deepEqual(fs.octets('documents', ETAT), octets);
assert.match(libelle(erreur.code, erreur.details), /UTF-8/);
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'MODE_LECTURE');
});
test("un plan bloqué dont l'état est réécrit entre-temps en octets hors UTF-8 : débloquer est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, le verrou rendu, sans entrée", async () => {
const { premiere, seconde, fs } = await deuxSeances();
await premiere.passerEnEcriture();
await premiere.executer('changerEtat', { etat: 'bloque' });
await premiere.passerEnLecture();
await seconde.ouvrir(BASE);
const octets = etatMalEncode(fs);
const avant = await empreinte(fs);
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
assert.deepEqual(await empreinte(fs), avant);
assert.deepEqual(fs.octets('documents', ETAT), octets);
});
for (const source of ['precedent', 'journal']) {
test(`ouvert par son secours ${source}, un état aux octets hors UTF-8 ne fige rien : passer en écriture réussit, et le premier geste met l'état dans un dossier daté de la corbeille, ses octets intacts, avant d'écrire`, async () => {
const montage = monter();
const { fs, seance } = montage;
await enEcriture(montage);
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
await seance.passerEnLecture();
const octets = etatMalEncode(fs);
const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE');
assert.equal(illisible.details.raison, 'ENCODAGE');
assert.match(libelle(illisible.code, illisible.details), /UTF-8/);
await seance.ouvrirSecours(BASE, source);
assert.equal(seance.ouvert.etatMalEncode, true);
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
const [dossier] = (await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom);
assert.deepEqual(fs.octets('documents', `corbeille/${dossier}/${ETAT}`), octets);
assert.deepEqual(noms(etatDe(fs).charge), [...noms(seance.charge)]);
assert.ok(noms(seance.charge).includes('Cerfeuil'));
assert.equal(seance.ouvert.etatMalEncode, false);
for (const { code, details } of seance.avertissements) assert.equal(typeof libelle(code, details), 'string');
});
}
});
describe('séance : revenir, défaire, refaire (§ 8.3)', () => { describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
// Trois gestes après la création : Ancolie, Bruyère, Capucine, aux // Trois gestes après la création : Ancolie, Bruyère, Capucine, aux
// révisions 2, 3 et 4. // révisions 2, 3 et 4.
@ -916,8 +1411,8 @@ describe("séance : un fichier d'un format plus récent (§ 8.8)", () => {
describe('séance : la perte du support en cours de séance (§ 8.6)', () => { describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
// Un événement du disque portable, en écriture, après un premier geste. // Un événement du disque portable, en écriture, après un premier geste.
async function surPortable() { async function surPortable(reglages = {}) {
const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages });
const montage = monter({ fs, racine: PORTABLE }); const montage = monter({ fs, racine: PORTABLE });
await enEcriture(montage); await enEcriture(montage);
await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
@ -1178,6 +1673,95 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]); assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]);
}); });
test("écrire ailleurs sur un support amovible l'annonce, comme la règle du dossier de travail au démarrage (§ 8.8)", async () => {
const { fs, seance } = await surPortable({ support: { documents: 'amovible' } });
fs.pannes.ecriture('portable');
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
const amovible = { code: 'SUPPORT_AMOVIBLE', details: { dossier: DOCUMENTS } };
const { avertissements } = await seance.ecrireAilleurs(DOC);
assert.deepEqual(avertissements, [
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
amovible,
]);
assert.ok(seance.avertissements.some(({ code }) => code === 'SUPPORT_AMOVIBLE'));
assert.match(libelle(amovible.code, amovible.details), /Gestion table tournante Libre/);
});
const fautesDuPont = [
['stockage', () => new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' })],
['pont', () => new TypeError('réponse du pont illisible pour typeSupport')],
];
for (const [nature, faute] of fautesDuPont) {
test(`écrire ailleurs réussit quand le type du support ne se dit pas (faute ${nature}) : le support vaut inconnu, rien n'est annoncé`, async () => {
const { fs, seance, planificateur } = await surPortable({ support: { documents: 'amovible' } });
fs.pannes.ecriture('portable');
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
fs.typeSupport = async () => {
throw faute();
};
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
assert.equal(base, BASE);
assert.deepEqual(avertissements, [
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
]);
assert.deepEqual(seance.avertissements, avertissements);
assert.deepEqual([seance.supportRompu, seance.mode, seance.racine, planificateur.actives], [false, 'ecriture', DOC, 1]);
});
}
test("tant que le support est rompu, ouvrir, ouvrir un secours, créer et fermer sont refusés SUPPORT_ROMPU_NON_SAUVE, sans rien changer", async () => {
const { fs, seance } = await surPortable();
fs.pannes.ecriture('portable');
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' };
const avant = fs.compteurs.ecritures;
await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
assert.equal(fs.compteurs.ecritures, avant);
assert.deepEqual([seance.supportRompu, seance.refusSupport, noms(seance.charge)], [true, refusSupport, ['Ancolie']]);
assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', refusSupport), /« Écrire ailleurs »/);
await seance.ecrireAilleurs(DOC);
await seance.fermer();
assert.equal(seance.ouvert, null);
});
test("le support rompu se quitte sur abandon explicite : ouvrir, ou fermer, avec {abandonner: true}", async () => {
const { fs, seance } = await surPortable();
fs.pannes.ecriture('portable');
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
const rendu = await seance.ouvrir(BASE, { abandonner: true });
assert.equal(rendu.base, BASE);
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie']]);
await refus(seance.passerEnEcriture(), 'ECRITURE');
assert.equal(seance.supportRompu, true);
await seance.fermer({ abandonner: true });
assert.deepEqual([seance.ouvert, seance.supportRompu], [null, false]);
});
test("le support rompu se quitte sur abandon explicite : créer, ou ouvrir un secours, avec {abandonner: true}", async () => {
const { fs, seance } = await surPortable();
// Un état que le support refuse rompt le support ; les renommages
// rétablis ensuite, ce qui suit s'écrit.
const rompre = async (nom) => {
await seance.passerEnEcriture();
fs.pannes.renommage('portable');
await refus(seance.executer('ajouterParticipant', { champs: { nom } }), 'ETAT_NON_ECRIT');
fs.pannes.renommage('portable', 0);
assert.equal(seance.supportRompu, true);
};
await rompre('Bruyère');
const grillons = 'Soirée des Grillons';
const cree = await seance.creer({ nom: grillons, date: null, siegesParDefaut: 2, tours: 2 }, { abandonner: true });
assert.equal(cree.base, grillons);
assert.deepEqual([seance.supportRompu, seance.mode, seance.charge.evenement.nom], [false, 'lecture', grillons]);
await rompre('Cerfeuil');
const secours = await seance.ouvrirSecours(BASE, 'journal', { abandonner: true });
assert.equal(secours.base, BASE);
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie', 'Bruyère']]);
});
test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => { test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => {
const { fs, seance } = await surPortable(); const { fs, seance } = await surPortable();
const avant = fs.compteurs.ecritures; const avant = fs.compteurs.ecritures;

View file

@ -27,10 +27,13 @@
// entier, par une écriture atomique, quand il reçoit une entrée de reprise, // entier, par une écriture atomique, quand il reçoit une entrée de reprise,
// seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté // seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté
// des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et // des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et
// quand il dépasse PLAFOND_ENTREES. Le correctif d'une entrée part de la // quand il dépasse PLAFOND_ENTREES. Un journal qui a disparu depuis la
// charge que restitue l'entrée qui la précède dans le journal, jamais de la // dernière écriture ne reçoit jamais d'ajout, qui commencerait un fichier
// charge ouverte : un correctif appliqué à une autre charge que la sienne // sans sa ligne d'ouverture : il se réécrit en entier, ouverture et entrées
// s'applique sans lever et rend autre chose. // en mémoire comprises, et l'Ouvert l'annonce, JOURNAL_RECREE. Le correctif
// d'une entrée part de la charge que restitue l'entrée qui la précède dans
// le journal, jamais de la charge ouverte : un correctif appliqué à une autre
// charge que la sienne s'applique sans lever et rend autre chose.
// //
// La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des // La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des
// fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se // fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se
@ -67,11 +70,28 @@
// ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre // ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre
// ouverture le remplace, comme tout .precedent, quand l'état sur le disque // ouverture le remplace, comme tout .precedent, quand l'état sur le disque
// se lit. // se lit.
//
// Un fichier dont les octets ne sont pas de l'UTF-8 — le système de fichiers
// lève LECTURE, cause UTF8_INVALIDE — ne se lit pas, et aucun octet ne s'y
// remplace en silence par U+FFFD. L'état ou le .precedent est illisible,
// raison ENCODAGE : l'état lève ETAT_ILLISIBLE avec ses secours. Le premier
// geste de l'Ouvert d'un secours met cet état dans un dossier daté de la
// corbeille, puis écrit comme pour un état absent : ses octets ne se perdent
// jamais, et le travail reprend sur place. Un journal se décode ligne par
// ligne : la première ligne hors UTF-8 arrête sa lecture comme une ligne
// illisible, elle et les suivantes écartées et comptées, et le premier geste
// le réécrit sans elles ; une coupure au milieu d'un caractère ne gâte que
// la dernière ligne. Le journal de la base dont la ligne d'ouverture n'est
// pas de l'UTF-8 n'est ni lu, ni apparié, ni remplacé : faute d'un autre
// journal que l'identifiant apparie, l'Ouvert l'annonce, JOURNAL_ILLISIBLE,
// et la première écriture pose l'historique sous une base libre, comme pour
// le journal d'un autre événement.
import { canoniser, serialiser } from './canonique.js'; import { canoniser, serialiser } from './canonique.js';
import { FORMAT, analyser } from './document.js'; import { FORMAT, analyser } from './document.js';
import { ErreurStockage } from './erreurs.js'; import { ErreurStockage } from './erreurs.js';
import { import {
PLAFOND_ENTREES, PLAFOND_ENTREES,
decoderJournal,
elaguer, elaguer,
estInstantane, estInstantane,
ligneEntree, ligneEntree,
@ -81,7 +101,7 @@ import {
reconstruire, reconstruire,
versionDe, versionDe,
} from './journal.js'; } from './journal.js';
import { SUFFIXES, deriverBase } from './noms.js'; import { DOSSIER_CORBEILLE, SUFFIXES, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
import { examiner } from './placements.js'; import { examiner } from './placements.js';
/** /**
@ -120,19 +140,30 @@ import { examiner } from './placements.js';
* @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur * @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur
* le disque, le .precedent qu'un secours ouvre ou le journal de la * le disque, le .precedent qu'un secours ouvre ou le journal de la
* base est d'un format plus récent que FORMAT * base est d'un format plus récent que FORMAT
* @property {boolean} etatMalEncode l'état sur le disque, à côté du secours
* ouvert, n'est pas de l'UTF-8 : le premier geste le met de côté
* dans la corbeille (mettreDeCoteEtatMalEncode) avant d'écrire
* @property {import('./placements.js').Examen} examen * @property {import('./placements.js').Examen} examen
* @property {Array<{code: string, details: Object}>} avertissements dans * @property {Array<{code: string, details: Object}>} avertissements dans
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {} ; * cet ordre, chacun au plus une fois : RETENU_FAUTIF {}, un retenu
* JOURNAL_REPRIS {}, le journal en avance d'une entrée rattrapé en * qui se contredit, ou que l'en-tête annonce et que la charge n'a
* mémoire ; JOURNAL_EN_AVANCE_DE {n} ou JOURNAL_INCOMPLET {etat, * pas ; JOURNAL_REPRIS {}, le journal en avance d'une entrée
* journal}, le journal qui finit n révisions après la charge, ou * rattrapé en mémoire ; JOURNAL_EN_AVANCE_DE {n} ou
* avant elle ; JOURNAL_ECARTEES {n} ; JOURNAL_FORMAT_PLUS_RECENT * JOURNAL_INCOMPLET {etat, journal}, le journal qui finit n
* {format}, le journal de la base, d'un format plus récent, que * révisions après la charge, ou avant elle ; JOURNAL_ECARTEES {n} ;
* rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement}, le journal * JOURNAL_RECREE {}, le fichier du journal, disparu depuis
* de la base, d'un autre événement, que rien ne lit ; * l'écriture précédente, que celle-ci a réécrit en entier ;
* JOURNAL_FORMAT_PLUS_RECENT {format}, le journal de la base, d'un
* format plus récent, que rien ne lit ni n'écrit ; JOURNAL_ILLISIBLE
* {raison: 'ENCODAGE'}, le journal de la base aux octets hors
* UTF-8, que rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement},
* le journal de la base, d'un autre événement, que rien ne lit ;
* PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant * PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant
* illisible ; DERIVE {ids, retenu}, les propositions périmées par * illisible ; PROPOSITIONS_MANQUANTES {n}, le nombre de
* identifiant, et vrai quand le retenu l'est (§ 9) * propositions que l'en-tête compte sans que la liste les porte ;
* DERIVE {ids, retenu}, les
* propositions périmées par identifiant, et vrai quand le retenu
* l'est (§ 9)
*/ */
/** /**
@ -185,17 +216,43 @@ const basesAuSuffixe = (entrees, suffixe) =>
.map(({ nom }) => baseDe(nom, suffixe)) .map(({ nom }) => baseDe(nom, suffixe))
.filter((base) => base !== null); .filter((base) => base !== null);
// Texte du fichier chemin, ou null quand il manque ; toute autre erreur // Ce que porte le fichier chemin : { texte, present, octets }. texte est
// remonte telle quelle. // null quand il manque, ou quand ses octets ne sont pas de l'UTF-8 (LECTURE,
// cause UTF8_INVALIDE), octets portant alors ceux que le refus rend, null
// sinon ; present ne vaut faux que pour un fichier qui manque. Toute autre
// erreur de lecture remonte telle quelle.
async function lireSiPresent(fs, racine, chemin) { async function lireSiPresent(fs, racine, chemin) {
try { try {
return await fs.lireTexte(racine, chemin); return { texte: await fs.lireTexte(racine, chemin), present: true, octets: null };
} catch (erreur) { } catch (erreur) {
if (erreur.code === 'ABSENT') return null; if (erreur.code === 'ABSENT') return { texte: null, present: false, octets: null };
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
return { texte: null, present: true, octets: erreur.details.octets };
}
throw erreur; throw erreur;
} }
} }
// Ce qu'un fichier de journal lu (lireSiPresent) donne à lire : { texte,
// ecartees }. Ses octets hors UTF-8 se décodent ligne par ligne
// (decoderJournal) : texte, les lignes qui précèdent la première qui ne se
// décode pas, null quand c'est l'ouverture, et ecartees, cette ligne et les
// suivantes. Sinon texte est celui du fichier, null quand il manque, et
// ecartees vaut 0.
const journalLisible = ({ texte, octets }) => (octets === null ? { texte, ecartees: 0 } : decoderJournal(octets));
// La lecture d'un journal lisible (journalLisible) : lireJournal de son
// texte, les lignes que le décodage a écartées comptées avec celles que la
// lecture écarte.
function lireLisible({ texte, ecartees }) {
const lu = lireJournal(texte);
return { ...lu, ecartees: lu.ecartees + ecartees };
}
// Les détails du refus d'un état ou d'un .precedent dont les octets ne sont
// pas de l'UTF-8, neufs à chaque appel : rien ne s'y désigne.
const malEncode = () => ({ raison: 'ENCODAGE', chemin: null });
// Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon // Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon
// { details } de son refus, raison et chemin. analyser ne lève que // { details } de son refus, raison et chemin. analyser ne lève que
// ETAT_ILLISIBLE. // ETAT_ILLISIBLE.
@ -212,12 +269,24 @@ function lireEtat(texte) {
// revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient // revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient
// d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce // d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce
// format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert // format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert
// d'un secours (§ 8.8). // d'un secours (§ 8.8). etatMalEncode quand ses octets ne sont pas de
// l'UTF-8 : le premier geste parti d'un secours le range d'abord dans la
// corbeille datée (mettreDeCoteEtatMalEncode), puis pose l'état.
function surDisque(texte, { lecture, details }) { function surDisque(texte, { lecture, details }) {
if (lecture !== null) { if (lecture !== null) {
return { texteEtat: texte, revisionEtat: lecture.entete.revision, formatPlusRecent: lecture.formatPlusRecent }; return {
texteEtat: texte,
revisionEtat: lecture.entete.revision,
formatPlusRecent: lecture.formatPlusRecent,
etatMalEncode: false,
};
} }
return { texteEtat: null, revisionEtat: null, formatPlusRecent: (details?.format ?? FORMAT) > FORMAT }; return {
texteEtat: null,
revisionEtat: null,
formatPlusRecent: (details?.format ?? FORMAT) > FORMAT,
etatMalEncode: details?.raison === 'ENCODAGE',
};
} }
// Le texte d'état, que l'analyse doit relire : un état que ce module écrit se // Le texte d'état, que l'analyse doit relire : un état que ce module écrit se
@ -277,34 +346,40 @@ async function deriver(fs, racine, nom, autres) {
} }
// Les fichiers de racine tels qu'une lecture les trouve : la liste du // Les fichiers de racine tels qu'une lecture les trouve : la liste du
// dossier, le texte de chaque fichier — null quand il manque, toute autre // dossier, ce que chaque journal donne à lire (journalLisible), la présence
// erreur de lecture remontant telle quelle —, et la lecture de chaque texte // de chaque fichier, et la lecture de chaque texte d'état ou de .precedent
// d'état ou de .precedent (lireEtat), chacun lu une fois, à la demande. Les // (lireEtat), chacun lu une fois, à la demande ; toute erreur de lecture
// autre qu'un absent ou des octets hors UTF-8 remonte telle quelle. Les
// décisions d'appariement d'une même opération se prennent ainsi sur les // décisions d'appariement d'une même opération se prennent ainsi sur les
// mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit // mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit
// après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche // après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche
// le journal d'un événement en prend une neuve (baseCherchee). // le journal d'un événement en prend une neuve (baseCherchee).
function vueDe(fs, racine) { function vueDe(fs, racine) {
let entrees = null; let entrees = null;
const textes = new Map(); const fichiers = new Map();
const lectures = new Map(); const lectures = new Map();
const texte = async (chemin) => { const fichier = async (chemin) => {
if (!textes.has(chemin)) textes.set(chemin, await lireSiPresent(fs, racine, chemin)); if (!fichiers.has(chemin)) fichiers.set(chemin, await lireSiPresent(fs, racine, chemin));
return textes.get(chemin); return fichiers.get(chemin);
}; };
return { return {
entrees: async () => { entrees: async () => {
entrees ??= await fs.lister(racine, ''); entrees ??= await fs.lister(racine, '');
return entrees; return entrees;
}, },
texte, // { texte, ecartees } du journal chemin (journalLisible).
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent, journal: async (chemin) => journalLisible(await fichier(chemin)),
// les trois null quand il manque. // Vrai quand le fichier existe, que ses octets soient de l'UTF-8 ou non.
present: async (chemin) => (await fichier(chemin)).present,
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent :
// les trois null quand il manque ; texte et lecture null, details
// {raison: 'ENCODAGE', chemin: null}, quand ses octets ne sont pas de
// l'UTF-8.
lecture: async (chemin) => { lecture: async (chemin) => {
if (!lectures.has(chemin)) { if (!lectures.has(chemin)) {
const lu = await texte(chemin); const { texte, present } = await fichier(chemin);
const absent = { texte: null, lecture: null, details: null }; const illisible = { texte: null, lecture: null, details: present ? malEncode() : null };
lectures.set(chemin, lu === null ? absent : { texte: lu, ...lireEtat(lu) }); lectures.set(chemin, texte === null ? illisible : { texte, ...lireEtat(texte) });
} }
return lectures.get(chemin); return lectures.get(chemin);
}, },
@ -322,9 +397,10 @@ async function proprietaire(vue, base) {
} }
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) : // Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
// { base, texte } du journal de base quand sa première ligne nomme id ; // { base, texte, ecartees } (journalLisible) du journal de base quand sa
// sinon du premier journal du dossier, par nom, qui le nomme et n'appartient // première ligne nomme id ; sinon du premier journal du dossier, par nom,
// pas à sa propre base (proprietaire) ; null quand aucun ne s'apparie. Un // qui le nomme et n'appartient pas à sa propre base (proprietaire) ; null
// quand aucun ne s'apparie. Un
// journal renommé à la main, ou créé sous une base dérivée quand celui de la // journal renommé à la main, ou créé sous une base dérivée quand celui de la
// base portait un autre événement, n'a ni état ni .precedent à sa base : il // base portait un autre événement, n'a ni état ni .precedent à sa base : il
// s'apparie. Le journal d'un original reste à l'original, et un état copié à // s'apparie. Le journal d'un original reste à l'original, et un état copié à
@ -333,12 +409,12 @@ async function proprietaire(vue, base) {
// journal sans base la suivent, après la préséance du journal de la base // journal sans base la suivent, après la préséance du journal de la base
// d'un format plus récent (formatDuJournalDeBase). // d'un format plus récent (formatDuJournalDeBase).
async function apparier(vue, base, id) { async function apparier(vue, base, id) {
const texte = await vue.texte(base + SUFFIXES.journal); const journal = await vue.journal(base + SUFFIXES.journal);
if (evenementDe(texte) === id) return { base, texte }; if (evenementDe(journal.texte) === id) return { base, ...journal };
for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) { for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
const texteAutre = await vue.texte(autre + SUFFIXES.journal); const journalAutre = await vue.journal(autre + SUFFIXES.journal);
if (evenementDe(texteAutre) === id && (await proprietaire(vue, autre)) !== id) { if (evenementDe(journalAutre.texte) === id && (await proprietaire(vue, autre)) !== id) {
return { base: autre, texte: texteAutre }; return { base: autre, ...journalAutre };
} }
} }
return null; return null;
@ -365,7 +441,7 @@ async function identifiantsLisibles(vue) {
async function identifiantSansEtat(vue, base) { async function identifiantSansEtat(vue, base) {
const { lecture } = await vue.lecture(base + SUFFIXES.precedent); const { lecture } = await vue.lecture(base + SUFFIXES.precedent);
if (lecture !== null) return lecture.charge.evenement.id; if (lecture !== null) return lecture.charge.evenement.id;
const id = evenementDe(await vue.texte(base + SUFFIXES.journal)); const id = evenementDe((await vue.journal(base + SUFFIXES.journal)).texte);
if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null; if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null;
return id; return id;
} }
@ -375,13 +451,17 @@ async function identifiantSansEtat(vue, base) {
// passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste // passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste
// (lister) : son identifiant ne se lit pas, et il reste celui de la base. // (lister) : son identifiant ne se lit pas, et il reste celui de la base.
async function formatDuJournalDeBase(vue, base) { async function formatDuJournalDeBase(vue, base) {
const texte = await vue.texte(base + SUFFIXES.journal); const { texte } = await vue.journal(base + SUFFIXES.journal);
return texte === null ? null : formatPlusRecentDe(texte); return texte === null ? null : formatPlusRecentDe(texte);
} }
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) : // Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
// lu, sa base, etranger, l'événement que porte le journal de la base quand // lu, sa base, etranger, l'événement que porte le journal de la base quand
// aucun ne s'apparie et qu'il en nomme un autre, et format. Le journal de la // aucun ne s'apparie et qu'il en nomme un autre, format, et malEncode, vrai
// quand aucun ne s'apparie et que la ligne d'ouverture du journal de la base
// n'est pas de l'UTF-8 — une ligne suivante qui ne l'est pas s'écarte
// seulement : lu et base valent alors null, comme pour un journal étranger,
// que rien ne lit ni ne remplace. Le journal de la
// base qui déclare un format plus récent que FORMAT se reconnaît d'abord, // base qui déclare un format plus récent que FORMAT se reconnaît d'abord,
// avant tout appariement, puisque son identifiant ne se lit pas : lu vaut // avant tout appariement, puisque son identifiant ne se lit pas : lu vaut
// null et format le nomme, ce qui garde l'Ouvert en lecture seule, même // null et format le nomme, ce qui garde l'Ouvert en lecture seule, même
@ -393,15 +473,18 @@ async function formatDuJournalDeBase(vue, base) {
// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le // valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le
// journal de l'événement (baseCherchee). // journal de l'événement (baseCherchee).
async function journalDe(vue, base, id) { async function journalDe(vue, base, id) {
const aucun = { lu: null, base: null, etranger: null, format: null, malEncode: false };
const format = await formatDuJournalDeBase(vue, base); const format = await formatDuJournalDeBase(vue, base);
if (format !== null) return { lu: null, base, etranger: null, format }; if (format !== null) return { ...aucun, base, format };
const texte = await vue.texte(base + SUFFIXES.journal); const chemin = base + SUFFIXES.journal;
if (id === null) return { lu: null, base: null, etranger: null, format: null }; const journal = await vue.journal(chemin);
const apparie = await apparier(vue, base, id); const apparie = id === null ? null : await apparier(vue, base, id);
if (apparie !== null) return { lu: lireJournal(apparie.texte), base: apparie.base, etranger: null, format: null }; if (apparie !== null) return { ...aucun, lu: lireLisible(apparie), base: apparie.base };
const etranger = evenementDe(texte); if (journal.texte === null && (await vue.present(chemin))) return { ...aucun, malEncode: true };
if (etranger !== null) return { lu: null, base: null, etranger, format: null }; if (id === null) return aucun;
return { lu: texte === null ? null : lireJournal(texte), base, etranger: null, format: null }; const etranger = evenementDe(journal.texte);
if (etranger !== null) return { ...aucun, etranger };
return { ...aucun, lu: journal.texte === null ? null : lireLisible(journal), base };
} }
// Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe // Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe
@ -439,30 +522,41 @@ function instantDe(lu) {
// Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert, // Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert,
// avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule // avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule
// de l'un ou de l'autre. // de l'un ou de l'autre, et ce que l'en-tête annonce sans que la charge le
const depuisLecture = ({ entete, charge, formatPlusRecent }, disque) => ({ // porte (manquants).
const depuisLecture = ({ entete, charge, formatPlusRecent, manquants }, disque) => ({
charge, charge,
revision: entete.revision, revision: entete.revision,
produitVersion: entete.produitVersion, produitVersion: entete.produitVersion,
manquants,
...disque, ...disque,
formatPlusRecent: formatPlusRecent || disque.formatPlusRecent, formatPlusRecent: formatPlusRecent || disque.formatPlusRecent,
}); });
// Une charge qui ne vient pas d'une lecture d'état ne manque de rien.
const AUCUN_MANQUANT = Object.freeze({ propositions: 0, retenu: false });
// Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert. // Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert.
// derniere est la révision de la dernière entrée du journal, celle de la // derniere est la révision de la dernière entrée du journal, celle de la
// charge quand il n'y en a pas. // charge quand il n'y en a pas. Les propositions que l'en-tête compte sans
function avertir({ examen, journal, revision, rattrape, ecartees, format, etranger }) { // que la liste les porte s'annoncent par leur seul nombre, que rien ne
// borne : aucune liste de cette taille ne se construit. Un retenu annoncé
// absent s'annonce comme un retenu fautif.
function avertir({ examen, journal, revision, rattrape, ecartees, recree, format, malEncode, etranger, manquants }) {
const avertissements = []; const avertissements = [];
const ajouter = (code, details = {}) => avertissements.push({ code, details }); const ajouter = (code, details = {}) => avertissements.push({ code, details });
if (examen.retenu.fautes.length > 0) ajouter('RETENU_FAUTIF'); if (examen.retenu.fautes.length > 0 || manquants.retenu) ajouter('RETENU_FAUTIF');
if (rattrape) ajouter('JOURNAL_REPRIS'); if (rattrape) ajouter('JOURNAL_REPRIS');
const derniere = journal === null ? revision : journal.entrees.at(-1).revision; const derniere = journal === null ? revision : journal.entrees.at(-1).revision;
if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision }); if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision });
if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere }); if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere });
if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees }); if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees });
if (recree) ajouter('JOURNAL_RECREE');
if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format }); if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format });
if (malEncode) ajouter('JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' });
if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger }); if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger });
if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) }); if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) });
if (manquants.propositions > 0) ajouter('PROPOSITIONS_MANQUANTES', { n: manquants.propositions });
const retenu = examen.retenu.raisons.length > 0; const retenu = examen.retenu.raisons.length > 0;
if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu }); if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu });
return avertissements; return avertissements;
@ -470,16 +564,18 @@ function avertir({ examen, journal, revision, rattrape, ecartees, format, etrang
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du // L'Ouvert d'une source — état, .precedent ou instant du journal — et du
// journal trouvé pour elle. rattraper : un journal en avance d'une seule // journal trouvé pour elle. rattraper : un journal en avance d'une seule
// entrée sur la source donne sa charge, sa révision et sa version. La source // entrée sur la source donne sa charge, sa révision et sa version, et ce que
// d'un format plus récent, ou un journal d'un format plus récent, gardent // l'en-tête de la source annonce sans que sa charge le porte ne compte plus.
// l'Ouvert en lecture seule. // La source d'un format plus récent, ou un journal d'un format plus récent,
// gardent l'Ouvert en lecture seule.
function assembler({ fs, racine, base, source, trouve, instant, rattraper }) { function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
let { charge, revision, produitVersion } = source; let { charge, revision, produitVersion, manquants } = source;
const rattrape = rattraper && instant !== null && instant.revision === revision + 1; const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
if (rattrape) { if (rattrape) {
charge = canoniser(instant.charge); charge = canoniser(instant.charge);
revision = instant.revision; revision = instant.revision;
produitVersion = versionDe(instant.journal, revision); produitVersion = versionDe(instant.journal, revision);
manquants = AUCUN_MANQUANT;
} }
const journal = instant?.journal ?? null; const journal = instant?.journal ?? null;
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0; const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
@ -498,6 +594,7 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
chargeJournal: instant?.charge ?? null, chargeJournal: instant?.charge ?? null,
journalAReecrire: journal !== null && ecartees > 0, journalAReecrire: journal !== null && ecartees > 0,
formatPlusRecent: source.formatPlusRecent || trouve.format !== null, formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
etatMalEncode: source.etatMalEncode,
examen, examen,
avertissements: avertir({ avertissements: avertir({
examen, examen,
@ -505,8 +602,11 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
revision, revision,
rattrape, rattrape,
ecartees, ecartees,
recree: false,
format: trouve.format, format: trouve.format,
malEncode: trouve.malEncode,
etranger: trouve.etranger, etranger: trouve.etranger,
manquants,
}), }),
}; };
} }
@ -517,11 +617,11 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
// secours lisibles : la révision du .precedent et le dernier instant du // secours lisibles : la révision du .precedent et le dernier instant du
// journal que l'identifiant apparie (journalSansEtat), jamais un journal // journal que l'identifiant apparie (journalSansEtat), jamais un journal
// pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le // pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le
// journal de la base n'existent. // journal de la base n'existent — un fichier aux octets hors UTF-8 existe.
async function refuser(vue, base, details) { async function refuser(vue, base, details) {
const precedent = await vue.lecture(base + SUFFIXES.precedent); const precedent = await vue.lecture(base + SUFFIXES.precedent);
const journal = await vue.texte(base + SUFFIXES.journal); const existe = async (suffixe) => vue.present(base + suffixe);
if (details.raison === 'ABSENT' && precedent.texte === null && journal === null) { if (details.raison === 'ABSENT' && !(await existe(SUFFIXES.precedent)) && !(await existe(SUFFIXES.journal))) {
return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat }); return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
} }
const trouve = await journalSansEtat(vue, base); const trouve = await journalSansEtat(vue, base);
@ -603,7 +703,7 @@ export async function lister(fs, racine) {
for (const { nom, modifie } of fichiers) { for (const { nom, modifie } of fichiers) {
const base = baseDe(nom, SUFFIXES.journal); const base = baseDe(nom, SUFFIXES.journal);
if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue; if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue;
const evenement = evenementDe(await vue.texte(nom)); const evenement = evenementDe((await vue.journal(nom)).texte);
if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT')); if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT'));
} }
const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? ''); const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? '');
@ -629,9 +729,10 @@ export async function lister(fs, racine) {
* @param {string} base * @param {string} base
* @returns {Promise<Ouvert>} * @returns {Promise<Ouvert>}
* @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format], * @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
* secours} : l'état ne se lit pas, ou manque à côté de son .precedent * secours} : l'état ne se lit pas — raison ENCODAGE pour des octets
* ou de son journal (raison ABSENT) ; format est celui de l'état * hors UTF-8 —, ou manque à côté de son .precedent ou de son
* quand son refus en nomme un, sinon celui du journal de la base * journal, fût-il aux octets hors UTF-8 (raison ABSENT) ; format est
* celui de l'état quand son refus en nomme un, sinon celui du journal de la base
* quand il est plus récent ; secours nomme la révision lisible du * quand il est plus récent ; secours nomme la révision lisible du
* .precedent et le dernier instant du journal que l'identifiant du * .precedent et le dernier instant du journal que l'identifiant du
* .precedent apparie — ou, sans .precedent lisible, celui du journal * .precedent apparie — ou, sans .precedent lisible, celui du journal
@ -642,8 +743,7 @@ export async function lister(fs, racine) {
export async function ouvrir(fs, racine, base) { export async function ouvrir(fs, racine, base) {
const vue = vueDe(fs, racine); const vue = vueDe(fs, racine);
const etat = await vue.lecture(base + SUFFIXES.etat); const etat = await vue.lecture(base + SUFFIXES.etat);
if (etat.texte === null) throw await refuser(vue, base, { raison: 'ABSENT', chemin: null }); if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
if (etat.lecture === null) throw await refuser(vue, base, etat.details);
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id); const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat)); const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true }); return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true });
@ -675,9 +775,12 @@ export async function ouvrir(fs, racine, base) {
* @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun * @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun
* journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors * journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors
* le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} : * le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} :
* celui de l'analyse d'un .precedent illisible, ou, source 'journal', * celui de l'analyse d'un .precedent illisible, raison ENCODAGE et
* chemin null pour des octets hors UTF-8, ou, source 'journal',
* {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base, * {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base,
* format} quand ce journal est d'un format plus récent * format} quand ce journal est d'un format plus récent, {raison:
* 'ENCODAGE', chemin: le journal de la base} quand sa ligne
* d'ouverture n'est pas de l'UTF-8 et qu'aucun autre ne s'apparie
* @throws {RangeError} une autre source * @throws {RangeError} une autre source
*/ */
export async function ouvrirSecours(fs, racine, base, source) { export async function ouvrirSecours(fs, racine, base, source) {
@ -688,7 +791,12 @@ export async function ouvrirSecours(fs, racine, base, source) {
const etat = await vue.lecture(base + SUFFIXES.etat); const etat = await vue.lecture(base + SUFFIXES.etat);
const disque = surDisque(etat.texte, etat); const disque = surDisque(etat.texte, etat);
if (source === 'precedent') { if (source === 'precedent') {
const lecture = analyser(await fs.lireTexte(racine, base + SUFFIXES.precedent)); const precedent = await vue.lecture(base + SUFFIXES.precedent);
if (precedent.details === null && precedent.lecture === null) {
throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.precedent });
}
if (precedent.lecture === null) throw new ErreurStockage('ETAT_ILLISIBLE', precedent.details);
const { lecture } = precedent;
const trouve = await journalDe(vue, base, lecture.charge.evenement.id); const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
const depuis = depuisLecture(lecture, disque); const depuis = depuisLecture(lecture, disque);
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false }); return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false });
@ -703,12 +811,16 @@ export async function ouvrirSecours(fs, racine, base, source) {
format: trouve.format, format: trouve.format,
}); });
} }
if (trouve.malEncode) {
throw new ErreurStockage('ETAT_ILLISIBLE', { ...malEncode(), chemin: base + SUFFIXES.journal });
}
const instant = instantDe(trouve.lu); const instant = instantDe(trouve.lu);
if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal }); if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal });
const depuis = { const depuis = {
charge: canoniser(instant.charge), charge: canoniser(instant.charge),
revision: instant.revision, revision: instant.revision,
produitVersion: versionDe(instant.journal, instant.revision), produitVersion: versionDe(instant.journal, instant.revision),
manquants: AUCUN_MANQUANT,
...disque, ...disque,
}; };
return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false }); return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false });
@ -720,6 +832,7 @@ function exigerEcriture({ formatPlusRecent }) {
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }); if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
} }
// version quand l'entrée qui suit revision la note, null sinon : à la première // version quand l'entrée qui suit revision la note, null sinon : à la première
// entrée, et quand la construction diffère de la précédente (§ 8.8). // entrée, et quand la construction diffère de la précédente (§ 8.8).
const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version); const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version);
@ -826,8 +939,8 @@ async function aligner(ouvert, preparation, texte) {
let depart = ouvert; let depart = ouvert;
if (preparation.reprise !== null) { if (preparation.reprise !== null) {
const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise); const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise);
depart = { ...ouvert, ...ecrit }; ({ journal } = ecrit);
journal = ecrit.journal; depart = { ...ouvert, journal, baseJournal: ecrit.baseJournal, journalAReecrire: false };
entier = false; entier = false;
} }
const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte); const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte);
@ -844,19 +957,27 @@ function textePlein(journal) {
return { texte: elague, journal: lireJournal(elague) }; return { texte: elague, journal: lireJournal(elague) };
} }
// L'ECRITURE d'une recherche dans le dossier de racine qui échoue avant une
// écriture du journal chemin — le dossier qui ne se liste plus, ou un
// fichier qui ne se lit plus : ABSENT quand le dossier a disparu avec son
// support, cause ENOENT ; LECTURE quand la lecture est refusée, sa cause —,
// chemin le journal, dossier le chemin de la racine, qui porte les fichiers
// de l'événement. L'application nomme ainsi le fichier et son dossier, et
// propose d'écrire ailleurs (§ 8.6). Toute autre erreur reste telle quelle.
function enEcriture(erreur, chemin, racine) {
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') return erreur;
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
return new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause });
}
// La base où s'écrit le journal d'un Ouvert sans base de journal, celui de // La base où s'écrit le journal d'un Ouvert sans base de journal, celui de
// sa base portant un autre événement : la base du journal que l'identifiant // sa base portant un autre événement : la base du journal que l'identifiant
// de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une // de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une
// base libre que son nom dérive. L'essai repris après un refus réécrit // base libre que son nom dérive. L'essai repris après un refus réécrit
// ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en // ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en
// dériver un second que l'ouverture n'apparierait pas. Une recherche qui // dériver un second que l'ouverture n'apparierait pas. Une recherche qui
// échoue — le dossier qui ne se liste plus, ou un journal qui ne se lit // échoue lève ECRITURE sur le journal de la base, comme l'écriture qu'elle
// plus : ABSENT quand le dossier a disparu avec son support, LECTURE quand // précède (enEcriture) ; toute autre erreur remonte telle quelle.
// la lecture est refusée — lève ECRITURE, comme l'écriture qu'elle précède :
// chemin le journal de la base, dossier le chemin de la racine, qui porte
// les fichiers de l'événement, et cause celle de l'échec, ENOENT pour un
// dossier disparu. L'application nomme ainsi le fichier et son dossier, et
// propose d'écrire ailleurs (§ 8.6). Toute autre erreur remonte telle quelle.
async function baseCherchee(ouvert, evenement) { async function baseCherchee(ouvert, evenement) {
const { fs, racine, base } = ouvert; const { fs, racine, base } = ouvert;
try { try {
@ -865,30 +986,88 @@ async function baseCherchee(ouvert, evenement) {
(await deriver(fs, racine, ouvert.charge.evenement.nom, [])) (await deriver(fs, racine, ouvert.charge.evenement.nom, []))
); );
} catch (erreur) { } catch (erreur) {
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') throw erreur; throw enEcriture(erreur, base + SUFFIXES.journal, racine);
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
throw new ErreurStockage('ECRITURE', { chemin: base + SUFFIXES.journal, dossier: racine.chemin, cause });
} }
} }
// Vrai quand le dossier de l'Ouvert porte encore le fichier chemin, son nom
// comparé comme le système compare les noms : sans égard à la casse par
// cleSansCasse quand le système l'ignore, à la lettre sinon. Ne lit que la
// liste du dossier, jamais le fichier. Un dossier qui ne se liste plus lève
// ECRITURE sur ce fichier (enEcriture).
async function fichierPresent({ fs, racine }, chemin) {
let entrees;
try {
entrees = await fs.lister(racine, '');
} catch (erreur) {
throw enEcriture(erreur, chemin, racine);
}
const { insensibleCasse } = await fs.emplacements();
const cle = insensibleCasse ? cleSansCasse : (nom) => nom;
return entrees.some(({ nom }) => cle(nom) === cle(chemin));
}
// Écrit au journal la ligne à la suite de journal, le journal en mémoire que // Écrit au journal la ligne à la suite de journal, le journal en mémoire que
// le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une // le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une
// écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand // écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand
// entier le demande ou que le plafond est dépassé. Sans base, le journal // entier le demande, que le plafond est dépassé, ou que le fichier a disparu
// prend celle que baseCherchee trouve. Rend le journal en mémoire, qui est // (fichierPresent) : un ajout le recommencerait sans sa ligne d'ouverture, et
// le fichier, et sa base. // la lecture l'écarterait en entier. Sans base, le journal prend celle que
// baseCherchee trouve. Rend le journal en mémoire, qui est le fichier, sa
// base, et recree, vrai quand le fichier disparu s'est réécrit.
async function ecrireJournal(ouvert, preparation, ligne) { async function ecrireJournal(ouvert, preparation, ligne) {
let journal = avec(preparation.journal, ligne); let journal = avec(preparation.journal, ligne);
const { fs, racine } = ouvert; const { fs, racine } = ouvert;
const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement)); const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement));
if (preparation.entier || journal.entrees.length > PLAFOND_ENTREES) { const chemin = base + SUFFIXES.journal;
const entier = preparation.entier || journal.entrees.length > PLAFOND_ENTREES;
const recree = !entier && !(await fichierPresent(ouvert, chemin));
if (entier || recree) {
const plein = textePlein(journal); const plein = textePlein(journal);
journal = plein.journal; journal = plein.journal;
await fs.ecrireAtomique(racine, base + SUFFIXES.journal, plein.texte); await fs.ecrireAtomique(racine, chemin, plein.texte);
} else { } else {
await fs.ajouterLigne(racine, base + SUFFIXES.journal, ligne); await fs.ajouterLigne(racine, chemin, ligne);
} }
return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false }; return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false, recree };
}
/**
* Met de côté l'état aux octets hors UTF-8 d'un Ouvert de secours (§ 8.8),
* avant le premier geste qui le remplace : le fichier passe tel quel dans un
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille), créé
* d'abord, comme supprimer y range un événement. Ses octets ne se perdent
* jamais, et l'écriture qui suit pose l'état comme pour un état absent :
* l'Ouvert rendu ne le tient plus pour mal encodé. Un Ouvert dont l'état se
* lit, ou manque, revient tel quel, sans rien écrire. Un état qui n'est plus
* dans le dossier — une mise de côté déjà faite, que suit un geste refusé
* réessayé du même Ouvert — ne se déplace pas une seconde fois. Un refus
* remonte tel quel : l'état reste à sa place ou dans la corbeille.
*
* @param {Ouvert} ouvert
* @param {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
* @returns {Promise<Ouvert>}
* @throws {ErreurStockage} ECRITURE du dossier ou de la corbeille qui ne se
* listent plus, de la création du dossier daté ou du déplacement ;
* CORBEILLE_SATUREE, sans rien écrire
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire
*/
export async function mettreDeCoteEtatMalEncode(ouvert, horodatage) {
if (!ouvert.etatMalEncode) return ouvert;
const { fs, racine, base } = ouvert;
const chemin = base + SUFFIXES.etat;
if (!(await fichierPresent(ouvert, chemin))) return { ...ouvert, etatMalEncode: false };
let existants = [];
try {
existants = (await fs.lister(racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
} catch (erreur) {
// Une corbeille absente est vide.
if (erreur.code !== 'ABSENT') throw enEcriture(erreur, DOSSIER_CORBEILLE, racine);
}
const dossier = dossierCorbeille(horodatage, existants);
await fs.creerDossier(racine, dossier);
await fs.deplacer(racine, chemin, `${dossier}/${chemin}`);
return { ...ouvert, etatMalEncode: false };
} }
// Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord, // Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord,
@ -918,16 +1097,23 @@ async function ecrireEtat(avance, texteEtat) {
} }
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses // L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
// placements et d'un journal qui finit ailleurs que sur elle. // placements, d'un journal qui finit ailleurs que sur elle, et du journal
function apresEcriture(ouvert, { charge, revision, journal }) { // disparu que l'écriture a réécrit (recree).
function apresEcriture(ouvert, { charge, revision, journal, recree = false }) {
const examen = examiner(charge); const examen = examiner(charge);
return { const avertissements = avertir({
...ouvert,
charge: examen.charge,
revision,
examen, examen,
avertissements: avertir({ examen, journal, revision, rattrape: false, ecartees: 0, format: null, etranger: null }), journal,
}; revision,
rattrape: false,
ecartees: 0,
recree,
format: null,
malEncode: false,
etranger: null,
manquants: AUCUN_MANQUANT,
});
return { ...ouvert, charge: examen.charge, revision, examen, avertissements };
} }
// La ligne de l'entrée d'un geste à la suite du journal que preparer a // La ligne de l'entrée d'un geste à la suite du journal que preparer a
@ -990,6 +1176,7 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
chargeJournal: canoniser(charge), chargeJournal: canoniser(charge),
journalAReecrire: false, journalAReecrire: false,
formatPlusRecent: false, formatPlusRecent: false,
etatMalEncode: false,
}; };
return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat); return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat);
} }
@ -1004,7 +1191,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
* ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et * ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et
* au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat, * au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat,
* s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien * s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien
* s'écrit au format courant : la conversion tombe au premier geste. * s'écrit au format courant : la conversion tombe au premier geste. Avant
* (1), l'état aux octets hors UTF-8 d'un Ouvert de secours passe dans un
* dossier daté de la corbeille, à l'horodatage du geste
* (mettreDeCoteEtatMalEncode).
* *
* Quand l'état lisible sur le disque ne porte pas la révision de la dernière * Quand l'état lisible sur le disque ne porte pas la révision de la dernière
* entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le * entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le
@ -1027,6 +1217,8 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
* requis quand elle a lieu * requis quand elle a lieu
* @returns {Promise<Ouvert>} * @returns {Promise<Ouvert>}
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ; * @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
* ce que lève la mise de côté d'un état aux octets hors UTF-8
* (mettreDeCoteEtatMalEncode), avant le journal ;
* ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant * ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant
* l'alignement, la recherche du journal d'un Ouvert sans base de * l'alignement, la recherche du journal d'un Ouvert sans base de
* journal comprise (baseCherchee) : le geste n'est pas au journal, et * journal comprise (baseCherchee) : le geste n'est pas au journal, et
@ -1044,10 +1236,11 @@ export async function enregistrer(ouvert, apres, options) {
const { ligne, revision } = ligneDuGeste(preparation, apres, options); const { ligne, revision } = ligneDuGeste(preparation, apres, options);
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer'); const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
const aligne = etatAligne(ouvert, preparation); const aligne = etatAligne(ouvert, preparation);
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne); const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
const ecrit = await ecrireJournal(suite.depart, suite.preparation, ligne); const suite = aligne === null ? { depart, preparation } : await aligner(depart, preparation, aligne);
const { recree, ...ecrit } = await ecrireJournal(suite.depart, suite.preparation, ligne);
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) }; const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) };
return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal }), texteEtat); return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree }), texteEtat);
} }
/** /**
@ -1066,9 +1259,9 @@ export async function nommer(ouvert, { nom, horodatage, libelleReprise }) {
exigerEcriture(ouvert); exigerEcriture(ouvert);
const preparation = preparer(ouvert, { libelleReprise, horodatage }); const preparation = preparer(ouvert, { libelleReprise, horodatage });
const revision = preparation.journal.entrees.at(-1).revision; const revision = preparation.journal.entrees.at(-1).revision;
const ecrit = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage })); const { recree, ...ecrit } = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage }));
const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge }; const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge };
return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal }); return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal, recree });
} }
/** /**
@ -1216,6 +1409,7 @@ export async function enregistrerAilleurs(
baseJournal: base, baseJournal: base,
chargeJournal: paire.chargeJournal, chargeJournal: paire.chargeJournal,
journalAReecrire: false, journalAReecrire: false,
etatMalEncode: false,
}; };
return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat); return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
} }

View file

@ -194,6 +194,40 @@ function remplacer(fichiers, nom, texte) {
const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1]; const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1];
// Un disque neuf dont documents porte les fichiers, nom posé en octets.
function disqueAvecOctets(fichiers, nom, octets) {
const { disque, fs } = disqueAvec(remplacer(fichiers, nom, null));
fs.deposerOctets('documents', nom, octets);
return { disque, fs };
}
// Les octets UTF-8 du texte, son premier « é » (C3 A9) remplacé par l'octet
// E9 qu'écrit un éditeur réglé en windows-1252 : une suite qui n'est pas de
// l'UTF-8.
function malEncode(texte) {
const octets = [...new TextEncoder().encode(texte)];
const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9);
assert.ok(rang >= 0, 'aucun « é » à mal encoder');
octets.splice(rang, 2, 0xe9);
return new Uint8Array(octets);
}
// Les octets UTF-8 d'un texte de journal, sa ligne de rang donné (0 pour
// l'ouverture) ouverte par l'octet E9 suivi de « { » : une suite qui n'est
// pas de l'UTF-8, sur cette ligne seule.
function ligneMalEncodee(texte, rang) {
const lignes = texte.split('\n');
assert.ok(rang < lignes.length - 1, `aucune ligne ${rang}`);
const avant = new TextEncoder().encode(lignes.slice(0, rang).map((ligne) => `${ligne}\n`).join(''));
const apres = new TextEncoder().encode(lignes.slice(rang).join('\n'));
return new Uint8Array([...avant, 0xe9, ...apres]);
}
// Le rang de la ligne d'un texte de journal qui porte l'entrée de révision
// donnée.
const rangDeLEntree = (texte, revision) =>
texte.split('\n').findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === revision);
// Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir // Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir
// n'écrit rien (§ 8.4). // n'écrit rien (§ 8.4).
async function rouvrir(disque, base) { async function rouvrir(disque, base) {
@ -594,6 +628,93 @@ describe('dépôt : un geste, une entrée, un état (§ 8.2)', () => {
assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal); assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal);
}); });
test("un journal effacé entre deux gestes ne reçoit pas d'ajout : le geste suivant le réécrit en entier, ouverture et entrées en mémoire comprises, JOURNAL_RECREE ; un jalon de même ; puis les ajouts reprennent", async () => {
const { disque, fs } = nouveauDisque();
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 2), geste(2));
await fs.supprimer(DOC, JOURNAL);
const trace = tracer(fs);
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 3), geste(3));
assert.deepEqual(trace.splice(0), [
['ecrireAtomique', JOURNAL],
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
]);
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
let journal = exigerEquivalence(fs, BASE);
assert.equal(journal.evenement, 'evt-lucioles');
assert.deepEqual(revisionsEtLibelles(journal), [
[1, CREATION.libelle],
[2, 'Geste 2'],
[3, 'Geste 3'],
]);
await fs.supprimer(DOC, JOURNAL);
trace.splice(0);
ouvert = await nommer(ouvert, { nom: 'Avant le tirage', horodatage: heure(4) });
assert.deepEqual(trace.splice(0), [['ecrireAtomique', JOURNAL]]);
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
journal = exigerEquivalence(fs, BASE);
assert.deepEqual(
journal.jalons.map(({ revision, nom }) => [revision, nom]),
[
[1, 'Création'],
[3, 'Avant le tirage'],
],
);
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 5), geste(5));
assert.deepEqual(trace.splice(0), [
['ajouterLigne', JOURNAL],
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
]);
assert.deepEqual(ouvert.avertissements, []);
assert.equal(exigerEquivalence(fs, BASE).entrees.length, 4);
assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []);
});
test("le journal présent se reconnaît comme le système compare les noms : sans égard à la casse, une base d'une autre casse ajoute en fin ; sensible à la casse, le nom exact", async () => {
const insensible = nouveauDisque();
await creer(insensible.fs, DOC, chargeEssai(), CREATION);
const autreCasse = await ouvrir(insensible.fs, DOC, BASE.toUpperCase());
const trace = tracer(insensible.fs);
const suite = await enregistrer(autreCasse, modifier(autreCasse.charge, 2), geste(2));
assert.deepEqual(trace[0], ['ajouterLigne', `${BASE.toUpperCase()}${SUFFIXES.journal}`]);
assert.deepEqual(suite.avertissements, []);
assert.equal(exigerEquivalence(insensible.fs, BASE).entrees.length, 2);
const sensible = nouveauDisque({ separateur: '/', insensibleCasse: false, documents: '/exemple/Documents' });
const racine = { id: 'documents', chemin: '/exemple/Documents' };
let ouvert = await creer(sensible.fs, racine, chargeEssai(), CREATION);
sensible.fs.deposer('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`, 'un autre fichier');
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
assert.deepEqual(ouvert.avertissements, []);
await sensible.fs.supprimer(racine, JOURNAL);
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
assert.equal(sensible.fs.contenu('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`), 'un autre fichier');
});
test("un dossier qui ne se liste plus au moment d'ajouter au journal lève ECRITURE sur le journal, sans rien écrire : ENOENT quand il a disparu, la cause d'une lecture refusée sinon", async () => {
for (const [nom, refus, cause] of [
['disparu', (chemin) => new ErreurStockage('ABSENT', { chemin }), 'ENOENT'],
['illisible', (chemin) => new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }), 'EACCES'],
]) {
const { disque, fs } = nouveauDisque();
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
const avant = await fichiersDe(disque);
const ecritures = fs.compteurs.ecritures;
fs.lister = async (racine, chemin) => {
throw refus(chemin);
};
const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'ECRITURE', nom);
assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause }, nom);
assert.equal(fs.compteurs.ecritures, ecritures, nom);
assert.deepEqual(await fichiersDe(disque), avant, nom);
}
});
test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => { test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => {
const { disque, fs } = nouveauDisque(); const { disque, fs } = nouveauDisque();
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
@ -1277,6 +1398,228 @@ describe('dépôt : un état illisible et ses secours (§ 8.8)', () => {
}); });
}); });
describe('dépôt : des octets hors UTF-8 (§ 8.8)', () => {
test("un état aux octets hors UTF-8 lève ETAT_ILLISIBLE, raison ENCODAGE, avec ses secours, sans rien écrire ; lister le montre illisible, apparié par son .precedent", async () => {
const { fichiers } = await troisRevisions();
const { fs } = disqueAvecOctets(fichiers, ETAT, malEncode(texteDe(fichiers, ETAT)));
const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
assert.deepEqual(erreur.details, {
base: BASE,
raison: 'ENCODAGE',
chemin: null,
secours: { precedent: 2, journal: 3 },
});
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
[[BASE, 'evt-lucioles', 'ENCODAGE', BASE]],
);
assert.equal(fs.compteurs.ecritures, 0);
});
test("ouvert d'un secours, le premier geste met l'état aux octets hors UTF-8 dans un dossier neuf de la corbeille, daté du geste, ses octets intacts, puis écrit le journal et l'état ; un jalon n'écrit que le journal, sans toucher l'état", async () => {
const { fichiers } = await troisRevisions();
const octets = malEncode(texteDe(fichiers, ETAT));
const ecritureDuJournal = { journal: ['ajouterLigne', JOURNAL], precedent: ['ecrireAtomique', JOURNAL] };
for (const source of ['journal', 'precedent']) {
const { disque, fs } = disqueAvecOctets(fichiers, ETAT, octets);
// Le dossier de la même seconde, déjà pris pour le secours .precedent,
// fait prendre le rang suivant.
if (source === 'precedent') fs.deposerDossier('documents', 'corbeille/2031-05-17_08-04-00');
const corbeille = `corbeille/2031-05-17_08-04-00${source === 'precedent' ? '_2' : ''}`;
const ouvert = await ouvrirSecours(fs, DOC, BASE, source);
assert.deepEqual([ouvert.etatMalEncode, ouvert.texteEtat], [true, null], source);
const trace = tracer(fs);
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
assert.deepEqual(
trace,
[['creerDossier', corbeille], ['deplacer', ETAT], ecritureDuJournal[source], ['ecrireAtomique', ETAT]],
source,
);
assert.deepEqual(fs.octets('documents', `${corbeille}/${ETAT}`), octets, source);
assert.equal(fs.contenu('documents', PRECEDENT), texteDe(fichiers, PRECEDENT), source);
assert.equal(suite.etatMalEncode, false, source);
exigerEquivalence(fs, BASE);
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual([rouvert.revision, rouvert.avertissements], [suite.revision, []], source);
}
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal');
const trace = tracer(fs);
const nomme = await nommer(parJournal, { nom: 'Avant la reprise', horodatage: heure(5) });
assert.deepEqual(trace, [['ajouterLigne', JOURNAL]]);
assert.equal(nomme.etatMalEncode, true);
assert.deepEqual(fs.octets('documents', ETAT), octets);
});
test("un geste refusé après la mise de côté de l'état aux octets hors UTF-8 se réessaie du même Ouvert sans rien déplacer de nouveau : un seul dossier daté, qui garde les octets", async () => {
const { fichiers } = await troisRevisions();
const octets = malEncode(texteDe(fichiers, ETAT));
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
const ajouter = fs.ajouterLigne;
fs.ajouterLigne = async (racine, chemin) => {
fs.ajouterLigne = ajouter;
throw new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EIO' });
};
const options = geste(4, { libelleReprise: REPRISE });
await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), options), 'ECRITURE');
assert.equal(fs.contenu('documents', JOURNAL), texteDe(fichiers, JOURNAL));
const trace = tracer(fs);
await enregistrer(ouvert, modifier(ouvert.charge, 4), options);
assert.deepEqual(trace, [
['ajouterLigne', JOURNAL],
['ecrireAtomique', ETAT],
]);
assert.deepEqual(
(await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom),
['2031-05-17_08-04-00'],
);
assert.deepEqual(fs.octets('documents', `corbeille/2031-05-17_08-04-00/${ETAT}`), octets);
exigerEquivalence(fs, BASE);
});
test("une corbeille qui ne se liste plus, au moment de mettre de côté l'état aux octets hors UTF-8, lève ECRITURE sur la corbeille sans rien écrire : l'état reste à sa place", async () => {
const { fichiers } = await troisRevisions();
const octets = malEncode(texteDe(fichiers, ETAT));
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
const lister = fs.lister;
fs.lister = async (racine, dossier) => {
if (dossier !== 'corbeille') return lister(racine, dossier);
throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: racine.chemin, cause: 'EACCES' });
};
const ecritures = fs.compteurs.ecritures;
const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4)), 'ECRITURE');
assert.deepEqual(erreur.details, { chemin: 'corbeille', dossier: DOCUMENTS, cause: 'EACCES' });
assert.equal(fs.compteurs.ecritures, ecritures);
assert.deepEqual(fs.octets('documents', ETAT), octets);
});
test("un .precedent aux octets hors UTF-8 ne gêne pas l'état lisible ; ouvert en secours, il lève ETAT_ILLISIBLE ENCODAGE ; un état illisible à côté de lui s'apparie par son journal", async () => {
const { fichiers } = await troisRevisions();
const { fs } = disqueAvecOctets(fichiers, PRECEDENT, malEncode(texteDe(fichiers, PRECEDENT)));
const ouvert = await ouvrir(fs, DOC, BASE);
assert.deepEqual([ouvert.revision, ouvert.etatMalEncode, ouvert.avertissements], [3, false, []]);
const erreur = await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ETAT_ILLISIBLE');
assert.deepEqual(erreur.details, { raison: 'ENCODAGE', chemin: null });
fs.deposer('documents', ETAT, '');
const illisible = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
assert.deepEqual(illisible.details, {
base: BASE,
raison: 'VIDE',
chemin: null,
secours: { precedent: null, journal: 3 },
});
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]),
[[BASE, 'evt-lucioles', 'VIDE']],
);
assert.equal(fs.compteurs.ecritures, 0);
});
test("un journal dont la ligne d'ouverture n'est pas de l'UTF-8 n'empêche pas d'ouvrir l'état : il n'est ni lu, ni apparié, ni remplacé, JOURNAL_ILLISIBLE {raison: ENCODAGE} ; le premier geste écrit l'historique sous une base libre, que la réouverture apparie", async () => {
const { fichiers } = await troisRevisions();
const octets = ligneMalEncodee(texteDe(fichiers, JOURNAL), 0);
const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets);
const ouvert = await ouvrir(fs, DOC, BASE);
assert.equal(fs.compteurs.ecritures, 0);
assert.deepEqual([ouvert.revision, ouvert.journal, ouvert.baseJournal], [3, null, null]);
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ILLISIBLE', details: { raison: 'ENCODAGE' } }]);
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
[[BASE, 'evt-lucioles', null, null]],
);
const secours = await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ETAT_ILLISIBLE');
assert.deepEqual(secours.details, { raison: 'ENCODAGE', chemin: JOURNAL });
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
assert.notEqual(suite.baseJournal, BASE);
assert.deepEqual(fs.octets('documents', JOURNAL), octets);
const journal = exigerEquivalence(fs, BASE, { journal: suite.baseJournal });
assert.deepEqual(revisionsEtLibelles(journal), [
[3, REPRISE],
[4, 'Geste 4'],
]);
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual([rouvert.revision, rouvert.baseJournal, rouvert.avertissements], [4, suite.baseJournal, []]);
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, journal: apparie }) => [base, apparie]),
[[BASE, suite.baseJournal]],
);
});
test("un journal seul dont la ligne d'ouverture n'est pas de l'UTF-8 se liste sans identifiant, et s'ouvre en ETAT_ILLISIBLE ABSENT sans secours, non en ABSENT : il existe", async () => {
const { fichiers } = await troisRevisions();
const { fs } = disqueAvecOctets([], JOURNAL, ligneMalEncodee(texteDe(fichiers, JOURNAL), 0));
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
[[BASE, null, 'ABSENT', BASE]],
);
const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
assert.deepEqual(erreur.details, {
base: BASE,
raison: 'ABSENT',
chemin: null,
secours: { precedent: null, journal: null },
});
assert.equal(fs.compteurs.ecritures, 0);
});
test("une dernière ligne de journal coupée au milieu d'un caractère de deux octets s'écarte comme une ligne illisible, JOURNAL_ECARTEES {n: 1} : le journal reste celui de l'événement, apparié et ouvrable en secours, et le premier geste le réécrit sans elle", async () => {
const { fichiers } = await troisRevisions();
const texte = texteDe(fichiers, JOURNAL);
const coupee = new TextEncoder().encode('{"type":"entree","revision":4,"libelle":"Ajout de Th');
const octets = new Uint8Array([...new TextEncoder().encode(texte), ...coupee, 0xc3]);
const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets);
const ouvert = await ouvrir(fs, DOC, BASE);
assert.equal(fs.compteurs.ecritures, 0);
assert.deepEqual([ouvert.revision, ouvert.baseJournal, ouvert.journalAReecrire], [3, BASE, true]);
assert.deepEqual(revisionsEtLibelles(ouvert.journal), revisionsEtLibelles(lireJournal(texte)));
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]);
assert.deepEqual(
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
[[BASE, 'evt-lucioles', null, BASE]],
);
assert.equal((await ouvrirSecours(fs, DOC, BASE, 'journal')).revision, 3);
assert.equal(fs.compteurs.ecritures, 0);
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
assert.equal(suite.baseJournal, BASE);
const journal = exigerEquivalence(fs, BASE);
assert.deepEqual(
journal.entrees.map(({ revision }) => revision),
[1, 2, 3, 4],
);
assert.deepEqual(
(await fichiersDe(disque)).map(([nom]) => nom),
[JOURNAL, ETAT, PRECEDENT],
);
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual([rouvert.revision, rouvert.avertissements], [4, []]);
});
test("un octet hors UTF-8 dans l'entrée 37 d'un journal de 100 entrées arrête la lecture sur elle : 36 entrées retenues, 64 écartées et comptées, le journal toujours apparié", async () => {
const { disque, fs } = nouveauDisque();
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
for (let n = 2; n <= 100; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
const texte = fs.contenu('documents', JOURNAL);
assert.equal(lireJournal(texte).entrees.length, 100);
fs.deposerOctets('documents', JOURNAL, ligneMalEncodee(texte, rangDeLEntree(texte, 37)));
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual(
rouvert.journal.entrees.map(({ revision }) => revision),
Array.from({ length: 36 }, (_, i) => i + 1),
);
assert.equal(rouvert.journal.ecartees, 64);
assert.equal(rouvert.baseJournal, BASE);
assert.deepEqual(rouvert.avertissements, [
{ code: 'JOURNAL_INCOMPLET', details: { etat: 100, journal: 36 } },
{ code: 'JOURNAL_ECARTEES', details: { n: 64 } },
]);
});
});
describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => { describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => {
test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => { test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => {
const charge = chargeEssai(); const charge = chargeEssai();
@ -1715,6 +2058,58 @@ describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)'
assert.equal(ouvert.charge.participants.length, 4); assert.equal(ouvert.charge.participants.length, 4);
}); });
test("une proposition que l'en-tête compte sans que la liste la porte s'annonce par leur nombre, PROPOSITIONS_MANQUANTES {n}, et un retenu annoncé absent, RETENU_FAUTIF : le fichier s'ouvre ; rattrapé du journal, ou après un geste, plus rien ne manque", async () => {
const charge = chargeEssai();
charge.propositions.push(placement(2, [[1, 2], [3, 4]]));
charge.prochainsIds.proposition = 3;
charge.retenu = retenuDe(charge.propositions[0]);
const { disque, fs } = nouveauDisque();
const cree = await creer(fs, DOC, charge, CREATION);
await enregistrer(cree, modifier(cree.charge, 2), geste(2));
const fichiers = await fichiersDe(disque);
// L'état perd la proposition 1 et le retenu, ses comptes intacts.
const tronquer = (texte) => {
const fichier = JSON.parse(texte);
fichier.charge.propositions.shift();
fichier.charge.retenu = null;
return JSON.stringify(fichier);
};
const tronque = tronquer(texteDe(fichiers, ETAT));
assert.deepEqual(analyser(tronque).manquants, { propositions: 1, retenu: true });
const sansJournal = disqueAvec([[ETAT, tronque]]);
const ouvert = await rouvrir(sansJournal.disque, BASE);
assert.deepEqual(ouvert.avertissements, [
{ code: 'RETENU_FAUTIF', details: {} },
{ code: 'PROPOSITIONS_MANQUANTES', details: { n: 1 } },
]);
assert.deepEqual([ouvert.charge.propositions.map(({ id }) => id), ouvert.charge.retenu], [[2], null]);
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3, { libelleReprise: REPRISE }));
assert.deepEqual(suite.avertissements, []);
const relu = analyser(sansJournal.fs.contenu('documents', ETAT));
assert.deepEqual(relu.manquants, { propositions: 0, retenu: false });
// Le journal en avance d'une entrée rend sa charge, qui ne manque de rien.
const enRetard = tronquer(texteDe(fichiers, PRECEDENT));
const avecJournal = disqueAvec(remplacer(fichiers, ETAT, enRetard));
const rattrape = await rouvrir(avecJournal.disque, BASE);
assert.deepEqual(rattrape.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
assert.deepEqual([rattrape.revision, rattrape.charge.propositions.length], [2, 2]);
assert.notEqual(rattrape.charge.retenu, null);
});
test("un en-tête qui annonce 2^40 propositions s'ouvre : PROPOSITIONS_MANQUANTES ne porte que leur nombre, jamais une liste de cette taille, à côté des fautives nommées", async () => {
const fichier = JSON.parse(etatTexte(avecFautive()));
fichier.entete.comptes.propositions = 2 ** 40;
const { disque } = disqueAvec([[ETAT, JSON.stringify(fichier)]]);
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual(ouvert.avertissements, [
{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } },
{ code: 'PROPOSITIONS_MANQUANTES', details: { n: 2 ** 40 - 2 } },
]);
assert.ok(JSON.stringify(ouvert.avertissements).length < 200);
});
test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => { test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => {
// Par péremption, ce qui a changé dans la charge depuis la production de // Par péremption, ce qui a changé dans la charge depuis la production de
// sa proposition : périmées, la proposition et le retenu se gardent et se // sa proposition : périmées, la proposition et le retenu se gardent et se
@ -2455,9 +2850,19 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT'); await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
}); });
test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => { test("une lecture qui échoue autrement que sur un fichier absent ou mal encodé remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE, une lecture refusée LECTURE", async () => {
const { fs } = nouveauDisque(); const { fs } = nouveauDisque();
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE'); await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
assert.equal(fs.compteurs.ecritures, 0); await creer(fs, DOC, chargeEssai(), CREATION);
const ecritures = fs.compteurs.ecritures;
fs.lireTexte = async (racine, chemin) => {
throw new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' });
};
assert.deepEqual((await echec(ouvrir(fs, DOC, BASE), 'LECTURE')).details, {
chemin: ETAT,
dossier: DOCUMENTS,
cause: 'EACCES',
});
assert.equal(fs.compteurs.ecritures, ecritures);
}); });
}); });

View file

@ -19,7 +19,11 @@
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ, // FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des // dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, et le tour des réservations (FORME) ; les // participants et des tables, et le tour des réservations (FORME) ; les
// comptes de l'en-tête (COMPTES) ; les références (REFERENCE). Un fichier // comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
// ce que l'en-tête annonce sans que la charge le porte se rend à côté
// d'elle, et le dépôt l'annonce. Un fichier
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma // d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
// connaît, et ce qui serait FORME au format courant y porte la raison // connaît, et ce qui serait FORME au format courant y porte la raison
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du // FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
@ -507,13 +511,26 @@ function examinerTours({ evenement, reservations }, faute) {
}); });
} }
// Chaque compte de l'en-tête contre la charge, dans l'ordre du schéma ; // Comptes de l'en-tête qui portent sur les placements : une proposition ou
// plus s'ajoute aux détails du refus. // un retenu perdus se recalculent, la saisie non.
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
// Chaque compte de la saisie dans l'en-tête contre la charge, dans l'ordre
// du schéma ; plus s'ajoute aux détails du refus. Rend ce que les comptes
// des placements annoncent sans que la charge le porte : propositions, le
// nombre annoncé au-delà de la liste ; retenu, vrai quand il est annoncé et
// absent. Un compte plus petit que sa liste ne dit pas quelle proposition
// est en trop : la liste se lit telle quelle.
function examinerComptes(comptes, charge, plus) { function examinerComptes(comptes, charge, plus) {
const reels = comptesDe(charge); const reels = comptesDe(charge);
for (const [cle] of COMPTES.champs) { for (const [cle] of COMPTES.champs) {
if (COMPTES_DES_PLACEMENTS.has(cle)) continue;
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus); if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
} }
return {
propositions: Math.max(0, comptes.propositions - reels.propositions),
retenu: comptes.retenu > reels.retenu,
};
} }
// Références des réservations, puis des titres : la personne et la table // Références des réservations, puis des titres : la personne et la table
@ -539,8 +556,10 @@ function examinerReferences(charge, plus) {
/** /**
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une * Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge et * marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge,
* formatPlusRecent, vrai quand le format dépasse FORMAT. Au format courant, * formatPlusRecent, vrai quand le format dépasse FORMAT, et manquants, ce que
* les comptes des propositions et du retenu annoncent sans que la charge le
* porte, qui ne refuse jamais le fichier (§ 8.9, point 3). Au format courant,
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés : * l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus * canoniser les met dans l'ordre canonique. Un fichier d'un format plus
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la * récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
@ -583,9 +602,9 @@ export function analyser(texte) {
const { entete, charge } = fichier; const { entete, charge } = fichier;
examinerIdentifiants(charge, faute); examinerIdentifiants(charge, faute);
examinerTours(charge, faute); examinerTours(charge, faute);
examinerComptes(entete.comptes, charge, plus); const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus); examinerReferences(charge, plus);
return { entete, charge, formatPlusRecent }; return { entete, charge, formatPlusRecent, manquants };
} }
/** /**
@ -593,8 +612,12 @@ export function analyser(texte) {
* appartenance et leur exclusion ; les tables avec leur capacité courante ; * appartenance et leur exclusion ; les tables avec leur capacité courante ;
* le nombre de tours ; les réservations sans leur siège, que le moteur ne * le nombre de tours ; les réservations sans leur siège, que le moteur ne
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des * lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
* réglages. Les listes gardent l'ordre de la charge : le rang d'une * réglages. Les tables vont par identifiant croissant, l'ordre du texte
* réservation que nomme une ErreurConfiguration est son rang dans la charge. * canonique : le moteur assoit table après table, et une charge aux tables
* dans un autre ordre, relue après un enregistrement, ne régénérerait plus
* ses propositions (§ 8.9). Les participants et les réservations gardent
* l'ordre de la charge : le rang d'une réservation que nomme une
* ErreurConfiguration est son rang dans la charge.
* *
* @param {import('./types.js').Charge} charge * @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration} * @returns {import('../moteur/types.js').Configuration}
@ -603,7 +626,9 @@ export function configurationDepuisCharge(charge) {
const { evenement, reglages } = charge; const { evenement, reglages } = charge;
return { return {
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })), participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
tables: charge.tables.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })), tables: [...charge.tables]
.sort((a, b) => a.id - b.id)
.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
tours: evenement.tours, tours: evenement.tours,
reservations: charge.reservations.map(({ participant, table, portee, tour }) => reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour }, portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },

View file

@ -14,6 +14,7 @@ import { describe, test } from '../../test/lanceur.js';
import { STATUT, normaliser } from '../moteur/configuration.js'; import { STATUT, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js'; import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js'; import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js'; import { serialiser } from './canonique.js';
import { import {
@ -231,6 +232,16 @@ describe('ErreurStockage (erreurs.js)', () => {
assert.deepEqual(sansDetails.details, {}); assert.deepEqual(sansDetails.details, {});
assert.equal(sansDetails.message, 'CORRECTIF {}'); assert.equal(sansDetails.message, 'CORRECTIF {}');
}); });
test("le message d'un détail en octets n'en dit que le nombre : les octets d'un fichier entier ne s'écrivent pas dans une trace", () => {
const octets = new Uint8Array(1 << 20);
const erreur = new ErreurStockage('LECTURE', { chemin: 'veillee.gtt-journal.jsonl', cause: 'UTF8_INVALIDE', octets });
assert.equal(erreur.details.octets, octets);
assert.equal(
erreur.message,
'LECTURE {"chemin":"veillee.gtt-journal.jsonl","cause":"UTF8_INVALIDE","octets":"1048576 octets"}',
);
});
}); });
describe('creerCharge (§ 4, § 6.1, § 9)', () => { describe('creerCharge (§ 4, § 6.1, § 9)', () => {
@ -299,7 +310,12 @@ describe('creerCharge (§ 4, § 6.1, § 9)', () => {
describe('analyser : ce qui se lit (§ 8.8)', () => { describe('analyser : ce qui se lit (§ 8.8)', () => {
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => { test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
const { entete, charge } = documentValide(); const { entete, charge } = documentValide();
assert.deepEqual(analyser(JSON.stringify(documentValide())), { entete, charge, formatPlusRecent: false }); assert.deepEqual(analyser(JSON.stringify(documentValide())), {
entete,
charge,
formatPlusRecent: false,
manquants: { propositions: 0, retenu: false },
});
}); });
test('variantes admises : chacune se lit sans refus', () => { test('variantes admises : chacune se lit sans refus', () => {
@ -645,7 +661,11 @@ describe('analyser : identifiants et tours (§ 4)', () => {
modifier(attendu); modifier(attendu);
attendu.entete.format = format; attendu.entete.format = format;
try { try {
assert.deepEqual(analyser(JSON.stringify(attendu)), { ...attendu, formatPlusRecent: format > FORMAT }); assert.deepEqual(analyser(JSON.stringify(attendu)), {
...attendu,
formatPlusRecent: format > FORMAT,
manquants: { propositions: 0, retenu: false },
});
return []; return [];
} catch (erreur) { } catch (erreur) {
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`]; return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
@ -666,20 +686,34 @@ describe('analyser : identifiants et tours (§ 4)', () => {
}); });
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => { describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
test('chaque compte se compare à sa liste, le retenu à 0 ou 1', () => { test('les comptes de la saisie — participants, tables, réservations, titres — se comparent à leur liste et refusent le fichier', () => {
assert.deepEqual(ecartsDeRefus([ assert.deepEqual(ecartsDeRefus([
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'], ['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'], ['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
['une réservation annoncée en moins', ['une réservation annoncée en moins',
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'], abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'], ['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
['aucune proposition annoncée',
abime((d) => { d.entete.comptes.propositions = 0; }), 'COMPTES', 'entete.comptes.propositions'],
['un retenu annoncé, aucun écrit', abime((d) => { d.entete.comptes.retenu = 1; }), 'COMPTES', 'entete.comptes.retenu'],
['un retenu écrit, aucun annoncé',
abime((d) => { d.charge.retenu = retenuValide(); }), 'COMPTES', 'entete.comptes.retenu'],
]), []); ]), []);
}); });
test("les comptes des propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) : manquants rend les propositions annoncées au-delà de la liste et le retenu annoncé absent ; une liste plus longue que son compte se lit telle quelle", () => {
const manquantsDe = (modifier) => analyser(abime(modifier)).manquants;
assert.deepEqual(manquantsDe(() => {}), { propositions: 0, retenu: false });
assert.deepEqual(manquantsDe((d) => { d.charge.propositions = []; }), { propositions: 1, retenu: false });
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 3; }), { propositions: 2, retenu: false });
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.retenu = 1; }), { propositions: 0, retenu: true });
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 0; }), { propositions: 0, retenu: false });
const retenuNonAnnonce = analyser(abime((d) => { d.charge.retenu = retenuValide(); }));
assert.deepEqual(retenuNonAnnonce.manquants, { propositions: 0, retenu: false });
assert.deepEqual(retenuNonAnnonce.charge.retenu, retenuValide());
// Au format plus récent de même.
const plusRecent = analyser(abime((d) => {
d.entete.format = FORMAT + 1;
d.charge.propositions = [];
d.entete.comptes.retenu = 1;
}));
assert.deepEqual(plusRecent.manquants, { propositions: 1, retenu: true });
});
}); });
describe('analyser : références (§ 4, § 6.2)', () => { describe('analyser : références (§ 4, § 6.2)', () => {
@ -756,7 +790,12 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
d.charge.propositions[0].note = 'refaite'; d.charge.propositions[0].note = 'refaite';
d.charge.propositions[0].placement[0].commentaire = 'tour calme'; d.charge.propositions[0].placement[0].commentaire = 'tour calme';
})); }));
assert.deepEqual(etendu, { entete: { ...entete, format: 2 }, charge, formatPlusRecent: true }); assert.deepEqual(etendu, {
entete: { ...entete, format: 2 },
charge,
formatPlusRecent: true,
manquants: { propositions: 0, retenu: false },
});
}); });
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => { test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
@ -852,14 +891,28 @@ describe('configurationDepuisCharge (§ 6.1)', () => {
}); });
}); });
test("les listes gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => { test("les tables vont par identifiant croissant, l'ordre du texte canonique : une charge aux tables dans le désordre et sa copie canonique donnent la même configuration, donc à graine égale le même plan (§ 8.9)", () => {
const { charge } = analyser(texteLivre('grande-sans-exception'));
const desordre = structuredClone(charge);
[desordre.tables[0], desordre.tables[1]] = [desordre.tables[1], desordre.tables[0]];
desordre.tables.push(desordre.tables.shift());
assert.notDeepEqual(desordre.tables.map(({ id }) => id), charge.tables.map(({ id }) => id));
const canonique = configurationDepuisCharge(charge);
assert.deepEqual(configurationDepuisCharge(desordre), canonique);
assert.deepEqual(
canonique.tables.map(({ id }) => id),
charge.tables.map(({ id }) => id).sort((a, b) => a - b),
);
});
test("les participants et les réservations gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
const { charge } = documentValide(); const { charge } = documentValide();
charge.participants.reverse(); charge.participants.reverse();
charge.tables.reverse(); charge.tables.reverse();
charge.reservations.reverse(); charge.reservations.reverse();
const configuration = configurationDepuisCharge(charge); const configuration = configurationDepuisCharge(charge);
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]); assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
assert.deepEqual(configuration.tables.map(({ id }) => id), [3, 1]); assert.deepEqual(configuration.tables.map(({ id }) => id), [1, 3]);
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]); assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
// La seconde réservation de la charge désigne une table absente : le // La seconde réservation de la charge désigne une table absente : le
// moteur la nomme à son rang dans la charge, 1. // moteur la nomme à son rang dans la charge, 1.

View file

@ -6,7 +6,12 @@
// secours — ; types.js en donne la table, code par code. L'appelant lit le // secours — ; types.js en donne la table, code par code. L'appelant lit le
// code et les détails ; le message, code et détails en JSON, sert aux traces // code et les détails ; le message, code et détails en JSON, sert aux traces
// de diagnostic et n'est pas un texte affiché, que fournit la table des // de diagnostic et n'est pas un texte affiché, que fournit la table des
// libellés de l'application (§ 14.6). // libellés de l'application (§ 14.6). Un détail en octets — ceux d'un
// fichier qui n'est pas de l'UTF-8 — ne s'y écrit que par son nombre
// d'octets : le fichier entier ne passe pas dans une trace.
// Le remplaçant JSON d'un détail : un Uint8Array devient « N octets ».
const sansOctets = (cle, valeur) => (valeur instanceof Uint8Array ? `${valeur.length} octets` : valeur);
export class ErreurStockage extends Error { export class ErreurStockage extends Error {
/** /**
@ -14,7 +19,7 @@ export class ErreurStockage extends Error {
* @param {Object} [details] * @param {Object} [details]
*/ */
constructor(code, details = {}) { constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`); super(`${code} ${JSON.stringify(details, sansOctets)}`);
this.code = code; this.code = code;
this.details = details; this.details = details;
} }

View file

@ -70,10 +70,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE'; const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans // Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
// retirer celle qu'il porte, que Blob.text retirerait. Un octet qui n'est pas // retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
// de l'UTF-8 se lit U+FFFD. // strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
const UTF8 = new TextEncoder(); const UTF8 = new TextEncoder();
const DECODEUR = new TextDecoder('utf-8', { ignoreBOM: true }); const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit. // Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
const ignorer = () => {}; const ignorer = () => {};
@ -180,8 +180,16 @@ async function retirer(dossier, nom) {
} }
} }
// Texte d'un fichier : ses octets décodés tels quels. // Ce que porte un fichier : { texte, octets }, ses octets et leur décodage
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer()); // tel quel ; texte null quand ils ne sont pas de l'UTF-8.
async function lire(poignee) {
const octets = new Uint8Array(await (await poignee.getFile()).arrayBuffer());
try {
return { texte: DECODEUR.decode(octets), octets };
} catch {
return { texte: null, octets };
}
}
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux, // Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur // poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
@ -307,7 +315,7 @@ export function creerFichiersWeb() {
dossier = await dossierDe([], true); dossier = await dossierDe([], true);
await retirer(dossier, TEMOIN); await retirer(dossier, TEMOIN);
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN))); await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
const relu = await lire(await dossier.getFileHandle(TEMOIN)); const { texte: relu } = await lire(await dossier.getFileHandle(TEMOIN));
await retirer(dossier, TEMOIN); await retirer(dossier, TEMOIN);
return relu === TEMOIN return relu === TEMOIN
? { inscriptible: true, cause: null } ? { inscriptible: true, cause: null }
@ -323,14 +331,22 @@ export function creerFichiersWeb() {
return 'inconnu'; return 'inconnu';
}, },
// Des octets hors UTF-8 lèvent LECTURE, cause UTF8_INVALIDE, qui porte
// ces octets.
lireTexte: async (racine, chemin) => { lireTexte: async (racine, chemin) => {
const loc = localiser(racine, chemin); const loc = localiser(racine, chemin);
if (loc.nom === null) throw absent(chemin); if (loc.nom === null) throw absent(chemin);
let lu;
try { try {
return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom)); lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
} catch (erreur) { } catch (erreur) {
throw echecLecture(loc, erreur); throw echecLecture(loc, erreur);
} }
if (lu.texte === null) {
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE', octets: lu.octets };
throw new ErreurStockage('LECTURE', details);
}
return lu.texte;
}, },
ecrireAtomique: async (racine, chemin, texte) => { ecrireAtomique: async (racine, chemin, texte) => {

View file

@ -137,10 +137,19 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]), new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]),
); );
// Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre // Des octets UTF-8 se lisent tels quels, marque d'ordre d'octets et CRLF
// d'octets et CRLF restent. // compris ; un octet qui n'est pas de l'UTF-8 lève LECTURE, cause
// UTF8_INVALIDE, au lieu de se lire U+FFFD ; le refus porte les octets
// du fichier, que le dépôt décode ligne par ligne.
await poserOpfs('pose.csv', BRUTS.subarray(0, BRUTS.length - 1));
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n');
await poserOpfs('pose.csv', BRUTS); await poserOpfs('pose.csv', BRUTS);
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n\u{FFFD}'); await echoue(fs.lireTexte(DOCUMENTS, 'pose.csv'), 'LECTURE', {
chemin: 'pose.csv',
dossier: CHEMIN_DOCUMENTS,
cause: 'UTF8_INVALIDE',
octets: new Uint8Array(BRUTS),
});
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']); expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']);
}); });

View file

@ -11,10 +11,10 @@
// //
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin // Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le // de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
// texte entier d'un journal, restitue la charge d'un instant, suit le fil // texte entier d'un journal, en décode les octets ligne par ligne, restitue
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON // la charge d'un instant, suit le fil courant et élague. Une table par sorte
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé // de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture // l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
// refuserait la forme. La suite des révisions et la cadence des instantanés, // refuserait la forme. La suite des révisions et la cadence des instantanés,
// que la lecture contrôle aussi, dépendent des lignes voisines : elles // que la lecture contrôle aussi, dépendent des lignes voisines : elles
// appartiennent à l'appelant. // appartiennent à l'appelant.
@ -63,6 +63,11 @@ const CHARGE = regleDuChamp(SCHEMA, 'charge');
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element; const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const MARQUE_ORDRE_OCTETS = '\u{FEFF}'; const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
// La fin de ligne, en octets.
const LF = 0x0a;
// Décodage strict d'une ligne : un octet hors UTF-8 lève, et la marque
// d'ordre d'octets reste dans le texte, que la lecture retire.
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application. // AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre. // Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/; const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
@ -333,6 +338,44 @@ export function lireJournal(texte) {
return lire(texte).journal; return lire(texte).journal;
} }
/**
* Le texte lisible des octets d'un journal (§ 8.6) : coupés à chaque octet
* de fin de ligne LF, chaque segment se décode strictement, dans l'ordre ;
* le premier qui n'est pas de l'UTF-8 arrête le texte, comme une ligne
* illisible arrête la lecture. Rend texte, les lignes qui le précèdent,
* chacune suivie de sa fin de ligne — tout le texte quand chaque segment se
* décode, null quand l'ouverture elle-même ne se décode pas —, et ecartees,
* ce segment et ceux qui le suivent, comptés comme lireJournal compte les
* lignes : chaque fin de ligne en termine une, et le dernier segment en est
* une quand il n'est pas vide. Une écriture coupée au milieu d'un caractère
* ne gâte ainsi que sa dernière ligne, et lireJournal(texte), ses ecartees
* augmentées de celles-ci, est la lecture du journal.
*
* @param {Uint8Array} octets
* @returns {{texte: string|null, ecartees: number}}
*/
export function decoderJournal(octets) {
const segments = [];
let debut = 0;
for (let rang = octets.indexOf(LF); rang !== -1; rang = octets.indexOf(LF, debut)) {
segments.push(octets.subarray(debut, rang));
debut = rang + 1;
}
segments.push(octets.subarray(debut));
const lignes = segments.length - 1 + (segments.at(-1).length > 0 ? 1 : 0);
let texte = '';
for (let rang = 0; rang < segments.length; rang += 1) {
let ligne;
try {
ligne = UTF8_STRICT.decode(segments[rang]);
} catch {
return { texte: rang === 0 ? null : texte, ecartees: lignes - rang };
}
texte += rang < segments.length - 1 ? `${ligne}\n` : ligne;
}
return { texte, ecartees: 0 };
}
/** /**
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou * Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes — * avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —

View file

@ -25,6 +25,7 @@ import {
INTERVALLE_INSTANTANE, INTERVALLE_INSTANTANE,
PLAFOND_ENTREES, PLAFOND_ENTREES,
PLANCHER_ENTREES, PLANCHER_ENTREES,
decoderJournal,
elaguer, elaguer,
estInstantane, estInstantane,
fil, fil,
@ -892,6 +893,18 @@ describe('la lecture s’arrête à la première ligne illisible et compte ce qu
assert.equal(fil(journal).position, 5); assert.equal(fil(journal).position, 5);
assert.equal(texteCanonique(reconstruire(journal, 5)), texteCanonique(etapes[4].apres)); assert.equal(texteCanonique(reconstruire(journal, 5)), texteCanonique(etapes[4].apres));
}); });
test('decoderJournal décode les octets ligne par ligne : la première ligne hors UTF-8 arrête le texte, elle et les suivantes comptées comme lireJournal compte ; une ouverture hors UTF-8 ne laisse aucun texte', () => {
const octets = (...morceaux) =>
new Uint8Array(morceaux.flatMap((morceau) => (typeof morceau === 'string' ? [...new TextEncoder().encode(morceau)] : morceau)));
assert.deepEqual(decoderJournal(octets('a\n\u{E9}\n')), { texte: 'a\n\u{E9}\n', ecartees: 0 });
assert.deepEqual(decoderJournal(octets('a\nb')), { texte: 'a\nb', ecartees: 0 });
assert.deepEqual(decoderJournal(octets('\u{FEFF}a\nb', [0xc3])), { texte: '\u{FEFF}a\n', ecartees: 1 });
assert.deepEqual(decoderJournal(octets('a\nb\n', [0xe9], 'c\nd\ne')), { texte: 'a\nb\n', ecartees: 3 });
assert.deepEqual(decoderJournal(octets('a\n', [0xe9], '\nd\n')), { texte: 'a\n', ecartees: 2 });
assert.deepEqual(decoderJournal(octets([0xe9], '\nb\n')), { texte: null, ecartees: 2 });
assert.deepEqual(decoderJournal(octets([0xc3])), { texte: null, ecartees: 1 });
});
}); });
// --- Le fil courant -------------------------------------------------------- // --- Le fil courant --------------------------------------------------------

View file

@ -21,15 +21,28 @@
// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît // (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît
// pas. Copier, renommer et charger le refusent avant toute écriture ; // pas. Copier, renommer et charger le refusent avant toute écriture ;
// importer en recopie les octets, et l'événement importé s'ouvre en lecture // importer en recopie les octets, et l'événement importé s'ouvre en lecture
// seule. // seule. Un état dont les octets ne sont pas de l'UTF-8 ne s'efface jamais :
// renommer l'Ouvert de son secours le met d'abord dans un dossier daté de la
// corbeille, comme le premier geste (mettreDeCoteEtatMalEncode, du dépôt) ;
// copier en fait un événement neuf, sans le toucher.
// //
// Une base se dérive du nom de l'événement (deriverBase), libre parmi les // Une base se dérive du nom de l'événement (deriverBase), libre parmi les
// bases que portent les fichiers d'événement à la racine du dossier // bases que portent les fichiers d'événement à la racine du dossier
// (basesPrises), comme le dépôt la dérive pour creer. // (basesPrises), comme le dépôt la dérive pour creer.
import { canoniser } from './canonique.js'; import { canoniser } from './canonique.js';
import { basesPrises, creer, enregistrer, enregistrerAilleurs, lister, ouvrir } from './depot.js'; import {
basesPrises,
creer,
enregistrer,
enregistrerAilleurs,
lister,
mettreDeCoteEtatMalEncode,
ouvrir,
ouvrirSecours,
} from './depot.js';
import { analyser, etatDeduit } from './document.js'; import { analyser, etatDeduit } from './document.js';
import { ErreurStockage } from './erreurs.js'; import { ErreurStockage } from './erreurs.js';
import { decoderJournal } from './journal.js';
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js'; import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
// Deux chaînes, comparées unité UTF-16 par unité. // Deux chaînes, comparées unité UTF-16 par unité.
@ -57,12 +70,32 @@ async function entreesDe(fs, racine, dossier) {
} }
} }
// Texte du fichier chemin, ou null quand il manque. // Texte du fichier chemin, ou null quand il manque ou que ses octets ne sont
// pas de l'UTF-8 (LECTURE, cause UTF8_INVALIDE) : un texte qui ne se lit pas
// ne se recopie pas. Toute autre erreur remonte telle quelle.
async function texteSiPresent(fs, racine, chemin) { async function texteSiPresent(fs, racine, chemin) {
try { try {
return await fs.lireTexte(racine, chemin); return await fs.lireTexte(racine, chemin);
} catch (erreur) { } catch (erreur) {
if (erreur.code === 'ABSENT') return null; if (erreur.code === 'ABSENT') return null;
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') return null;
throw erreur;
}
}
// Texte du journal chemin tel que l'ouverture le lit : le fichier entier ;
// quand ses octets ne sont pas tous de l'UTF-8, les lignes qui précèdent la
// première qui ne se décode pas (decoderJournal), celles que la lecture
// écarterait n'étant pas recopiées ; null quand il manque, ou que sa ligne
// d'ouverture ne se décode pas. Toute autre erreur remonte telle quelle.
async function journalSiPresent(fs, racine, chemin) {
try {
return await fs.lireTexte(racine, chemin);
} catch (erreur) {
if (erreur.code === 'ABSENT') return null;
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
return decoderJournal(erreur.details.octets).texte;
}
throw erreur; throw erreur;
} }
} }
@ -72,6 +105,14 @@ function exigerFormatCourant(formatPlusRecent) {
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }); if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
} }
// Une séance est une chaîne non vide : celle sous laquelle supprimer sonde
// le verrou.
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') {
throw new TypeError("supprimer : la séance n'est pas une chaîne non vide");
}
}
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa // Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
// révision au journal ; sans elle — un état sans journal, ou que le journal // révision au journal ; sans elle — un état sans journal, ou que le journal
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à // ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
@ -240,6 +281,11 @@ async function supprimerAncienne(ouvert, nouvelle) {
* l'ancienne base, ne se touche pas ; le verrou non plus, que la séance * l'ancienne base, ne se touche pas ; le verrou non plus, que la séance
* déplace. * déplace.
* *
* L'Ouvert d'un secours dont l'état sur le disque n'est pas de l'UTF-8 met
* d'abord cet état dans un dossier daté de la corbeille, à l'horodatage du
* renommage (mettreDeCoteEtatMalEncode) : ni la recopie ni la suppression de
* l'ancienne paire ne le remplacent ni ne l'effacent, et ses octets restent.
*
* Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase * Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase
* les retire aussi —, ne touche à rien : aucune entrée, aucune recopie, * les retire aussi —, ne touche à rien : aucune entrée, aucune recopie,
* aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait * aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait
@ -266,6 +312,8 @@ async function supprimerAncienne(ouvert, nouvelle) {
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison: * @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand * 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
* le dossier n'existe plus ; en place, ce que lève enregistrer ; * le dossier n'existe plus ; en place, ce que lève enregistrer ;
* ce que lève la mise de côté d'un état aux octets hors UTF-8
* (mettreDeCoteEtatMalEncode), l'ancienne paire sinon intacte ;
* ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de * ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin, * l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
* dossier, cause, ouvert} de la suppression de l'ancienne * dossier, cause, ouvert} de la suppression de l'ancienne
@ -284,8 +332,9 @@ export async function renommer(ouvert, { nom, libelle, horodatage, produitVersio
return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise }); return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise });
} }
const geste = { apres, libelle, produitVersion }; const geste = { apres, libelle, produitVersion };
const nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage }); const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
await supprimerAncienne(ouvert, nouvelle); const nouvelle = await ecrireNouvellePaire(depart, { geste, libelleReprise, horodatage });
await supprimerAncienne(depart, nouvelle);
return nouvelle; return nouvelle;
} }
@ -295,28 +344,32 @@ export async function renommer(ouvert, { nom, libelle, horodatage, produitVersio
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8 // Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8
// ne sont pas un texte JSON : ETAT_ILLISIBLE, raison JSON, comme l'analyse le // ne sont pas un texte : ETAT_ILLISIBLE, raison ENCODAGE, comme le dépôt le
// lèverait d'un texte qui ne se lit pas. // lève d'un état du dossier aux octets hors UTF-8.
function decoder(octets) { function decoder(octets) {
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array'); if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
try { try {
return UTF8_STRICT.decode(octets); return UTF8_STRICT.decode(octets);
} catch { } catch {
throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'JSON', chemin: null }); throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'ENCODAGE', chemin: null });
} }
} }
// Écrit dans racine l'événement id, nom : texteJournal, quand il est donné, // Écrit dans racine l'événement id, nom : ses fichiers, tels quels, chacun
// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette // quand il est donné, sous une base libre que nom dérive — le .precedent
// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id. // d'abord, qui tient la base à l'événement (apparier, dans le dépôt), puis
async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) { // le journal, puis l'état. Rend cette base. IDENTIFIANT_PRESENT quand lister
const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && resume.id === id); // nomme id dans le dossier, que son état se lise ou non : l'événement y est
// déjà, et une seconde copie de son identifiant dériverait (§ 8.6).
async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null, texteJournal }) {
const present = (await lister(fs, racine)).find((resume) => resume.id === id);
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base }); if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
const { separateur } = await fs.emplacements(); const { separateur } = await fs.emplacements();
const existantes = await basesPrises(fs, racine); const existantes = await basesPrises(fs, racine);
const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes }); const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes });
if (textePrecedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, textePrecedent);
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal); if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat); if (texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
return base; return base;
} }
@ -333,9 +386,10 @@ async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
* @param {Uint8Array} octets * @param {Uint8Array} octets
* @returns {Promise<{base: string}>} * @returns {Promise<{base: string}>}
* @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de * @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de
* l'analyse, raison JSON pour des octets hors UTF-8 ; ni base ni * l'analyse, raison ENCODAGE pour des octets hors UTF-8 ; ni base ni
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id, * secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
* base} quand un état lisible du dossier porte son identifiant ; * base} quand lister nomme son identifiant dans le dossier, que
* l'état qui le porte se lise ou non ;
* CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de * CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de
* l'état * l'état
* @throws {TypeError} des octets qui ne sont pas un Uint8Array * @throws {TypeError} des octets qui ne sont pas un Uint8Array
@ -346,15 +400,61 @@ export async function importer(fs, racine, octets) {
return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) }; return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) };
} }
// Les fichiers de l'événement de base dans depuis, dont l'état ne se lit
// pas, recopiés tels quels dans vers (deposer) : le .precedent et l'état,
// chacun quand il existe et que ses octets sont de l'UTF-8, et le journal
// que le secours source apparie, par ses lignes lisibles (journalSiPresent). Le secours source — 'precedent' ou 'journal'
// (ouvrirSecours) — donne l'identifiant et le nom. Rend la base dans vers.
async function importerSecours(fs, depuis, vers, base, source) {
const { charge, baseJournal } = await ouvrirSecours(fs, depuis, base, source);
const { id, nom } = charge.evenement;
const lire = (chemin) => texteSiPresent(fs, depuis, chemin);
const journal = baseJournal === null ? null : baseJournal + SUFFIXES.journal;
return deposer(fs, vers, {
id,
nom,
textePrecedent: await lire(base + SUFFIXES.precedent),
texteJournal: journal === null ? null : await journalSiPresent(fs, depuis, journal),
texteEtat: await lire(base + SUFFIXES.etat),
});
}
// Importe l'événement de base de depuis dans vers (importerDepuis) et rend
// sa base là-bas. Un état illisible qui a un secours lisible s'importe par
// ses fichiers (importerSecours), le .precedent pour source quand il se lit ;
// sans secours, son refus remonte.
async function importerUn(fs, depuis, vers, base) {
let ouvert;
try {
ouvert = await ouvrir(fs, depuis, base);
} catch (erreur) {
if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur;
const { precedent, journal } = erreur.details.secours;
if (precedent === null && journal === null) throw erreur;
return importerSecours(fs, depuis, vers, base, precedent === null ? 'journal' : 'precedent');
}
const { texteEtat, baseJournal } = ouvert;
const { id, nom } = analyser(texteEtat).charge.evenement;
const texteJournal = baseJournal === null ? null : await journalSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
return deposer(fs, vers, { id, nom, texteEtat, texteJournal });
}
/** /**
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base * Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie * de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
* se copient tels quels dans vers, le journal d'abord, sous une base libre * se copient tels quels dans vers, le journal d'abord, sous une base libre
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Les * que dérive le nom que porte l'état. Le .precedent ne se copie pas. Un
* originaux restent où ils sont, sans qu'aucune écriture ne touche depuis. * événement dont l'état ne se lit pas, mais dont le .precedent ou le journal
* Une base qui ne s'importe pas se refuse par son code — ETAT_ILLISIBLE, * se lisent (§ 8.8), se copie par ses fichiers, tels quels : le .precedent,
* ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ; * le journal que son secours apparie, et l'état, chacun quand ses octets
* l'écriture refusée de son état laisse dans vers le journal déjà écrit. * sont de l'UTF-8 ; il s'ouvre là-bas en secours. Un journal dont une ligne
* n'est pas de l'UTF-8 se copie par les lignes qui la précèdent, celles que
* l'ouverture lit (journalSiPresent). Les originaux restent où
* ils sont, sans qu'aucune écriture ne touche depuis. Une base qui ne
* s'importe pas se refuse par son code — ETAT_ILLISIBLE sans secours
* lisible, ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les
* suivantes ; l'écriture refusée d'un fichier laisse dans vers ceux déjà
* écrits.
* *
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs * @param {import('./systeme_fichiers.js').SystemeFichiers} fs
* @param {import('./systeme_fichiers.js').Racine} depuis * @param {import('./systeme_fichiers.js').Racine} depuis
@ -371,11 +471,7 @@ export async function importerDepuis(fs, depuis, vers, bases) {
const refuses = []; const refuses = [];
for (const base of bases) { for (const base of bases) {
try { try {
const { texteEtat, baseJournal } = await ouvrir(fs, depuis, base); importes.push({ base, nouvelle: await importerUn(fs, depuis, vers, base) });
const { id, nom } = analyser(texteEtat).charge.evenement;
const texteJournal =
baseJournal === null ? null : await texteSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
importes.push({ base, nouvelle: await deposer(fs, vers, { id, nom, texteEtat, texteJournal }) });
} catch (erreur) { } catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur; if (!(erreur instanceof ErreurStockage)) throw erreur;
refuses.push({ base, code: erreur.code }); refuses.push({ base, code: erreur.code });
@ -419,6 +515,15 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se * chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
* liste encore tant que son état n'est pas parti. Rien ne vide la corbeille. * liste encore tant que son état n'est pas parti. Rien ne vide la corbeille.
* *
* Une autre séance qui tient l'événement en écriture y écrit encore : avant
* tout déplacement, supprimer sonde le verrou en le prenant sous seance, la
* séance qui supprime (§ 8.8). Un verrou d'une séance vivante, d'un autre
* poste ou illisible — vivant n'y est pas faux — lève VERROU_PRIS, sans rien
* déplacer. Le verrou d'une séance morte, celui de seance, ou celui que la
* sonde vient de prendre part avec le reste ; un refus qui suit la sonde
* rend le verrou qu'elle a pris. Là où le système n'a pas de verrou
* (verrouDisponible faux), la sonde ne pose rien.
*
* Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un * Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un
* dossier de travail qui disparaît pendant la suppression ne renaît pas * dossier de travail qui disparaît pendant la suppression ne renaît pas
* (§ 8.6). Disparu avant la création du dossier daté, il fait lever * (§ 8.6). Disparu avant la création du dossier daté, il fait lever
@ -428,36 +533,59 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs * @param {import('./systeme_fichiers.js').SystemeFichiers} fs
* @param {import('./systeme_fichiers.js').Racine} racine * @param {import('./systeme_fichiers.js').Racine} racine
* @param {string} base * @param {string} base
* @param {{horodatage: string}} options AAAA-MM-JJTHH:MM:SS±HH:MM * @param {{horodatage: string, seance: string}} options horodatage
* AAAA-MM-JJTHH:MM:SS±HH:MM ; seance, la séance qui supprime
* @returns {Promise<{dossier: string}>} chemin relatif du dossier daté * @returns {Promise<{dossier: string}>} chemin relatif du dossier daté
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun * @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
* fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ; * fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ;
* VERROU_PRIS {seance, depuis, vivant}, sans rien déplacer ;
* ECRITURE de la création du dossier daté ou d'un déplacement * ECRITURE de la création du dossier daté ou d'un déplacement
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît * refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
* pendant les déplacements * pendant les déplacements
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire * @throws {TypeError} un horodatage hors de sa forme, une séance qui n'est
* pas une chaîne non vide, sans rien écrire
*/ */
export async function supprimer(fs, racine, base, { horodatage }) { export async function supprimer(fs, racine, base, { horodatage, seance }) {
exigerSeance(seance);
const { insensibleCasse } = await fs.emplacements(); const { insensibleCasse } = await fs.emplacements();
const journal = await baseDuJournal(fs, racine, base, insensibleCasse); const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
const verrou = base + SUFFIXES.verrou;
const fichiers = [ const fichiers = [
base + SUFFIXES.verrou, verrou,
...(journal === null ? [] : [journal + SUFFIXES.journal]), ...(journal === null ? [] : [journal + SUFFIXES.journal]),
base + SUFFIXES.precedent, base + SUFFIXES.precedent,
base + SUFFIXES.etat, base + SUFFIXES.etat,
]; ];
const presents = (await fs.lister(racine, '')) // Les noms des fichiers du dossier, triés ; ceux de l'événement parmi eux,
.filter(({ type }) => type === 'fichier') // sous leur nom sur le disque, dans l'ordre de leur départ.
.map(({ nom }) => nom) const nomsSurDisque = async () =>
.sort(comparerTextes); (await fs.lister(racine, ''))
const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers] .filter(({ type }) => type === 'fichier')
.map((nom) => nomSurDisque(presents, nom, insensibleCasse)) .map(({ nom }) => nom)
.filter((nom) => nom !== null); .sort(comparerTextes);
if (partants.length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat }); const partantsParmi = (presents) =>
[...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
.filter((nom) => nom !== null);
let presents = await nomsSurDisque();
if (partantsParmi(presents).length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom); const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
const dossier = dossierCorbeille(horodatage, existants); const dossier = dossierCorbeille(horodatage, existants);
await fs.creerDossier(racine, dossier); // La sonde vise le verrou sous son nom sur le disque, celui qui part.
for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`); const nomDuVerrou = nomSurDisque(presents, verrou, insensibleCasse) ?? verrou;
const sonde = await fs.verrouiller(racine, nomDuVerrou, seance);
if (!sonde.pris && sonde.seance !== seance && sonde.vivant !== false) {
throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant });
}
try {
if (sonde.pris) presents = await nomsSurDisque();
await fs.creerDossier(racine, dossier);
for (const nom of partantsParmi(presents)) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
} catch (erreur) {
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
if (sonde.pris) await fs.deverrouiller(racine, nomDuVerrou, seance).catch(() => {});
throw erreur;
}
return { dossier }; return { dossier };
} }

View file

@ -44,6 +44,18 @@ const VERROU = `${BASE}${SUFFIXES.verrou}`;
const REPRISE = "Reprise de l'état trouvé sur le disque"; const REPRISE = "Reprise de l'état trouvé sur le disque";
const UTF8 = new TextEncoder(); const UTF8 = new TextEncoder();
// La séance qui supprime : celle qui tient le verrou que posent les épreuves.
const SEANCE = 'seance-a';
// Le verrou d'une séance morte de ce poste : son pid n'est celui d'aucun
// processus du disque d'épreuve, et supprimer l'emporte avec l'événement.
const VERROU_MORT = `${JSON.stringify({
seance: 'seance-morte',
pid: 999,
hote: 'poste-epreuve',
depuis: '2031-05-17T07:00:00-04:00',
})}\n`;
// Le texte tel que l'écrit un éditeur réglé en Windows-1252 : un octet par // Le texte tel que l'écrit un éditeur réglé en Windows-1252 : un octet par
// caractère, « é » donnant E9, qui n'est pas de l'UTF-8. Le texte ne porte // caractère, « é » donnant E9, qui n'est pas de l'UTF-8. Le texte ne porte
// que des caractères que Windows-1252 code comme ISO 8859-1, sur leur point // que des caractères que Windows-1252 code comme ISO 8859-1, sur leur point
@ -191,6 +203,17 @@ async function arbre(disque, racine = DOC, reglages = {}) {
return fichiers.sort(parChemin); return fichiers.sort(parChemin);
} }
// Ce qui tient lieu, dans arbreSonde, du texte d'un verrou que la sonde de
// supprimer a pris sous SEANCE : son pid et l'instant de sa prise varient.
const SONDE = 'verrou de la sonde';
// L'arbre de racine (arbre), le texte de chaque verrou de SEANCE remplacé
// par SONDE.
async function arbreSonde(disque, racine = DOC) {
const deLaSonde = (chemin, texte) => chemin.endsWith(SUFFIXES.verrou) && JSON.parse(texte).seance === SEANCE;
return (await arbre(disque, racine)).map(([chemin, texte]) => [chemin, deLaSonde(chemin, texte) ? SONDE : texte]);
}
// Ouvre base par un processus neuf, en affirmant qu'ouvrir n'écrit rien. // Ouvre base par un processus neuf, en affirmant qu'ouvrir n'écrit rien.
async function rouvrir(disque, base) { async function rouvrir(disque, base) {
const fs = creerFichiersSimules({ disque }); const fs = creerFichiersSimules({ disque });
@ -778,7 +801,7 @@ describe('opérations : renommer (§ 8.7)', () => {
let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION); let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION);
ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2)); ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2));
assert.equal(ouvertDeux.base, deux); assert.equal(ouvertDeux.base, deux);
await supprimer(second, DOC, gala, { horodatage: HEURE_SUPPRESSION }); await supprimer(second, DOC, gala, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
const avantDeux = await arbre(disqueDeux); const avantDeux = await arbre(disqueDeux);
const ecrituresDeux = second.compteurs.ecritures; const ecrituresDeux = second.compteurs.ecritures;
const traceDeux = tracer(second); const traceDeux = tracer(second);
@ -943,8 +966,8 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
assert.ok(windows1252.includes(0xe9)); assert.ok(windows1252.includes(0xe9));
assert.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE); assert.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE);
const cas = [ const cas = [
['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'JSON', chemin: null }], ['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'ENCODAGE', chemin: null }],
['un état écrit en Windows-1252', windows1252, { raison: 'JSON', chemin: null }], ['un état écrit en Windows-1252', windows1252, { raison: 'ENCODAGE', chemin: null }],
['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }], ['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }],
['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }], ['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }],
['des comptes faux', UTF8.encode(comptesFaux), { raison: 'COMPTES', chemin: 'entete.comptes.participants' }], ['des comptes faux', UTF8.encode(comptesFaux), { raison: 'COMPTES', chemin: 'entete.comptes.participants' }],
@ -959,7 +982,7 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
await assert.rejects(importer(nouveauDisque().fs, DOC, etatTexte(chargeEssai())), TypeError); await assert.rejects(importer(nouveauDisque().fs, DOC, etatTexte(chargeEssai())), TypeError);
}); });
test("un identifiant que porte déjà un état lisible du dossier lève IDENTIFIANT_PRESENT {id, base}, quel que soit le nom, sans rien écrire ; ni un état illisible ni un journal sans état ne le portent", async () => { test("un identifiant que lister nomme dans le dossier lève IDENTIFIANT_PRESENT {id, base}, quel que soit le nom, sans rien écrire : un état lisible, un état illisible apparié par son .precedent, un journal sans état ; un état illisible sans identifiant ne le porte pas", async () => {
const { disque, fs } = nouveauDisque(); const { disque, fs } = nouveauDisque();
await creer(fs, DOC, chargeEssai(), CREATION); await creer(fs, DOC, chargeEssai(), CREATION);
const avant = await arbre(disque); const avant = await arbre(disque);
@ -972,12 +995,25 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
const illisible = disqueAvec([[ETAT, '{']]); const illisible = disqueAvec([[ETAT, '{']]);
assert.deepEqual(await importer(illisible.fs, DOC, octets), { base: 'Bal des Hérons' }); assert.deepEqual(await importer(illisible.fs, DOC, octets), { base: 'Bal des Hérons' });
const apparie = disqueAvec([
[ETAT, ''],
[PRECEDENT, etatTexte(chargeEssai())],
]);
const journalSeul = disqueAvec([[JOURNAL, (await paireDe(chargeEssai())).journal]]); const journalSeul = disqueAvec([[JOURNAL, (await paireDe(chargeEssai())).journal]]);
assert.deepEqual( for (const [nom, { disque: autre, fs: autreFs }, raison] of [
(await lister(journalSeul.fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]), ['un état illisible apparié par son .precedent', apparie, 'VIDE'],
[[BASE, 'evt-lucioles', 'ABSENT']], ['un journal sans état', journalSeul, 'ABSENT'],
); ]) {
assert.deepEqual(await importer(journalSeul.fs, DOC, octets), { base: 'Bal des Hérons' }); assert.deepEqual(
(await lister(autreFs, DOC)).map(({ base, id, illisible: lue }) => [base, id, lue]),
[[BASE, 'evt-lucioles', raison]],
nom,
);
const poses = await arbre(autre);
const refus = await echec(importer(autreFs, DOC, octets), 'IDENTIFIANT_PRESENT', nom);
assert.deepEqual(refus.details, { id: 'evt-lucioles', base: BASE }, nom);
assert.deepEqual(await arbre(autre), poses, nom);
}
}); });
test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => { test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => {
@ -1071,6 +1107,102 @@ describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
} }
}); });
test("un événement dont l'état ne se lit pas s'importe par ses secours : l'état, le .precedent et le journal qu'il s'apparie se recopient tels quels — sauf des octets hors UTF-8, qui ne se lisent pas —, et il s'ouvre là-bas en secours, par le .precedent seul quand il n'a pas de journal ; sans secours lisible, il se refuse ETAT_ILLISIBLE ; un identifiant déjà là-bas, illisible ou non, IDENTIFIANT_PRESENT", async () => {
const disque = new Map();
const PORTABLE = { executable: 'E:\\soirees' };
const fs = creerFichiersSimules({ disque, ...PORTABLE });
fs.deposerDossier('portable');
fs.deposerDossier('documents');
const { portable, documents } = await fs.racines();
// Gala : l'état vidé, son .precedent et son journal intacts. Fanfare :
// l'état aux octets hors UTF-8, et son journal. Roseraie : l'état
// illisible et son .precedent, sans journal. Ifs : l'état seul,
// illisible. Saules : déjà dans documents, l'état illisible.
const gala = await (async () => {
const { fs: source, ouvert } = await deuxRevisions(chargeEssai({ id: 'evt-gala', nom: 'Gala des Ormes' }));
const lire = (suffixe) => source.contenu('documents', `${ouvert.base}${suffixe}`);
return { precedent: lire(SUFFIXES.precedent), journal: lire(SUFFIXES.journal), etat: lire(SUFFIXES.etat) };
})();
const fanfare = await paireDe(chargeEssai({ id: 'evt-fanfare', nom: 'Fanfare' }));
const saules = await paireDe(chargeEssai({ id: 'evt-saules', nom: 'Fête des Saules' }));
const roseraie = await paireDe(chargeEssai({ id: 'evt-roseraie', nom: 'Roseraie' }));
fs.deposer('portable', `Gala des Ormes${SUFFIXES.etat}`, '');
fs.deposer('portable', `Gala des Ormes${SUFFIXES.precedent}`, gala.precedent);
fs.deposer('portable', `Gala des Ormes${SUFFIXES.journal}`, gala.journal);
fs.deposerOctets('portable', `Fanfare${SUFFIXES.etat}`, enWindows1252(fanfare.etat));
fs.deposer('portable', `Fanfare${SUFFIXES.journal}`, fanfare.journal);
fs.deposer('portable', `Roseraie${SUFFIXES.etat}`, '{');
fs.deposer('portable', `Roseraie${SUFFIXES.precedent}`, roseraie.etat);
fs.deposer('portable', `Kermesse des Ifs${SUFFIXES.etat}`, '{');
fs.deposer('portable', `Fête des Saules${SUFFIXES.etat}`, saules.etat);
fs.deposer('documents', `Saules${SUFFIXES.etat}`, '');
fs.deposer('documents', `Saules${SUFFIXES.journal}`, saules.journal);
const ecritures = fs.compteurs.ecritures;
const bases = ['Gala des Ormes', 'Fanfare', 'Roseraie', 'Kermesse des Ifs', 'Fête des Saules'];
assert.deepEqual(await importerDepuis(fs, portable, documents, bases), {
importes: [
{ base: 'Gala des Ormes', nouvelle: 'Gala des Ormes' },
{ base: 'Fanfare', nouvelle: 'Fanfare' },
{ base: 'Roseraie', nouvelle: 'Roseraie' },
],
refuses: [
{ base: 'Kermesse des Ifs', code: 'ETAT_ILLISIBLE' },
{ base: 'Fête des Saules', code: 'IDENTIFIANT_PRESENT' },
],
});
assert.equal(fs.compteurs.ecritures - ecritures, 6);
assert.deepEqual(fs.octets('portable', `Fanfare${SUFFIXES.etat}`), enWindows1252(fanfare.etat));
assert.deepEqual(await arbre(disque, documents), [
[`Fanfare${SUFFIXES.journal}`, fanfare.journal],
[`Gala des Ormes${SUFFIXES.journal}`, gala.journal],
[`Gala des Ormes${SUFFIXES.etat}`, ''],
[`Gala des Ormes${SUFFIXES.precedent}`, gala.precedent],
[`Roseraie${SUFFIXES.etat}`, '{'],
[`Roseraie${SUFFIXES.precedent}`, roseraie.etat],
[`Saules${SUFFIXES.journal}`, saules.journal],
[`Saules${SUFFIXES.etat}`, ''],
]);
const lecteur = creerFichiersSimules({ disque });
for (const [base, raison, secours] of [
['Gala des Ormes', 'VIDE', { precedent: 1, journal: 2 }],
['Fanfare', 'ABSENT', { precedent: null, journal: 2 }],
]) {
const erreur = await echec(ouvrir(lecteur, documents, base), 'ETAT_ILLISIBLE', base);
assert.deepEqual([erreur.details.raison, erreur.details.secours], [raison, secours], base);
assert.equal((await ouvrirSecours(lecteur, documents, base, 'journal')).revision, 2, base);
}
const roseraieLa = await echec(ouvrir(lecteur, documents, 'Roseraie'), 'ETAT_ILLISIBLE');
assert.deepEqual([roseraieLa.details.raison, roseraieLa.details.secours], ['JSON', { precedent: 2, journal: null }]);
const parPrecedent = await ouvrirSecours(lecteur, documents, 'Roseraie', 'precedent');
assert.deepEqual([parPrecedent.revision, parPrecedent.charge.evenement.id], [2, 'evt-roseraie']);
});
test("un journal dont la dernière ligne est coupée au milieu d'un caractère s'importe par ses lignes lisibles, celles que l'ouverture lit : l'événement s'ouvre là-bas avec son historique, sans rien écarter", async () => {
const disque = new Map();
const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
fs.deposerDossier('portable');
fs.deposerDossier('documents');
const { portable, documents } = await fs.racines();
const paire = await paireDe(chargeEssai({ id: 'evt-gala', nom: 'Gala des Ormes' }));
const coupee = [...UTF8.encode('{"type":"entree","revision":3,"libelle":"Ajout de Th'), 0xc3];
fs.deposer('portable', `Gala des Ormes${SUFFIXES.etat}`, paire.etat);
fs.deposerOctets('portable', `Gala des Ormes${SUFFIXES.journal}`, new Uint8Array([...UTF8.encode(paire.journal), ...coupee]));
const ouvertIci = await ouvrir(fs, portable, 'Gala des Ormes');
assert.deepEqual(ouvertIci.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]);
assert.deepEqual(await importerDepuis(fs, portable, documents, ['Gala des Ormes']), {
importes: [{ base: 'Gala des Ormes', nouvelle: 'Gala des Ormes' }],
refuses: [],
});
assert.deepEqual(await arbre(disque, documents), [
[`Gala des Ormes${SUFFIXES.journal}`, paire.journal],
[`Gala des Ormes${SUFFIXES.etat}`, paire.etat],
]);
const la = await ouvrir(creerFichiersSimules({ disque }), documents, 'Gala des Ormes');
assert.deepEqual([la.revision, la.journal.entrees.length, la.avertissements], [2, 2, []]);
});
test("une lecture que le système de fichiers refuse autrement que par l'absence refuse la base par son code, sans rien écrire là-bas", async () => { test("une lecture que le système de fichiers refuse autrement que par l'absence refuse la base par son code, sans rien écrire là-bas", async () => {
const disque = new Map(); const disque = new Map();
const PORTABLE = { executable: 'E:\\soirees' }; const PORTABLE = { executable: 'E:\\soirees' };
@ -1098,6 +1230,25 @@ describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
}); });
assert.equal(fs.compteurs.ecritures, ecritures); assert.equal(fs.compteurs.ecritures, ecritures);
assert.deepEqual(await arbre(disque, documents), []); assert.deepEqual(await arbre(disque, documents), []);
// De même pour le .precedent d'un état illisible, que la recopie relit
// après l'ouverture et l'ouverture du secours.
const roseraie = await paireDe(chargeEssai({ id: 'evt-roseraie', nom: 'Roseraie' }));
fs.deposer('portable', `Roseraie${SUFFIXES.etat}`, '{');
fs.deposer('portable', `Roseraie${SUFFIXES.precedent}`, roseraie.etat);
let precedents = 0;
fs.lireTexte = async (racine, chemin) => {
if (chemin === `Roseraie${SUFFIXES.precedent}` && (precedents += 1) > 2) {
throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EIO' });
}
return lire(racine, chemin);
};
assert.deepEqual(await importerDepuis(fs, portable, documents, ['Roseraie']), {
importes: [],
refuses: [{ base: 'Roseraie', code: 'LECTURE' }],
});
assert.equal(fs.compteurs.ecritures, ecritures);
assert.deepEqual(await arbre(disque, documents), []);
}); });
}); });
@ -1110,7 +1261,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION); await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION);
const avant = await arbre(disque); const avant = await arbre(disque);
const trace = tracer(fs); const trace = tracer(fs);
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), { dossier: DATE }); assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE });
const deplaces = [`${JOURNAL}.ecriture`, `${ETAT}.ecriture`, VERROU, JOURNAL, PRECEDENT, ETAT]; const deplaces = [`${JOURNAL}.ecriture`, `${ETAT}.ecriture`, VERROU, JOURNAL, PRECEDENT, ETAT];
assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]); assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]);
assert.deepEqual( assert.deepEqual(
@ -1142,14 +1293,15 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
suppressions.push(chemin); suppressions.push(chemin);
return primitive(racine, chemin); return primitive(racine, chemin);
}; };
const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30) }); const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30), seance: SEANCE });
const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30) }); const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30), seance: SEANCE });
const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00' }); const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00', seance: SEANCE });
assert.deepEqual( assert.deepEqual(
[a, b, c], [a, b, c],
[{ dossier: DATE }, { dossier: `${DATE}_2` }, { dossier: 'corbeille/2031-05-17_08-29-59' }], [{ dossier: DATE }, { dossier: `${DATE}_2` }, { dossier: 'corbeille/2031-05-17_08-29-59' }],
); );
const paire = (nom) => [`${nom}${SUFFIXES.journal}`, `${nom}${SUFFIXES.etat}`]; // La paire, et le verrou que la sonde a pris : il part avec elle.
const paire = (nom) => [`${nom}${SUFFIXES.journal}`, `${nom}${SUFFIXES.etat}`, `${nom}${SUFFIXES.verrou}`];
const corbeille = [ const corbeille = [
{ dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') }, { dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') },
{ dossier: DATE, fichiers: paire('Bal des Hérons') }, { dossier: DATE, fichiers: paire('Bal des Hérons') },
@ -1163,7 +1315,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const renomme = await renommer(ouvert, renommage('Kermesse des Ifs', 31)); const renomme = await renommer(ouvert, renommage('Kermesse des Ifs', 31));
await copier(renomme, optionsCopie('Variante des Ifs')); await copier(renomme, optionsCopie('Variante des Ifs'));
await importer(fs, DOC, UTF8.encode(etatTexte(chargeEssai({ id: 'evt-e', nom: 'Bal des Hérons' })))); await importer(fs, DOC, UTF8.encode(etatTexte(chargeEssai({ id: 'evt-e', nom: 'Bal des Hérons' }))));
await supprimer(fs, DOC, 'Kermesse des Ifs', { horodatage: heure(30) }); await supprimer(fs, DOC, 'Kermesse des Ifs', { horodatage: heure(30), seance: SEANCE });
const apres = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/')); const apres = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/'));
assert.deepEqual( assert.deepEqual(
apres.filter(([chemin]) => !chemin.startsWith(`${DATE}_3/`)), apres.filter(([chemin]) => !chemin.startsWith(`${DATE}_3/`)),
@ -1193,10 +1345,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
]; ];
for (const [nom, poses, restants] of cas) { for (const [nom, poses, restants] of cas) {
const { disque, fs } = disqueAvec(poses); const { disque, fs } = disqueAvec(poses);
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
assert.deepEqual( assert.deepEqual(
await arbre(disque), await arbreSonde(disque),
poses [...poses, [VERROU, SONDE]]
.map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte]) .map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte])
.sort(parChemin), .sort(parChemin),
nom, nom,
@ -1218,10 +1370,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[base], [base],
nom, nom,
); );
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION }); const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
assert.deepEqual( assert.deepEqual(
await arbre(disque), await arbreSonde(disque),
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin), [...poses, [`${base}${SUFFIXES.verrou}`, SONDE]]
.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte])
.sort(parChemin),
nom, nom,
); );
} }
@ -1234,12 +1388,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[ETAT, etat], [ETAT, etat],
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal], [`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
[`soirée des lucioles${SUFFIXES.precedent}`, etat], [`soirée des lucioles${SUFFIXES.precedent}`, etat],
[`Soire\u0301e Des Lucioles${SUFFIXES.verrou}`, '{}\n'], [`Soire\u0301e Des Lucioles${SUFFIXES.verrou}`, VERROU_MORT],
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'], [`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
]; ];
const { disque, fs } = disqueAvec(poses); const { disque, fs } = disqueAvec(poses);
const trace = tracer(fs); const trace = tracer(fs);
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
assert.deepEqual( assert.deepEqual(
await arbre(disque), await arbre(disque),
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin), poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin),
@ -1254,6 +1408,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const autre = disqueAvec(poses); const autre = disqueAvec(poses);
const { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', { const { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', {
horodatage: HEURE_SUPPRESSION, horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
}); });
assert.deepEqual( assert.deepEqual(
await arbre(autre.disque), await arbre(autre.disque),
@ -1267,6 +1422,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const troisieme = disqueAvec(illisible); const troisieme = disqueAvec(illisible);
const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', { const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', {
horodatage: HEURE_SUPPRESSION, horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
}); });
assert.deepEqual( assert.deepEqual(
await arbre(troisieme.disque), await arbre(troisieme.disque),
@ -1279,10 +1435,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const lac = 'Χορος της Λιμνης'; const lac = 'Χορος της Λιμνης';
const grecs = [ const grecs = [
[`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))], [`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))],
[`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, '{}\n'], [`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, VERROU_MORT],
]; ];
const quatrieme = disqueAvec(grecs); const quatrieme = disqueAvec(grecs);
const { dossier: dossierLac } = await supprimer(quatrieme.fs, DOC, lac, { horodatage: HEURE_SUPPRESSION }); const { dossier: dossierLac } = await supprimer(quatrieme.fs, DOC, lac, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
assert.deepEqual( assert.deepEqual(
await arbre(quatrieme.disque), await arbre(quatrieme.disque),
grecs.map(([chemin, texte]) => [`${dossierLac}/${chemin}`, texte]).sort(parChemin), grecs.map(([chemin, texte]) => [`${dossierLac}/${chemin}`, texte]).sort(parChemin),
@ -1305,15 +1461,19 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const verrou = `${voisine}${SUFFIXES.verrou}`; const verrou = `${voisine}${SUFFIXES.verrou}`;
const poses = [ const poses = [
[`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))], [`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))],
[verrou, '{}\n'], [verrou, VERROU_MORT],
]; ];
const { disque, fs } = disqueAvec(poses); const { disque, fs } = disqueAvec(poses);
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION }); const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
// Le verrou d'une base distincte n'est pas celui de l'événement : la
// sonde en prend un, qui part avec lui.
const sonde = prise ? [] : [[`${dossier}/${base}${SUFFIXES.verrou}`, SONDE]];
assert.deepEqual( assert.deepEqual(
await arbre(disque), await arbreSonde(disque),
poses [
.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte]) ...poses.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte]),
.sort(parChemin), ...sonde,
].sort(parChemin),
voisine, voisine,
); );
} }
@ -1325,24 +1485,29 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[ETAT, etat], [ETAT, etat],
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal], [`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
[`soirée des lucioles${SUFFIXES.precedent}`, etat], [`soirée des lucioles${SUFFIXES.precedent}`, etat],
[`Soirée Des Lucioles${SUFFIXES.verrou}`, '{}\n'], [`Soirée Des Lucioles${SUFFIXES.verrou}`, VERROU_MORT],
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'], [`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
]; ];
const sensible = { insensibleCasse: false }; const sensible = { insensibleCasse: false };
const { disque, fs } = disqueAvec(poses, sensible); const { disque, fs } = disqueAvec(poses, sensible);
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
// Le journal d'une autre base que son identifiant apparie part avec // Le journal d'une autre base que son identifiant apparie part avec
// l'événement (apparier, dans le dépôt) ; le reste ne lui appartient pas. // l'événement (apparier, dans le dépôt) ; le reste ne lui appartient pas.
// Le verrou d'une autre casse n'est pas le sien : la sonde en prend un,
// qui part avec lui.
const partis = [ETAT, poses[1][0]]; const partis = [ETAT, poses[1][0]];
const restes = (await arbre(disque, DOC, sensible)).filter(([chemin]) => chemin !== `${dossier}/${VERROU}`);
assert.deepEqual( assert.deepEqual(
await arbre(disque, DOC, sensible), restes,
poses poses
.map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte]) .map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte])
.sort(parChemin), .sort(parChemin),
); );
assert.equal(JSON.parse(creerFichiersSimules({ disque, ...sensible }).contenu('documents', `${dossier}/${VERROU}`)).seance, SEANCE);
await echec( await echec(
supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', { supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', {
horodatage: HEURE_SUPPRESSION, horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
}), }),
'ABSENT', 'ABSENT',
); );
@ -1357,7 +1522,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
fs.pannes.couperApres(n); fs.pannes.couperApres(n);
let acheve = true; let acheve = true;
try { try {
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
} catch (erreur) { } catch (erreur) {
assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`); assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`);
acheve = false; acheve = false;
@ -1385,7 +1550,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const temoin = effacerDocumentsAvant(disque, fs, n); const temoin = effacerDocumentsAvant(disque, fs, n);
let refus = null; let refus = null;
try { try {
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
} catch (erreur) { } catch (erreur) {
refus = erreur; refus = erreur;
} }
@ -1408,12 +1573,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
await creer(fs, DOC, chargeEssai(), CREATION); await creer(fs, DOC, chargeEssai(), CREATION);
const avant = await arbre(disque); const avant = await arbre(disque);
const ecritures = fs.compteurs.ecritures; const ecritures = fs.compteurs.ecritures;
const absent = await echec(supprimer(fs, DOC, 'Inexistant', { horodatage: HEURE_SUPPRESSION }), 'ABSENT'); const absent = await echec(supprimer(fs, DOC, 'Inexistant', { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'ABSENT');
assert.deepEqual(absent.details, { chemin: `Inexistant${SUFFIXES.etat}` }); assert.deepEqual(absent.details, { chemin: `Inexistant${SUFFIXES.etat}` });
await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: '17 mai 2031' }), TypeError); await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: '17 mai 2031', seance: SEANCE }), TypeError);
fs.deposerDossier('documents', DATE); fs.deposerDossier('documents', DATE);
for (let rang = 2; rang <= 99; rang += 1) fs.deposerDossier('documents', `${DATE}_${rang}`); for (let rang = 2; rang <= 99; rang += 1) fs.deposerDossier('documents', `${DATE}_${rang}`);
const saturee = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'CORBEILLE_SATUREE'); const saturee = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'CORBEILLE_SATUREE');
assert.deepEqual(saturee.details, { dossier: DATE }); assert.deepEqual(saturee.details, { dossier: DATE });
assert.equal(fs.compteurs.ecritures, ecritures); assert.equal(fs.compteurs.ecritures, ecritures);
assert.deepEqual(await arbre(disque), avant); assert.deepEqual(await arbre(disque), avant);
@ -1423,7 +1588,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
test("une lecture que le système de fichiers refuse autrement que par l'absence remonte telle quelle, sans rien écrire : un chemin refusé, une corbeille illisible", async () => { test("une lecture que le système de fichiers refuse autrement que par l'absence remonte telle quelle, sans rien écrire : un chemin refusé, une corbeille illisible", async () => {
const { fs } = await deuxRevisions(); const { fs } = await deuxRevisions();
const ecritures = fs.compteurs.ecritures; const ecritures = fs.compteurs.ecritures;
await echec(supprimer(fs, DOC, '../ailleurs', { horodatage: HEURE_SUPPRESSION }), 'CHEMIN_REFUSE'); await echec(supprimer(fs, DOC, '../ailleurs', { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'CHEMIN_REFUSE');
const lister = fs.lister; const lister = fs.lister;
fs.lister = async (racine, dossier) => { fs.lister = async (racine, dossier) => {
if (dossier === 'corbeille') { if (dossier === 'corbeille') {
@ -1432,10 +1597,89 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
return lister(racine, dossier); return lister(racine, dossier);
}; };
await echec(listerCorbeille(fs, DOC), 'LECTURE'); await echec(listerCorbeille(fs, DOC), 'LECTURE');
await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'LECTURE'); await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'LECTURE');
assert.equal(fs.compteurs.ecritures, ecritures); assert.equal(fs.compteurs.ecritures, ecritures);
}); });
test("supprimer sonde le verrou sous la séance qui supprime : une autre séance vivante, d'un autre poste ou au verrou illisible lève VERROU_PRIS {seance, depuis, vivant} sans rien déplacer ; un verrou mort, le sien, ou celui que la sonde prend partent avec le reste", async () => {
const refuses = [
['une autre séance vivante', (fs, ouvert) => verrouiller(ouvert, 'seance-b'), { seance: 'seance-b', vivant: true }],
[
"une séance d'un autre poste",
(fs, ouvert, disque) => verrouiller({ ...ouvert, fs: creerFichiersSimules({ disque, hote: 'poste-voisin' }) }, 'seance-c'),
{ seance: 'seance-c', vivant: null },
],
['un verrou illisible', (fs) => fs.deposer('documents', VERROU, '{'), { seance: null, vivant: null }],
];
for (const [nom, poser, attendu] of refuses) {
const { disque, fs, ouvert } = await deuxRevisions();
await poser(fs, ouvert, disque);
const avant = await arbre(disque);
const trace = tracer(fs);
const erreur = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'VERROU_PRIS', nom);
const { depuis, ...reste } = erreur.details;
assert.deepEqual(reste, attendu, nom);
assert.equal(depuis === null, attendu.seance === null, nom);
assert.deepEqual(trace, [], nom);
assert.deepEqual(await arbre(disque), avant, nom);
}
// Deux processus sur un même disque : le premier tient l'événement en
// écriture et continue ses gestes ; le second ne le supprime pas.
const partage = await deuxRevisions();
await verrouiller(partage.ouvert, SEANCE);
const second = creerFichiersSimules({ disque: partage.disque });
const refus = await echec(supprimer(second, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: 'seance-b' }), 'VERROU_PRIS');
assert.deepEqual([refus.details.seance, refus.details.vivant], [SEANCE, true]);
const suite = await enregistrer(partage.ouvert, noter(partage.ouvert.charge, 3), geste(3));
assert.deepEqual([suite.revision, suite.avertissements], [3, []]);
assert.deepEqual((await lister(second, DOC)).map(({ base, journal }) => [base, journal]), [[BASE, BASE]]);
assert.deepEqual(await listerCorbeille(second, DOC), []);
// Le verrou d'une séance coupée, le sien, ou celui que la sonde pose
// faute de verrou : chacun part dans la corbeille avec l'événement.
const acceptes = [
[
"une séance coupée",
async (disque) => {
const coupee = creerFichiersSimules({ disque });
await coupee.verrouiller(DOC, VERROU, 'seance-morte');
coupee.pannes.couperApres(0);
return 'seance-morte';
},
],
['la séance qui supprime', async (disque, fs, ouvert) => (await verrouiller(ouvert, SEANCE), SEANCE)],
['aucun verrou', async () => SEANCE],
];
for (const [nom, poser] of acceptes) {
const { disque, fs, ouvert } = await deuxRevisions();
const tenant = await poser(disque, fs, ouvert);
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE }, nom);
assert.deepEqual(await listerCorbeille(fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT, VERROU] }], nom);
assert.equal(JSON.parse(fs.contenu('documents', `${DATE}/${VERROU}`)).seance, tenant, nom);
assert.deepEqual(await lister(fs, DOC), [], nom);
}
});
test("sans verrou disponible, la sonde ne pose rien et rien d'autre ne part ; une séance absente lève TypeError sans rien écrire ; un refus après la sonde rend le verrou qu'elle a pris", async () => {
const web = await deuxRevisions(chargeEssai(), { verrouDisponible: false });
assert.deepEqual(await supprimer(web.fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE });
assert.deepEqual(await listerCorbeille(web.fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT] }]);
const { disque, fs } = await deuxRevisions();
const avant = await arbre(disque);
const ecritures = fs.compteurs.ecritures;
for (const seance of [undefined, '']) {
await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance }), TypeError);
}
assert.equal(fs.compteurs.ecritures, ecritures);
fs.creerDossier = async (racine, chemin) => {
throw new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EACCES' });
};
await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'ECRITURE');
assert.deepEqual(await arbre(disque), avant);
});
test('une corbeille absente se liste vide ; un fichier posé à même la corbeille ne se liste pas comme un dossier', async () => { test('une corbeille absente se liste vide ; un fichier posé à même la corbeille ne se liste pas comme un dossier', async () => {
const { fs } = nouveauDisque(); const { fs } = nouveauDisque();
assert.deepEqual(await listerCorbeille(fs, DOC), []); assert.deepEqual(await listerCorbeille(fs, DOC), []);
@ -1552,3 +1796,33 @@ describe("opérations : ce qu'une construction plus récente a écrit (§ 8.8)",
assert.equal(ouvert.fs.compteurs.ecritures, 0); assert.equal(ouvert.fs.compteurs.ecritures, 0);
}); });
}); });
describe('opérations : un état aux octets hors UTF-8 (§ 8.8)', () => {
test("ouvert d'un secours, renommer met d'abord l'état aux octets hors UTF-8 dans un dossier daté de la corbeille, ses octets intacts, puis renomme, sous un autre nom comme en place ; copier en fait un événement neuf sans le toucher", async () => {
for (const [nom, base] of [
['Gala des Saules', 'Gala des Saules'],
[`${BASE} `.toUpperCase(), BASE],
]) {
const { disque, fs } = await deuxRevisions();
const octets = enWindows1252(fs.contenu('documents', ETAT));
fs.deposerOctets('documents', ETAT, octets);
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
const copie = await copier(ouvert, optionsCopie('Variante des Lucioles'));
assert.equal(copie.charge.evenement.filiation.source.id, 'evt-lucioles', nom);
assert.deepEqual(fs.octets('documents', ETAT), octets, nom);
const renomme = await renommer(ouvert, renommage(nom, 3, { libelleReprise: REPRISE }));
assert.equal(renomme.base, base, nom);
assert.deepEqual(fs.octets('documents', `corbeille/2031-05-17_08-03-00/${ETAT}`), octets, nom);
assert.deepEqual(
(await lister(creerFichiersSimules({ disque }), DOC)).map(({ base: b, illisible }) => [b, illisible]),
[
[base, null],
['Variante des Lucioles', null],
],
nom,
);
const rouvert = await ouvrir(creerFichiersSimules({ disque }), DOC, base);
assert.deepEqual([rouvert.charge.evenement.nom, rouvert.avertissements], [nom, []], nom);
}
});
});

View file

@ -72,6 +72,10 @@ import { ErreurStockage } from './erreurs.js';
* @property {string|null} nom null pour une personne que la charge * @property {string|null} nom null pour une personne que la charge
* n'a plus * n'a plus
* @property {string|null} prenom * @property {string|null} prenom
* @property {boolean} reserve vrai pour une personne de la réserve ;
* une liste écrite au-delà des tables
* déclarées n'a pas de table et n'est
* pas la réserve
* *
* Ce que rend examiner. * Ce que rend examiner.
* *
@ -399,31 +403,41 @@ const ordreDesTables = (a, b) =>
(a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id; (a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id;
/** /**
* Forme nommée d'un placement cohérent, proposition ou retenu (§ 8.9, * Forme nommée d'un placement, proposition ou retenu (§ 8.9, point 4) : une
* point 4) : une ligne par personne placée à un tour, { tour, table, * ligne par identifiant écrit à un tour, { tour, table, numero, siege,
* numero, siege, participant, nom, prenom }, pour la lecture et le * participant, nom, prenom, reserve }, pour la lecture et le signalement
* signalement d'un défaut. Les lignes se rangent par tour, puis par numéro * d'un défaut. Les lignes se rangent par tour, puis par numéro de table —
* de table — une table que la charge n'a plus vient après les autres, sans * une table que la charge n'a plus vient après les autres, sans numéro, et
* numéro, et l'identifiant départage deux numéros égaux —, puis par rang * l'identifiant départage deux numéros égaux —, puis par rang dans la
* dans la liste ; la réserve suit les tables de son tour, table et numéro * liste ; la réserve suit les tables de son tour, table et numéro null.
* null. siege est le rang à partir de 1 quand le placement attribue les * siege est le rang à partir de 1 quand le placement attribue les sièges,
* sièges, null sinon et dans la réserve. Nom et prénom viennent de la * null sinon et dans la réserve. Nom et prénom viennent de la charge ; une
* charge ; une personne qu'elle n'a plus les a null. Lève TypeError quand p * personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
* n'est pas cohérent (fautes). *
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
* se rend tel qu'il est écrit, pour que son défaut se lise : un identifiant
* écrit deux fois donne deux lignes, un manquant aucune ; les tours écrits,
* qu'ils soient plus ou moins nombreux que ceux déclarés ; une table
* déclarée sans liste n'a pas de ligne, et une liste écrite au-delà des
* tables déclarées suit les tables de son tour, sans table ni numéro, avant
* la réserve. Ses fautes se lisent par fautes(p), que l'appelant rend à
* côté. Lève TypeError quand la forme ou la déclaration de p est fausse
* (faute FORME) : ses listes ne se lisent pas.
* *
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
* @param {import('./types.js').Charge} charge * @param {import('./types.js').Charge} charge
* @returns {Ligne[]} * @returns {Ligne[]}
*/ */
export function formeNommee(p, charge) { export function formeNommee(p, charge) {
exigerCoherent(p, 'formeNommee'); const [premiere] = fautes(p);
if (premiere?.code === 'FORME') throw new TypeError(`formeNommee : placement fautif ${JSON.stringify(premiere)}`);
const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne])); const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne]));
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero])); const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
const tables = p.tables const tables = p.tables
.map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null })) .map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null }))
.sort(ordreDesTables); .sort(ordreDesTables);
const lignes = []; const lignes = [];
const ajouter = (tour, table, numero, siege, participant) => { const ajouter = (tour, table, numero, siege, participant, reserve) => {
const personne = personneDe.get(participant); const personne = personneDe.get(participant);
lignes.push({ lignes.push({
tour, tour,
@ -433,16 +447,21 @@ export function formeNommee(p, charge) {
participant, participant,
nom: personne?.nom ?? null, nom: personne?.nom ?? null,
prenom: personne?.prenom ?? null, prenom: personne?.prenom ?? null,
reserve,
});
};
// Les personnes d'une liste de table, sans table pour une liste écrite
// au-delà des tables déclarées.
const ajouterListe = (tour, ids, table, numero) => {
ids.forEach((participant, position) => {
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
}); });
}; };
p.placement.forEach(({ sieges, reserve }, rangDuTour) => { p.placement.forEach(({ sieges, reserve }, rangDuTour) => {
const tour = rangDuTour + 1; const tour = rangDuTour + 1;
for (const { id, rang, numero } of tables) { for (const { id, rang, numero } of tables) ajouterListe(tour, sieges[rang] ?? [], id, numero);
sieges[rang].forEach((participant, position) => { for (const ids of sieges.slice(p.tables.length)) ajouterListe(tour, ids, null, null);
ajouter(tour, id, numero, p.siegesAttribues ? position + 1 : null, participant); for (const participant of reserve) ajouter(tour, null, null, null, participant, true);
});
}
for (const participant of reserve) ajouter(tour, null, null, null, participant);
}); });
return lignes; return lignes;
} }

View file

@ -209,8 +209,9 @@ function refusDe(appel) {
// aucune valeur au-delà. // aucune valeur au-delà.
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) }); const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
// Une ligne attendue de la forme nommée. // Une ligne attendue de la forme nommée : sans table, une ligne de la
const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({ // réserve, sauf quand enReserve dit le contraire.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom, enReserve = idTable === null) => ({
tour, tour,
table: idTable, table: idTable,
numero, numero,
@ -218,6 +219,7 @@ const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
participant, participant,
nom, nom,
prenom, prenom,
reserve: enReserve,
}); });
describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => { describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => {
@ -1306,17 +1308,90 @@ describe('formeNommee : la lecture et le signalement (§ 8.9, point 4)', () => {
]); ]);
}); });
test("planDepuisFichier, derive et formeNommee refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; fautes ne lève jamais", () => { test("un placement incohérent se rend pour le signalement de son défaut, une ligne par identifiant écrit, ses fautes à côté (fautes) : doublon, inconnu, liste au-delà de sa capacité, liste en trop sans table, liste ou tour en moins", () => {
const charge = chargePetite();
const casDe = (abimer) => {
const p = fichierParfait();
abimer(p);
return p;
};
const cas = [
[
'un assis aussi dans la réserve',
casDe((p) => { p.placement[0].reserve = [2]; }),
['DOUBLON'],
(lignes) => {
assert.equal(lignes.length, 49);
assert.deepEqual(lignes.filter(({ tour, participant }) => tour === 1 && participant === 2), [
ligne(1, 1, 1, null, 2, 'Bruyère', 'Basile'),
ligne(1, null, null, null, 2, 'Bruyère', 'Basile'),
]);
},
],
[
"un inconnu à la place d'un déclaré",
casDe((p) => { p.placement[1].sieges[0][0] = 99; }),
['INCONNU', 'MANQUANT'],
(lignes) => {
assert.equal(lignes.length, 48);
assert.deepEqual(lignes.filter(({ participant }) => participant === 99), [
ligne(2, 1, 1, null, 99, null, null),
]);
},
],
[
'une table au-delà de sa capacité, une liste en trop',
casDe((p) => {
p.siegesAttribues = true;
p.placement[0].sieges[0].push(4);
p.placement[0].sieges.push([5, 6]);
}),
['LONGUEUR', 'LONGUEUR', 'DOUBLON', 'DOUBLON', 'DOUBLON'],
(lignes) => {
assert.equal(lignes.length, 51);
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(0, 4).map(({ siege, participant }) => [siege, participant]), [
[1, 1],
[2, 2],
[3, 3],
[4, 4],
]);
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(-2), [
ligne(1, null, null, 1, 5, 'Églantine', 'Edmée', false),
ligne(1, null, null, 2, 6, 'Fougère', 'Firmin', false),
]);
},
],
[
'une liste et un tour en moins',
casDe((p) => {
p.placement[0].sieges.pop();
p.placement.pop();
}),
['LONGUEUR', 'LONGUEUR', 'MANQUANT', 'MANQUANT', 'MANQUANT'],
(lignes) => {
assert.equal(lignes.length, 33);
assert.deepEqual([...new Set(lignes.map(({ tour }) => tour))], [1, 2, 3]);
assert.equal(lignes.filter(({ tour, table }) => tour === 1 && table === 4).length, 0);
},
],
];
for (const [nom, p, codes, verifier] of cas) {
assert.deepEqual(fautes(p).map(({ code }) => code), codes, nom);
verifier(formeNommee(p, charge));
}
});
test("planDepuisFichier et derive refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; formeNommee ne refuse qu'une forme fausse ; fautes ne lève jamais", () => {
const charge = chargePetite(); const charge = chargePetite();
const abimee = fichierParfait(); const abimee = fichierParfait();
abimee.placement[0].sieges[0].pop(); abimee.placement[0].sieges[0].pop();
for (const [nom, appel] of [ for (const [nom, appel] of [
['planDepuisFichier', () => planDepuisFichier(abimee)], ['planDepuisFichier', () => planDepuisFichier(abimee)],
['derive', () => derive(abimee, charge)], ['derive', () => derive(abimee, charge)],
['formeNommee', () => formeNommee(abimee, charge)],
]) { ]) {
leveTypeError(appel, /"MANQUANT"/, nom); leveTypeError(appel, /"MANQUANT"/, nom);
} }
assert.equal(formeNommee(abimee, charge).length, 47);
for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) { for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) {
leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible)); leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible));
leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible)); leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible));

View file

@ -28,6 +28,14 @@
// EXISTE {chemin} deplacer vers une cible existante // EXISTE {chemin} deplacer vers une cible existante
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine // CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
// inconnue // inconnue
// LECTURE {chemin, dossier, cause} une lecture refusée autrement que
// sur un absent ; cause
// UTF8_INVALIDE pour un fichier dont
// les octets ne sont pas de l'UTF-8,
// que lireTexte ne lit jamais en
// U+FFFD ; les détails portent
// alors aussi octets, une copie
// des octets du fichier
// //
// types.js donne la table entière des codes du stockage. // types.js donne la table entière des codes du stockage.
import { ErreurStockage } from './erreurs.js'; import { ErreurStockage } from './erreurs.js';
@ -92,7 +100,10 @@ import { ErreurStockage } from './erreurs.js';
* @property {(r: Racine, chemin: string) => Promise<string>} lireTexte * @property {(r: Racine, chemin: string) => Promise<string>} lireTexte
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et * le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
* forme de normalisation comprises ; ABSENT pour un fichier absent * forme de normalisation comprises ; ABSENT pour un fichier absent
* ou un dossier * ou un dossier. Le décodage est strict : des octets qui ne sont
* pas de l'UTF-8 lèvent LECTURE {chemin, dossier, cause:
* 'UTF8_INVALIDE', octets}, octets étant un Uint8Array, copie des
* octets du fichier, que le dépôt décode ligne par ligne
* @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique * @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie * <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où * le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où

View file

@ -214,6 +214,11 @@
* réduite aux clés connues quand le * réduite aux clés connues quand le
* format est plus récent * format est plus récent
* @property {boolean} formatPlusRecent format > FORMAT : lecture seule * @property {boolean} formatPlusRecent format > FORMAT : lecture seule
* @property {{propositions: number, retenu: boolean}} manquants
* ce que les comptes des placements annoncent sans que la charge
* le porte : propositions, le nombre annoncé au-delà de la liste ;
* retenu, vrai quand il est annoncé et absent. Un tel écart ne
* refuse jamais le fichier (§ 8.9, point 3) ; le dépôt l'annonce
*/ */
/** /**
@ -337,6 +342,12 @@
* ECRITURE {chemin, dossier, cause} une écriture, un ajout, un * ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
* renommage refusés ; la * renommage refusés ; la
* cible est intacte * cible est intacte
* LECTURE {chemin, dossier, cause} une lecture refusée
* autrement que sur un
* absent, ou des octets qui
* ne sont pas de l'UTF-8,
* cause UTF8_INVALIDE, et
* octets, leur copie
* EXISTE {chemin} déplacer vers une cible * EXISTE {chemin} déplacer vers une cible
* existante * existante
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine * CHEMIN_REFUSE {chemin} chemin absolu, .., racine
@ -355,7 +366,11 @@
* verrou * verrou
* LECTURE_SEULE {raison} un geste sur un fichier * LECTURE_SEULE {raison} un geste sur un fichier
* d'un format plus récent, * d'un format plus récent,
* ou sur un plan bloqué * sur un plan bloqué, ou
* passer en écriture sur
* un état devenu aux
* octets hors UTF-8 (raison
* ENCODAGE, de la séance)
* ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal, * ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal,
* l'état n'a pas pu s'écrire * l'état n'a pas pu s'écrire
* (§ 8.2) * (§ 8.2)
@ -375,9 +390,13 @@
* l'appelant le lit avant * l'appelant le lit avant
* toute écriture * toute écriture
* *
* @typedef {'ABSENT'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible * @typedef {'ABSENT'|'ENCODAGE'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais * ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
* par analyser * par analyser
* ENCODAGE des octets qui ne sont pas de l'UTF-8 : le système de
* fichiers lève LECTURE, cause UTF8_INVALIDE, et le dépôt
* pose cette raison, comme l'import d'un fichier ; jamais
* analyser, qui ne reçoit que du texte
* VIDE zéro octet, ou des blancs seulement * VIDE zéro octet, ou des blancs seulement
* JSON le texte n'est pas du JSON * JSON le texte n'est pas du JSON
* FORME type, clé manquante ou inconnue, valeur hors de son * FORME type, clé manquante ou inconnue, valeur hors de son
@ -386,7 +405,10 @@
* table en double ou atteignant prochainsIds, tour d'une * table en double ou atteignant prochainsIds, tour d'une
* réservation hors de sa portée ; des propositions et du * réservation hors de sa portée ; des propositions et du
* retenu, seul le conteneur * retenu, seul le conteneur
* COMPTES un compte de l'en-tête contredit sa liste * COMPTES un compte de la saisie — participants, tables,
* réservations, titres — contredit sa liste ; ceux des
* propositions et du retenu ne refusent jamais (Lecture,
* manquants)
* REFERENCE réservation ou titre vers une table ou une personne * REFERENCE réservation ou titre vers une table ou une personne
* absente, siège au-delà de la capacité * absente, siège au-delà de la capacité
* FORMAT_INCONNU format présent, mais non entier ou < 1 * FORMAT_INCONNU format présent, mais non entier ou < 1
@ -403,7 +425,9 @@
* charge.participants[2].nom : clés séparées par un point, rangs * charge.participants[2].nom : clés séparées par un point, rangs
* entre crochets, une clé qui ne s'écrit pas comme un identifiant * entre crochets, une clé qui ne s'écrit pas comme un identifiant
* entre crochets en JSON ; '' pour la racine ; null quand rien ne se * entre crochets en JSON ; '' pour la racine ; null quand rien ne se
* désigne (ABSENT, VIDE, JSON) * désigne (ABSENT, ENCODAGE, VIDE, JSON) ; le chemin du journal
* quand ouvrirSecours refuse le journal de la base, d'un format plus
* récent ou aux octets hors UTF-8
* @property {number} [format] le format lu quand il dépasse FORMAT : * @property {number} [format] le format lu quand il dépasse FORMAT :
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus * FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
* récent * récent

View file

@ -710,6 +710,32 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
await refuse(fichiers.lireTexte(DOC, 'bom.csv'), 'LECTURE', { chemin: 'bom.csv', dossier: DOCUMENTS, cause: 'EACCES' }); await refuse(fichiers.lireTexte(DOC, 'bom.csv'), 'LECTURE', { chemin: 'bom.csv', dossier: DOCUMENTS, cause: 'EACCES' });
}); });
test("lireTexte décode strictement : des octets hors UTF-8 lèvent LECTURE {chemin, dossier, cause: UTF8_INVALIDE, octets}, sans rien remplacer par U+FFFD ; octets est un Uint8Array, que l'IPC transporte, et le message n'en dit que le nombre", async () => {
const { disque, fichiers } = preparer();
// Un « è » enregistré en windows-1252, l'octet E8, au milieu d'un texte.
const octets = new Uint8Array([0x7b, 0x22, 0x69, 0xe8, 0x22, 0x7d]);
disque.deposer(`${DOCUMENTS}/archives/soiree.gtt.json`, octets);
const refus = await refuse(fichiers.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE', {
chemin: 'archives/soiree.gtt.json',
dossier: `${DOCUMENTS}/archives`,
cause: 'UTF8_INVALIDE',
octets,
});
assert.equal(Object.getPrototypeOf(refus.details.octets), Uint8Array.prototype);
assert.equal(
refus.message,
`LECTURE {"chemin":"archives/soiree.gtt.json","dossier":"${DOCUMENTS}/archives","cause":"UTF8_INVALIDE","octets":"6 octets"}`,
);
// Une suite tronquée en fin de fichier n'est pas de l'UTF-8 non plus.
disque.deposer(`${DOCUMENTS}/tronque.jsonl`, new Uint8Array([0x61, 0xc3]));
await refuse(fichiers.lireTexte(DOC, 'tronque.jsonl'), 'LECTURE', {
chemin: 'tronque.jsonl',
dossier: DOCUMENTS,
cause: 'UTF8_INVALIDE',
octets: new Uint8Array([0x61, 0xc3]),
});
});
test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => { test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => {
const { disque, fichiers } = preparer(); const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/b.txt`, 'bb'); disque.deposer(`${DOCUMENTS}/b.txt`, 'bb');

View file

@ -53,8 +53,25 @@ const REESSAIS_RENOMMAGE = 10;
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1); const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
const PAS_HORLOGE = 1000; const PAS_HORLOGE = 1000;
// La taille d'un fichier compte les octets de son texte en UTF-8. // La taille d'un fichier compte les octets de son texte en UTF-8. Un fichier
// porte un texte, ou les octets que deposerOctets a posés tels quels : ceux-ci
// se décodent strictement, comme la coquille et la plateforme web les
// décodent, la marque d'ordre d'octets gardée dans le texte.
const UTF8 = new TextEncoder(); const UTF8 = new TextEncoder();
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// Le texte d'un fichier, ou null quand ses octets ne sont pas de l'UTF-8.
function texteDuNoeud({ texte }) {
if (typeof texte === 'string') return texte;
try {
return UTF8_STRICT.decode(texte);
} catch {
return null;
}
}
// Les octets d'un fichier, copiés.
const octetsDuNoeud = ({ texte }) => (typeof texte === 'string' ? UTF8.encode(texte) : new Uint8Array(texte));
// État de chaque disque, hors de sa Map : la casse et le séparateur de sa // État de chaque disque, hors de sa Map : la casse et le séparateur de sa
// première instance, son horloge, le prochain pid, et ses processus par pid. // première instance, son horloge, le prochain pid, et ses processus par pid.
@ -151,12 +168,20 @@ const estContenuVerrou = (contenu) =>
* lister), refusés compris ; les autres ne comptent pas ; * lister), refusés compris ; les autres ne comptent pas ;
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un * - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
* fichier, dossiers parents compris, sans compter ni avancer l'horloge ; * fichier, dossiers parents compris, sans compter ni avancer l'horloge ;
* - deposerOctets(racineId, chemin, octets, modifie = l'instant courant) :
* pose un fichier de ces octets, tels quels, de même ; lireTexte les
* décode strictement et lève LECTURE {chemin, dossier, cause:
* 'UTF8_INVALIDE', octets} quand ils ne sont pas de l'UTF-8, octets en
* étant une copie, et un ajout en fin les garde ;
* - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris, * - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris,
* de même ; * de même ;
* - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null * - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null
* pour un absent ou un dossier ; TypeError pour des octets hors UTF-8 ;
* - octets(racineId, chemin) : les octets d'un fichier, sans compter ; null
* pour un absent ou un dossier. * pour un absent ou un dossier.
* deposer, deposerDossier et contenu servent encore après une coupure : ils * deposer, deposerOctets, deposerDossier, contenu et octets servent encore
* sont les mains de l'épreuve sur le disque, non celles du processus. * après une coupure : ils sont les mains de l'épreuve sur le disque, non
* celles du processus.
*/ */
export function creerFichiersSimules({ export function creerFichiersSimules({
disque = new Map(), disque = new Map(),
@ -287,11 +312,11 @@ export function creerFichiersSimules({
return panne.cause; return panne.cause;
} }
// Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa // Ce que rend verrouiller d'un verrou présent, d'après son texte, null pour
// prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le // des octets hors UTF-8 : sa séance, l'instant de sa prise, et vivant —
// disque porte un processus de cet hôte sous ce pid, et que ce processus // null pour un autre hôte ; pour celui-ci, vrai quand le disque porte un
// n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et // processus de cet hôte sous ce pid, et que ce processus n'est pas coupé.
// vivant à null. // Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
function verrouPresent(texte) { function verrouPresent(texte) {
let contenu; let contenu;
try { try {
@ -367,10 +392,19 @@ export function creerFichiersSimules({
return Object.hasOwn(support, id) ? support[id] : 'fixe'; return Object.hasOwn(support, id) ? support[id] : 'fixe';
}), }),
// Des octets déposés qui ne sont pas de l'UTF-8 lèvent LECTURE, cause
// UTF8_INVALIDE, qui en porte une copie : aucun octet ne se remplace en
// silence par U+FFFD.
lireTexte: primitive('lectures', (racine, chemin) => { lireTexte: primitive('lectures', (racine, chemin) => {
const noeud = disque.get(localiser(racine, chemin).cle); const loc = localiser(racine, chemin);
const noeud = disque.get(loc.cle);
if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin }); if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin });
return noeud.texte; const texte = texteDuNoeud(noeud);
if (texte === null) {
const octets = octetsDuNoeud(noeud);
throw new ErreurStockage('LECTURE', { chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE', octets });
}
return texte;
}), }),
ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => { ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => {
@ -405,7 +439,9 @@ export function creerFichiersSimules({
exigerDossierParent(loc); exigerDossierParent(loc);
exigerPasDossier(loc); exigerPasDossier(loc);
const avant = disque.get(loc.cle)?.texte ?? ''; const avant = disque.get(loc.cle)?.texte ?? '';
poserFichier(loc, `${avant}${ligne}\n`, avancer()); const ajout = `${ligne}\n`;
const texte = typeof avant === 'string' ? avant + ajout : new Uint8Array([...avant, ...UTF8.encode(ajout)]);
poserFichier(loc, texte, avancer());
}), }),
lister: primitive('lectures', (racine, dossier) => { lister: primitive('lectures', (racine, dossier) => {
@ -414,7 +450,7 @@ export function creerFichiersSimules({
const entrees = []; const entrees = [];
for (const noeud of disque.values()) { for (const noeud of disque.values()) {
if (noeud.parent !== loc.cle) continue; if (noeud.parent !== loc.cle) continue;
const taille = noeud.type === 'fichier' ? UTF8.encode(noeud.texte).length : 0; const taille = noeud.type === 'fichier' ? octetsDuNoeud(noeud).length : 0;
entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie }); entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie });
} }
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0)); return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
@ -460,7 +496,7 @@ export function creerFichiersSimules({
exigerDossierParent(loc); exigerDossierParent(loc);
exigerPasDossier(loc); exigerPasDossier(loc);
const present = disque.get(loc.cle); const present = disque.get(loc.cle);
if (present !== undefined) return verrouPresent(present.texte); if (present !== undefined) return verrouPresent(texteDuNoeud(present));
const prise = avancer(); const prise = avancer();
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise); poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
return { pris: true }; return { pris: true };
@ -472,7 +508,7 @@ export function creerFichiersSimules({
if (!verrouDisponible) return; if (!verrouDisponible) return;
exigerInscriptible(loc); exigerInscriptible(loc);
const present = disque.get(loc.cle); const present = disque.get(loc.cle);
if (present?.type === 'fichier' && verrouPresent(present.texte).seance === seance) disque.delete(loc.cle); if (present?.type === 'fichier' && verrouPresent(texteDuNoeud(present)).seance === seance) disque.delete(loc.cle);
}), }),
ouvrirDansExplorateur: primitive(null, (racine) => { ouvrirDansExplorateur: primitive(null, (racine) => {
@ -509,6 +545,14 @@ export function creerFichiersSimules({
poserFichier(loc, texte, modifie); poserFichier(loc, texte, modifie);
}, },
deposerOctets(racineId, chemin, octets, modifie = maintenant()) {
if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array");
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length - 1, maintenant);
exigerPasDossier(loc);
poserFichier(loc, new Uint8Array(octets), modifie);
},
deposerDossier(racineId, chemin = '') { deposerDossier(racineId, chemin = '') {
const loc = localiser({ id: racineId }, chemin); const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length, maintenant); creerDossiers(loc, loc.parties.length, maintenant);
@ -516,7 +560,15 @@ export function creerFichiersSimules({
contenu(racineId, chemin) { contenu(racineId, chemin) {
const noeud = disque.get(localiser({ id: racineId }, chemin).cle); const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
return noeud?.type === 'fichier' ? noeud.texte : null; if (noeud?.type !== 'fichier') return null;
const texte = texteDuNoeud(noeud);
if (texte === null) throw new TypeError(`contenu : ${chemin} porte des octets hors UTF-8, que rend octets`);
return texte;
},
octets(racineId, chemin) {
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
return noeud?.type === 'fichier' ? octetsDuNoeud(noeud) : null;
}, },
}; };
} }

View file

@ -556,6 +556,64 @@ describe('fichiers simulés : compteurs, préparation, horloge', () => {
}); });
}); });
describe('fichiers simulés : octets déposés et décodage strict', () => {
// « Selvi » puis l'octet E8, un « è » enregistré en windows-1252 : une suite
// qui n'est pas de l'UTF-8.
const HORS_UTF8 = new Uint8Array([0x7b, 0x22, 0x53, 0x65, 0x6c, 0x76, 0x69, 0xe8, 0x22, 0x7d]);
test("lireTexte refuse des octets qui ne sont pas de l'UTF-8 : LECTURE {chemin, dossier, cause: UTF8_INVALIDE, octets}, comptée, ses octets une copie dont le message ne dit que le nombre ; lister en donne la taille, octets les rend tels quels", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'archives/soiree.gtt.json', HORS_UTF8);
await echoue(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE', {
chemin: 'archives/soiree.gtt.json',
dossier: `${DOCUMENTS}\\archives`,
cause: 'UTF8_INVALIDE',
octets: HORS_UTF8,
});
const refus = await rejette(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE');
assert.equal(
refus.message,
`LECTURE {"chemin":"archives/soiree.gtt.json","dossier":${JSON.stringify(`${DOCUMENTS}\\archives`)},"cause":"UTF8_INVALIDE","octets":"10 octets"}`,
);
refus.details.octets[0] = 0;
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 2 });
assert.deepEqual(await fs.lister(DOC, 'archives'), [
{ nom: 'soiree.gtt.json', type: 'fichier', taille: 10, modifie: (await fs.lister(DOC, 'archives'))[0].modifie },
]);
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
assert.equal(erreurDe(() => fs.contenu('documents', 'archives/soiree.gtt.json')).name, 'TypeError');
});
test("des octets UTF-8 déposés se lisent tels quels, marque d'ordre d'octets comprise ; un ajout en fin garde les octets d'avant, une écriture atomique les remplace", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'bom.csv', new Uint8Array([0xef, 0xbb, 0xbf, 0xc3, 0xa9, 0x0d, 0x0a]));
assert.equal(await fs.lireTexte(DOC, 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
assert.equal(fs.contenu('documents', 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
fs.deposerOctets('documents', 'journal.jsonl', HORS_UTF8);
await fs.ajouterLigne(DOC, 'journal.jsonl', '\u{E9}');
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([...HORS_UTF8, 0xc3, 0xa9, 0x0a]));
await fs.ecrireAtomique(DOC, 'journal.jsonl', 'neuf');
assert.equal(await fs.lireTexte(DOC, 'journal.jsonl'), 'neuf');
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([0x6e, 0x65, 0x75, 0x66]));
assert.equal(fs.octets('documents', 'absent.txt'), null);
assert.equal(erreurDe(() => fs.deposerOctets('documents', 'x.txt', 'x')).name, 'TypeError');
});
test("un verrou aux octets hors UTF-8 se lit comme un verrou illisible", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'soiree.gtt.verrou', HORS_UTF8);
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: null,
depuis: null,
vivant: null,
});
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
assert.deepEqual(fs.octets('documents', 'soiree.gtt.verrou'), HORS_UTF8);
});
});
describe('fichiers simulés : préconditions et dossiers', () => { describe('fichiers simulés : préconditions et dossiers', () => {
test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => { test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => {
const fs = preparer(); const fs = preparer();