[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:
parent
4003b1f833
commit
3b00566560
2 changed files with 685 additions and 0 deletions
226
src/stockage/dossier_travail.js
Normal file
226
src/stockage/dossier_travail.js
Normal 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 };
|
||||
}
|
||||
459
src/stockage/dossier_travail.test.js
Normal file
459
src/stockage/dossier_travail.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue