Compare commits
3 commits
67fd0bc9ff
...
8d03d5ba41
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d03d5ba41 | |||
| c72215a2da | |||
| 0bc48fa1ae |
32 changed files with 3960 additions and 484 deletions
|
|
@ -29,8 +29,10 @@
|
||||||
//
|
//
|
||||||
// Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier,
|
// Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier,
|
||||||
// cause}, une lecture que le système refuse autrement que sur un absent — un
|
// cause}, une lecture que le système refuse autrement que sur un absent — un
|
||||||
// dossier illisible n'est pas un dossier vide —, et NON_DISPONIBLE {cause},
|
// dossier illisible n'est pas un dossier vide —, ou un fichier dont les
|
||||||
// l'explorateur du système qui ne s'ouvre pas.
|
// octets ne sont pas de l'UTF-8, cause UTF8_INVALIDE, ses détails portant
|
||||||
|
// alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE
|
||||||
|
// {cause}, l'explorateur du système qui ne s'ouvre pas.
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { realpath as realpathRappel } from 'node:fs';
|
import { realpath as realpathRappel } from 'node:fs';
|
||||||
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
||||||
|
|
@ -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';
|
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,
|
* Un appel système qu'un antivirus ou un agent de synchronisation peut
|
||||||
* REESSAIS_RENOMMAGE fois sur une cause passagère : un antivirus ou un agent
|
* refuser sous Windows, tant qu'il tient le fichier ouvert (EPERM, EBUSY,
|
||||||
* de synchronisation qui tient la cible ouverte sous Windows (EPERM, EBUSY,
|
* EACCES), se réessaie, après son premier essai, REESSAIS_PASSAGERS fois :
|
||||||
* EACCES). La pause entre deux essais part de PAUSE_INITIALE_MS et double
|
* le renommage de l'écriture atomique, l'ouverture en ajout d'ajouterLigne,
|
||||||
* jusqu'à PAUSE_MAX_MS : 3,75 s d'attente au plus avant de renoncer.
|
* 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_INITIALE_MS = 50;
|
||||||
export const PAUSE_MAX_MS = 500;
|
export const PAUSE_MAX_MS = 500;
|
||||||
const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
||||||
|
|
@ -92,6 +96,17 @@ export const DELAI_SUPPORT_MS = 5000;
|
||||||
// une apostrophe le ferait lire autrement.
|
// une apostrophe le ferait lire autrement.
|
||||||
const COMMANDE_LECTEUR = '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType';
|
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),
|
// 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 — « . »,
|
// 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,
|
// « .. » 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 = new Set(['ENOENT', 'ENOTDIR']);
|
||||||
const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']);
|
const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']);
|
||||||
|
|
||||||
|
// Décodage strict d'un fichier lu : un octet hors UTF-8 lève au lieu de se
|
||||||
|
// lire U+FFFD, et la marque d'ordre d'octets reste dans le texte.
|
||||||
|
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
|
|
||||||
|
// Le remplaçant JSON d'un détail : un Uint8Array devient « N octets », comme
|
||||||
|
// dans le message d'une ErreurStockage.
|
||||||
|
const sansOctets = (cle, valeur) => (valeur instanceof Uint8Array ? `${valeur.length} octets` : valeur);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un échec que l'interface nomme : le code et les détails d'une
|
* Un échec que l'interface nomme : le code et les détails d'une
|
||||||
* ErreurStockage (src/stockage/types.js en donne la table).
|
* ErreurStockage (src/stockage/types.js en donne la table). Le message écrit
|
||||||
|
* les détails en JSON, un détail en octets par son seul nombre d'octets.
|
||||||
*/
|
*/
|
||||||
export class RefusFichier extends Error {
|
export class RefusFichier extends Error {
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,7 +138,7 @@ export class RefusFichier extends Error {
|
||||||
* @param {Object} details
|
* @param {Object} details
|
||||||
*/
|
*/
|
||||||
constructor(code, details) {
|
constructor(code, details) {
|
||||||
super(`${code} ${JSON.stringify(details)}`);
|
super(`${code} ${JSON.stringify(details, sansOctets)}`);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
|
|
@ -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");
|
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
|
// Le contenu d'un verrou, {seance, pid, hote, depuis, demarrage}, ou null
|
||||||
// n'en est pas un : JSON invalide, champ manquant ou de type faux, pid qui
|
// quand le texte n'en est pas un : JSON invalide, champ manquant ou de type
|
||||||
// ne désigne pas un processus — 0 et les négatifs désignent des groupes.
|
// 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) {
|
function analyserVerrou(texte) {
|
||||||
let contenu;
|
let contenu;
|
||||||
try {
|
try {
|
||||||
|
|
@ -245,8 +271,9 @@ function analyserVerrou(texte) {
|
||||||
Number.isSafeInteger(contenu.pid) &&
|
Number.isSafeInteger(contenu.pid) &&
|
||||||
contenu.pid > 0 &&
|
contenu.pid > 0 &&
|
||||||
typeof contenu.hote === 'string' &&
|
typeof contenu.hote === 'string' &&
|
||||||
typeof contenu.depuis === 'string';
|
typeof contenu.depuis === 'string' &&
|
||||||
return valide ? contenu : null;
|
(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,
|
// 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
|
* delai}) → la sortie standard de la commande
|
||||||
* @param {(ms: number) => Promise<void>} [reglages.attendre]
|
* @param {(ms: number) => Promise<void>} [reglages.attendre]
|
||||||
* @param {(pid: number) => void} [reglages.signaler] celui de processusVivant
|
* @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 {number} [reglages.pid]
|
||||||
* @param {string} [reglages.hote]
|
* @param {string} [reglages.hote]
|
||||||
* @param {() => Date} [reglages.maintenant]
|
* @param {() => Date} [reglages.maintenant]
|
||||||
|
|
@ -321,6 +351,7 @@ export function creerFichiers({
|
||||||
lancer = lancerCommande,
|
lancer = lancerCommande,
|
||||||
attendre = pause,
|
attendre = pause,
|
||||||
signaler,
|
signaler,
|
||||||
|
demarrage,
|
||||||
pid = process.pid,
|
pid = process.pid,
|
||||||
hote = hostname(),
|
hote = hostname(),
|
||||||
maintenant = () => new Date(),
|
maintenant = () => new Date(),
|
||||||
|
|
@ -330,6 +361,9 @@ export function creerFichiers({
|
||||||
const bases = new Map([['documents', documents]]);
|
const bases = new Map([['documents', documents]]);
|
||||||
if (portable !== null) bases.set('portable', portable);
|
if (portable !== null) bases.set('portable', portable);
|
||||||
let choisis = 0;
|
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
|
// 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
|
// 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
|
// Rend ce que rend l'opération, et la réessaie sur une cause passagère,
|
||||||
// double ; la dernière erreur se lève.
|
// après une pause qui double ; une autre cause, ou la dernière erreur des
|
||||||
async function renommer(de, vers) {
|
// 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;
|
let attente = PAUSE_INITIALE_MS;
|
||||||
for (let essai = 0; ; essai += 1) {
|
for (let essai = 0; ; essai += 1) {
|
||||||
try {
|
try {
|
||||||
await fs.rename(de, vers);
|
return await operation();
|
||||||
return;
|
|
||||||
} catch (erreur) {
|
} 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);
|
await attendre(attente);
|
||||||
attente = Math.min(2 * attente, PAUSE_MAX_MS);
|
attente = Math.min(2 * attente, PAUSE_MAX_MS);
|
||||||
|
|
@ -477,13 +512,74 @@ export function creerFichiers({
|
||||||
const poignee = await fs.open(temporaire, 'wx');
|
const poignee = await fs.open(temporaire, 'wx');
|
||||||
try {
|
try {
|
||||||
await ecrireDans(poignee, octets);
|
await ecrireDans(poignee, octets);
|
||||||
await renommer(temporaire, absolu);
|
await reessayer(() => fs.rename(temporaire, absolu));
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
await effacer(temporaire).catch(() => {});
|
await effacer(temporaire).catch(() => {});
|
||||||
throw erreur;
|
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
|
// 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.
|
// depuis. Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
|
||||||
async function verrouPresent(absolu) {
|
async function verrouPresent(absolu) {
|
||||||
|
|
@ -500,7 +596,7 @@ export function creerFichiers({
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: contenu.seance,
|
seance: contenu.seance,
|
||||||
depuis: contenu.depuis,
|
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) {
|
async lireTexte(racine, chemin) {
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
|
let octets;
|
||||||
try {
|
try {
|
||||||
await confiner(loc);
|
await confiner(loc);
|
||||||
return (await fs.readFile(loc.absolu)).toString('utf8');
|
octets = await fs.readFile(loc.absolu);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecLecture(loc, erreur);
|
throw echecLecture(loc, erreur);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
return UTF8_STRICT.decode(octets);
|
||||||
|
} catch {
|
||||||
|
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE' };
|
||||||
|
throw new RefusFichier('LECTURE', { ...details, octets: new Uint8Array(octets) });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Une cible qui est un dossier est refusée avant d'écrire : le
|
// Une cible qui est un dossier est refusée avant d'écrire : le
|
||||||
|
|
@ -622,13 +725,15 @@ export function creerFichiers({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ouvre en ajout — le fichier absent se crée, son dossier jamais —,
|
// 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) {
|
async ajouterLigne(racine, chemin, ligne) {
|
||||||
exigerLigne(ligne);
|
exigerLigne(ligne);
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
try {
|
try {
|
||||||
await confiner(loc);
|
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) {
|
} catch (erreur) {
|
||||||
throw echecEcriture(loc, 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
|
// 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
|
// 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
|
// est une cible présente. Un dossier absent sous la cible nomme la
|
||||||
|
|
@ -703,7 +808,7 @@ export function creerFichiers({
|
||||||
throw echecEcriture(cible, erreur);
|
throw echecEcriture(cible, erreur);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await fs.rename(source.absolu, cible.absolu);
|
await reessayer(() => fs.rename(source.absolu, cible.absolu));
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecEcriture(source, erreur);
|
throw echecEcriture(source, erreur);
|
||||||
}
|
}
|
||||||
|
|
@ -715,21 +820,26 @@ export function creerFichiers({
|
||||||
await confiner(loc);
|
await confiner(loc);
|
||||||
const etat = await etatDe(loc.absolu);
|
const etat = await etatDe(loc.absolu);
|
||||||
if (etat?.isDirectory()) throw refus(loc, 'EISDIR');
|
if (etat?.isDirectory()) throw refus(loc, 'EISDIR');
|
||||||
if (etat !== null) await effacer(loc.absolu);
|
if (etat !== null) await reessayer(() => effacer(loc.absolu));
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecEcriture(loc, erreur);
|
throw echecEcriture(loc, erreur);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Création exclusive (wx), qui ne suit aucun lien. Un verrou présent se
|
// Création exclusive (wx), qui ne suit aucun lien, d'un verrou qui
|
||||||
// lit ; s'il disparaît entre la création refusée et sa lecture, la prise
|
// inscrit la séance, le pid, l'hôte, l'instant de la prise et le
|
||||||
// se tente une seconde fois. Un verrou écrit à moitié s'efface.
|
// 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) {
|
async verrouiller(racine, chemin, seance) {
|
||||||
exigerSeance(seance);
|
exigerSeance(seance);
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
try {
|
try {
|
||||||
await confiner(loc);
|
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) {
|
for (let essai = 0; ; essai += 1) {
|
||||||
let poignee;
|
let poignee;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { appliquerImport, bilanRemplacement } from '../csv/import.js';
|
||||||
import { serialiserCharge } from '../stockage/canonique.js';
|
import { serialiserCharge } from '../stockage/canonique.js';
|
||||||
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { versFichier } from '../stockage/placements.js';
|
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
|
||||||
import { libelle } from './libelles.js';
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -266,6 +266,25 @@ function supprimerParticipant(charge, { id }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Les avertissements de chaque personne qu'un import exclut ou réintègre
|
||||||
|
// (§ 4.4) : celle que la charge suivante porte sous le même identifiant,
|
||||||
|
// exclu changé, et qui tient une réservation. Chacune a le bilan que
|
||||||
|
// donnerait sa commande seule, exclureParticipant ou reintegrerParticipant,
|
||||||
|
// appliquée à la charge d'avant : RESERVATIONS_SUSPENDUES ou
|
||||||
|
// RESERVATIONS_RETABLIES, par identifiant croissant.
|
||||||
|
function avertissementsDExclusion(avant, suivante) {
|
||||||
|
const exclusDApres = new Map(suivante.participants.map(({ id, exclu }) => [id, exclu]));
|
||||||
|
return avant.participants
|
||||||
|
.filter(({ id, exclu }) => exclusDApres.has(id) && exclusDApres.get(id) !== exclu)
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map((participant) => {
|
||||||
|
const seule = avecParticipant(avant, participant.id, (copie) => ({ ...copie, exclu: !copie.exclu }));
|
||||||
|
const code = participant.exclu ? 'RESERVATIONS_RETABLIES' : 'RESERVATIONS_SUSPENDUES';
|
||||||
|
return avertissement(code, bilanExclusion(avant, seule, participant));
|
||||||
|
})
|
||||||
|
.filter(({ details }) => details.reservations > 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
|
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
|
||||||
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
|
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
|
||||||
|
|
@ -274,6 +293,13 @@ function supprimerParticipant(charge, { id }) {
|
||||||
* l'état : sans avertissement en brouillon et proposé ; en retenu,
|
* l'état : sans avertissement en brouillon et proposé ; en retenu,
|
||||||
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
|
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
|
||||||
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
|
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
|
||||||
|
* Une personne que l'import exclut ou réintègre le dit ensuite comme sa
|
||||||
|
* commande le dirait (avertissementsDExclusion).
|
||||||
|
*
|
||||||
|
* Un import dont la charge ne change pas, mais qui refuse des lignes, n'est
|
||||||
|
* pas sans effet : ses refus sont à lire et à réexporter. Il rend la charge
|
||||||
|
* reçue, un libellé null — aucune entrée ne s'écrit —, aucun avertissement,
|
||||||
|
* et son résumé.
|
||||||
*/
|
*/
|
||||||
function importerParticipants(charge, { apercu, mode }) {
|
function importerParticipants(charge, { apercu, mode }) {
|
||||||
exigerModifiable(charge);
|
exigerModifiable(charge);
|
||||||
|
|
@ -284,11 +310,17 @@ function importerParticipants(charge, { apercu, mode }) {
|
||||||
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
|
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
const { ajoutes, misAJour, retires, derive } = resultat.resume;
|
const { ajoutes, misAJour, retires, derive, refusees } = resultat.resume;
|
||||||
|
if (refusees.length > 0 && serialiserCharge(resultat.charge) === serialiserCharge(charge)) {
|
||||||
|
return { charge, libelle: null, avertissements: [], resume: resultat.resume };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
charge: resultat.charge,
|
charge: resultat.charge,
|
||||||
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
|
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
|
||||||
avertissements: derive ? [avertissement('IMPORT_EN_RETENU', {})] : [],
|
avertissements: [
|
||||||
|
...(derive ? [avertissement('IMPORT_EN_RETENU', {})] : []),
|
||||||
|
...avertissementsDExclusion(charge, resultat.charge),
|
||||||
|
],
|
||||||
resume: resultat.resume,
|
resume: resultat.resume,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -418,11 +450,14 @@ export const COMMANDES = new Map([
|
||||||
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
||||||
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
|
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
|
||||||
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
|
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
|
||||||
|
* Un rendu au libellé null ne change rien non plus, mais porte ce qu'il y a
|
||||||
|
* à lire — l'import dont seules des lignes refusées auraient changé la
|
||||||
|
* liste : il passe tel quel, et rien ne s'écrit.
|
||||||
*
|
*
|
||||||
* @param {string} nom
|
* @param {string} nom
|
||||||
* @param {Object} charge
|
* @param {Object} charge
|
||||||
* @param {Object} [args]
|
* @param {Object} [args]
|
||||||
* @returns {{charge: Object, libelle: string, avertissements: Array<{code: string, details: Object}>}}
|
* @returns {{charge: Object, libelle: string|null, avertissements: Array<{code: string, details: Object}>}}
|
||||||
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
|
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
|
||||||
* @throws {TypeError} une commande inconnue, un argument de forme fausse
|
* @throws {TypeError} une commande inconnue, un argument de forme fausse
|
||||||
*/
|
*/
|
||||||
|
|
@ -430,10 +465,45 @@ export function appliquerCommande(nom, charge, args = {}) {
|
||||||
const commande = COMMANDES.get(nom);
|
const commande = COMMANDES.get(nom);
|
||||||
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
|
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
|
||||||
const rendu = commande(charge, args ?? {});
|
const rendu = commande(charge, args ?? {});
|
||||||
|
if (rendu.libelle === null) return { ...rendu, avertissements: rendu.avertissements ?? [] };
|
||||||
if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
|
if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
|
||||||
return { ...rendu, avertissements: rendu.avertissements ?? [] };
|
return { ...rendu, avertissements: rendu.avertissements ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La forme nommée d'un placement de la charge (§ 8.9, point 4), pour la
|
||||||
|
* lecture et le signalement d'un défaut : une lecture, ni commande ni
|
||||||
|
* entrée. cible désigne { proposition: id } ou { retenu: true }. Rend
|
||||||
|
* { lignes, fautes } : fautes, celles que le placement porte (fautes) ;
|
||||||
|
* lignes, sa forme nommée (formeNommee), une ligne par identifiant écrit,
|
||||||
|
* ou null quand sa forme est fausse (faute FORME), qui ne se lit pas en
|
||||||
|
* lignes. Une proposition fautive n'est plus dans la charge qu'examiner
|
||||||
|
* rend ; le retenu y reste, fautif ou non. La charge n'est pas modifiée.
|
||||||
|
*
|
||||||
|
* @param {Object} charge
|
||||||
|
* @param {{proposition: number}|{retenu: true}} cible
|
||||||
|
* @returns {{lignes: Array<Object>|null, fautes: Array<Object>}}
|
||||||
|
* @throws {ErreurCommande} PROPOSITION_INCONNUE {id} ; AUCUN_RETENU, remède
|
||||||
|
* la section des propositions
|
||||||
|
* @throws {TypeError} une cible d'une autre forme
|
||||||
|
*/
|
||||||
|
export function formeNommeeDe(charge, cible) {
|
||||||
|
const cles = cible !== null && typeof cible === 'object' ? clesRangees(cible) : null;
|
||||||
|
let placement;
|
||||||
|
if (cles?.length === 1 && cles[0] === 'proposition' && Number.isSafeInteger(cible.proposition)) {
|
||||||
|
placement = charge.propositions.find(({ id }) => id === cible.proposition);
|
||||||
|
if (placement === undefined) throw refus('PROPOSITION_INCONNUE', { id: cible.proposition });
|
||||||
|
} else if (cles?.length === 1 && cles[0] === 'retenu' && cible.retenu === true) {
|
||||||
|
placement = charge.retenu;
|
||||||
|
if (placement === null) throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
|
||||||
|
} else {
|
||||||
|
throw new TypeError(`cible : {proposition: id} ou {retenu: true} attendu, reçu ${JSON.stringify(cible)}`);
|
||||||
|
}
|
||||||
|
const lues = fautes(placement);
|
||||||
|
const lisible = !lues.some(({ code }) => code === 'FORME');
|
||||||
|
return { lignes: lisible ? formeNommee(placement, charge) : null, fautes: lues };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
|
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
|
||||||
* champ se contrôle selon sa règle avant que l'identifiant ne se tire :
|
* champ se contrôle selon sa règle avant que l'identifiant ne se tire :
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import { apercevoir } from '../csv/apercu.js';
|
||||||
import { bilanRemplacement } from '../csv/import.js';
|
import { bilanRemplacement } from '../csv/import.js';
|
||||||
import { rechercher } from '../moteur/recherche.js';
|
import { rechercher } from '../moteur/recherche.js';
|
||||||
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
||||||
import { derive, planDepuisFichier } from '../stockage/placements.js';
|
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve } from './commandes.js';
|
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||||
|
|
||||||
const V1 = VERSION.affichee;
|
const V1 = VERSION.affichee;
|
||||||
|
|
||||||
|
|
@ -118,7 +118,7 @@ describe('commandes : le registre (§ 8.2)', () => {
|
||||||
|
|
||||||
test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => {
|
test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => {
|
||||||
const charge = chargeEssai();
|
const charge = chargeEssai();
|
||||||
const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants });
|
const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants, mode: 'mettreAJour' });
|
||||||
refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', {
|
refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', {
|
||||||
commande: 'importerParticipants',
|
commande: 'importerParticipants',
|
||||||
remede: null,
|
remede: null,
|
||||||
|
|
@ -256,7 +256,7 @@ describe("commandes : l'import (§ 10.1)", () => {
|
||||||
|
|
||||||
test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => {
|
test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => {
|
||||||
const avant = chargeEssai();
|
const avant = chargeEssai();
|
||||||
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
|
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants, mode: 'remplacer' });
|
||||||
const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' });
|
const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' });
|
||||||
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
|
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
|
||||||
assert.deepEqual(remplace.charge.reservations, []);
|
assert.deepEqual(remplace.charge.reservations, []);
|
||||||
|
|
@ -264,7 +264,7 @@ describe("commandes : l'import (§ 10.1)", () => {
|
||||||
// intervertirait ne rendrait pas.
|
// intervertirait ne rendrait pas.
|
||||||
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
|
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
|
||||||
const misAJour = executer('importerParticipants', avant, {
|
const misAJour = executer('importerParticipants', avant, {
|
||||||
apercu: apercevoir(csv, { participants: avant.participants }),
|
apercu: apercevoir(csv, { participants: avant.participants, mode: 'mettreAJour' }),
|
||||||
mode: 'mettreAJour',
|
mode: 'mettreAJour',
|
||||||
});
|
});
|
||||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]);
|
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]);
|
||||||
|
|
@ -277,7 +277,7 @@ describe("commandes : l'import (§ 10.1)", () => {
|
||||||
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
|
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
|
||||||
const retenue = chargeRiche();
|
const retenue = chargeRiche();
|
||||||
assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']);
|
assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']);
|
||||||
const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants }), mode });
|
const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants, mode }), mode });
|
||||||
assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []);
|
assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []);
|
||||||
assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []);
|
assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []);
|
||||||
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
|
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
|
||||||
|
|
@ -285,7 +285,10 @@ describe("commandes : l'import (§ 10.1)", () => {
|
||||||
}
|
}
|
||||||
// Une mise à jour sans personne nouvelle avertit aussi : le placement
|
// Une mise à jour sans personne nouvelle avertit aussi : le placement
|
||||||
// retenu date de la liste d'avant.
|
// retenu date de la liste d'avant.
|
||||||
const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', { participants: retenue.participants });
|
const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', {
|
||||||
|
participants: retenue.participants,
|
||||||
|
mode: 'mettreAJour',
|
||||||
|
});
|
||||||
const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' });
|
const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' });
|
||||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
|
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
|
||||||
assert.deepEqual(misAJour.avertissements, enRetenu);
|
assert.deepEqual(misAJour.avertissements, enRetenu);
|
||||||
|
|
@ -313,6 +316,104 @@ describe("commandes : l'import (§ 10.1)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("commandes : l'import qui exclut, réintègre ou ne change rien (§ 4.4, § 10.1)", () => {
|
||||||
|
// La charge d'essai, où Ondine Lacasse tient aussi une réservation, au
|
||||||
|
// tour 1, sans siège.
|
||||||
|
function deuxReservees() {
|
||||||
|
const charge = chargeEssai();
|
||||||
|
charge.reservations.push({ participant: 4, table: 2, siege: null, portee: 'tour', tour: 1 });
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
const importer = (charge, csv) =>
|
||||||
|
appliquerCommande('importerParticipants', charge, {
|
||||||
|
apercu: apercevoir(csv, { participants: charge.participants, mode: 'mettreAJour' }),
|
||||||
|
mode: 'mettreAJour',
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une mise à jour qui exclut des personnes réservées le dit pour chacune, comme exclure (§ 4.4)", () => {
|
||||||
|
const avant = figer(deuxReservees());
|
||||||
|
const rendu = importer(avant, 'nom;prenom;exclu\nOmbrelle;Iris;oui\nLacasse;Ondine;oui\nPervenche;Théo;oui\n');
|
||||||
|
assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 3 modifiés');
|
||||||
|
assert.deepEqual(rendu.avertissements, [
|
||||||
|
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
|
||||||
|
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 4, personne: 'Ondine Lacasse', reservations: 1, titres: 0 } },
|
||||||
|
]);
|
||||||
|
// Le même bilan que la commande d'exclusion, personne par personne.
|
||||||
|
assert.deepEqual(rendu.avertissements[0], executer('exclureParticipant', avant, { id: 1 }).avertissements[0]);
|
||||||
|
assert.deepEqual(rendu.avertissements[1], executer('exclureParticipant', avant, { id: 4 }).avertissements[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une mise à jour qui réintègre une personne réservée le dit, comme réintégrer ; en retenu, après la dérive', () => {
|
||||||
|
const exclue = executer('exclureParticipant', chargeEssai(), { id: 1 }).charge;
|
||||||
|
const rendu = importer(exclue, 'nom;prenom;exclu\nOmbrelle;Iris;non\n');
|
||||||
|
const retablies = { code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } };
|
||||||
|
assert.deepEqual(rendu.avertissements, [retablies]);
|
||||||
|
assert.equal(rendu.charge.participants[0].exclu, false);
|
||||||
|
const retenue = chargeRiche();
|
||||||
|
const enRetenu = importer(retenue, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n');
|
||||||
|
assert.deepEqual(enRetenu.avertissements.map(({ code }) => code), ['IMPORT_EN_RETENU', 'RESERVATIONS_SUSPENDUES']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une mise à jour dont les lignes valides ne changent rien, mais qui refuse des lignes, n'est pas SANS_EFFET : elle rend son résumé, sans entrée", () => {
|
||||||
|
const avant = chargeEssai();
|
||||||
|
avant.participants.push(personne(5, 'Lacasse', 'Ondine'));
|
||||||
|
avant.prochainsIds.participant = 6;
|
||||||
|
figer(avant);
|
||||||
|
const rendu = importer(avant, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n');
|
||||||
|
assert.equal(rendu.libelle, null);
|
||||||
|
assert.deepEqual(rendu.avertissements, []);
|
||||||
|
assert.equal(rendu.charge, avant);
|
||||||
|
assert.deepEqual(rendu.resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [
|
||||||
|
[2, 'PLUSIEURS_CORRESPONDENT', [4, 5]],
|
||||||
|
]);
|
||||||
|
assert.deepEqual([rendu.resume.ajoutes, rendu.resume.misAJour, rendu.resume.inchanges], [0, 0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes : la forme nommée, une lecture (§ 8.9, point 4)', () => {
|
||||||
|
test("rend la forme nommée d'une proposition ou du retenu, avec ses fautes, sans toucher la charge", () => {
|
||||||
|
const charge = figer(chargeRiche());
|
||||||
|
assert.deepEqual(formeNommeeDe(charge, { proposition: 2 }), {
|
||||||
|
lignes: formeNommee(charge.propositions[1], charge),
|
||||||
|
fautes: [],
|
||||||
|
});
|
||||||
|
const retenu = formeNommeeDe(charge, { retenu: true });
|
||||||
|
assert.deepEqual(retenu, { lignes: formeNommee(charge.retenu, charge), fautes: [] });
|
||||||
|
assert.ok(retenu.lignes.length > 0);
|
||||||
|
assert.ok(retenu.lignes.every(({ nom }) => typeof nom === 'string'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un retenu incohérent se lit quand même : une ligne par identifiant écrit, et ses fautes à côté", () => {
|
||||||
|
const charge = chargeRiche();
|
||||||
|
const assis = charge.retenu.placement[0].sieges[0][0];
|
||||||
|
charge.retenu.placement[0].reserve = [...charge.retenu.placement[0].reserve, assis].sort((a, b) => a - b);
|
||||||
|
const coherent = chargeRiche();
|
||||||
|
const lue = formeNommeeDe(charge, { retenu: true });
|
||||||
|
assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]);
|
||||||
|
assert.deepEqual(lue.fautes, fautes(charge.retenu));
|
||||||
|
assert.equal(lue.lignes.length, formeNommee(coherent.retenu, coherent).length + 1);
|
||||||
|
assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un placement dont la forme est fausse n'a pas de lignes : ses fautes seules le disent", () => {
|
||||||
|
const charge = chargeRiche();
|
||||||
|
charge.retenu.placement = 'illisible';
|
||||||
|
const lue = formeNommeeDe(charge, { retenu: true });
|
||||||
|
assert.equal(lue.lignes, null);
|
||||||
|
assert.deepEqual(lue.fautes.map(({ code }) => code), ['FORME']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une proposition inconnue, un retenu absent sont refusés ; une cible d'une autre forme est une faute du code", () => {
|
||||||
|
refuse(() => formeNommeeDe(chargeRiche(), { proposition: 9 }), 'PROPOSITION_INCONNUE', { id: 9, remede: null });
|
||||||
|
refuse(() => formeNommeeDe(chargeEssai(), { retenu: true }), 'AUCUN_RETENU', {
|
||||||
|
remede: { geste: 'ouvrirSection', section: 'propositions' },
|
||||||
|
});
|
||||||
|
for (const cible of [{}, { retenu: false }, { proposition: '2' }, { proposition: 2, retenu: true }, null]) {
|
||||||
|
assert.throws(() => formeNommeeDe(chargeRiche(), cible), TypeError, JSON.stringify(cible));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('commandes : les états du plan (§ 9)', () => {
|
describe('commandes : les états du plan (§ 9)', () => {
|
||||||
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
|
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
|
||||||
function chargeA(etat, contenu) {
|
function chargeA(etat, contenu) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
|
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
|
||||||
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
|
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
|
||||||
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
|
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
|
||||||
// un code brut. Une valeur codée d'un détail — raison, état, champ, mode,
|
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
|
||||||
// échec — que la table ne connaît pas lève aussi, au lieu de se taire. Une
|
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
|
||||||
|
// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la
|
||||||
|
// nomme, par son texte quand la table en porte un, telle quelle sinon. Une
|
||||||
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
|
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
|
||||||
// un code de l'application : les textes la citent telle quelle, entre
|
// un code de l'application : les textes la citent telle quelle, entre
|
||||||
// parenthèses ; une cause que la table porte s'y lit par son texte.
|
// parenthèses ; une cause que la table porte s'y lit par son texte.
|
||||||
|
|
@ -104,10 +106,25 @@ const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chem
|
||||||
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
|
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
|
||||||
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
|
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
|
||||||
|
|
||||||
|
// Une raison codée en une phrase : son texte quand la table en porte un et
|
||||||
|
// que les détails reçus lui suffisent ; sinon une phrase générique qui la
|
||||||
|
// cite telle quelle. Un texte qui attend ses propres détails lève ou rend
|
||||||
|
// « undefined » sans eux : la phrase générique le remplace alors.
|
||||||
|
function phraseDeRaison(raison, details = {}) {
|
||||||
|
const generique = `La raison donnée est ${cite(String(raison))}.`;
|
||||||
|
if (!TEXTES.has(raison)) return generique;
|
||||||
|
try {
|
||||||
|
const texte = texteDe(raison, details);
|
||||||
|
return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique;
|
||||||
|
} catch {
|
||||||
|
return generique;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// La raison d'un fichier d'état illisible, en une phrase.
|
// La raison d'un fichier d'état illisible, en une phrase.
|
||||||
function raisonIllisible({ raison, ...details }) {
|
function raisonIllisible({ raison, ...details }) {
|
||||||
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
|
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
|
||||||
return texteDe(raison, details);
|
return phraseDeRaison(raison, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
|
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
|
||||||
|
|
@ -181,7 +198,7 @@ const TEXTES = new Map([
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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.'
|
'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') {
|
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
|
||||||
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
|
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
|
||||||
}
|
}
|
||||||
throw new RangeError(`libelle : raison de lecture seule inconnue ${JSON.stringify(raison)}`);
|
if (raison === 'ENCODAGE') {
|
||||||
|
return (
|
||||||
|
`Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` +
|
||||||
|
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
|
||||||
|
"range ce fichier dans la corbeille avant d'écrire l'état."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -230,6 +254,10 @@ const TEXTES = new Map([
|
||||||
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
|
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'ENCODAGE',
|
||||||
|
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
|
||||||
|
],
|
||||||
|
|
||||||
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
|
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
|
||||||
[
|
[
|
||||||
|
|
@ -289,12 +317,24 @@ const TEXTES = new Map([
|
||||||
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
|
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
|
||||||
'à partir de la première ligne illisible.',
|
'à partir de la première ligne illisible.',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_RECREE',
|
||||||
|
() =>
|
||||||
|
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
|
||||||
|
'tel que la séance le connaissait.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'JOURNAL_FORMAT_PLUS_RECENT',
|
'JOURNAL_FORMAT_PLUS_RECENT',
|
||||||
({ format }) =>
|
({ format }) =>
|
||||||
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
|
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
|
||||||
"il ne se lit pas, et l'événement reste en lecture seule.",
|
"il ne se lit pas, et l'événement reste en lecture seule.",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_ILLISIBLE',
|
||||||
|
({ raison }) =>
|
||||||
|
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` +
|
||||||
|
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'JOURNAL_ETRANGER',
|
'JOURNAL_ETRANGER',
|
||||||
({ evenement }) =>
|
({ evenement }) =>
|
||||||
|
|
@ -307,6 +347,12 @@ const TEXTES = new Map([
|
||||||
? `Une proposition incohérente est écartée (${numeros(ids)}).`
|
? `Une proposition incohérente est écartée (${numeros(ids)}).`
|
||||||
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
|
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'PROPOSITIONS_MANQUANTES',
|
||||||
|
({ n }) =>
|
||||||
|
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
|
||||||
|
'dans le fichier : celles qu\'il porte sont gardées.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'DERIVE',
|
'DERIVE',
|
||||||
(details) => {
|
(details) => {
|
||||||
|
|
@ -353,11 +399,13 @@ const TEXTES = new Map([
|
||||||
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
|
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
|
||||||
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
|
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
|
||||||
],
|
],
|
||||||
|
// dossier, quand il est donné, nomme le dossier où la séance écrit
|
||||||
|
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
|
||||||
[
|
[
|
||||||
'SUPPORT_AMOVIBLE',
|
'SUPPORT_AMOVIBLE',
|
||||||
() =>
|
({ dossier }) =>
|
||||||
'Le dossier de travail est sur un support amovible : ' +
|
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
|
||||||
'retirer le support pendant une écriture peut la laisser incomplète.',
|
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'REGLAGES_ILLISIBLES',
|
'REGLAGES_ILLISIBLES',
|
||||||
|
|
@ -455,6 +503,27 @@ const TEXTES = new Map([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
|
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
|
||||||
|
// Quitter l'événement d'un support rompu : la séance tient la seule copie
|
||||||
|
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
|
||||||
|
// dernier geste n'est qu'à l'historique de ce support.
|
||||||
|
[
|
||||||
|
'SUPPORT_ROMPU_NON_SAUVE',
|
||||||
|
({ echec, dossier }) => {
|
||||||
|
const geste =
|
||||||
|
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
|
||||||
|
return (
|
||||||
|
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
|
||||||
|
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
|
||||||
|
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EVENEMENT_CHANGE',
|
||||||
|
({ revision }) =>
|
||||||
|
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
|
||||||
|
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'VERROU_REPRIS',
|
'VERROU_REPRIS',
|
||||||
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
|
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
|
||||||
|
|
@ -580,7 +649,8 @@ export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
|
||||||
* @param {Object} [details]
|
* @param {Object} [details]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
|
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
|
||||||
* — raison, état, champ, mode, échec — que la table ne connaît pas
|
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
|
||||||
|
* une raison, qu'une phrase générique nomme
|
||||||
*/
|
*/
|
||||||
export function libelle(code, details = {}) {
|
export function libelle(code, details = {}) {
|
||||||
return texteDe(code, details);
|
return texteDe(code, details);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,17 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
||||||
assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']);
|
assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un motif de lecture seule ou d'illisibilité ne fait jamais lever le texte ni n'y laisse « undefined », quel que soit le code qui le porte", () => {
|
||||||
|
assert.ok(CODES.length > 0, 'table vide');
|
||||||
|
for (const code of ['LECTURE_SEULE', 'ETAT_ILLISIBLE', 'JOURNAL_ILLISIBLE']) {
|
||||||
|
for (const raison of [...CODES, 'RAISON_INVENTEE']) {
|
||||||
|
const texte = libelle(code, { raison, base: 'Soirée des Lucioles', chemin: null });
|
||||||
|
assert.equal(typeof texte, 'string', `${code} / ${raison}`);
|
||||||
|
assert.ok(!texte.includes('undefined'), `${code} / ${raison} : ${texte}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
||||||
const { parArbre, tables } = releverArbre();
|
const { parArbre, tables } = releverArbre();
|
||||||
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
||||||
|
|
@ -204,6 +215,7 @@ const EXEMPLES = new Map([
|
||||||
['REFERENCE', { chemin: 'charge.reservations[0].table' }],
|
['REFERENCE', { chemin: 'charge.reservations[0].table' }],
|
||||||
['FORMAT_INCONNU', { chemin: 'entete.format' }],
|
['FORMAT_INCONNU', { chemin: 'entete.format' }],
|
||||||
['FORMAT_PLUS_RECENT', { format: 2 }],
|
['FORMAT_PLUS_RECENT', { format: 2 }],
|
||||||
|
['ENCODAGE', {}],
|
||||||
// Fautes d'un placement
|
// Fautes d'un placement
|
||||||
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
|
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
|
||||||
['DOUBLON', { tour: 1, participant: 3 }],
|
['DOUBLON', { tour: 1, participant: 3 }],
|
||||||
|
|
@ -225,9 +237,12 @@ const EXEMPLES = new Map([
|
||||||
['JOURNAL_EN_AVANCE_DE', { n: 3 }],
|
['JOURNAL_EN_AVANCE_DE', { n: 3 }],
|
||||||
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
|
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
|
||||||
['JOURNAL_ECARTEES', { n: 64 }],
|
['JOURNAL_ECARTEES', { n: 64 }],
|
||||||
|
['JOURNAL_RECREE', {}],
|
||||||
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
|
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
|
||||||
|
['JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' }],
|
||||||
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
|
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
|
||||||
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
|
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
|
||||||
|
['PROPOSITIONS_MANQUANTES', { n: 2 }],
|
||||||
['DERIVE', { ids: [1, 2], retenu: true }],
|
['DERIVE', { ids: [1, 2], retenu: true }],
|
||||||
// Dossier de travail et réglages locaux
|
// Dossier de travail et réglages locaux
|
||||||
['NON_PUBLIE', {}],
|
['NON_PUBLIE', {}],
|
||||||
|
|
@ -265,6 +280,8 @@ const EXEMPLES = new Map([
|
||||||
['BLOQUE', {}],
|
['BLOQUE', {}],
|
||||||
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||||
['SUPPORT_INTACT', {}],
|
['SUPPORT_INTACT', {}],
|
||||||
|
['SUPPORT_ROMPU_NON_SAUVE', { echec: 'ETAT_NON_ECRIT', chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EIO' }],
|
||||||
|
['EVENEMENT_CHANGE', { revision: 7 }],
|
||||||
['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }],
|
['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }],
|
||||||
['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
|
['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||||
['RIEN_A_DEFAIRE', {}],
|
['RIEN_A_DEFAIRE', {}],
|
||||||
|
|
@ -385,6 +402,19 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("le support amovible nomme le dossier quand il est donné ; l'événement changé nomme la révision adoptée", () => {
|
||||||
|
assert.doesNotMatch(libelle('SUPPORT_AMOVIBLE', {}), /«/);
|
||||||
|
assert.match(libelle('SUPPORT_AMOVIBLE', { dossier: 'F:\\soirees' }), /« F:\\soirees »/);
|
||||||
|
assert.match(libelle('EVENEMENT_CHANGE', { revision: 7 }), /\b7\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quitter un événement au support rompu nomme « Écrire ailleurs », et ce qui serait perdu", () => {
|
||||||
|
const details = { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' };
|
||||||
|
for (const echec of ['ECRITURE', 'ETAT_NON_ECRIT', 'JALON_NON_ECRIT', 'VERROU_NON_ECRIT']) {
|
||||||
|
assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', { echec, ...details }), /« Écrire ailleurs »/, echec);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => {
|
test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => {
|
||||||
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
|
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
|
||||||
assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/);
|
assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/);
|
||||||
|
|
@ -405,8 +435,39 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
||||||
const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } };
|
const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } };
|
||||||
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
|
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
|
||||||
}
|
}
|
||||||
assert.throws(() => libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null }), RangeError);
|
const encodage = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'ENCODAGE', chemin: null, secours: { precedent: 4, journal: null } });
|
||||||
assert.throws(() => libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' }), RangeError);
|
assert.ok(encodage.includes(libelle('ENCODAGE')), encodage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une raison qu'un texte ne connaît pas ne lève jamais : une phrase générique la nomme, par son texte quand la table en porte un, telle quelle sinon", () => {
|
||||||
|
const inventee = libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null });
|
||||||
|
assert.match(inventee, /« RAISON_INVENTEE »/);
|
||||||
|
const seule = libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' });
|
||||||
|
assert.match(seule, /« RAISON_INVENTEE »/);
|
||||||
|
assert.match(seule, /lecture seule/);
|
||||||
|
const parSonTexte = libelle('LECTURE_SEULE', { raison: 'VIDE' });
|
||||||
|
assert.ok(parSonTexte.includes(libelle('VIDE')), parSonTexte);
|
||||||
|
assert.doesNotMatch(parSonTexte, /VIDE/);
|
||||||
|
assert.match(libelle('JOURNAL_ILLISIBLE', { raison: 'RAISON_INVENTEE' }), /« RAISON_INVENTEE »/);
|
||||||
|
for (const texte of [inventee, seule, parSonTexte]) for (const trace of TRACES) assert.doesNotMatch(texte, trace);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un état aux octets hors UTF-8 se dit par son encodage : en lecture seule, il nomme le secours qui le met de côté ; le journal illisible, l'historique laissé tel quel", () => {
|
||||||
|
const seule = libelle('LECTURE_SEULE', { raison: 'ENCODAGE' });
|
||||||
|
assert.ok(seule.includes(libelle('ENCODAGE')), seule);
|
||||||
|
assert.match(seule, /secours/);
|
||||||
|
assert.match(seule, /corbeille/);
|
||||||
|
assert.notEqual(seule, libelle('LECTURE_SEULE', { raison: 'FORMAT' }));
|
||||||
|
const journal = libelle('JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' });
|
||||||
|
assert.ok(journal.includes(libelle('ENCODAGE')), journal);
|
||||||
|
assert.match(libelle('JOURNAL_RECREE'), /historique/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les propositions manquantes se disent par leur nombre, au singulier comme au pluriel, sans jamais les énumérer', () => {
|
||||||
|
assert.match(libelle('PROPOSITIONS_MANQUANTES', { n: 1 }), /^1 proposition que l'en-tête annonce manque/);
|
||||||
|
const enorme = libelle('PROPOSITIONS_MANQUANTES', { n: 2 ** 40 });
|
||||||
|
assert.match(enorme, new RegExp(`^${2 ** 40} propositions que l'en-tête annonce manquent`));
|
||||||
|
assert.ok(enorme.length < 200, enorme);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => {
|
test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,25 @@
|
||||||
// Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire :
|
// Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire :
|
||||||
// un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un
|
// un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un
|
||||||
// plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture,
|
// plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture,
|
||||||
// MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Sur un plan bloqué, débloquer
|
// MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Un état aux octets hors
|
||||||
|
// UTF-8 ne s'ouvre qu'en secours, dont le premier geste le met de côté dans
|
||||||
|
// la corbeille (§ 8.8). Sur un plan bloqué, débloquer
|
||||||
// est le seul geste admis : il prend lui-même le verrou, et laisse la séance
|
// est le seul geste admis : il prend lui-même le verrou, et laisse la séance
|
||||||
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
|
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
|
||||||
// lecture et rend le verrou.
|
// lecture et rend le verrou.
|
||||||
//
|
//
|
||||||
|
// Le verrou pris — passer en écriture, débloquer —, la séance relit
|
||||||
|
// l'événement sur le disque : une autre séance a pu l'écrire tant que
|
||||||
|
// celle-ci le lisait. Un Ouvert de secours se relit par l'état dès que
|
||||||
|
// l'état a changé depuis l'ouverture ; un .precedent, par le journal dès
|
||||||
|
// que le journal a reçu une entrée ; et par sa source sinon. Ce qu'elle
|
||||||
|
// relit devient son Ouvert, et EVENEMENT_CHANGE {revision} le dit quand la
|
||||||
|
// révision ou la charge diffèrent : une séance n'écrit jamais par-dessus le
|
||||||
|
// travail d'une autre (§ 8.2, § 8.8). Un événement qui ne se relit plus rend
|
||||||
|
// le verrou, et le refus de la lecture remonte ; un état devenu illisible par
|
||||||
|
// ses octets hors UTF-8 se refuse comme un format plus récent,
|
||||||
|
// LECTURE_SEULE {raison: 'ENCODAGE'}.
|
||||||
|
//
|
||||||
// Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle
|
// Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle
|
||||||
// d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt
|
// d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt
|
||||||
// l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte
|
// l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte
|
||||||
|
|
@ -31,7 +45,14 @@
|
||||||
// l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé
|
// l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé
|
||||||
// ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà
|
// ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà
|
||||||
// l'événement, celui de la séance compris : deux copies d'un même
|
// l'événement, celui de la séance compris : deux copies d'un même
|
||||||
// identifiant ne se côtoient jamais dans un dossier (§ 8.6).
|
// identifiant ne se côtoient jamais dans un dossier (§ 8.6). Tant que le
|
||||||
|
// support est rompu, l'Ouvert de la séance est la seule copie qu'écrire
|
||||||
|
// ailleurs peut sauver : ouvrir, ouvrir un secours, créer et fermer se
|
||||||
|
// refusent SUPPORT_ROMPU_NON_SAUVE, sauf appelés avec {abandonner: true}.
|
||||||
|
//
|
||||||
|
// Les avertissements de la séance sont ceux de l'Ouvert courant — ceux de
|
||||||
|
// l'ouverture, puis ceux que le dépôt recalcule après chaque écriture —,
|
||||||
|
// suivis de ceux du dernier geste.
|
||||||
//
|
//
|
||||||
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
|
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
|
||||||
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
|
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
|
||||||
|
|
@ -51,7 +72,7 @@ import {
|
||||||
} from '../stockage/depot.js';
|
} from '../stockage/depot.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { fil, reconstruire } from '../stockage/journal.js';
|
import { fil, reconstruire } from '../stockage/journal.js';
|
||||||
import { appliquerCommande, chargeNeuve } from './commandes.js';
|
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||||
import { libelle } from './libelles.js';
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
|
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
|
||||||
|
|
@ -80,6 +101,10 @@ const avertissement = (code, details) => ({ code, details });
|
||||||
|
|
||||||
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
|
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
|
||||||
|
|
||||||
|
// La révision de la dernière entrée du journal de l'Ouvert, null sans
|
||||||
|
// journal.
|
||||||
|
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
|
||||||
|
|
||||||
// Le libellé de l'entrée de révision donnée.
|
// Le libellé de l'entrée de révision donnée.
|
||||||
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
|
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
|
||||||
|
|
||||||
|
|
@ -122,6 +147,9 @@ export function creerSeance({
|
||||||
const libelleReprise = libelle('ENTREE_REPRISE');
|
const libelleReprise = libelle('ENTREE_REPRISE');
|
||||||
|
|
||||||
// L'état de la séance. ouvert : l'Ouvert courant, null sans événement ;
|
// L'état de la séance. ouvert : l'Ouvert courant, null sans événement ;
|
||||||
|
// source : d'où il se relit, null pour l'état, 'precedent' ou 'journal'
|
||||||
|
// pour un secours que nul geste n'a encore écrit en état — 'journal' aussi
|
||||||
|
// après un jalon qui pose l'entrée de reprise sans écrire l'état ;
|
||||||
// verrouTenu : la séance tient le verrou de cet événement ; refusSupport :
|
// verrouTenu : la séance tient le verrou de cet événement ; refusSupport :
|
||||||
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
|
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
|
||||||
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
|
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
|
||||||
|
|
@ -130,12 +158,12 @@ export function creerSeance({
|
||||||
const etat = {
|
const etat = {
|
||||||
racine,
|
racine,
|
||||||
ouvert: null,
|
ouvert: null,
|
||||||
|
source: null,
|
||||||
mode: 'lecture',
|
mode: 'lecture',
|
||||||
verrouTenu: false,
|
verrouTenu: false,
|
||||||
supportRompu: false,
|
supportRompu: false,
|
||||||
refusSupport: null,
|
refusSupport: null,
|
||||||
verrouAutre: null,
|
verrouAutre: null,
|
||||||
avertissementsOuverture: [],
|
|
||||||
avertissementsGeste: [],
|
avertissementsGeste: [],
|
||||||
gestes: 0,
|
gestes: 0,
|
||||||
calculs: 0,
|
calculs: 0,
|
||||||
|
|
@ -233,6 +261,72 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'Ouvert que le disque porte pour l'événement de l'Ouvert reçu, et la
|
||||||
|
// source d'où il se relira : { frais, source }. Sans source, par l'état
|
||||||
|
// (ouvrir). Un Ouvert de secours se relit par l'état quand celui-ci se lit
|
||||||
|
// et que son texte n'est plus celui de l'ouverture : une écriture l'a
|
||||||
|
// remplacé, et la source revient à l'état. Sinon — l'état illisible, ou
|
||||||
|
// lisible et inchangé, que l'opérateur a écarté pour son secours (§ 8.9,
|
||||||
|
// point 3) —, par sa source (ouvrirSecours) ; un .precedent dont le journal
|
||||||
|
// lisible a reçu une entrée depuis cède au dernier instant du journal, qui
|
||||||
|
// porte cette entrée, et la source devient 'journal'. Un journal qui ne se
|
||||||
|
// lit plus — d'un format plus récent, mal encodé — laisse le .precedent,
|
||||||
|
// lecture seule comprise. Un refus autre qu'ETAT_ILLISIBLE remonte. Sans
|
||||||
|
// source, un état dont les octets ne sont plus de l'UTF-8 garde la séance
|
||||||
|
// en lecture, comme un format plus récent : LECTURE_SEULE {raison:
|
||||||
|
// 'ENCODAGE'} ; l'opérateur l'écarte en ouvrant un secours.
|
||||||
|
async function relire(ouvert) {
|
||||||
|
const { fs: systeme, racine: dossier, base, texteEtat } = ouvert;
|
||||||
|
if (etat.source === null) {
|
||||||
|
try {
|
||||||
|
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
|
||||||
|
} catch (erreur) {
|
||||||
|
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
|
||||||
|
if (illisible && erreur.details.raison === 'ENCODAGE') {
|
||||||
|
throw new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE' });
|
||||||
|
}
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parEtat = await ouvrirSurDisque(systeme, dossier, base);
|
||||||
|
if (parEtat.texteEtat !== texteEtat) return { frais: parEtat, source: null };
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE')) throw erreur;
|
||||||
|
}
|
||||||
|
const parSource = await ouvrirSecoursSurDisque(systeme, dossier, base, etat.source);
|
||||||
|
const derniere = derniereEntree(parSource);
|
||||||
|
if (etat.source === 'precedent' && derniere !== null && derniere !== derniereEntree(ouvert)) {
|
||||||
|
return { frais: await ouvrirSecoursSurDisque(systeme, dossier, base, 'journal'), source: 'journal' };
|
||||||
|
}
|
||||||
|
return { frais: parSource, source: etat.source };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relit l'événement de l'Ouvert, le verrou de la séance pris (relire). Ce
|
||||||
|
// que la lecture rend devient l'Ouvert de la séance, journal et jalons
|
||||||
|
// compris ; rend [EVENEMENT_CHANGE {revision}] quand sa révision ou sa
|
||||||
|
// charge diffèrent de celles de l'Ouvert, [] sinon. Une entrée d'une autre
|
||||||
|
// séance change toujours la révision relue : l'état qu'elle a écrit, ou le
|
||||||
|
// dernier instant du journal qu'un .precedent lui cède (relire). Une
|
||||||
|
// lecture refusée rend le verrou — ses avertissements deviennent ceux du
|
||||||
|
// dernier geste —, et son refus remonte : la séance reste en lecture sur
|
||||||
|
// l'Ouvert reçu.
|
||||||
|
async function relireSousVerrou(ouvert) {
|
||||||
|
let relu;
|
||||||
|
try {
|
||||||
|
relu = await relire(ouvert);
|
||||||
|
} catch (erreur) {
|
||||||
|
etat.avertissementsGeste = await rendreVerrou(ouvert);
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
const { frais, source } = relu;
|
||||||
|
etat.ouvert = frais;
|
||||||
|
etat.source = source;
|
||||||
|
const change =
|
||||||
|
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
|
||||||
|
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
|
||||||
|
}
|
||||||
|
|
||||||
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
|
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
|
||||||
// retour explicite annule en plus le geste ouvert. Les avertissements du
|
// retour explicite annule en plus le geste ouvert. Les avertissements du
|
||||||
// rendu s'ajoutent à ceux du dernier geste.
|
// rendu s'ajoutent à ceux du dernier geste.
|
||||||
|
|
@ -256,6 +350,15 @@ export function creerSeance({
|
||||||
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
|
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quitter l'événement — ouvrir, créer, fermer — quand le support est
|
||||||
|
// rompu perdrait la seule copie qu'écrire ailleurs peut sauver : refusé,
|
||||||
|
// sauf sur abandon explicite.
|
||||||
|
function exigerSauve({ abandonner = false } = {}) {
|
||||||
|
if (etat.supportRompu && abandonner !== true) {
|
||||||
|
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
|
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
|
||||||
// l'ordre de l'en-tête du module.
|
// l'ordre de l'en-tête du module.
|
||||||
function exigerEcriture() {
|
function exigerEcriture() {
|
||||||
|
|
@ -311,6 +414,7 @@ export function creerSeance({
|
||||||
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
|
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
|
||||||
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
|
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
|
||||||
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
|
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
|
||||||
|
etat.source = null;
|
||||||
const suite = [...avertissements];
|
const suite = [...avertissements];
|
||||||
const etatDuPlan = ouvert.charge.evenement.etat;
|
const etatDuPlan = ouvert.charge.evenement.etat;
|
||||||
if (etatDuPlan !== depart.charge.evenement.etat) {
|
if (etatDuPlan !== depart.charge.evenement.etat) {
|
||||||
|
|
@ -340,19 +444,37 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La
|
// Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La
|
||||||
// commande d'abord, qui ne touche rien ; puis le verrou, que la séance
|
// commande d'abord, qui ne touche rien ; puis, en lecture, le verrou, que
|
||||||
// prend en lecture ; puis l'entrée et le jalon de l'état rendu. La séance
|
// la séance prend, et la relecture de l'événement (relireSousVerrou) ; la
|
||||||
// reste en écriture.
|
// commande s'applique alors à ce qui est relu. Celui-ci d'un format plus
|
||||||
|
// récent, ou que la commande refuse — une autre séance l'a débloqué —,
|
||||||
|
// rend le verrou, la séance restant en lecture sur ce qu'elle a relu, et
|
||||||
|
// le refus remonte. Sinon l'entrée et le jalon de l'état rendu s'écrivent,
|
||||||
|
// et la séance reste en écriture.
|
||||||
async function debloquerPlan(ouvert, args) {
|
async function debloquerPlan(ouvert, args) {
|
||||||
exigerSupport();
|
exigerSupport();
|
||||||
const resultat = appliquerCommande('debloquer', ouvert.charge, args);
|
let resultat = appliquerCommande('debloquer', ouvert.charge, args);
|
||||||
const avertissements = etat.mode === 'ecriture' ? [] : await prendreVerrou(ouvert);
|
let depart = ouvert;
|
||||||
|
const avertissements = [];
|
||||||
|
if (etat.mode !== 'ecriture') {
|
||||||
|
avertissements.push(...(await prendreVerrou(ouvert)));
|
||||||
|
avertissements.push(...(await relireSousVerrou(ouvert)));
|
||||||
|
depart = etat.ouvert;
|
||||||
|
try {
|
||||||
|
if (depart.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
resultat = appliquerCommande('debloquer', depart.charge, args);
|
||||||
|
} catch (erreur) {
|
||||||
|
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
etat.mode = 'ecriture';
|
etat.mode = 'ecriture';
|
||||||
return ecrireGeste(ouvert, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
|
return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
|
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
|
||||||
// commande ajoute.
|
// commande ajoute. Un rendu au libellé null n'écrit rien : la séance rend
|
||||||
|
// son résumé, le libellé null.
|
||||||
async function geste(nom, args) {
|
async function geste(nom, args) {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
|
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
|
||||||
|
|
@ -360,6 +482,10 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
exigerEcriture();
|
exigerEcriture();
|
||||||
const resultat = appliquerCommande(nom, ouvert.charge, args);
|
const resultat = appliquerCommande(nom, ouvert.charge, args);
|
||||||
|
if (resultat.libelle === null) {
|
||||||
|
etat.avertissementsGeste = [...resultat.avertissements];
|
||||||
|
return { libelle: null, resume: resultat.resume };
|
||||||
|
}
|
||||||
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
|
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
|
||||||
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
|
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
|
||||||
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
|
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
|
||||||
|
|
@ -461,12 +587,12 @@ export function creerSeance({
|
||||||
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
|
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
|
||||||
Object.assign(etat, {
|
Object.assign(etat, {
|
||||||
ouvert: null,
|
ouvert: null,
|
||||||
|
source: null,
|
||||||
mode: 'lecture',
|
mode: 'lecture',
|
||||||
verrouTenu: false,
|
verrouTenu: false,
|
||||||
supportRompu: false,
|
supportRompu: false,
|
||||||
refusSupport: null,
|
refusSupport: null,
|
||||||
verrouAutre: null,
|
verrouAutre: null,
|
||||||
avertissementsOuverture: [],
|
|
||||||
avertissementsGeste: [],
|
avertissementsGeste: [],
|
||||||
gestes: 0,
|
gestes: 0,
|
||||||
calculs: 0,
|
calculs: 0,
|
||||||
|
|
@ -475,12 +601,13 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
|
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
|
||||||
// fermé le précédent.
|
// fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
|
||||||
async function ouvrirAvec(lire) {
|
async function ouvrirAvec(lire, source, quitter) {
|
||||||
|
exigerSauve(quitter);
|
||||||
await fermerEvenement();
|
await fermerEvenement();
|
||||||
const ouvert = await lire();
|
const ouvert = await lire();
|
||||||
etat.ouvert = ouvert;
|
etat.ouvert = ouvert;
|
||||||
etat.avertissementsOuverture = ouvert.avertissements;
|
etat.source = source;
|
||||||
return { base: ouvert.base, avertissements: ouvert.avertissements };
|
return { base: ouvert.base, avertissements: ouvert.avertissements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,8 +616,10 @@ export function creerSeance({
|
||||||
// l'événement ouvert reste, son mode et son verrou compris ; puis le
|
// l'événement ouvert reste, son mode et son verrou compris ; puis le
|
||||||
// précédent se ferme, et s'écrivent l'entrée de création et le jalon
|
// précédent se ferme, et s'écrivent l'entrée de création et le jalon
|
||||||
// « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état
|
// « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état
|
||||||
// que le support refuse d'écrire, le journal écrit, rompt le support.
|
// que le support refuse d'écrire, le journal écrit, rompt le support. Le
|
||||||
async function creerEvenement(champs) {
|
// support de l'événement ouvert rompu, refusé sauf abandon (exigerSauve).
|
||||||
|
async function creerEvenement(champs, quitter) {
|
||||||
|
exigerSauve(quitter);
|
||||||
const { charge, libelle: texte } = chargeNeuve(champs, identifiant);
|
const { charge, libelle: texte } = chargeNeuve(champs, identifiant);
|
||||||
await fermerEvenement();
|
await fermerEvenement();
|
||||||
const options = {
|
const options = {
|
||||||
|
|
@ -505,12 +634,14 @@ export function creerSeance({
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
etat.avertissementsOuverture = etat.ouvert.avertissements;
|
|
||||||
return { base: etat.ouvert.base };
|
return { base: etat.ouvert.base };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Les modes (§ 8.4, § 8.8) -------------------------------------------------------
|
// --- Les modes (§ 8.4, § 8.8) -------------------------------------------------------
|
||||||
|
|
||||||
|
// Le verrou pris, l'événement se relit (relireSousVerrou) ; relu d'un
|
||||||
|
// format plus récent, ou bloqué par une autre séance, il rend le verrou,
|
||||||
|
// et la séance reste en lecture sur ce qu'elle a relu, le refus levé.
|
||||||
async function passerEnEcriture() {
|
async function passerEnEcriture() {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
|
@ -518,6 +649,15 @@ export function creerSeance({
|
||||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||||
exigerSupport();
|
exigerSupport();
|
||||||
const avertissements = await prendreVerrou(ouvert);
|
const avertissements = await prendreVerrou(ouvert);
|
||||||
|
avertissements.push(...(await relireSousVerrou(ouvert)));
|
||||||
|
const relu = etat.ouvert;
|
||||||
|
let refus = null;
|
||||||
|
if (relu.formatPlusRecent) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
else if (estBloque(relu)) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
||||||
|
if (refus !== null) {
|
||||||
|
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
|
||||||
|
throw refus;
|
||||||
|
}
|
||||||
etat.mode = 'ecriture';
|
etat.mode = 'ecriture';
|
||||||
etat.avertissementsGeste = avertissements;
|
etat.avertissementsGeste = avertissements;
|
||||||
armer();
|
armer();
|
||||||
|
|
@ -533,14 +673,20 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en
|
// Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en
|
||||||
// NFC, ses blancs de bord retirés.
|
// NFC, ses blancs de bord retirés. Un jalon qui pose d'abord l'entrée de
|
||||||
|
// reprise avance la révision de l'Ouvert sans écrire l'état : le dernier
|
||||||
|
// instant du journal porte désormais la charge de la séance, et la source
|
||||||
|
// de la relecture devient 'journal'.
|
||||||
async function nommer(nom) {
|
async function nommer(nom) {
|
||||||
try {
|
try {
|
||||||
const ouvert = exigerEcriture();
|
const ouvert = exigerEcriture();
|
||||||
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
|
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
|
||||||
const texte = nom.normalize('NFC').trim();
|
const texte = nom.normalize('NFC').trim();
|
||||||
if (texte === '') throw new ErreurSeance('NOM_REQUIS');
|
if (texte === '') throw new ErreurSeance('NOM_REQUIS');
|
||||||
await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise }));
|
const nomme = await ecrire(ouvert, (o) =>
|
||||||
|
nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise }),
|
||||||
|
);
|
||||||
|
if (nomme.revision !== ouvert.revision) etat.source = 'journal';
|
||||||
} finally {
|
} finally {
|
||||||
await apresGeste();
|
await apresGeste();
|
||||||
}
|
}
|
||||||
|
|
@ -573,7 +719,10 @@ export function creerSeance({
|
||||||
// transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en
|
// transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en
|
||||||
// lecture, le support rompu par ce refus, le verrou rendu là où il était ;
|
// lecture, le support rompu par ce refus, le verrou rendu là où il était ;
|
||||||
// un nouvel essai y trouve ce journal, et se refuse. Tout autre échec
|
// un nouvel essai y trouve ce journal, et se refuse. Tout autre échec
|
||||||
// laisse tout en l'état, le support toujours rompu.
|
// laisse tout en l'état, le support toujours rompu. Réussi, il demande le
|
||||||
|
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
|
||||||
|
// annonce la garantie affaiblie d'un support amovible, comme la règle du
|
||||||
|
// dossier de travail au démarrage (§ 8.8).
|
||||||
async function ecrireAilleurs(nouvelle) {
|
async function ecrireAilleurs(nouvelle) {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
|
@ -595,7 +744,7 @@ export function creerSeance({
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
const avertissements = await rendreVerrou(ouvert);
|
const avertissements = await rendreVerrou(ouvert);
|
||||||
Object.assign(etat, { ouvert: ailleurs, racine: nouvelle, supportRompu: false, refusSupport: null });
|
Object.assign(etat, { ouvert: ailleurs, source: null, racine: nouvelle, supportRompu: false, refusSupport: null });
|
||||||
if (etat.mode === 'ecriture') {
|
if (etat.mode === 'ecriture') {
|
||||||
try {
|
try {
|
||||||
avertissements.push(...(await prendreVerrou(ailleurs)));
|
avertissements.push(...(await prendreVerrou(ailleurs)));
|
||||||
|
|
@ -605,11 +754,26 @@ export function creerSeance({
|
||||||
avertissements.push(avertissement(erreur.code, erreur.details));
|
avertissements.push(avertissement(erreur.code, erreur.details));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ((await typeDeSupport(nouvelle)) === 'amovible') {
|
||||||
|
avertissements.push(avertissement('SUPPORT_AMOVIBLE', { dossier: nouvelle.chemin }));
|
||||||
|
}
|
||||||
etat.avertissementsGeste = avertissements;
|
etat.avertissementsGeste = avertissements;
|
||||||
armer();
|
armer();
|
||||||
return { base: ailleurs.base, avertissements };
|
return { base: ailleurs.base, avertissements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le type du support de la racine (§ 8.8) ; 'inconnu' pour toute faute du
|
||||||
|
// système qui le demande — un refus du stockage, une réponse du pont
|
||||||
|
// illisible : l'événement y est déjà écrit et la séance transportée, et le
|
||||||
|
// type ne sert qu'à un avertissement.
|
||||||
|
async function typeDeSupport(dossier) {
|
||||||
|
try {
|
||||||
|
return await fs.typeSupport(dossier);
|
||||||
|
} catch {
|
||||||
|
return 'inconnu';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** L'identifiant de la séance, qui signe son verrou. */
|
/** L'identifiant de la séance, qui signe son verrou. */
|
||||||
get id() {
|
get id() {
|
||||||
|
|
@ -642,9 +806,9 @@ export function creerSeance({
|
||||||
get verrouAutre() {
|
get verrouAutre() {
|
||||||
return etat.verrouAutre;
|
return etat.verrouAutre;
|
||||||
},
|
},
|
||||||
/** Ceux de l'ouverture, puis ceux du dernier geste. */
|
/** Ceux de l'Ouvert courant, recalculés à chaque écriture, puis ceux du dernier geste. */
|
||||||
get avertissements() {
|
get avertissements() {
|
||||||
return [...etat.avertissementsOuverture, ...etat.avertissementsGeste];
|
return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
|
||||||
},
|
},
|
||||||
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
|
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
|
||||||
get retours() {
|
get retours() {
|
||||||
|
|
@ -659,11 +823,17 @@ export function creerSeance({
|
||||||
return etat.calculs > 0;
|
return etat.calculs > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
ouvrir: (base) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base))),
|
// quitter.abandonner : vrai pour quitter l'événement d'un support rompu
|
||||||
ouvrirSecours: (base, source) =>
|
// (exigerSauve).
|
||||||
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source))),
|
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
|
||||||
creer: (champs) => enFile(() => creerEvenement(champs)),
|
ouvrirSecours: (base, source, quitter) =>
|
||||||
fermer: () => enFile(async () => ({ avertissements: await fermerEvenement() })),
|
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source), source, quitter)),
|
||||||
|
creer: (champs, quitter) => enFile(() => creerEvenement(champs, quitter)),
|
||||||
|
fermer: (quitter) =>
|
||||||
|
enFile(async () => {
|
||||||
|
exigerSauve(quitter);
|
||||||
|
return { avertissements: await fermerEvenement() };
|
||||||
|
}),
|
||||||
passerEnEcriture: () => enFile(passerEnEcriture),
|
passerEnEcriture: () => enFile(passerEnEcriture),
|
||||||
passerEnLecture: () => enFile(passerEnLecture),
|
passerEnLecture: () => enFile(passerEnLecture),
|
||||||
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
||||||
|
|
@ -673,6 +843,11 @@ export function creerSeance({
|
||||||
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
|
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
|
||||||
nommer: (nom) => enFile(() => nommer(nom)),
|
nommer: (nom) => enFile(() => nommer(nom)),
|
||||||
ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)),
|
ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)),
|
||||||
|
/**
|
||||||
|
* La forme nommée d'une proposition ou du retenu (formeNommeeDe) : une
|
||||||
|
* lecture, admise dans tout mode, qui n'écrit rien.
|
||||||
|
*/
|
||||||
|
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
|
||||||
|
|
||||||
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
|
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
|
||||||
// ils s'emboîtent, et un fini de trop ne compte pas.
|
// ils s'emboîtent, et un fini de trop ne compte pas.
|
||||||
|
|
|
||||||
|
|
@ -572,7 +572,9 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
|
||||||
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
|
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
|
||||||
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
|
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
|
||||||
assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]);
|
assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]);
|
||||||
assert.deepEqual(seance.avertissements, enRetenu.avertissements);
|
// La séance montre aussi la dérive que l'Ouvert écrit porte désormais.
|
||||||
|
assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...enRetenu.avertissements]);
|
||||||
|
assert.deepEqual(seance.ouvert.avertissements.map(({ code }) => code), ['DERIVE']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => {
|
test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => {
|
||||||
|
|
@ -609,6 +611,499 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § 9, § 10.1)", () => {
|
||||||
|
// L'événement d'essai, ouvert depuis le disque, en écriture.
|
||||||
|
async function essaiEnEcriture() {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
await deposerEssai(fs);
|
||||||
|
const montage = monter({ fs });
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
await montage.seance.passerEnEcriture();
|
||||||
|
return montage;
|
||||||
|
}
|
||||||
|
const mettreAJour = (seance, csv) =>
|
||||||
|
seance.executer('importerParticipants', {
|
||||||
|
apercu: apercevoir(csv, { participants: seance.charge.participants, mode: 'mettreAJour' }),
|
||||||
|
mode: 'mettreAJour',
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un import qui exclut l'animatrice réservée dit ses réservations suspendues et son titre non pourvu, comme exclure (§ 4.4)", async () => {
|
||||||
|
const { seance } = await essaiEnEcriture();
|
||||||
|
const rendu = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n');
|
||||||
|
const suspendues = { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 };
|
||||||
|
assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 1 modifié');
|
||||||
|
assert.deepEqual(rendu.avertissements, [{ code: 'RESERVATIONS_SUSPENDUES', details: suspendues }]);
|
||||||
|
assert.equal(seance.charge.participants[0].exclu, true);
|
||||||
|
const retour = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;non\n');
|
||||||
|
assert.deepEqual(retour.avertissements, [{ code: 'RESERVATIONS_RETABLIES', details: suspendues }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un import dont les lignes valides ne changent rien, mais qui refuse des lignes, n'écrit rien et rend son résumé", async () => {
|
||||||
|
const { fs, seance } = await essaiEnEcriture();
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine' } });
|
||||||
|
const avant = fs.compteurs.ecritures;
|
||||||
|
const entrees = journalDe(fs).entrees.length;
|
||||||
|
const rendu = await mettreAJour(seance, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n');
|
||||||
|
assert.equal(rendu.libelle, null);
|
||||||
|
assert.deepEqual(rendu.avertissements, []);
|
||||||
|
assert.deepEqual(rendu.resume.refusees.map(({ ligne, code }) => [ligne, code]), [[2, 'PLUSIEURS_CORRESPONDENT']]);
|
||||||
|
assert.equal(fs.compteurs.ecritures, avant);
|
||||||
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
||||||
|
assert.deepEqual(seance.avertissements, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("après un geste, la séance montre les avertissements de l'Ouvert écrit : la dérive paraît et s'en va (§ 9)", async () => {
|
||||||
|
const { seance } = await essaiEnEcriture();
|
||||||
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||||
|
await seance.executer('retenirProposition', { id: 1 });
|
||||||
|
assert.deepEqual(seance.avertissements, []);
|
||||||
|
const exclusion = await seance.executer('exclureParticipant', { id: 2 });
|
||||||
|
assert.deepEqual(exclusion.avertissements, []);
|
||||||
|
assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE']);
|
||||||
|
assert.deepEqual(seance.avertissements, seance.ouvert.avertissements);
|
||||||
|
assert.equal(seance.avertissements[0].details.retenu, true);
|
||||||
|
await seance.executer('reintegrerParticipant', { id: 2 });
|
||||||
|
assert.deepEqual(seance.avertissements, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le journal incomplet de l'ouverture ne s'affiche plus une fois la reprise écrite", async () => {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
await deposerEssai(fs);
|
||||||
|
const plus = chargeEssai();
|
||||||
|
plus.participants.push(personne(5, 'Ancolie'));
|
||||||
|
plus.prochainsIds.participant = 6;
|
||||||
|
fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 }));
|
||||||
|
const montage = monter({ fs });
|
||||||
|
const { seance } = montage;
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
const incomplet = [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }];
|
||||||
|
assert.deepEqual(seance.avertissements, incomplet);
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
assert.deepEqual(seance.avertissements, incomplet);
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } });
|
||||||
|
assert.deepEqual(seance.avertissements, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('séance : la forme nommée se lit sans rien écrire (§ 8.9, point 4)', () => {
|
||||||
|
test("la forme nommée d'une proposition ou du retenu se lit en lecture, sans entrée ni écriture", async () => {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
await deposerEssai(fs);
|
||||||
|
const montage = monter({ fs });
|
||||||
|
const { seance } = montage;
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||||
|
await seance.executer('retenirProposition', { id: 2 });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
const avant = fs.compteurs.ecritures;
|
||||||
|
const entrees = journalDe(fs).entrees.length;
|
||||||
|
const proposition = await seance.formeNommee({ proposition: 1 });
|
||||||
|
assert.deepEqual(proposition.fautes, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
proposition.lignes.filter(({ tour }) => tour === 1).map(({ nom }) => nom).sort(),
|
||||||
|
['Grisaille', 'Lacasse', 'Ombrelle', 'Pervenche'],
|
||||||
|
);
|
||||||
|
const retenu = await seance.formeNommee({ retenu: true });
|
||||||
|
assert.equal(retenu.lignes.length, 8);
|
||||||
|
await refus(seance.formeNommee({ proposition: 7 }), 'PROPOSITION_INCONNUE');
|
||||||
|
assert.equal(fs.compteurs.ecritures, avant);
|
||||||
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
||||||
|
assert.equal(seance.mode, 'lecture');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un retenu fautif sur le disque se lit sous forme nommée par la séance : ses lignes et ses fautes (RETENU_FAUTIF, § 8.9)", async () => {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
await deposerEssai(fs);
|
||||||
|
const montage = monter({ fs });
|
||||||
|
const { seance } = montage;
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||||
|
await seance.executer('retenirProposition', { id: 1 });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
const { entete, charge } = etatDe(fs);
|
||||||
|
const premier = charge.retenu.placement[0];
|
||||||
|
const assis = premier.sieges.find((liste) => liste.length > 0)[0];
|
||||||
|
premier.reserve = [...premier.reserve, assis].sort((a, b) => a - b);
|
||||||
|
fs.deposer('documents', ETAT, serialiser(charge, { revision: entete.revision, produitVersion: V1 }));
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
assert.equal(seance.avertissements[0].code, 'RETENU_FAUTIF');
|
||||||
|
const lue = await seance.formeNommee({ retenu: true });
|
||||||
|
assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]);
|
||||||
|
assert.equal(lue.lignes.length, 9);
|
||||||
|
assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans événement ouvert, la forme nommée est refusée AUCUN_EVENEMENT', async () => {
|
||||||
|
await refus(monter().seance.formeNommee({ retenu: true }), 'AUCUN_EVENEMENT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () => {
|
||||||
|
// Deux processus d'un même poste sur un même disque : la première séance
|
||||||
|
// crée l'événement d'essai, puis la seconde l'ouvre en lecture.
|
||||||
|
async function deuxSeances() {
|
||||||
|
const disque = new Map();
|
||||||
|
const premiere = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||||
|
await premiere.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
|
||||||
|
const seconde = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' });
|
||||||
|
await ouvrirSansEcrire(seconde);
|
||||||
|
return { premiere: premiere.seance, seconde: seconde.seance, fs: seconde.fs, fsPremiere: premiere.fs, disque };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("passer en écriture relit l'événement : le travail de l'autre séance est adopté, EVENEMENT_CHANGE le dit, et le geste suivant s'écrit à sa suite", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }];
|
||||||
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs).seance], ['ecriture', seconde.id]);
|
||||||
|
assert.deepEqual(noms(seconde.charge), ['Xavier']);
|
||||||
|
assert.deepEqual(seconde.avertissements, change);
|
||||||
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
||||||
|
const journal = journalDe(fs);
|
||||||
|
assert.deepEqual(libellesDe(journal), [CREATION, 'Ajouté Xavier', 'Ajouté Yvette']);
|
||||||
|
assert.equal(journal.ecartees, 0);
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
|
||||||
|
assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans geste de l'autre séance, rien n'avertit ; son jalon est adopté avec le journal relu", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.nommer('Avant les invitations');
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: [] });
|
||||||
|
assert.deepEqual(seconde.ouvert.journal.jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
|
||||||
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
||||||
|
assert.deepEqual(journalDe(fs).jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un événement qui a quitté le disque entre-temps, renommé ailleurs : le passage en écriture est refusé, le verrou rendu, rien ne se recrée", async () => {
|
||||||
|
const { seconde, fs, disque } = await deuxSeances();
|
||||||
|
const autre = creerFichiersSimules({ disque });
|
||||||
|
for (const fichier of [ETAT, JOURNAL]) await autre.supprimer(DOC, fichier);
|
||||||
|
await refus(seconde.passerEnEcriture(), 'ABSENT');
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
||||||
|
assert.deepEqual((await fs.lister(DOC, '')).map(({ nom }) => nom), []);
|
||||||
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }), 'MODE_LECTURE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un plan que l'autre séance a bloqué entre-temps : adopté, EVENEMENT_CHANGE le dit, et le passage est refusé BLOQUE, le verrou rendu", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'BLOQUE' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'bloque']);
|
||||||
|
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("débloquer relit l'événement sous le verrou : le déblocage s'écrit à la suite du travail de l'autre séance", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await seconde.ouvrir(BASE);
|
||||||
|
await premiere.debloquer();
|
||||||
|
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
assert.equal(premiere.mode, 'lecture');
|
||||||
|
const rendu = await seconde.debloquer();
|
||||||
|
assert.deepEqual(rendu, {
|
||||||
|
libelle: 'Plan débloqué, état « Brouillon »',
|
||||||
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
|
||||||
|
});
|
||||||
|
assert.equal(seconde.mode, 'ecriture');
|
||||||
|
const journal = journalDe(fs);
|
||||||
|
assert.deepEqual(journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4, 5, 6]);
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier']);
|
||||||
|
assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("débloquer un plan que l'autre séance a débloqué entre-temps : refusé PLAN_NON_BLOQUE, adopté, le verrou rendu", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await seconde.ouvrir(BASE);
|
||||||
|
await premiere.debloquer();
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
const entrees = journalDe(fs).entrees.length;
|
||||||
|
await refus(seconde.debloquer(), 'PLAN_NON_BLOQUE');
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'brouillon']);
|
||||||
|
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }]);
|
||||||
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un secours ouvert se relit par la même source : passer en écriture ne bute pas sur l'état illisible", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await seance.ouvrirSecours(BASE, 'journal');
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'événement de la première séance porte Aubépine puis Bourrache, son
|
||||||
|
// état devient illisible, et chacune des deux ouvre son .precedent : la
|
||||||
|
// révision 2, Aubépine seule.
|
||||||
|
async function surPrecedent() {
|
||||||
|
const deux = await deuxSeances();
|
||||||
|
const { premiere, seconde, fs } = deux;
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await premiere.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
for (const seance of [premiere, seconde]) await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [2, ['Aubépine']]);
|
||||||
|
return deux;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const gestes of [['Cerfeuil'], ['Cerfeuil', 'Dictame']]) {
|
||||||
|
test(`un secours .precedent se relit par l'état qu'une autre séance a écrit depuis (${gestes.length} geste·s) : adopté, EVENEMENT_CHANGE le dit, rien ne s'efface`, async () => {
|
||||||
|
const { premiere, seconde, fs } = await surPrecedent();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
for (const nom of gestes) await premiere.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
const { revision } = etatDe(fs).entete;
|
||||||
|
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision } }];
|
||||||
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
|
||||||
|
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [revision, ['Aubépine', ...gestes]]);
|
||||||
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', ...gestes, 'Estragon']);
|
||||||
|
assert.equal(journalDe(fs).ecartees, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("un secours .precedent dont l'état reste illisible, mais dont le journal a reçu le geste d'une autre séance : le dernier instant du journal est adopté, EVENEMENT_CHANGE le dit", async () => {
|
||||||
|
const { premiere, seconde, fs, fsPremiere } = await surPrecedent();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.nommer('Point');
|
||||||
|
fsPremiere.pannes.renommage('documents');
|
||||||
|
await refus(premiere.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ETAT_NON_ECRIT');
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
assert.equal(fs.contenu('documents', ETAT), '');
|
||||||
|
assert.deepEqual(await seconde.passerEnEcriture(), {
|
||||||
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(noms(seconde.charge), ['Aubépine', 'Cerfeuil']);
|
||||||
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil', 'Estragon']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un secours .precedent dont l'état redevient lisible entre-temps se relit par l'état, sans entrée nouvelle au journal : EVENEMENT_CHANGE le dit", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
const lisible = fs.contenu('documents', ETAT);
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
fs.deposer('documents', ETAT, lisible);
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), {
|
||||||
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(noms(seance.charge), ['Aubépine', 'Bourrache']);
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Bourrache', 'Cerfeuil']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un secours .precedent ouvert devant un état lisible se relit par le .precedent tant que l'état ne change pas : le choix de l'opérateur reste (§ 8.9, point 3)", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [2, ['Aubépine']]);
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("après un geste sur un secours .precedent, la séance se relit par l'état : redevenu illisible, il refuse le passage en écriture, sans revenir au .precedent", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await refus(seance.passerEnEcriture(), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual([seance.mode, verrouDe(fs), noms(seance.charge)], ['lecture', null, ['Aubépine', 'Cerfeuil']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("après avoir écrit ailleurs un secours .precedent, la séance s'y relit par l'état, que le dossier nouveau porte seul", async () => {
|
||||||
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
||||||
|
fs.deposerDossier('portable');
|
||||||
|
const montage = monter({ fs });
|
||||||
|
const { seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
fs.pannes.ecriture('documents');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ECRITURE');
|
||||||
|
await seance.ecrireAilleurs(PORTABLE);
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
assert.deepEqual([seance.mode, noms(seance.charge)], ['ecriture', ['Aubépine']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le jalon d'une séance seule sur un secours .precedent pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE ni recul de révision", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
await seance.ouvrirSecours(BASE, 'precedent');
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, ['Aubépine']]);
|
||||||
|
const codes = seance.avertissements.map(({ code }) => code);
|
||||||
|
assert.ok(!codes.includes('JOURNAL_EN_AVANCE_DE'), codes.join());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le jalon d'une séance seule sur un état que le journal devance de deux entrées pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE", async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
const premier = fs.contenu('documents', ETAT);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
fs.deposer('documents', ETAT, premier);
|
||||||
|
await ouvrirSansEcrire(montage);
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'événement réécrit par une construction plus récente : l'état, ou la
|
||||||
|
// première ligne du journal, déclarent le format 2.
|
||||||
|
const plusRecent = {
|
||||||
|
etat: (fs) => fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).replace('"format":1', '"format":2')),
|
||||||
|
journal: (fs) => fs.deposer('documents', JOURNAL, fs.contenu('documents', JOURNAL).replace('"format":1', '"format":2')),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [fichier, designe] of [['etat', "l'état"], ['journal', 'le journal']]) {
|
||||||
|
test(`${designe} qu'une construction plus récente a réécrit entre-temps : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
|
||||||
|
const { seconde, fs } = await deuxSeances();
|
||||||
|
const entrees = journalDe(fs).entrees.length;
|
||||||
|
plusRecent[fichier](fs);
|
||||||
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
||||||
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
if (fichier === 'etat') assert.equal(journalDe(fs).entrees.length, entrees);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`un secours .precedent dont ${designe} est réécrit entre-temps par une construction plus récente : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
|
||||||
|
const { seconde, fs } = await surPrecedent();
|
||||||
|
if (fichier === 'etat') {
|
||||||
|
const precedent = fs.contenu('documents', `${BASE}${SUFFIXES.precedent}`);
|
||||||
|
fs.deposer('documents', ETAT, precedent.replace('"format":1', '"format":2'));
|
||||||
|
} else {
|
||||||
|
plusRecent.journal(fs);
|
||||||
|
}
|
||||||
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`un plan bloqué dont ${designe} est réécrit entre-temps par une construction plus récente : débloquer est refusé LECTURE_SEULE FORMAT, le verrou rendu, sans entrée`, async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await seconde.ouvrir(BASE);
|
||||||
|
const texte = fs.contenu('documents', JOURNAL);
|
||||||
|
plusRecent[fichier](fs);
|
||||||
|
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
||||||
|
if (fichier === 'etat') assert.equal(fs.contenu('documents', JOURNAL), texte);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'état réécrit en octets hors UTF-8 : son premier « é » remplacé par
|
||||||
|
// l'octet E9 d'un éditeur réglé en windows-1252. Rend ces octets.
|
||||||
|
function etatMalEncode(fs) {
|
||||||
|
const octets = [...new TextEncoder().encode(fs.contenu('documents', ETAT))];
|
||||||
|
const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9);
|
||||||
|
octets.splice(rang, 2, 0xe9);
|
||||||
|
fs.deposerOctets('documents', ETAT, new Uint8Array(octets));
|
||||||
|
return new Uint8Array(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les noms des fichiers de documents et le texte du journal : ce qu'un
|
||||||
|
// refus laisse tel quel.
|
||||||
|
const empreinte = async (fs) => [(await fs.lister(DOC, '')).map(({ nom }) => nom), fs.contenu('documents', JOURNAL)];
|
||||||
|
|
||||||
|
test("l'état réécrit entre-temps en octets hors UTF-8 : passer en écriture est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, comme un format plus récent, le verrou rendu et rien de l'événement écrit ; le refus a son texte", async () => {
|
||||||
|
const { seconde, fs } = await deuxSeances();
|
||||||
|
const octets = etatMalEncode(fs);
|
||||||
|
const avant = await empreinte(fs);
|
||||||
|
const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
||||||
|
assert.deepEqual(await empreinte(fs), avant);
|
||||||
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
||||||
|
assert.match(libelle(erreur.code, erreur.details), /UTF-8/);
|
||||||
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'MODE_LECTURE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un plan bloqué dont l'état est réécrit entre-temps en octets hors UTF-8 : débloquer est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, le verrou rendu, sans entrée", async () => {
|
||||||
|
const { premiere, seconde, fs } = await deuxSeances();
|
||||||
|
await premiere.passerEnEcriture();
|
||||||
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await premiere.passerEnLecture();
|
||||||
|
await seconde.ouvrir(BASE);
|
||||||
|
const octets = etatMalEncode(fs);
|
||||||
|
const avant = await empreinte(fs);
|
||||||
|
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
|
||||||
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
||||||
|
assert.deepEqual(await empreinte(fs), avant);
|
||||||
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const source of ['precedent', 'journal']) {
|
||||||
|
test(`ouvert par son secours ${source}, un état aux octets hors UTF-8 ne fige rien : passer en écriture réussit, et le premier geste met l'état dans un dossier daté de la corbeille, ses octets intacts, avant d'écrire`, async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
const octets = etatMalEncode(fs);
|
||||||
|
const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE');
|
||||||
|
assert.equal(illisible.details.raison, 'ENCODAGE');
|
||||||
|
assert.match(libelle(illisible.code, illisible.details), /UTF-8/);
|
||||||
|
await seance.ouvrirSecours(BASE, source);
|
||||||
|
assert.equal(seance.ouvert.etatMalEncode, true);
|
||||||
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
||||||
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
||||||
|
const [dossier] = (await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom);
|
||||||
|
assert.deepEqual(fs.octets('documents', `corbeille/${dossier}/${ETAT}`), octets);
|
||||||
|
assert.deepEqual(noms(etatDe(fs).charge), [...noms(seance.charge)]);
|
||||||
|
assert.ok(noms(seance.charge).includes('Cerfeuil'));
|
||||||
|
assert.equal(seance.ouvert.etatMalEncode, false);
|
||||||
|
for (const { code, details } of seance.avertissements) assert.equal(typeof libelle(code, details), 'string');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
|
describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
|
||||||
// Trois gestes après la création : Ancolie, Bruyère, Capucine, aux
|
// Trois gestes après la création : Ancolie, Bruyère, Capucine, aux
|
||||||
// révisions 2, 3 et 4.
|
// révisions 2, 3 et 4.
|
||||||
|
|
@ -916,8 +1411,8 @@ describe("séance : un fichier d'un format plus récent (§ 8.8)", () => {
|
||||||
|
|
||||||
describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
||||||
// Un événement du disque portable, en écriture, après un premier geste.
|
// Un événement du disque portable, en écriture, après un premier geste.
|
||||||
async function surPortable() {
|
async function surPortable(reglages = {}) {
|
||||||
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages });
|
||||||
const montage = monter({ fs, racine: PORTABLE });
|
const montage = monter({ fs, racine: PORTABLE });
|
||||||
await enEcriture(montage);
|
await enEcriture(montage);
|
||||||
await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
||||||
|
|
@ -1178,6 +1673,95 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
||||||
assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]);
|
assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("écrire ailleurs sur un support amovible l'annonce, comme la règle du dossier de travail au démarrage (§ 8.8)", async () => {
|
||||||
|
const { fs, seance } = await surPortable({ support: { documents: 'amovible' } });
|
||||||
|
fs.pannes.ecriture('portable');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
||||||
|
const amovible = { code: 'SUPPORT_AMOVIBLE', details: { dossier: DOCUMENTS } };
|
||||||
|
const { avertissements } = await seance.ecrireAilleurs(DOC);
|
||||||
|
assert.deepEqual(avertissements, [
|
||||||
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
||||||
|
amovible,
|
||||||
|
]);
|
||||||
|
assert.ok(seance.avertissements.some(({ code }) => code === 'SUPPORT_AMOVIBLE'));
|
||||||
|
assert.match(libelle(amovible.code, amovible.details), /Gestion table tournante Libre/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fautesDuPont = [
|
||||||
|
['stockage', () => new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' })],
|
||||||
|
['pont', () => new TypeError('réponse du pont illisible pour typeSupport')],
|
||||||
|
];
|
||||||
|
for (const [nature, faute] of fautesDuPont) {
|
||||||
|
test(`écrire ailleurs réussit quand le type du support ne se dit pas (faute ${nature}) : le support vaut inconnu, rien n'est annoncé`, async () => {
|
||||||
|
const { fs, seance, planificateur } = await surPortable({ support: { documents: 'amovible' } });
|
||||||
|
fs.pannes.ecriture('portable');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
||||||
|
fs.typeSupport = async () => {
|
||||||
|
throw faute();
|
||||||
|
};
|
||||||
|
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
||||||
|
assert.equal(base, BASE);
|
||||||
|
assert.deepEqual(avertissements, [
|
||||||
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(seance.avertissements, avertissements);
|
||||||
|
assert.deepEqual([seance.supportRompu, seance.mode, seance.racine, planificateur.actives], [false, 'ecriture', DOC, 1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tant que le support est rompu, ouvrir, ouvrir un secours, créer et fermer sont refusés SUPPORT_ROMPU_NON_SAUVE, sans rien changer", async () => {
|
||||||
|
const { fs, seance } = await surPortable();
|
||||||
|
fs.pannes.ecriture('portable');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
||||||
|
const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' };
|
||||||
|
const avant = fs.compteurs.ecritures;
|
||||||
|
await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
||||||
|
await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
||||||
|
await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
||||||
|
await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
||||||
|
assert.equal(fs.compteurs.ecritures, avant);
|
||||||
|
assert.deepEqual([seance.supportRompu, seance.refusSupport, noms(seance.charge)], [true, refusSupport, ['Ancolie']]);
|
||||||
|
assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', refusSupport), /« Écrire ailleurs »/);
|
||||||
|
await seance.ecrireAilleurs(DOC);
|
||||||
|
await seance.fermer();
|
||||||
|
assert.equal(seance.ouvert, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le support rompu se quitte sur abandon explicite : ouvrir, ou fermer, avec {abandonner: true}", async () => {
|
||||||
|
const { fs, seance } = await surPortable();
|
||||||
|
fs.pannes.ecriture('portable');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
||||||
|
const rendu = await seance.ouvrir(BASE, { abandonner: true });
|
||||||
|
assert.equal(rendu.base, BASE);
|
||||||
|
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie']]);
|
||||||
|
await refus(seance.passerEnEcriture(), 'ECRITURE');
|
||||||
|
assert.equal(seance.supportRompu, true);
|
||||||
|
await seance.fermer({ abandonner: true });
|
||||||
|
assert.deepEqual([seance.ouvert, seance.supportRompu], [null, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le support rompu se quitte sur abandon explicite : créer, ou ouvrir un secours, avec {abandonner: true}", async () => {
|
||||||
|
const { fs, seance } = await surPortable();
|
||||||
|
// Un état que le support refuse rompt le support ; les renommages
|
||||||
|
// rétablis ensuite, ce qui suit s'écrit.
|
||||||
|
const rompre = async (nom) => {
|
||||||
|
await seance.passerEnEcriture();
|
||||||
|
fs.pannes.renommage('portable');
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom } }), 'ETAT_NON_ECRIT');
|
||||||
|
fs.pannes.renommage('portable', 0);
|
||||||
|
assert.equal(seance.supportRompu, true);
|
||||||
|
};
|
||||||
|
await rompre('Bruyère');
|
||||||
|
const grillons = 'Soirée des Grillons';
|
||||||
|
const cree = await seance.creer({ nom: grillons, date: null, siegesParDefaut: 2, tours: 2 }, { abandonner: true });
|
||||||
|
assert.equal(cree.base, grillons);
|
||||||
|
assert.deepEqual([seance.supportRompu, seance.mode, seance.charge.evenement.nom], [false, 'lecture', grillons]);
|
||||||
|
await rompre('Cerfeuil');
|
||||||
|
const secours = await seance.ouvrirSecours(BASE, 'journal', { abandonner: true });
|
||||||
|
assert.equal(secours.base, BASE);
|
||||||
|
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie', 'Bruyère']]);
|
||||||
|
});
|
||||||
|
|
||||||
test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => {
|
test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => {
|
||||||
const { fs, seance } = await surPortable();
|
const { fs, seance } = await surPortable();
|
||||||
const avant = fs.compteurs.ecritures;
|
const avant = fs.compteurs.ecritures;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@
|
||||||
// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne,
|
// 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 ;
|
// 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
|
// 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
|
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
|
||||||
// revient dans le CSV des refus (§ 10.1).
|
// 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 { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
|
||||||
import { cleNormalisee } from './normalisation.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
|
* 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
|
* 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
|
* la valeur d'une personne existante qu'un import met à jour
|
||||||
*
|
*
|
||||||
* @typedef {Object} Apercu
|
* @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,
|
* @property {string|null} separateur null pour un fichier à une colonne,
|
||||||
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
|
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
|
||||||
* SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc
|
* 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
|
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
|
||||||
* pour les deux autres codes
|
* pour les deux autres codes
|
||||||
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
|
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
|
||||||
* chaque groupe d'au moins deux orthographes, existantes comprises,
|
* chaque groupe d'au moins deux orthographes, existantes comprises
|
||||||
* qui porte une ligne valide, rangé par sa première ligne ;
|
* 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 ;
|
* orthographes dans l'ordre de rencontre, la première affichée ;
|
||||||
* lignes : rangs des lignes valides de cette orthographe, vide pour
|
* lignes : rangs des lignes valides de cette orthographe, vide pour
|
||||||
* une orthographe que seuls des participants existants portent
|
* 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
|
* chaque triplet (nom, prénom, appartenance) normalisé que portent au
|
||||||
* moins deux personnes, dont une ligne valide, rangé par sa première
|
* moins deux personnes, dont une ligne valide, rangé par sa première
|
||||||
* ligne ; cle : le triplet normalisé, en JSON ; existants :
|
* 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
|
* @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
|
* sous un refus global, lignes, fusions, doublons, homonymes et
|
||||||
* refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
|
* multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
|
||||||
* refusées sont la cause
|
* refusées sont la cause
|
||||||
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
|
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
|
||||||
* l'enregistrement où le guillemet s'ouvre ; sous
|
* l'enregistrement où le guillemet s'ouvre ; sous
|
||||||
|
|
@ -105,14 +140,16 @@ export const VALEURS_EXCLU = new Map([
|
||||||
* null sinon
|
* null sinon
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
|
// Aperçu d'un texte refusé en entier : son mode, ce que la lecture a établi
|
||||||
// refus, aucune ligne.
|
// avant le refus, aucune ligne.
|
||||||
function refuser(
|
function refuserEn(
|
||||||
|
mode,
|
||||||
code,
|
code,
|
||||||
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
|
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
|
||||||
ligne = null,
|
ligne = null,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
mode,
|
||||||
separateur,
|
separateur,
|
||||||
entetes,
|
entetes,
|
||||||
association,
|
association,
|
||||||
|
|
@ -120,6 +157,8 @@ function refuser(
|
||||||
refusees: [],
|
refusees: [],
|
||||||
fusions: [],
|
fusions: [],
|
||||||
doublons: [],
|
doublons: [],
|
||||||
|
homonymes: [],
|
||||||
|
multilignes: [],
|
||||||
refusGlobal: code,
|
refusGlobal: code,
|
||||||
refusGlobalLigne: ligne,
|
refusGlobalLigne: ligne,
|
||||||
};
|
};
|
||||||
|
|
@ -237,9 +276,98 @@ function doublonsDe(existants, valides) {
|
||||||
.sort((a, b) => a.lignes[0] - b.lignes[0]);
|
.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
|
* 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 {string} texte le texte que rend decoder, ou celui d'un collage
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
|
|
@ -248,12 +376,17 @@ function doublonsDe(existants, valides) {
|
||||||
* ordonne leurs orthographes
|
* ordonne leurs orthographes
|
||||||
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
|
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
|
||||||
* une ambiguïté de l'en-tête (voir associer)
|
* une ambiguïté de l'en-tête (voir associer)
|
||||||
|
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par
|
||||||
|
* défaut
|
||||||
* @returns {Apercu}
|
* @returns {Apercu}
|
||||||
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
|
* @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 (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');
|
if (contientNul(texte)) return refuser('CARACTERE_NUL');
|
||||||
|
|
||||||
let separateur;
|
let separateur;
|
||||||
|
|
@ -295,7 +428,7 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
|
||||||
}
|
}
|
||||||
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
|
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 { afficheeDe, fusions } = reconcilier(existants, valides);
|
||||||
const lignes = valides.map(({ ligne, champs, brut }) => ({
|
const lignes = valides.map(({ ligne, champs, brut }) => ({
|
||||||
ligne,
|
ligne,
|
||||||
|
|
@ -303,11 +436,14 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) {
|
||||||
brut,
|
brut,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
|
mode,
|
||||||
...lu,
|
...lu,
|
||||||
lignes,
|
lignes,
|
||||||
refusees,
|
refusees,
|
||||||
fusions,
|
fusions,
|
||||||
doublons: doublonsDe(existants, lignes),
|
doublons: doublonsDe(existants, lignes),
|
||||||
|
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
|
||||||
|
multilignes: multilignesDe(lignes, separateur),
|
||||||
refusGlobal: null,
|
refusGlobal: null,
|
||||||
refusGlobalLigne: null,
|
refusGlobalLigne: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const refusGlobal = (
|
||||||
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
|
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
|
||||||
ligne = null,
|
ligne = null,
|
||||||
) => ({
|
) => ({
|
||||||
|
mode: 'ajouter',
|
||||||
separateur,
|
separateur,
|
||||||
entetes,
|
entetes,
|
||||||
association,
|
association,
|
||||||
|
|
@ -60,6 +61,8 @@ const refusGlobal = (
|
||||||
refusees: [],
|
refusees: [],
|
||||||
fusions: [],
|
fusions: [],
|
||||||
doublons: [],
|
doublons: [],
|
||||||
|
homonymes: [],
|
||||||
|
multilignes: [],
|
||||||
refusGlobal: code,
|
refusGlobal: code,
|
||||||
refusGlobalLigne: ligne,
|
refusGlobalLigne: ligne,
|
||||||
});
|
});
|
||||||
|
|
@ -94,6 +97,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
|
||||||
'Pervenche;Théo;;;;oui;',
|
'Pervenche;Théo;;;;oui;',
|
||||||
);
|
);
|
||||||
assert.deepEqual(apercevoir(texte), {
|
assert.deepEqual(apercevoir(texte), {
|
||||||
|
mode: 'ajouter',
|
||||||
separateur: ';',
|
separateur: ';',
|
||||||
entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'],
|
entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'],
|
||||||
association: {
|
association: {
|
||||||
|
|
@ -119,6 +123,8 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
|
||||||
refusees: [],
|
refusees: [],
|
||||||
fusions: [],
|
fusions: [],
|
||||||
doublons: [],
|
doublons: [],
|
||||||
|
homonymes: [],
|
||||||
|
multilignes: [],
|
||||||
refusGlobal: null,
|
refusGlobal: null,
|
||||||
refusGlobalLigne: 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)', () => {
|
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', () => {
|
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
|
// 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à", () => {
|
test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => {
|
||||||
for (const rang of [2, 21]) {
|
for (const rang of [2, 21]) {
|
||||||
const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x');
|
const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x');
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,10 @@
|
||||||
// champs en trop parmi les vingt premiers enregistrements, qui fait refuser
|
// champs en trop parmi les vingt premiers enregistrements, qui fait refuser
|
||||||
// le texte entier, à son rang, par le choix du séparateur.
|
// le texte entier, à son rang, par le choix du séparateur.
|
||||||
|
|
||||||
|
import { MODES, designer, indexerParNom } from './apercu.js';
|
||||||
import { ErreurCsv } from './erreurs.js';
|
import { ErreurCsv } from './erreurs.js';
|
||||||
import { SEPARATEUR, ecrireCsv } from './export.js';
|
import { SEPARATEUR, ecrireCsv } from './export.js';
|
||||||
import { enregistrementVide } from './lecture.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
|
// 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
|
// 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
|
* @property {boolean} derive vrai en état retenu
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const croissant = (a, b) => a - b;
|
|
||||||
const parLigne = (a, b) => a.ligne - b.ligne;
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// les lignes à ajouter, les refus et les comptes. Chaque ligne désigne par
|
||||||
// participants d'avant l'import dont le nom et le prénom ont la même clé
|
// designer, contre l'index des participants d'avant l'import : un
|
||||||
// normalisée que les siens, et l'appartenance aussi quand la ligne en porte
|
// participant ajouté par une ligne n'est désigné par aucune autre, et le
|
||||||
// une : un participant ajouté par une ligne n'est désigné par aucune autre.
|
// prénom ou l'appartenance qu'une ligne pose n'écarte pas ce participant des
|
||||||
// Un prénom absent compte comme vide, sur la ligne comme chez le
|
// candidats d'une suivante. Une ligne qui ne désigne personne est à ajouter.
|
||||||
// participant : seule une ligne sans prénom désigne un participant sans
|
// Une ligne qui en désigne plusieurs est refusée PLUSIEURS_CORRESPONDENT,
|
||||||
// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne
|
// jamais ajoutée. Le participant qu'une ligne désigne seule est mis à jour
|
||||||
// filtre même un homonyme unique, et un participant sans appartenance ne
|
// par la première de ces lignes, dans l'ordre du fichier ; chaque suivante
|
||||||
// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter,
|
// est refusée DEJA_DESIGNE : deux lignes ne se fondent pas en silence dans
|
||||||
// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est
|
// une même personne.
|
||||||
// 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.
|
|
||||||
function mettreAJour(participants, lignes) {
|
function mettreAJour(participants, lignes) {
|
||||||
const parNom = new Map();
|
const index = indexerParNom(participants);
|
||||||
for (const participant of participants) {
|
const parId = new Map(participants.map((participant) => [participant.id, participant]));
|
||||||
const cle = cleDeNom(participant.nom, participant.prenom);
|
|
||||||
if (!parNom.has(cle)) parNom.set(cle, []);
|
|
||||||
parNom.get(cle).push(participant);
|
|
||||||
}
|
|
||||||
const designes = new Map();
|
const designes = new Map();
|
||||||
const nouvelles = [];
|
const nouvelles = [];
|
||||||
const refusees = [];
|
const refusees = [];
|
||||||
let misAJour = 0;
|
let misAJour = 0;
|
||||||
let inchanges = 0;
|
let inchanges = 0;
|
||||||
for (const { ligne, champs, brut } of lignes) {
|
for (const { ligne, champs, brut } of lignes) {
|
||||||
const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance);
|
const { candidats } = designer(champs, index);
|
||||||
const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter(
|
|
||||||
(participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance,
|
|
||||||
);
|
|
||||||
if (candidats.length === 0) {
|
if (candidats.length === 0) {
|
||||||
nouvelles.push({ champs });
|
nouvelles.push({ champs });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (candidats.length > 1) {
|
if (candidats.length > 1) {
|
||||||
const ids = candidats.map(({ id }) => id).sort(croissant);
|
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: candidats, brut: [...brut] });
|
||||||
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const [designe] = candidats;
|
const [id] = candidats;
|
||||||
if (designes.has(designe.id)) {
|
if (designes.has(id)) {
|
||||||
refusees.push({
|
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],
|
brut: [...brut],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
designes.set(designe.id, ligne);
|
designes.set(id, ligne);
|
||||||
if (poser(designe, champs)) misAJour += 1;
|
if (poser(parId.get(id), champs)) misAJour += 1;
|
||||||
else inchanges += 1;
|
else inchanges += 1;
|
||||||
}
|
}
|
||||||
return { nouvelles, refusees, misAJour, inchanges };
|
return { nouvelles, refusees, misAJour, inchanges };
|
||||||
|
|
@ -223,16 +203,17 @@ function mettreAJour(participants, lignes) {
|
||||||
* Applique un aperçu à une charge (§ 10.1), selon le mode :
|
* Applique un aperçu à une charge (§ 10.1), selon le mode :
|
||||||
* - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se
|
* - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se
|
||||||
* fusionne jamais ;
|
* fusionne jamais ;
|
||||||
* - 'mettreAJour' : une ligne désigne les participants d'avant l'import de
|
* - 'mettreAJour' : une ligne désigne, par designer, les participants
|
||||||
* même nom et même prénom normalisés, et de même appartenance quand la
|
* d'avant l'import de même nom normalisé ; le prénom et l'appartenance
|
||||||
* ligne en porte une : un prénom absent compte comme vide, sur la ligne
|
* n'en écartent un que lorsque la ligne et lui en portent tous deux un,
|
||||||
* comme chez le participant, et un participant sans appartenance n'est
|
* de clés normalisées différentes, selon leurs valeurs d'avant l'import.
|
||||||
* désigné que par une ligne qui n'en porte pas. Désigné seul, le
|
* Une ligne ajoutée à côté d'un participant de même nom est celle que
|
||||||
* participant prend chaque champ non vide de la ligne, nom et prénom
|
* l'aperçu nomme dans homonymes. Désigné seul, le participant prend chaque champ non vide de
|
||||||
* compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une
|
* la ligne, nom, prénom et appartenance compris, et garde la valeur d'un
|
||||||
* ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs,
|
* champ vide ; exclu vide le garde. Une ligne qui ne désigne personne est
|
||||||
* refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une
|
* ajoutée ; qui en désigne plusieurs, refusée PLUSIEURS_CORRESPONDENT ; qui
|
||||||
* ligne précédente désigne déjà, refusée DEJA_DESIGNE ;
|
* 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,
|
* - 'remplacer' : les participants et leurs réservations sont retirés,
|
||||||
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
|
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
|
||||||
* restent (bilanRemplacement).
|
* restent (bilanRemplacement).
|
||||||
|
|
@ -251,19 +232,22 @@ function mettreAJour(participants, lignes) {
|
||||||
*
|
*
|
||||||
* @param {import('../stockage/types.js').Charge} charge
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
* @param {import('./apercu.js').Apercu} apercu calculé contre les
|
* @param {import('./apercu.js').Apercu} apercu calculé contre les
|
||||||
* participants de la charge : l'appartenance d'une ligne y prend
|
* participants de la charge, pour ce mode : hors de remplacer,
|
||||||
* l'orthographe d'un existant de même clé, que remplacer garde même quand
|
* l'appartenance d'une ligne y prend l'orthographe d'un existant de même
|
||||||
* il retire cet existant
|
* clé ; en remplacer, celle du fichier
|
||||||
* @param {'ajouter'|'mettreAJour'|'remplacer'} mode
|
* @param {'ajouter'|'mettreAJour'|'remplacer'} mode
|
||||||
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}}
|
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}}
|
||||||
* @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état
|
* @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
|
* 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 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
|
* 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) {
|
export function appliquerImport(charge, apercu, mode) {
|
||||||
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(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 (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE');
|
||||||
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
|
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
|
||||||
const { ambigus } = apercu.association;
|
const { ambigus } = apercu.association;
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,10 @@ function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants,
|
||||||
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
|
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 = {}) {
|
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
|
// 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", () => {
|
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 existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })];
|
||||||
const { charge: apres, resume } = importer(
|
const texte = csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test');
|
||||||
chargeDe({ participants: existants }),
|
// L'aperçu nomme la ligne qui s'ajoute à côté de l'homonyme.
|
||||||
csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'),
|
assert.deepEqual(apercevoir(texte, { participants: existants, mode: 'mettreAJour' }).homonymes, [
|
||||||
'mettreAJour',
|
{ ligne: 2, participants: [1] },
|
||||||
);
|
]);
|
||||||
|
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), texte, 'mettreAJour');
|
||||||
assert.deepEqual(apres.participants, [
|
assert.deepEqual(apres.participants, [
|
||||||
existants[0],
|
existants[0],
|
||||||
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }),
|
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 }));
|
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', () => {
|
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é', () => {
|
||||||
// La clé est (nom, prénom), un prénom absent y comptant comme vide : une
|
// Un fichier qui ne porte que les noms et les courriels complète les
|
||||||
// liste de noms sans prénoms ajoute des personnes au lieu de mettre à
|
// inscrits ; il ne les double pas.
|
||||||
// jour les inscrits de même nom.
|
const existants = [
|
||||||
const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')];
|
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
|
||||||
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour');
|
personne(2, 'Lacasse', 'Ondine', 'Société Alpha'),
|
||||||
assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]);
|
personne(3, 'Pervenche', 'Théo'),
|
||||||
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
|
];
|
||||||
|
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 existants = [personne(1, 'Grisaille', null, 'Club des Merles')];
|
||||||
const { charge: apres, resume } = importer(
|
const { charge: apres, resume } = importer(
|
||||||
chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour',
|
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(apres.participants, [personne(1, 'Grisaille', 'Silas', 'Club des Merles', { exclu: true })]);
|
||||||
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
|
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", () => {
|
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, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })];
|
const existants = [
|
||||||
|
personne(1, 'Ombrelle', 'Iris'),
|
||||||
|
personne(2, 'Lacasse', 'Ondine', null, { courriel: 'ondine@exemple.test' }),
|
||||||
|
];
|
||||||
const { charge: apres, resume } = importer(
|
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(apres.participants, [
|
||||||
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
|
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", () => {
|
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', () => {
|
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({
|
const charge = chargeDe({
|
||||||
participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')],
|
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 charge = chargeGarnie();
|
||||||
const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), {
|
const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), {
|
||||||
participants: charge.participants,
|
participants: charge.participants,
|
||||||
|
mode,
|
||||||
});
|
});
|
||||||
const avant = structuredClone({ charge, apercu });
|
const avant = structuredClone({ charge, apercu });
|
||||||
const resultat = appliquerImport(charge, apercu, mode);
|
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', () => {
|
test('un mode inconnu lève TypeError', () => {
|
||||||
const apercu = apercevoir(csv('nom', 'Ombrelle'));
|
const apercu = apercevoir(csv('nom', 'Ombrelle'));
|
||||||
for (const mode of ['fusionner', 'Ajouter', undefined, null]) {
|
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'),
|
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);
|
assert.equal(apercu.separateur, null);
|
||||||
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
|
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
|
||||||
const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees));
|
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;',
|
'pervenche;theo;autre@exemple.test;',
|
||||||
'Sarcelle;Ondine;;peut-être',
|
'Sarcelle;Ondine;;peut-être',
|
||||||
),
|
),
|
||||||
{ participants: charge.participants },
|
{ participants: charge.participants, mode: 'mettreAJour' },
|
||||||
);
|
);
|
||||||
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
|
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
|
||||||
assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [
|
assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ const CANDIDATS = [';', ',', '\t'];
|
||||||
// Nombre d'enregistrements que le choix du séparateur lit.
|
// Nombre d'enregistrements que le choix du séparateur lit.
|
||||||
const FENETRE = 20;
|
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
|
// 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
|
// des guillemets à la fin de la lecture. La lecture s'arrête après limite
|
||||||
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture
|
// 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 ;
|
// - un guillemet ouvre un champ cité quand il en est le premier caractère ;
|
||||||
// ailleurs, c'est un caractère comme un autre ;
|
// ailleurs, c'est un caractère comme un autre ;
|
||||||
// - dans un champ cité, le séparateur et les fins de ligne font partie du
|
// - 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
|
// texte, deux guillemets font un guillemet, un seul referme le champ quand
|
||||||
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ;
|
// 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
|
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
|
||||||
// fin ;
|
// fin ;
|
||||||
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
|
// - 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) {
|
} else if (texte[i + 1] === GUILLEMET) {
|
||||||
champ += GUILLEMET;
|
champ += GUILLEMET;
|
||||||
i += 1;
|
i += 1;
|
||||||
} else {
|
} else if (fermeLeChamp(texte[i + 1], separateur)) {
|
||||||
cite = false;
|
cite = false;
|
||||||
|
} else {
|
||||||
|
enregistrement.push(champ);
|
||||||
|
enregistrements.push(enregistrement);
|
||||||
|
return { enregistrements, guillemetOuvert: true };
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -88,9 +103,10 @@ function analyser(texte, separateur, limite) {
|
||||||
/**
|
/**
|
||||||
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
|
* 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
|
* 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
|
* ligne CRLF, LF ou CR. Un champ cité se referme sur un guillemet que suivent
|
||||||
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas
|
* le séparateur, une fin de ligne ou la fin du texte. Une ligne vide est un
|
||||||
* d'enregistrement. Aucun blanc n'est retiré des champs.
|
* 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 :
|
* 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
|
* 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
|
* @param {string|null} separateur un caractère, ou null pour un fichier à une
|
||||||
* colonne, dont chaque ligne est un seul champ
|
* colonne, dont chaque ligne est un seul champ
|
||||||
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
|
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
|
||||||
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le
|
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité, ou
|
||||||
* dernier enregistrement porte alors le texte lu jusque-là.
|
* 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) {
|
export function decouper(texte, separateur) {
|
||||||
return analyser(texte, separateur, Infinity);
|
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
|
// 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
|
// 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
|
// guillemet resté ouvert, ou refermé sur un autre caractère que le
|
||||||
// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête
|
// 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
|
// 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
|
// 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
|
// premier défaut qui écarte le candidat, ou null : un enregistrement non vide
|
||||||
|
|
|
||||||
|
|
@ -183,9 +183,46 @@ describe('decouper : enregistrements et champs (§ 10.1)', () => {
|
||||||
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
|
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => {
|
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", () => {
|
||||||
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]);
|
// Le champ cité est refusé là où son guillemet fermant est suivi d'un
|
||||||
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]);
|
// 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', () => {
|
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));
|
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', () => {
|
test('un texte vide, ou fait de lignes vides', () => {
|
||||||
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
|
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
|
||||||
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));
|
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,13 @@
|
||||||
// entier, par une écriture atomique, quand il reçoit une entrée de reprise,
|
// entier, par une écriture atomique, quand il reçoit une entrée de reprise,
|
||||||
// seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté
|
// seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté
|
||||||
// des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et
|
// des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et
|
||||||
// quand il dépasse PLAFOND_ENTREES. Le correctif d'une entrée part de la
|
// quand il dépasse PLAFOND_ENTREES. Un journal qui a disparu depuis la
|
||||||
// charge que restitue l'entrée qui la précède dans le journal, jamais de la
|
// dernière écriture ne reçoit jamais d'ajout, qui commencerait un fichier
|
||||||
// charge ouverte : un correctif appliqué à une autre charge que la sienne
|
// sans sa ligne d'ouverture : il se réécrit en entier, ouverture et entrées
|
||||||
// s'applique sans lever et rend autre chose.
|
// en mémoire comprises, et l'Ouvert l'annonce, JOURNAL_RECREE. Le correctif
|
||||||
|
// d'une entrée part de la charge que restitue l'entrée qui la précède dans
|
||||||
|
// le journal, jamais de la charge ouverte : un correctif appliqué à une autre
|
||||||
|
// charge que la sienne s'applique sans lever et rend autre chose.
|
||||||
//
|
//
|
||||||
// La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des
|
// La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des
|
||||||
// fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se
|
// fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se
|
||||||
|
|
@ -67,11 +70,28 @@
|
||||||
// ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre
|
// ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre
|
||||||
// ouverture le remplace, comme tout .precedent, quand l'état sur le disque
|
// ouverture le remplace, comme tout .precedent, quand l'état sur le disque
|
||||||
// se lit.
|
// se lit.
|
||||||
|
//
|
||||||
|
// Un fichier dont les octets ne sont pas de l'UTF-8 — le système de fichiers
|
||||||
|
// lève LECTURE, cause UTF8_INVALIDE — ne se lit pas, et aucun octet ne s'y
|
||||||
|
// remplace en silence par U+FFFD. L'état ou le .precedent est illisible,
|
||||||
|
// raison ENCODAGE : l'état lève ETAT_ILLISIBLE avec ses secours. Le premier
|
||||||
|
// geste de l'Ouvert d'un secours met cet état dans un dossier daté de la
|
||||||
|
// corbeille, puis écrit comme pour un état absent : ses octets ne se perdent
|
||||||
|
// jamais, et le travail reprend sur place. Un journal se décode ligne par
|
||||||
|
// ligne : la première ligne hors UTF-8 arrête sa lecture comme une ligne
|
||||||
|
// illisible, elle et les suivantes écartées et comptées, et le premier geste
|
||||||
|
// le réécrit sans elles ; une coupure au milieu d'un caractère ne gâte que
|
||||||
|
// la dernière ligne. Le journal de la base dont la ligne d'ouverture n'est
|
||||||
|
// pas de l'UTF-8 n'est ni lu, ni apparié, ni remplacé : faute d'un autre
|
||||||
|
// journal que l'identifiant apparie, l'Ouvert l'annonce, JOURNAL_ILLISIBLE,
|
||||||
|
// et la première écriture pose l'historique sous une base libre, comme pour
|
||||||
|
// le journal d'un autre événement.
|
||||||
import { canoniser, serialiser } from './canonique.js';
|
import { canoniser, serialiser } from './canonique.js';
|
||||||
import { FORMAT, analyser } from './document.js';
|
import { FORMAT, analyser } from './document.js';
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
import {
|
import {
|
||||||
PLAFOND_ENTREES,
|
PLAFOND_ENTREES,
|
||||||
|
decoderJournal,
|
||||||
elaguer,
|
elaguer,
|
||||||
estInstantane,
|
estInstantane,
|
||||||
ligneEntree,
|
ligneEntree,
|
||||||
|
|
@ -81,7 +101,7 @@ import {
|
||||||
reconstruire,
|
reconstruire,
|
||||||
versionDe,
|
versionDe,
|
||||||
} from './journal.js';
|
} from './journal.js';
|
||||||
import { SUFFIXES, deriverBase } from './noms.js';
|
import { DOSSIER_CORBEILLE, SUFFIXES, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
||||||
import { examiner } from './placements.js';
|
import { examiner } from './placements.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -120,19 +140,30 @@ import { examiner } from './placements.js';
|
||||||
* @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur
|
* @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur
|
||||||
* le disque, le .precedent qu'un secours ouvre ou le journal de la
|
* le disque, le .precedent qu'un secours ouvre ou le journal de la
|
||||||
* base est d'un format plus récent que FORMAT
|
* base est d'un format plus récent que FORMAT
|
||||||
|
* @property {boolean} etatMalEncode l'état sur le disque, à côté du secours
|
||||||
|
* ouvert, n'est pas de l'UTF-8 : le premier geste le met de côté
|
||||||
|
* dans la corbeille (mettreDeCoteEtatMalEncode) avant d'écrire
|
||||||
* @property {import('./placements.js').Examen} examen
|
* @property {import('./placements.js').Examen} examen
|
||||||
* @property {Array<{code: string, details: Object}>} avertissements dans
|
* @property {Array<{code: string, details: Object}>} avertissements dans
|
||||||
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {} ;
|
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {}, un retenu
|
||||||
* JOURNAL_REPRIS {}, le journal en avance d'une entrée rattrapé en
|
* qui se contredit, ou que l'en-tête annonce et que la charge n'a
|
||||||
* mémoire ; JOURNAL_EN_AVANCE_DE {n} ou JOURNAL_INCOMPLET {etat,
|
* pas ; JOURNAL_REPRIS {}, le journal en avance d'une entrée
|
||||||
* journal}, le journal qui finit n révisions après la charge, ou
|
* rattrapé en mémoire ; JOURNAL_EN_AVANCE_DE {n} ou
|
||||||
* avant elle ; JOURNAL_ECARTEES {n} ; JOURNAL_FORMAT_PLUS_RECENT
|
* JOURNAL_INCOMPLET {etat, journal}, le journal qui finit n
|
||||||
* {format}, le journal de la base, d'un format plus récent, que
|
* révisions après la charge, ou avant elle ; JOURNAL_ECARTEES {n} ;
|
||||||
* rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement}, le journal
|
* JOURNAL_RECREE {}, le fichier du journal, disparu depuis
|
||||||
* de la base, d'un autre événement, que rien ne lit ;
|
* l'écriture précédente, que celle-ci a réécrit en entier ;
|
||||||
|
* JOURNAL_FORMAT_PLUS_RECENT {format}, le journal de la base, d'un
|
||||||
|
* format plus récent, que rien ne lit ni n'écrit ; JOURNAL_ILLISIBLE
|
||||||
|
* {raison: 'ENCODAGE'}, le journal de la base aux octets hors
|
||||||
|
* UTF-8, que rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement},
|
||||||
|
* le journal de la base, d'un autre événement, que rien ne lit ;
|
||||||
* PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant
|
* PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant
|
||||||
* illisible ; DERIVE {ids, retenu}, les propositions périmées par
|
* illisible ; PROPOSITIONS_MANQUANTES {n}, le nombre de
|
||||||
* identifiant, et vrai quand le retenu l'est (§ 9)
|
* propositions que l'en-tête compte sans que la liste les porte ;
|
||||||
|
* DERIVE {ids, retenu}, les
|
||||||
|
* propositions périmées par identifiant, et vrai quand le retenu
|
||||||
|
* l'est (§ 9)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -185,17 +216,43 @@ const basesAuSuffixe = (entrees, suffixe) =>
|
||||||
.map(({ nom }) => baseDe(nom, suffixe))
|
.map(({ nom }) => baseDe(nom, suffixe))
|
||||||
.filter((base) => base !== null);
|
.filter((base) => base !== null);
|
||||||
|
|
||||||
// Texte du fichier chemin, ou null quand il manque ; toute autre erreur
|
// Ce que porte le fichier chemin : { texte, present, octets }. texte est
|
||||||
// remonte telle quelle.
|
// null quand il manque, ou quand ses octets ne sont pas de l'UTF-8 (LECTURE,
|
||||||
|
// cause UTF8_INVALIDE), octets portant alors ceux que le refus rend, null
|
||||||
|
// sinon ; present ne vaut faux que pour un fichier qui manque. Toute autre
|
||||||
|
// erreur de lecture remonte telle quelle.
|
||||||
async function lireSiPresent(fs, racine, chemin) {
|
async function lireSiPresent(fs, racine, chemin) {
|
||||||
try {
|
try {
|
||||||
return await fs.lireTexte(racine, chemin);
|
return { texte: await fs.lireTexte(racine, chemin), present: true, octets: null };
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur.code === 'ABSENT') return null;
|
if (erreur.code === 'ABSENT') return { texte: null, present: false, octets: null };
|
||||||
|
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
|
||||||
|
return { texte: null, present: true, octets: erreur.details.octets };
|
||||||
|
}
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ce qu'un fichier de journal lu (lireSiPresent) donne à lire : { texte,
|
||||||
|
// ecartees }. Ses octets hors UTF-8 se décodent ligne par ligne
|
||||||
|
// (decoderJournal) : texte, les lignes qui précèdent la première qui ne se
|
||||||
|
// décode pas, null quand c'est l'ouverture, et ecartees, cette ligne et les
|
||||||
|
// suivantes. Sinon texte est celui du fichier, null quand il manque, et
|
||||||
|
// ecartees vaut 0.
|
||||||
|
const journalLisible = ({ texte, octets }) => (octets === null ? { texte, ecartees: 0 } : decoderJournal(octets));
|
||||||
|
|
||||||
|
// La lecture d'un journal lisible (journalLisible) : lireJournal de son
|
||||||
|
// texte, les lignes que le décodage a écartées comptées avec celles que la
|
||||||
|
// lecture écarte.
|
||||||
|
function lireLisible({ texte, ecartees }) {
|
||||||
|
const lu = lireJournal(texte);
|
||||||
|
return { ...lu, ecartees: lu.ecartees + ecartees };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les détails du refus d'un état ou d'un .precedent dont les octets ne sont
|
||||||
|
// pas de l'UTF-8, neufs à chaque appel : rien ne s'y désigne.
|
||||||
|
const malEncode = () => ({ raison: 'ENCODAGE', chemin: null });
|
||||||
|
|
||||||
// Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon
|
// Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon
|
||||||
// { details } de son refus, raison et chemin. analyser ne lève que
|
// { details } de son refus, raison et chemin. analyser ne lève que
|
||||||
// ETAT_ILLISIBLE.
|
// ETAT_ILLISIBLE.
|
||||||
|
|
@ -212,12 +269,24 @@ function lireEtat(texte) {
|
||||||
// revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient
|
// revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient
|
||||||
// d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce
|
// d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce
|
||||||
// format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert
|
// format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert
|
||||||
// d'un secours (§ 8.8).
|
// d'un secours (§ 8.8). etatMalEncode quand ses octets ne sont pas de
|
||||||
|
// l'UTF-8 : le premier geste parti d'un secours le range d'abord dans la
|
||||||
|
// corbeille datée (mettreDeCoteEtatMalEncode), puis pose l'état.
|
||||||
function surDisque(texte, { lecture, details }) {
|
function surDisque(texte, { lecture, details }) {
|
||||||
if (lecture !== null) {
|
if (lecture !== null) {
|
||||||
return { texteEtat: texte, revisionEtat: lecture.entete.revision, formatPlusRecent: lecture.formatPlusRecent };
|
return {
|
||||||
|
texteEtat: texte,
|
||||||
|
revisionEtat: lecture.entete.revision,
|
||||||
|
formatPlusRecent: lecture.formatPlusRecent,
|
||||||
|
etatMalEncode: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { texteEtat: null, revisionEtat: null, formatPlusRecent: (details?.format ?? FORMAT) > FORMAT };
|
return {
|
||||||
|
texteEtat: null,
|
||||||
|
revisionEtat: null,
|
||||||
|
formatPlusRecent: (details?.format ?? FORMAT) > FORMAT,
|
||||||
|
etatMalEncode: details?.raison === 'ENCODAGE',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le texte d'état, que l'analyse doit relire : un état que ce module écrit se
|
// Le texte d'état, que l'analyse doit relire : un état que ce module écrit se
|
||||||
|
|
@ -277,34 +346,40 @@ async function deriver(fs, racine, nom, autres) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les fichiers de racine tels qu'une lecture les trouve : la liste du
|
// Les fichiers de racine tels qu'une lecture les trouve : la liste du
|
||||||
// dossier, le texte de chaque fichier — null quand il manque, toute autre
|
// dossier, ce que chaque journal donne à lire (journalLisible), la présence
|
||||||
// erreur de lecture remontant telle quelle —, et la lecture de chaque texte
|
// de chaque fichier, et la lecture de chaque texte d'état ou de .precedent
|
||||||
// d'état ou de .precedent (lireEtat), chacun lu une fois, à la demande. Les
|
// (lireEtat), chacun lu une fois, à la demande ; toute erreur de lecture
|
||||||
|
// autre qu'un absent ou des octets hors UTF-8 remonte telle quelle. Les
|
||||||
// décisions d'appariement d'une même opération se prennent ainsi sur les
|
// décisions d'appariement d'une même opération se prennent ainsi sur les
|
||||||
// mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit
|
// mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit
|
||||||
// après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche
|
// après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche
|
||||||
// le journal d'un événement en prend une neuve (baseCherchee).
|
// le journal d'un événement en prend une neuve (baseCherchee).
|
||||||
function vueDe(fs, racine) {
|
function vueDe(fs, racine) {
|
||||||
let entrees = null;
|
let entrees = null;
|
||||||
const textes = new Map();
|
const fichiers = new Map();
|
||||||
const lectures = new Map();
|
const lectures = new Map();
|
||||||
const texte = async (chemin) => {
|
const fichier = async (chemin) => {
|
||||||
if (!textes.has(chemin)) textes.set(chemin, await lireSiPresent(fs, racine, chemin));
|
if (!fichiers.has(chemin)) fichiers.set(chemin, await lireSiPresent(fs, racine, chemin));
|
||||||
return textes.get(chemin);
|
return fichiers.get(chemin);
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
entrees: async () => {
|
entrees: async () => {
|
||||||
entrees ??= await fs.lister(racine, '');
|
entrees ??= await fs.lister(racine, '');
|
||||||
return entrees;
|
return entrees;
|
||||||
},
|
},
|
||||||
texte,
|
// { texte, ecartees } du journal chemin (journalLisible).
|
||||||
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent,
|
journal: async (chemin) => journalLisible(await fichier(chemin)),
|
||||||
// les trois null quand il manque.
|
// Vrai quand le fichier existe, que ses octets soient de l'UTF-8 ou non.
|
||||||
|
present: async (chemin) => (await fichier(chemin)).present,
|
||||||
|
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent :
|
||||||
|
// les trois null quand il manque ; texte et lecture null, details
|
||||||
|
// {raison: 'ENCODAGE', chemin: null}, quand ses octets ne sont pas de
|
||||||
|
// l'UTF-8.
|
||||||
lecture: async (chemin) => {
|
lecture: async (chemin) => {
|
||||||
if (!lectures.has(chemin)) {
|
if (!lectures.has(chemin)) {
|
||||||
const lu = await texte(chemin);
|
const { texte, present } = await fichier(chemin);
|
||||||
const absent = { texte: null, lecture: null, details: null };
|
const illisible = { texte: null, lecture: null, details: present ? malEncode() : null };
|
||||||
lectures.set(chemin, lu === null ? absent : { texte: lu, ...lireEtat(lu) });
|
lectures.set(chemin, texte === null ? illisible : { texte, ...lireEtat(texte) });
|
||||||
}
|
}
|
||||||
return lectures.get(chemin);
|
return lectures.get(chemin);
|
||||||
},
|
},
|
||||||
|
|
@ -322,9 +397,10 @@ async function proprietaire(vue, base) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
||||||
// { base, texte } du journal de base quand sa première ligne nomme id ;
|
// { base, texte, ecartees } (journalLisible) du journal de base quand sa
|
||||||
// sinon du premier journal du dossier, par nom, qui le nomme et n'appartient
|
// première ligne nomme id ; sinon du premier journal du dossier, par nom,
|
||||||
// pas à sa propre base (proprietaire) ; null quand aucun ne s'apparie. Un
|
// qui le nomme et n'appartient pas à sa propre base (proprietaire) ; null
|
||||||
|
// quand aucun ne s'apparie. Un
|
||||||
// journal renommé à la main, ou créé sous une base dérivée quand celui de la
|
// journal renommé à la main, ou créé sous une base dérivée quand celui de la
|
||||||
// base portait un autre événement, n'a ni état ni .precedent à sa base : il
|
// base portait un autre événement, n'a ni état ni .precedent à sa base : il
|
||||||
// s'apparie. Le journal d'un original reste à l'original, et un état copié à
|
// s'apparie. Le journal d'un original reste à l'original, et un état copié à
|
||||||
|
|
@ -333,12 +409,12 @@ async function proprietaire(vue, base) {
|
||||||
// journal sans base la suivent, après la préséance du journal de la base
|
// journal sans base la suivent, après la préséance du journal de la base
|
||||||
// d'un format plus récent (formatDuJournalDeBase).
|
// d'un format plus récent (formatDuJournalDeBase).
|
||||||
async function apparier(vue, base, id) {
|
async function apparier(vue, base, id) {
|
||||||
const texte = await vue.texte(base + SUFFIXES.journal);
|
const journal = await vue.journal(base + SUFFIXES.journal);
|
||||||
if (evenementDe(texte) === id) return { base, texte };
|
if (evenementDe(journal.texte) === id) return { base, ...journal };
|
||||||
for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
|
for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
|
||||||
const texteAutre = await vue.texte(autre + SUFFIXES.journal);
|
const journalAutre = await vue.journal(autre + SUFFIXES.journal);
|
||||||
if (evenementDe(texteAutre) === id && (await proprietaire(vue, autre)) !== id) {
|
if (evenementDe(journalAutre.texte) === id && (await proprietaire(vue, autre)) !== id) {
|
||||||
return { base: autre, texte: texteAutre };
|
return { base: autre, ...journalAutre };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -365,7 +441,7 @@ async function identifiantsLisibles(vue) {
|
||||||
async function identifiantSansEtat(vue, base) {
|
async function identifiantSansEtat(vue, base) {
|
||||||
const { lecture } = await vue.lecture(base + SUFFIXES.precedent);
|
const { lecture } = await vue.lecture(base + SUFFIXES.precedent);
|
||||||
if (lecture !== null) return lecture.charge.evenement.id;
|
if (lecture !== null) return lecture.charge.evenement.id;
|
||||||
const id = evenementDe(await vue.texte(base + SUFFIXES.journal));
|
const id = evenementDe((await vue.journal(base + SUFFIXES.journal)).texte);
|
||||||
if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null;
|
if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null;
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
@ -375,13 +451,17 @@ async function identifiantSansEtat(vue, base) {
|
||||||
// passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste
|
// passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste
|
||||||
// (lister) : son identifiant ne se lit pas, et il reste celui de la base.
|
// (lister) : son identifiant ne se lit pas, et il reste celui de la base.
|
||||||
async function formatDuJournalDeBase(vue, base) {
|
async function formatDuJournalDeBase(vue, base) {
|
||||||
const texte = await vue.texte(base + SUFFIXES.journal);
|
const { texte } = await vue.journal(base + SUFFIXES.journal);
|
||||||
return texte === null ? null : formatPlusRecentDe(texte);
|
return texte === null ? null : formatPlusRecentDe(texte);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
||||||
// lu, sa base, etranger, l'événement que porte le journal de la base quand
|
// lu, sa base, etranger, l'événement que porte le journal de la base quand
|
||||||
// aucun ne s'apparie et qu'il en nomme un autre, et format. Le journal de la
|
// aucun ne s'apparie et qu'il en nomme un autre, format, et malEncode, vrai
|
||||||
|
// quand aucun ne s'apparie et que la ligne d'ouverture du journal de la base
|
||||||
|
// n'est pas de l'UTF-8 — une ligne suivante qui ne l'est pas s'écarte
|
||||||
|
// seulement : lu et base valent alors null, comme pour un journal étranger,
|
||||||
|
// que rien ne lit ni ne remplace. Le journal de la
|
||||||
// base qui déclare un format plus récent que FORMAT se reconnaît d'abord,
|
// base qui déclare un format plus récent que FORMAT se reconnaît d'abord,
|
||||||
// avant tout appariement, puisque son identifiant ne se lit pas : lu vaut
|
// avant tout appariement, puisque son identifiant ne se lit pas : lu vaut
|
||||||
// null et format le nomme, ce qui garde l'Ouvert en lecture seule, même
|
// null et format le nomme, ce qui garde l'Ouvert en lecture seule, même
|
||||||
|
|
@ -393,15 +473,18 @@ async function formatDuJournalDeBase(vue, base) {
|
||||||
// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le
|
// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le
|
||||||
// journal de l'événement (baseCherchee).
|
// journal de l'événement (baseCherchee).
|
||||||
async function journalDe(vue, base, id) {
|
async function journalDe(vue, base, id) {
|
||||||
|
const aucun = { lu: null, base: null, etranger: null, format: null, malEncode: false };
|
||||||
const format = await formatDuJournalDeBase(vue, base);
|
const format = await formatDuJournalDeBase(vue, base);
|
||||||
if (format !== null) return { lu: null, base, etranger: null, format };
|
if (format !== null) return { ...aucun, base, format };
|
||||||
const texte = await vue.texte(base + SUFFIXES.journal);
|
const chemin = base + SUFFIXES.journal;
|
||||||
if (id === null) return { lu: null, base: null, etranger: null, format: null };
|
const journal = await vue.journal(chemin);
|
||||||
const apparie = await apparier(vue, base, id);
|
const apparie = id === null ? null : await apparier(vue, base, id);
|
||||||
if (apparie !== null) return { lu: lireJournal(apparie.texte), base: apparie.base, etranger: null, format: null };
|
if (apparie !== null) return { ...aucun, lu: lireLisible(apparie), base: apparie.base };
|
||||||
const etranger = evenementDe(texte);
|
if (journal.texte === null && (await vue.present(chemin))) return { ...aucun, malEncode: true };
|
||||||
if (etranger !== null) return { lu: null, base: null, etranger, format: null };
|
if (id === null) return aucun;
|
||||||
return { lu: texte === null ? null : lireJournal(texte), base, etranger: null, format: null };
|
const etranger = evenementDe(journal.texte);
|
||||||
|
if (etranger !== null) return { ...aucun, etranger };
|
||||||
|
return { ...aucun, lu: journal.texte === null ? null : lireLisible(journal), base };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe
|
// Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe
|
||||||
|
|
@ -439,30 +522,41 @@ function instantDe(lu) {
|
||||||
|
|
||||||
// Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert,
|
// Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert,
|
||||||
// avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule
|
// avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule
|
||||||
// de l'un ou de l'autre.
|
// de l'un ou de l'autre, et ce que l'en-tête annonce sans que la charge le
|
||||||
const depuisLecture = ({ entete, charge, formatPlusRecent }, disque) => ({
|
// porte (manquants).
|
||||||
|
const depuisLecture = ({ entete, charge, formatPlusRecent, manquants }, disque) => ({
|
||||||
charge,
|
charge,
|
||||||
revision: entete.revision,
|
revision: entete.revision,
|
||||||
produitVersion: entete.produitVersion,
|
produitVersion: entete.produitVersion,
|
||||||
|
manquants,
|
||||||
...disque,
|
...disque,
|
||||||
formatPlusRecent: formatPlusRecent || disque.formatPlusRecent,
|
formatPlusRecent: formatPlusRecent || disque.formatPlusRecent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Une charge qui ne vient pas d'une lecture d'état ne manque de rien.
|
||||||
|
const AUCUN_MANQUANT = Object.freeze({ propositions: 0, retenu: false });
|
||||||
|
|
||||||
// Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert.
|
// Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert.
|
||||||
// derniere est la révision de la dernière entrée du journal, celle de la
|
// derniere est la révision de la dernière entrée du journal, celle de la
|
||||||
// charge quand il n'y en a pas.
|
// charge quand il n'y en a pas. Les propositions que l'en-tête compte sans
|
||||||
function avertir({ examen, journal, revision, rattrape, ecartees, format, etranger }) {
|
// que la liste les porte s'annoncent par leur seul nombre, que rien ne
|
||||||
|
// borne : aucune liste de cette taille ne se construit. Un retenu annoncé
|
||||||
|
// absent s'annonce comme un retenu fautif.
|
||||||
|
function avertir({ examen, journal, revision, rattrape, ecartees, recree, format, malEncode, etranger, manquants }) {
|
||||||
const avertissements = [];
|
const avertissements = [];
|
||||||
const ajouter = (code, details = {}) => avertissements.push({ code, details });
|
const ajouter = (code, details = {}) => avertissements.push({ code, details });
|
||||||
if (examen.retenu.fautes.length > 0) ajouter('RETENU_FAUTIF');
|
if (examen.retenu.fautes.length > 0 || manquants.retenu) ajouter('RETENU_FAUTIF');
|
||||||
if (rattrape) ajouter('JOURNAL_REPRIS');
|
if (rattrape) ajouter('JOURNAL_REPRIS');
|
||||||
const derniere = journal === null ? revision : journal.entrees.at(-1).revision;
|
const derniere = journal === null ? revision : journal.entrees.at(-1).revision;
|
||||||
if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision });
|
if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision });
|
||||||
if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere });
|
if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere });
|
||||||
if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees });
|
if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees });
|
||||||
|
if (recree) ajouter('JOURNAL_RECREE');
|
||||||
if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format });
|
if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format });
|
||||||
|
if (malEncode) ajouter('JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' });
|
||||||
if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger });
|
if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger });
|
||||||
if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) });
|
if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) });
|
||||||
|
if (manquants.propositions > 0) ajouter('PROPOSITIONS_MANQUANTES', { n: manquants.propositions });
|
||||||
const retenu = examen.retenu.raisons.length > 0;
|
const retenu = examen.retenu.raisons.length > 0;
|
||||||
if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu });
|
if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu });
|
||||||
return avertissements;
|
return avertissements;
|
||||||
|
|
@ -470,16 +564,18 @@ function avertir({ examen, journal, revision, rattrape, ecartees, format, etrang
|
||||||
|
|
||||||
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
|
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
|
||||||
// journal trouvé pour elle. rattraper : un journal en avance d'une seule
|
// journal trouvé pour elle. rattraper : un journal en avance d'une seule
|
||||||
// entrée sur la source donne sa charge, sa révision et sa version. La source
|
// entrée sur la source donne sa charge, sa révision et sa version, et ce que
|
||||||
// d'un format plus récent, ou un journal d'un format plus récent, gardent
|
// l'en-tête de la source annonce sans que sa charge le porte ne compte plus.
|
||||||
// l'Ouvert en lecture seule.
|
// La source d'un format plus récent, ou un journal d'un format plus récent,
|
||||||
|
// gardent l'Ouvert en lecture seule.
|
||||||
function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
||||||
let { charge, revision, produitVersion } = source;
|
let { charge, revision, produitVersion, manquants } = source;
|
||||||
const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
|
const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
|
||||||
if (rattrape) {
|
if (rattrape) {
|
||||||
charge = canoniser(instant.charge);
|
charge = canoniser(instant.charge);
|
||||||
revision = instant.revision;
|
revision = instant.revision;
|
||||||
produitVersion = versionDe(instant.journal, revision);
|
produitVersion = versionDe(instant.journal, revision);
|
||||||
|
manquants = AUCUN_MANQUANT;
|
||||||
}
|
}
|
||||||
const journal = instant?.journal ?? null;
|
const journal = instant?.journal ?? null;
|
||||||
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
||||||
|
|
@ -498,6 +594,7 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
||||||
chargeJournal: instant?.charge ?? null,
|
chargeJournal: instant?.charge ?? null,
|
||||||
journalAReecrire: journal !== null && ecartees > 0,
|
journalAReecrire: journal !== null && ecartees > 0,
|
||||||
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
|
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
|
||||||
|
etatMalEncode: source.etatMalEncode,
|
||||||
examen,
|
examen,
|
||||||
avertissements: avertir({
|
avertissements: avertir({
|
||||||
examen,
|
examen,
|
||||||
|
|
@ -505,8 +602,11 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
||||||
revision,
|
revision,
|
||||||
rattrape,
|
rattrape,
|
||||||
ecartees,
|
ecartees,
|
||||||
|
recree: false,
|
||||||
format: trouve.format,
|
format: trouve.format,
|
||||||
|
malEncode: trouve.malEncode,
|
||||||
etranger: trouve.etranger,
|
etranger: trouve.etranger,
|
||||||
|
manquants,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -517,11 +617,11 @@ function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
||||||
// secours lisibles : la révision du .precedent et le dernier instant du
|
// secours lisibles : la révision du .precedent et le dernier instant du
|
||||||
// journal que l'identifiant apparie (journalSansEtat), jamais un journal
|
// journal que l'identifiant apparie (journalSansEtat), jamais un journal
|
||||||
// pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le
|
// pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le
|
||||||
// journal de la base n'existent.
|
// journal de la base n'existent — un fichier aux octets hors UTF-8 existe.
|
||||||
async function refuser(vue, base, details) {
|
async function refuser(vue, base, details) {
|
||||||
const precedent = await vue.lecture(base + SUFFIXES.precedent);
|
const precedent = await vue.lecture(base + SUFFIXES.precedent);
|
||||||
const journal = await vue.texte(base + SUFFIXES.journal);
|
const existe = async (suffixe) => vue.present(base + suffixe);
|
||||||
if (details.raison === 'ABSENT' && precedent.texte === null && journal === null) {
|
if (details.raison === 'ABSENT' && !(await existe(SUFFIXES.precedent)) && !(await existe(SUFFIXES.journal))) {
|
||||||
return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
||||||
}
|
}
|
||||||
const trouve = await journalSansEtat(vue, base);
|
const trouve = await journalSansEtat(vue, base);
|
||||||
|
|
@ -603,7 +703,7 @@ export async function lister(fs, racine) {
|
||||||
for (const { nom, modifie } of fichiers) {
|
for (const { nom, modifie } of fichiers) {
|
||||||
const base = baseDe(nom, SUFFIXES.journal);
|
const base = baseDe(nom, SUFFIXES.journal);
|
||||||
if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue;
|
if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue;
|
||||||
const evenement = evenementDe(await vue.texte(nom));
|
const evenement = evenementDe((await vue.journal(nom)).texte);
|
||||||
if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT'));
|
if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT'));
|
||||||
}
|
}
|
||||||
const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? '');
|
const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? '');
|
||||||
|
|
@ -629,9 +729,10 @@ export async function lister(fs, racine) {
|
||||||
* @param {string} base
|
* @param {string} base
|
||||||
* @returns {Promise<Ouvert>}
|
* @returns {Promise<Ouvert>}
|
||||||
* @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
|
* @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
|
||||||
* secours} : l'état ne se lit pas, ou manque à côté de son .precedent
|
* secours} : l'état ne se lit pas — raison ENCODAGE pour des octets
|
||||||
* ou de son journal (raison ABSENT) ; format est celui de l'état
|
* hors UTF-8 —, ou manque à côté de son .precedent ou de son
|
||||||
* quand son refus en nomme un, sinon celui du journal de la base
|
* journal, fût-il aux octets hors UTF-8 (raison ABSENT) ; format est
|
||||||
|
* celui de l'état quand son refus en nomme un, sinon celui du journal de la base
|
||||||
* quand il est plus récent ; secours nomme la révision lisible du
|
* quand il est plus récent ; secours nomme la révision lisible du
|
||||||
* .precedent et le dernier instant du journal que l'identifiant du
|
* .precedent et le dernier instant du journal que l'identifiant du
|
||||||
* .precedent apparie — ou, sans .precedent lisible, celui du journal
|
* .precedent apparie — ou, sans .precedent lisible, celui du journal
|
||||||
|
|
@ -642,8 +743,7 @@ export async function lister(fs, racine) {
|
||||||
export async function ouvrir(fs, racine, base) {
|
export async function ouvrir(fs, racine, base) {
|
||||||
const vue = vueDe(fs, racine);
|
const vue = vueDe(fs, racine);
|
||||||
const etat = await vue.lecture(base + SUFFIXES.etat);
|
const etat = await vue.lecture(base + SUFFIXES.etat);
|
||||||
if (etat.texte === null) throw await refuser(vue, base, { raison: 'ABSENT', chemin: null });
|
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
|
||||||
if (etat.lecture === null) throw await refuser(vue, base, etat.details);
|
|
||||||
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
||||||
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
||||||
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true });
|
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true });
|
||||||
|
|
@ -675,9 +775,12 @@ export async function ouvrir(fs, racine, base) {
|
||||||
* @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun
|
* @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun
|
||||||
* journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors
|
* journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors
|
||||||
* le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} :
|
* le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} :
|
||||||
* celui de l'analyse d'un .precedent illisible, ou, source 'journal',
|
* celui de l'analyse d'un .precedent illisible, raison ENCODAGE et
|
||||||
|
* chemin null pour des octets hors UTF-8, ou, source 'journal',
|
||||||
* {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base,
|
* {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base,
|
||||||
* format} quand ce journal est d'un format plus récent
|
* format} quand ce journal est d'un format plus récent, {raison:
|
||||||
|
* 'ENCODAGE', chemin: le journal de la base} quand sa ligne
|
||||||
|
* d'ouverture n'est pas de l'UTF-8 et qu'aucun autre ne s'apparie
|
||||||
* @throws {RangeError} une autre source
|
* @throws {RangeError} une autre source
|
||||||
*/
|
*/
|
||||||
export async function ouvrirSecours(fs, racine, base, source) {
|
export async function ouvrirSecours(fs, racine, base, source) {
|
||||||
|
|
@ -688,7 +791,12 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
||||||
const etat = await vue.lecture(base + SUFFIXES.etat);
|
const etat = await vue.lecture(base + SUFFIXES.etat);
|
||||||
const disque = surDisque(etat.texte, etat);
|
const disque = surDisque(etat.texte, etat);
|
||||||
if (source === 'precedent') {
|
if (source === 'precedent') {
|
||||||
const lecture = analyser(await fs.lireTexte(racine, base + SUFFIXES.precedent));
|
const precedent = await vue.lecture(base + SUFFIXES.precedent);
|
||||||
|
if (precedent.details === null && precedent.lecture === null) {
|
||||||
|
throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.precedent });
|
||||||
|
}
|
||||||
|
if (precedent.lecture === null) throw new ErreurStockage('ETAT_ILLISIBLE', precedent.details);
|
||||||
|
const { lecture } = precedent;
|
||||||
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
||||||
const depuis = depuisLecture(lecture, disque);
|
const depuis = depuisLecture(lecture, disque);
|
||||||
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false });
|
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false });
|
||||||
|
|
@ -703,12 +811,16 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
||||||
format: trouve.format,
|
format: trouve.format,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (trouve.malEncode) {
|
||||||
|
throw new ErreurStockage('ETAT_ILLISIBLE', { ...malEncode(), chemin: base + SUFFIXES.journal });
|
||||||
|
}
|
||||||
const instant = instantDe(trouve.lu);
|
const instant = instantDe(trouve.lu);
|
||||||
if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal });
|
if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal });
|
||||||
const depuis = {
|
const depuis = {
|
||||||
charge: canoniser(instant.charge),
|
charge: canoniser(instant.charge),
|
||||||
revision: instant.revision,
|
revision: instant.revision,
|
||||||
produitVersion: versionDe(instant.journal, instant.revision),
|
produitVersion: versionDe(instant.journal, instant.revision),
|
||||||
|
manquants: AUCUN_MANQUANT,
|
||||||
...disque,
|
...disque,
|
||||||
};
|
};
|
||||||
return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false });
|
return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false });
|
||||||
|
|
@ -720,6 +832,7 @@ function exigerEcriture({ formatPlusRecent }) {
|
||||||
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// version quand l'entrée qui suit revision la note, null sinon : à la première
|
// version quand l'entrée qui suit revision la note, null sinon : à la première
|
||||||
// entrée, et quand la construction diffère de la précédente (§ 8.8).
|
// entrée, et quand la construction diffère de la précédente (§ 8.8).
|
||||||
const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version);
|
const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version);
|
||||||
|
|
@ -826,8 +939,8 @@ async function aligner(ouvert, preparation, texte) {
|
||||||
let depart = ouvert;
|
let depart = ouvert;
|
||||||
if (preparation.reprise !== null) {
|
if (preparation.reprise !== null) {
|
||||||
const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise);
|
const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise);
|
||||||
depart = { ...ouvert, ...ecrit };
|
({ journal } = ecrit);
|
||||||
journal = ecrit.journal;
|
depart = { ...ouvert, journal, baseJournal: ecrit.baseJournal, journalAReecrire: false };
|
||||||
entier = false;
|
entier = false;
|
||||||
}
|
}
|
||||||
const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte);
|
const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte);
|
||||||
|
|
@ -844,19 +957,27 @@ function textePlein(journal) {
|
||||||
return { texte: elague, journal: lireJournal(elague) };
|
return { texte: elague, journal: lireJournal(elague) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'ECRITURE d'une recherche dans le dossier de racine qui échoue avant une
|
||||||
|
// écriture du journal chemin — le dossier qui ne se liste plus, ou un
|
||||||
|
// fichier qui ne se lit plus : ABSENT quand le dossier a disparu avec son
|
||||||
|
// support, cause ENOENT ; LECTURE quand la lecture est refusée, sa cause —,
|
||||||
|
// chemin le journal, dossier le chemin de la racine, qui porte les fichiers
|
||||||
|
// de l'événement. L'application nomme ainsi le fichier et son dossier, et
|
||||||
|
// propose d'écrire ailleurs (§ 8.6). Toute autre erreur reste telle quelle.
|
||||||
|
function enEcriture(erreur, chemin, racine) {
|
||||||
|
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') return erreur;
|
||||||
|
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
|
||||||
|
return new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause });
|
||||||
|
}
|
||||||
|
|
||||||
// La base où s'écrit le journal d'un Ouvert sans base de journal, celui de
|
// La base où s'écrit le journal d'un Ouvert sans base de journal, celui de
|
||||||
// sa base portant un autre événement : la base du journal que l'identifiant
|
// sa base portant un autre événement : la base du journal que l'identifiant
|
||||||
// de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une
|
// de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une
|
||||||
// base libre que son nom dérive. L'essai repris après un refus réécrit
|
// base libre que son nom dérive. L'essai repris après un refus réécrit
|
||||||
// ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en
|
// ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en
|
||||||
// dériver un second que l'ouverture n'apparierait pas. Une recherche qui
|
// dériver un second que l'ouverture n'apparierait pas. Une recherche qui
|
||||||
// échoue — le dossier qui ne se liste plus, ou un journal qui ne se lit
|
// échoue lève ECRITURE sur le journal de la base, comme l'écriture qu'elle
|
||||||
// plus : ABSENT quand le dossier a disparu avec son support, LECTURE quand
|
// précède (enEcriture) ; toute autre erreur remonte telle quelle.
|
||||||
// la lecture est refusée — lève ECRITURE, comme l'écriture qu'elle précède :
|
|
||||||
// chemin le journal de la base, dossier le chemin de la racine, qui porte
|
|
||||||
// les fichiers de l'événement, et cause celle de l'échec, ENOENT pour un
|
|
||||||
// dossier disparu. L'application nomme ainsi le fichier et son dossier, et
|
|
||||||
// propose d'écrire ailleurs (§ 8.6). Toute autre erreur remonte telle quelle.
|
|
||||||
async function baseCherchee(ouvert, evenement) {
|
async function baseCherchee(ouvert, evenement) {
|
||||||
const { fs, racine, base } = ouvert;
|
const { fs, racine, base } = ouvert;
|
||||||
try {
|
try {
|
||||||
|
|
@ -865,30 +986,88 @@ async function baseCherchee(ouvert, evenement) {
|
||||||
(await deriver(fs, racine, ouvert.charge.evenement.nom, []))
|
(await deriver(fs, racine, ouvert.charge.evenement.nom, []))
|
||||||
);
|
);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') throw erreur;
|
throw enEcriture(erreur, base + SUFFIXES.journal, racine);
|
||||||
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
|
|
||||||
throw new ErreurStockage('ECRITURE', { chemin: base + SUFFIXES.journal, dossier: racine.chemin, cause });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vrai quand le dossier de l'Ouvert porte encore le fichier chemin, son nom
|
||||||
|
// comparé comme le système compare les noms : sans égard à la casse par
|
||||||
|
// cleSansCasse quand le système l'ignore, à la lettre sinon. Ne lit que la
|
||||||
|
// liste du dossier, jamais le fichier. Un dossier qui ne se liste plus lève
|
||||||
|
// ECRITURE sur ce fichier (enEcriture).
|
||||||
|
async function fichierPresent({ fs, racine }, chemin) {
|
||||||
|
let entrees;
|
||||||
|
try {
|
||||||
|
entrees = await fs.lister(racine, '');
|
||||||
|
} catch (erreur) {
|
||||||
|
throw enEcriture(erreur, chemin, racine);
|
||||||
|
}
|
||||||
|
const { insensibleCasse } = await fs.emplacements();
|
||||||
|
const cle = insensibleCasse ? cleSansCasse : (nom) => nom;
|
||||||
|
return entrees.some(({ nom }) => cle(nom) === cle(chemin));
|
||||||
|
}
|
||||||
|
|
||||||
// Écrit au journal la ligne à la suite de journal, le journal en mémoire que
|
// Écrit au journal la ligne à la suite de journal, le journal en mémoire que
|
||||||
// le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une
|
// le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une
|
||||||
// écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand
|
// écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand
|
||||||
// entier le demande ou que le plafond est dépassé. Sans base, le journal
|
// entier le demande, que le plafond est dépassé, ou que le fichier a disparu
|
||||||
// prend celle que baseCherchee trouve. Rend le journal en mémoire, qui est
|
// (fichierPresent) : un ajout le recommencerait sans sa ligne d'ouverture, et
|
||||||
// le fichier, et sa base.
|
// la lecture l'écarterait en entier. Sans base, le journal prend celle que
|
||||||
|
// baseCherchee trouve. Rend le journal en mémoire, qui est le fichier, sa
|
||||||
|
// base, et recree, vrai quand le fichier disparu s'est réécrit.
|
||||||
async function ecrireJournal(ouvert, preparation, ligne) {
|
async function ecrireJournal(ouvert, preparation, ligne) {
|
||||||
let journal = avec(preparation.journal, ligne);
|
let journal = avec(preparation.journal, ligne);
|
||||||
const { fs, racine } = ouvert;
|
const { fs, racine } = ouvert;
|
||||||
const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement));
|
const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement));
|
||||||
if (preparation.entier || journal.entrees.length > PLAFOND_ENTREES) {
|
const chemin = base + SUFFIXES.journal;
|
||||||
|
const entier = preparation.entier || journal.entrees.length > PLAFOND_ENTREES;
|
||||||
|
const recree = !entier && !(await fichierPresent(ouvert, chemin));
|
||||||
|
if (entier || recree) {
|
||||||
const plein = textePlein(journal);
|
const plein = textePlein(journal);
|
||||||
journal = plein.journal;
|
journal = plein.journal;
|
||||||
await fs.ecrireAtomique(racine, base + SUFFIXES.journal, plein.texte);
|
await fs.ecrireAtomique(racine, chemin, plein.texte);
|
||||||
} else {
|
} else {
|
||||||
await fs.ajouterLigne(racine, base + SUFFIXES.journal, ligne);
|
await fs.ajouterLigne(racine, chemin, ligne);
|
||||||
}
|
}
|
||||||
return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false };
|
return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false, recree };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met de côté l'état aux octets hors UTF-8 d'un Ouvert de secours (§ 8.8),
|
||||||
|
* avant le premier geste qui le remplace : le fichier passe tel quel dans un
|
||||||
|
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille), créé
|
||||||
|
* d'abord, comme supprimer y range un événement. Ses octets ne se perdent
|
||||||
|
* jamais, et l'écriture qui suit pose l'état comme pour un état absent :
|
||||||
|
* l'Ouvert rendu ne le tient plus pour mal encodé. Un Ouvert dont l'état se
|
||||||
|
* lit, ou manque, revient tel quel, sans rien écrire. Un état qui n'est plus
|
||||||
|
* dans le dossier — une mise de côté déjà faite, que suit un geste refusé
|
||||||
|
* réessayé du même Ouvert — ne se déplace pas une seconde fois. Un refus
|
||||||
|
* remonte tel quel : l'état reste à sa place ou dans la corbeille.
|
||||||
|
*
|
||||||
|
* @param {Ouvert} ouvert
|
||||||
|
* @param {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||||
|
* @returns {Promise<Ouvert>}
|
||||||
|
* @throws {ErreurStockage} ECRITURE du dossier ou de la corbeille qui ne se
|
||||||
|
* listent plus, de la création du dossier daté ou du déplacement ;
|
||||||
|
* CORBEILLE_SATUREE, sans rien écrire
|
||||||
|
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire
|
||||||
|
*/
|
||||||
|
export async function mettreDeCoteEtatMalEncode(ouvert, horodatage) {
|
||||||
|
if (!ouvert.etatMalEncode) return ouvert;
|
||||||
|
const { fs, racine, base } = ouvert;
|
||||||
|
const chemin = base + SUFFIXES.etat;
|
||||||
|
if (!(await fichierPresent(ouvert, chemin))) return { ...ouvert, etatMalEncode: false };
|
||||||
|
let existants = [];
|
||||||
|
try {
|
||||||
|
existants = (await fs.lister(racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
||||||
|
} catch (erreur) {
|
||||||
|
// Une corbeille absente est vide.
|
||||||
|
if (erreur.code !== 'ABSENT') throw enEcriture(erreur, DOSSIER_CORBEILLE, racine);
|
||||||
|
}
|
||||||
|
const dossier = dossierCorbeille(horodatage, existants);
|
||||||
|
await fs.creerDossier(racine, dossier);
|
||||||
|
await fs.deplacer(racine, chemin, `${dossier}/${chemin}`);
|
||||||
|
return { ...ouvert, etatMalEncode: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord,
|
// Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord,
|
||||||
|
|
@ -918,16 +1097,23 @@ async function ecrireEtat(avance, texteEtat) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
|
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
|
||||||
// placements et d'un journal qui finit ailleurs que sur elle.
|
// placements, d'un journal qui finit ailleurs que sur elle, et du journal
|
||||||
function apresEcriture(ouvert, { charge, revision, journal }) {
|
// disparu que l'écriture a réécrit (recree).
|
||||||
|
function apresEcriture(ouvert, { charge, revision, journal, recree = false }) {
|
||||||
const examen = examiner(charge);
|
const examen = examiner(charge);
|
||||||
return {
|
const avertissements = avertir({
|
||||||
...ouvert,
|
|
||||||
charge: examen.charge,
|
|
||||||
revision,
|
|
||||||
examen,
|
examen,
|
||||||
avertissements: avertir({ examen, journal, revision, rattrape: false, ecartees: 0, format: null, etranger: null }),
|
journal,
|
||||||
};
|
revision,
|
||||||
|
rattrape: false,
|
||||||
|
ecartees: 0,
|
||||||
|
recree,
|
||||||
|
format: null,
|
||||||
|
malEncode: false,
|
||||||
|
etranger: null,
|
||||||
|
manquants: AUCUN_MANQUANT,
|
||||||
|
});
|
||||||
|
return { ...ouvert, charge: examen.charge, revision, examen, avertissements };
|
||||||
}
|
}
|
||||||
|
|
||||||
// La ligne de l'entrée d'un geste à la suite du journal que preparer a
|
// La ligne de l'entrée d'un geste à la suite du journal que preparer a
|
||||||
|
|
@ -990,6 +1176,7 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
||||||
chargeJournal: canoniser(charge),
|
chargeJournal: canoniser(charge),
|
||||||
journalAReecrire: false,
|
journalAReecrire: false,
|
||||||
formatPlusRecent: false,
|
formatPlusRecent: false,
|
||||||
|
etatMalEncode: false,
|
||||||
};
|
};
|
||||||
return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat);
|
return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat);
|
||||||
}
|
}
|
||||||
|
|
@ -1004,7 +1191,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
||||||
* ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et
|
* ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et
|
||||||
* au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat,
|
* au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat,
|
||||||
* s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien
|
* s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien
|
||||||
* s'écrit au format courant : la conversion tombe au premier geste.
|
* s'écrit au format courant : la conversion tombe au premier geste. Avant
|
||||||
|
* (1), l'état aux octets hors UTF-8 d'un Ouvert de secours passe dans un
|
||||||
|
* dossier daté de la corbeille, à l'horodatage du geste
|
||||||
|
* (mettreDeCoteEtatMalEncode).
|
||||||
*
|
*
|
||||||
* Quand l'état lisible sur le disque ne porte pas la révision de la dernière
|
* Quand l'état lisible sur le disque ne porte pas la révision de la dernière
|
||||||
* entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le
|
* entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le
|
||||||
|
|
@ -1027,6 +1217,8 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
||||||
* requis quand elle a lieu
|
* requis quand elle a lieu
|
||||||
* @returns {Promise<Ouvert>}
|
* @returns {Promise<Ouvert>}
|
||||||
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
|
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
|
||||||
|
* ce que lève la mise de côté d'un état aux octets hors UTF-8
|
||||||
|
* (mettreDeCoteEtatMalEncode), avant le journal ;
|
||||||
* ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant
|
* ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant
|
||||||
* l'alignement, la recherche du journal d'un Ouvert sans base de
|
* l'alignement, la recherche du journal d'un Ouvert sans base de
|
||||||
* journal comprise (baseCherchee) : le geste n'est pas au journal, et
|
* journal comprise (baseCherchee) : le geste n'est pas au journal, et
|
||||||
|
|
@ -1044,10 +1236,11 @@ export async function enregistrer(ouvert, apres, options) {
|
||||||
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
||||||
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
||||||
const aligne = etatAligne(ouvert, preparation);
|
const aligne = etatAligne(ouvert, preparation);
|
||||||
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
|
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
|
||||||
const ecrit = await ecrireJournal(suite.depart, suite.preparation, ligne);
|
const suite = aligne === null ? { depart, preparation } : await aligner(depart, preparation, aligne);
|
||||||
|
const { recree, ...ecrit } = await ecrireJournal(suite.depart, suite.preparation, ligne);
|
||||||
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) };
|
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) };
|
||||||
return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal }), texteEtat);
|
return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree }), texteEtat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1066,9 +1259,9 @@ export async function nommer(ouvert, { nom, horodatage, libelleReprise }) {
|
||||||
exigerEcriture(ouvert);
|
exigerEcriture(ouvert);
|
||||||
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||||
const revision = preparation.journal.entrees.at(-1).revision;
|
const revision = preparation.journal.entrees.at(-1).revision;
|
||||||
const ecrit = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage }));
|
const { recree, ...ecrit } = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage }));
|
||||||
const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge };
|
const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge };
|
||||||
return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal });
|
return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal, recree });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1216,6 +1409,7 @@ export async function enregistrerAilleurs(
|
||||||
baseJournal: base,
|
baseJournal: base,
|
||||||
chargeJournal: paire.chargeJournal,
|
chargeJournal: paire.chargeJournal,
|
||||||
journalAReecrire: false,
|
journalAReecrire: false,
|
||||||
|
etatMalEncode: false,
|
||||||
};
|
};
|
||||||
return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
|
return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,40 @@ function remplacer(fichiers, nom, texte) {
|
||||||
|
|
||||||
const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1];
|
const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1];
|
||||||
|
|
||||||
|
// Un disque neuf dont documents porte les fichiers, nom posé en octets.
|
||||||
|
function disqueAvecOctets(fichiers, nom, octets) {
|
||||||
|
const { disque, fs } = disqueAvec(remplacer(fichiers, nom, null));
|
||||||
|
fs.deposerOctets('documents', nom, octets);
|
||||||
|
return { disque, fs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les octets UTF-8 du texte, son premier « é » (C3 A9) remplacé par l'octet
|
||||||
|
// E9 qu'écrit un éditeur réglé en windows-1252 : une suite qui n'est pas de
|
||||||
|
// l'UTF-8.
|
||||||
|
function malEncode(texte) {
|
||||||
|
const octets = [...new TextEncoder().encode(texte)];
|
||||||
|
const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9);
|
||||||
|
assert.ok(rang >= 0, 'aucun « é » à mal encoder');
|
||||||
|
octets.splice(rang, 2, 0xe9);
|
||||||
|
return new Uint8Array(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les octets UTF-8 d'un texte de journal, sa ligne de rang donné (0 pour
|
||||||
|
// l'ouverture) ouverte par l'octet E9 suivi de « { » : une suite qui n'est
|
||||||
|
// pas de l'UTF-8, sur cette ligne seule.
|
||||||
|
function ligneMalEncodee(texte, rang) {
|
||||||
|
const lignes = texte.split('\n');
|
||||||
|
assert.ok(rang < lignes.length - 1, `aucune ligne ${rang}`);
|
||||||
|
const avant = new TextEncoder().encode(lignes.slice(0, rang).map((ligne) => `${ligne}\n`).join(''));
|
||||||
|
const apres = new TextEncoder().encode(lignes.slice(rang).join('\n'));
|
||||||
|
return new Uint8Array([...avant, 0xe9, ...apres]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le rang de la ligne d'un texte de journal qui porte l'entrée de révision
|
||||||
|
// donnée.
|
||||||
|
const rangDeLEntree = (texte, revision) =>
|
||||||
|
texte.split('\n').findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === revision);
|
||||||
|
|
||||||
// Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir
|
// Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir
|
||||||
// n'écrit rien (§ 8.4).
|
// n'écrit rien (§ 8.4).
|
||||||
async function rouvrir(disque, base) {
|
async function rouvrir(disque, base) {
|
||||||
|
|
@ -594,6 +628,93 @@ describe('dépôt : un geste, une entrée, un état (§ 8.2)', () => {
|
||||||
assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal);
|
assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un journal effacé entre deux gestes ne reçoit pas d'ajout : le geste suivant le réécrit en entier, ouverture et entrées en mémoire comprises, JOURNAL_RECREE ; un jalon de même ; puis les ajouts reprennent", async () => {
|
||||||
|
const { disque, fs } = nouveauDisque();
|
||||||
|
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 2), geste(2));
|
||||||
|
await fs.supprimer(DOC, JOURNAL);
|
||||||
|
const trace = tracer(fs);
|
||||||
|
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 3), geste(3));
|
||||||
|
assert.deepEqual(trace.splice(0), [
|
||||||
|
['ecrireAtomique', JOURNAL],
|
||||||
|
['ecrireAtomique', PRECEDENT],
|
||||||
|
['ecrireAtomique', ETAT],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
|
||||||
|
let journal = exigerEquivalence(fs, BASE);
|
||||||
|
assert.equal(journal.evenement, 'evt-lucioles');
|
||||||
|
assert.deepEqual(revisionsEtLibelles(journal), [
|
||||||
|
[1, CREATION.libelle],
|
||||||
|
[2, 'Geste 2'],
|
||||||
|
[3, 'Geste 3'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await fs.supprimer(DOC, JOURNAL);
|
||||||
|
trace.splice(0);
|
||||||
|
ouvert = await nommer(ouvert, { nom: 'Avant le tirage', horodatage: heure(4) });
|
||||||
|
assert.deepEqual(trace.splice(0), [['ecrireAtomique', JOURNAL]]);
|
||||||
|
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
|
||||||
|
journal = exigerEquivalence(fs, BASE);
|
||||||
|
assert.deepEqual(
|
||||||
|
journal.jalons.map(({ revision, nom }) => [revision, nom]),
|
||||||
|
[
|
||||||
|
[1, 'Création'],
|
||||||
|
[3, 'Avant le tirage'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 5), geste(5));
|
||||||
|
assert.deepEqual(trace.splice(0), [
|
||||||
|
['ajouterLigne', JOURNAL],
|
||||||
|
['ecrireAtomique', PRECEDENT],
|
||||||
|
['ecrireAtomique', ETAT],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(ouvert.avertissements, []);
|
||||||
|
assert.equal(exigerEquivalence(fs, BASE).entrees.length, 4);
|
||||||
|
assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le journal présent se reconnaît comme le système compare les noms : sans égard à la casse, une base d'une autre casse ajoute en fin ; sensible à la casse, le nom exact", async () => {
|
||||||
|
const insensible = nouveauDisque();
|
||||||
|
await creer(insensible.fs, DOC, chargeEssai(), CREATION);
|
||||||
|
const autreCasse = await ouvrir(insensible.fs, DOC, BASE.toUpperCase());
|
||||||
|
const trace = tracer(insensible.fs);
|
||||||
|
const suite = await enregistrer(autreCasse, modifier(autreCasse.charge, 2), geste(2));
|
||||||
|
assert.deepEqual(trace[0], ['ajouterLigne', `${BASE.toUpperCase()}${SUFFIXES.journal}`]);
|
||||||
|
assert.deepEqual(suite.avertissements, []);
|
||||||
|
assert.equal(exigerEquivalence(insensible.fs, BASE).entrees.length, 2);
|
||||||
|
|
||||||
|
const sensible = nouveauDisque({ separateur: '/', insensibleCasse: false, documents: '/exemple/Documents' });
|
||||||
|
const racine = { id: 'documents', chemin: '/exemple/Documents' };
|
||||||
|
let ouvert = await creer(sensible.fs, racine, chargeEssai(), CREATION);
|
||||||
|
sensible.fs.deposer('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`, 'un autre fichier');
|
||||||
|
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
|
||||||
|
assert.deepEqual(ouvert.avertissements, []);
|
||||||
|
await sensible.fs.supprimer(racine, JOURNAL);
|
||||||
|
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
|
||||||
|
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]);
|
||||||
|
assert.equal(sensible.fs.contenu('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`), 'un autre fichier');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un dossier qui ne se liste plus au moment d'ajouter au journal lève ECRITURE sur le journal, sans rien écrire : ENOENT quand il a disparu, la cause d'une lecture refusée sinon", async () => {
|
||||||
|
for (const [nom, refus, cause] of [
|
||||||
|
['disparu', (chemin) => new ErreurStockage('ABSENT', { chemin }), 'ENOENT'],
|
||||||
|
['illisible', (chemin) => new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }), 'EACCES'],
|
||||||
|
]) {
|
||||||
|
const { disque, fs } = nouveauDisque();
|
||||||
|
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
const avant = await fichiersDe(disque);
|
||||||
|
const ecritures = fs.compteurs.ecritures;
|
||||||
|
fs.lister = async (racine, chemin) => {
|
||||||
|
throw refus(chemin);
|
||||||
|
};
|
||||||
|
const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'ECRITURE', nom);
|
||||||
|
assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause }, nom);
|
||||||
|
assert.equal(fs.compteurs.ecritures, ecritures, nom);
|
||||||
|
assert.deepEqual(await fichiersDe(disque), avant, nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => {
|
test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => {
|
||||||
const { disque, fs } = nouveauDisque();
|
const { disque, fs } = nouveauDisque();
|
||||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
|
@ -1277,6 +1398,228 @@ describe('dépôt : un état illisible et ses secours (§ 8.8)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('dépôt : des octets hors UTF-8 (§ 8.8)', () => {
|
||||||
|
test("un état aux octets hors UTF-8 lève ETAT_ILLISIBLE, raison ENCODAGE, avec ses secours, sans rien écrire ; lister le montre illisible, apparié par son .precedent", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const { fs } = disqueAvecOctets(fichiers, ETAT, malEncode(texteDe(fichiers, ETAT)));
|
||||||
|
const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual(erreur.details, {
|
||||||
|
base: BASE,
|
||||||
|
raison: 'ENCODAGE',
|
||||||
|
chemin: null,
|
||||||
|
secours: { precedent: 2, journal: 3 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
|
||||||
|
[[BASE, 'evt-lucioles', 'ENCODAGE', BASE]],
|
||||||
|
);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ouvert d'un secours, le premier geste met l'état aux octets hors UTF-8 dans un dossier neuf de la corbeille, daté du geste, ses octets intacts, puis écrit le journal et l'état ; un jalon n'écrit que le journal, sans toucher l'état", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const octets = malEncode(texteDe(fichiers, ETAT));
|
||||||
|
const ecritureDuJournal = { journal: ['ajouterLigne', JOURNAL], precedent: ['ecrireAtomique', JOURNAL] };
|
||||||
|
for (const source of ['journal', 'precedent']) {
|
||||||
|
const { disque, fs } = disqueAvecOctets(fichiers, ETAT, octets);
|
||||||
|
// Le dossier de la même seconde, déjà pris pour le secours .precedent,
|
||||||
|
// fait prendre le rang suivant.
|
||||||
|
if (source === 'precedent') fs.deposerDossier('documents', 'corbeille/2031-05-17_08-04-00');
|
||||||
|
const corbeille = `corbeille/2031-05-17_08-04-00${source === 'precedent' ? '_2' : ''}`;
|
||||||
|
const ouvert = await ouvrirSecours(fs, DOC, BASE, source);
|
||||||
|
assert.deepEqual([ouvert.etatMalEncode, ouvert.texteEtat], [true, null], source);
|
||||||
|
const trace = tracer(fs);
|
||||||
|
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
|
||||||
|
assert.deepEqual(
|
||||||
|
trace,
|
||||||
|
[['creerDossier', corbeille], ['deplacer', ETAT], ecritureDuJournal[source], ['ecrireAtomique', ETAT]],
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
assert.deepEqual(fs.octets('documents', `${corbeille}/${ETAT}`), octets, source);
|
||||||
|
assert.equal(fs.contenu('documents', PRECEDENT), texteDe(fichiers, PRECEDENT), source);
|
||||||
|
assert.equal(suite.etatMalEncode, false, source);
|
||||||
|
exigerEquivalence(fs, BASE);
|
||||||
|
const rouvert = await rouvrir(disque, BASE);
|
||||||
|
assert.deepEqual([rouvert.revision, rouvert.avertissements], [suite.revision, []], source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
|
||||||
|
const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal');
|
||||||
|
const trace = tracer(fs);
|
||||||
|
const nomme = await nommer(parJournal, { nom: 'Avant la reprise', horodatage: heure(5) });
|
||||||
|
assert.deepEqual(trace, [['ajouterLigne', JOURNAL]]);
|
||||||
|
assert.equal(nomme.etatMalEncode, true);
|
||||||
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un geste refusé après la mise de côté de l'état aux octets hors UTF-8 se réessaie du même Ouvert sans rien déplacer de nouveau : un seul dossier daté, qui garde les octets", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const octets = malEncode(texteDe(fichiers, ETAT));
|
||||||
|
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
|
||||||
|
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
|
||||||
|
const ajouter = fs.ajouterLigne;
|
||||||
|
fs.ajouterLigne = async (racine, chemin) => {
|
||||||
|
fs.ajouterLigne = ajouter;
|
||||||
|
throw new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EIO' });
|
||||||
|
};
|
||||||
|
const options = geste(4, { libelleReprise: REPRISE });
|
||||||
|
await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), options), 'ECRITURE');
|
||||||
|
assert.equal(fs.contenu('documents', JOURNAL), texteDe(fichiers, JOURNAL));
|
||||||
|
const trace = tracer(fs);
|
||||||
|
await enregistrer(ouvert, modifier(ouvert.charge, 4), options);
|
||||||
|
assert.deepEqual(trace, [
|
||||||
|
['ajouterLigne', JOURNAL],
|
||||||
|
['ecrireAtomique', ETAT],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom),
|
||||||
|
['2031-05-17_08-04-00'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(fs.octets('documents', `corbeille/2031-05-17_08-04-00/${ETAT}`), octets);
|
||||||
|
exigerEquivalence(fs, BASE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une corbeille qui ne se liste plus, au moment de mettre de côté l'état aux octets hors UTF-8, lève ECRITURE sur la corbeille sans rien écrire : l'état reste à sa place", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const octets = malEncode(texteDe(fichiers, ETAT));
|
||||||
|
const { fs } = disqueAvecOctets(fichiers, ETAT, octets);
|
||||||
|
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
|
||||||
|
const lister = fs.lister;
|
||||||
|
fs.lister = async (racine, dossier) => {
|
||||||
|
if (dossier !== 'corbeille') return lister(racine, dossier);
|
||||||
|
throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: racine.chemin, cause: 'EACCES' });
|
||||||
|
};
|
||||||
|
const ecritures = fs.compteurs.ecritures;
|
||||||
|
const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4)), 'ECRITURE');
|
||||||
|
assert.deepEqual(erreur.details, { chemin: 'corbeille', dossier: DOCUMENTS, cause: 'EACCES' });
|
||||||
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un .precedent aux octets hors UTF-8 ne gêne pas l'état lisible ; ouvert en secours, il lève ETAT_ILLISIBLE ENCODAGE ; un état illisible à côté de lui s'apparie par son journal", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const { fs } = disqueAvecOctets(fichiers, PRECEDENT, malEncode(texteDe(fichiers, PRECEDENT)));
|
||||||
|
const ouvert = await ouvrir(fs, DOC, BASE);
|
||||||
|
assert.deepEqual([ouvert.revision, ouvert.etatMalEncode, ouvert.avertissements], [3, false, []]);
|
||||||
|
const erreur = await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual(erreur.details, { raison: 'ENCODAGE', chemin: null });
|
||||||
|
fs.deposer('documents', ETAT, '');
|
||||||
|
const illisible = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual(illisible.details, {
|
||||||
|
base: BASE,
|
||||||
|
raison: 'VIDE',
|
||||||
|
chemin: null,
|
||||||
|
secours: { precedent: null, journal: 3 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]),
|
||||||
|
[[BASE, 'evt-lucioles', 'VIDE']],
|
||||||
|
);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un journal dont la ligne d'ouverture n'est pas de l'UTF-8 n'empêche pas d'ouvrir l'état : il n'est ni lu, ni apparié, ni remplacé, JOURNAL_ILLISIBLE {raison: ENCODAGE} ; le premier geste écrit l'historique sous une base libre, que la réouverture apparie", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const octets = ligneMalEncodee(texteDe(fichiers, JOURNAL), 0);
|
||||||
|
const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets);
|
||||||
|
const ouvert = await ouvrir(fs, DOC, BASE);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
assert.deepEqual([ouvert.revision, ouvert.journal, ouvert.baseJournal], [3, null, null]);
|
||||||
|
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ILLISIBLE', details: { raison: 'ENCODAGE' } }]);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
|
||||||
|
[[BASE, 'evt-lucioles', null, null]],
|
||||||
|
);
|
||||||
|
const secours = await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual(secours.details, { raison: 'ENCODAGE', chemin: JOURNAL });
|
||||||
|
|
||||||
|
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
|
||||||
|
assert.notEqual(suite.baseJournal, BASE);
|
||||||
|
assert.deepEqual(fs.octets('documents', JOURNAL), octets);
|
||||||
|
const journal = exigerEquivalence(fs, BASE, { journal: suite.baseJournal });
|
||||||
|
assert.deepEqual(revisionsEtLibelles(journal), [
|
||||||
|
[3, REPRISE],
|
||||||
|
[4, 'Geste 4'],
|
||||||
|
]);
|
||||||
|
const rouvert = await rouvrir(disque, BASE);
|
||||||
|
assert.deepEqual([rouvert.revision, rouvert.baseJournal, rouvert.avertissements], [4, suite.baseJournal, []]);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, journal: apparie }) => [base, apparie]),
|
||||||
|
[[BASE, suite.baseJournal]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un journal seul dont la ligne d'ouverture n'est pas de l'UTF-8 se liste sans identifiant, et s'ouvre en ETAT_ILLISIBLE ABSENT sans secours, non en ABSENT : il existe", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const { fs } = disqueAvecOctets([], JOURNAL, ligneMalEncodee(texteDe(fichiers, JOURNAL), 0));
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
|
||||||
|
[[BASE, null, 'ABSENT', BASE]],
|
||||||
|
);
|
||||||
|
const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual(erreur.details, {
|
||||||
|
base: BASE,
|
||||||
|
raison: 'ABSENT',
|
||||||
|
chemin: null,
|
||||||
|
secours: { precedent: null, journal: null },
|
||||||
|
});
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une dernière ligne de journal coupée au milieu d'un caractère de deux octets s'écarte comme une ligne illisible, JOURNAL_ECARTEES {n: 1} : le journal reste celui de l'événement, apparié et ouvrable en secours, et le premier geste le réécrit sans elle", async () => {
|
||||||
|
const { fichiers } = await troisRevisions();
|
||||||
|
const texte = texteDe(fichiers, JOURNAL);
|
||||||
|
const coupee = new TextEncoder().encode('{"type":"entree","revision":4,"libelle":"Ajout de Th');
|
||||||
|
const octets = new Uint8Array([...new TextEncoder().encode(texte), ...coupee, 0xc3]);
|
||||||
|
const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets);
|
||||||
|
const ouvert = await ouvrir(fs, DOC, BASE);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
assert.deepEqual([ouvert.revision, ouvert.baseJournal, ouvert.journalAReecrire], [3, BASE, true]);
|
||||||
|
assert.deepEqual(revisionsEtLibelles(ouvert.journal), revisionsEtLibelles(lireJournal(texte)));
|
||||||
|
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]),
|
||||||
|
[[BASE, 'evt-lucioles', null, BASE]],
|
||||||
|
);
|
||||||
|
assert.equal((await ouvrirSecours(fs, DOC, BASE, 'journal')).revision, 3);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
|
||||||
|
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
|
||||||
|
assert.equal(suite.baseJournal, BASE);
|
||||||
|
const journal = exigerEquivalence(fs, BASE);
|
||||||
|
assert.deepEqual(
|
||||||
|
journal.entrees.map(({ revision }) => revision),
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await fichiersDe(disque)).map(([nom]) => nom),
|
||||||
|
[JOURNAL, ETAT, PRECEDENT],
|
||||||
|
);
|
||||||
|
const rouvert = await rouvrir(disque, BASE);
|
||||||
|
assert.deepEqual([rouvert.revision, rouvert.avertissements], [4, []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un octet hors UTF-8 dans l'entrée 37 d'un journal de 100 entrées arrête la lecture sur elle : 36 entrées retenues, 64 écartées et comptées, le journal toujours apparié", async () => {
|
||||||
|
const { disque, fs } = nouveauDisque();
|
||||||
|
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
for (let n = 2; n <= 100; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
|
||||||
|
const texte = fs.contenu('documents', JOURNAL);
|
||||||
|
assert.equal(lireJournal(texte).entrees.length, 100);
|
||||||
|
fs.deposerOctets('documents', JOURNAL, ligneMalEncodee(texte, rangDeLEntree(texte, 37)));
|
||||||
|
const rouvert = await rouvrir(disque, BASE);
|
||||||
|
assert.deepEqual(
|
||||||
|
rouvert.journal.entrees.map(({ revision }) => revision),
|
||||||
|
Array.from({ length: 36 }, (_, i) => i + 1),
|
||||||
|
);
|
||||||
|
assert.equal(rouvert.journal.ecartees, 64);
|
||||||
|
assert.equal(rouvert.baseJournal, BASE);
|
||||||
|
assert.deepEqual(rouvert.avertissements, [
|
||||||
|
{ code: 'JOURNAL_INCOMPLET', details: { etat: 100, journal: 36 } },
|
||||||
|
{ code: 'JOURNAL_ECARTEES', details: { n: 64 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => {
|
describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => {
|
||||||
test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => {
|
test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => {
|
||||||
const charge = chargeEssai();
|
const charge = chargeEssai();
|
||||||
|
|
@ -1715,6 +2058,58 @@ describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)'
|
||||||
assert.equal(ouvert.charge.participants.length, 4);
|
assert.equal(ouvert.charge.participants.length, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("une proposition que l'en-tête compte sans que la liste la porte s'annonce par leur nombre, PROPOSITIONS_MANQUANTES {n}, et un retenu annoncé absent, RETENU_FAUTIF : le fichier s'ouvre ; rattrapé du journal, ou après un geste, plus rien ne manque", async () => {
|
||||||
|
const charge = chargeEssai();
|
||||||
|
charge.propositions.push(placement(2, [[1, 2], [3, 4]]));
|
||||||
|
charge.prochainsIds.proposition = 3;
|
||||||
|
charge.retenu = retenuDe(charge.propositions[0]);
|
||||||
|
const { disque, fs } = nouveauDisque();
|
||||||
|
const cree = await creer(fs, DOC, charge, CREATION);
|
||||||
|
await enregistrer(cree, modifier(cree.charge, 2), geste(2));
|
||||||
|
const fichiers = await fichiersDe(disque);
|
||||||
|
// L'état perd la proposition 1 et le retenu, ses comptes intacts.
|
||||||
|
const tronquer = (texte) => {
|
||||||
|
const fichier = JSON.parse(texte);
|
||||||
|
fichier.charge.propositions.shift();
|
||||||
|
fichier.charge.retenu = null;
|
||||||
|
return JSON.stringify(fichier);
|
||||||
|
};
|
||||||
|
const tronque = tronquer(texteDe(fichiers, ETAT));
|
||||||
|
assert.deepEqual(analyser(tronque).manquants, { propositions: 1, retenu: true });
|
||||||
|
|
||||||
|
const sansJournal = disqueAvec([[ETAT, tronque]]);
|
||||||
|
const ouvert = await rouvrir(sansJournal.disque, BASE);
|
||||||
|
assert.deepEqual(ouvert.avertissements, [
|
||||||
|
{ code: 'RETENU_FAUTIF', details: {} },
|
||||||
|
{ code: 'PROPOSITIONS_MANQUANTES', details: { n: 1 } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual([ouvert.charge.propositions.map(({ id }) => id), ouvert.charge.retenu], [[2], null]);
|
||||||
|
const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3, { libelleReprise: REPRISE }));
|
||||||
|
assert.deepEqual(suite.avertissements, []);
|
||||||
|
const relu = analyser(sansJournal.fs.contenu('documents', ETAT));
|
||||||
|
assert.deepEqual(relu.manquants, { propositions: 0, retenu: false });
|
||||||
|
|
||||||
|
// Le journal en avance d'une entrée rend sa charge, qui ne manque de rien.
|
||||||
|
const enRetard = tronquer(texteDe(fichiers, PRECEDENT));
|
||||||
|
const avecJournal = disqueAvec(remplacer(fichiers, ETAT, enRetard));
|
||||||
|
const rattrape = await rouvrir(avecJournal.disque, BASE);
|
||||||
|
assert.deepEqual(rattrape.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
|
||||||
|
assert.deepEqual([rattrape.revision, rattrape.charge.propositions.length], [2, 2]);
|
||||||
|
assert.notEqual(rattrape.charge.retenu, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un en-tête qui annonce 2^40 propositions s'ouvre : PROPOSITIONS_MANQUANTES ne porte que leur nombre, jamais une liste de cette taille, à côté des fautives nommées", async () => {
|
||||||
|
const fichier = JSON.parse(etatTexte(avecFautive()));
|
||||||
|
fichier.entete.comptes.propositions = 2 ** 40;
|
||||||
|
const { disque } = disqueAvec([[ETAT, JSON.stringify(fichier)]]);
|
||||||
|
const ouvert = await rouvrir(disque, BASE);
|
||||||
|
assert.deepEqual(ouvert.avertissements, [
|
||||||
|
{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } },
|
||||||
|
{ code: 'PROPOSITIONS_MANQUANTES', details: { n: 2 ** 40 - 2 } },
|
||||||
|
]);
|
||||||
|
assert.ok(JSON.stringify(ouvert.avertissements).length < 200);
|
||||||
|
});
|
||||||
|
|
||||||
test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => {
|
test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => {
|
||||||
// Par péremption, ce qui a changé dans la charge depuis la production de
|
// Par péremption, ce qui a changé dans la charge depuis la production de
|
||||||
// sa proposition : périmées, la proposition et le retenu se gardent et se
|
// sa proposition : périmées, la proposition et le retenu se gardent et se
|
||||||
|
|
@ -2455,9 +2850,19 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
||||||
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
|
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => {
|
test("une lecture qui échoue autrement que sur un fichier absent ou mal encodé remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE, une lecture refusée LECTURE", async () => {
|
||||||
const { fs } = nouveauDisque();
|
const { fs } = nouveauDisque();
|
||||||
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
|
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
|
||||||
assert.equal(fs.compteurs.ecritures, 0);
|
await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
const ecritures = fs.compteurs.ecritures;
|
||||||
|
fs.lireTexte = async (racine, chemin) => {
|
||||||
|
throw new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' });
|
||||||
|
};
|
||||||
|
assert.deepEqual((await echec(ouvrir(fs, DOC, BASE), 'LECTURE')).details, {
|
||||||
|
chemin: ETAT,
|
||||||
|
dossier: DOCUMENTS,
|
||||||
|
cause: 'EACCES',
|
||||||
|
});
|
||||||
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@
|
||||||
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
|
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
|
||||||
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
|
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
|
||||||
// participants et des tables, et le tour des réservations (FORME) ; les
|
// participants et des tables, et le tour des réservations (FORME) ; les
|
||||||
// comptes de l'en-tête (COMPTES) ; les références (REFERENCE). Un fichier
|
// comptes de la saisie dans l'en-tête — participants, tables, réservations,
|
||||||
|
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
|
||||||
|
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
|
||||||
|
// ce que l'en-tête annonce sans que la charge le porte se rend à côté
|
||||||
|
// d'elle, et le dépôt l'annonce. Un fichier
|
||||||
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
|
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
|
||||||
// connaît, et ce qui serait FORME au format courant y porte la raison
|
// connaît, et ce qui serait FORME au format courant y porte la raison
|
||||||
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
|
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
|
||||||
|
|
@ -507,13 +511,26 @@ function examinerTours({ evenement, reservations }, faute) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chaque compte de l'en-tête contre la charge, dans l'ordre du schéma ;
|
// Comptes de l'en-tête qui portent sur les placements : une proposition ou
|
||||||
// plus s'ajoute aux détails du refus.
|
// un retenu perdus se recalculent, la saisie non.
|
||||||
|
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
|
||||||
|
|
||||||
|
// Chaque compte de la saisie dans l'en-tête contre la charge, dans l'ordre
|
||||||
|
// du schéma ; plus s'ajoute aux détails du refus. Rend ce que les comptes
|
||||||
|
// des placements annoncent sans que la charge le porte : propositions, le
|
||||||
|
// nombre annoncé au-delà de la liste ; retenu, vrai quand il est annoncé et
|
||||||
|
// absent. Un compte plus petit que sa liste ne dit pas quelle proposition
|
||||||
|
// est en trop : la liste se lit telle quelle.
|
||||||
function examinerComptes(comptes, charge, plus) {
|
function examinerComptes(comptes, charge, plus) {
|
||||||
const reels = comptesDe(charge);
|
const reels = comptesDe(charge);
|
||||||
for (const [cle] of COMPTES.champs) {
|
for (const [cle] of COMPTES.champs) {
|
||||||
|
if (COMPTES_DES_PLACEMENTS.has(cle)) continue;
|
||||||
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
|
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
propositions: Math.max(0, comptes.propositions - reels.propositions),
|
||||||
|
retenu: comptes.retenu > reels.retenu,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Références des réservations, puis des titres : la personne et la table
|
// Références des réservations, puis des titres : la personne et la table
|
||||||
|
|
@ -539,8 +556,10 @@ function examinerReferences(charge, plus) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
|
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
|
||||||
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge et
|
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge,
|
||||||
* formatPlusRecent, vrai quand le format dépasse FORMAT. Au format courant,
|
* formatPlusRecent, vrai quand le format dépasse FORMAT, et manquants, ce que
|
||||||
|
* les comptes des propositions et du retenu annoncent sans que la charge le
|
||||||
|
* porte, qui ne refuse jamais le fichier (§ 8.9, point 3). Au format courant,
|
||||||
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
|
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
|
||||||
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
|
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
|
||||||
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
|
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
|
||||||
|
|
@ -583,9 +602,9 @@ export function analyser(texte) {
|
||||||
const { entete, charge } = fichier;
|
const { entete, charge } = fichier;
|
||||||
examinerIdentifiants(charge, faute);
|
examinerIdentifiants(charge, faute);
|
||||||
examinerTours(charge, faute);
|
examinerTours(charge, faute);
|
||||||
examinerComptes(entete.comptes, charge, plus);
|
const manquants = examinerComptes(entete.comptes, charge, plus);
|
||||||
examinerReferences(charge, plus);
|
examinerReferences(charge, plus);
|
||||||
return { entete, charge, formatPlusRecent };
|
return { entete, charge, formatPlusRecent, manquants };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -593,8 +612,12 @@ export function analyser(texte) {
|
||||||
* appartenance et leur exclusion ; les tables avec leur capacité courante ;
|
* appartenance et leur exclusion ; les tables avec leur capacité courante ;
|
||||||
* le nombre de tours ; les réservations sans leur siège, que le moteur ne
|
* le nombre de tours ; les réservations sans leur siège, que le moteur ne
|
||||||
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
|
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
|
||||||
* réglages. Les listes gardent l'ordre de la charge : le rang d'une
|
* réglages. Les tables vont par identifiant croissant, l'ordre du texte
|
||||||
* réservation que nomme une ErreurConfiguration est son rang dans la charge.
|
* canonique : le moteur assoit table après table, et une charge aux tables
|
||||||
|
* dans un autre ordre, relue après un enregistrement, ne régénérerait plus
|
||||||
|
* ses propositions (§ 8.9). Les participants et les réservations gardent
|
||||||
|
* l'ordre de la charge : le rang d'une réservation que nomme une
|
||||||
|
* ErreurConfiguration est son rang dans la charge.
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').Charge} charge
|
* @param {import('./types.js').Charge} charge
|
||||||
* @returns {import('../moteur/types.js').Configuration}
|
* @returns {import('../moteur/types.js').Configuration}
|
||||||
|
|
@ -603,7 +626,9 @@ export function configurationDepuisCharge(charge) {
|
||||||
const { evenement, reglages } = charge;
|
const { evenement, reglages } = charge;
|
||||||
return {
|
return {
|
||||||
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
|
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
|
||||||
tables: charge.tables.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
|
tables: [...charge.tables]
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
|
||||||
tours: evenement.tours,
|
tours: evenement.tours,
|
||||||
reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
|
reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
|
||||||
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },
|
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { describe, test } from '../../test/lanceur.js';
|
||||||
import { STATUT, normaliser } from '../moteur/configuration.js';
|
import { STATUT, normaliser } from '../moteur/configuration.js';
|
||||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||||
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
|
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
|
||||||
|
import { texteLivre } from '../demo/livrees.js';
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
import { serialiser } from './canonique.js';
|
import { serialiser } from './canonique.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -231,6 +232,16 @@ describe('ErreurStockage (erreurs.js)', () => {
|
||||||
assert.deepEqual(sansDetails.details, {});
|
assert.deepEqual(sansDetails.details, {});
|
||||||
assert.equal(sansDetails.message, 'CORRECTIF {}');
|
assert.equal(sansDetails.message, 'CORRECTIF {}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("le message d'un détail en octets n'en dit que le nombre : les octets d'un fichier entier ne s'écrivent pas dans une trace", () => {
|
||||||
|
const octets = new Uint8Array(1 << 20);
|
||||||
|
const erreur = new ErreurStockage('LECTURE', { chemin: 'veillee.gtt-journal.jsonl', cause: 'UTF8_INVALIDE', octets });
|
||||||
|
assert.equal(erreur.details.octets, octets);
|
||||||
|
assert.equal(
|
||||||
|
erreur.message,
|
||||||
|
'LECTURE {"chemin":"veillee.gtt-journal.jsonl","cause":"UTF8_INVALIDE","octets":"1048576 octets"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('creerCharge (§ 4, § 6.1, § 9)', () => {
|
describe('creerCharge (§ 4, § 6.1, § 9)', () => {
|
||||||
|
|
@ -299,7 +310,12 @@ describe('creerCharge (§ 4, § 6.1, § 9)', () => {
|
||||||
describe('analyser : ce qui se lit (§ 8.8)', () => {
|
describe('analyser : ce qui se lit (§ 8.8)', () => {
|
||||||
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
|
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
|
||||||
const { entete, charge } = documentValide();
|
const { entete, charge } = documentValide();
|
||||||
assert.deepEqual(analyser(JSON.stringify(documentValide())), { entete, charge, formatPlusRecent: false });
|
assert.deepEqual(analyser(JSON.stringify(documentValide())), {
|
||||||
|
entete,
|
||||||
|
charge,
|
||||||
|
formatPlusRecent: false,
|
||||||
|
manquants: { propositions: 0, retenu: false },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('variantes admises : chacune se lit sans refus', () => {
|
test('variantes admises : chacune se lit sans refus', () => {
|
||||||
|
|
@ -645,7 +661,11 @@ describe('analyser : identifiants et tours (§ 4)', () => {
|
||||||
modifier(attendu);
|
modifier(attendu);
|
||||||
attendu.entete.format = format;
|
attendu.entete.format = format;
|
||||||
try {
|
try {
|
||||||
assert.deepEqual(analyser(JSON.stringify(attendu)), { ...attendu, formatPlusRecent: format > FORMAT });
|
assert.deepEqual(analyser(JSON.stringify(attendu)), {
|
||||||
|
...attendu,
|
||||||
|
formatPlusRecent: format > FORMAT,
|
||||||
|
manquants: { propositions: 0, retenu: false },
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
|
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
|
||||||
|
|
@ -666,20 +686,34 @@ describe('analyser : identifiants et tours (§ 4)', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
|
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
|
||||||
test('chaque compte se compare à sa liste, le retenu à 0 ou 1', () => {
|
test('les comptes de la saisie — participants, tables, réservations, titres — se comparent à leur liste et refusent le fichier', () => {
|
||||||
assert.deepEqual(ecartsDeRefus([
|
assert.deepEqual(ecartsDeRefus([
|
||||||
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
|
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
|
||||||
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
|
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
|
||||||
['une réservation annoncée en moins',
|
['une réservation annoncée en moins',
|
||||||
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
|
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
|
||||||
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
|
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
|
||||||
['aucune proposition annoncée',
|
|
||||||
abime((d) => { d.entete.comptes.propositions = 0; }), 'COMPTES', 'entete.comptes.propositions'],
|
|
||||||
['un retenu annoncé, aucun écrit', abime((d) => { d.entete.comptes.retenu = 1; }), 'COMPTES', 'entete.comptes.retenu'],
|
|
||||||
['un retenu écrit, aucun annoncé',
|
|
||||||
abime((d) => { d.charge.retenu = retenuValide(); }), 'COMPTES', 'entete.comptes.retenu'],
|
|
||||||
]), []);
|
]), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("les comptes des propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) : manquants rend les propositions annoncées au-delà de la liste et le retenu annoncé absent ; une liste plus longue que son compte se lit telle quelle", () => {
|
||||||
|
const manquantsDe = (modifier) => analyser(abime(modifier)).manquants;
|
||||||
|
assert.deepEqual(manquantsDe(() => {}), { propositions: 0, retenu: false });
|
||||||
|
assert.deepEqual(manquantsDe((d) => { d.charge.propositions = []; }), { propositions: 1, retenu: false });
|
||||||
|
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 3; }), { propositions: 2, retenu: false });
|
||||||
|
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.retenu = 1; }), { propositions: 0, retenu: true });
|
||||||
|
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 0; }), { propositions: 0, retenu: false });
|
||||||
|
const retenuNonAnnonce = analyser(abime((d) => { d.charge.retenu = retenuValide(); }));
|
||||||
|
assert.deepEqual(retenuNonAnnonce.manquants, { propositions: 0, retenu: false });
|
||||||
|
assert.deepEqual(retenuNonAnnonce.charge.retenu, retenuValide());
|
||||||
|
// Au format plus récent de même.
|
||||||
|
const plusRecent = analyser(abime((d) => {
|
||||||
|
d.entete.format = FORMAT + 1;
|
||||||
|
d.charge.propositions = [];
|
||||||
|
d.entete.comptes.retenu = 1;
|
||||||
|
}));
|
||||||
|
assert.deepEqual(plusRecent.manquants, { propositions: 1, retenu: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyser : références (§ 4, § 6.2)', () => {
|
describe('analyser : références (§ 4, § 6.2)', () => {
|
||||||
|
|
@ -756,7 +790,12 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
|
||||||
d.charge.propositions[0].note = 'refaite';
|
d.charge.propositions[0].note = 'refaite';
|
||||||
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
|
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
|
||||||
}));
|
}));
|
||||||
assert.deepEqual(etendu, { entete: { ...entete, format: 2 }, charge, formatPlusRecent: true });
|
assert.deepEqual(etendu, {
|
||||||
|
entete: { ...entete, format: 2 },
|
||||||
|
charge,
|
||||||
|
formatPlusRecent: true,
|
||||||
|
manquants: { propositions: 0, retenu: false },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
|
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
|
||||||
|
|
@ -852,14 +891,28 @@ describe('configurationDepuisCharge (§ 6.1)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("les listes gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
|
test("les tables vont par identifiant croissant, l'ordre du texte canonique : une charge aux tables dans le désordre et sa copie canonique donnent la même configuration, donc à graine égale le même plan (§ 8.9)", () => {
|
||||||
|
const { charge } = analyser(texteLivre('grande-sans-exception'));
|
||||||
|
const desordre = structuredClone(charge);
|
||||||
|
[desordre.tables[0], desordre.tables[1]] = [desordre.tables[1], desordre.tables[0]];
|
||||||
|
desordre.tables.push(desordre.tables.shift());
|
||||||
|
assert.notDeepEqual(desordre.tables.map(({ id }) => id), charge.tables.map(({ id }) => id));
|
||||||
|
const canonique = configurationDepuisCharge(charge);
|
||||||
|
assert.deepEqual(configurationDepuisCharge(desordre), canonique);
|
||||||
|
assert.deepEqual(
|
||||||
|
canonique.tables.map(({ id }) => id),
|
||||||
|
charge.tables.map(({ id }) => id).sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les participants et les réservations gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
|
||||||
const { charge } = documentValide();
|
const { charge } = documentValide();
|
||||||
charge.participants.reverse();
|
charge.participants.reverse();
|
||||||
charge.tables.reverse();
|
charge.tables.reverse();
|
||||||
charge.reservations.reverse();
|
charge.reservations.reverse();
|
||||||
const configuration = configurationDepuisCharge(charge);
|
const configuration = configurationDepuisCharge(charge);
|
||||||
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
|
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
|
||||||
assert.deepEqual(configuration.tables.map(({ id }) => id), [3, 1]);
|
assert.deepEqual(configuration.tables.map(({ id }) => id), [1, 3]);
|
||||||
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
|
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
|
||||||
// La seconde réservation de la charge désigne une table absente : le
|
// La seconde réservation de la charge désigne une table absente : le
|
||||||
// moteur la nomme à son rang dans la charge, 1.
|
// moteur la nomme à son rang dans la charge, 1.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@
|
||||||
// secours — ; types.js en donne la table, code par code. L'appelant lit le
|
// secours — ; types.js en donne la table, code par code. L'appelant lit le
|
||||||
// code et les détails ; le message, code et détails en JSON, sert aux traces
|
// code et les détails ; le message, code et détails en JSON, sert aux traces
|
||||||
// de diagnostic et n'est pas un texte affiché, que fournit la table des
|
// de diagnostic et n'est pas un texte affiché, que fournit la table des
|
||||||
// libellés de l'application (§ 14.6).
|
// libellés de l'application (§ 14.6). Un détail en octets — ceux d'un
|
||||||
|
// fichier qui n'est pas de l'UTF-8 — ne s'y écrit que par son nombre
|
||||||
|
// d'octets : le fichier entier ne passe pas dans une trace.
|
||||||
|
|
||||||
|
// Le remplaçant JSON d'un détail : un Uint8Array devient « N octets ».
|
||||||
|
const sansOctets = (cle, valeur) => (valeur instanceof Uint8Array ? `${valeur.length} octets` : valeur);
|
||||||
|
|
||||||
export class ErreurStockage extends Error {
|
export class ErreurStockage extends Error {
|
||||||
/**
|
/**
|
||||||
|
|
@ -14,7 +19,7 @@ export class ErreurStockage extends Error {
|
||||||
* @param {Object} [details]
|
* @param {Object} [details]
|
||||||
*/
|
*/
|
||||||
constructor(code, details = {}) {
|
constructor(code, details = {}) {
|
||||||
super(`${code} ${JSON.stringify(details)}`);
|
super(`${code} ${JSON.stringify(details, sansOctets)}`);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
|
||||||
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
||||||
|
|
||||||
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
||||||
// retirer celle qu'il porte, que Blob.text retirerait. Un octet qui n'est pas
|
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
|
||||||
// de l'UTF-8 se lit U+FFFD.
|
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
|
||||||
const UTF8 = new TextEncoder();
|
const UTF8 = new TextEncoder();
|
||||||
const DECODEUR = new TextDecoder('utf-8', { ignoreBOM: true });
|
const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
|
|
||||||
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
|
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
|
||||||
const ignorer = () => {};
|
const ignorer = () => {};
|
||||||
|
|
@ -180,8 +180,16 @@ async function retirer(dossier, nom) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Texte d'un fichier : ses octets décodés tels quels.
|
// Ce que porte un fichier : { texte, octets }, ses octets et leur décodage
|
||||||
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer());
|
// tel quel ; texte null quand ils ne sont pas de l'UTF-8.
|
||||||
|
async function lire(poignee) {
|
||||||
|
const octets = new Uint8Array(await (await poignee.getFile()).arrayBuffer());
|
||||||
|
try {
|
||||||
|
return { texte: DECODEUR.decode(octets), octets };
|
||||||
|
} catch {
|
||||||
|
return { texte: null, octets };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
|
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
|
||||||
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
|
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
|
||||||
|
|
@ -307,7 +315,7 @@ export function creerFichiersWeb() {
|
||||||
dossier = await dossierDe([], true);
|
dossier = await dossierDe([], true);
|
||||||
await retirer(dossier, TEMOIN);
|
await retirer(dossier, TEMOIN);
|
||||||
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
|
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
|
||||||
const relu = await lire(await dossier.getFileHandle(TEMOIN));
|
const { texte: relu } = await lire(await dossier.getFileHandle(TEMOIN));
|
||||||
await retirer(dossier, TEMOIN);
|
await retirer(dossier, TEMOIN);
|
||||||
return relu === TEMOIN
|
return relu === TEMOIN
|
||||||
? { inscriptible: true, cause: null }
|
? { inscriptible: true, cause: null }
|
||||||
|
|
@ -323,14 +331,22 @@ export function creerFichiersWeb() {
|
||||||
return 'inconnu';
|
return 'inconnu';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Des octets hors UTF-8 lèvent LECTURE, cause UTF8_INVALIDE, qui porte
|
||||||
|
// ces octets.
|
||||||
lireTexte: async (racine, chemin) => {
|
lireTexte: async (racine, chemin) => {
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
if (loc.nom === null) throw absent(chemin);
|
if (loc.nom === null) throw absent(chemin);
|
||||||
|
let lu;
|
||||||
try {
|
try {
|
||||||
return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecLecture(loc, erreur);
|
throw echecLecture(loc, erreur);
|
||||||
}
|
}
|
||||||
|
if (lu.texte === null) {
|
||||||
|
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE', octets: lu.octets };
|
||||||
|
throw new ErreurStockage('LECTURE', details);
|
||||||
|
}
|
||||||
|
return lu.texte;
|
||||||
},
|
},
|
||||||
|
|
||||||
ecrireAtomique: async (racine, chemin, texte) => {
|
ecrireAtomique: async (racine, chemin, texte) => {
|
||||||
|
|
|
||||||
|
|
@ -137,10 +137,19 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
|
||||||
new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]),
|
new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre
|
// Des octets UTF-8 se lisent tels quels, marque d'ordre d'octets et CRLF
|
||||||
// d'octets et CRLF restent.
|
// compris ; un octet qui n'est pas de l'UTF-8 lève LECTURE, cause
|
||||||
|
// UTF8_INVALIDE, au lieu de se lire U+FFFD ; le refus porte les octets
|
||||||
|
// du fichier, que le dépôt décode ligne par ligne.
|
||||||
|
await poserOpfs('pose.csv', BRUTS.subarray(0, BRUTS.length - 1));
|
||||||
|
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n');
|
||||||
await poserOpfs('pose.csv', BRUTS);
|
await poserOpfs('pose.csv', BRUTS);
|
||||||
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n\u{FFFD}');
|
await echoue(fs.lireTexte(DOCUMENTS, 'pose.csv'), 'LECTURE', {
|
||||||
|
chemin: 'pose.csv',
|
||||||
|
dossier: CHEMIN_DOCUMENTS,
|
||||||
|
cause: 'UTF8_INVALIDE',
|
||||||
|
octets: new Uint8Array(BRUTS),
|
||||||
|
});
|
||||||
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']);
|
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
//
|
//
|
||||||
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
|
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
|
||||||
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
|
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
|
||||||
// texte entier d'un journal, restitue la charge d'un instant, suit le fil
|
// texte entier d'un journal, en décode les octets ligne par ligne, restitue
|
||||||
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON
|
// la charge d'un instant, suit le fil courant et élague. Une table par sorte
|
||||||
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé
|
// de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à
|
||||||
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
|
// l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
|
||||||
// refuserait la forme. La suite des révisions et la cadence des instantanés,
|
// refuserait la forme. La suite des révisions et la cadence des instantanés,
|
||||||
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
|
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
|
||||||
// appartiennent à l'appelant.
|
// appartiennent à l'appelant.
|
||||||
|
|
@ -63,6 +63,11 @@ const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||||||
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
||||||
|
|
||||||
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
||||||
|
// La fin de ligne, en octets.
|
||||||
|
const LF = 0x0a;
|
||||||
|
// Décodage strict d'une ligne : un octet hors UTF-8 lève, et la marque
|
||||||
|
// d'ordre d'octets reste dans le texte, que la lecture retire.
|
||||||
|
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
|
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
|
||||||
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
|
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
|
||||||
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
|
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
|
||||||
|
|
@ -333,6 +338,44 @@ export function lireJournal(texte) {
|
||||||
return lire(texte).journal;
|
return lire(texte).journal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le texte lisible des octets d'un journal (§ 8.6) : coupés à chaque octet
|
||||||
|
* de fin de ligne LF, chaque segment se décode strictement, dans l'ordre ;
|
||||||
|
* le premier qui n'est pas de l'UTF-8 arrête le texte, comme une ligne
|
||||||
|
* illisible arrête la lecture. Rend texte, les lignes qui le précèdent,
|
||||||
|
* chacune suivie de sa fin de ligne — tout le texte quand chaque segment se
|
||||||
|
* décode, null quand l'ouverture elle-même ne se décode pas —, et ecartees,
|
||||||
|
* ce segment et ceux qui le suivent, comptés comme lireJournal compte les
|
||||||
|
* lignes : chaque fin de ligne en termine une, et le dernier segment en est
|
||||||
|
* une quand il n'est pas vide. Une écriture coupée au milieu d'un caractère
|
||||||
|
* ne gâte ainsi que sa dernière ligne, et lireJournal(texte), ses ecartees
|
||||||
|
* augmentées de celles-ci, est la lecture du journal.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} octets
|
||||||
|
* @returns {{texte: string|null, ecartees: number}}
|
||||||
|
*/
|
||||||
|
export function decoderJournal(octets) {
|
||||||
|
const segments = [];
|
||||||
|
let debut = 0;
|
||||||
|
for (let rang = octets.indexOf(LF); rang !== -1; rang = octets.indexOf(LF, debut)) {
|
||||||
|
segments.push(octets.subarray(debut, rang));
|
||||||
|
debut = rang + 1;
|
||||||
|
}
|
||||||
|
segments.push(octets.subarray(debut));
|
||||||
|
const lignes = segments.length - 1 + (segments.at(-1).length > 0 ? 1 : 0);
|
||||||
|
let texte = '';
|
||||||
|
for (let rang = 0; rang < segments.length; rang += 1) {
|
||||||
|
let ligne;
|
||||||
|
try {
|
||||||
|
ligne = UTF8_STRICT.decode(segments[rang]);
|
||||||
|
} catch {
|
||||||
|
return { texte: rang === 0 ? null : texte, ecartees: lignes - rang };
|
||||||
|
}
|
||||||
|
texte += rang < segments.length - 1 ? `${ligne}\n` : ligne;
|
||||||
|
}
|
||||||
|
return { texte, ecartees: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
|
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
|
||||||
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —
|
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
INTERVALLE_INSTANTANE,
|
INTERVALLE_INSTANTANE,
|
||||||
PLAFOND_ENTREES,
|
PLAFOND_ENTREES,
|
||||||
PLANCHER_ENTREES,
|
PLANCHER_ENTREES,
|
||||||
|
decoderJournal,
|
||||||
elaguer,
|
elaguer,
|
||||||
estInstantane,
|
estInstantane,
|
||||||
fil,
|
fil,
|
||||||
|
|
@ -892,6 +893,18 @@ describe('la lecture s’arrête à la première ligne illisible et compte ce qu
|
||||||
assert.equal(fil(journal).position, 5);
|
assert.equal(fil(journal).position, 5);
|
||||||
assert.equal(texteCanonique(reconstruire(journal, 5)), texteCanonique(etapes[4].apres));
|
assert.equal(texteCanonique(reconstruire(journal, 5)), texteCanonique(etapes[4].apres));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('decoderJournal décode les octets ligne par ligne : la première ligne hors UTF-8 arrête le texte, elle et les suivantes comptées comme lireJournal compte ; une ouverture hors UTF-8 ne laisse aucun texte', () => {
|
||||||
|
const octets = (...morceaux) =>
|
||||||
|
new Uint8Array(morceaux.flatMap((morceau) => (typeof morceau === 'string' ? [...new TextEncoder().encode(morceau)] : morceau)));
|
||||||
|
assert.deepEqual(decoderJournal(octets('a\n\u{E9}\n')), { texte: 'a\n\u{E9}\n', ecartees: 0 });
|
||||||
|
assert.deepEqual(decoderJournal(octets('a\nb')), { texte: 'a\nb', ecartees: 0 });
|
||||||
|
assert.deepEqual(decoderJournal(octets('\u{FEFF}a\nb', [0xc3])), { texte: '\u{FEFF}a\n', ecartees: 1 });
|
||||||
|
assert.deepEqual(decoderJournal(octets('a\nb\n', [0xe9], 'c\nd\ne')), { texte: 'a\nb\n', ecartees: 3 });
|
||||||
|
assert.deepEqual(decoderJournal(octets('a\n', [0xe9], '\nd\n')), { texte: 'a\n', ecartees: 2 });
|
||||||
|
assert.deepEqual(decoderJournal(octets([0xe9], '\nb\n')), { texte: null, ecartees: 2 });
|
||||||
|
assert.deepEqual(decoderJournal(octets([0xc3])), { texte: null, ecartees: 1 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Le fil courant --------------------------------------------------------
|
// --- Le fil courant --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,28 @@
|
||||||
// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît
|
// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît
|
||||||
// pas. Copier, renommer et charger le refusent avant toute écriture ;
|
// pas. Copier, renommer et charger le refusent avant toute écriture ;
|
||||||
// importer en recopie les octets, et l'événement importé s'ouvre en lecture
|
// importer en recopie les octets, et l'événement importé s'ouvre en lecture
|
||||||
// seule.
|
// seule. Un état dont les octets ne sont pas de l'UTF-8 ne s'efface jamais :
|
||||||
|
// renommer l'Ouvert de son secours le met d'abord dans un dossier daté de la
|
||||||
|
// corbeille, comme le premier geste (mettreDeCoteEtatMalEncode, du dépôt) ;
|
||||||
|
// copier en fait un événement neuf, sans le toucher.
|
||||||
//
|
//
|
||||||
// Une base se dérive du nom de l'événement (deriverBase), libre parmi les
|
// Une base se dérive du nom de l'événement (deriverBase), libre parmi les
|
||||||
// bases que portent les fichiers d'événement à la racine du dossier
|
// bases que portent les fichiers d'événement à la racine du dossier
|
||||||
// (basesPrises), comme le dépôt la dérive pour creer.
|
// (basesPrises), comme le dépôt la dérive pour creer.
|
||||||
import { canoniser } from './canonique.js';
|
import { canoniser } from './canonique.js';
|
||||||
import { basesPrises, creer, enregistrer, enregistrerAilleurs, lister, ouvrir } from './depot.js';
|
import {
|
||||||
|
basesPrises,
|
||||||
|
creer,
|
||||||
|
enregistrer,
|
||||||
|
enregistrerAilleurs,
|
||||||
|
lister,
|
||||||
|
mettreDeCoteEtatMalEncode,
|
||||||
|
ouvrir,
|
||||||
|
ouvrirSecours,
|
||||||
|
} from './depot.js';
|
||||||
import { analyser, etatDeduit } from './document.js';
|
import { analyser, etatDeduit } from './document.js';
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
import { decoderJournal } from './journal.js';
|
||||||
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
||||||
|
|
||||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
|
|
@ -57,12 +70,32 @@ async function entreesDe(fs, racine, dossier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Texte du fichier chemin, ou null quand il manque.
|
// Texte du fichier chemin, ou null quand il manque ou que ses octets ne sont
|
||||||
|
// pas de l'UTF-8 (LECTURE, cause UTF8_INVALIDE) : un texte qui ne se lit pas
|
||||||
|
// ne se recopie pas. Toute autre erreur remonte telle quelle.
|
||||||
async function texteSiPresent(fs, racine, chemin) {
|
async function texteSiPresent(fs, racine, chemin) {
|
||||||
try {
|
try {
|
||||||
return await fs.lireTexte(racine, chemin);
|
return await fs.lireTexte(racine, chemin);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur.code === 'ABSENT') return null;
|
if (erreur.code === 'ABSENT') return null;
|
||||||
|
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') return null;
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte du journal chemin tel que l'ouverture le lit : le fichier entier ;
|
||||||
|
// quand ses octets ne sont pas tous de l'UTF-8, les lignes qui précèdent la
|
||||||
|
// première qui ne se décode pas (decoderJournal), celles que la lecture
|
||||||
|
// écarterait n'étant pas recopiées ; null quand il manque, ou que sa ligne
|
||||||
|
// d'ouverture ne se décode pas. Toute autre erreur remonte telle quelle.
|
||||||
|
async function journalSiPresent(fs, racine, chemin) {
|
||||||
|
try {
|
||||||
|
return await fs.lireTexte(racine, chemin);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur.code === 'ABSENT') return null;
|
||||||
|
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
|
||||||
|
return decoderJournal(erreur.details.octets).texte;
|
||||||
|
}
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +105,14 @@ function exigerFormatCourant(formatPlusRecent) {
|
||||||
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Une séance est une chaîne non vide : celle sous laquelle supprimer sonde
|
||||||
|
// le verrou.
|
||||||
|
function exigerSeance(seance) {
|
||||||
|
if (typeof seance !== 'string' || seance === '') {
|
||||||
|
throw new TypeError("supprimer : la séance n'est pas une chaîne non vide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
||||||
// révision au journal ; sans elle — un état sans journal, ou que le journal
|
// révision au journal ; sans elle — un état sans journal, ou que le journal
|
||||||
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
||||||
|
|
@ -240,6 +281,11 @@ async function supprimerAncienne(ouvert, nouvelle) {
|
||||||
* l'ancienne base, ne se touche pas ; le verrou non plus, que la séance
|
* l'ancienne base, ne se touche pas ; le verrou non plus, que la séance
|
||||||
* déplace.
|
* déplace.
|
||||||
*
|
*
|
||||||
|
* L'Ouvert d'un secours dont l'état sur le disque n'est pas de l'UTF-8 met
|
||||||
|
* d'abord cet état dans un dossier daté de la corbeille, à l'horodatage du
|
||||||
|
* renommage (mettreDeCoteEtatMalEncode) : ni la recopie ni la suppression de
|
||||||
|
* l'ancienne paire ne le remplacent ni ne l'effacent, et ses octets restent.
|
||||||
|
*
|
||||||
* Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase
|
* Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase
|
||||||
* les retire aussi —, ne touche à rien : aucune entrée, aucune recopie,
|
* les retire aussi —, ne touche à rien : aucune entrée, aucune recopie,
|
||||||
* aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait
|
* aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait
|
||||||
|
|
@ -266,6 +312,8 @@ async function supprimerAncienne(ouvert, nouvelle) {
|
||||||
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
|
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
|
||||||
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
|
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
|
||||||
* le dossier n'existe plus ; en place, ce que lève enregistrer ;
|
* le dossier n'existe plus ; en place, ce que lève enregistrer ;
|
||||||
|
* ce que lève la mise de côté d'un état aux octets hors UTF-8
|
||||||
|
* (mettreDeCoteEtatMalEncode), l'ancienne paire sinon intacte ;
|
||||||
* ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de
|
* ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de
|
||||||
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
|
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
|
||||||
* dossier, cause, ouvert} de la suppression de l'ancienne
|
* dossier, cause, ouvert} de la suppression de l'ancienne
|
||||||
|
|
@ -284,8 +332,9 @@ export async function renommer(ouvert, { nom, libelle, horodatage, produitVersio
|
||||||
return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise });
|
return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise });
|
||||||
}
|
}
|
||||||
const geste = { apres, libelle, produitVersion };
|
const geste = { apres, libelle, produitVersion };
|
||||||
const nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage });
|
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
|
||||||
await supprimerAncienne(ouvert, nouvelle);
|
const nouvelle = await ecrireNouvellePaire(depart, { geste, libelleReprise, horodatage });
|
||||||
|
await supprimerAncienne(depart, nouvelle);
|
||||||
return nouvelle;
|
return nouvelle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,28 +344,32 @@ export async function renommer(ouvert, { nom, libelle, horodatage, produitVersio
|
||||||
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
|
|
||||||
// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8
|
// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8
|
||||||
// ne sont pas un texte JSON : ETAT_ILLISIBLE, raison JSON, comme l'analyse le
|
// ne sont pas un texte : ETAT_ILLISIBLE, raison ENCODAGE, comme le dépôt le
|
||||||
// lèverait d'un texte qui ne se lit pas.
|
// lève d'un état du dossier aux octets hors UTF-8.
|
||||||
function decoder(octets) {
|
function decoder(octets) {
|
||||||
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
|
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
|
||||||
try {
|
try {
|
||||||
return UTF8_STRICT.decode(octets);
|
return UTF8_STRICT.decode(octets);
|
||||||
} catch {
|
} catch {
|
||||||
throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'JSON', chemin: null });
|
throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'ENCODAGE', chemin: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Écrit dans racine l'événement id, nom : texteJournal, quand il est donné,
|
// Écrit dans racine l'événement id, nom : ses fichiers, tels quels, chacun
|
||||||
// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette
|
// quand il est donné, sous une base libre que nom dérive — le .precedent
|
||||||
// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id.
|
// d'abord, qui tient la base à l'événement (apparier, dans le dépôt), puis
|
||||||
async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
|
// le journal, puis l'état. Rend cette base. IDENTIFIANT_PRESENT quand lister
|
||||||
const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && resume.id === id);
|
// nomme id dans le dossier, que son état se lise ou non : l'événement y est
|
||||||
|
// déjà, et une seconde copie de son identifiant dériverait (§ 8.6).
|
||||||
|
async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null, texteJournal }) {
|
||||||
|
const present = (await lister(fs, racine)).find((resume) => resume.id === id);
|
||||||
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
|
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
|
||||||
const { separateur } = await fs.emplacements();
|
const { separateur } = await fs.emplacements();
|
||||||
const existantes = await basesPrises(fs, racine);
|
const existantes = await basesPrises(fs, racine);
|
||||||
const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||||||
|
if (textePrecedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, textePrecedent);
|
||||||
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
||||||
await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
if (texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,9 +386,10 @@ async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
|
||||||
* @param {Uint8Array} octets
|
* @param {Uint8Array} octets
|
||||||
* @returns {Promise<{base: string}>}
|
* @returns {Promise<{base: string}>}
|
||||||
* @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de
|
* @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de
|
||||||
* l'analyse, raison JSON pour des octets hors UTF-8 ; ni base ni
|
* l'analyse, raison ENCODAGE pour des octets hors UTF-8 ; ni base ni
|
||||||
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
|
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
|
||||||
* base} quand un état lisible du dossier porte son identifiant ;
|
* base} quand lister nomme son identifiant dans le dossier, que
|
||||||
|
* l'état qui le porte se lise ou non ;
|
||||||
* CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de
|
* CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de
|
||||||
* l'état
|
* l'état
|
||||||
* @throws {TypeError} des octets qui ne sont pas un Uint8Array
|
* @throws {TypeError} des octets qui ne sont pas un Uint8Array
|
||||||
|
|
@ -346,15 +400,61 @@ export async function importer(fs, racine, octets) {
|
||||||
return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) };
|
return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Les fichiers de l'événement de base dans depuis, dont l'état ne se lit
|
||||||
|
// pas, recopiés tels quels dans vers (deposer) : le .precedent et l'état,
|
||||||
|
// chacun quand il existe et que ses octets sont de l'UTF-8, et le journal
|
||||||
|
// que le secours source apparie, par ses lignes lisibles (journalSiPresent). Le secours source — 'precedent' ou 'journal'
|
||||||
|
// (ouvrirSecours) — donne l'identifiant et le nom. Rend la base dans vers.
|
||||||
|
async function importerSecours(fs, depuis, vers, base, source) {
|
||||||
|
const { charge, baseJournal } = await ouvrirSecours(fs, depuis, base, source);
|
||||||
|
const { id, nom } = charge.evenement;
|
||||||
|
const lire = (chemin) => texteSiPresent(fs, depuis, chemin);
|
||||||
|
const journal = baseJournal === null ? null : baseJournal + SUFFIXES.journal;
|
||||||
|
return deposer(fs, vers, {
|
||||||
|
id,
|
||||||
|
nom,
|
||||||
|
textePrecedent: await lire(base + SUFFIXES.precedent),
|
||||||
|
texteJournal: journal === null ? null : await journalSiPresent(fs, depuis, journal),
|
||||||
|
texteEtat: await lire(base + SUFFIXES.etat),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importe l'événement de base de depuis dans vers (importerDepuis) et rend
|
||||||
|
// sa base là-bas. Un état illisible qui a un secours lisible s'importe par
|
||||||
|
// ses fichiers (importerSecours), le .precedent pour source quand il se lit ;
|
||||||
|
// sans secours, son refus remonte.
|
||||||
|
async function importerUn(fs, depuis, vers, base) {
|
||||||
|
let ouvert;
|
||||||
|
try {
|
||||||
|
ouvert = await ouvrir(fs, depuis, base);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur;
|
||||||
|
const { precedent, journal } = erreur.details.secours;
|
||||||
|
if (precedent === null && journal === null) throw erreur;
|
||||||
|
return importerSecours(fs, depuis, vers, base, precedent === null ? 'journal' : 'precedent');
|
||||||
|
}
|
||||||
|
const { texteEtat, baseJournal } = ouvert;
|
||||||
|
const { id, nom } = analyser(texteEtat).charge.evenement;
|
||||||
|
const texteJournal = baseJournal === null ? null : await journalSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
||||||
|
return deposer(fs, vers, { id, nom, texteEtat, texteJournal });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
|
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
|
||||||
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
|
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
|
||||||
* se copient tels quels dans vers, le journal d'abord, sous une base libre
|
* se copient tels quels dans vers, le journal d'abord, sous une base libre
|
||||||
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Les
|
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Un
|
||||||
* originaux restent où ils sont, sans qu'aucune écriture ne touche depuis.
|
* événement dont l'état ne se lit pas, mais dont le .precedent ou le journal
|
||||||
* Une base qui ne s'importe pas se refuse par son code — ETAT_ILLISIBLE,
|
* se lisent (§ 8.8), se copie par ses fichiers, tels quels : le .precedent,
|
||||||
* ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ;
|
* le journal que son secours apparie, et l'état, chacun quand ses octets
|
||||||
* l'écriture refusée de son état laisse dans vers le journal déjà écrit.
|
* sont de l'UTF-8 ; il s'ouvre là-bas en secours. Un journal dont une ligne
|
||||||
|
* n'est pas de l'UTF-8 se copie par les lignes qui la précèdent, celles que
|
||||||
|
* l'ouverture lit (journalSiPresent). Les originaux restent où
|
||||||
|
* ils sont, sans qu'aucune écriture ne touche depuis. Une base qui ne
|
||||||
|
* s'importe pas se refuse par son code — ETAT_ILLISIBLE sans secours
|
||||||
|
* lisible, ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les
|
||||||
|
* suivantes ; l'écriture refusée d'un fichier laisse dans vers ceux déjà
|
||||||
|
* écrits.
|
||||||
*
|
*
|
||||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||||
* @param {import('./systeme_fichiers.js').Racine} depuis
|
* @param {import('./systeme_fichiers.js').Racine} depuis
|
||||||
|
|
@ -371,11 +471,7 @@ export async function importerDepuis(fs, depuis, vers, bases) {
|
||||||
const refuses = [];
|
const refuses = [];
|
||||||
for (const base of bases) {
|
for (const base of bases) {
|
||||||
try {
|
try {
|
||||||
const { texteEtat, baseJournal } = await ouvrir(fs, depuis, base);
|
importes.push({ base, nouvelle: await importerUn(fs, depuis, vers, base) });
|
||||||
const { id, nom } = analyser(texteEtat).charge.evenement;
|
|
||||||
const texteJournal =
|
|
||||||
baseJournal === null ? null : await texteSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
|
||||||
importes.push({ base, nouvelle: await deposer(fs, vers, { id, nom, texteEtat, texteJournal }) });
|
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
refuses.push({ base, code: erreur.code });
|
refuses.push({ base, code: erreur.code });
|
||||||
|
|
@ -419,6 +515,15 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
|
||||||
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
|
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
|
||||||
* liste encore tant que son état n'est pas parti. Rien ne vide la corbeille.
|
* liste encore tant que son état n'est pas parti. Rien ne vide la corbeille.
|
||||||
*
|
*
|
||||||
|
* Une autre séance qui tient l'événement en écriture y écrit encore : avant
|
||||||
|
* tout déplacement, supprimer sonde le verrou en le prenant sous seance, la
|
||||||
|
* séance qui supprime (§ 8.8). Un verrou d'une séance vivante, d'un autre
|
||||||
|
* poste ou illisible — vivant n'y est pas faux — lève VERROU_PRIS, sans rien
|
||||||
|
* déplacer. Le verrou d'une séance morte, celui de seance, ou celui que la
|
||||||
|
* sonde vient de prendre part avec le reste ; un refus qui suit la sonde
|
||||||
|
* rend le verrou qu'elle a pris. Là où le système n'a pas de verrou
|
||||||
|
* (verrouDisponible faux), la sonde ne pose rien.
|
||||||
|
*
|
||||||
* Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un
|
* Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un
|
||||||
* dossier de travail qui disparaît pendant la suppression ne renaît pas
|
* dossier de travail qui disparaît pendant la suppression ne renaît pas
|
||||||
* (§ 8.6). Disparu avant la création du dossier daté, il fait lever
|
* (§ 8.6). Disparu avant la création du dossier daté, il fait lever
|
||||||
|
|
@ -428,36 +533,59 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
|
||||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||||
* @param {string} base
|
* @param {string} base
|
||||||
* @param {{horodatage: string}} options AAAA-MM-JJTHH:MM:SS±HH:MM
|
* @param {{horodatage: string, seance: string}} options horodatage
|
||||||
|
* AAAA-MM-JJTHH:MM:SS±HH:MM ; seance, la séance qui supprime
|
||||||
* @returns {Promise<{dossier: string}>} chemin relatif du dossier daté
|
* @returns {Promise<{dossier: string}>} chemin relatif du dossier daté
|
||||||
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
|
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
|
||||||
* fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ;
|
* fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ;
|
||||||
|
* VERROU_PRIS {seance, depuis, vivant}, sans rien déplacer ;
|
||||||
* ECRITURE de la création du dossier daté ou d'un déplacement
|
* ECRITURE de la création du dossier daté ou d'un déplacement
|
||||||
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
|
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
|
||||||
* pendant les déplacements
|
* pendant les déplacements
|
||||||
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire
|
* @throws {TypeError} un horodatage hors de sa forme, une séance qui n'est
|
||||||
|
* pas une chaîne non vide, sans rien écrire
|
||||||
*/
|
*/
|
||||||
export async function supprimer(fs, racine, base, { horodatage }) {
|
export async function supprimer(fs, racine, base, { horodatage, seance }) {
|
||||||
|
exigerSeance(seance);
|
||||||
const { insensibleCasse } = await fs.emplacements();
|
const { insensibleCasse } = await fs.emplacements();
|
||||||
const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
|
const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
|
||||||
|
const verrou = base + SUFFIXES.verrou;
|
||||||
const fichiers = [
|
const fichiers = [
|
||||||
base + SUFFIXES.verrou,
|
verrou,
|
||||||
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
||||||
base + SUFFIXES.precedent,
|
base + SUFFIXES.precedent,
|
||||||
base + SUFFIXES.etat,
|
base + SUFFIXES.etat,
|
||||||
];
|
];
|
||||||
const presents = (await fs.lister(racine, ''))
|
// Les noms des fichiers du dossier, triés ; ceux de l'événement parmi eux,
|
||||||
.filter(({ type }) => type === 'fichier')
|
// sous leur nom sur le disque, dans l'ordre de leur départ.
|
||||||
.map(({ nom }) => nom)
|
const nomsSurDisque = async () =>
|
||||||
.sort(comparerTextes);
|
(await fs.lister(racine, ''))
|
||||||
const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
|
.filter(({ type }) => type === 'fichier')
|
||||||
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
|
.map(({ nom }) => nom)
|
||||||
.filter((nom) => nom !== null);
|
.sort(comparerTextes);
|
||||||
if (partants.length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
const partantsParmi = (presents) =>
|
||||||
|
[...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
|
||||||
|
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
|
||||||
|
.filter((nom) => nom !== null);
|
||||||
|
let presents = await nomsSurDisque();
|
||||||
|
if (partantsParmi(presents).length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
||||||
const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
||||||
const dossier = dossierCorbeille(horodatage, existants);
|
const dossier = dossierCorbeille(horodatage, existants);
|
||||||
await fs.creerDossier(racine, dossier);
|
// La sonde vise le verrou sous son nom sur le disque, celui qui part.
|
||||||
for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
|
const nomDuVerrou = nomSurDisque(presents, verrou, insensibleCasse) ?? verrou;
|
||||||
|
const sonde = await fs.verrouiller(racine, nomDuVerrou, seance);
|
||||||
|
if (!sonde.pris && sonde.seance !== seance && sonde.vivant !== false) {
|
||||||
|
throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (sonde.pris) presents = await nomsSurDisque();
|
||||||
|
await fs.creerDossier(racine, dossier);
|
||||||
|
for (const nom of partantsParmi(presents)) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
|
||||||
|
} catch (erreur) {
|
||||||
|
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
|
||||||
|
if (sonde.pris) await fs.deverrouiller(racine, nomDuVerrou, seance).catch(() => {});
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
return { dossier };
|
return { dossier };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,18 @@ const VERROU = `${BASE}${SUFFIXES.verrou}`;
|
||||||
const REPRISE = "Reprise de l'état trouvé sur le disque";
|
const REPRISE = "Reprise de l'état trouvé sur le disque";
|
||||||
const UTF8 = new TextEncoder();
|
const UTF8 = new TextEncoder();
|
||||||
|
|
||||||
|
// La séance qui supprime : celle qui tient le verrou que posent les épreuves.
|
||||||
|
const SEANCE = 'seance-a';
|
||||||
|
|
||||||
|
// Le verrou d'une séance morte de ce poste : son pid n'est celui d'aucun
|
||||||
|
// processus du disque d'épreuve, et supprimer l'emporte avec l'événement.
|
||||||
|
const VERROU_MORT = `${JSON.stringify({
|
||||||
|
seance: 'seance-morte',
|
||||||
|
pid: 999,
|
||||||
|
hote: 'poste-epreuve',
|
||||||
|
depuis: '2031-05-17T07:00:00-04:00',
|
||||||
|
})}\n`;
|
||||||
|
|
||||||
// Le texte tel que l'écrit un éditeur réglé en Windows-1252 : un octet par
|
// Le texte tel que l'écrit un éditeur réglé en Windows-1252 : un octet par
|
||||||
// caractère, « é » donnant E9, qui n'est pas de l'UTF-8. Le texte ne porte
|
// caractère, « é » donnant E9, qui n'est pas de l'UTF-8. Le texte ne porte
|
||||||
// que des caractères que Windows-1252 code comme ISO 8859-1, sur leur point
|
// que des caractères que Windows-1252 code comme ISO 8859-1, sur leur point
|
||||||
|
|
@ -191,6 +203,17 @@ async function arbre(disque, racine = DOC, reglages = {}) {
|
||||||
return fichiers.sort(parChemin);
|
return fichiers.sort(parChemin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ce qui tient lieu, dans arbreSonde, du texte d'un verrou que la sonde de
|
||||||
|
// supprimer a pris sous SEANCE : son pid et l'instant de sa prise varient.
|
||||||
|
const SONDE = 'verrou de la sonde';
|
||||||
|
|
||||||
|
// L'arbre de racine (arbre), le texte de chaque verrou de SEANCE remplacé
|
||||||
|
// par SONDE.
|
||||||
|
async function arbreSonde(disque, racine = DOC) {
|
||||||
|
const deLaSonde = (chemin, texte) => chemin.endsWith(SUFFIXES.verrou) && JSON.parse(texte).seance === SEANCE;
|
||||||
|
return (await arbre(disque, racine)).map(([chemin, texte]) => [chemin, deLaSonde(chemin, texte) ? SONDE : texte]);
|
||||||
|
}
|
||||||
|
|
||||||
// Ouvre base par un processus neuf, en affirmant qu'ouvrir n'écrit rien.
|
// Ouvre base par un processus neuf, en affirmant qu'ouvrir n'écrit rien.
|
||||||
async function rouvrir(disque, base) {
|
async function rouvrir(disque, base) {
|
||||||
const fs = creerFichiersSimules({ disque });
|
const fs = creerFichiersSimules({ disque });
|
||||||
|
|
@ -778,7 +801,7 @@ describe('opérations : renommer (§ 8.7)', () => {
|
||||||
let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION);
|
let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION);
|
||||||
ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2));
|
ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2));
|
||||||
assert.equal(ouvertDeux.base, deux);
|
assert.equal(ouvertDeux.base, deux);
|
||||||
await supprimer(second, DOC, gala, { horodatage: HEURE_SUPPRESSION });
|
await supprimer(second, DOC, gala, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
const avantDeux = await arbre(disqueDeux);
|
const avantDeux = await arbre(disqueDeux);
|
||||||
const ecrituresDeux = second.compteurs.ecritures;
|
const ecrituresDeux = second.compteurs.ecritures;
|
||||||
const traceDeux = tracer(second);
|
const traceDeux = tracer(second);
|
||||||
|
|
@ -943,8 +966,8 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
|
||||||
assert.ok(windows1252.includes(0xe9));
|
assert.ok(windows1252.includes(0xe9));
|
||||||
assert.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE);
|
assert.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE);
|
||||||
const cas = [
|
const cas = [
|
||||||
['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'JSON', chemin: null }],
|
['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'ENCODAGE', chemin: null }],
|
||||||
['un état écrit en Windows-1252', windows1252, { raison: 'JSON', chemin: null }],
|
['un état écrit en Windows-1252', windows1252, { raison: 'ENCODAGE', chemin: null }],
|
||||||
['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }],
|
['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }],
|
||||||
['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }],
|
['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }],
|
||||||
['des comptes faux', UTF8.encode(comptesFaux), { raison: 'COMPTES', chemin: 'entete.comptes.participants' }],
|
['des comptes faux', UTF8.encode(comptesFaux), { raison: 'COMPTES', chemin: 'entete.comptes.participants' }],
|
||||||
|
|
@ -959,7 +982,7 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
|
||||||
await assert.rejects(importer(nouveauDisque().fs, DOC, etatTexte(chargeEssai())), TypeError);
|
await assert.rejects(importer(nouveauDisque().fs, DOC, etatTexte(chargeEssai())), TypeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("un identifiant que porte déjà un état lisible du dossier lève IDENTIFIANT_PRESENT {id, base}, quel que soit le nom, sans rien écrire ; ni un état illisible ni un journal sans état ne le portent", async () => {
|
test("un identifiant que lister nomme dans le dossier lève IDENTIFIANT_PRESENT {id, base}, quel que soit le nom, sans rien écrire : un état lisible, un état illisible apparié par son .precedent, un journal sans état ; un état illisible sans identifiant ne le porte pas", async () => {
|
||||||
const { disque, fs } = nouveauDisque();
|
const { disque, fs } = nouveauDisque();
|
||||||
await creer(fs, DOC, chargeEssai(), CREATION);
|
await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
const avant = await arbre(disque);
|
const avant = await arbre(disque);
|
||||||
|
|
@ -972,12 +995,25 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
|
||||||
|
|
||||||
const illisible = disqueAvec([[ETAT, '{']]);
|
const illisible = disqueAvec([[ETAT, '{']]);
|
||||||
assert.deepEqual(await importer(illisible.fs, DOC, octets), { base: 'Bal des Hérons' });
|
assert.deepEqual(await importer(illisible.fs, DOC, octets), { base: 'Bal des Hérons' });
|
||||||
|
const apparie = disqueAvec([
|
||||||
|
[ETAT, ''],
|
||||||
|
[PRECEDENT, etatTexte(chargeEssai())],
|
||||||
|
]);
|
||||||
const journalSeul = disqueAvec([[JOURNAL, (await paireDe(chargeEssai())).journal]]);
|
const journalSeul = disqueAvec([[JOURNAL, (await paireDe(chargeEssai())).journal]]);
|
||||||
assert.deepEqual(
|
for (const [nom, { disque: autre, fs: autreFs }, raison] of [
|
||||||
(await lister(journalSeul.fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]),
|
['un état illisible apparié par son .precedent', apparie, 'VIDE'],
|
||||||
[[BASE, 'evt-lucioles', 'ABSENT']],
|
['un journal sans état', journalSeul, 'ABSENT'],
|
||||||
);
|
]) {
|
||||||
assert.deepEqual(await importer(journalSeul.fs, DOC, octets), { base: 'Bal des Hérons' });
|
assert.deepEqual(
|
||||||
|
(await lister(autreFs, DOC)).map(({ base, id, illisible: lue }) => [base, id, lue]),
|
||||||
|
[[BASE, 'evt-lucioles', raison]],
|
||||||
|
nom,
|
||||||
|
);
|
||||||
|
const poses = await arbre(autre);
|
||||||
|
const refus = await echec(importer(autreFs, DOC, octets), 'IDENTIFIANT_PRESENT', nom);
|
||||||
|
assert.deepEqual(refus.details, { id: 'evt-lucioles', base: BASE }, nom);
|
||||||
|
assert.deepEqual(await arbre(autre), poses, nom);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => {
|
test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => {
|
||||||
|
|
@ -1071,6 +1107,102 @@ describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un événement dont l'état ne se lit pas s'importe par ses secours : l'état, le .precedent et le journal qu'il s'apparie se recopient tels quels — sauf des octets hors UTF-8, qui ne se lisent pas —, et il s'ouvre là-bas en secours, par le .precedent seul quand il n'a pas de journal ; sans secours lisible, il se refuse ETAT_ILLISIBLE ; un identifiant déjà là-bas, illisible ou non, IDENTIFIANT_PRESENT", async () => {
|
||||||
|
const disque = new Map();
|
||||||
|
const PORTABLE = { executable: 'E:\\soirees' };
|
||||||
|
const fs = creerFichiersSimules({ disque, ...PORTABLE });
|
||||||
|
fs.deposerDossier('portable');
|
||||||
|
fs.deposerDossier('documents');
|
||||||
|
const { portable, documents } = await fs.racines();
|
||||||
|
// Gala : l'état vidé, son .precedent et son journal intacts. Fanfare :
|
||||||
|
// l'état aux octets hors UTF-8, et son journal. Roseraie : l'état
|
||||||
|
// illisible et son .precedent, sans journal. Ifs : l'état seul,
|
||||||
|
// illisible. Saules : déjà dans documents, l'état illisible.
|
||||||
|
const gala = await (async () => {
|
||||||
|
const { fs: source, ouvert } = await deuxRevisions(chargeEssai({ id: 'evt-gala', nom: 'Gala des Ormes' }));
|
||||||
|
const lire = (suffixe) => source.contenu('documents', `${ouvert.base}${suffixe}`);
|
||||||
|
return { precedent: lire(SUFFIXES.precedent), journal: lire(SUFFIXES.journal), etat: lire(SUFFIXES.etat) };
|
||||||
|
})();
|
||||||
|
const fanfare = await paireDe(chargeEssai({ id: 'evt-fanfare', nom: 'Fanfare' }));
|
||||||
|
const saules = await paireDe(chargeEssai({ id: 'evt-saules', nom: 'Fête des Saules' }));
|
||||||
|
const roseraie = await paireDe(chargeEssai({ id: 'evt-roseraie', nom: 'Roseraie' }));
|
||||||
|
fs.deposer('portable', `Gala des Ormes${SUFFIXES.etat}`, '');
|
||||||
|
fs.deposer('portable', `Gala des Ormes${SUFFIXES.precedent}`, gala.precedent);
|
||||||
|
fs.deposer('portable', `Gala des Ormes${SUFFIXES.journal}`, gala.journal);
|
||||||
|
fs.deposerOctets('portable', `Fanfare${SUFFIXES.etat}`, enWindows1252(fanfare.etat));
|
||||||
|
fs.deposer('portable', `Fanfare${SUFFIXES.journal}`, fanfare.journal);
|
||||||
|
fs.deposer('portable', `Roseraie${SUFFIXES.etat}`, '{');
|
||||||
|
fs.deposer('portable', `Roseraie${SUFFIXES.precedent}`, roseraie.etat);
|
||||||
|
fs.deposer('portable', `Kermesse des Ifs${SUFFIXES.etat}`, '{');
|
||||||
|
fs.deposer('portable', `Fête des Saules${SUFFIXES.etat}`, saules.etat);
|
||||||
|
fs.deposer('documents', `Saules${SUFFIXES.etat}`, '');
|
||||||
|
fs.deposer('documents', `Saules${SUFFIXES.journal}`, saules.journal);
|
||||||
|
const ecritures = fs.compteurs.ecritures;
|
||||||
|
|
||||||
|
const bases = ['Gala des Ormes', 'Fanfare', 'Roseraie', 'Kermesse des Ifs', 'Fête des Saules'];
|
||||||
|
assert.deepEqual(await importerDepuis(fs, portable, documents, bases), {
|
||||||
|
importes: [
|
||||||
|
{ base: 'Gala des Ormes', nouvelle: 'Gala des Ormes' },
|
||||||
|
{ base: 'Fanfare', nouvelle: 'Fanfare' },
|
||||||
|
{ base: 'Roseraie', nouvelle: 'Roseraie' },
|
||||||
|
],
|
||||||
|
refuses: [
|
||||||
|
{ base: 'Kermesse des Ifs', code: 'ETAT_ILLISIBLE' },
|
||||||
|
{ base: 'Fête des Saules', code: 'IDENTIFIANT_PRESENT' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(fs.compteurs.ecritures - ecritures, 6);
|
||||||
|
assert.deepEqual(fs.octets('portable', `Fanfare${SUFFIXES.etat}`), enWindows1252(fanfare.etat));
|
||||||
|
assert.deepEqual(await arbre(disque, documents), [
|
||||||
|
[`Fanfare${SUFFIXES.journal}`, fanfare.journal],
|
||||||
|
[`Gala des Ormes${SUFFIXES.journal}`, gala.journal],
|
||||||
|
[`Gala des Ormes${SUFFIXES.etat}`, ''],
|
||||||
|
[`Gala des Ormes${SUFFIXES.precedent}`, gala.precedent],
|
||||||
|
[`Roseraie${SUFFIXES.etat}`, '{'],
|
||||||
|
[`Roseraie${SUFFIXES.precedent}`, roseraie.etat],
|
||||||
|
[`Saules${SUFFIXES.journal}`, saules.journal],
|
||||||
|
[`Saules${SUFFIXES.etat}`, ''],
|
||||||
|
]);
|
||||||
|
const lecteur = creerFichiersSimules({ disque });
|
||||||
|
for (const [base, raison, secours] of [
|
||||||
|
['Gala des Ormes', 'VIDE', { precedent: 1, journal: 2 }],
|
||||||
|
['Fanfare', 'ABSENT', { precedent: null, journal: 2 }],
|
||||||
|
]) {
|
||||||
|
const erreur = await echec(ouvrir(lecteur, documents, base), 'ETAT_ILLISIBLE', base);
|
||||||
|
assert.deepEqual([erreur.details.raison, erreur.details.secours], [raison, secours], base);
|
||||||
|
assert.equal((await ouvrirSecours(lecteur, documents, base, 'journal')).revision, 2, base);
|
||||||
|
}
|
||||||
|
const roseraieLa = await echec(ouvrir(lecteur, documents, 'Roseraie'), 'ETAT_ILLISIBLE');
|
||||||
|
assert.deepEqual([roseraieLa.details.raison, roseraieLa.details.secours], ['JSON', { precedent: 2, journal: null }]);
|
||||||
|
const parPrecedent = await ouvrirSecours(lecteur, documents, 'Roseraie', 'precedent');
|
||||||
|
assert.deepEqual([parPrecedent.revision, parPrecedent.charge.evenement.id], [2, 'evt-roseraie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un journal dont la dernière ligne est coupée au milieu d'un caractère s'importe par ses lignes lisibles, celles que l'ouverture lit : l'événement s'ouvre là-bas avec son historique, sans rien écarter", async () => {
|
||||||
|
const disque = new Map();
|
||||||
|
const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
|
||||||
|
fs.deposerDossier('portable');
|
||||||
|
fs.deposerDossier('documents');
|
||||||
|
const { portable, documents } = await fs.racines();
|
||||||
|
const paire = await paireDe(chargeEssai({ id: 'evt-gala', nom: 'Gala des Ormes' }));
|
||||||
|
const coupee = [...UTF8.encode('{"type":"entree","revision":3,"libelle":"Ajout de Th'), 0xc3];
|
||||||
|
fs.deposer('portable', `Gala des Ormes${SUFFIXES.etat}`, paire.etat);
|
||||||
|
fs.deposerOctets('portable', `Gala des Ormes${SUFFIXES.journal}`, new Uint8Array([...UTF8.encode(paire.journal), ...coupee]));
|
||||||
|
const ouvertIci = await ouvrir(fs, portable, 'Gala des Ormes');
|
||||||
|
assert.deepEqual(ouvertIci.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]);
|
||||||
|
|
||||||
|
assert.deepEqual(await importerDepuis(fs, portable, documents, ['Gala des Ormes']), {
|
||||||
|
importes: [{ base: 'Gala des Ormes', nouvelle: 'Gala des Ormes' }],
|
||||||
|
refuses: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(await arbre(disque, documents), [
|
||||||
|
[`Gala des Ormes${SUFFIXES.journal}`, paire.journal],
|
||||||
|
[`Gala des Ormes${SUFFIXES.etat}`, paire.etat],
|
||||||
|
]);
|
||||||
|
const la = await ouvrir(creerFichiersSimules({ disque }), documents, 'Gala des Ormes');
|
||||||
|
assert.deepEqual([la.revision, la.journal.entrees.length, la.avertissements], [2, 2, []]);
|
||||||
|
});
|
||||||
|
|
||||||
test("une lecture que le système de fichiers refuse autrement que par l'absence refuse la base par son code, sans rien écrire là-bas", async () => {
|
test("une lecture que le système de fichiers refuse autrement que par l'absence refuse la base par son code, sans rien écrire là-bas", async () => {
|
||||||
const disque = new Map();
|
const disque = new Map();
|
||||||
const PORTABLE = { executable: 'E:\\soirees' };
|
const PORTABLE = { executable: 'E:\\soirees' };
|
||||||
|
|
@ -1098,6 +1230,25 @@ describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
|
||||||
});
|
});
|
||||||
assert.equal(fs.compteurs.ecritures, ecritures);
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
assert.deepEqual(await arbre(disque, documents), []);
|
assert.deepEqual(await arbre(disque, documents), []);
|
||||||
|
|
||||||
|
// De même pour le .precedent d'un état illisible, que la recopie relit
|
||||||
|
// après l'ouverture et l'ouverture du secours.
|
||||||
|
const roseraie = await paireDe(chargeEssai({ id: 'evt-roseraie', nom: 'Roseraie' }));
|
||||||
|
fs.deposer('portable', `Roseraie${SUFFIXES.etat}`, '{');
|
||||||
|
fs.deposer('portable', `Roseraie${SUFFIXES.precedent}`, roseraie.etat);
|
||||||
|
let precedents = 0;
|
||||||
|
fs.lireTexte = async (racine, chemin) => {
|
||||||
|
if (chemin === `Roseraie${SUFFIXES.precedent}` && (precedents += 1) > 2) {
|
||||||
|
throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EIO' });
|
||||||
|
}
|
||||||
|
return lire(racine, chemin);
|
||||||
|
};
|
||||||
|
assert.deepEqual(await importerDepuis(fs, portable, documents, ['Roseraie']), {
|
||||||
|
importes: [],
|
||||||
|
refuses: [{ base: 'Roseraie', code: 'LECTURE' }],
|
||||||
|
});
|
||||||
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
|
assert.deepEqual(await arbre(disque, documents), []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1110,7 +1261,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION);
|
await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION);
|
||||||
const avant = await arbre(disque);
|
const avant = await arbre(disque);
|
||||||
const trace = tracer(fs);
|
const trace = tracer(fs);
|
||||||
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), { dossier: DATE });
|
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE });
|
||||||
const deplaces = [`${JOURNAL}.ecriture`, `${ETAT}.ecriture`, VERROU, JOURNAL, PRECEDENT, ETAT];
|
const deplaces = [`${JOURNAL}.ecriture`, `${ETAT}.ecriture`, VERROU, JOURNAL, PRECEDENT, ETAT];
|
||||||
assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]);
|
assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|
@ -1142,14 +1293,15 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
suppressions.push(chemin);
|
suppressions.push(chemin);
|
||||||
return primitive(racine, chemin);
|
return primitive(racine, chemin);
|
||||||
};
|
};
|
||||||
const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30) });
|
const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30), seance: SEANCE });
|
||||||
const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30) });
|
const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30), seance: SEANCE });
|
||||||
const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00' });
|
const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00', seance: SEANCE });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[a, b, c],
|
[a, b, c],
|
||||||
[{ dossier: DATE }, { dossier: `${DATE}_2` }, { dossier: 'corbeille/2031-05-17_08-29-59' }],
|
[{ dossier: DATE }, { dossier: `${DATE}_2` }, { dossier: 'corbeille/2031-05-17_08-29-59' }],
|
||||||
);
|
);
|
||||||
const paire = (nom) => [`${nom}${SUFFIXES.journal}`, `${nom}${SUFFIXES.etat}`];
|
// La paire, et le verrou que la sonde a pris : il part avec elle.
|
||||||
|
const paire = (nom) => [`${nom}${SUFFIXES.journal}`, `${nom}${SUFFIXES.etat}`, `${nom}${SUFFIXES.verrou}`];
|
||||||
const corbeille = [
|
const corbeille = [
|
||||||
{ dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') },
|
{ dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') },
|
||||||
{ dossier: DATE, fichiers: paire('Bal des Hérons') },
|
{ dossier: DATE, fichiers: paire('Bal des Hérons') },
|
||||||
|
|
@ -1163,7 +1315,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const renomme = await renommer(ouvert, renommage('Kermesse des Ifs', 31));
|
const renomme = await renommer(ouvert, renommage('Kermesse des Ifs', 31));
|
||||||
await copier(renomme, optionsCopie('Variante des Ifs'));
|
await copier(renomme, optionsCopie('Variante des Ifs'));
|
||||||
await importer(fs, DOC, UTF8.encode(etatTexte(chargeEssai({ id: 'evt-e', nom: 'Bal des Hérons' }))));
|
await importer(fs, DOC, UTF8.encode(etatTexte(chargeEssai({ id: 'evt-e', nom: 'Bal des Hérons' }))));
|
||||||
await supprimer(fs, DOC, 'Kermesse des Ifs', { horodatage: heure(30) });
|
await supprimer(fs, DOC, 'Kermesse des Ifs', { horodatage: heure(30), seance: SEANCE });
|
||||||
const apres = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/'));
|
const apres = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/'));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
apres.filter(([chemin]) => !chemin.startsWith(`${DATE}_3/`)),
|
apres.filter(([chemin]) => !chemin.startsWith(`${DATE}_3/`)),
|
||||||
|
|
@ -1193,10 +1345,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
];
|
];
|
||||||
for (const [nom, poses, restants] of cas) {
|
for (const [nom, poses, restants] of cas) {
|
||||||
const { disque, fs } = disqueAvec(poses);
|
const { disque, fs } = disqueAvec(poses);
|
||||||
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
|
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(disque),
|
await arbreSonde(disque),
|
||||||
poses
|
[...poses, [VERROU, SONDE]]
|
||||||
.map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte])
|
.map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte])
|
||||||
.sort(parChemin),
|
.sort(parChemin),
|
||||||
nom,
|
nom,
|
||||||
|
|
@ -1218,10 +1370,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
[base],
|
[base],
|
||||||
nom,
|
nom,
|
||||||
);
|
);
|
||||||
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION });
|
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(disque),
|
await arbreSonde(disque),
|
||||||
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin),
|
[...poses, [`${base}${SUFFIXES.verrou}`, SONDE]]
|
||||||
|
.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte])
|
||||||
|
.sort(parChemin),
|
||||||
nom,
|
nom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1234,12 +1388,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
[ETAT, etat],
|
[ETAT, etat],
|
||||||
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
|
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
|
||||||
[`soirée des lucioles${SUFFIXES.precedent}`, etat],
|
[`soirée des lucioles${SUFFIXES.precedent}`, etat],
|
||||||
[`Soire\u0301e Des Lucioles${SUFFIXES.verrou}`, '{}\n'],
|
[`Soire\u0301e Des Lucioles${SUFFIXES.verrou}`, VERROU_MORT],
|
||||||
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
|
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
|
||||||
];
|
];
|
||||||
const { disque, fs } = disqueAvec(poses);
|
const { disque, fs } = disqueAvec(poses);
|
||||||
const trace = tracer(fs);
|
const trace = tracer(fs);
|
||||||
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
|
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(disque),
|
await arbre(disque),
|
||||||
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin),
|
poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin),
|
||||||
|
|
@ -1254,6 +1408,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const autre = disqueAvec(poses);
|
const autre = disqueAvec(poses);
|
||||||
const { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', {
|
const { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', {
|
||||||
horodatage: HEURE_SUPPRESSION,
|
horodatage: HEURE_SUPPRESSION,
|
||||||
|
seance: SEANCE,
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(autre.disque),
|
await arbre(autre.disque),
|
||||||
|
|
@ -1267,6 +1422,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const troisieme = disqueAvec(illisible);
|
const troisieme = disqueAvec(illisible);
|
||||||
const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', {
|
const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', {
|
||||||
horodatage: HEURE_SUPPRESSION,
|
horodatage: HEURE_SUPPRESSION,
|
||||||
|
seance: SEANCE,
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(troisieme.disque),
|
await arbre(troisieme.disque),
|
||||||
|
|
@ -1279,10 +1435,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const lac = 'Χορος της Λιμνης';
|
const lac = 'Χορος της Λιμνης';
|
||||||
const grecs = [
|
const grecs = [
|
||||||
[`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))],
|
[`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))],
|
||||||
[`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, '{}\n'],
|
[`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, VERROU_MORT],
|
||||||
];
|
];
|
||||||
const quatrieme = disqueAvec(grecs);
|
const quatrieme = disqueAvec(grecs);
|
||||||
const { dossier: dossierLac } = await supprimer(quatrieme.fs, DOC, lac, { horodatage: HEURE_SUPPRESSION });
|
const { dossier: dossierLac } = await supprimer(quatrieme.fs, DOC, lac, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(quatrieme.disque),
|
await arbre(quatrieme.disque),
|
||||||
grecs.map(([chemin, texte]) => [`${dossierLac}/${chemin}`, texte]).sort(parChemin),
|
grecs.map(([chemin, texte]) => [`${dossierLac}/${chemin}`, texte]).sort(parChemin),
|
||||||
|
|
@ -1305,15 +1461,19 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const verrou = `${voisine}${SUFFIXES.verrou}`;
|
const verrou = `${voisine}${SUFFIXES.verrou}`;
|
||||||
const poses = [
|
const poses = [
|
||||||
[`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))],
|
[`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))],
|
||||||
[verrou, '{}\n'],
|
[verrou, VERROU_MORT],
|
||||||
];
|
];
|
||||||
const { disque, fs } = disqueAvec(poses);
|
const { disque, fs } = disqueAvec(poses);
|
||||||
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION });
|
const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
|
// Le verrou d'une base distincte n'est pas celui de l'événement : la
|
||||||
|
// sonde en prend un, qui part avec lui.
|
||||||
|
const sonde = prise ? [] : [[`${dossier}/${base}${SUFFIXES.verrou}`, SONDE]];
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(disque),
|
await arbreSonde(disque),
|
||||||
poses
|
[
|
||||||
.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte])
|
...poses.map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte]),
|
||||||
.sort(parChemin),
|
...sonde,
|
||||||
|
].sort(parChemin),
|
||||||
voisine,
|
voisine,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1325,24 +1485,29 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
[ETAT, etat],
|
[ETAT, etat],
|
||||||
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
|
[`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal],
|
||||||
[`soirée des lucioles${SUFFIXES.precedent}`, etat],
|
[`soirée des lucioles${SUFFIXES.precedent}`, etat],
|
||||||
[`Soirée Des Lucioles${SUFFIXES.verrou}`, '{}\n'],
|
[`Soirée Des Lucioles${SUFFIXES.verrou}`, VERROU_MORT],
|
||||||
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
|
[`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'],
|
||||||
];
|
];
|
||||||
const sensible = { insensibleCasse: false };
|
const sensible = { insensibleCasse: false };
|
||||||
const { disque, fs } = disqueAvec(poses, sensible);
|
const { disque, fs } = disqueAvec(poses, sensible);
|
||||||
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
|
const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
// Le journal d'une autre base que son identifiant apparie part avec
|
// Le journal d'une autre base que son identifiant apparie part avec
|
||||||
// l'événement (apparier, dans le dépôt) ; le reste ne lui appartient pas.
|
// l'événement (apparier, dans le dépôt) ; le reste ne lui appartient pas.
|
||||||
|
// Le verrou d'une autre casse n'est pas le sien : la sonde en prend un,
|
||||||
|
// qui part avec lui.
|
||||||
const partis = [ETAT, poses[1][0]];
|
const partis = [ETAT, poses[1][0]];
|
||||||
|
const restes = (await arbre(disque, DOC, sensible)).filter(([chemin]) => chemin !== `${dossier}/${VERROU}`);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await arbre(disque, DOC, sensible),
|
restes,
|
||||||
poses
|
poses
|
||||||
.map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte])
|
.map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte])
|
||||||
.sort(parChemin),
|
.sort(parChemin),
|
||||||
);
|
);
|
||||||
|
assert.equal(JSON.parse(creerFichiersSimules({ disque, ...sensible }).contenu('documents', `${dossier}/${VERROU}`)).seance, SEANCE);
|
||||||
await echec(
|
await echec(
|
||||||
supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', {
|
supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', {
|
||||||
horodatage: HEURE_SUPPRESSION,
|
horodatage: HEURE_SUPPRESSION,
|
||||||
|
seance: SEANCE,
|
||||||
}),
|
}),
|
||||||
'ABSENT',
|
'ABSENT',
|
||||||
);
|
);
|
||||||
|
|
@ -1357,7 +1522,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
fs.pannes.couperApres(n);
|
fs.pannes.couperApres(n);
|
||||||
let acheve = true;
|
let acheve = true;
|
||||||
try {
|
try {
|
||||||
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
|
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`);
|
assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`);
|
||||||
acheve = false;
|
acheve = false;
|
||||||
|
|
@ -1385,7 +1550,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const temoin = effacerDocumentsAvant(disque, fs, n);
|
const temoin = effacerDocumentsAvant(disque, fs, n);
|
||||||
let refus = null;
|
let refus = null;
|
||||||
try {
|
try {
|
||||||
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION });
|
await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE });
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
refus = erreur;
|
refus = erreur;
|
||||||
}
|
}
|
||||||
|
|
@ -1408,12 +1573,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
await creer(fs, DOC, chargeEssai(), CREATION);
|
await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
const avant = await arbre(disque);
|
const avant = await arbre(disque);
|
||||||
const ecritures = fs.compteurs.ecritures;
|
const ecritures = fs.compteurs.ecritures;
|
||||||
const absent = await echec(supprimer(fs, DOC, 'Inexistant', { horodatage: HEURE_SUPPRESSION }), 'ABSENT');
|
const absent = await echec(supprimer(fs, DOC, 'Inexistant', { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'ABSENT');
|
||||||
assert.deepEqual(absent.details, { chemin: `Inexistant${SUFFIXES.etat}` });
|
assert.deepEqual(absent.details, { chemin: `Inexistant${SUFFIXES.etat}` });
|
||||||
await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: '17 mai 2031' }), TypeError);
|
await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: '17 mai 2031', seance: SEANCE }), TypeError);
|
||||||
fs.deposerDossier('documents', DATE);
|
fs.deposerDossier('documents', DATE);
|
||||||
for (let rang = 2; rang <= 99; rang += 1) fs.deposerDossier('documents', `${DATE}_${rang}`);
|
for (let rang = 2; rang <= 99; rang += 1) fs.deposerDossier('documents', `${DATE}_${rang}`);
|
||||||
const saturee = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'CORBEILLE_SATUREE');
|
const saturee = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'CORBEILLE_SATUREE');
|
||||||
assert.deepEqual(saturee.details, { dossier: DATE });
|
assert.deepEqual(saturee.details, { dossier: DATE });
|
||||||
assert.equal(fs.compteurs.ecritures, ecritures);
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
assert.deepEqual(await arbre(disque), avant);
|
assert.deepEqual(await arbre(disque), avant);
|
||||||
|
|
@ -1423,7 +1588,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
test("une lecture que le système de fichiers refuse autrement que par l'absence remonte telle quelle, sans rien écrire : un chemin refusé, une corbeille illisible", async () => {
|
test("une lecture que le système de fichiers refuse autrement que par l'absence remonte telle quelle, sans rien écrire : un chemin refusé, une corbeille illisible", async () => {
|
||||||
const { fs } = await deuxRevisions();
|
const { fs } = await deuxRevisions();
|
||||||
const ecritures = fs.compteurs.ecritures;
|
const ecritures = fs.compteurs.ecritures;
|
||||||
await echec(supprimer(fs, DOC, '../ailleurs', { horodatage: HEURE_SUPPRESSION }), 'CHEMIN_REFUSE');
|
await echec(supprimer(fs, DOC, '../ailleurs', { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'CHEMIN_REFUSE');
|
||||||
const lister = fs.lister;
|
const lister = fs.lister;
|
||||||
fs.lister = async (racine, dossier) => {
|
fs.lister = async (racine, dossier) => {
|
||||||
if (dossier === 'corbeille') {
|
if (dossier === 'corbeille') {
|
||||||
|
|
@ -1432,10 +1597,89 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
return lister(racine, dossier);
|
return lister(racine, dossier);
|
||||||
};
|
};
|
||||||
await echec(listerCorbeille(fs, DOC), 'LECTURE');
|
await echec(listerCorbeille(fs, DOC), 'LECTURE');
|
||||||
await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'LECTURE');
|
await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'LECTURE');
|
||||||
assert.equal(fs.compteurs.ecritures, ecritures);
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("supprimer sonde le verrou sous la séance qui supprime : une autre séance vivante, d'un autre poste ou au verrou illisible lève VERROU_PRIS {seance, depuis, vivant} sans rien déplacer ; un verrou mort, le sien, ou celui que la sonde prend partent avec le reste", async () => {
|
||||||
|
const refuses = [
|
||||||
|
['une autre séance vivante', (fs, ouvert) => verrouiller(ouvert, 'seance-b'), { seance: 'seance-b', vivant: true }],
|
||||||
|
[
|
||||||
|
"une séance d'un autre poste",
|
||||||
|
(fs, ouvert, disque) => verrouiller({ ...ouvert, fs: creerFichiersSimules({ disque, hote: 'poste-voisin' }) }, 'seance-c'),
|
||||||
|
{ seance: 'seance-c', vivant: null },
|
||||||
|
],
|
||||||
|
['un verrou illisible', (fs) => fs.deposer('documents', VERROU, '{'), { seance: null, vivant: null }],
|
||||||
|
];
|
||||||
|
for (const [nom, poser, attendu] of refuses) {
|
||||||
|
const { disque, fs, ouvert } = await deuxRevisions();
|
||||||
|
await poser(fs, ouvert, disque);
|
||||||
|
const avant = await arbre(disque);
|
||||||
|
const trace = tracer(fs);
|
||||||
|
const erreur = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'VERROU_PRIS', nom);
|
||||||
|
const { depuis, ...reste } = erreur.details;
|
||||||
|
assert.deepEqual(reste, attendu, nom);
|
||||||
|
assert.equal(depuis === null, attendu.seance === null, nom);
|
||||||
|
assert.deepEqual(trace, [], nom);
|
||||||
|
assert.deepEqual(await arbre(disque), avant, nom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deux processus sur un même disque : le premier tient l'événement en
|
||||||
|
// écriture et continue ses gestes ; le second ne le supprime pas.
|
||||||
|
const partage = await deuxRevisions();
|
||||||
|
await verrouiller(partage.ouvert, SEANCE);
|
||||||
|
const second = creerFichiersSimules({ disque: partage.disque });
|
||||||
|
const refus = await echec(supprimer(second, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: 'seance-b' }), 'VERROU_PRIS');
|
||||||
|
assert.deepEqual([refus.details.seance, refus.details.vivant], [SEANCE, true]);
|
||||||
|
const suite = await enregistrer(partage.ouvert, noter(partage.ouvert.charge, 3), geste(3));
|
||||||
|
assert.deepEqual([suite.revision, suite.avertissements], [3, []]);
|
||||||
|
assert.deepEqual((await lister(second, DOC)).map(({ base, journal }) => [base, journal]), [[BASE, BASE]]);
|
||||||
|
assert.deepEqual(await listerCorbeille(second, DOC), []);
|
||||||
|
|
||||||
|
// Le verrou d'une séance coupée, le sien, ou celui que la sonde pose
|
||||||
|
// faute de verrou : chacun part dans la corbeille avec l'événement.
|
||||||
|
const acceptes = [
|
||||||
|
[
|
||||||
|
"une séance coupée",
|
||||||
|
async (disque) => {
|
||||||
|
const coupee = creerFichiersSimules({ disque });
|
||||||
|
await coupee.verrouiller(DOC, VERROU, 'seance-morte');
|
||||||
|
coupee.pannes.couperApres(0);
|
||||||
|
return 'seance-morte';
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['la séance qui supprime', async (disque, fs, ouvert) => (await verrouiller(ouvert, SEANCE), SEANCE)],
|
||||||
|
['aucun verrou', async () => SEANCE],
|
||||||
|
];
|
||||||
|
for (const [nom, poser] of acceptes) {
|
||||||
|
const { disque, fs, ouvert } = await deuxRevisions();
|
||||||
|
const tenant = await poser(disque, fs, ouvert);
|
||||||
|
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE }, nom);
|
||||||
|
assert.deepEqual(await listerCorbeille(fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT, VERROU] }], nom);
|
||||||
|
assert.equal(JSON.parse(fs.contenu('documents', `${DATE}/${VERROU}`)).seance, tenant, nom);
|
||||||
|
assert.deepEqual(await lister(fs, DOC), [], nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans verrou disponible, la sonde ne pose rien et rien d'autre ne part ; une séance absente lève TypeError sans rien écrire ; un refus après la sonde rend le verrou qu'elle a pris", async () => {
|
||||||
|
const web = await deuxRevisions(chargeEssai(), { verrouDisponible: false });
|
||||||
|
assert.deepEqual(await supprimer(web.fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE });
|
||||||
|
assert.deepEqual(await listerCorbeille(web.fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT] }]);
|
||||||
|
|
||||||
|
const { disque, fs } = await deuxRevisions();
|
||||||
|
const avant = await arbre(disque);
|
||||||
|
const ecritures = fs.compteurs.ecritures;
|
||||||
|
for (const seance of [undefined, '']) {
|
||||||
|
await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance }), TypeError);
|
||||||
|
}
|
||||||
|
assert.equal(fs.compteurs.ecritures, ecritures);
|
||||||
|
fs.creerDossier = async (racine, chemin) => {
|
||||||
|
throw new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EACCES' });
|
||||||
|
};
|
||||||
|
await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), 'ECRITURE');
|
||||||
|
assert.deepEqual(await arbre(disque), avant);
|
||||||
|
});
|
||||||
|
|
||||||
test('une corbeille absente se liste vide ; un fichier posé à même la corbeille ne se liste pas comme un dossier', async () => {
|
test('une corbeille absente se liste vide ; un fichier posé à même la corbeille ne se liste pas comme un dossier', async () => {
|
||||||
const { fs } = nouveauDisque();
|
const { fs } = nouveauDisque();
|
||||||
assert.deepEqual(await listerCorbeille(fs, DOC), []);
|
assert.deepEqual(await listerCorbeille(fs, DOC), []);
|
||||||
|
|
@ -1552,3 +1796,33 @@ describe("opérations : ce qu'une construction plus récente a écrit (§ 8.8)",
|
||||||
assert.equal(ouvert.fs.compteurs.ecritures, 0);
|
assert.equal(ouvert.fs.compteurs.ecritures, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('opérations : un état aux octets hors UTF-8 (§ 8.8)', () => {
|
||||||
|
test("ouvert d'un secours, renommer met d'abord l'état aux octets hors UTF-8 dans un dossier daté de la corbeille, ses octets intacts, puis renomme, sous un autre nom comme en place ; copier en fait un événement neuf sans le toucher", async () => {
|
||||||
|
for (const [nom, base] of [
|
||||||
|
['Gala des Saules', 'Gala des Saules'],
|
||||||
|
[`${BASE} `.toUpperCase(), BASE],
|
||||||
|
]) {
|
||||||
|
const { disque, fs } = await deuxRevisions();
|
||||||
|
const octets = enWindows1252(fs.contenu('documents', ETAT));
|
||||||
|
fs.deposerOctets('documents', ETAT, octets);
|
||||||
|
const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
|
||||||
|
const copie = await copier(ouvert, optionsCopie('Variante des Lucioles'));
|
||||||
|
assert.equal(copie.charge.evenement.filiation.source.id, 'evt-lucioles', nom);
|
||||||
|
assert.deepEqual(fs.octets('documents', ETAT), octets, nom);
|
||||||
|
const renomme = await renommer(ouvert, renommage(nom, 3, { libelleReprise: REPRISE }));
|
||||||
|
assert.equal(renomme.base, base, nom);
|
||||||
|
assert.deepEqual(fs.octets('documents', `corbeille/2031-05-17_08-03-00/${ETAT}`), octets, nom);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(creerFichiersSimules({ disque }), DOC)).map(({ base: b, illisible }) => [b, illisible]),
|
||||||
|
[
|
||||||
|
[base, null],
|
||||||
|
['Variante des Lucioles', null],
|
||||||
|
],
|
||||||
|
nom,
|
||||||
|
);
|
||||||
|
const rouvert = await ouvrir(creerFichiersSimules({ disque }), DOC, base);
|
||||||
|
assert.deepEqual([rouvert.charge.evenement.nom, rouvert.avertissements], [nom, []], nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* @property {string|null} nom null pour une personne que la charge
|
* @property {string|null} nom null pour une personne que la charge
|
||||||
* n'a plus
|
* n'a plus
|
||||||
* @property {string|null} prenom
|
* @property {string|null} prenom
|
||||||
|
* @property {boolean} reserve vrai pour une personne de la réserve ;
|
||||||
|
* une liste écrite au-delà des tables
|
||||||
|
* déclarées n'a pas de table et n'est
|
||||||
|
* pas la réserve
|
||||||
*
|
*
|
||||||
* Ce que rend examiner.
|
* Ce que rend examiner.
|
||||||
*
|
*
|
||||||
|
|
@ -399,31 +403,41 @@ const ordreDesTables = (a, b) =>
|
||||||
(a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id;
|
(a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forme nommée d'un placement cohérent, proposition ou retenu (§ 8.9,
|
* Forme nommée d'un placement, proposition ou retenu (§ 8.9, point 4) : une
|
||||||
* point 4) : une ligne par personne placée à un tour, { tour, table,
|
* ligne par identifiant écrit à un tour, { tour, table, numero, siege,
|
||||||
* numero, siege, participant, nom, prenom }, pour la lecture et le
|
* participant, nom, prenom, reserve }, pour la lecture et le signalement
|
||||||
* signalement d'un défaut. Les lignes se rangent par tour, puis par numéro
|
* d'un défaut. Les lignes se rangent par tour, puis par numéro de table —
|
||||||
* de table — une table que la charge n'a plus vient après les autres, sans
|
* une table que la charge n'a plus vient après les autres, sans numéro, et
|
||||||
* numéro, et l'identifiant départage deux numéros égaux —, puis par rang
|
* l'identifiant départage deux numéros égaux —, puis par rang dans la
|
||||||
* dans la liste ; la réserve suit les tables de son tour, table et numéro
|
* liste ; la réserve suit les tables de son tour, table et numéro null.
|
||||||
* null. siege est le rang à partir de 1 quand le placement attribue les
|
* siege est le rang à partir de 1 quand le placement attribue les sièges,
|
||||||
* sièges, null sinon et dans la réserve. Nom et prénom viennent de la
|
* null sinon et dans la réserve. Nom et prénom viennent de la charge ; une
|
||||||
* charge ; une personne qu'elle n'a plus les a null. Lève TypeError quand p
|
* personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
|
||||||
* n'est pas cohérent (fautes).
|
*
|
||||||
|
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
|
||||||
|
* se rend tel qu'il est écrit, pour que son défaut se lise : un identifiant
|
||||||
|
* écrit deux fois donne deux lignes, un manquant aucune ; les tours écrits,
|
||||||
|
* qu'ils soient plus ou moins nombreux que ceux déclarés ; une table
|
||||||
|
* déclarée sans liste n'a pas de ligne, et une liste écrite au-delà des
|
||||||
|
* tables déclarées suit les tables de son tour, sans table ni numéro, avant
|
||||||
|
* la réserve. Ses fautes se lisent par fautes(p), que l'appelant rend à
|
||||||
|
* côté. Lève TypeError quand la forme ou la déclaration de p est fausse
|
||||||
|
* (faute FORME) : ses listes ne se lisent pas.
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
||||||
* @param {import('./types.js').Charge} charge
|
* @param {import('./types.js').Charge} charge
|
||||||
* @returns {Ligne[]}
|
* @returns {Ligne[]}
|
||||||
*/
|
*/
|
||||||
export function formeNommee(p, charge) {
|
export function formeNommee(p, charge) {
|
||||||
exigerCoherent(p, 'formeNommee');
|
const [premiere] = fautes(p);
|
||||||
|
if (premiere?.code === 'FORME') throw new TypeError(`formeNommee : placement fautif ${JSON.stringify(premiere)}`);
|
||||||
const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne]));
|
const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne]));
|
||||||
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
|
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
|
||||||
const tables = p.tables
|
const tables = p.tables
|
||||||
.map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null }))
|
.map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null }))
|
||||||
.sort(ordreDesTables);
|
.sort(ordreDesTables);
|
||||||
const lignes = [];
|
const lignes = [];
|
||||||
const ajouter = (tour, table, numero, siege, participant) => {
|
const ajouter = (tour, table, numero, siege, participant, reserve) => {
|
||||||
const personne = personneDe.get(participant);
|
const personne = personneDe.get(participant);
|
||||||
lignes.push({
|
lignes.push({
|
||||||
tour,
|
tour,
|
||||||
|
|
@ -433,16 +447,21 @@ export function formeNommee(p, charge) {
|
||||||
participant,
|
participant,
|
||||||
nom: personne?.nom ?? null,
|
nom: personne?.nom ?? null,
|
||||||
prenom: personne?.prenom ?? null,
|
prenom: personne?.prenom ?? null,
|
||||||
|
reserve,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Les personnes d'une liste de table, sans table pour une liste écrite
|
||||||
|
// au-delà des tables déclarées.
|
||||||
|
const ajouterListe = (tour, ids, table, numero) => {
|
||||||
|
ids.forEach((participant, position) => {
|
||||||
|
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
p.placement.forEach(({ sieges, reserve }, rangDuTour) => {
|
p.placement.forEach(({ sieges, reserve }, rangDuTour) => {
|
||||||
const tour = rangDuTour + 1;
|
const tour = rangDuTour + 1;
|
||||||
for (const { id, rang, numero } of tables) {
|
for (const { id, rang, numero } of tables) ajouterListe(tour, sieges[rang] ?? [], id, numero);
|
||||||
sieges[rang].forEach((participant, position) => {
|
for (const ids of sieges.slice(p.tables.length)) ajouterListe(tour, ids, null, null);
|
||||||
ajouter(tour, id, numero, p.siegesAttribues ? position + 1 : null, participant);
|
for (const participant of reserve) ajouter(tour, null, null, null, participant, true);
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const participant of reserve) ajouter(tour, null, null, null, participant);
|
|
||||||
});
|
});
|
||||||
return lignes;
|
return lignes;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,9 @@ function refusDe(appel) {
|
||||||
// aucune valeur au-delà.
|
// aucune valeur au-delà.
|
||||||
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
|
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
|
||||||
|
|
||||||
// Une ligne attendue de la forme nommée.
|
// Une ligne attendue de la forme nommée : sans table, une ligne de la
|
||||||
const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
|
// réserve, sauf quand enReserve dit le contraire.
|
||||||
|
const ligne = (tour, idTable, numero, siege, participant, nom, prenom, enReserve = idTable === null) => ({
|
||||||
tour,
|
tour,
|
||||||
table: idTable,
|
table: idTable,
|
||||||
numero,
|
numero,
|
||||||
|
|
@ -218,6 +219,7 @@ const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
|
||||||
participant,
|
participant,
|
||||||
nom,
|
nom,
|
||||||
prenom,
|
prenom,
|
||||||
|
reserve: enReserve,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => {
|
describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => {
|
||||||
|
|
@ -1306,17 +1308,90 @@ describe('formeNommee : la lecture et le signalement (§ 8.9, point 4)', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("planDepuisFichier, derive et formeNommee refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; fautes ne lève jamais", () => {
|
test("un placement incohérent se rend pour le signalement de son défaut, une ligne par identifiant écrit, ses fautes à côté (fautes) : doublon, inconnu, liste au-delà de sa capacité, liste en trop sans table, liste ou tour en moins", () => {
|
||||||
|
const charge = chargePetite();
|
||||||
|
const casDe = (abimer) => {
|
||||||
|
const p = fichierParfait();
|
||||||
|
abimer(p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const cas = [
|
||||||
|
[
|
||||||
|
'un assis aussi dans la réserve',
|
||||||
|
casDe((p) => { p.placement[0].reserve = [2]; }),
|
||||||
|
['DOUBLON'],
|
||||||
|
(lignes) => {
|
||||||
|
assert.equal(lignes.length, 49);
|
||||||
|
assert.deepEqual(lignes.filter(({ tour, participant }) => tour === 1 && participant === 2), [
|
||||||
|
ligne(1, 1, 1, null, 2, 'Bruyère', 'Basile'),
|
||||||
|
ligne(1, null, null, null, 2, 'Bruyère', 'Basile'),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"un inconnu à la place d'un déclaré",
|
||||||
|
casDe((p) => { p.placement[1].sieges[0][0] = 99; }),
|
||||||
|
['INCONNU', 'MANQUANT'],
|
||||||
|
(lignes) => {
|
||||||
|
assert.equal(lignes.length, 48);
|
||||||
|
assert.deepEqual(lignes.filter(({ participant }) => participant === 99), [
|
||||||
|
ligne(2, 1, 1, null, 99, null, null),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'une table au-delà de sa capacité, une liste en trop',
|
||||||
|
casDe((p) => {
|
||||||
|
p.siegesAttribues = true;
|
||||||
|
p.placement[0].sieges[0].push(4);
|
||||||
|
p.placement[0].sieges.push([5, 6]);
|
||||||
|
}),
|
||||||
|
['LONGUEUR', 'LONGUEUR', 'DOUBLON', 'DOUBLON', 'DOUBLON'],
|
||||||
|
(lignes) => {
|
||||||
|
assert.equal(lignes.length, 51);
|
||||||
|
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(0, 4).map(({ siege, participant }) => [siege, participant]), [
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
[3, 3],
|
||||||
|
[4, 4],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(-2), [
|
||||||
|
ligne(1, null, null, 1, 5, 'Églantine', 'Edmée', false),
|
||||||
|
ligne(1, null, null, 2, 6, 'Fougère', 'Firmin', false),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'une liste et un tour en moins',
|
||||||
|
casDe((p) => {
|
||||||
|
p.placement[0].sieges.pop();
|
||||||
|
p.placement.pop();
|
||||||
|
}),
|
||||||
|
['LONGUEUR', 'LONGUEUR', 'MANQUANT', 'MANQUANT', 'MANQUANT'],
|
||||||
|
(lignes) => {
|
||||||
|
assert.equal(lignes.length, 33);
|
||||||
|
assert.deepEqual([...new Set(lignes.map(({ tour }) => tour))], [1, 2, 3]);
|
||||||
|
assert.equal(lignes.filter(({ tour, table }) => tour === 1 && table === 4).length, 0);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [nom, p, codes, verifier] of cas) {
|
||||||
|
assert.deepEqual(fautes(p).map(({ code }) => code), codes, nom);
|
||||||
|
verifier(formeNommee(p, charge));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planDepuisFichier et derive refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; formeNommee ne refuse qu'une forme fausse ; fautes ne lève jamais", () => {
|
||||||
const charge = chargePetite();
|
const charge = chargePetite();
|
||||||
const abimee = fichierParfait();
|
const abimee = fichierParfait();
|
||||||
abimee.placement[0].sieges[0].pop();
|
abimee.placement[0].sieges[0].pop();
|
||||||
for (const [nom, appel] of [
|
for (const [nom, appel] of [
|
||||||
['planDepuisFichier', () => planDepuisFichier(abimee)],
|
['planDepuisFichier', () => planDepuisFichier(abimee)],
|
||||||
['derive', () => derive(abimee, charge)],
|
['derive', () => derive(abimee, charge)],
|
||||||
['formeNommee', () => formeNommee(abimee, charge)],
|
|
||||||
]) {
|
]) {
|
||||||
leveTypeError(appel, /"MANQUANT"/, nom);
|
leveTypeError(appel, /"MANQUANT"/, nom);
|
||||||
}
|
}
|
||||||
|
assert.equal(formeNommee(abimee, charge).length, 47);
|
||||||
for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) {
|
for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) {
|
||||||
leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible));
|
leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible));
|
||||||
leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible));
|
leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible));
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@
|
||||||
// EXISTE {chemin} deplacer vers une cible existante
|
// EXISTE {chemin} deplacer vers une cible existante
|
||||||
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
|
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
|
||||||
// inconnue
|
// inconnue
|
||||||
|
// LECTURE {chemin, dossier, cause} une lecture refusée autrement que
|
||||||
|
// sur un absent ; cause
|
||||||
|
// UTF8_INVALIDE pour un fichier dont
|
||||||
|
// les octets ne sont pas de l'UTF-8,
|
||||||
|
// que lireTexte ne lit jamais en
|
||||||
|
// U+FFFD ; les détails portent
|
||||||
|
// alors aussi octets, une copie
|
||||||
|
// des octets du fichier
|
||||||
//
|
//
|
||||||
// types.js donne la table entière des codes du stockage.
|
// types.js donne la table entière des codes du stockage.
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
|
@ -64,9 +72,11 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
/**
|
/**
|
||||||
* Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon,
|
* 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
|
* 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
|
* processus tourne sur cette machine avec l'heure de démarrage inscrite ;
|
||||||
* il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance,
|
* faux quand il est mort à coup sûr — aucun processus de ce pid, ou deux
|
||||||
* depuis et vivant à null.
|
* 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}
|
* @typedef {{pris: true}
|
||||||
* | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou
|
* | {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
|
* @property {(r: Racine, chemin: string) => Promise<string>} lireTexte
|
||||||
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
|
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
|
||||||
* forme de normalisation comprises ; ABSENT pour un fichier absent
|
* forme de normalisation comprises ; ABSENT pour un fichier absent
|
||||||
* ou un dossier
|
* ou un dossier. Le décodage est strict : des octets qui ne sont
|
||||||
|
* pas de l'UTF-8 lèvent LECTURE {chemin, dossier, cause:
|
||||||
|
* 'UTF8_INVALIDE', octets}, octets étant un Uint8Array, copie des
|
||||||
|
* octets du fichier, que le dépôt décode ligne par ligne
|
||||||
* @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique
|
* @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique
|
||||||
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
|
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
|
||||||
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où
|
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où
|
||||||
|
|
@ -122,8 +135,8 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
|
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
|
||||||
* un fichier ; un fichier absent n'est pas une faute
|
* un fichier ; un fichier absent n'est pas une faute
|
||||||
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
|
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
|
||||||
* création exclusive de {seance, pid, hote, depuis} : un verrou
|
* création exclusive de {seance, pid, hote, depuis, demarrage} :
|
||||||
* présent n'est jamais repris, fût-il de la même séance
|
* 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
|
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
|
||||||
* n'efface que le verrou de cette séance ; un verrou absent n'est
|
* n'efface que le verrou de cette séance ; un verrou absent n'est
|
||||||
* pas une faute
|
* pas une faute
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,11 @@
|
||||||
* réduite aux clés connues quand le
|
* réduite aux clés connues quand le
|
||||||
* format est plus récent
|
* format est plus récent
|
||||||
* @property {boolean} formatPlusRecent format > FORMAT : lecture seule
|
* @property {boolean} formatPlusRecent format > FORMAT : lecture seule
|
||||||
|
* @property {{propositions: number, retenu: boolean}} manquants
|
||||||
|
* ce que les comptes des placements annoncent sans que la charge
|
||||||
|
* le porte : propositions, le nombre annoncé au-delà de la liste ;
|
||||||
|
* retenu, vrai quand il est annoncé et absent. Un tel écart ne
|
||||||
|
* refuse jamais le fichier (§ 8.9, point 3) ; le dépôt l'annonce
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -337,6 +342,12 @@
|
||||||
* ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
|
* ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
|
||||||
* renommage refusés ; la
|
* renommage refusés ; la
|
||||||
* cible est intacte
|
* cible est intacte
|
||||||
|
* LECTURE {chemin, dossier, cause} une lecture refusée
|
||||||
|
* autrement que sur un
|
||||||
|
* absent, ou des octets qui
|
||||||
|
* ne sont pas de l'UTF-8,
|
||||||
|
* cause UTF8_INVALIDE, et
|
||||||
|
* octets, leur copie
|
||||||
* EXISTE {chemin} déplacer vers une cible
|
* EXISTE {chemin} déplacer vers une cible
|
||||||
* existante
|
* existante
|
||||||
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine
|
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine
|
||||||
|
|
@ -355,7 +366,11 @@
|
||||||
* verrou
|
* verrou
|
||||||
* LECTURE_SEULE {raison} un geste sur un fichier
|
* LECTURE_SEULE {raison} un geste sur un fichier
|
||||||
* d'un format plus récent,
|
* d'un format plus récent,
|
||||||
* ou sur un plan bloqué
|
* sur un plan bloqué, ou
|
||||||
|
* passer en écriture sur
|
||||||
|
* un état devenu aux
|
||||||
|
* octets hors UTF-8 (raison
|
||||||
|
* ENCODAGE, de la séance)
|
||||||
* ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal,
|
* ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal,
|
||||||
* l'état n'a pas pu s'écrire
|
* l'état n'a pas pu s'écrire
|
||||||
* (§ 8.2)
|
* (§ 8.2)
|
||||||
|
|
@ -375,9 +390,13 @@
|
||||||
* l'appelant le lit avant
|
* l'appelant le lit avant
|
||||||
* toute écriture
|
* toute écriture
|
||||||
*
|
*
|
||||||
* @typedef {'ABSENT'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
|
* @typedef {'ABSENT'|'ENCODAGE'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
|
||||||
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
|
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
|
||||||
* par analyser
|
* par analyser
|
||||||
|
* ENCODAGE des octets qui ne sont pas de l'UTF-8 : le système de
|
||||||
|
* fichiers lève LECTURE, cause UTF8_INVALIDE, et le dépôt
|
||||||
|
* pose cette raison, comme l'import d'un fichier ; jamais
|
||||||
|
* analyser, qui ne reçoit que du texte
|
||||||
* VIDE zéro octet, ou des blancs seulement
|
* VIDE zéro octet, ou des blancs seulement
|
||||||
* JSON le texte n'est pas du JSON
|
* JSON le texte n'est pas du JSON
|
||||||
* FORME type, clé manquante ou inconnue, valeur hors de son
|
* FORME type, clé manquante ou inconnue, valeur hors de son
|
||||||
|
|
@ -386,7 +405,10 @@
|
||||||
* table en double ou atteignant prochainsIds, tour d'une
|
* table en double ou atteignant prochainsIds, tour d'une
|
||||||
* réservation hors de sa portée ; des propositions et du
|
* réservation hors de sa portée ; des propositions et du
|
||||||
* retenu, seul le conteneur
|
* retenu, seul le conteneur
|
||||||
* COMPTES un compte de l'en-tête contredit sa liste
|
* COMPTES un compte de la saisie — participants, tables,
|
||||||
|
* réservations, titres — contredit sa liste ; ceux des
|
||||||
|
* propositions et du retenu ne refusent jamais (Lecture,
|
||||||
|
* manquants)
|
||||||
* REFERENCE réservation ou titre vers une table ou une personne
|
* REFERENCE réservation ou titre vers une table ou une personne
|
||||||
* absente, siège au-delà de la capacité
|
* absente, siège au-delà de la capacité
|
||||||
* FORMAT_INCONNU format présent, mais non entier ou < 1
|
* FORMAT_INCONNU format présent, mais non entier ou < 1
|
||||||
|
|
@ -403,7 +425,9 @@
|
||||||
* charge.participants[2].nom : clés séparées par un point, rangs
|
* charge.participants[2].nom : clés séparées par un point, rangs
|
||||||
* entre crochets, une clé qui ne s'écrit pas comme un identifiant
|
* entre crochets, une clé qui ne s'écrit pas comme un identifiant
|
||||||
* entre crochets en JSON ; '' pour la racine ; null quand rien ne se
|
* entre crochets en JSON ; '' pour la racine ; null quand rien ne se
|
||||||
* désigne (ABSENT, VIDE, JSON)
|
* désigne (ABSENT, ENCODAGE, VIDE, JSON) ; le chemin du journal
|
||||||
|
* quand ouvrirSecours refuse le journal de la base, d'un format plus
|
||||||
|
* récent ou aux octets hors UTF-8
|
||||||
* @property {number} [format] le format lu quand il dépasse FORMAT :
|
* @property {number} [format] le format lu quand il dépasse FORMAT :
|
||||||
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
|
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
|
||||||
* récent
|
* récent
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@
|
||||||
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
|
// 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
|
// 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
|
// 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
|
// renommage qui remplace la cible, le verrou d'un processus vivant ou
|
||||||
// terminé. Chaque épreuve écrit sous ses propres dossiers
|
// terminé, sous Linux celui d'un pid vivant dont le démarrage n'est pas
|
||||||
// temporaires, retirés à sa fin, qu'elle passe ou non.
|
// 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 assert from 'node:assert/strict';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import {
|
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
|
// 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.
|
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
|
||||||
const SOUS_WINDOWS = process.platform === 'win32';
|
const SOUS_WINDOWS = process.platform === 'win32';
|
||||||
|
const SOUS_LINUX = process.platform === 'linux';
|
||||||
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
|
|
||||||
// Dossiers temporaires de l'épreuve en cours.
|
// Dossiers temporaires de l'épreuve en cours.
|
||||||
|
|
@ -303,4 +306,92 @@ describe('coquille sur un vrai disque', () => {
|
||||||
vivant: false,
|
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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
PAUSE_MAX_MS,
|
PAUSE_MAX_MS,
|
||||||
PREFIXE_CANAL,
|
PREFIXE_CANAL,
|
||||||
PRIMITIVES,
|
PRIMITIVES,
|
||||||
REESSAIS_RENOMMAGE,
|
REESSAIS_PASSAGERS,
|
||||||
RefusFichier,
|
RefusFichier,
|
||||||
SUFFIXE_ECRITURE,
|
SUFFIXE_ECRITURE,
|
||||||
TEMOIN,
|
TEMOIN,
|
||||||
|
|
@ -338,7 +338,8 @@ function dialogueFactice(reponses = []) {
|
||||||
* La coquille sur un disque en mémoire où la racine documents existe. Rend
|
* 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
|
* le disque, les primitives, et ce que l'épreuve lit des injections : les
|
||||||
* pauses demandées, les dossiers ouverts dans l'explorateur, les commandes
|
* 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({
|
function preparer({
|
||||||
disque = creerDisque(),
|
disque = creerDisque(),
|
||||||
|
|
@ -346,6 +347,7 @@ function preparer({
|
||||||
ouvrir = async () => '',
|
ouvrir = async () => '',
|
||||||
lancer = async () => '',
|
lancer = async () => '',
|
||||||
signaler = () => {},
|
signaler = () => {},
|
||||||
|
demarrage,
|
||||||
plateforme = 'linux',
|
plateforme = 'linux',
|
||||||
hote = HOTE,
|
hote = HOTE,
|
||||||
fs = disque.fs,
|
fs = disque.fs,
|
||||||
|
|
@ -380,6 +382,7 @@ function preparer({
|
||||||
signaux.push(pid);
|
signaux.push(pid);
|
||||||
return signaler(pid);
|
return signaler(pid);
|
||||||
},
|
},
|
||||||
|
demarrage,
|
||||||
pid: PID,
|
pid: PID,
|
||||||
hote,
|
hote,
|
||||||
maintenant: () => INSTANT,
|
maintenant: () => INSTANT,
|
||||||
|
|
@ -387,6 +390,28 @@ function preparer({
|
||||||
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
|
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.
|
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
|
||||||
async function rejette(promesse, message) {
|
async function rejette(promesse, message) {
|
||||||
let raison;
|
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 () => {
|
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]);
|
assert.deepEqual([PAUSE_INITIALE_MS, PAUSE_MAX_MS], [50, 500]);
|
||||||
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
|
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
|
||||||
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
|
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' });
|
await refuse(fichiers.lireTexte(DOC, 'bom.csv'), 'LECTURE', { chemin: 'bom.csv', dossier: DOCUMENTS, cause: 'EACCES' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lireTexte décode strictement : des octets hors UTF-8 lèvent LECTURE {chemin, dossier, cause: UTF8_INVALIDE, octets}, sans rien remplacer par U+FFFD ; octets est un Uint8Array, que l'IPC transporte, et le message n'en dit que le nombre", async () => {
|
||||||
|
const { disque, fichiers } = preparer();
|
||||||
|
// Un « è » enregistré en windows-1252, l'octet E8, au milieu d'un texte.
|
||||||
|
const octets = new Uint8Array([0x7b, 0x22, 0x69, 0xe8, 0x22, 0x7d]);
|
||||||
|
disque.deposer(`${DOCUMENTS}/archives/soiree.gtt.json`, octets);
|
||||||
|
const refus = await refuse(fichiers.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE', {
|
||||||
|
chemin: 'archives/soiree.gtt.json',
|
||||||
|
dossier: `${DOCUMENTS}/archives`,
|
||||||
|
cause: 'UTF8_INVALIDE',
|
||||||
|
octets,
|
||||||
|
});
|
||||||
|
assert.equal(Object.getPrototypeOf(refus.details.octets), Uint8Array.prototype);
|
||||||
|
assert.equal(
|
||||||
|
refus.message,
|
||||||
|
`LECTURE {"chemin":"archives/soiree.gtt.json","dossier":"${DOCUMENTS}/archives","cause":"UTF8_INVALIDE","octets":"6 octets"}`,
|
||||||
|
);
|
||||||
|
// Une suite tronquée en fin de fichier n'est pas de l'UTF-8 non plus.
|
||||||
|
disque.deposer(`${DOCUMENTS}/tronque.jsonl`, new Uint8Array([0x61, 0xc3]));
|
||||||
|
await refuse(fichiers.lireTexte(DOC, 'tronque.jsonl'), 'LECTURE', {
|
||||||
|
chemin: 'tronque.jsonl',
|
||||||
|
dossier: DOCUMENTS,
|
||||||
|
cause: 'UTF8_INVALIDE',
|
||||||
|
octets: new Uint8Array([0x61, 0xc3]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => {
|
test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => {
|
||||||
const { disque, fichiers } = preparer();
|
const { disque, fichiers } = preparer();
|
||||||
disque.deposer(`${DOCUMENTS}/b.txt`, 'bb');
|
disque.deposer(`${DOCUMENTS}/b.txt`, 'bb');
|
||||||
|
|
@ -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']);
|
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();
|
const { disque, fichiers, pauses } = preparer();
|
||||||
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
||||||
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
|
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
|
||||||
|
|
@ -785,8 +836,8 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
||||||
'ECRITURE',
|
'ECRITURE',
|
||||||
ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'),
|
ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'),
|
||||||
);
|
);
|
||||||
disque.panne('rename', 'EBUSY');
|
disque.panne('rename', 'EROFS');
|
||||||
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EBUSY'));
|
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.equal(disque.appels.filter((ligne) => ligne.startsWith(`rename ${DOCUMENTS}/source.txt`)).length, 1);
|
||||||
assert.deepEqual(pauses, []);
|
assert.deepEqual(pauses, []);
|
||||||
assert.equal(disque.contenu(`${DOCUMENTS}/source.txt`), 'source');
|
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)', () => {
|
describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
|
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
|
||||||
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
|
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 () => {
|
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 { disque, fichiers } = preparer();
|
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(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}`]);
|
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
|
||||||
const texte = disque.contenu(VERROU);
|
const texte = disque.contenu(VERROU);
|
||||||
assert.ok(texte.endsWith('}\n'), texte);
|
assert.ok(texte.endsWith('}\n'), texte);
|
||||||
const contenu = JSON.parse(texte);
|
const contenu = JSON.parse(texte);
|
||||||
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis']);
|
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
||||||
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null });
|
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.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());
|
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 [
|
for (const [nom, signaler, vivant] of [
|
||||||
['répond', () => {}, true],
|
['répond', () => {}, null],
|
||||||
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, true],
|
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, null],
|
||||||
['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false],
|
['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false],
|
||||||
]) {
|
]) {
|
||||||
const { disque, fichiers, signaux } = preparer({ signaler });
|
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 () => {
|
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();
|
const { disque, fichiers, signaux } = preparer();
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,25 @@ const REESSAIS_RENOMMAGE = 10;
|
||||||
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
|
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
|
||||||
const PAS_HORLOGE = 1000;
|
const PAS_HORLOGE = 1000;
|
||||||
|
|
||||||
// La taille d'un fichier compte les octets de son texte en UTF-8.
|
// La taille d'un fichier compte les octets de son texte en UTF-8. Un fichier
|
||||||
|
// porte un texte, ou les octets que deposerOctets a posés tels quels : ceux-ci
|
||||||
|
// se décodent strictement, comme la coquille et la plateforme web les
|
||||||
|
// décodent, la marque d'ordre d'octets gardée dans le texte.
|
||||||
const UTF8 = new TextEncoder();
|
const UTF8 = new TextEncoder();
|
||||||
|
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
|
|
||||||
|
// Le texte d'un fichier, ou null quand ses octets ne sont pas de l'UTF-8.
|
||||||
|
function texteDuNoeud({ texte }) {
|
||||||
|
if (typeof texte === 'string') return texte;
|
||||||
|
try {
|
||||||
|
return UTF8_STRICT.decode(texte);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les octets d'un fichier, copiés.
|
||||||
|
const octetsDuNoeud = ({ texte }) => (typeof texte === 'string' ? UTF8.encode(texte) : new Uint8Array(texte));
|
||||||
|
|
||||||
// État de chaque disque, hors de sa Map : la casse et le séparateur de sa
|
// État de chaque disque, hors de sa Map : la casse et le séparateur de sa
|
||||||
// première instance, son horloge, le prochain pid, et ses processus par pid.
|
// première instance, son horloge, le prochain pid, et ses processus par pid.
|
||||||
|
|
@ -151,12 +168,20 @@ const estContenuVerrou = (contenu) =>
|
||||||
* lister), refusés compris ; les autres ne comptent pas ;
|
* lister), refusés compris ; les autres ne comptent pas ;
|
||||||
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
|
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
|
||||||
* fichier, dossiers parents compris, sans compter ni avancer l'horloge ;
|
* fichier, dossiers parents compris, sans compter ni avancer l'horloge ;
|
||||||
|
* - deposerOctets(racineId, chemin, octets, modifie = l'instant courant) :
|
||||||
|
* pose un fichier de ces octets, tels quels, de même ; lireTexte les
|
||||||
|
* décode strictement et lève LECTURE {chemin, dossier, cause:
|
||||||
|
* 'UTF8_INVALIDE', octets} quand ils ne sont pas de l'UTF-8, octets en
|
||||||
|
* étant une copie, et un ajout en fin les garde ;
|
||||||
* - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris,
|
* - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris,
|
||||||
* de même ;
|
* de même ;
|
||||||
* - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null
|
* - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null
|
||||||
|
* pour un absent ou un dossier ; TypeError pour des octets hors UTF-8 ;
|
||||||
|
* - octets(racineId, chemin) : les octets d'un fichier, sans compter ; null
|
||||||
* pour un absent ou un dossier.
|
* pour un absent ou un dossier.
|
||||||
* deposer, deposerDossier et contenu servent encore après une coupure : ils
|
* deposer, deposerOctets, deposerDossier, contenu et octets servent encore
|
||||||
* sont les mains de l'épreuve sur le disque, non celles du processus.
|
* après une coupure : ils sont les mains de l'épreuve sur le disque, non
|
||||||
|
* celles du processus.
|
||||||
*/
|
*/
|
||||||
export function creerFichiersSimules({
|
export function creerFichiersSimules({
|
||||||
disque = new Map(),
|
disque = new Map(),
|
||||||
|
|
@ -287,11 +312,11 @@ export function creerFichiersSimules({
|
||||||
return panne.cause;
|
return panne.cause;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa
|
// Ce que rend verrouiller d'un verrou présent, d'après son texte, null pour
|
||||||
// prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le
|
// des octets hors UTF-8 : sa séance, l'instant de sa prise, et vivant —
|
||||||
// disque porte un processus de cet hôte sous ce pid, et que ce processus
|
// null pour un autre hôte ; pour celui-ci, vrai quand le disque porte un
|
||||||
// n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et
|
// processus de cet hôte sous ce pid, et que ce processus n'est pas coupé.
|
||||||
// vivant à null.
|
// Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
|
||||||
function verrouPresent(texte) {
|
function verrouPresent(texte) {
|
||||||
let contenu;
|
let contenu;
|
||||||
try {
|
try {
|
||||||
|
|
@ -367,10 +392,19 @@ export function creerFichiersSimules({
|
||||||
return Object.hasOwn(support, id) ? support[id] : 'fixe';
|
return Object.hasOwn(support, id) ? support[id] : 'fixe';
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Des octets déposés qui ne sont pas de l'UTF-8 lèvent LECTURE, cause
|
||||||
|
// UTF8_INVALIDE, qui en porte une copie : aucun octet ne se remplace en
|
||||||
|
// silence par U+FFFD.
|
||||||
lireTexte: primitive('lectures', (racine, chemin) => {
|
lireTexte: primitive('lectures', (racine, chemin) => {
|
||||||
const noeud = disque.get(localiser(racine, chemin).cle);
|
const loc = localiser(racine, chemin);
|
||||||
|
const noeud = disque.get(loc.cle);
|
||||||
if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin });
|
if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin });
|
||||||
return noeud.texte;
|
const texte = texteDuNoeud(noeud);
|
||||||
|
if (texte === null) {
|
||||||
|
const octets = octetsDuNoeud(noeud);
|
||||||
|
throw new ErreurStockage('LECTURE', { chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE', octets });
|
||||||
|
}
|
||||||
|
return texte;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => {
|
ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => {
|
||||||
|
|
@ -405,7 +439,9 @@ export function creerFichiersSimules({
|
||||||
exigerDossierParent(loc);
|
exigerDossierParent(loc);
|
||||||
exigerPasDossier(loc);
|
exigerPasDossier(loc);
|
||||||
const avant = disque.get(loc.cle)?.texte ?? '';
|
const avant = disque.get(loc.cle)?.texte ?? '';
|
||||||
poserFichier(loc, `${avant}${ligne}\n`, avancer());
|
const ajout = `${ligne}\n`;
|
||||||
|
const texte = typeof avant === 'string' ? avant + ajout : new Uint8Array([...avant, ...UTF8.encode(ajout)]);
|
||||||
|
poserFichier(loc, texte, avancer());
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lister: primitive('lectures', (racine, dossier) => {
|
lister: primitive('lectures', (racine, dossier) => {
|
||||||
|
|
@ -414,7 +450,7 @@ export function creerFichiersSimules({
|
||||||
const entrees = [];
|
const entrees = [];
|
||||||
for (const noeud of disque.values()) {
|
for (const noeud of disque.values()) {
|
||||||
if (noeud.parent !== loc.cle) continue;
|
if (noeud.parent !== loc.cle) continue;
|
||||||
const taille = noeud.type === 'fichier' ? UTF8.encode(noeud.texte).length : 0;
|
const taille = noeud.type === 'fichier' ? octetsDuNoeud(noeud).length : 0;
|
||||||
entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie });
|
entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie });
|
||||||
}
|
}
|
||||||
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
|
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
|
||||||
|
|
@ -460,7 +496,7 @@ export function creerFichiersSimules({
|
||||||
exigerDossierParent(loc);
|
exigerDossierParent(loc);
|
||||||
exigerPasDossier(loc);
|
exigerPasDossier(loc);
|
||||||
const present = disque.get(loc.cle);
|
const present = disque.get(loc.cle);
|
||||||
if (present !== undefined) return verrouPresent(present.texte);
|
if (present !== undefined) return verrouPresent(texteDuNoeud(present));
|
||||||
const prise = avancer();
|
const prise = avancer();
|
||||||
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
|
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
|
||||||
return { pris: true };
|
return { pris: true };
|
||||||
|
|
@ -472,7 +508,7 @@ export function creerFichiersSimules({
|
||||||
if (!verrouDisponible) return;
|
if (!verrouDisponible) return;
|
||||||
exigerInscriptible(loc);
|
exigerInscriptible(loc);
|
||||||
const present = disque.get(loc.cle);
|
const present = disque.get(loc.cle);
|
||||||
if (present?.type === 'fichier' && verrouPresent(present.texte).seance === seance) disque.delete(loc.cle);
|
if (present?.type === 'fichier' && verrouPresent(texteDuNoeud(present)).seance === seance) disque.delete(loc.cle);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
ouvrirDansExplorateur: primitive(null, (racine) => {
|
ouvrirDansExplorateur: primitive(null, (racine) => {
|
||||||
|
|
@ -509,6 +545,14 @@ export function creerFichiersSimules({
|
||||||
poserFichier(loc, texte, modifie);
|
poserFichier(loc, texte, modifie);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deposerOctets(racineId, chemin, octets, modifie = maintenant()) {
|
||||||
|
if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array");
|
||||||
|
const loc = localiser({ id: racineId }, chemin);
|
||||||
|
creerDossiers(loc, loc.parties.length - 1, maintenant);
|
||||||
|
exigerPasDossier(loc);
|
||||||
|
poserFichier(loc, new Uint8Array(octets), modifie);
|
||||||
|
},
|
||||||
|
|
||||||
deposerDossier(racineId, chemin = '') {
|
deposerDossier(racineId, chemin = '') {
|
||||||
const loc = localiser({ id: racineId }, chemin);
|
const loc = localiser({ id: racineId }, chemin);
|
||||||
creerDossiers(loc, loc.parties.length, maintenant);
|
creerDossiers(loc, loc.parties.length, maintenant);
|
||||||
|
|
@ -516,7 +560,15 @@ export function creerFichiersSimules({
|
||||||
|
|
||||||
contenu(racineId, chemin) {
|
contenu(racineId, chemin) {
|
||||||
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
|
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
|
||||||
return noeud?.type === 'fichier' ? noeud.texte : null;
|
if (noeud?.type !== 'fichier') return null;
|
||||||
|
const texte = texteDuNoeud(noeud);
|
||||||
|
if (texte === null) throw new TypeError(`contenu : ${chemin} porte des octets hors UTF-8, que rend octets`);
|
||||||
|
return texte;
|
||||||
|
},
|
||||||
|
|
||||||
|
octets(racineId, chemin) {
|
||||||
|
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
|
||||||
|
return noeud?.type === 'fichier' ? octetsDuNoeud(noeud) : null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,64 @@ describe('fichiers simulés : compteurs, préparation, horloge', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fichiers simulés : octets déposés et décodage strict', () => {
|
||||||
|
// « Selvi » puis l'octet E8, un « è » enregistré en windows-1252 : une suite
|
||||||
|
// qui n'est pas de l'UTF-8.
|
||||||
|
const HORS_UTF8 = new Uint8Array([0x7b, 0x22, 0x53, 0x65, 0x6c, 0x76, 0x69, 0xe8, 0x22, 0x7d]);
|
||||||
|
|
||||||
|
test("lireTexte refuse des octets qui ne sont pas de l'UTF-8 : LECTURE {chemin, dossier, cause: UTF8_INVALIDE, octets}, comptée, ses octets une copie dont le message ne dit que le nombre ; lister en donne la taille, octets les rend tels quels", async () => {
|
||||||
|
const fs = preparer();
|
||||||
|
fs.deposerOctets('documents', 'archives/soiree.gtt.json', HORS_UTF8);
|
||||||
|
await echoue(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE', {
|
||||||
|
chemin: 'archives/soiree.gtt.json',
|
||||||
|
dossier: `${DOCUMENTS}\\archives`,
|
||||||
|
cause: 'UTF8_INVALIDE',
|
||||||
|
octets: HORS_UTF8,
|
||||||
|
});
|
||||||
|
const refus = await rejette(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE');
|
||||||
|
assert.equal(
|
||||||
|
refus.message,
|
||||||
|
`LECTURE {"chemin":"archives/soiree.gtt.json","dossier":${JSON.stringify(`${DOCUMENTS}\\archives`)},"cause":"UTF8_INVALIDE","octets":"10 octets"}`,
|
||||||
|
);
|
||||||
|
refus.details.octets[0] = 0;
|
||||||
|
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
|
||||||
|
assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 2 });
|
||||||
|
assert.deepEqual(await fs.lister(DOC, 'archives'), [
|
||||||
|
{ nom: 'soiree.gtt.json', type: 'fichier', taille: 10, modifie: (await fs.lister(DOC, 'archives'))[0].modifie },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
|
||||||
|
assert.equal(erreurDe(() => fs.contenu('documents', 'archives/soiree.gtt.json')).name, 'TypeError');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("des octets UTF-8 déposés se lisent tels quels, marque d'ordre d'octets comprise ; un ajout en fin garde les octets d'avant, une écriture atomique les remplace", async () => {
|
||||||
|
const fs = preparer();
|
||||||
|
fs.deposerOctets('documents', 'bom.csv', new Uint8Array([0xef, 0xbb, 0xbf, 0xc3, 0xa9, 0x0d, 0x0a]));
|
||||||
|
assert.equal(await fs.lireTexte(DOC, 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
|
||||||
|
assert.equal(fs.contenu('documents', 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
|
||||||
|
fs.deposerOctets('documents', 'journal.jsonl', HORS_UTF8);
|
||||||
|
await fs.ajouterLigne(DOC, 'journal.jsonl', '\u{E9}');
|
||||||
|
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([...HORS_UTF8, 0xc3, 0xa9, 0x0a]));
|
||||||
|
await fs.ecrireAtomique(DOC, 'journal.jsonl', 'neuf');
|
||||||
|
assert.equal(await fs.lireTexte(DOC, 'journal.jsonl'), 'neuf');
|
||||||
|
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([0x6e, 0x65, 0x75, 0x66]));
|
||||||
|
assert.equal(fs.octets('documents', 'absent.txt'), null);
|
||||||
|
assert.equal(erreurDe(() => fs.deposerOctets('documents', 'x.txt', 'x')).name, 'TypeError');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un verrou aux octets hors UTF-8 se lit comme un verrou illisible", async () => {
|
||||||
|
const fs = preparer();
|
||||||
|
fs.deposerOctets('documents', 'soiree.gtt.verrou', HORS_UTF8);
|
||||||
|
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
||||||
|
pris: false,
|
||||||
|
seance: null,
|
||||||
|
depuis: null,
|
||||||
|
vivant: null,
|
||||||
|
});
|
||||||
|
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
|
||||||
|
assert.deepEqual(fs.octets('documents', 'soiree.gtt.verrou'), HORS_UTF8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fichiers simulés : préconditions et dossiers', () => {
|
describe('fichiers simulés : préconditions et dossiers', () => {
|
||||||
test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => {
|
test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => {
|
||||||
const fs = preparer();
|
const fs = preparer();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue