Compare commits

...

3 commits

Author SHA1 Message Date
8d03d5ba41 [FIX] electron: lock liveness only when certain, retries on every write
A lock judged by its pid alone took a reused pid for a live session after a
reboot, and the event stayed locked. The lock now records the process start
time; a session is dead only when that is certain — no process with the pid,
or two known start times that differ — and alive only when they match.
Otherwise liveness is unknown and the session is not taken over, so two
sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY
and EACCES that guarded only the rename now guards appends, moves and
deletes too, through one helper.
Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC.

--- FR ---

[FIX] electron : vie du verrou sûre, réessais sur chaque écriture

Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance
vivante après un redémarrage, et l'événement restait verrouillé. Le verrou
inscrit désormais l'heure de démarrage du processus ; une séance n'est morte
que si c'est certain — aucun processus de ce pid, ou deux heures connues qui
diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue
et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble
(§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne
gardaient que le renommage, gardent aussi ajouts, déplacements et
suppressions, par une seule aide.
Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC.

Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
c72215a2da [FIX] csv: update matching, stray quotes, preview by import mode
An update file without a prénom or appartenance column matched nobody who
had one, and added everyone again. Prénom and appartenance now narrow the
match only when both the line and the participant carry them. A quote that
opened a field and closed anywhere later swallowed records and could merge
two people; a closing quote not followed by a separator or a record end now
refuses the file with GUILLEMET_OUVERT. The preview takes the import mode,
so in replace mode no line is shown as a duplicate of someone deleted.
Checked: 1519 node tests, the new cases red on the previous code.

--- FR ---

[FIX] csv : rapprochement, guillemet égaré, aperçu selon le mode

Un fichier de mise à jour sans colonne prénom ou appartenance ne retrouvait
personne qui en avait une, et ajoutait tout le monde de nouveau. Prénom et
appartenance ne restreignent désormais le rapprochement que s'ils sont des
deux côtés. Un guillemet qui ouvrait un champ et se fermait plus loin avalait
des lignes et pouvait fondre deux personnes ; un guillemet fermant que ne
suit ni séparateur ni fin d'enregistrement refuse le fichier (GUILLEMET_OUVERT).
L'aperçu prend le mode : en remplacement, aucune ligne n'est un doublon d'une
personne supprimée.
Vérifié : 1519 épreuves node ; les nouveaux cas, rouges sur l'ancien code.

Assisted-by: Claude Opus 5.5
2026-10-07 01:20:50 -04:00
0bc48fa1ae [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
2026-10-07 01:20:42 -04:00
32 changed files with 3960 additions and 484 deletions

View file

@ -29,8 +29,10 @@
//
// 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
// dossier illisible n'est pas un dossier vide —, et NON_DISPONIBLE {cause},
// l'explorateur du système qui ne s'ouvre pas.
// dossier illisible n'est pas un dossier vide —, ou un fichier dont les
// 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 { realpath as realpathRappel } from 'node:fs';
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
@ -73,13 +75,15 @@ export const TEMOIN = '.gtt-temoin';
const TEXTE_TEMOIN = 'T\u{E9}moin d\u{2019}\u{E9}criture de Gestion table tournante Libre.\n';
/**
* Le renommage de l'écriture atomique se réessaie, après son premier essai,
* REESSAIS_RENOMMAGE fois sur une cause passagère : un antivirus ou un agent
* de synchronisation qui tient la cible ouverte sous Windows (EPERM, EBUSY,
* EACCES). La pause entre deux essais part de PAUSE_INITIALE_MS et double
* jusqu'à PAUSE_MAX_MS : 3,75 s d'attente au plus avant de renoncer.
* Un appel système qu'un antivirus ou un agent de synchronisation peut
* refuser sous Windows, tant qu'il tient le fichier ouvert (EPERM, EBUSY,
* EACCES), se réessaie, après son premier essai, REESSAIS_PASSAGERS fois :
* le renommage de l'écriture atomique, l'ouverture en ajout d'ajouterLigne,
* le renommage de deplacer, l'effacement de supprimer. La pause entre deux
* essais part de PAUSE_INITIALE_MS et double jusqu'à PAUSE_MAX_MS : 3,75 s
* d'attente au plus avant de renoncer.
*/
export const REESSAIS_RENOMMAGE = 10;
export const REESSAIS_PASSAGERS = 10;
export const PAUSE_INITIALE_MS = 50;
export const PAUSE_MAX_MS = 500;
const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']);
@ -92,6 +96,17 @@ export const DELAI_SUPPORT_MS = 5000;
// une apostrophe le ferait lire autrement.
const COMMANDE_LECTEUR = '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType';
// Lit l'heure de création du processus dont la variable GTT_PID porte le
// pid, en FILETIME UTC : un entier, le même à chaque lecture. Un processus
// absent, ou d'un autre compte — système, service, élevé — que .NET ne peut
// ouvrir, ne rend aucun nombre : la vie de la séance reste alors inconnue.
const COMMANDE_DEMARRAGE = '(Get-Process -Id $env:GTT_PID -ErrorAction Stop).StartTime.ToFileTimeUtc()';
// Dans /proc/<pid>/stat, le démarrage est le champ 22 ; les champs se
// comptent après la parenthèse fermante du nom, le champ 2, qui peut porter
// des espaces et des parenthèses : le champ 3 y a le rang 0.
const RANG_DEMARRAGE_STAT = 22 - 3;
// La règle des segments d'exigerCheminRelatif (src/stockage/systeme_fichiers.js),
// recopiée : un segment ne finit ni par un point ni par une espace — « . »,
// « .. » et ce que Windows y ramène —, et ne porte ni barre oblique inverse,
@ -104,9 +119,18 @@ const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u;
const ABSENCE = new Set(['ENOENT', 'ENOTDIR']);
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
* 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 {
/**
@ -114,7 +138,7 @@ export class RefusFichier extends Error {
* @param {Object} details
*/
constructor(code, details) {
super(`${code} ${JSON.stringify(details)}`);
super(`${code} ${JSON.stringify(details, sansOctets)}`);
this.code = code;
this.details = details;
}
@ -229,9 +253,11 @@ function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Le contenu d'un verrou, {seance, pid, hote, depuis}, ou null quand le texte
// n'en est pas un : JSON invalide, champ manquant ou de type faux, pid qui
// ne désigne pas un processus — 0 et les négatifs désignent des groupes.
// Le contenu d'un verrou, {seance, pid, hote, depuis, demarrage}, ou null
// quand le texte n'en est pas un : JSON invalide, champ manquant ou de type
// faux, pid qui ne désigne pas un processus — 0 et les négatifs désignent
// des groupes. demarrage est une chaîne, null quand il est inconnu ; un
// verrou qui ne le porte pas le laisse inconnu. Une autre clé ne compte pas.
function analyserVerrou(texte) {
let contenu;
try {
@ -245,8 +271,9 @@ function analyserVerrou(texte) {
Number.isSafeInteger(contenu.pid) &&
contenu.pid > 0 &&
typeof contenu.hote === 'string' &&
typeof contenu.depuis === 'string';
return valide ? contenu : null;
typeof contenu.depuis === 'string' &&
(contenu.demarrage === undefined || contenu.demarrage === null || typeof contenu.demarrage === 'string');
return valide ? { ...contenu, demarrage: contenu.demarrage ?? null } : null;
}
// Un instant sous la forme de l'horloge de l'application,
@ -304,6 +331,9 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
* delai}) → la sortie standard de la commande
* @param {(ms: number) => Promise<void>} [reglages.attendre]
* @param {(pid: number) => void} [reglages.signaler] celui de processusVivant
* @param {(pid: number) => Promise<string|null>} [reglages.demarrage]
* l'instant de démarrage d'un processus, null quand il ne se lit pas ;
* par défaut, celui que lit la plateforme
* @param {number} [reglages.pid]
* @param {string} [reglages.hote]
* @param {() => Date} [reglages.maintenant]
@ -321,6 +351,7 @@ export function creerFichiers({
lancer = lancerCommande,
attendre = pause,
signaler,
demarrage,
pid = process.pid,
hote = hostname(),
maintenant = () => new Date(),
@ -330,6 +361,9 @@ export function creerFichiers({
const bases = new Map([['documents', documents]]);
if (portable !== null) bases.set('portable', portable);
let choisis = 0;
const demarrageDe = demarrage ?? lireDemarrage;
// Le démarrage du processus courant, lu une fois qu'il est connu.
let demarrageCourant = null;
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
@ -450,16 +484,17 @@ export function creerFichiers({
}
}
// Renomme, et réessaie sur une cause passagère, après une pause qui
// double ; la dernière erreur se lève.
async function renommer(de, vers) {
// Rend ce que rend l'opération, et la réessaie sur une cause passagère,
// après une pause qui double ; une autre cause, ou la dernière erreur des
// réessais, se lève. L'opération ne doit rien laisser derrière un échec :
// elle se rejoue entière.
async function reessayer(operation) {
let attente = PAUSE_INITIALE_MS;
for (let essai = 0; ; essai += 1) {
try {
await fs.rename(de, vers);
return;
return await operation();
} catch (erreur) {
if (essai === REESSAIS_RENOMMAGE || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur;
if (essai === REESSAIS_PASSAGERS || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur;
}
await attendre(attente);
attente = Math.min(2 * attente, PAUSE_MAX_MS);
@ -477,13 +512,74 @@ export function creerFichiers({
const poignee = await fs.open(temporaire, 'wx');
try {
await ecrireDans(poignee, octets);
await renommer(temporaire, absolu);
await reessayer(() => fs.rename(temporaire, absolu));
} catch (erreur) {
await effacer(temporaire).catch(() => {});
throw erreur;
}
}
// L'instant de démarrage d'un processus de ce poste, tel que la plateforme
// le donne sans module natif : sous Linux le champ 22 de /proc/<pid>/stat,
// en tops d'horloge depuis l'amorçage ; sous Windows l'heure de création
// que lit PowerShell ; sous macOS la colonne lstart de ps, en locale C et
// en temps universel — ps l'écrit sinon dans le fuseau du poste, qui peut
// changer entre la prise du verrou et sa relecture. Une
// lecture qui échoue, une sortie de forme fausse ou une autre plateforme
// rendent null. Un pid réattribué, après un redémarrage, désigne un
// processus d'un autre démarrage.
async function lireDemarrage(cible) {
try {
if (plateforme === 'linux') {
const texte = (await fs.readFile(`/proc/${cible}/stat`)).toString('utf8');
const fin = texte.lastIndexOf(')');
if (fin < 0) return null;
const valeur = texte.slice(fin + 1).trim().split(/\s+/)[RANG_DEMARRAGE_STAT];
return /^\d+$/.test(valeur ?? '') ? valeur : null;
}
if (plateforme === 'win32') {
const sortie = await lancer('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', COMMANDE_DEMARRAGE], {
variables: { GTT_PID: String(cible) },
delai: DELAI_SUPPORT_MS,
});
const valeur = String(sortie).trim();
return /^\d+$/.test(valeur) ? valeur : null;
}
if (plateforme === 'darwin') {
const sortie = await lancer('ps', ['-o', 'lstart=', '-p', String(cible)], {
variables: { LC_ALL: 'C', TZ: 'UTC' },
delai: DELAI_SUPPORT_MS,
});
const valeur = String(sortie).trim();
return valeur === '' ? null : valeur;
}
} catch {
// Un processus disparu, une commande qui échoue ou dépasse son délai :
// le démarrage reste inconnu.
}
return null;
}
// Le démarrage du processus courant ; un inconnu se relit à l'appel
// suivant, un connu non.
async function monDemarrage() {
if (demarrageCourant === null) demarrageCourant = await demarrageDe(pid);
return demarrageCourant;
}
// La vie de la séance d'un verrou de ce poste. Faux seulement quand sa
// mort est certaine : aucun processus de ce pid ne répond, ou le
// démarrage inscrit et celui du processus actuel sont connus tous deux et
// diffèrent — le pid est passé à un autre processus. Vrai quand les deux
// démarrages sont connus et égaux. Null sinon : un démarrage inconnu d'un
// côté ou de l'autre ne prouve ni la vie ni la mort, et une séance dite
// morte à tort perdrait son verrou au profit d'un second écrivain.
async function seanceVivante(contenu) {
if (!processusVivant(contenu.pid, signaler)) return false;
const actuel = contenu.demarrage === null ? null : await demarrageDe(contenu.pid);
return actuel === null ? null : actuel === contenu.demarrage;
}
// Ce que rend verrouiller d'un verrou présent, ou null quand il a disparu
// depuis. Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
async function verrouPresent(absolu) {
@ -500,7 +596,7 @@ export function creerFichiers({
pris: false,
seance: contenu.seance,
depuis: contenu.depuis,
vivant: contenu.hote === hote ? processusVivant(contenu.pid, signaler) : null,
vivant: contenu.hote === hote ? await seanceVivante(contenu) : null,
};
}
@ -598,12 +694,19 @@ export function creerFichiers({
async lireTexte(racine, chemin) {
const loc = localiser(racine, chemin);
let octets;
try {
await confiner(loc);
return (await fs.readFile(loc.absolu)).toString('utf8');
octets = await fs.readFile(loc.absolu);
} catch (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
@ -622,13 +725,15 @@ export function creerFichiers({
},
// Ouvre en ajout — le fichier absent se crée, son dossier jamais —,
// écrit la ligne et sa fin de ligne, vide sur le disque et ferme.
// écrit la ligne et sa fin de ligne, vide sur le disque et ferme. Seule
// l'ouverture se réessaie : une écriture refusée a pu laisser une partie
// de la ligne, qu'un second essai prolongerait.
async ajouterLigne(racine, chemin, ligne) {
exigerLigne(ligne);
const loc = localiser(racine, chemin);
try {
await confiner(loc);
await ecrireDans(await fs.open(loc.absolu, 'a'), `${ligne}\n`);
await ecrireDans(await reessayer(() => fs.open(loc.absolu, 'a')), `${ligne}\n`);
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
@ -678,7 +783,7 @@ export function creerFichiers({
}
},
// Un fichier, en un seul essai. rename écrase une cible existante sous
// Un fichier. rename écrase une cible existante sous
// POSIX : sa présence se contrôle d'abord, comme le système compare les
// noms, si bien que sans égard à la casse une autre casse du même nom
// est une cible présente. Un dossier absent sous la cible nomme la
@ -703,7 +808,7 @@ export function creerFichiers({
throw echecEcriture(cible, erreur);
}
try {
await fs.rename(source.absolu, cible.absolu);
await reessayer(() => fs.rename(source.absolu, cible.absolu));
} catch (erreur) {
throw echecEcriture(source, erreur);
}
@ -715,21 +820,26 @@ export function creerFichiers({
await confiner(loc);
const etat = await etatDe(loc.absolu);
if (etat?.isDirectory()) throw refus(loc, 'EISDIR');
if (etat !== null) await effacer(loc.absolu);
if (etat !== null) await reessayer(() => effacer(loc.absolu));
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Création exclusive (wx), qui ne suit aucun lien. Un verrou présent se
// lit ; s'il disparaît entre la création refusée et sa lecture, la prise
// se tente une seconde fois. Un verrou écrit à moitié s'efface.
// Création exclusive (wx), qui ne suit aucun lien, d'un verrou qui
// inscrit la séance, le pid, l'hôte, l'instant de la prise et le
// démarrage du processus, lus avant la création : la création et
// l'écriture se suivent sans attente. Un verrou présent
// se lit ; s'il disparaît entre la création refusée et sa lecture, la
// prise se tente une seconde fois. Un verrou écrit à moitié s'efface.
async verrouiller(racine, chemin, seance) {
exigerSeance(seance);
const loc = localiser(racine, chemin);
try {
await confiner(loc);
const contenu = `${JSON.stringify({ seance, pid, hote, depuis: horodatage(maintenant()) })}\n`;
const demarrageInscrit = await monDemarrage();
const inscrit = { seance, pid, hote, depuis: horodatage(maintenant()), demarrage: demarrageInscrit };
const contenu = `${JSON.stringify(inscrit)}\n`;
for (let essai = 0; ; essai += 1) {
let poignee;
try {

View file

@ -29,7 +29,7 @@ import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.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';
/**
@ -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
* 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'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
* 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 }) {
exigerModifiable(charge);
@ -284,11 +310,17 @@ function importerParticipants(charge, { apercu, mode }) {
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
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 {
charge: resultat.charge,
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,
};
}
@ -418,11 +450,14 @@ export const COMMANDES = new Map([
* 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
* 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 {Object} charge
* @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 {TypeError} une commande inconnue, un argument de forme fausse
*/
@ -430,10 +465,45 @@ export function appliquerCommande(nom, charge, args = {}) {
const commande = COMMANDES.get(nom);
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
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 });
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
* 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 { rechercher } from '../moteur/recherche.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 { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve } from './commandes.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
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', () => {
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', {
commande: 'importerParticipants',
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', () => {
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' });
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
assert.deepEqual(remplace.charge.reservations, []);
@ -264,7 +264,7 @@ describe("commandes : l'import (§ 10.1)", () => {
// intervertirait ne rendrait pas.
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
const misAJour = executer('importerParticipants', avant, {
apercu: apercevoir(csv, { participants: avant.participants }),
apercu: apercevoir(csv, { participants: avant.participants, mode: 'mettreAJour' }),
mode: 'mettreAJour',
});
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 retenue = chargeRiche();
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', proposee, ajout(proposee)).avertissements, []);
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
// 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' });
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
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)', () => {
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
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
// 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
// un code brut. Une valeur codée d'un détail — raison, état, champ, mode,
// échec — que la table ne connaît pas lève aussi, au lieu de se taire. Une
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
// 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
// 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.
@ -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 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.
function raisonIllisible({ raison, ...details }) {
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.
@ -181,7 +198,7 @@ const TEXTES = new Map([
);
}
return (
`Un verrou d'un autre poste, ou illisible, tient cet événement${depuisQuand(depuis)} : ` +
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis)} : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
);
},
@ -195,7 +212,14 @@ const TEXTES = new Map([
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
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}.`;
},
],
[
'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) ----
[
@ -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'}, ` +
'à 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',
({ 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.",
],
[
'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',
({ evenement }) =>
@ -307,6 +347,12 @@ const TEXTES = new Map([
? `Une proposition incohérente est écartée (${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',
(details) => {
@ -353,11 +399,13 @@ const TEXTES = new Map([
"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.',
],
// dossier, quand il est donné, nomme le dossier où la séance écrit
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
[
'SUPPORT_AMOVIBLE',
() =>
'Le dossier de travail est sur un support amovible : ' +
'retirer le support pendant une écriture peut la laisser incomplète.',
({ dossier }) =>
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
],
[
'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."],
// 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',
({ 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]
* @returns {string}
* @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 = {}) {
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']);
});
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', () => {
const { parArbre, tables } = releverArbre();
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' }],
['FORMAT_INCONNU', { chemin: 'entete.format' }],
['FORMAT_PLUS_RECENT', { format: 2 }],
['ENCODAGE', {}],
// Fautes d'un placement
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
['DOUBLON', { tour: 1, participant: 3 }],
@ -225,9 +237,12 @@ const EXEMPLES = new Map([
['JOURNAL_EN_AVANCE_DE', { n: 3 }],
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
['JOURNAL_ECARTEES', { n: 64 }],
['JOURNAL_RECREE', {}],
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
['JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' }],
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
['PROPOSITIONS_MANQUANTES', { n: 2 }],
['DERIVE', { ids: [1, 2], retenu: true }],
// Dossier de travail et réglages locaux
['NON_PUBLIE', {}],
@ -265,6 +280,8 @@ const EXEMPLES = new Map([
['BLOQUE', {}],
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['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_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
['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 »', () => {
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« 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 } };
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
}
assert.throws(() => libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null }), RangeError);
assert.throws(() => libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' }), RangeError);
const encodage = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'ENCODAGE', chemin: null, secours: { precedent: 4, journal: null } });
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", () => {

View file

@ -18,11 +18,25 @@
// 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
// 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
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
// 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
// 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
@ -31,7 +45,14 @@
// 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à
// 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,
// 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';
import { ErreurStockage } from '../stockage/erreurs.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';
/** 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';
// 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.
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
@ -122,6 +147,9 @@ export function creerSeance({
const libelleReprise = libelle('ENTREE_REPRISE');
// 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 :
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
@ -130,12 +158,12 @@ export function creerSeance({
const etat = {
racine,
ouvert: null,
source: null,
mode: 'lecture',
verrouTenu: false,
supportRompu: false,
refusSupport: null,
verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [],
gestes: 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
// retour explicite annule en plus le geste ouvert. Les avertissements du
// rendu s'ajoutent à ceux du dernier geste.
@ -256,6 +350,15 @@ export function creerSeance({
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'ordre de l'en-tête du module.
function exigerEcriture() {
@ -311,6 +414,7 @@ export function creerSeance({
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
etat.source = null;
const suite = [...avertissements];
const etatDuPlan = ouvert.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
// commande d'abord, qui ne touche rien ; puis le verrou, que la séance
// prend en lecture ; puis l'entrée et le jalon de l'état rendu. La séance
// reste en écriture.
// commande d'abord, qui ne touche rien ; puis, en lecture, le verrou, que
// la séance prend, et la relecture de l'événement (relireSousVerrou) ; la
// 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) {
exigerSupport();
const resultat = appliquerCommande('debloquer', ouvert.charge, args);
const avertissements = etat.mode === 'ecriture' ? [] : await prendreVerrou(ouvert);
let resultat = appliquerCommande('debloquer', ouvert.charge, args);
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';
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
// 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) {
const ouvert = exigerOuvert();
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
@ -360,6 +482,10 @@ export function creerSeance({
}
exigerEcriture();
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 texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
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);
Object.assign(etat, {
ouvert: null,
source: null,
mode: 'lecture',
verrouTenu: false,
supportRompu: false,
refusSupport: null,
verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [],
gestes: 0,
calculs: 0,
@ -475,12 +601,13 @@ export function creerSeance({
}
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
// fermé le précédent.
async function ouvrirAvec(lire) {
// fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
async function ouvrirAvec(lire, source, quitter) {
exigerSauve(quitter);
await fermerEvenement();
const ouvert = await lire();
etat.ouvert = ouvert;
etat.avertissementsOuverture = ouvert.avertissements;
etat.source = source;
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
// 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
// que le support refuse d'écrire, le journal écrit, rompt le support.
async function creerEvenement(champs) {
// que le support refuse d'écrire, le journal écrit, rompt le support. Le
// 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);
await fermerEvenement();
const options = {
@ -505,12 +634,14 @@ export function creerSeance({
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
throw erreur;
}
etat.avertissementsOuverture = etat.ouvert.avertissements;
return { base: etat.ouvert.base };
}
// --- 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() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
@ -518,6 +649,15 @@ export function creerSeance({
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
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.avertissementsGeste = avertissements;
armer();
@ -533,14 +673,20 @@ export function creerSeance({
}
// 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) {
try {
const ouvert = exigerEcriture();
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
const texte = nom.normalize('NFC').trim();
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 {
await apresGeste();
}
@ -573,7 +719,10 @@ export function creerSeance({
// 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 ;
// 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) {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
@ -595,7 +744,7 @@ export function creerSeance({
throw erreur;
}
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') {
try {
avertissements.push(...(await prendreVerrou(ailleurs)));
@ -605,11 +754,26 @@ export function creerSeance({
avertissements.push(avertissement(erreur.code, erreur.details));
}
}
if ((await typeDeSupport(nouvelle)) === 'amovible') {
avertissements.push(avertissement('SUPPORT_AMOVIBLE', { dossier: nouvelle.chemin }));
}
etat.avertissementsGeste = avertissements;
armer();
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 {
/** L'identifiant de la séance, qui signe son verrou. */
get id() {
@ -642,9 +806,9 @@ export function creerSeance({
get 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() {
return [...etat.avertissementsOuverture, ...etat.avertissementsGeste];
return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
},
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
get retours() {
@ -659,11 +823,17 @@ export function creerSeance({
return etat.calculs > 0;
},
ouvrir: (base) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base))),
ouvrirSecours: (base, source) =>
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source))),
creer: (champs) => enFile(() => creerEvenement(champs)),
fermer: () => enFile(async () => ({ avertissements: await fermerEvenement() })),
// quitter.abandonner : vrai pour quitter l'événement d'un support rompu
// (exigerSauve).
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
ouvrirSecours: (base, source, quitter) =>
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),
passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
@ -673,6 +843,11 @@ export function creerSeance({
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
nommer: (nom) => enFile(() => nommer(nom)),
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é ;
// 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 enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
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 () => {
@ -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)', () => {
// Trois gestes après la création : Ancolie, Bruyère, Capucine, aux
// 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)', () => {
// Un événement du disque portable, en écriture, après un premier geste.
async function surPortable() {
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
async function surPortable(reglages = {}) {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages });
const montage = monter({ fs, racine: PORTABLE });
await enEcriture(montage);
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]);
});
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 () => {
const { fs, seance } = await surPortable();
const avant = fs.compteurs.ecritures;

View file

@ -22,7 +22,11 @@
// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne,
// valide ou refusée pour un seul motif ; zéro ligne valide refuse ;
// 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent.
// existants, et les doublons se relèvent ; en mode remplacer, qui
// retire chaque existant, avec ceux du fichier seuls ;
// 8. en mode mettreAJour, les lignes ajoutées à côté d'un existant de même
// nom se nomment ; dans chaque mode, les cellules qui s'étendent sur
// plusieurs lignes du texte et portent le séparateur.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1).
@ -32,6 +36,13 @@ import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
/**
* Modes de l'import (§ 10.1), que l'aperçu et l'import reçoivent tous deux.
*
* @type {readonly string[]}
*/
export const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']);
/**
* Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste
* fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce
@ -69,6 +80,9 @@ export const VALEURS_EXCLU = new Map([
* la valeur d'une personne existante qu'un import met à jour
*
* @typedef {Object} Apercu
* @property {'ajouter'|'mettreAJour'|'remplacer'} mode le mode pour lequel
* l'aperçu est calculé : appliquerImport n'applique l'aperçu que
* dans ce mode
* @property {string|null} separateur null pour un fichier à une colonne,
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
* SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc
@ -84,8 +98,9 @@ export const VALEURS_EXCLU = new Map([
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
* pour les deux autres codes
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises,
* qui porte une ligne valide, rangé par sa première ligne ;
* chaque groupe d'au moins deux orthographes, existantes comprises
* hors du mode remplacer, qui porte une ligne valide, rangé par sa
* première ligne ;
* orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent
@ -93,10 +108,30 @@ export const VALEURS_EXCLU = new Map([
* chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première
* ligne ; cle : le triplet normalisé, en JSON ; existants :
* identifiants croissants
* identifiants croissants, toujours vide en mode remplacer
* @property {Array<{ligne: number, participants: number[]}>} homonymes
* en mode mettreAJour, chaque ligne valide qui ne désigne personne
* (designer) alors que des existants portent son nom normalisé :
* l'import l'ajoute à côté d'eux, une seconde personne de ce nom,
* parce que le prénom ou l'appartenance, présents des deux côtés,
* diffèrent ; participants : ces existants, identifiants
* croissants ; rangée par ligne. Toujours vide dans les autres
* modes : ajouter crée chaque ligne, remplacer retire chaque
* existant
* @property {Array<{ligne: number, rangs: number[]}>} multilignes
* chaque ligne valide dont une cellule au moins s'étend sur
* plusieurs lignes du texte et porte le séparateur, rangée par
* ligne ; rangs : les rangs croissants de ces cellules dans brut.
* Sans séparateur, la fin de ligne seule suffit. C'est la forme
* que laisse un guillemet égaré qu'un autre guillemet referme plus
* loin, devant le séparateur ou une fin de ligne : les
* enregistrements d'entre les deux tiennent dans la cellule, et
* deux personnes se fondent en une. Une note écrite sur plusieurs
* lignes a la même forme, et l'export l'écrit ainsi (§ 10.2) :
* l'aperçu la nomme sans refuser la ligne
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusions et doublons sont vides, et
* refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* sous un refus global, lignes, fusions, doublons, homonymes et
* multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous
@ -105,14 +140,16 @@ export const VALEURS_EXCLU = new Map([
* null sinon
*/
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
// refus, aucune ligne.
function refuser(
// Aperçu d'un texte refusé en entier : son mode, ce que la lecture a établi
// avant le refus, aucune ligne.
function refuserEn(
mode,
code,
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null,
) {
return {
mode,
separateur,
entetes,
association,
@ -120,6 +157,8 @@ function refuser(
refusees: [],
fusions: [],
doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: code,
refusGlobalLigne: ligne,
};
@ -237,9 +276,98 @@ function doublonsDe(existants, valides) {
.sort((a, b) => a.lignes[0] - b.lignes[0]);
}
// Vrai quand la valeur d'une ligne et celle d'un participant laissent ce
// participant parmi les candidats de la ligne : l'une ou l'autre est absente
// — null, ou de clé normalisée vide —, ou leurs clés normalisées sont égales.
function compatibles(deLaLigne, duParticipant) {
const cleLigne = cleNormalisee(deLaLigne ?? '');
const cleParticipant = cleNormalisee(duParticipant ?? '');
return cleLigne === '' || cleParticipant === '' || cleLigne === cleParticipant;
}
/**
* Index des participants par nom normalisé, que lit designer. Chaque entrée
* copie l'identifiant, le prénom et l'appartenance du participant : ce
* qu'une mise à jour pose ensuite sur lui ne change pas l'index, qui garde
* les valeurs d'avant l'import.
*
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} participants
* @returns {Map<string, Array<{id: number, prenom: string|null, appartenance: string|null}>>}
*/
export function indexerParNom(participants) {
const index = new Map();
for (const { id, nom, prenom, appartenance } of participants) {
const cle = cleNormalisee(nom);
if (!index.has(cle)) index.set(cle, []);
index.get(cle).push({ id, prenom, appartenance });
}
return index;
}
/**
* Participants qu'une ligne désigne en mise à jour (§ 10.1), seule règle de
* la désignation, que l'aperçu et l'import lisent tous deux. memeNom : les
* participants de l'index de même nom normalisé que la ligne. candidats :
* ceux d'entre eux que ni le prénom ni l'appartenance n'écartent ; l'un ou
* l'autre n'écarte un participant que lorsque la ligne et lui en portent
* tous deux un, de clés normalisées différentes : une colonne absente ou une
* cellule vide n'écarte personne, comme elle ne vide aucune valeur.
*
* @param {{nom: string, prenom: string|null, appartenance: string|null}} champs
* @param {ReturnType<typeof indexerParNom>} index
* @returns {{memeNom: number[], candidats: number[]}} identifiants croissants
*/
export function designer(champs, index) {
const memeNom = index.get(cleNormalisee(champs.nom)) ?? [];
const croissants = (entrees) => entrees.map(({ id }) => id).sort((a, b) => a - b);
return {
memeNom: croissants(memeNom),
candidats: croissants(
memeNom.filter(
({ prenom, appartenance }) =>
compatibles(champs.prenom, prenom) && compatibles(champs.appartenance, appartenance),
),
),
};
}
// Homonymes d'une mise à jour : chaque ligne valide que designer ne fait
// désigner à personne, alors que des participants portent son nom, avec
// leurs identifiants. L'index est celui des participants d'avant l'import :
// une ligne n'y change rien pour la suivante.
function homonymesDe(participants, lignes) {
const index = indexerParNom(participants);
const homonymes = [];
for (const { ligne, champs } of lignes) {
const { memeNom, candidats } = designer(champs, index);
if (memeNom.length > 0 && candidats.length === 0) homonymes.push({ ligne, participants: memeNom });
}
return homonymes;
}
// Cellules d'une ligne sur plusieurs lignes du texte qui portent le
// séparateur, ou toute fin de ligne sans séparateur : les lignes valides qui
// en ont, avec leurs rangs.
function multilignesDe(lignes, separateur) {
const multilignes = [];
for (const { ligne, brut } of lignes) {
const rangs = [];
brut.forEach((cellule, rang) => {
const surPlusieursLignes = cellule.includes('\n') || cellule.includes('\r');
if (surPlusieursLignes && (separateur === null || cellule.includes(separateur))) rangs.push(rang);
});
if (rangs.length > 0) multilignes.push({ ligne, rangs });
}
return multilignes;
}
/**
* Aperçu d'un texte décodé, import ou collage, contre les participants
* existants : rien n'est importé.
* existants, pour un mode de l'import : rien n'est importé. En mode
* remplacer, chaque existant est retiré avant que les lignes s'ajoutent :
* aucune ligne n'en est le doublon, et aucune orthographe existante ne
* s'affiche ni ne fond avec celles du fichier. Ajouter et mettre à jour
* lisent le fichier contre les existants.
*
* @param {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options]
@ -248,12 +376,17 @@ function doublonsDe(existants, valides) {
* ordonne leurs orthographes
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer)
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par
* défaut
* @returns {Apercu}
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder
* d'abord par decoder ; pour un mode inconnu
*/
export function apercevoir(texte, { participants = [], choix = {} } = {}) {
export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajouter' } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
// Chaque refus global porte le mode de l'aperçu.
const refuser = (...refus) => refuserEn(mode, ...refus);
if (contientNul(texte)) return refuser('CARACTERE_NUL');
let separateur;
@ -295,7 +428,7 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
}
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = [...participants].sort((a, b) => a.id - b.id);
const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides);
const lignes = valides.map(({ ligne, champs, brut }) => ({
ligne,
@ -303,11 +436,14 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
brut,
}));
return {
mode,
...lu,
lignes,
refusees,
fusions,
doublons: doublonsDe(existants, lignes),
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
multilignes: multilignesDe(lignes, separateur),
refusGlobal: null,
refusGlobalLigne: null,
};

View file

@ -53,6 +53,7 @@ const refusGlobal = (
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null,
) => ({
mode: 'ajouter',
separateur,
entetes,
association,
@ -60,6 +61,8 @@ const refusGlobal = (
refusees: [],
fusions: [],
doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: code,
refusGlobalLigne: ligne,
});
@ -94,6 +97,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
'Pervenche;Théo;;;;oui;',
);
assert.deepEqual(apercevoir(texte), {
mode: 'ajouter',
separateur: ';',
entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'],
association: {
@ -119,6 +123,8 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
refusees: [],
fusions: [],
doublons: [],
homonymes: [],
multilignes: [],
refusGlobal: null,
refusGlobalLigne: null,
});
@ -565,6 +571,89 @@ describe('apercevoir : les doublons, signalés et jamais fusionnés (§ 10.1)',
});
});
describe("apercevoir : le mode de l'import (§ 10.1)", () => {
// Deux participants existants, dont l'appartenance de l'un s'écrit
// autrement dans le fichier.
const participants = [existant(1, 'Ombrelle', 'Iris', 'Club des Merles'), existant(2, 'Lacasse', 'Ondine', null)];
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles', 'Lacasse;Ondine;', 'Lacasse;Ondine;');
test("ajouter, par défaut, et mettre à jour lisent le fichier contre les existants : doublons et fusions les comptent", () => {
for (const mode of [undefined, 'ajouter', 'mettreAJour']) {
const apercu = apercevoir(texte, { participants, mode });
assert.equal(apercu.mode, mode ?? 'ajouter');
assert.deepEqual(apercu.doublons, [
{ cle: '["ombrelle","iris","club des merles"]', lignes: [2], existants: [1] },
{ cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [2] },
], String(mode));
assert.deepEqual(apercu.fusions, [
{ affichee: 'Club des Merles', orthographes: [{ texte: 'Club des Merles', lignes: [] }, { texte: 'club des merles', lignes: [2] }] },
], String(mode));
assert.equal(apercu.lignes[0].champs.appartenance, 'Club des Merles', String(mode));
}
});
test("remplacer retire chaque existant : aucune ligne n'en est le doublon, aucune orthographe existante ne s'affiche ni ne fond", () => {
const apercu = apercevoir(texte, { participants, mode: 'remplacer' });
assert.equal(apercu.mode, 'remplacer');
assert.deepEqual(apercu.doublons, [{ cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [] }]);
assert.deepEqual(apercu.fusions, []);
assert.equal(apercu.lignes[0].champs.appartenance, 'club des merles');
assert.deepEqual(apercu, apercevoir(texte, { participants: [], mode: 'remplacer' }));
});
test("mettre à jour nomme chaque ligne qu'il ajoute à côté d'un existant de même nom, que le prénom ou l'appartenance écarte des deux côtés", () => {
// Iris passe du Club des Merles à la Société Alpha : la ligne ne désigne
// personne, et l'import ajouterait une seconde Iris Ombrelle.
const existants = [
existant(1, 'Ombrelle', 'Iris', 'Club des Merles'),
existant(2, 'Ombrelle', 'Théo', null),
existant(3, 'Lacasse', 'Ondine', null),
];
const changeDeClub = csv(
'nom;prenom;appartenance',
// Iris écarte 2, la Société Alpha écarte 1 : un homonyme de 1 et 2.
'OMBRELLE;Iris;Société Alpha',
// Désigne 3, qui n'a pas d'appartenance : aucun homonyme.
'Lacasse;Ondine;Club des Merles',
// Aucun existant de ce nom : un ajout, pas un homonyme.
'Pervenche;Théo;',
// Sans prénom ni appartenance : les deux Ombrelle, un refus de
// l'import, pas un ajout.
'Ombrelle;;',
// Désigne 2, sans prénom ni appartenance de part et d'autre.
'Ombrelle;Théo;Chorale du Givre',
);
assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode: 'mettreAJour' }).homonymes, [
{ ligne: 2, participants: [1, 2] },
]);
// Ajouter et remplacer n'en nomment aucun : ajouter crée chaque ligne,
// remplacer retire chaque existant.
for (const mode of ['ajouter', 'remplacer']) {
assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode }).homonymes, [], mode);
}
// Les lignes qui désignent un existant ne retirent rien aux candidats
// des suivantes ; les identifiants sont croissants, quel que soit
// l'ordre des existants.
const inverse = csv('nom;prenom', 'Ombrelle;Théo', 'Ombrelle;Iris', 'Ombrelle;Anouk');
assert.deepEqual(
apercevoir(inverse, { participants: [existants[1], existants[0]], mode: 'mettreAJour' }).homonymes,
[{ ligne: 4, participants: [1, 2] }],
);
});
test('un aperçu refusé porte son mode', () => {
assert.equal(apercevoir('', { mode: 'remplacer' }).mode, 'remplacer');
assert.equal(apercevoir(csv('prenom', 'Iris'), { mode: 'mettreAJour' }).mode, 'mettreAJour');
});
test('un mode inconnu : TypeError, qui nomme les trois modes', () => {
assert.throws(() => apercevoir(texte, { mode: 'fusionner' }), {
name: 'TypeError',
message: 'mode : ajouter, mettreAJour, remplacer attendu, reçu fusionner',
});
});
});
describe('apercevoir : la réconciliation et la clé de doublon, une seule forme normalisée (§ 10.1)', () => {
test('deux personnes du même nom et du même prénom forment un doublon si et seulement si leurs appartenances se fondent', () => {
// Chaque orthographe éprouve une dimension de la clé : casse, accent
@ -764,6 +853,72 @@ describe('apercevoir : les refus globaux (§ 10.1)', () => {
}
});
test("un guillemet égaré au-delà de la fenêtre, refermé plus loin sur une lettre : GUILLEMET_OUVERT au rang où il s'ouvre, aucune personne fondue dans une autre", () => {
// Vingt-cinq enregistrements : la note du rang 23 ouvre un guillemet que
// rien ne referme à sa place ; celui de « "Bob" », au rang 25, le
// referme, suivi d'une lettre. Les rangs 24 et 25 tiendraient sinon dans
// la note du rang 23, et l'aperçu compterait 22 lignes sans un refus.
const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`);
const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;surnommé "Bob" par tous');
assert.deepEqual(
apercevoir(texte),
refusGlobal(
'GUILLEMET_OUVERT',
{
separateur: ';',
entetes: ['nom', 'prenom', 'notes'],
association: { colonnes: { nom: 0, prenom: 1, notes: 2 }, ambigus: [], nonReconnues: [] },
},
23,
),
);
// Le même texte dont la note se referme à sa place s'importe en entier.
const referme = texte.replace('"VIP au fond', '"VIP au fond"');
assert.equal(apercevoir(referme).refusGlobal, null);
assert.equal(apercevoir(referme).lignes.length, 24);
});
test("un guillemet égaré refermé plus loin par un pouce, au-delà de la fenêtre : la cellule fondue est nommée, à son rang", () => {
// Le guillemet de « écran 27" » referme la note ouverte au rang 23 :
// les rangs 24 et 25 tiennent dans cette note, que rien ne refuse.
// L'aperçu nomme la cellule, qui s'étend sur plusieurs lignes du texte
// et porte le séparateur.
const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`);
const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;écran 27"', 'Nom25;Pre25;');
const apercu = apercevoir(texte);
assert.equal(apercu.refusGlobal, null);
assert.equal(apercu.lignes.length, 23);
assert.equal(apercu.lignes[21].champs.notes, 'VIP au fond\r\nNom23;Pre23;\r\nNom24;Pre24;écran 27');
assert.deepEqual(apercu.multilignes, [{ ligne: 23, rangs: [2] }]);
});
test("une cellule nommée : sur plusieurs lignes du texte et portant le séparateur, dans la fenêtre comme au-delà ; un nom sur plusieurs lignes d'un fichier à une colonne", () => {
// Dans la fenêtre, le guillemet égaré du rang 3 se referme au rang 4 :
// l'enregistrement fondu a la largeur de l'en-tête, et le séparateur
// est retenu.
const fondu = csv('nom;prenom;notes', 'Ombrelle;Iris;', 'Lacasse;Ondine;"à l\'entrée', 'Pervenche;Théo;écran 27"', 'Givre;Silas;');
assert.deepEqual(apercevoir(fondu).multilignes, [{ ligne: 3, rangs: [2] }]);
// Une note sur plusieurs lignes sans le séparateur, ou le séparateur
// sur une seule ligne, n'est pas nommée ; une cellule qui porte les
// deux l'est, une fois par cellule, quel que soit le séparateur.
const notes = csv(
'nom,prenom,appartenance,notes',
'Ombrelle,Iris,"Club, nord","arrive tôt\nrepart tard"',
'Lacasse,Ondine,"Chorale\ndu Givre","noix, arachides\r\nsans gluten"',
'Pervenche,Théo,,"au fond\rprès de la scène, côté jardin"',
);
assert.deepEqual(apercevoir(notes).multilignes, [{ ligne: 3, rangs: [3] }, { ligne: 4, rangs: [3] }]);
// Fichier à une colonne : un nom ne s'étend jamais sur deux lignes, et
// aucun séparateur n'est à chercher.
assert.deepEqual(apercevoir(csv('nom', 'Ombrelle', '"Lacasse', 'Pervenche"', 'Givre')).multilignes, [
{ ligne: 3, rangs: [0] },
]);
// Une ligne refusée revient dans le CSV des refus : elle n'est pas
// nommée ici.
const refusee = csv('nom;notes;exclu', 'Ombrelle;"a\nb;c";peut-être', 'Lacasse;;');
assert.deepEqual(apercevoir(refusee).multilignes, []);
});
test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => {
for (const rang of [2, 21]) {
const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x');

View file

@ -21,12 +21,10 @@
// champs en trop parmi les vingt premiers enregistrements, qui fait refuser
// le texte entier, à son rang, par le choix du séparateur.
import { MODES, designer, indexerParNom } from './apercu.js';
import { ErreurCsv } from './erreurs.js';
import { SEPARATEUR, ecrireCsv } from './export.js';
import { enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']);
// Champs d'une ligne de l'aperçu, dans l'ordre du participant : ceux que
// porte une personne créée, et que pose une mise à jour quand la cellule
@ -67,14 +65,8 @@ const ENTETE_MOTIF = 'motif';
* @property {boolean} derive vrai en état retenu
*/
const croissant = (a, b) => a - b;
const parLigne = (a, b) => a.ligne - b.ligne;
// Clé qui apparie une ligne aux participants existants : le nom et le
// prénom, chacun par sa clé normalisée, un prénom absent comptant comme
// vide, écrits en JSON pour qu'aucun texte ne se confonde avec une frontière.
const cleDeNom = (nom, prenom) => JSON.stringify([cleNormalisee(nom), cleNormalisee(prenom ?? '')]);
// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation
// d'une personne non exclue, sur sa place, à un tour au moins : la
// réservation d'une personne exclue est suspendue. La place se lit selon le
@ -164,56 +156,44 @@ function poser(participant, champs) {
}
// Met à jour, en place, les participants que désignent les lignes, et rend
// les lignes à ajouter, les refus et les comptes. Une ligne désigne les
// participants d'avant l'import dont le nom et le prénom ont la même clé
// normalisée que les siens, et l'appartenance aussi quand la ligne en porte
// une : un participant ajouté par une ligne n'est désigné par aucune autre.
// Un prénom absent compte comme vide, sur la ligne comme chez le
// participant : seule une ligne sans prénom désigne un participant sans
// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne
// filtre même un homonyme unique, et un participant sans appartenance ne
// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter,
// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est
// refusée PLUSIEURS_CORRESPONDENT. Le participant qu'une ligne désigne seule
// est mis à jour par la première de ces lignes, dans l'ordre du fichier ;
// chaque suivante est refusée DEJA_DESIGNE : deux lignes ne se fondent pas
// en silence dans une même personne.
// les lignes à ajouter, les refus et les comptes. Chaque ligne désigne par
// designer, contre l'index des participants d'avant l'import : un
// participant ajouté par une ligne n'est désigné par aucune autre, et le
// prénom ou l'appartenance qu'une ligne pose n'écarte pas ce participant des
// candidats d'une suivante. Une ligne qui ne désigne personne est à ajouter.
// Une ligne qui en désigne plusieurs est refusée PLUSIEURS_CORRESPONDENT,
// jamais ajoutée. Le participant qu'une ligne désigne seule est mis à jour
// par la première de ces lignes, dans l'ordre du fichier ; chaque suivante
// est refusée DEJA_DESIGNE : deux lignes ne se fondent pas en silence dans
// une même personne.
function mettreAJour(participants, lignes) {
const parNom = new Map();
for (const participant of participants) {
const cle = cleDeNom(participant.nom, participant.prenom);
if (!parNom.has(cle)) parNom.set(cle, []);
parNom.get(cle).push(participant);
}
const index = indexerParNom(participants);
const parId = new Map(participants.map((participant) => [participant.id, participant]));
const designes = new Map();
const nouvelles = [];
const refusees = [];
let misAJour = 0;
let inchanges = 0;
for (const { ligne, champs, brut } of lignes) {
const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance);
const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter(
(participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance,
);
const { candidats } = designer(champs, index);
if (candidats.length === 0) {
nouvelles.push({ champs });
continue;
}
if (candidats.length > 1) {
const ids = candidats.map(({ id }) => id).sort(croissant);
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] });
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: candidats, brut: [...brut] });
continue;
}
const [designe] = candidats;
if (designes.has(designe.id)) {
const [id] = candidats;
if (designes.has(id)) {
refusees.push({
ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [designe.id], premiereLigne: designes.get(designe.id),
ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [id], premiereLigne: designes.get(id),
brut: [...brut],
});
continue;
}
designes.set(designe.id, ligne);
if (poser(designe, champs)) misAJour += 1;
designes.set(id, ligne);
if (poser(parId.get(id), champs)) misAJour += 1;
else inchanges += 1;
}
return { nouvelles, refusees, misAJour, inchanges };
@ -223,16 +203,17 @@ function mettreAJour(participants, lignes) {
* Applique un aperçu à une charge (§ 10.1), selon le mode :
* - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se
* fusionne jamais ;
* - 'mettreAJour' : une ligne désigne les participants d'avant l'import de
* même nom et même prénom normalisés, et de même appartenance quand la
* ligne en porte une : un prénom absent compte comme vide, sur la ligne
* comme chez le participant, et un participant sans appartenance n'est
* désigné que par une ligne qui n'en porte pas. Désigné seul, le
* participant prend chaque champ non vide de la ligne, nom et prénom
* compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une
* ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs,
* refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une
* ligne précédente désigne déjà, refusée DEJA_DESIGNE ;
* - 'mettreAJour' : une ligne désigne, par designer, les participants
* d'avant l'import de même nom normalisé ; le prénom et l'appartenance
* n'en écartent un que lorsque la ligne et lui en portent tous deux un,
* de clés normalisées différentes, selon leurs valeurs d'avant l'import.
* Une ligne ajoutée à côté d'un participant de même nom est celle que
* l'aperçu nomme dans homonymes. Désigné seul, le participant prend chaque champ non vide de
* la ligne, nom, prénom et appartenance compris, et garde la valeur d'un
* champ vide ; exclu vide le garde. Une ligne qui ne désigne personne est
* ajoutée ; qui en désigne plusieurs, refusée PLUSIEURS_CORRESPONDENT ; qui
* désigne un participant qu'une ligne précédente désigne déjà, refusée
* DEJA_DESIGNE ;
* - 'remplacer' : les participants et leurs réservations sont retirés,
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
* restent (bilanRemplacement).
@ -251,19 +232,22 @@ function mettreAJour(participants, lignes) {
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('./apercu.js').Apercu} apercu calculé contre les
* participants de la charge : l'appartenance d'une ligne y prend
* l'orthographe d'un existant de même clé, que remplacer garde même quand
* il retire cet existant
* participants de la charge, pour ce mode : hors de remplacer,
* l'appartenance d'une ligne y prend l'orthographe d'un existant de même
* clé ; en remplacer, celle du fichier
* @param {'ajouter'|'mettreAJour'|'remplacer'} mode
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}}
* @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état
* bloqué ; le refus global de l'aperçu, son code, détails { ligne }, le rang
* que nomme l'aperçu ou null ; AMBIGUITE, détails { champs }, les champs
* que l'en-tête laisse ambigus, tant que le choix ne les tranche pas
* @throws {TypeError} pour un mode inconnu
* @throws {TypeError} pour un mode inconnu, et pour un aperçu calculé pour un
* autre mode : l'aperçu montré à l'opérateur est celui que l'import
* applique
*/
export function appliquerImport(charge, apercu, mode) {
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
if (apercu.mode !== mode) throw new TypeError(`aperçu calculé pour ${String(apercu.mode)}, import en ${mode}`);
if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE');
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
const { ambigus } = apercu.association;

View file

@ -130,9 +130,10 @@ function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants,
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
}
// Aperçu d'un texte contre les participants de la charge, puis import.
// Aperçu d'un texte contre les participants de la charge, dans le mode
// donné, puis import dans ce même mode.
function importer(charge, texte, mode, choix = {}) {
return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix }), mode);
return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix, mode }), mode);
}
// Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et
@ -304,11 +305,12 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
test("l'appartenance que porte la ligne compte même devant un seul homonyme : d'une autre appartenance, la ligne est ajoutée, et l'homonyme garde ses valeurs", () => {
const existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'),
'mettreAJour',
);
const texte = csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test');
// L'aperçu nomme la ligne qui s'ajoute à côté de l'homonyme.
assert.deepEqual(apercevoir(texte, { participants: existants, mode: 'mettreAJour' }).homonymes, [
{ ligne: 2, participants: [1] },
]);
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), texte, 'mettreAJour');
assert.deepEqual(apres.participants, [
existants[0],
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }),
@ -316,32 +318,91 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
});
test('une ligne sans prénom ne désigne que des participants sans prénom : devant le seul participant de ce nom, qui en porte un, elle est ajoutée, et lui reste tel quel', () => {
// La clé est (nom, prénom), un prénom absent y comptant comme vide : une
// liste de noms sans prénoms ajoute des personnes au lieu de mettre à
// jour les inscrits de même nom.
const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')];
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour');
assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
test('une ligne sans prénom, ou un fichier sans colonne prénom, désigne le seul participant de ce nom, qui en porte un : il est mis à jour, rien ajouté', () => {
// Un fichier qui ne porte que les noms et les courriels complète les
// inscrits ; il ne les double pas.
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Lacasse', 'Ondine', 'Société Alpha'),
personne(3, 'Pervenche', 'Théo'),
];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv('nom;courriel', 'Ombrelle;iris@exemple.test', 'Lacasse;ondine@exemple.test'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [
{ ...existants[0], courriel: 'iris@exemple.test' },
{ ...existants[1], courriel: 'ondine@exemple.test' },
existants[2],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2 }));
const vide = importer(chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Ombrelle;;oui'), 'mettreAJour');
assert.deepEqual(vide.charge.participants, [{ ...existants[0], exclu: true }, existants[1], existants[2]]);
assert.deepEqual(vide.resume, resumeDe({ misAJour: 1 }));
});
test('une ligne qui porte un prénom ne désigne pas un participant sans prénom : elle est ajoutée, et lui reste sans prénom', () => {
test('une ligne qui porte un prénom désigne le seul participant de ce nom sans prénom : il prend le prénom', () => {
const existants = [personne(1, 'Grisaille', null, 'Club des Merles')];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour',
);
assert.deepEqual(apres.participants, [...existants, personne(2, 'Grisaille', 'Silas', null, { exclu: true })]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
assert.deepEqual(apres.participants, [personne(1, 'Grisaille', 'Silas', 'Club des Merles', { exclu: true })]);
assert.deepEqual(resume, resumeDe({ misAJour: 1 }));
});
test("un participant sans appartenance n'est désigné que par une ligne qui n'en porte pas : celle qui en porte une est ajoutée, et lui reste sans appartenance", () => {
const existants = [personne(1, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })];
test("une ligne qui porte une appartenance désigne le participant de ce nom et de ce prénom qui n'en a pas : il la prend, rien n'est ajouté", () => {
const existants = [
personne(1, 'Ombrelle', 'Iris'),
personne(2, 'Lacasse', 'Ondine', null, { courriel: 'ondine@exemple.test' }),
];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;appartenance', 'Pervenche;Théo;Club des Merles'), 'mettreAJour',
chargeDe({ participants: existants }),
csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles', 'Lacasse;Ondine;Société Alpha'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [...existants, personne(2, 'Pervenche', 'Théo', 'Club des Merles')]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
assert.deepEqual(apres.participants, [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Lacasse', 'Ondine', 'Société Alpha', { courriel: 'ondine@exemple.test' }),
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2 }));
});
test("le prénom et l'appartenance ne départagent que lorsque la ligne et le participant en portent tous deux un : plusieurs homonymes restants sont refusés, jamais un ajout", () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Ombrelle', 'Ondine', null),
personne(3, 'Ombrelle', null, 'Chorale du Givre'),
personne(4, 'Givre', 'Silas', 'Club des Merles'),
personne(5, 'Givre', 'Anouk', 'Club des Merles'),
];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv(
'nom;prenom;appartenance;courriel',
// Iris : 1 par le prénom, 3 sans prénom ; Chorale du Givre écarte 1.
'Ombrelle;Iris;Chorale du Givre;a@exemple.test',
// Sans prénom ni appartenance : les trois Ombrelle.
'OMBRELLE;;;b@exemple.test',
// Club des Merles écarte 3 ; Iris écarte 2 : reste 1.
'Ombrelle;Iris;Club des Merles;c@exemple.test',
// Sans prénom : deux Givre du même club.
'Givre;;club des merles;d@exemple.test',
),
'mettreAJour',
);
assert.deepEqual(resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [
[3, 'PLUSIEURS_CORRESPONDENT', [1, 2, 3]],
[5, 'PLUSIEURS_CORRESPONDENT', [4, 5]],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2, refusees: resume.refusees }));
assert.deepEqual(apres.participants, [
{ ...existants[0], courriel: 'c@exemple.test' },
existants[1],
{ ...existants[2], prenom: 'Iris', courriel: 'a@exemple.test' },
existants[3],
existants[4],
]);
});
test("l'appartenance se compare par sa clé normalisée, des deux côtés : la ligne désigne l'homonyme qui l'écrit sans accents ou en capitales, et lui pose l'orthographe affichée", () => {
@ -385,6 +446,33 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
}));
});
test("une ligne désigne parmi les valeurs d'avant l'import : ce qu'une ligne précédente pose ne l'écarte pas, et elle est refusée DEJA_DESIGNE, dans les deux ordres", () => {
// Ombrelle n'a ni prénom ni appartenance : chaque ligne la désigne, quel
// que soit le prénom ou l'appartenance que la précédente lui a donné.
const existants = [personne(1, 'Ombrelle')];
const cas = [
['nom;prenom', ['Ombrelle;Iris', 'Ombrelle;Théo']],
['nom;prenom', ['Ombrelle;Théo', 'Ombrelle;Iris']],
['nom;appartenance', ['Ombrelle;Club des Merles', 'Ombrelle;Chorale du Givre']],
];
for (const [entete, [premiere, seconde]] of cas) {
const texte = csv(entete, premiere, seconde);
const apercu = apercevoir(texte, { participants: existants, mode: 'mettreAJour' });
const { charge: apres, resume } = appliquerImport(chargeDe({ participants: existants }), apercu, 'mettreAJour');
const [champ, valeur] = [entete.split(';')[1], premiere.split(';')[1]];
assert.deepEqual(apres.participants, [{ ...existants[0], [champ]: valeur }], premiere);
assert.deepEqual(resume, resumeDe({
misAJour: 1,
refusees: [{
ligne: 3, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 2, brut: seconde.split(';'),
}],
}), premiere);
// L'aperçu, qui lit lui aussi les valeurs d'avant l'import, ne nomme
// aucun homonyme.
assert.deepEqual(apercu.homonymes, [], premiere);
}
});
test('une mise à jour dont toutes les lignes sont refusées rend une charge égale à celle reçue ; les identifiants refusés, croissants', () => {
const charge = chargeDe({
participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')],
@ -498,6 +586,7 @@ describe('appliquerImport : la charge rendue', () => {
const charge = chargeGarnie();
const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), {
participants: charge.participants,
mode,
});
const avant = structuredClone({ charge, apercu });
const resultat = appliquerImport(charge, apercu, mode);
@ -583,6 +672,29 @@ describe('appliquerImport : les refus globaux (§ 10.1)', () => {
);
});
test("un aperçu calculé pour un autre mode lève TypeError : l'aperçu montré est celui que l'import applique", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')] });
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles');
for (const vu of MODES) {
const apercu = apercevoir(texte, { participants: charge.participants, mode: vu });
for (const mode of MODES.filter((autre) => autre !== vu)) {
assert.throws(() => appliquerImport(charge, apercu, mode), {
name: 'TypeError',
message: `aperçu calculé pour ${vu}, import en ${mode}`,
});
}
}
});
test("remplacer pose l'orthographe du fichier, non celle d'un participant retiré : l'aperçu et l'import s'accordent", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], prochain: 2 });
const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles');
const apercu = apercevoir(texte, { participants: charge.participants, mode: 'remplacer' });
assert.deepEqual(apercu.doublons, []);
const { charge: apres } = appliquerImport(charge, apercu, 'remplacer');
assert.deepEqual(apres.participants, [personne(2, 'Ombrelle', 'Iris', 'club des merles')]);
});
test('un mode inconnu lève TypeError', () => {
const apercu = apercevoir(csv('nom', 'Ombrelle'));
for (const mode of ['fusionner', 'Ajouter', undefined, null]) {
@ -687,7 +799,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => {
personne(3, 'Pervenche'),
],
});
const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants });
const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants, mode: 'mettreAJour' });
assert.equal(apercu.separateur, null);
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees));
@ -790,7 +902,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => {
'pervenche;theo;autre@exemple.test;',
'Sarcelle;Ondine;;peut-être',
),
{ participants: charge.participants },
{ participants: charge.participants, mode: 'mettreAJour' },
);
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [

View file

@ -18,6 +18,11 @@ const CANDIDATS = [';', ',', '\t'];
// Nombre d'enregistrements que le choix du séparateur lit.
const FENETRE = 20;
// Vrai quand le caractère qui suit un guillemet fermant le laisse refermer
// le champ cité : le séparateur, une fin de ligne, ou la fin du texte.
const fermeLeChamp = (suivant, separateur) =>
suivant === undefined || suivant === separateur || suivant === '\r' || suivant === '\n';
// Parcourt texte en un seul passage et rend ses enregistrements, avec l'état
// des guillemets à la fin de la lecture. La lecture s'arrête après limite
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture
@ -27,8 +32,14 @@ const FENETRE = 20;
// - un guillemet ouvre un champ cité quand il en est le premier caractère ;
// ailleurs, c'est un caractère comme un autre ;
// - dans un champ cité, le séparateur et les fins de ligne font partie du
// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ;
// texte, deux guillemets font un guillemet, un seul referme le champ quand
// le séparateur, une fin de ligne ou la fin du texte le suit ;
// - un guillemet seul suivi de tout autre caractère ne referme pas le champ :
// le guillemet qui l'a ouvert est égaré, et tout ce qu'il a lu depuis
// tiendrait dans un seul champ, jusqu'à fondre plusieurs enregistrements
// en un. La lecture s'arrête là, guillemetOuvert vrai : le dernier
// enregistrement rendu est celui où le champ s'ouvre, et porte le texte lu
// jusqu'à ce guillemet ;
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
// fin ;
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
@ -50,8 +61,12 @@ function analyser(texte, separateur, limite) {
} else if (texte[i + 1] === GUILLEMET) {
champ += GUILLEMET;
i += 1;
} else {
} else if (fermeLeChamp(texte[i + 1], separateur)) {
cite = false;
} else {
enregistrement.push(champ);
enregistrements.push(enregistrement);
return { enregistrements, guillemetOuvert: true };
}
continue;
}
@ -88,9 +103,10 @@ function analyser(texte, separateur, limite) {
/**
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
* guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de
* ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas
* d'enregistrement. Aucun blanc n'est retiré des champs.
* ligne CRLF, LF ou CR. Un champ cité se referme sur un guillemet que suivent
* le séparateur, une fin de ligne ou la fin du texte. Une ligne vide est un
* enregistrement d'un seul champ vide ; la ligne vide qui suit la dernière fin
* de ligne ne fait pas d'enregistrement. Aucun blanc n'est retiré des champs.
*
* Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris :
* le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs
@ -101,8 +117,10 @@ function analyser(texte, separateur, limite) {
* @param {string|null} separateur un caractère, ou null pour un fichier à une
* colonne, dont chaque ligne est un seul champ
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le
* dernier enregistrement porte alors le texte lu jusque-là.
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité, ou
* qu'un guillemet seul y est suivi d'un autre caractère que le séparateur
* ou une fin de ligne : la découpe s'arrête alors, et le dernier
* enregistrement, celui où le champ s'ouvre, porte le texte lu jusque-là.
*/
export function decouper(texte, separateur) {
return analyser(texte, separateur, Infinity);
@ -122,8 +140,9 @@ export function enregistrementVide(enregistrement) {
// Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est
// le premier enregistrement non vide parmi ceux que la fenêtre termine : un
// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement
// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête
// guillemet resté ouvert, ou refermé sur un autre caractère que le
// séparateur ou une fin de ligne, laisse inachevé l'enregistrement où il
// s'ouvre, le dernier lu. Rend null quand l'en-tête
// manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat
// n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le
// premier défaut qui écarte le candidat, ou null : un enregistrement non vide

View file

@ -183,9 +183,46 @@ describe('decouper : enregistrements et champs (§ 10.1)', () => {
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
});
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => {
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]);
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]);
test("un guillemet fermant suivi d'autre chose qu'un séparateur, une fin d'enregistrement ou un second guillemet : la lecture s'arrête, guillemetOuvert", () => {
// Le champ cité est refusé là où son guillemet fermant est suivi d'un
// autre caractère ; le dernier enregistrement rendu est celui où le champ
// s'ouvre, et porte le texte lu jusqu'à ce guillemet.
assert.deepEqual(decouper('"Benoît" ;x', ';'), { enregistrements: [['Benoît']], guillemetOuvert: true });
assert.deepEqual(decouper('"a"b;c', ';'), { enregistrements: [['a']], guillemetOuvert: true });
assert.deepEqual(decouper('nom;notes\nA;"a ""b"" c"x\nB;y\n', ';'), {
enregistrements: [['nom', 'notes'], ['A', 'a "b" c']],
guillemetOuvert: true,
});
// Sous un séparateur null, seule une fin d'enregistrement suit le
// guillemet fermant.
assert.deepEqual(decouper('nom\n"Roy, Camille";x\n', null), {
enregistrements: [['nom'], ['Roy, Camille']],
guillemetOuvert: true,
});
});
test("un guillemet égaré qui avale des enregistrements : le champ se referme plus loin sur un autre caractère, la lecture s'arrête au rang où il s'ouvre", () => {
// Le guillemet qui ouvre la note de A n'est jamais refermé à sa place :
// celui de « "Bob" » le referme, suivi d'une lettre. Les enregistrements
// de B et de C ne se fondent pas dans la note de A.
const texte = 'nom;prenom;notes\r\nA;Pa;"VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé "Bob" par tous\r\nD;Pd;\r\n';
assert.deepEqual(decouper(texte, ';'), {
enregistrements: [
['nom', 'prenom', 'notes'],
['A', 'Pa', 'VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé '],
],
guillemetOuvert: true,
});
});
test('un guillemet fermant suivi du séparateur, de CR, de LF, de CRLF ou de la fin du texte referme le champ', () => {
assert.deepEqual(enregistrementsDe('"a";b'), [['a', 'b']]);
assert.deepEqual(enregistrementsDe('"a"\rb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('"a"\nb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('"a"\r\nb'), [['a'], ['b']]);
assert.deepEqual(enregistrementsDe('x;"a"'), [['x', 'a']]);
assert.deepEqual(enregistrementsDe('x\t"a"\ty', '\t'), [['x', 'a', 'y']]);
assert.deepEqual(enregistrementsDe('"a"\nb', null), [['a'], ['b']]);
});
test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => {
@ -397,6 +434,19 @@ describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2));
});
test("un guillemet fermant suivi d'un autre caractère dans la fenêtre écarte le candidat, au rang où le champ s'ouvre", () => {
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;Exemple\nOdile;"Fictive"x\nRémi;Témoin\n', reconnaitre),
ouvert(';', 3),
);
// Le champ s'ouvre au rang 2 et avale les rangs 3 et 4 avant de se
// refermer sur une lettre : le rang nommé est celui où il s'ouvre.
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;"Exemple\nOdile;Fictive\nRémi;dit "R" ici\n', reconnaitre),
ouvert(';', 2),
);
});
test('un texte vide, ou fait de lignes vides', () => {
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));

View file

@ -27,10 +27,13 @@
// 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é
// 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
// 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.
// quand il dépasse PLAFOND_ENTREES. Un journal qui a disparu depuis la
// dernière écriture ne reçoit jamais d'ajout, qui commencerait un fichier
// sans sa ligne d'ouverture : il se réécrit en entier, ouverture et entrées
// 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
// 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
// ouverture le remplace, comme tout .precedent, quand l'état sur le disque
// 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 { FORMAT, analyser } from './document.js';
import { ErreurStockage } from './erreurs.js';
import {
PLAFOND_ENTREES,
decoderJournal,
elaguer,
estInstantane,
ligneEntree,
@ -81,7 +101,7 @@ import {
reconstruire,
versionDe,
} from './journal.js';
import { SUFFIXES, deriverBase } from './noms.js';
import { DOSSIER_CORBEILLE, SUFFIXES, cleSansCasse, deriverBase, dossierCorbeille } from './noms.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
* le disque, le .precedent qu'un secours ouvre ou le journal de la
* 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 {Array<{code: string, details: Object}>} avertissements dans
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {} ;
* JOURNAL_REPRIS {}, le journal en avance d'une entrée rattrapé en
* mémoire ; JOURNAL_EN_AVANCE_DE {n} ou JOURNAL_INCOMPLET {etat,
* journal}, le journal qui finit n révisions après la charge, ou
* avant elle ; JOURNAL_ECARTEES {n} ; JOURNAL_FORMAT_PLUS_RECENT
* {format}, le journal de la base, d'un format plus récent, que
* rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement}, le journal
* de la base, d'un autre événement, que rien ne lit ;
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {}, un retenu
* qui se contredit, ou que l'en-tête annonce et que la charge n'a
* pas ; JOURNAL_REPRIS {}, le journal en avance d'une entrée
* rattrapé en mémoire ; JOURNAL_EN_AVANCE_DE {n} ou
* JOURNAL_INCOMPLET {etat, journal}, le journal qui finit n
* révisions après la charge, ou avant elle ; JOURNAL_ECARTEES {n} ;
* JOURNAL_RECREE {}, le fichier du journal, disparu depuis
* 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
* illisible ; DERIVE {ids, retenu}, les propositions périmées par
* identifiant, et vrai quand le retenu l'est (§ 9)
* illisible ; PROPOSITIONS_MANQUANTES {n}, le nombre de
* 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))
.filter((base) => base !== null);
// Texte du fichier chemin, ou null quand il manque ; toute autre erreur
// remonte telle quelle.
// Ce que porte le fichier chemin : { texte, present, octets }. texte est
// 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) {
try {
return await fs.lireTexte(racine, chemin);
return { texte: await fs.lireTexte(racine, chemin), present: true, octets: null };
} 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;
}
}
// 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
// { details } de son refus, raison et chemin. analyser ne lève que
// ETAT_ILLISIBLE.
@ -212,12 +269,24 @@ function lireEtat(texte) {
// 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
// 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 }) {
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
@ -277,34 +346,40 @@ async function deriver(fs, racine, nom, autres) {
}
// 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
// erreur de lecture remontant telle quelle —, et la lecture de chaque texte
// d'état ou de .precedent (lireEtat), chacun lu une fois, à la demande. Les
// dossier, ce que chaque journal donne à lire (journalLisible), la présence
// de chaque fichier, et la lecture de chaque texte d'état ou de .precedent
// (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
// 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
// le journal d'un événement en prend une neuve (baseCherchee).
function vueDe(fs, racine) {
let entrees = null;
const textes = new Map();
const fichiers = new Map();
const lectures = new Map();
const texte = async (chemin) => {
if (!textes.has(chemin)) textes.set(chemin, await lireSiPresent(fs, racine, chemin));
return textes.get(chemin);
const fichier = async (chemin) => {
if (!fichiers.has(chemin)) fichiers.set(chemin, await lireSiPresent(fs, racine, chemin));
return fichiers.get(chemin);
};
return {
entrees: async () => {
entrees ??= await fs.lister(racine, '');
return entrees;
},
texte,
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent,
// les trois null quand il manque.
// { texte, ecartees } du journal chemin (journalLisible).
journal: async (chemin) => journalLisible(await fichier(chemin)),
// 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) => {
if (!lectures.has(chemin)) {
const lu = await texte(chemin);
const absent = { texte: null, lecture: null, details: null };
lectures.set(chemin, lu === null ? absent : { texte: lu, ...lireEtat(lu) });
const { texte, present } = await fichier(chemin);
const illisible = { texte: null, lecture: null, details: present ? malEncode() : null };
lectures.set(chemin, texte === null ? illisible : { texte, ...lireEtat(texte) });
}
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) :
// { base, texte } du journal de base quand sa première ligne nomme id ;
// sinon du premier journal du dossier, par nom, qui le nomme et n'appartient
// pas à sa propre base (proprietaire) ; null quand aucun ne s'apparie. Un
// { base, texte, ecartees } (journalLisible) du journal de base quand sa
// première ligne nomme id ; sinon du premier journal du dossier, par nom,
// 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
// 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é à
@ -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
// d'un format plus récent (formatDuJournalDeBase).
async function apparier(vue, base, id) {
const texte = await vue.texte(base + SUFFIXES.journal);
if (evenementDe(texte) === id) return { base, texte };
const journal = await vue.journal(base + SUFFIXES.journal);
if (evenementDe(journal.texte) === id) return { base, ...journal };
for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
const texteAutre = await vue.texte(autre + SUFFIXES.journal);
if (evenementDe(texteAutre) === id && (await proprietaire(vue, autre)) !== id) {
return { base: autre, texte: texteAutre };
const journalAutre = await vue.journal(autre + SUFFIXES.journal);
if (evenementDe(journalAutre.texte) === id && (await proprietaire(vue, autre)) !== id) {
return { base: autre, ...journalAutre };
}
}
return null;
@ -365,7 +441,7 @@ async function identifiantsLisibles(vue) {
async function identifiantSansEtat(vue, base) {
const { lecture } = await vue.lecture(base + SUFFIXES.precedent);
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;
return id;
}
@ -375,13 +451,17 @@ async function identifiantSansEtat(vue, base) {
// 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.
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);
}
// 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
// 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,
// 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
@ -393,15 +473,18 @@ async function formatDuJournalDeBase(vue, base) {
// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le
// journal de l'événement (baseCherchee).
async function journalDe(vue, base, id) {
const aucun = { lu: null, base: null, etranger: null, format: null, malEncode: false };
const format = await formatDuJournalDeBase(vue, base);
if (format !== null) return { lu: null, base, etranger: null, format };
const texte = await vue.texte(base + SUFFIXES.journal);
if (id === null) return { lu: null, base: null, etranger: null, format: null };
const apparie = await apparier(vue, base, id);
if (apparie !== null) return { lu: lireJournal(apparie.texte), base: apparie.base, etranger: null, format: null };
const etranger = evenementDe(texte);
if (etranger !== null) return { lu: null, base: null, etranger, format: null };
return { lu: texte === null ? null : lireJournal(texte), base, etranger: null, format: null };
if (format !== null) return { ...aucun, base, format };
const chemin = base + SUFFIXES.journal;
const journal = await vue.journal(chemin);
const apparie = id === null ? null : await apparier(vue, base, id);
if (apparie !== null) return { ...aucun, lu: lireLisible(apparie), base: apparie.base };
if (journal.texte === null && (await vue.present(chemin))) return { ...aucun, malEncode: true };
if (id === null) return aucun;
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
@ -439,30 +522,41 @@ function instantDe(lu) {
// 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
// de l'un ou de l'autre.
const depuisLecture = ({ entete, charge, formatPlusRecent }, disque) => ({
// de l'un ou de l'autre, et ce que l'en-tête annonce sans que la charge le
// porte (manquants).
const depuisLecture = ({ entete, charge, formatPlusRecent, manquants }, disque) => ({
charge,
revision: entete.revision,
produitVersion: entete.produitVersion,
manquants,
...disque,
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.
// derniere est la révision de la dernière entrée du journal, celle de la
// charge quand il n'y en a pas.
function avertir({ examen, journal, revision, rattrape, ecartees, format, etranger }) {
// charge quand il n'y en a pas. Les propositions que l'en-tête compte sans
// 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 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');
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_INCOMPLET', { etat: revision, journal: derniere });
if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees });
if (recree) ajouter('JOURNAL_RECREE');
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 (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;
if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu });
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
// 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
// d'un format plus récent, ou un journal d'un format plus récent, gardent
// l'Ouvert en lecture seule.
// entrée sur la source donne sa charge, sa révision et sa version, et ce que
// l'en-tête de la source annonce sans que sa charge le porte ne compte plus.
// 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 }) {
let { charge, revision, produitVersion } = source;
let { charge, revision, produitVersion, manquants } = source;
const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
if (rattrape) {
charge = canoniser(instant.charge);
revision = instant.revision;
produitVersion = versionDe(instant.journal, revision);
manquants = AUCUN_MANQUANT;
}
const journal = instant?.journal ?? null;
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,
journalAReecrire: journal !== null && ecartees > 0,
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
etatMalEncode: source.etatMalEncode,
examen,
avertissements: avertir({
examen,
@ -505,8 +602,11 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
revision,
rattrape,
ecartees,
recree: false,
format: trouve.format,
malEncode: trouve.malEncode,
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
// journal que l'identifiant apparie (journalSansEtat), jamais un journal
// 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) {
const precedent = await vue.lecture(base + SUFFIXES.precedent);
const journal = await vue.texte(base + SUFFIXES.journal);
if (details.raison === 'ABSENT' && precedent.texte === null && journal === null) {
const existe = async (suffixe) => vue.present(base + suffixe);
if (details.raison === 'ABSENT' && !(await existe(SUFFIXES.precedent)) && !(await existe(SUFFIXES.journal))) {
return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
}
const trouve = await journalSansEtat(vue, base);
@ -603,7 +703,7 @@ export async function lister(fs, racine) {
for (const { nom, modifie } of fichiers) {
const base = baseDe(nom, SUFFIXES.journal);
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'));
}
const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? '');
@ -629,9 +729,10 @@ export async function lister(fs, racine) {
* @param {string} base
* @returns {Promise<Ouvert>}
* @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
* secours} : l'état ne se lit pas, ou manque à côté de son .precedent
* ou de son journal (raison ABSENT) ; format est celui de l'état
* quand son refus en nomme un, sinon celui du journal de la base
* secours} : l'état ne se lit pas — raison ENCODAGE pour des octets
* hors UTF-8 —, ou manque à côté de son .precedent ou de son
* 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
* .precedent et le dernier instant du journal que l'identifiant du
* .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) {
const vue = vueDe(fs, racine);
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);
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
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
* journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors
* 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,
* 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
*/
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 disque = surDisque(etat.texte, etat);
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 depuis = depuisLecture(lecture, disque);
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,
});
}
if (trouve.malEncode) {
throw new ErreurStockage('ETAT_ILLISIBLE', { ...malEncode(), chemin: base + SUFFIXES.journal });
}
const instant = instantDe(trouve.lu);
if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal });
const depuis = {
charge: canoniser(instant.charge),
revision: instant.revision,
produitVersion: versionDe(instant.journal, instant.revision),
manquants: AUCUN_MANQUANT,
...disque,
};
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' });
}
// 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).
const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version);
@ -826,8 +939,8 @@ async function aligner(ouvert, preparation, texte) {
let depart = ouvert;
if (preparation.reprise !== null) {
const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise);
depart = { ...ouvert, ...ecrit };
journal = ecrit.journal;
({ journal } = ecrit);
depart = { ...ouvert, journal, baseJournal: ecrit.baseJournal, journalAReecrire: false };
entier = false;
}
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) };
}
// 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
// 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
// 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
// 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
// plus : ABSENT quand le dossier a disparu avec son support, LECTURE quand
// 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.
// échoue lève ECRITURE sur le journal de la base, comme l'écriture qu'elle
// précède (enEcriture) ; toute autre erreur remonte telle quelle.
async function baseCherchee(ouvert, evenement) {
const { fs, racine, base } = ouvert;
try {
@ -865,30 +986,88 @@ async function baseCherchee(ouvert, evenement) {
(await deriver(fs, racine, ouvert.charge.evenement.nom, []))
);
} catch (erreur) {
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') throw erreur;
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
throw new ErreurStockage('ECRITURE', { chemin: base + SUFFIXES.journal, dossier: racine.chemin, cause });
throw enEcriture(erreur, base + SUFFIXES.journal, racine);
}
}
// 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
// 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
// entier le demande ou que le plafond est dépassé. Sans base, le journal
// prend celle que baseCherchee trouve. Rend le journal en mémoire, qui est
// le fichier, et sa base.
// entier le demande, que le plafond est dépassé, ou que le fichier a disparu
// (fichierPresent) : un ajout le recommencerait sans sa ligne d'ouverture, et
// 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) {
let journal = avec(preparation.journal, ligne);
const { fs, racine } = ouvert;
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);
journal = plein.journal;
await fs.ecrireAtomique(racine, base + SUFFIXES.journal, plein.texte);
await fs.ecrireAtomique(racine, chemin, plein.texte);
} 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,
@ -918,16 +1097,23 @@ async function ecrireEtat(avance, texteEtat) {
}
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
// placements et d'un journal qui finit ailleurs que sur elle.
function apresEcriture(ouvert, { charge, revision, journal }) {
// placements, d'un journal qui finit ailleurs que sur elle, et du journal
// disparu que l'écriture a réécrit (recree).
function apresEcriture(ouvert, { charge, revision, journal, recree = false }) {
const examen = examiner(charge);
return {
...ouvert,
charge: examen.charge,
revision,
const avertissements = avertir({
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
@ -990,6 +1176,7 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
chargeJournal: canoniser(charge),
journalAReecrire: false,
formatPlusRecent: false,
etatMalEncode: false,
};
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
* 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'é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
* 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
* @returns {Promise<Ouvert>}
* @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
* l'alignement, la recherche du journal d'un Ouvert sans base de
* 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 texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
const aligne = etatAligne(ouvert, preparation);
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
const ecrit = await ecrireJournal(suite.depart, suite.preparation, ligne);
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
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) };
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);
const preparation = preparer(ouvert, { libelleReprise, horodatage });
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 };
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,
chargeJournal: paire.chargeJournal,
journalAReecrire: false,
etatMalEncode: false,
};
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];
// 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
// n'écrit rien (§ 8.4).
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);
});
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 () => {
const { disque, fs } = nouveauDisque();
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)", () => {
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();
@ -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);
});
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 () => {
// 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
@ -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');
});
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();
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,
// 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
// 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
// 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
@ -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 ;
// plus s'ajoute aux détails du refus.
// Comptes de l'en-tête qui portent sur les placements : une proposition ou
// 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) {
const reels = comptesDe(charge);
for (const [cle] of COMPTES.champs) {
if (COMPTES_DES_PLACEMENTS.has(cle)) continue;
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
@ -539,8 +556,10 @@ function examinerReferences(charge, plus) {
/**
* 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
* formatPlusRecent, vrai quand le format dépasse FORMAT. Au format courant,
* 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, 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 :
* 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
@ -583,9 +602,9 @@ export function analyser(texte) {
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerComptes(entete.comptes, charge, plus);
const manquants = examinerComptes(entete.comptes, 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 ;
* 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
* réglages. Les listes gardent l'ordre de la charge : le rang d'une
* réservation que nomme une ErreurConfiguration est son rang dans la charge.
* réglages. Les tables vont par identifiant croissant, l'ordre du texte
* 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
* @returns {import('../moteur/types.js').Configuration}
@ -603,7 +626,9 @@ export function configurationDepuisCharge(charge) {
const { evenement, reglages } = charge;
return {
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,
reservations: charge.reservations.map(({ 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 { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import {
@ -231,6 +232,16 @@ describe('ErreurStockage (erreurs.js)', () => {
assert.deepEqual(sansDetails.details, {});
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)', () => {
@ -299,7 +310,12 @@ describe('creerCharge (§ 4, § 6.1, § 9)', () => {
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', () => {
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', () => {
@ -645,7 +661,11 @@ describe('analyser : identifiants et tours (§ 4)', () => {
modifier(attendu);
attendu.entete.format = format;
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 [];
} catch (erreur) {
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)', () => {
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([
['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 réservation annoncée en moins',
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'],
['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)', () => {
@ -756,7 +790,12 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
d.charge.propositions[0].note = 'refaite';
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", () => {
@ -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();
charge.participants.reverse();
charge.tables.reverse();
charge.reservations.reverse();
const configuration = configurationDepuisCharge(charge);
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]);
// La seconde réservation de la charge désigne une table absente : le
// 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
// 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
// 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 {
/**
@ -14,7 +19,7 @@ export class ErreurStockage extends Error {
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
super(`${code} ${JSON.stringify(details, sansOctets)}`);
this.code = code;
this.details = details;
}

View file

@ -70,10 +70,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
// 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
// de l'UTF-8 se lit U+FFFD.
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
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.
const ignorer = () => {};
@ -180,8 +180,16 @@ async function retirer(dossier, nom) {
}
}
// Texte d'un fichier : ses octets décodés tels quels.
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer());
// Ce que porte un fichier : { texte, octets }, ses octets et leur décodage
// 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,
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
@ -307,7 +315,7 @@ export function creerFichiersWeb() {
dossier = await dossierDe([], true);
await retirer(dossier, 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);
return relu === TEMOIN
? { inscriptible: true, cause: null }
@ -323,14 +331,22 @@ export function creerFichiersWeb() {
return 'inconnu';
},
// Des octets hors UTF-8 lèvent LECTURE, cause UTF8_INVALIDE, qui porte
// ces octets.
lireTexte: async (racine, chemin) => {
const loc = localiser(racine, chemin);
if (loc.nom === null) throw absent(chemin);
let lu;
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) {
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) => {

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]),
);
// Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre
// d'octets et CRLF restent.
// Des octets UTF-8 se lisent tels quels, marque d'ordre d'octets et CRLF
// 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);
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']);
});

View file

@ -11,10 +11,10 @@
//
// 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
// texte entier d'un journal, restitue la charge d'un instant, suit le fil
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
// texte entier d'un journal, en décode les octets ligne par ligne, restitue
// la charge d'un instant, suit le fil courant et élague. Une table par sorte
// de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à
// 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,
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
// appartiennent à l'appelant.
@ -63,6 +63,11 @@ const CHARGE = regleDuChamp(SCHEMA, 'charge');
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
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.
// 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}$/;
@ -333,6 +338,44 @@ export function lireJournal(texte) {
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
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —

View file

@ -25,6 +25,7 @@ import {
INTERVALLE_INSTANTANE,
PLAFOND_ENTREES,
PLANCHER_ENTREES,
decoderJournal,
elaguer,
estInstantane,
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(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 --------------------------------------------------------

View file

@ -21,15 +21,28 @@
// (§ 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 ;
// 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
// bases que portent les fichiers d'événement à la racine du dossier
// (basesPrises), comme le dépôt la dérive pour creer.
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 { ErreurStockage } from './erreurs.js';
import { decoderJournal } from './journal.js';
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
// 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) {
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 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;
}
}
@ -72,6 +105,14 @@ function exigerFormatCourant(formatPlusRecent) {
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
// révision au journal ; sans elle — un état sans journal, ou que le journal
// 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
* 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
* 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
@ -266,6 +312,8 @@ async function supprimerAncienne(ouvert, nouvelle) {
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
* 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
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
* 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 });
}
const geste = { apres, libelle, produitVersion };
const nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage });
await supprimerAncienne(ouvert, nouvelle);
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
const nouvelle = await ecrireNouvellePaire(depart, { geste, libelleReprise, horodatage });
await supprimerAncienne(depart, 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 });
// 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
// lèverait d'un texte qui ne se lit pas.
// ne sont pas un texte : ETAT_ILLISIBLE, raison ENCODAGE, comme le dépôt le
// lève d'un état du dossier aux octets hors UTF-8.
function decoder(octets) {
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
try {
return UTF8_STRICT.decode(octets);
} 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é,
// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette
// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id.
async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && resume.id === id);
// Écrit dans racine l'événement id, nom : ses fichiers, tels quels, chacun
// quand il est donné, sous une base libre que nom dérive — le .precedent
// d'abord, qui tient la base à l'événement (apparier, dans le dépôt), puis
// le journal, puis l'état. Rend cette base. IDENTIFIANT_PRESENT quand lister
// 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 });
const { separateur } = await fs.emplacements();
const existantes = await basesPrises(fs, racine);
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);
await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
if (texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
return base;
}
@ -333,9 +386,10 @@ async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
* @param {Uint8Array} octets
* @returns {Promise<{base: string}>}
* @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,
* 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
* l'état
* @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 }) };
}
// 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
* 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
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. 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,
* ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ;
* l'écriture refusée de son état laisse dans vers le journal déjà écrit.
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Un
* événement dont l'état ne se lit pas, mais dont le .precedent ou le journal
* se lisent (§ 8.8), se copie par ses fichiers, tels quels : le .precedent,
* le journal que son secours apparie, et l'état, chacun quand ses octets
* 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').Racine} depuis
@ -371,11 +471,7 @@ export async function importerDepuis(fs, depuis, vers, bases) {
const refuses = [];
for (const base of bases) {
try {
const { texteEtat, baseJournal } = await ouvrir(fs, depuis, 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 }) });
importes.push({ base, nouvelle: await importerUn(fs, depuis, vers, base) });
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
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
* 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
* 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
@ -428,36 +533,59 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
* @param {import('./systeme_fichiers.js').Racine} racine
* @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é
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
* 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
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
* 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 journal = await baseDuJournal(fs, racine, base, insensibleCasse);
const verrou = base + SUFFIXES.verrou;
const fichiers = [
base + SUFFIXES.verrou,
verrou,
...(journal === null ? [] : [journal + SUFFIXES.journal]),
base + SUFFIXES.precedent,
base + SUFFIXES.etat,
];
const presents = (await fs.lister(racine, ''))
.filter(({ type }) => type === 'fichier')
.map(({ nom }) => nom)
.sort(comparerTextes);
const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
.filter((nom) => nom !== null);
if (partants.length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
// Les noms des fichiers du dossier, triés ; ceux de l'événement parmi eux,
// sous leur nom sur le disque, dans l'ordre de leur départ.
const nomsSurDisque = async () =>
(await fs.lister(racine, ''))
.filter(({ type }) => type === 'fichier')
.map(({ nom }) => nom)
.sort(comparerTextes);
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 dossier = dossierCorbeille(horodatage, existants);
await fs.creerDossier(racine, dossier);
for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
// La sonde vise le verrou sous son nom sur le disque, celui qui part.
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 };
}

View file

@ -44,6 +44,18 @@ const VERROU = `${BASE}${SUFFIXES.verrou}`;
const REPRISE = "Reprise de l'état trouvé sur le disque";
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
// 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
@ -191,6 +203,17 @@ async function arbre(disque, racine = DOC, reglages = {}) {
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.
async function rouvrir(disque, base) {
const fs = creerFichiersSimules({ disque });
@ -778,7 +801,7 @@ describe('opérations : renommer (§ 8.7)', () => {
let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION);
ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2));
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 ecrituresDeux = second.compteurs.ecritures;
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.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE);
const cas = [
['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'JSON', chemin: null }],
['un état écrit en Windows-1252', windows1252, { 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: 'ENCODAGE', chemin: null }],
['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }],
['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }],
['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);
});
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();
await creer(fs, DOC, chargeEssai(), CREATION);
const avant = await arbre(disque);
@ -972,12 +995,25 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
const illisible = disqueAvec([[ETAT, '{']]);
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]]);
assert.deepEqual(
(await lister(journalSeul.fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]),
[[BASE, 'evt-lucioles', 'ABSENT']],
);
assert.deepEqual(await importer(journalSeul.fs, DOC, octets), { base: 'Bal des Hérons' });
for (const [nom, { disque: autre, fs: autreFs }, raison] of [
['un état illisible apparié par son .precedent', apparie, 'VIDE'],
['un journal sans état', journalSeul, 'ABSENT'],
]) {
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 () => {
@ -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 () => {
const disque = new Map();
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.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);
const avant = await arbre(disque);
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];
assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]);
assert.deepEqual(
@ -1142,14 +1293,15 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
suppressions.push(chemin);
return primitive(racine, chemin);
};
const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30) });
const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30) });
const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00' });
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), seance: SEANCE });
const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00', seance: SEANCE });
assert.deepEqual(
[a, b, c],
[{ 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 = [
{ dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') },
{ 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));
await copier(renomme, optionsCopie('Variante des Ifs'));
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/'));
assert.deepEqual(
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) {
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(
await arbre(disque),
poses
await arbreSonde(disque),
[...poses, [VERROU, SONDE]]
.map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte])
.sort(parChemin),
nom,
@ -1218,10 +1370,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[base],
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(
await arbre(disque),
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin),
await arbreSonde(disque),
[...poses, [`${base}${SUFFIXES.verrou}`, SONDE]]
.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte])
.sort(parChemin),
nom,
);
}
@ -1234,12 +1388,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[ETAT, etat],
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
[`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'],
];
const { disque, fs } = disqueAvec(poses);
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(
await arbre(disque),
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 { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', {
horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
});
assert.deepEqual(
await arbre(autre.disque),
@ -1267,6 +1422,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const troisieme = disqueAvec(illisible);
const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', {
horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
});
assert.deepEqual(
await arbre(troisieme.disque),
@ -1279,10 +1435,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const lac = 'Χορος της Λιμνης';
const grecs = [
[`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))],
[`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, '{}\n'],
[`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, VERROU_MORT],
];
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(
await arbre(quatrieme.disque),
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 poses = [
[`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))],
[verrou, '{}\n'],
[verrou, VERROU_MORT],
];
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(
await arbre(disque),
poses
.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte])
.sort(parChemin),
await arbreSonde(disque),
[
...poses.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte]),
...sonde,
].sort(parChemin),
voisine,
);
}
@ -1325,24 +1485,29 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
[ETAT, etat],
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
[`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'],
];
const sensible = { insensibleCasse: false };
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
// 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 restes = (await arbre(disque, DOC, sensible)).filter(([chemin]) => chemin !== `${dossier}/${VERROU}`);
assert.deepEqual(
await arbre(disque, DOC, sensible),
restes,
poses
.map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte])
.sort(parChemin),
);
assert.equal(JSON.parse(creerFichiersSimules({ disque, ...sensible }).contenu('documents', `${dossier}/${VERROU}`)).seance, SEANCE);
await echec(
supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', {
horodatage: HEURE_SUPPRESSION,
seance: SEANCE,
}),
'ABSENT',
);
@ -1357,7 +1522,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
fs.pannes.couperApres(n);
let acheve = true;
try {
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
} catch (erreur) {
assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`);
acheve = false;
@ -1385,7 +1550,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
const temoin = effacerDocumentsAvant(disque, fs, n);
let refus = null;
try {
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
} catch (erreur) {
refus = erreur;
}
@ -1408,12 +1573,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
await creer(fs, DOC, chargeEssai(), CREATION);
const avant = await arbre(disque);
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}` });
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);
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.equal(fs.compteurs.ecritures, ecritures);
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 () => {
const { fs } = await deuxRevisions();
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;
fs.lister = async (racine, dossier) => {
if (dossier === 'corbeille') {
@ -1432,10 +1597,89 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
return lister(racine, dossier);
};
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);
});
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 () => {
const { fs } = nouveauDisque();
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);
});
});
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
* n'a plus
* @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.
*
@ -399,31 +403,41 @@ const ordreDesTables = (a, b) =>
(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,
* point 4) : une ligne par personne placée à un tour, { tour, table,
* numero, siege, participant, nom, prenom }, pour la lecture et le
* signalement d'un défaut. Les lignes se rangent par tour, puis par numéro
* de table — une table que la charge n'a plus vient après les autres, sans
* numéro, et l'identifiant départage deux numéros égaux —, puis par rang
* dans la liste ; la réserve suit les tables de son tour, table et numéro
* null. siege est le rang à partir de 1 quand le placement attribue les
* sièges, null sinon et dans la réserve. Nom et prénom viennent de la
* charge ; une personne qu'elle n'a plus les a null. Lève TypeError quand p
* n'est pas cohérent (fautes).
* Forme nommée d'un placement, proposition ou retenu (§ 8.9, point 4) : une
* ligne par identifiant écrit à un tour, { tour, table, numero, siege,
* participant, nom, prenom, reserve }, pour la lecture et le signalement
* d'un défaut. Les lignes se rangent par tour, puis par numéro de table —
* une table que la charge n'a plus vient après les autres, sans numéro, et
* l'identifiant départage deux numéros égaux —, puis par rang dans la
* liste ; la réserve suit les tables de son tour, table et numéro null.
* siege est le rang à partir de 1 quand le placement attribue les sièges,
* null sinon et dans la réserve. Nom et prénom viennent de la charge ; une
* personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
*
* 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').Charge} charge
* @returns {Ligne[]}
*/
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 numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
const tables = p.tables
.map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null }))
.sort(ordreDesTables);
const lignes = [];
const ajouter = (tour, table, numero, siege, participant) => {
const ajouter = (tour, table, numero, siege, participant, reserve) => {
const personne = personneDe.get(participant);
lignes.push({
tour,
@ -433,16 +447,21 @@ export function formeNommee(p, charge) {
participant,
nom: personne?.nom ?? 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) => {
const tour = rangDuTour + 1;
for (const { id, rang, numero } of tables) {
sieges[rang].forEach((participant, position) => {
ajouter(tour, id, numero, p.siegesAttribues ? position + 1 : null, participant);
});
}
for (const participant of reserve) ajouter(tour, null, null, null, participant);
for (const { id, rang, numero } of tables) ajouterListe(tour, sieges[rang] ?? [], id, numero);
for (const ids of sieges.slice(p.tables.length)) ajouterListe(tour, ids, null, null);
for (const participant of reserve) ajouter(tour, null, null, null, participant, true);
});
return lignes;
}

View file

@ -209,8 +209,9 @@ function refusDe(appel) {
// aucune valeur au-delà.
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
// Une ligne attendue de la forme nommée.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
// Une ligne attendue de la forme nommée : sans table, une ligne de la
// réserve, sauf quand enReserve dit le contraire.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom, enReserve = idTable === null) => ({
tour,
table: idTable,
numero,
@ -218,6 +219,7 @@ const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
participant,
nom,
prenom,
reserve: enReserve,
});
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 abimee = fichierParfait();
abimee.placement[0].sieges[0].pop();
for (const [nom, appel] of [
['planDepuisFichier', () => planDepuisFichier(abimee)],
['derive', () => derive(abimee, charge)],
['formeNommee', () => formeNommee(abimee, charge)],
]) {
leveTypeError(appel, /"MANQUANT"/, nom);
}
assert.equal(formeNommee(abimee, charge).length, 47);
for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) {
leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible));
leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible));

View file

@ -28,6 +28,14 @@
// EXISTE {chemin} deplacer vers une cible existante
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
// 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.
import { ErreurStockage } from './erreurs.js';
@ -64,9 +72,11 @@ import { ErreurStockage } from './erreurs.js';
/**
* Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon,
* la séance qui le tient, l'instant de sa prise, et vivant : vrai quand son
* processus tourne sur la même machine, faux quand il y est mort, null quand
* il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance,
* depuis et vivant à null.
* processus tourne sur cette machine avec l'heure de démarrage inscrite ;
* faux quand il est mort à coup sûr — aucun processus de ce pid, ou deux
* heures de démarrage connues qui diffèrent ; null sinon — verrou d'une autre
* machine, ou vie qui ne se prouve pas. Un verrou qui ne se lit pas rend
* seance, depuis et vivant à null.
*
* @typedef {{pris: true}
* | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou
@ -92,7 +102,10 @@ import { ErreurStockage } from './erreurs.js';
* @property {(r: Racine, chemin: string) => Promise<string>} lireTexte
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
* 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
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où
@ -122,8 +135,8 @@ import { ErreurStockage } from './erreurs.js';
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
* un fichier ; un fichier absent n'est pas une faute
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
* création exclusive de {seance, pid, hote, depuis} : un verrou
* présent n'est jamais repris, fût-il de la même séance
* création exclusive de {seance, pid, hote, depuis, demarrage} :
* un verrou présent n'est jamais repris, fût-il de la même séance
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
* n'efface que le verrou de cette séance ; un verrou absent n'est
* pas une faute

View file

@ -214,6 +214,11 @@
* réduite aux clés connues quand le
* format est plus récent
* @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
* renommage refusés ; la
* 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
* existante
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine
@ -355,7 +366,11 @@
* verrou
* LECTURE_SEULE {raison} un geste sur un fichier
* 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,
* l'état n'a pas pu s'écrire
* (§ 8.2)
@ -375,9 +390,13 @@
* l'appelant le lit avant
* 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
* 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
* JSON le texte n'est pas du JSON
* FORME type, clé manquante ou inconnue, valeur hors de son
@ -386,7 +405,10 @@
* table en double ou atteignant prochainsIds, tour d'une
* réservation hors de sa portée ; des propositions et du
* 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
* absente, siège au-delà de la capacité
* 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
* entre crochets, une clé qui ne s'écrit pas comme un identifiant
* 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 :
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
* récent

View file

@ -9,9 +9,11 @@
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
// système de fichiers, un dossier en lecture seule, un lien qui sort de la
// racine, un dossier voisin dont le nom prolonge celui de la racine, le
// renommage qui remplace la cible, et le verrou d'un processus vivant ou
// terminé. Chaque épreuve écrit sous ses propres dossiers
// temporaires, retirés à sa fin, qu'elle passe ou non.
// renommage qui remplace la cible, le verrou d'un processus vivant ou
// terminé, sous Linux celui d'un pid vivant dont le démarrage n'est pas
// celui qu'il inscrit, et le démarrage que ps écrit en temps universel quel
// que soit le fuseau du processus qui le lit. Chaque épreuve écrit sous ses
// propres dossiers temporaires, retirés à sa fin, qu'elle passe ou non.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
@ -42,6 +44,7 @@ const NOM_PRODUIT = 'Gestion table tournante Libre';
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
const SOUS_WINDOWS = process.platform === 'win32';
const SOUS_LINUX = process.platform === 'linux';
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
// Dossiers temporaires de l'épreuve en cours.
@ -303,4 +306,92 @@ describe('coquille sur un vrai disque', () => {
vivant: false,
});
});
testNettoye(
"sous Linux, le verrou inscrit le champ 22 de /proc/<pid>/stat ; un pid vivant d'un autre démarrage est une séance morte, et un verrou sans démarrage laisse vivant inconnu",
{ skip: !SOUS_LINUX },
async () => {
// Le démarrage d'un processus, lu ici sans passer par la coquille.
const demarrageDe = (cible) => {
const texte = readFileSync(`/proc/${cible}/stat`, 'utf8');
return texte.slice(texte.lastIndexOf(')') + 1).trim().split(/\s+/)[19];
};
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
assert.equal(pose.demarrage, demarrageDe(process.pid));
// Le processus parent vit ; un verrou à son pid ne vit qu'avec son
// démarrage, et sans démarrage inscrit, rien ne dit s'il vit encore.
const parent = process.ppid;
const depuis = '2026-10-06T19:02:11-04:00';
for (const [nom, inscrit, vivant] of [
['son démarrage', { demarrage: demarrageDe(parent) }, true],
['son démarrage, une clé étrangère', { demarrage: demarrageDe(parent), inconnue: 1 }, true],
['un autre démarrage', { demarrage: `${demarrageDe(parent)}0` }, false],
['sans démarrage', {}, null],
]) {
writeFileSync(
join(documents, 'parent.gtt.verrou'),
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
);
assert.deepEqual(
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b'),
{ pris: false, seance: 'seance-p', depuis, vivant },
nom,
);
}
},
);
testNettoye(
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
{ skip: SOUS_WINDOWS },
async () => {
const documents = join(dossierTemporaire(), 'Documents');
mkdirSync(documents, { recursive: true });
const fichiers = creerFichiers({
emplacements: {},
racines: { portable: null, documents },
dialog: SANS_APPEL,
shell: SANS_APPEL,
plateforme: 'darwin',
});
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
encoding: 'utf8',
});
assert.equal(enUtc.status, 0, enUtc.stderr);
assert.equal(pose.demarrage, enUtc.stdout.trim());
// Un second processus, dans un fuseau qui n'est pas celui de
// l'épreuve, relit le verrou par la même coquille.
const autre = Intl.DateTimeFormat().resolvedOptions().timeZone === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati';
const lecteur = [
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b");',
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
].join('\n');
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
env: {
...process.env,
TZ: autre,
GTT_MODULE: new URL('../electron/fichiers.js', import.meta.url).href,
GTT_DOCUMENTS: documents,
},
encoding: 'utf8',
});
assert.equal(relu.status, 0, relu.stderr);
assert.deepEqual(JSON.parse(relu.stdout), {
fuseau: autre,
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
});
},
);
});

View file

@ -23,7 +23,7 @@ import {
PAUSE_MAX_MS,
PREFIXE_CANAL,
PRIMITIVES,
REESSAIS_RENOMMAGE,
REESSAIS_PASSAGERS,
RefusFichier,
SUFFIXE_ECRITURE,
TEMOIN,
@ -338,7 +338,8 @@ function dialogueFactice(reponses = []) {
* La coquille sur un disque en mémoire où la racine documents existe. Rend
* le disque, les primitives, et ce que l'épreuve lit des injections : les
* pauses demandées, les dossiers ouverts dans l'explorateur, les commandes
* lancées, les pid signalés.
* lancées, les pid signalés. demarrage, quand il est donné, remplace la
* lecture de l'instant de démarrage d'un processus.
*/
function preparer({
disque = creerDisque(),
@ -346,6 +347,7 @@ function preparer({
ouvrir = async () => '',
lancer = async () => '',
signaler = () => {},
demarrage,
plateforme = 'linux',
hote = HOTE,
fs = disque.fs,
@ -380,6 +382,7 @@ function preparer({
signaux.push(pid);
return signaler(pid);
},
demarrage,
pid: PID,
hote,
maintenant: () => INSTANT,
@ -387,6 +390,28 @@ function preparer({
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
}
/**
* Une table de processus du poste : pid → instant de démarrage, null quand
* la plateforme ne le donne pas. signaler lève ESRCH pour un pid absent de
* la table ; demarrage rend l'instant de la table et consigne le pid lu.
*
* @param {Array<[number, string|null]>} entrees
*/
function tableDeProcessus(entrees) {
const table = new Map(entrees);
const lus = [];
return {
lus,
signaler(pid) {
if (!table.has(pid)) throw erreurSysteme('ESRCH', 'kill', '');
},
async demarrage(pid) {
lus.push(pid);
return table.has(pid) ? table.get(pid) : null;
},
};
}
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
let raison;
@ -468,7 +493,7 @@ describe('coquille, fichiers : écriture atomique (§ 8.8)', () => {
});
test('EPERM, EBUSY et EACCES se réessaient dix fois : dix refus passent, onze lèvent ECRITURE {chemin, dossier, cause}, la cible intacte, le temporaire retiré', async () => {
assert.equal(REESSAIS_RENOMMAGE, 10);
assert.equal(REESSAIS_PASSAGERS, 10);
assert.deepEqual([PAUSE_INITIALE_MS, PAUSE_MAX_MS], [50, 500]);
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
@ -710,6 +735,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' });
});
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 () => {
const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/b.txt`, 'bb');
@ -767,7 +818,7 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']);
});
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé pour une cause durable, essayé une fois", async () => {
const { disque, fichiers, pauses } = preparer();
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
@ -785,8 +836,8 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
'ECRITURE',
ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'),
);
disque.panne('rename', 'EBUSY');
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EBUSY'));
disque.panne('rename', 'EROFS');
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EROFS'));
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`rename ${DOCUMENTS}/source.txt`)).length, 1);
assert.deepEqual(pauses, []);
assert.equal(disque.contenu(`${DOCUMENTS}/source.txt`), 'source');
@ -807,27 +858,153 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
});
});
describe('coquille, fichiers : réessais sur une cause passagère (§ 8.8)', () => {
const JOURNAL = `${DOCUMENTS}/soiree.gtt-journal.jsonl`;
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
// Chaque primitive, l'appel système qu'un antivirus ou un agent de
// synchronisation peut refuser sous Windows, le disque préparé, l'appel,
// ce qu'il laisse quand il réussit, et quand il échoue.
const CAS = [
{
primitive: 'ajouterLigne',
fonction: 'open',
chemin: JOURNAL,
poser: (disque) => disque.deposer(JOURNAL, '{"type":"journal"}\n'),
appeler: (fichiers) => fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":1}'),
reussi: (disque) => assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n{"revision":1}\n'),
intact: (disque) => assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n'),
refus: ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, null),
},
{
primitive: 'deplacer',
fonction: 'rename',
chemin: `${DOCUMENTS}/soiree.gtt.json`,
poser: (disque) => {
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
},
appeler: (fichiers) => fichiers.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'),
reussi: (disque) => {
assert.equal(disque.contenu(`${DOCUMENTS}/corbeille/soiree.gtt.json`), '\u{E9}tat');
assert.equal(disque.existe(`${DOCUMENTS}/soiree.gtt.json`), false);
},
intact: (disque) => {
assert.equal(disque.contenu(`${DOCUMENTS}/soiree.gtt.json`), '\u{E9}tat');
assert.equal(disque.existe(`${DOCUMENTS}/corbeille/soiree.gtt.json`), false);
},
refus: ecriture('soiree.gtt.json', DOCUMENTS, null),
},
{
primitive: 'supprimer',
fonction: 'unlink',
chemin: `${DOCUMENTS}/ancienne.gtt.json`,
poser: (disque) => disque.deposer(`${DOCUMENTS}/ancienne.gtt.json`, 'x'),
appeler: (fichiers) => fichiers.supprimer(DOC, 'ancienne.gtt.json'),
reussi: (disque) => assert.equal(disque.existe(`${DOCUMENTS}/ancienne.gtt.json`), false),
intact: (disque) => assert.equal(disque.contenu(`${DOCUMENTS}/ancienne.gtt.json`), 'x'),
refus: ecriture('ancienne.gtt.json', DOCUMENTS, null),
},
];
const essais = (disque, { fonction, chemin }) =>
disque.appels.filter((ligne) => ligne.startsWith(`${fonction} ${chemin}`)).length;
for (const cas of CAS) {
test(`${cas.primitive} : refusé deux fois sur une cause passagère puis accepté, réussit après des pauses de 50 puis 100 ms`, async () => {
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
const { disque, fichiers, pauses } = preparer();
cas.poser(disque);
disque.panne(cas.fonction, cause, { fois: 2, chemin: cas.chemin });
await cas.appeler(fichiers);
cas.reussi(disque);
assert.deepEqual(pauses, [50, 100], cause);
assert.equal(essais(disque, cas), 3, cause);
}
});
test(`${cas.primitive} : toujours refusé sur une cause passagère, lève ECRITURE au onzième essai, après les pauses du renommage, et ne touche à rien`, async () => {
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
const { disque, fichiers, pauses } = preparer();
cas.poser(disque);
disque.panne(cas.fonction, cause, { chemin: cas.chemin });
await refuse(cas.appeler(fichiers), 'ECRITURE', { ...cas.refus, cause }, cause);
cas.intact(disque);
assert.deepEqual(pauses, PAUSES, cause);
assert.equal(essais(disque, cas), REESSAIS_PASSAGERS + 1, cause);
}
});
test(`${cas.primitive} : une cause durable ne se réessaie pas`, async () => {
for (const cause of ['EROFS', 'ENOSPC', 'EIO']) {
const { disque, fichiers, pauses } = preparer();
cas.poser(disque);
disque.panne(cas.fonction, cause, { chemin: cas.chemin });
await refuse(cas.appeler(fichiers), 'ECRITURE', { ...cas.refus, cause }, cause);
cas.intact(disque);
assert.deepEqual(pauses, [], cause);
assert.equal(essais(disque, cas), 1, cause);
}
});
}
test("ajouterLigne ne réessaie que l'ouverture : une écriture refusée ne se répète pas, une ligne n'est jamais écrite deux fois", async () => {
const { disque, fichiers, pauses } = preparer();
disque.deposer(JOURNAL, '{"type":"journal"}\n');
disque.panne('write', 'EBUSY', { chemin: JOURNAL });
await refuse(fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, 'EBUSY'));
assert.deepEqual(pauses, []);
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`write ${JOURNAL}`)).length, 1);
});
test("supprimer : un fichier qui disparaît pendant les réessais est supprimé, sans faute", async () => {
const CIBLE = `${DOCUMENTS}/ancienne.gtt.json`;
const disque = creerDisque();
// Le premier effacement retire le fichier mais répond EBUSY, comme un
// effacement que le système ne fait qu'au dernier fichier fermé : le
// réessai trouve ENOENT.
let premier = true;
const fs = {
...disque.fs,
async unlink(chemin) {
await disque.fs.unlink(chemin);
if (premier && chemin === CIBLE) {
premier = false;
throw erreurSysteme('EBUSY', 'unlink', chemin);
}
},
};
const { fichiers, pauses } = preparer({ disque, fs });
disque.deposer(CIBLE, 'x');
await fichiers.supprimer(DOC, 'ancienne.gtt.json');
assert.equal(disque.existe(CIBLE), false);
assert.deepEqual(pauses, [50]);
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`unlink ${CIBLE}`)).length, 2);
});
});
describe('coquille, fichiers : le verrou (§ 8.8)', () => {
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
test("se crée en exclusif, porte {seance, pid, hote, depuis} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
const { disque, fichiers } = preparer();
test("se crée en exclusif, porte {seance, pid, hote, depuis, demarrage} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
const processus = tableDeProcessus([[PID, '918273']]);
const { disque, fichiers } = preparer(processus);
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
const texte = disque.contenu(VERROU);
assert.ok(texte.endsWith('}\n'), texte);
const contenu = JSON.parse(texte);
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis']);
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null });
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null, demarrage: '918273' });
assert.deepEqual(processus.lus, [PID]);
assert.match(contenu.depuis, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
assert.equal(Date.parse(contenu.depuis), INSTANT.getTime());
});
test('un verrou présent rend sa séance : vivant quand son processus répond sur cette machine, même sans droit de le signaler', async () => {
test("un verrou présent rend sa séance : morte quand son pid ne répond pas ; sans démarrage inscrit, un pid qui répond, même sans droit de le signaler, laisse vivant inconnu", async () => {
for (const [nom, signaler, vivant] of [
['répond', () => {}, true],
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, true],
['répond', () => {}, null],
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, null],
['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false],
]) {
const { disque, fichiers, signaux } = preparer({ signaler });
@ -841,6 +1018,181 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
}
});
test("sans instant de démarrage que la plateforme donne, le verrou porte demarrage à null ; un démarrage courant inconnu se relit à la prise suivante, un connu ne se relit pas", async () => {
// Le démarrage du processus courant ne se lit pas la première fois.
const reponses = [null, '918273'];
const lus = [];
const { disque, fichiers } = preparer({
demarrage: async (cible) => {
lus.push(cible);
return reponses.length > 1 ? reponses.shift() : reponses[0];
},
});
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, null);
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
assert.deepEqual(lus, [PID, PID]);
});
test("vivant : faux seulement quand la mort est certaine — pid qui ne répond pas, ou deux démarrages connus et différents ; vrai pour deux démarrages connus et égaux ; inconnu (null) sinon", async () => {
const DEPUIS = '2026-10-06T19:02:11-04:00';
const tenu = (vivant) => ({ pris: false, seance: 'seance-a', depuis: DEPUIS, vivant });
// Un démarrage inscrit inconnu ne demande pas celui du pid. Une clé
// Une clé que le contenu d'un verrou ne définit pas ne compte pas.
for (const [nom, demarrageLu, inscrit, vivant, lus] of [
['même démarrage', '5000', { demarrage: '5000' }, true, [PID, 777]],
['pid réattribué', '5000', { demarrage: '4000' }, false, [PID, 777]],
['démarrage inscrit inconnu', '5000', { demarrage: null }, null, [PID]],
['verrou sans démarrage', '5000', {}, null, [PID]],
['démarrage actuel inconnu', null, { demarrage: '4000' }, null, [PID, 777]],
['même démarrage, clé étrangère', '5000', { demarrage: '5000', inconnue: 1 }, true, [PID, 777]],
['démarrage actuel inconnu, clé étrangère', null, { demarrage: '4000', inconnue: 1 }, null, [PID, 777]],
]) {
const processus = tableDeProcessus([[PID, '1'], [777, demarrageLu]]);
const { disque, fichiers } = preparer(processus);
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, ...inscrit });
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(vivant), nom);
// Le démarrage du processus courant se lit avant la prise, celui du
// pid inscrit une fois le verrou trouvé.
assert.deepEqual(processus.lus, lus, nom);
}
// Un pid qui ne répond pas est mort, sans lecture de son démarrage ; celui
// du processus courant, lu une fois, ne se relit pas.
const processus = tableDeProcessus([[PID, '1']]);
const { disque, fichiers } = preparer(processus);
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, demarrage: '5000' });
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
assert.deepEqual(processus.lus, [PID]);
});
test("un démarrage inscrit qui n'est ni une chaîne ni null rend le verrou illisible", async () => {
const { disque, fichiers } = preparer(tableDeProcessus([[777, '5000']]));
for (const inscrit of [{ demarrage: 5000 }, { demarrage: true }, { demarrage: {} }]) {
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: 'x', ...inscrit });
assert.deepEqual(
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'),
{ pris: false, seance: null, depuis: null, vivant: null },
JSON.stringify(inscrit),
);
}
});
test("sous Linux, le démarrage est le champ 22 de /proc/<pid>/stat, compté après la parenthèse fermante du nom ; un fichier absent ou de forme fausse le rend inconnu, et vivant avec lui", async () => {
// Champs 3 à 22 : l'état, puis dix-huit nombres, le dernier étant le
// démarrage ; le nom porte une espace et une parenthèse fermante.
const stat = (pid, debut) => `${pid} (nom ) bizarre) S ${Array.from({ length: 18 }, (_, i) => i + 1).join(' ')} ${debut} 99 0\n`;
const disque = creerDisque();
disque.deposer(`/proc/${PID}/stat`, stat(PID, 31337));
disque.deposer('/proc/777/stat', stat(777, 5000));
disque.deposer('/proc/778/stat', stat(778, 4000));
disque.deposer('/proc/779/stat', '779 (court) S 1 2 3\n');
disque.deposer('/proc/780/stat', stat(780, 'x'));
// Sans parenthèse fermante, aucun champ ne se compte, même quand la
// ligne en porte assez.
disque.deposer('/proc/782/stat', stat(782, 5000).replace(/[()]/g, ''));
const { fichiers } = preparer({ disque, signaler: () => {} });
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '31337');
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null], [781, null], [782, null]]) {
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '5000' });
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
}
});
test("sous Windows, le démarrage est l'heure de création que rend PowerShell, le pid passé par l'environnement, borné à cinq secondes ; un échec le rend inconnu, et vivant avec lui", async () => {
const reponses = new Map([['777', '133725000000000000\r\n'], ['778', '133700000000000000\r\n'], ['779', '\r\n']]);
const { disque, fichiers, commandes } = preparer({
plateforme: 'win32',
lancer: async (_commande, _arguments, { variables }) => {
if (variables.GTT_PID === String(PID)) return '133725999999999999\r\n';
if (!reponses.has(variables.GTT_PID)) throw new Error('Get-Process : aucun processus');
return reponses.get(variables.GTT_PID);
},
});
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '133725999999999999');
assert.deepEqual(commandes.map(({ commande }) => commande), ['powershell.exe']);
const [{ arguments_, options }] = commandes;
assert.deepEqual(arguments_.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']);
assert.match(arguments_[3], /\$env:GTT_PID/);
assert.match(arguments_[3], /\.StartTime\.ToFileTimeUtc\(\)$/);
assert.deepEqual(options, { variables: { GTT_PID: String(PID) }, delai: DELAI_SUPPORT_MS });
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null]]) {
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '133725000000000000' });
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
}
});
test("sous Windows, un pid qui refuse le signal et dont l'heure de création ne se lit pas — le processus d'un autre compte — laisse vivant inconnu, jamais faux", async () => {
const DEPUIS = '2026-10-06T19:02:11-04:00';
const { disque, fichiers, signaux } = preparer({
plateforme: 'win32',
signaler: () => {
throw erreurSysteme('EPERM', 'kill', '');
},
lancer: async (_commande, _arguments, { variables }) => {
if (variables.GTT_PID === String(PID)) return '133725999999999999\r\n';
throw new Error('Get-Process : accès refusé');
},
});
deposerVerrou(disque, { seance: 'seance-a', pid: 4312, hote: HOTE, depuis: DEPUIS, demarrage: '133725000000000000' });
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-a',
depuis: DEPUIS,
vivant: null,
});
assert.deepEqual(signaux, [4312]);
});
test("sous macOS, le démarrage est la colonne lstart de ps, en locale C et en temps universel ; une sortie vide le rend inconnu, et vivant avec lui ; ailleurs, inconnu, sans commande", async () => {
const sorties = new Map([['777', 'Tue Oct 6 19:02:11 2026\n'], ['779', '\n']]);
const { disque, fichiers, commandes } = preparer({
plateforme: 'darwin',
lancer: async (_commande, arguments_) => sorties.get(arguments_[3]) ?? 'Wed Oct 7 08:00:00 2026\n',
});
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, 'Wed Oct 7 08:00:00 2026');
assert.deepEqual(commandes[0], {
commande: 'ps',
arguments_: ['-o', 'lstart=', '-p', String(PID)],
options: { variables: { LC_ALL: 'C', TZ: 'UTC' }, delai: DELAI_SUPPORT_MS },
});
for (const [pid, vivant] of [[777, true], [778, false], [779, null]]) {
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: 'Tue Oct 6 19:02:11 2026' });
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
}
const ailleurs = preparer({ plateforme: 'freebsd' });
await ailleurs.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(JSON.parse(ailleurs.disque.contenu(VERROU)).demarrage, null);
assert.deepEqual(ailleurs.commandes, []);
});
test("sous macOS, une séance vivante reste vivante quand le fuseau du poste change entre la prise et la lecture", async () => {
// Un ps factice : le même démarrage, écrit dans le fuseau que donne TZ,
// ou à défaut dans celui du poste au moment de la commande.
const ecrit = new Map([
['UTC', 'Tue Oct 6 23:02:11 2026'],
['America/Montreal', 'Tue Oct 6 19:02:11 2026'],
['Europe/Paris', 'Wed Oct 7 01:02:11 2026'],
]);
let fuseauDuPoste = 'America/Montreal';
const lancer = async (_commande, _arguments, { variables }) => `${ecrit.get(variables.TZ ?? fuseauDuPoste)}\n`;
const premiere = preparer({ plateforme: 'darwin', lancer });
assert.deepEqual(await premiere.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
fuseauDuPoste = 'Europe/Paris';
const seconde = preparer({ disque: premiere.disque, plateforme: 'darwin', lancer });
const tenu = await seconde.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
assert.deepEqual({ seance: tenu.seance, vivant: tenu.vivant }, { seance: 'seance-a', vivant: true });
});
test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => {
const { disque, fichiers, signaux } = preparer();
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });

View file

@ -53,8 +53,25 @@ const REESSAIS_RENOMMAGE = 10;
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
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_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
// 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 ;
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
* 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,
* de même ;
* - 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.
* deposer, deposerDossier et contenu servent encore après une coupure : ils
* sont les mains de l'épreuve sur le disque, non celles du processus.
* deposer, deposerOctets, deposerDossier, contenu et octets servent encore
* après une coupure : ils sont les mains de l'épreuve sur le disque, non
* celles du processus.
*/
export function creerFichiersSimules({
disque = new Map(),
@ -287,11 +312,11 @@ export function creerFichiersSimules({
return panne.cause;
}
// Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa
// prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le
// disque porte un processus de cet hôte sous ce pid, et que ce processus
// n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et
// vivant à null.
// Ce que rend verrouiller d'un verrou présent, d'après son texte, null pour
// des octets hors UTF-8 : sa séance, l'instant de sa prise, et vivant —
// null pour un autre hôte ; pour celui-ci, vrai quand le disque porte un
// processus de cet hôte sous ce pid, et que ce processus n'est pas coupé.
// Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
function verrouPresent(texte) {
let contenu;
try {
@ -367,10 +392,19 @@ export function creerFichiersSimules({
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) => {
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 });
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) => {
@ -405,7 +439,9 @@ export function creerFichiersSimules({
exigerDossierParent(loc);
exigerPasDossier(loc);
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) => {
@ -414,7 +450,7 @@ export function creerFichiersSimules({
const entrees = [];
for (const noeud of disque.values()) {
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 });
}
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);
exigerPasDossier(loc);
const present = disque.get(loc.cle);
if (present !== undefined) return verrouPresent(present.texte);
if (present !== undefined) return verrouPresent(texteDuNoeud(present));
const prise = avancer();
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
return { pris: true };
@ -472,7 +508,7 @@ export function creerFichiersSimules({
if (!verrouDisponible) return;
exigerInscriptible(loc);
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) => {
@ -509,6 +545,14 @@ export function creerFichiersSimules({
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 = '') {
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length, maintenant);
@ -516,7 +560,15 @@ export function creerFichiersSimules({
contenu(racineId, chemin) {
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', () => {
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();