[ADD] storage: working-folder rule, portable data/ or Documents

Once per session the rule picks where events live (§ 8.6): data/ next
to the executable that the portable launcher publishes, unless that
folder sits under per-user application data or a temporary folder, or
a written witness does not read back; Documents otherwise, with the
reason. A data/ that holds events but refuses writes raises a question
instead of a silent switch; an empty folder with events elsewhere
proposes their import. A removable medium and the web platform are
announced. It writes only the probes' witnesses.

Checked: 20 tests, the five outcomes on the failing double; branches 100%.

--- FR ---

[ADD] stockage : dossier de travail, data/ portable ou Documents

Une fois par séance, la règle choisit où vivent les événements (§ 8.6) :
data/ à côté de l'exécutable que publie le lanceur portable, sauf sous
des données applicatives ou un dossier temporaire, ou quand un témoin
écrit ne se relit pas ; sinon Documents, raison dite. Un data/ qui porte
des événements mais refuse l'écriture pose une question au lieu d'une
bascule muette ; un dossier vide alors que l'autre en porte propose leur
import. Un support amovible et la plateforme web s'annoncent. Elle
n'écrit que les témoins des sondes.

Vérifié : 20 épreuves, les cinq issues sur le double ; branches 100 %.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 14:47:22 -04:00
parent 4003b1f833
commit 3b00566560
2 changed files with 685 additions and 0 deletions

View file

@ -0,0 +1,226 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La règle du dossier de travail (§ 8.6), que l'application applique une
// fois par séance, au démarrage, sur le système de fichiers qu'elle reçoit
// (§ 13.4). Sous la plateforme web, « à côté de l'exécutable » ne désigne
// rien : le dossier de travail est la racine documents, et PLATEFORME_WEB
// annonce les garanties que la plateforme n'offre pas (§ 8.8). Ailleurs, la
// règle tient en cinq points :
//
// 1. le dossier de l'exécutable est celui que publie le lanceur portable,
// Emplacements.executable, jamais celui du processus, qu'une archive
// auto-extractible fait tourner dans un dossier temporaire ; non publié,
// la règle passe au point 5 (NON_PUBLIE) ;
// 2. sous un emplacement de données applicatives, elle passe au point 5
// sans sonder data/ (DONNEES_APPLICATIVES) : la sonde y réussirait, et
// data/ naîtrait là où le § 8.6 l'interdit ;
// 3. sinon, la sonde éprouve l'écriture dans data/, qu'elle crée ;
// 4. réussie, data/ est le dossier de travail : le mode portable ;
// 5. échouée (SONDE_ECHOUEE, avec sa cause) ou interrompue, le dossier de
// travail est la racine documents.
//
// Le dossier retenu est toujours sondé, et sa sonde le crée : au point 5 et
// sous web, la racine documents naît là, avant la réponse quand la règle
// demande, et aucune écriture de fichier ne la crée ensuite, puisqu'un
// dossier de travail disparu ne se recrée pas en silence. Hors des témoins
// des sondes, la règle ne fait que lire : en mode portable, rien ne s'écrit
// hors de data/ ; sous un emplacement de données applicatives, data/ n'est
// ni sondé ni créé.
//
// Ce qui arrive ensuite (§ 8.6) se lit dans le résultat :
//
// question le logiciel demande avant d'ouvrir la liste.
// PORTABLE_NON_INSCRIPTIBLE : la sonde de data/ échoue,
// et data/ porte des événements, ou ne se lit pas — leur
// compte est alors inconnu, null ; passer en silence aux
// Documents ouvrirait sur une liste où ils manquent.
// DOCUMENTS_NON_INSCRIPTIBLE : la sonde du dossier retenu
// échoue, et aucun geste ne s'y écrirait. La première
// l'emporte quand les deux tombent : elle seule nomme des
// événements qui existent, ou peuvent exister, ailleurs, et
// l'échec du dossier retenu se nomme encore à la première
// écriture refusée.
// autre le dossier retenu est neuf et vide — il ne porte aucune
// entrée — quand l'autre emplacement porte des événements :
// le logiciel propose de les importer, par copie (§ 8.7).
// Un dossier qui a servi, fût-ce pour une corbeille ou des
// réglages locaux, ne le propose plus à chaque séance ; un
// emplacement qui ne se lit pas ne propose rien.
// avertissements PLATEFORME_WEB, puis SUPPORT_AMOVIBLE quand le dossier
// retenu est sur un support amovible, où le renommage
// par-dessus ne vaut pas celui du volume système (§ 8.8).
//
// Un événement se compte par ses deux fichiers (§ 8.6) : chaque base qui
// porte un état ou un journal à la racine du dossier, un journal sans son
// état compris, dont l'événement se relève (§ 8.8). Le compte lit les noms
// sans ouvrir les fichiers ; l'appariement par identifiant interne reste
// celui de la liste des événements.
//
// Une lecture qui échoue autrement que sur un dossier absent ne vaut pas un
// dossier vide. Sur le dossier retenu, l'échec remonte : la séance n'y
// lirait pas sa liste. Sur l'autre emplacement, qui ne sert qu'à proposer un
// import ou à nommer ce qu'une bascule cacherait, il laisse un compte
// inconnu, et le dossier retenu reste. Un chemin refusé, et ce qui n'est pas
// une ErreurStockage, remontent d'où qu'ils viennent : une faute du code ou
// un processus coupé ne disent rien du disque.
import { ErreurStockage } from './erreurs.js';
import { SUFFIXES } from './noms.js';
/**
* @typedef {import('./systeme_fichiers.js').Racine} Racine
* @typedef {import('./systeme_fichiers.js').SystemeFichiers} SystemeFichiers
*/
/**
* Le dossier de travail d'une séance, et ce que le logiciel doit en dire.
*
* @typedef {Object} DossierTravail
* @property {Racine} racine
* @property {'portable'|'documents'|'web'} mode
* @property {null|'NON_PUBLIE'|'DONNEES_APPLICATIVES'|'SONDE_ECHOUEE'} raison
* pourquoi le mode n'est pas portable ; null en mode portable et
* sous web
* @property {string|null} cause cause de l'échec de la sonde de data/
* (SONDE_ECHOUEE) ; null sinon
* @property {null|{code: 'PORTABLE_NON_INSCRIPTIBLE'|'DOCUMENTS_NON_INSCRIPTIBLE',
* racine: Racine, evenements: number|null, cause: string|null}} question
* le dossier qui refuse l'écriture, ses événements — null quand
* data/ ne se lit pas —, la cause de sa sonde : le logiciel
* demande avant d'ouvrir la liste
* @property {null|{racine: Racine, evenements: number}} autre
* le dossier retenu est neuf et vide, l'autre emplacement porte
* ces événements : le logiciel propose de les importer, par copie
* @property {Array<'PLATEFORME_WEB'|'SUPPORT_AMOVIBLE'>} avertissements
* dans cet ordre
*/
// Les deux fichiers d'un événement (§ 8.6).
const FICHIERS_EVENEMENT = [SUFFIXES.etat, SUFFIXES.journal];
// Un caractère vers sa majuscule quand elle tient en un seul caractère, sans
// regarder le contexte, comme Windows compare deux noms : ß, dont la
// majuscule s'écrit SS, reste lui-même.
function majusculeSimple(caractere) {
const majuscule = caractere.toUpperCase();
return Array.from(majuscule).length === 1 ? majuscule : caractere;
}
// Un chemin tel que le système le compare : chaque caractère vers sa
// majuscule simple là où la casse ne compte pas, puis sans ses séparateurs
// finaux.
function comparable(chemin, insensibleCasse, separateur) {
let texte = insensibleCasse ? Array.from(chemin, majusculeSimple).join('') : chemin;
while (texte.endsWith(separateur)) texte = texte.slice(0, -1);
return texte;
}
/**
* Vrai quand chemin est parent ou se trouve sous lui, sur une frontière de
* segment : C:\Users\Exemple\AppDataBis n'est pas sous
* C:\Users\Exemple\AppData. Les deux chemins sont absolus, au séparateur du
* système, et comparés comme il compare les noms. Un séparateur final ne
* compte pas, si bien que la racine d'un volume, C:\ ou /, porte tout le
* volume.
*
* @param {string} chemin
* @param {string} parent
* @param {{insensibleCasse: boolean, separateur: '\\'|'/'}} systeme
* ceux des Emplacements
* @returns {boolean}
*/
export function estSous(chemin, parent, { insensibleCasse, separateur }) {
const enfant = comparable(chemin, insensibleCasse, separateur);
const dossier = comparable(parent, insensibleCasse, separateur);
return enfant === dossier || enfant.startsWith(dossier + separateur);
}
// Entrées directes de la racine d'un dossier ; aucune pour un dossier
// absent. Tout autre échec de la lecture remonte : une panne n'est pas un
// dossier vide.
async function entrees(fs, racine) {
try {
return await fs.lister(racine, '');
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ABSENT') return [];
throw erreur;
}
}
// Événements d'une liste d'entrées : les bases distinctes de ses fichiers
// d'état et de journal. Un dossier ne compte pas, fût-il nommé comme eux.
function compterEvenements(liste) {
const bases = new Set();
for (const { nom, type } of liste) {
const suffixe = FICHIERS_EVENEMENT.find((fin) => nom.endsWith(fin));
if (type === 'fichier' && suffixe !== undefined) bases.add(nom.slice(0, -suffixe.length));
}
return bases.size;
}
// Événements de l'autre emplacement ; null, compte inconnu, quand sa lecture
// échoue sur une ErreurStockage autre qu'un dossier absent ou un chemin
// refusé.
async function compterAilleurs(fs, ailleurs) {
try {
return compterEvenements(await entrees(fs, ailleurs));
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code !== 'CHEMIN_REFUSE') return null;
throw erreur;
}
}
// Les points 1 à 5 : le mode, le dossier retenu, l'autre emplacement — null
// quand il n'y en a pas —, pourquoi data/ n'est pas retenu, et la sonde du
// dossier retenu quand elle a déjà eu lieu, null sinon.
async function appliquerRegle(fs, { portable, documents }) {
const repli = (raison, cause) => ({ mode: 'documents', racine: documents, ailleurs: portable, raison, cause, sonde: null });
if (fs.nature === 'web') {
return { mode: 'web', racine: documents, ailleurs: null, raison: null, cause: null, sonde: null };
}
const emplacements = await fs.emplacements();
if (emplacements.executable === null) return repli('NON_PUBLIE', null);
if (emplacements.donneesApplicatives.some((dossier) => estSous(emplacements.executable, dossier, emplacements))) {
return repli('DONNEES_APPLICATIVES', null);
}
const sonde = await fs.sonder(portable);
if (!sonde.inscriptible) return repli('SONDE_ECHOUEE', sonde.cause);
return { mode: 'portable', racine: portable, ailleurs: documents, raison: null, cause: null, sonde };
}
/**
* Applique la règle du dossier de travail (§ 8.6), une fois par séance ; la
* perte d'inscriptibilité en cours de séance se traite à l'écriture qui
* échoue, non ici. N'écrit que les témoins des sondes : celle de data/, aux
* points 3 et 4, et celle du dossier retenu, qui le crée.
*
* @param {SystemeFichiers} fs
* @returns {Promise<DossierTravail>}
* @throws ce que lève une primitive, hors d'un dossier absent à la lecture
* et, à celle de l'autre emplacement, d'une ErreurStockage autre que
* CHEMIN_REFUSE
*/
export async function determinerDossierTravail(fs) {
const issue = await appliquerRegle(fs, await fs.racines());
const { mode, racine, ailleurs, raison, cause } = issue;
const sonde = issue.sonde ?? (await fs.sonder(racine));
const ici = await entrees(fs, racine);
const evenementsAilleurs = ailleurs === null ? 0 : await compterAilleurs(fs, ailleurs);
// SONDE_ECHOUEE est la seule raison née d'une sonde de data/, qui est
// alors l'autre emplacement. Un compte inconnu demande comme un compte non
// nul : data/ peut porter ce qu'une bascule cacherait.
let question = null;
if (raison === 'SONDE_ECHOUEE' && evenementsAilleurs !== 0) {
question = { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: ailleurs, evenements: evenementsAilleurs, cause };
} else if (!sonde.inscriptible) {
question = { code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine, evenements: compterEvenements(ici), cause: sonde.cause };
}
// Un compte inconnu, null, ne propose rien : null n'est pas supérieur à 0.
const autre = ici.length === 0 && evenementsAilleurs > 0 ? { racine: ailleurs, evenements: evenementsAilleurs } : null;
const avertissements = [];
if (mode === 'web') avertissements.push('PLATEFORME_WEB');
if ((await fs.typeSupport(racine)) === 'amovible') avertissements.push('SUPPORT_AMOVIBLE');
return { racine, mode, raison, cause, question, autre, avertissements };
}

View file

@ -0,0 +1,459 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la règle du dossier de travail (§ 8.6, § 13.4, § 14.10), sur
// le système de fichiers d'épreuve : estSous, sur une frontière de segment et
// selon la casse du système ; les cinq issues simulées — chemin publié par le
// lanceur portable, exécutable sous un emplacement de données applicatives,
// sonde qui échoue, exécutable non publié, support amovible — et la
// plateforme web ; puis ce qui arrive ensuite : la question quand un dossier
// qui porte des événements refuse l'écriture, la proposition d'import quand
// le dossier retenu est neuf et vide, le compte des événements, et la
// lecture qui échoue : celle du dossier retenu fait rejeter, celle de l'autre
// emplacement laisse un compte inconnu. La perte d'inscriptibilité en cours
// de séance n'est pas de cette règle, qui ne s'applique qu'une fois.
//
// Le relevé des écritures de la règle — primitive et racine, dans l'ordre des
// appels — montre qu'elle n'en fait pas d'autre que ses sondes. Les noms
// d'épreuve sont inventés ; dans les données d'épreuve, un caractère hors de
// l'ASCII s'écrit en échappement.
import assert from 'node:assert/strict';
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
import { determinerDossierTravail, estSous } from './dossier_travail.js';
import { ErreurStockage } from './erreurs.js';
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
const DOC = { id: 'documents', chemin: DOCUMENTS };
const PORTABLE = { id: 'portable', chemin: 'E:\\soirees\\data' };
// Un exécutable publié sous un emplacement de données applicatives, comme
// l'y rangerait un installateur, et son data/.
const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programs\\gtt';
const DATA_SOUS_APPDATA = { id: 'portable', chemin: `${SOUS_APPDATA}\\data` };
const WINDOWS = { insensibleCasse: true, separateur: '\\' };
const UNIX = { insensibleCasse: false, separateur: '/' };
// Réglages du système d'épreuve sous Unix, sensible à la casse.
const SYSTEME_UNIX = {
separateur: '/',
insensibleCasse: false,
documents: '/home/exemple/Documents/Gestion table tournante Libre',
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
};
// Les primitives d'écriture de l'interface (systeme_fichiers.js).
const PRIMITIVES_ECRITURE = [
'ecrireAtomique',
'ajouterLigne',
'creerDossier',
'deplacer',
'supprimer',
'verrouiller',
'deverrouiller',
'sonder',
];
// Un système d'épreuve, et le relevé de ses écritures : [primitive, racine],
// dans l'ordre des appels. Le reste — lectures, pannes, mains de l'épreuve —
// est celui du système simulé.
function preparer(options = {}) {
const simule = creerFichiersSimules(options);
const ecritures = [];
const fs = { ...simule };
for (const nom of PRIMITIVES_ECRITURE) {
fs[nom] = (racine, ...reste) => {
ecritures.push([nom, racine?.id]);
return simule[nom](racine, ...reste);
};
}
return { fs, ecritures };
}
// Le résultat d'une détermination : les champs donnés, les autres à leur
// valeur quand rien n'est à demander, à proposer ni à annoncer.
const dossier = (champs) => ({ raison: null, cause: null, question: null, autre: null, avertissements: [], ...champs });
// Pose un événement sous une base : son état et son journal. La règle compte
// les fichiers sans les lire : leur contenu n'importe pas.
function poserEvenement(fs, racineId, base) {
fs.deposer(racineId, `${base}.gtt.json`, '{}\n');
fs.deposer(racineId, `${base}.gtt-journal.jsonl`, '{}\n');
}
// Rejet de la liste d'un dossier qui n'existe pas.
const absent = (erreur) => erreur instanceof ErreurStockage && erreur.code === 'ABSENT';
// Une lecture que la plateforme refuse. Le contrat ne nomme d'échec de
// lecture que le dossier absent : une ErreurStockage d'un autre code, qui
// porte la cause de la plateforme, en tient lieu.
const lectureRefusee = (racine, cause) => new ErreurStockage('ECRITURE', { chemin: '', dossier: racine.chemin, cause });
// Le système d'épreuve dont lister lève erreur sur une racine, et lit les
// autres comme lui.
function listerEnPanne(fs, racineId, erreur) {
return {
...fs,
async lister(racine, dossier) {
if (racine.id === racineId) throw erreur;
return fs.lister(racine, dossier);
},
};
}
describe('estSous', () => {
test("vrai pour le parent et sous lui, sur une frontière de segment : AppDataBis n'est pas sous AppData", () => {
const appData = 'C:\\Users\\Exemple\\AppData';
const cas = [
['C:\\Users\\Exemple\\AppData\\Local\\Temp\\x', 'C:\\Users\\Exemple\\AppData\\Local', WINDOWS, true],
[appData, appData, WINDOWS, true],
['C:\\Users\\Exemple\\AppDataBis', appData, WINDOWS, false],
['C:\\Users\\Exemple\\AppDataBis\\soirees', appData, WINDOWS, false],
['C:\\Users\\Exemple', appData, WINDOWS, false],
['E:\\soirees', appData, WINDOWS, false],
['/home/exemple/.config/outil', '/home/exemple/.config', UNIX, true],
['/home/exemple/.configBis', '/home/exemple/.config', UNIX, false],
// Sous Unix, la barre oblique inverse est un caractère du nom.
['/tmp\\x', '/tmp', UNIX, false],
];
for (const [chemin, parent, systeme, attendu] of cas) {
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
}
});
test("un séparateur final ne compte pas, et la racine d'un volume porte tout le volume", () => {
const local = 'C:\\Users\\Exemple\\AppData\\Local';
const cas = [
[`${local}\\`, local, WINDOWS, true],
[`${local}\\x`, `${local}\\`, WINDOWS, true],
[`${local}x`, `${local}\\`, WINDOWS, false],
['C:\\soirees', 'C:\\', WINDOWS, true],
['C:\\', 'C:\\', WINDOWS, true],
['D:\\soirees', 'C:\\', WINDOWS, false],
['/tmp/x', '/', UNIX, true],
['/tmp', '/tmp/', UNIX, true],
['/tmpx', '/tmp/', UNIX, false],
];
for (const [chemin, parent, systeme, attendu] of cas) {
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
}
});
test("sans égard à la casse là où le système l'ignore, caractère par caractère comme Windows ; sensible ailleurs", () => {
const cas = [
['c:\\users\\exemple\\appdata\\local\\temp\\x', 'C:\\Users\\Exemple\\AppData\\Local', WINDOWS, true],
['C:\\USERS\\EXEMPLE\\APPDATA\\ROAMING', 'c:\\users\\exemple\\appdata\\roaming', WINDOWS, true],
// Au-delà de l'ASCII : É et é se confondent.
['C:\\Users\\\u{C9}T\u{C9}\\AppData\\Local\\x', 'C:\\Users\\\u{E9}t\u{E9}\\AppData\\Local', WINDOWS, true],
// ß n'a pas de majuscule d'un seul caractère : Windows ne le confond
// pas avec SS, deux noms, deux dossiers.
['C:\\Users\\STRASSE\\x', 'C:\\Users\\stra\u{DF}e', WINDOWS, false],
['/home/exemple/.config/outil', '/home/exemple/.CONFIG', UNIX, false],
['/TMP/x', '/tmp', UNIX, false],
];
for (const [chemin, parent, systeme, attendu] of cas) {
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
}
});
});
describe('determinerDossierTravail : les cinq issues (§ 8.6, § 13.4)', () => {
test("publié sur E:\\soirees, sonde réussie : data/ est le dossier de travail, créé par la sonde, et rien ne s'écrit hors de lui", async () => {
const { fs, ecritures } = preparer({ executable: 'E:\\soirees' });
assert.deepEqual(await determinerDossierTravail(fs), dossier({ racine: PORTABLE, mode: 'portable' }));
assert.deepEqual(ecritures, [['sonder', 'portable']]);
assert.deepEqual(await fs.lister(PORTABLE, ''), []);
await assert.rejects(fs.lister(DOC, ''), absent);
});
test('publié sous AppData\\Local\\Temp : documents, DONNEES_APPLICATIVES, et data/ ni sondé ni créé', async () => {
const executable = 'C:\\Users\\Exemple\\AppData\\Local\\Temp\\x';
const { fs, ecritures } = preparer({ executable });
assert.deepEqual(
await determinerDossierTravail(fs),
dossier({ racine: DOC, mode: 'documents', raison: 'DONNEES_APPLICATIVES' }),
);
assert.deepEqual(ecritures, [['sonder', 'documents']]);
await assert.rejects(fs.lister({ id: 'portable', chemin: `${executable}\\data` }, ''), absent);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test("l'exécutable se compare aux données applicatives selon la casse et le séparateur du système", async () => {
// AppData lui-même, et non ses seuls Roaming et Local : tout ce qu'il
// contient est sous lui, et rien de ce qui prolonge son nom.
const appData = ['C:\\Users\\Exemple\\AppData'];
const cas = [
[{ executable: 'c:\\users\\exemple\\appdata\\roaming\\outil' }, ['documents', 'DONNEES_APPLICATIVES']],
[{ donneesApplicatives: appData, executable: 'C:\\Users\\Exemple\\AppData\\LocalLow\\soirees' }, ['documents', 'DONNEES_APPLICATIVES']],
[{ donneesApplicatives: appData, executable: 'C:\\Users\\Exemple\\AppDataBis\\soirees' }, ['portable', null]],
[{ ...SYSTEME_UNIX, executable: '/tmp/montage-gtt' }, ['documents', 'DONNEES_APPLICATIVES']],
[{ ...SYSTEME_UNIX, executable: '/TMP/soirees' }, ['portable', null]],
[{ ...SYSTEME_UNIX, executable: '/media/cle/soirees' }, ['portable', null]],
];
for (const [options, attendu] of cas) {
const { fs } = preparer(options);
const { mode, raison } = await determinerDossierTravail(fs);
assert.deepEqual([mode, raison], attendu, options.executable);
}
});
test('la sonde de data/ échoue (EACCES) : documents, SONDE_ECHOUEE et sa cause ; data/ ne porte rien, rien à demander', async () => {
const { fs, ecritures } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } });
assert.deepEqual(
await determinerDossierTravail(fs),
dossier({ racine: DOC, mode: 'documents', raison: 'SONDE_ECHOUEE', cause: 'EACCES' }),
);
assert.deepEqual(ecritures, [
['sonder', 'portable'],
['sonder', 'documents'],
]);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('non publié : documents, NON_PUBLIE, et le dossier des Documents créé par sa sonde', async () => {
const { fs, ecritures } = preparer();
assert.deepEqual(await determinerDossierTravail(fs), dossier({ racine: DOC, mode: 'documents', raison: 'NON_PUBLIE' }));
assert.deepEqual(ecritures, [['sonder', 'documents']]);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('support amovible : SUPPORT_AMOVIBLE, pour le seul dossier de travail retenu', async () => {
const cas = [
[{ executable: 'E:\\soirees', support: { portable: 'amovible' } }, ['portable', ['SUPPORT_AMOVIBLE']]],
// data/ sur la clé refuse l'écriture : le dossier retenu est fixe.
[{ executable: 'E:\\soirees', support: { portable: 'amovible' }, sonde: { portable: 'EROFS' } }, ['documents', []]],
[{ support: { documents: 'amovible' } }, ['documents', ['SUPPORT_AMOVIBLE']]],
// Un support inconnu n'est pas annoncé amovible.
[{ executable: 'E:\\soirees', support: { portable: 'inconnu' } }, ['portable', []]],
// Les deux avertissements, dans l'ordre que le résultat promet.
[{ ...SYSTEME_UNIX, nature: 'web', support: { documents: 'amovible' } }, ['web', ['PLATEFORME_WEB', 'SUPPORT_AMOVIBLE']]],
];
for (const [options, attendu] of cas) {
const { fs } = preparer(options);
const { mode, avertissements } = await determinerDossierTravail(fs);
assert.deepEqual([mode, avertissements], attendu, JSON.stringify(options));
}
});
test("web : la racine documents, mode web, PLATEFORME_WEB ; un dossier publié n'y désigne rien", async () => {
const racine = { id: 'documents', chemin: '/gestion_table_tournante_libre' };
const { fs, ecritures } = preparer({
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
separateur: '/',
insensibleCasse: false,
documents: racine.chemin,
executable: '/media/cle/soirees',
});
poserEvenement(fs, 'portable', 'gala-printemps');
assert.deepEqual(
await determinerDossierTravail(fs),
dossier({ racine, mode: 'web', avertissements: ['PLATEFORME_WEB'] }),
);
assert.deepEqual(ecritures, [['sonder', 'documents']]);
});
});
describe('determinerDossierTravail : ce qui arrive ensuite (§ 8.6)', () => {
test("data/ porte deux événements et refuse l'écriture : question avec le compte, et rien ne bascule en silence", async () => {
const { fs, ecritures } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
poserEvenement(fs, 'portable', 'gala-printemps');
poserEvenement(fs, 'portable', 'souper-benefice');
const avant = await fs.lister(PORTABLE, '');
assert.deepEqual(
await determinerDossierTravail(fs),
dossier({
racine: DOC,
mode: 'documents',
raison: 'SONDE_ECHOUEE',
cause: 'EROFS',
question: { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 2, cause: 'EROFS' },
autre: { racine: PORTABLE, evenements: 2 },
}),
);
// Seules les sondes écrivent : data/ reste tel quel, et rien n'en est
// copié dans les Documents avant que l'opérateur ait répondu.
assert.deepEqual(ecritures, [
['sonder', 'portable'],
['sonder', 'documents'],
]);
assert.deepEqual(await fs.lister(PORTABLE, ''), avant);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test("un événement compte par ses deux fichiers, à la racine du dossier : un état, un journal, ou les deux sous la même base", async () => {
const { fs } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
// Trois événements : état et journal, état seul, journal seul.
poserEvenement(fs, 'portable', 'gala');
fs.deposer('portable', 'souper.gtt.json', '{}\n');
fs.deposer('portable', 'tournoi.gtt-journal.jsonl', '{}\n');
// Aucun des suivants n'en est un.
fs.deposer('portable', 'kermesse.gtt.json.precedent', '{}\n');
fs.deposer('portable', 'kermesse.gtt.verrou', '{}\n');
fs.deposer('portable', 'kermesse.gtt.json.ecriture', '{}\n');
fs.deposer('portable', 'reglages_locaux.json', '{}\n');
fs.deposer('portable', 'corbeille/2026-01-02_03-04-05/bazar.gtt.json', '{}\n');
fs.deposerDossier('portable', 'archives.gtt.json');
const { question } = await determinerDossierTravail(fs);
assert.deepEqual(question, { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 3, cause: 'EROFS' });
});
test("data/ qui ne porte que des restes d'événements ne fait rien demander", async () => {
const { fs } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
fs.deposer('portable', 'kermesse.gtt.json.precedent', '{}\n');
fs.deposer('portable', 'corbeille/2026-01-02_03-04-05/bazar.gtt.json', '{}\n');
const { question, raison } = await determinerDossierTravail(fs);
assert.deepEqual([question, raison], [null, 'SONDE_ECHOUEE']);
});
test("le dossier retenu neuf et vide, l'autre emplacement porte des événements : autre, dans les deux sens", async () => {
const versDocuments = preparer({ executable: 'E:\\soirees' });
for (const base of ['gala', 'souper', 'tournoi']) poserEvenement(versDocuments.fs, 'documents', base);
assert.deepEqual(
await determinerDossierTravail(versDocuments.fs),
dossier({ racine: PORTABLE, mode: 'portable', autre: { racine: DOC, evenements: 3 } }),
);
// data/ sous AppData se lit sans se sonder.
const versData = preparer({ executable: SOUS_APPDATA });
poserEvenement(versData.fs, 'portable', 'gala');
assert.deepEqual(
await determinerDossierTravail(versData.fs),
dossier({
racine: DOC,
mode: 'documents',
raison: 'DONNEES_APPLICATIVES',
autre: { racine: DATA_SOUS_APPDATA, evenements: 1 },
}),
);
assert.deepEqual(versData.ecritures, [['sonder', 'documents']]);
});
test("un dossier retenu qui porte la moindre entrée n'est pas neuf, et un emplacement sans événement ne propose rien", async () => {
// data/ a servi : sa corbeille le dit.
const corbeille = preparer({ executable: 'E:\\soirees' });
corbeille.fs.deposerDossier('portable', 'corbeille/2026-01-02_03-04-05');
poserEvenement(corbeille.fs, 'documents', 'gala');
// Les Documents ont servi : leurs réglages locaux le disent.
const reglages = preparer({ executable: SOUS_APPDATA });
reglages.fs.deposer('documents', 'reglages_locaux.json', '{}\n');
poserEvenement(reglages.fs, 'portable', 'gala');
// Les Documents n'ont que des restes d'événements.
const restes = preparer({ executable: 'E:\\soirees' });
restes.fs.deposer('documents', 'gala.gtt.json.precedent', '{}\n');
restes.fs.deposerDossier('documents', 'corbeille');
for (const [nom, { fs }] of [['corbeille', corbeille], ['reglages', reglages], ['restes', restes]]) {
assert.equal((await determinerDossierTravail(fs)).autre, null, nom);
}
});
test("un témoin resté d'une séance précédente ne compte pas : la sonde l'efface avant que la règle lise le dossier", async () => {
const portable = preparer({ executable: 'E:\\soirees' });
portable.fs.deposer('portable', '.gtt-temoin', '.gtt-temoin');
poserEvenement(portable.fs, 'documents', 'gala');
assert.deepEqual((await determinerDossierTravail(portable.fs)).autre, { racine: DOC, evenements: 1 });
assert.equal(portable.fs.contenu('portable', '.gtt-temoin'), null);
const documents = preparer({ executable: SOUS_APPDATA });
documents.fs.deposer('documents', '.gtt-temoin', '.gtt-temoin');
poserEvenement(documents.fs, 'portable', 'gala');
assert.deepEqual((await determinerDossierTravail(documents.fs)).autre, { racine: DATA_SOUS_APPDATA, evenements: 1 });
assert.equal(documents.fs.contenu('documents', '.gtt-temoin'), null);
});
test("la sonde du dossier retenu échoue : DOCUMENTS_NON_INSCRIPTIBLE ; data/ qui porte des événements garde la priorité", async () => {
const nonPublie = preparer({ sonde: { documents: 'EPERM' } });
poserEvenement(nonPublie.fs, 'documents', 'gala');
assert.deepEqual(
await determinerDossierTravail(nonPublie.fs),
dossier({
racine: DOC,
mode: 'documents',
raison: 'NON_PUBLIE',
question: { code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: DOC, evenements: 1, cause: 'EPERM' },
}),
);
const racineWeb = { id: 'documents', chemin: '/gestion_table_tournante_libre' };
const web = preparer({ ...SYSTEME_UNIX, nature: 'web', documents: racineWeb.chemin, sonde: { documents: 'QuotaExceededError' } });
assert.deepEqual(
(await determinerDossierTravail(web.fs)).question,
{ code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: racineWeb, evenements: 0, cause: 'QuotaExceededError' },
);
const lesDeux = { executable: 'E:\\soirees', sonde: { portable: 'EROFS', documents: 'EPERM' } };
const avecEvenements = preparer(lesDeux);
poserEvenement(avecEvenements.fs, 'portable', 'gala');
assert.deepEqual(
(await determinerDossierTravail(avecEvenements.fs)).question,
{ code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 1, cause: 'EROFS' },
);
const sansEvenement = preparer(lesDeux);
assert.deepEqual(
(await determinerDossierTravail(sansEvenement.fs)).question,
{ code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: DOC, evenements: 0, cause: 'EPERM' },
);
});
test("la lecture du dossier retenu qui échoue autrement que sur un dossier absent remonte : une panne n'est pas un dossier vide", async () => {
// Le processus meurt après la sonde de data/, sa seule écriture.
const coupe = preparer({ executable: 'E:\\soirees' });
coupe.fs.pannes.couperApres(1);
await assert.rejects(determinerDossierTravail(coupe.fs), PanneSimulee);
// data/ retenu, puis les Documents retenus, refusent leur lecture.
const cas = [
[{ executable: 'E:\\soirees' }, PORTABLE],
[{ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } }, DOC],
];
for (const [options, racine] of cas) {
const refus = lectureRefusee(racine, 'EIO');
const { fs } = preparer(options);
await assert.rejects(determinerDossierTravail(listerEnPanne(fs, racine.id, refus)), (erreur) => erreur === refus, racine.id);
}
});
test("la lecture de l'autre emplacement qui échoue laisse le dossier retenu : rien ne se propose, et data/ qui refuse l'écriture fait demander, compte inconnu", async () => {
// Les Documents, sur un partage injoignable, portent peut-être des
// événements : data/ reste le dossier de travail, sans proposition.
const portable = preparer({ executable: 'E:\\soirees' });
poserEvenement(portable.fs, 'documents', 'gala');
assert.deepEqual(
await determinerDossierTravail(listerEnPanne(portable.fs, 'documents', lectureRefusee(DOC, 'ETIMEDOUT'))),
dossier({ racine: PORTABLE, mode: 'portable' }),
);
// data/ sous AppData ne se lit pas : rien à proposer, rien à demander.
const sousAppData = preparer({ executable: SOUS_APPDATA });
poserEvenement(sousAppData.fs, 'portable', 'gala');
assert.deepEqual(
await determinerDossierTravail(listerEnPanne(sousAppData.fs, 'portable', lectureRefusee(DATA_SOUS_APPDATA, 'EPERM'))),
dossier({ racine: DOC, mode: 'documents', raison: 'DONNEES_APPLICATIVES' }),
);
// data/ refuse l'écriture et la lecture, sous des droits posés par un
// autre poste : ce qu'il porte est inconnu, et la règle demande plutôt
// que de basculer en silence.
const etranger = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EPERM' } });
poserEvenement(etranger.fs, 'portable', 'gala');
assert.deepEqual(
await determinerDossierTravail(listerEnPanne(etranger.fs, 'portable', lectureRefusee(PORTABLE, 'EPERM'))),
dossier({
racine: DOC,
mode: 'documents',
raison: 'SONDE_ECHOUEE',
cause: 'EPERM',
question: { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: null, cause: 'EPERM' },
}),
);
});
test("un chemin refusé, ou une erreur hors du stockage, remonte même de l'autre emplacement : une faute du code n'est pas un état du disque", async () => {
for (const erreur of [new ErreurStockage('CHEMIN_REFUSE', { chemin: '' }), new Error('canal rompu')]) {
const { fs } = preparer({ executable: 'E:\\soirees' });
await assert.rejects(determinerDossierTravail(listerEnPanne(fs, 'documents', erreur)), (recue) => recue === erreur, erreur.message);
}
});
});