diff --git a/src/stockage/noms.js b/src/stockage/noms.js
new file mode 100644
index 0000000..c033a2b
--- /dev/null
+++ b/src/stockage/noms.js
@@ -0,0 +1,287 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Les noms des fichiers d'un événement (§ 8.6, § 8.7) : leurs suffixes, la
+// base que le nom de l'événement donne dans un dossier de travail, et le
+// dossier daté de la corbeille. Le module est pur : il ne touche aucun
+// fichier, ne lit ni horloge ni aléa, et ne dépend d'aucune langue — la casse
+// se compare par toUpperCase ou toLowerCase, jamais par une comparaison
+// localisée.
+//
+// Les longueurs se comptent en unités UTF-16, comme MAX_PATH de Windows, et
+// une coupe ne sépare jamais une paire de substitution. Le chemin complet
+// d'un fichier est la racine du dossier de travail, un séparateur, puis un
+// chemin relatif. La borne porte sur le plus long chemin relatif que
+// l'événement écrit : un fichier de la corbeille datée, au suffixe le plus
+// long, que l'écriture atomique prolonge de SUFFIXE_ECRITURE. Une base qui ne
+// tiendrait que pour l'état laisserait déborder le journal, le fichier
+// précédent et leurs fichiers d'écriture.
+import { ErreurStockage } from './erreurs.js';
+
+// Suffixes des fichiers d'un événement, que précède la base de son nom.
+export const SUFFIXES = Object.freeze({
+ etat: '.gtt.json',
+ precedent: '.gtt.json.precedent',
+ journal: '.gtt-journal.jsonl',
+ verrou: '.gtt.verrou',
+});
+
+// Ajouté au nom d'un fichier pendant son écriture atomique, puis renommé
+// par-dessus la cible.
+export const SUFFIXE_ECRITURE = '.ecriture';
+
+// Dossier, à la racine du dossier de travail, où la suppression déplace les
+// fichiers d'un événement, dans un dossier daté.
+export const DOSSIER_CORBEILLE = 'corbeille';
+
+// MAX_PATH de Windows : 260 unités, le NUL final compris.
+export const LONGUEUR_CHEMIN_MAX = 259;
+
+// Un dossier daté de la corbeille se nomme AAAA-MM-JJ_HH-MM-SS, suivi de _n
+// quand ce nom est pris. Le rang s'arrête à RANG_MAX, deux chiffres : la borne
+// du chemin retient ce rang, le plus long que dossierCorbeille rend.
+const MODELE_DATE = 'AAAA-MM-JJ_HH-MM-SS';
+const RANG_MAX = 99;
+const RANG_LE_PLUS_LONG = `_${RANG_MAX}`;
+
+// Ce que le plus long chemin relatif porte hors de la base : le dossier daté
+// de la corbeille au rang le plus long et son séparateur, puis le plus long
+// des suffixes, que l'écriture atomique prolonge de SUFFIXE_ECRITURE. Un
+// suffixe ajouté à SUFFIXES s'ajoute aussi à ce maximum.
+const LONGUEUR_FIXE =
+ `${DOSSIER_CORBEILLE}/${MODELE_DATE}${RANG_LE_PLUS_LONG}/`.length +
+ Math.max(SUFFIXES.etat.length, SUFFIXES.precedent.length, SUFFIXES.journal.length, SUFFIXES.verrou.length) +
+ SUFFIXE_ECRITURE.length;
+
+/**
+ * Longueur, en unités UTF-16, du plus long chemin relatif que l'événement de
+ * base donnée écrit : le fichier d'écriture atomique de l'état précédent,
+ * dans le dossier daté de la corbeille au rang de collision le plus long, soit
+ * corbeille/AAAA-MM-JJ_HH-MM-SS_99/.gtt.json.precedent.ecriture. C'est
+ * lui, et non l'état ou le journal du dossier de travail, qui borne la base
+ * (§ 8.6, § 14.10).
+ *
+ * @param {string} base
+ * @returns {number}
+ */
+export function longueurRelativeMax(base) {
+ return LONGUEUR_FIXE + base.length;
+}
+
+// Le nom que prend l'événement quand il ne reste rien de son nom.
+const NOM_GENERIQUE = 'evenement';
+
+// Les caractères que Windows refuse dans un nom de fichier : < > : " / \ | ? *
+// et les codes U+0000 à U+001F, tabulation et fin de ligne comprises. Ces
+// codes partent déjà avec les caractères de contrôle de INVISIBLES ; la classe
+// les garde pour énoncer la règle de Windows en entier.
+const INTERDITS = /[<>:"\/\\|?*\u0000-\u001f]/g;
+
+// Une suite de blancs : espace, espace insécable, espaces typographiques.
+const BLANCS = /\s+/g;
+
+// Les noms de périphériques que Windows réserve, en minuscules : CON, PRN,
+// AUX, NUL, COM et LPT suivis d'un chiffre ou d'un exposant ¹ ² ³ (U+00B9,
+// U+00B2, U+00B3), CONIN$ et CONOUT$.
+const RESERVES = /^(?:con|prn|aux|nul|(?:com|lpt)[0-9\u00b2\u00b3\u00b9]|conin\$|conout\$)$/;
+
+// Le texte sans les espaces ni les points de ses deux bouts, que Windows
+// retire d'un nom. Un parcours depuis chaque bout, non une expression
+// régulière : une longue suite de points au milieu d'un nom ne coûte qu'une
+// passe.
+function retirerBords(texte) {
+ const estBord = (caractere) => caractere === ' ' || caractere === '.';
+ let debut = 0;
+ let fin = texte.length;
+ while (debut < fin && estBord(texte[debut])) debut += 1;
+ while (fin > debut && estBord(texte[fin - 1])) fin -= 1;
+ return texte.slice(debut, fin);
+}
+
+// Les caractères qu'un nom ne porte pas, parce qu'on ne les voit pas ou qu'ils
+// changent ce qu'on voit : les caractères de contrôle (catégorie Cc), soit
+// U+0000 à U+001F, tabulation et fin de ligne comprises, U+007F et U+0080 à
+// U+009F ; les caractères de format (Cf) — espace, antiliant et liant sans
+// chasse, marques, enchâssements et isolats bidirectionnels, trait d'union
+// conditionnel, gluon de mots, U+FEFF ; les caractères ignorables par défaut
+// (Default_Ignorable_Code_Point), que le rendu laisse vides, dont ceux que la
+// catégorie Cf ne range pas : remplisseurs hangul, lien de graphèmes,
+// sélecteurs de variante ; et les demi-paires de substitution isolées. Sous le
+// drapeau u, l'expression lit le texte par caractère : une paire bien formée
+// en est un seul, hors de U+D800 à U+DFFF, et seule une moitié isolée tombe
+// dans cette plage.
+const INVISIBLES = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\ud800-\udfff]/gu;
+
+// Le nom nettoyé : sans caractère invisible ni demi-paire isolée, en NFC, sans
+// les caractères que Windows refuse, ses blancs réduits à une espace, ses
+// espaces et ses points de bord retirés ; le nom générique quand il n'en reste
+// rien. Un caractère invisible ne se voit pas, ou retourne l'affichage du texte
+// qui le suit : gardé, il donnerait une base invisible, ou qui se lit autrement
+// qu'elle s'écrit. Le système de fichiers écrit une demi-paire isolée en
+// U+FFFD : deux bases qui ne différeraient que par elle désigneraient le même
+// fichier.
+//
+// L'ordre compte. Le retrait des invisibles vient d'abord : entre une lettre et
+// sa marque combinante, un caractère de format empêcherait la composition en
+// NFC ; une demi-paire part avant le caractère refusé qui la séparait de
+// l'autre moitié, et ne s'y recolle pas ; U+FEFF, que \s compte parmi les
+// blancs, part au lieu de devenir une espace, et la tabulation comme la fin de
+// ligne, des contrôles, partent sans en laisser une. La première conversion en
+// NFC précède le retrait des caractères refusés, car un caractère refusé
+// composé avec une marque combinante, comme U+0338, devient un caractère que
+// Windows accepte. La seconde le suit : un caractère refusé qui séparait une
+// lettre de sa marque combinante les laisse se toucher, et sans elle la base
+// ne serait ni en NFC, ni égale à la base qu'on en dérive à son tour. Retirer
+// un caractère refusé peut aussi rapprocher deux blancs, que la réduction fond
+// ensuite.
+function epurer(nom) {
+ const visible = nom.replace(INVISIBLES, '');
+ const composee = visible.normalize('NFC').replace(INTERDITS, '').normalize('NFC');
+ const epure = retirerBords(composee.replace(BLANCS, ' '));
+ return epure === '' ? NOM_GENERIQUE : epure;
+}
+
+// La tête du texte, d'au plus limite unités. La coupe tombe entre deux
+// caractères : une paire de substitution tient tout entière ou n'y est pas.
+function tete(texte, limite) {
+ let fin = 0;
+ for (const caractere of texte) {
+ if (fin + caractere.length > limite) break;
+ fin += caractere.length;
+ }
+ return texte.slice(0, fin);
+}
+
+// La base, avec « _ » ajouté à sa partie réservée. Windows reconnaît un
+// périphérique dans le nom dont la partie avant le premier point, espaces
+// finaux retirés, est un nom réservé : « con.soirée » comme « con ».
+function ecarterReserve(base) {
+ const point = base.indexOf('.');
+ const partie = (point === -1 ? base : base.slice(0, point)).trimEnd();
+ return RESERVES.test(partie.toLowerCase()) ? `${partie}_${base.slice(partie.length)}` : base;
+}
+
+// La plus longue tête de brut dont la base, une fois ses espaces et points
+// finaux retirés et son nom réservé écarté, tient en limite unités ; null
+// quand aucune ne tient. Le « _ » d'un nom réservé compte dans la longueur :
+// une coupe qui laisse « con » dans un nom plus long donne « co » quand
+// « con_ » ne tient pas, et jamais un nom réservé.
+function tenir(brut, limite) {
+ for (let taille = limite; taille > 0; taille -= 1) {
+ const base = ecarterReserve(retirerBords(tete(brut, taille)));
+ if (base !== '' && base.length <= limite) return base;
+ }
+ return null;
+}
+
+// Clé de comparaison de deux bases : la majuscule de leur NFC. Windows compare
+// deux noms de fichier par leur majuscule, un caractère après l'autre et sans
+// regarder le contexte ; toLowerCase en diffère, car il écrit « ς » le Σ qui
+// finit un mot et « σ » les autres, si bien que « ΣΑΣ » et « σασ » auraient
+// deux clés pour un seul fichier. La NFC réunit les deux écritures d'un même
+// nom, qui se lisent pareil à l'écran. Une majuscule de plusieurs lettres (ß
+// donne SS) réunit des noms que Windows distingue : un rang de plus, jamais un
+// fichier écrasé.
+const cle = (base) => base.normalize('NFC').toUpperCase();
+
+/**
+ * Base de nom de fichier d'un nom d'événement dans un dossier de travail
+ * (§ 8.6) : ce que précèdent les suffixes de SUFFIXES. Les caractères
+ * invisibles — de contrôle (catégorie Cc), de format (Cf), ignorables par
+ * défaut — et les demi-paires de substitution isolées sont retirés d'abord ;
+ * le nom passe en NFC ; les caractères que Windows refuse sont retirés, puis le
+ * nom repasse en NFC, car ce retrait peut rapprocher une lettre de sa marque
+ * combinante ; les blancs consécutifs se réduisent à une espace ; les espaces
+ * et les points des deux bouts sont retirés ; quand il ne reste rien, la base
+ * est « evenement », jamais une base invisible. Un nom réservé
+ * — CON, PRN, AUX, NUL, COM0 à COM9, COM¹ à COM³, LPT0 à LPT9, LPT¹ à LPT³,
+ * CONIN$, CONOUT$ — comparé sans égard à la casse sur la partie avant le
+ * premier point reçoit « _ » à cette partie. La base se tronque, sans couper
+ * une paire de substitution, espaces et points finaux retirés, jusqu'à ce que
+ * racine + séparateur + longueurRelativeMax(base) ne dépasse pas
+ * LONGUEUR_CHEMIN_MAX. Une base qui heurte l'une des existantes, comparées par
+ * la majuscule de leur NFC comme Windows compare les noms de fichier, prend
+ * « (2) », « (3) »… après une espace ; la base se raccourcit alors pour que ce
+ * rang tienne dans la borne.
+ * Une racine qui finit par le séparateur ne compte pas un séparateur de plus.
+ *
+ * @param {string} nom le nom de l'événement, qui reste l'autorité (§ 8.6)
+ * @param {Object} contexte
+ * @param {string} contexte.racine chemin du dossier de travail
+ * @param {'\\'|'/'} contexte.separateur celui du système de fichiers
+ * @param {Iterable} contexte.existantes bases déjà présentes dans le
+ * dossier de travail
+ * @returns {string}
+ * @throws {ErreurStockage} CHEMIN_TROP_LONG {racine} : aucune base tirée du
+ * nom ne tient sous la borne, ou plus aucun rang
+ */
+export function deriverBase(nom, { racine, separateur, existantes }) {
+ const brut = epurer(nom);
+ const unitesRacine = racine.endsWith(separateur) ? racine.length - 1 : racine.length;
+ const libres = LONGUEUR_CHEMIN_MAX - (unitesRacine + 1) - longueurRelativeMax('');
+ const prises = new Set(Array.from(existantes, cle));
+ // La base de rang donné : brut tel quel au rang 1, puis suivi de « (n) », le
+ // rang prenant sa place dans la borne. Un rang qui ne tient plus ne tiendra
+ // pas davantage au rang suivant.
+ const proposer = (rang) => {
+ const suffixe = rang === 1 ? '' : ` (${rang})`;
+ const base = tenir(brut, libres - suffixe.length);
+ if (base === null) throw new ErreurStockage('CHEMIN_TROP_LONG', { racine });
+ return base + suffixe;
+ };
+ let rang = 1;
+ let candidat = proposer(rang);
+ while (prises.has(cle(candidat))) {
+ rang += 1;
+ candidat = proposer(rang);
+ }
+ return candidat;
+}
+
+// Un horodatage de l'horloge de l'application : AAAA-MM-JJTHH:MM:SS±HH:MM. Le
+// chemin du dossier ne retient que les chiffres du jour et de l'heure.
+const HORODATAGE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})[+-]\d{2}:\d{2}$/;
+
+const PREFIXE_CORBEILLE = `${DOSSIER_CORBEILLE}/`;
+
+/**
+ * Chemin relatif du dossier de corbeille d'un horodatage (§ 8.7) :
+ * corbeille/AAAA-MM-JJ_HH-MM-SS, auquel _2, _3… s'ajoute tant que ce nom est
+ * pris, le premier rang libre l'emportant. La date et l'heure sont gardées
+ * telles qu'écrites, sans le décalage ; les deux-points, que Windows refuse,
+ * deviennent des traits d'union. Le rang s'arrête à 99 : longueurRelativeMax
+ * en réserve deux chiffres, et au rang 100 le plus long chemin d'un événement
+ * dont la base remplit la borne dépasserait LONGUEUR_CHEMIN_MAX.
+ *
+ * @param {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
+ * @param {Iterable} existants entrées déjà présentes de la
+ * corbeille, par leur nom ou par leur chemin relatif
+ * @returns {string}
+ * @throws {TypeError} horodatage d'une autre forme : le chemin ne porte que
+ * des chiffres
+ * @throws {ErreurStockage} CORBEILLE_SATUREE {dossier} : le nom et ses rangs 2
+ * à 99 sont pris ; dossier est le chemin relatif du nom sans rang
+ */
+export function dossierCorbeille(horodatage, existants) {
+ const lu = typeof horodatage === 'string' ? HORODATAGE.exec(horodatage) : null;
+ if (lu === null) {
+ throw new TypeError(`horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(horodatage)}`);
+ }
+ const [, jour, heures, minutes, secondes] = lu;
+ const nom = `${jour}_${heures}-${minutes}-${secondes}`;
+ const pris = new Set(
+ Array.from(existants, (entree) =>
+ entree.startsWith(PREFIXE_CORBEILLE) ? entree.slice(PREFIXE_CORBEILLE.length) : entree,
+ ),
+ );
+ let rang = 1;
+ let candidat = nom;
+ while (pris.has(candidat)) {
+ rang += 1;
+ candidat = `${nom}_${rang}`;
+ }
+ if (rang > RANG_MAX) {
+ throw new ErreurStockage('CORBEILLE_SATUREE', { dossier: `${PREFIXE_CORBEILLE}${nom}` });
+ }
+ return `${PREFIXE_CORBEILLE}${candidat}`;
+}
diff --git a/src/stockage/noms.test.js b/src/stockage/noms.test.js
new file mode 100644
index 0000000..b82b0d3
--- /dev/null
+++ b/src/stockage/noms.test.js
@@ -0,0 +1,1030 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Épreuves des noms de fichiers d'un événement (§ 8.6, § 8.7, § 14.10) : les
+// valeurs du contrat, la longueur du plus long chemin relatif qu'un événement
+// écrit, la dérivation de la base d'un nom — caractères invisibles et
+// demi-paires isolées, noms réservés, caractères que Windows refuse, composition
+// en NFC, collisions comparées par la majuscule, borne du chemin complet — et
+// le dossier daté de la corbeille, jusqu'à son dernier rang. Chaque épreuve de
+// dérivation se joue sur les deux dossiers de travail possibles du § 8.6.
+//
+// La borne s'éprouve contre un oracle écrit ici : chaque chemin qu'un
+// événement écrit se recompose en entier, de la racine au suffixe, avec des
+// textes littéraux et non les constantes du module. Une borne prise sur le seul
+// état, ou sur le seul journal, y dépasse 259 unités. Les noms d'épreuve sont
+// inventés. Un caractère invisible ou combinant s'écrit en échappement, jamais
+// en clair : ce qu'on ne voit pas ne se relit pas.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../test/lanceur.js';
+import { ErreurStockage } from './erreurs.js';
+import {
+ DOSSIER_CORBEILLE,
+ LONGUEUR_CHEMIN_MAX,
+ SUFFIXES,
+ SUFFIXE_ECRITURE,
+ deriverBase,
+ dossierCorbeille,
+ longueurRelativeMax,
+} from './noms.js';
+
+// Les deux dossiers de travail possibles (§ 8.6) : le dossier du produit dans
+// les Documents, et data/ à côté d'un exécutable posé sur un support amovible.
+const DOSSIERS = [
+ { racine: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre', separateur: '\\' },
+ { racine: 'E:\\soirees\\data', separateur: '\\' },
+];
+
+// Le dossier daté de la corbeille au rang de collision le plus long, à deux
+// chiffres, et les suffixes des fichiers d'un événement : le journal en compte
+// 18, l'état 9.
+const CORBEILLE_RANG_99 = 'corbeille/2026-10-06_19-02-11_99';
+const SUFFIXES_ECRITS = ['.gtt.json', '.gtt.json.precedent', '.gtt-journal.jsonl', '.gtt.verrou'];
+
+// Le caractère de substitution haute d'une paire, et celui de substitution basse.
+const estHaute = (unite) => unite >= 0xd800 && unite <= 0xdbff;
+const estBasse = (unite) => unite >= 0xdc00 && unite <= 0xdfff;
+
+// n lettres de l'alphabet, répété : ni espace ni point, et chaque position
+// d'une coupe se reconnaît.
+const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
+const lettres = (n) => Array.from({ length: n }, (_, i) => ALPHABET[i % ALPHABET.length]).join('');
+
+// Plus long chemin de la liste ; le premier en cas d'égalité.
+const plusLong = (chemins) => chemins.reduce((a, b) => (b.length > a.length ? b : a));
+
+// Chaque chemin relatif qu'un événement de base donnée écrit : un fichier de
+// chaque suffixe et son fichier d'écriture atomique, dans le dossier de travail
+// puis dans le dossier daté de la corbeille.
+function cheminsRelatifs(base) {
+ const noms = SUFFIXES_ECRITS.flatMap((suffixe) => [`${base}${suffixe}`, `${base}${suffixe}.ecriture`]);
+ return [...noms, ...noms.map((nom) => `${CORBEILLE_RANG_99}/${nom}`)];
+}
+
+// Chaque chemin complet : la racine, un seul séparateur, puis le chemin
+// relatif écrit avec ce séparateur.
+function cheminsComplets({ racine, separateur }, base) {
+ const tete = racine.endsWith(separateur) ? racine : racine + separateur;
+ return cheminsRelatifs(base).map((relatif) => tete + relatif.replaceAll('/', separateur));
+}
+
+// Plus grande longueur de base dont le plus long chemin complet tient en 259
+// unités : l'oracle de la borne, qui ne lit que des chemins recomposés.
+function longueurMaxDeBase(dossier) {
+ let n = 0;
+ while (plusLong(cheminsComplets(dossier, 'a'.repeat(n + 1))).length <= 259) n += 1;
+ return n;
+}
+
+// Le contexte que deriverBase reçoit pour un dossier de travail.
+const contexte = ({ racine, separateur }, existantes = []) => ({ racine, separateur, existantes });
+const derive = (nom, dossier, existantes = []) => deriverBase(nom, contexte(dossier, existantes));
+
+// Une épreuve par dossier de travail : son titre dit lequel.
+function surChaqueDossier(titre, epreuve) {
+ for (const dossier of DOSSIERS) test(`${titre} — ${dossier.racine}`, () => epreuve(dossier));
+}
+
+// L'erreur que lève fonction ; échoue quand elle n'en lève aucune.
+function erreurDe(fonction) {
+ try {
+ fonction();
+ } catch (erreur) {
+ return erreur;
+ }
+ return assert.fail('aucune erreur levée');
+}
+
+// Vrai quand fonction lève CHEMIN_TROP_LONG, qui nomme la racine donnée.
+function refuseTropLong(fonction, racine) {
+ const erreur = erreurDe(fonction);
+ assert.ok(erreur instanceof ErreurStockage, `${erreur?.name} au lieu d'une ErreurStockage`);
+ assert.equal(erreur.code, 'CHEMIN_TROP_LONG');
+ assert.deepEqual(erreur.details, { racine });
+}
+
+// Un dossier de travail dont la racine compte n unités, lecteur compris.
+const dossierDeLongueur = (n) => ({ racine: `C:\\${'x'.repeat(n - 3)}`, separateur: '\\' });
+
+// Les noms que Windows réserve, en capitales : CON, PRN, AUX, NUL, COM et LPT
+// de 0 à 9 et des exposants ¹ ² ³, CONIN$ et CONOUT$.
+const CHIFFRES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '\u00b9', '\u00b2', '\u00b3'];
+const NOMS_RESERVES = [
+ 'CON', 'PRN', 'AUX', 'NUL',
+ ...CHIFFRES.map((chiffre) => `COM${chiffre}`),
+ ...CHIFFRES.map((chiffre) => `LPT${chiffre}`),
+ 'CONIN$', 'CONOUT$',
+];
+const MINUSCULES_RESERVEES = new Set(NOMS_RESERVES.map((nom) => nom.toLowerCase()));
+
+// Vrai quand Windows verrait un périphérique : la partie avant le premier
+// point, espaces finaux retirés, est un nom réservé.
+const estReserve = (base) => MINUSCULES_RESERVEES.has(base.split('.')[0].trimEnd().toLowerCase());
+
+// Clé de comparaison de deux bases : la majuscule de leur NFC, comme Windows
+// compare deux noms de fichier, caractère par caractère et sans contexte.
+const cle = (base) => base.normalize('NFC').toUpperCase();
+
+// Les caractères qu'aucun œil ne voit : de contrôle (Cc), de format (Cf) et
+// ignorables par défaut.
+const INVISIBLE = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
+
+// Un caractère par son point de code, U+XXXX ; un texte par ceux de tous ses
+// caractères, séparés d'une espace : la seule façon de lire un écart de
+// composition, où une lettre et sa marque combinante se confondent à l'écran.
+const pointDeCode = (caractere) => `U+${caractere.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
+const pointsDeCode = (texte) => Array.from(texte, pointDeCode).join(' ');
+
+// Un texte où chaque caractère invisible, et chaque demi-paire de substitution
+// isolée, s'écrit ⟨U+XXXX⟩ : la seule façon de lire un écart qu'on ne voit pas.
+const visible = (texte) =>
+ texte.replace(/[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\ud800-\udfff]/gu, (caractere) => `⟨${pointDeCode(caractere)}⟩`);
+
+describe('noms.js : valeurs du contrat', () => {
+ test('SUFFIXES, SUFFIXE_ECRITURE, DOSSIER_CORBEILLE et LONGUEUR_CHEMIN_MAX valent le contrat, et SUFFIXES ne se modifie pas', () => {
+ // Seule épreuve qui écrit ces valeurs en clair : les autres recomposent
+ // les chemins avec des textes littéraux.
+ assert.deepEqual(SUFFIXES, {
+ etat: '.gtt.json',
+ precedent: '.gtt.json.precedent',
+ journal: '.gtt-journal.jsonl',
+ verrou: '.gtt.verrou',
+ });
+ assert.ok(Object.isFrozen(SUFFIXES));
+ assert.equal(SUFFIXE_ECRITURE, '.ecriture');
+ assert.equal(DOSSIER_CORBEILLE, 'corbeille');
+ assert.equal(LONGUEUR_CHEMIN_MAX, 259);
+ });
+
+ test("longueurs des suffixes : l'état 9, le journal 18, l'état précédent 19, et 28 avec le suffixe d'écriture", () => {
+ assert.deepEqual(
+ [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou].map((suffixe) => suffixe.length),
+ [9, 19, 18, 11],
+ );
+ assert.equal(SUFFIXE_ECRITURE.length, 9);
+ assert.equal(`${SUFFIXES.precedent}${SUFFIXE_ECRITURE}`.length, 28);
+ assert.equal(`${SUFFIXES.journal}${SUFFIXE_ECRITURE}`.length, 27);
+ });
+});
+
+describe("longueurRelativeMax : le plus long chemin relatif qu'un événement écrit", () => {
+ test("elle vaut la longueur du plus long chemin recomposé : l'état précédent en écriture atomique, dans la corbeille au rang 99", () => {
+ const bases = ['', 'a', 'Gala', lettres(100), lettres(197)];
+ assert.ok(bases.length > 0, 'aucune base examinée');
+ for (const base of bases) {
+ const plus = plusLong(cheminsRelatifs(base));
+ assert.equal(plus, `${CORBEILLE_RANG_99}/${base}.gtt.json.precedent.ecriture`);
+ assert.equal(longueurRelativeMax(base), plus.length, `base de ${base.length} unités`);
+ }
+ // La partie qui ne dépend pas de la base : la corbeille datée, son
+ // séparateur, et le suffixe le plus long.
+ assert.equal(longueurRelativeMax(''), 33 + 28);
+ });
+
+ test("le rang le plus long que rend dossierCorbeille est celui que la longueur retient : deux chiffres, au rang 99", () => {
+ const horodatage = '2026-10-06T19:02:11-04:00';
+ const premier = dossierCorbeille(horodatage, []);
+ // Les rangs 2 à 98 sont pris : le suivant est le 99.
+ const existants = [premier, ...Array.from({ length: 97 }, (_, i) => `${premier}_${i + 2}`)];
+ assert.equal(existants.length, 98);
+ const dernier = dossierCorbeille(horodatage, existants);
+ assert.equal(dernier, CORBEILLE_RANG_99);
+ assert.equal(longueurRelativeMax('Gala'), `${dernier}/Gala.gtt.json.precedent.ecriture`.length);
+ });
+});
+
+describe('deriverBase : les deux dossiers de travail', () => {
+ test("leur racine compte 56 et 15 unités, et la borne laisse 141 et 182 unités à la base", () => {
+ assert.deepEqual(DOSSIERS.map(({ racine }) => racine.length), [56, 15]);
+ // 259 − racine − 1 séparateur − 61 de chemin relatif hors base.
+ assert.deepEqual(DOSSIERS.map(longueurMaxDeBase), [259 - 56 - 1 - 61, 259 - 15 - 1 - 61]);
+ assert.deepEqual(DOSSIERS.map(longueurMaxDeBase), [141, 182]);
+ // Le module laisse ces longueurs à la base d'un nom qui les dépasse.
+ assert.deepEqual(DOSSIERS.map((dossier) => derive(lettres(400), dossier).length), [141, 182]);
+ });
+});
+
+describe('deriverBase : les caractères de format et les demi-paires isolées', () => {
+ surChaqueDossier('un nom fait de seuls caractères de format ou de demi-paires isolées donne evenement, jamais une base invisible', (dossier) => {
+ const invisibles = [
+ '\u200b',
+ '\u200b\u200c\u200d\u2060\ufeff\u00ad',
+ '\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069',
+ '\ud800',
+ '\udfff',
+ '\ude00\ud83d',
+ '\u{e0041}\u{e007f}',
+ ' \u200b . \u200b ',
+ ];
+ assert.ok(invisibles.length > 0, 'aucun nom examiné');
+ assert.deepEqual(invisibles.map((nom) => derive(nom, dossier)), invisibles.map(() => 'evenement'));
+ });
+
+ surChaqueDossier('chaque caractère de format, et chaque demi-paire isolée, est retiré, non remplacé', (dossier) => {
+ // Espace, antiliant et liant sans chasse ; marques, enchâssements, forçages
+ // et isolats bidirectionnels ; trait d'union conditionnel ; gluon de mots ;
+ // U+FEFF ; marque de lettre arabe ; une étiquette, hors du plan de base.
+ const formats = [
+ '\u200b', '\u200c', '\u200d', '\u200e', '\u200f',
+ '\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
+ '\u2066', '\u2067', '\u2068', '\u2069',
+ '\u00ad', '\u2060', '\ufeff', '\u061c', '\u{e0041}',
+ ];
+ const demiPaires = ['\ud800', '\udbff', '\udc00', '\udfff'];
+ assert.equal(formats.length, 19);
+ assert.equal(demiPaires.length, 4);
+ const ecarts = [...formats, ...demiPaires]
+ .map((caractere) => [caractere, derive(`Ga${caractere}la`, dossier)])
+ .filter(([, base]) => base !== 'Gala')
+ .map(([caractere, base]) => `U+${caractere.codePointAt(0).toString(16).padStart(4, '0')} donne ${JSON.stringify(base)}`);
+ assert.deepEqual(ecarts, []);
+ assert.equal(derive('\u200eGala\u200e', dossier), 'Gala');
+ assert.equal(derive('\ufeffGala\ufeff', dossier), 'Gala');
+ // U+FEFF, que \s compte parmi les blancs, part sans laisser d'espace.
+ assert.equal(derive('Ga\ufeffla', dossier), 'Gala');
+ // Un forçage de droite à gauche part : la fin du nom s'affiche comme elle s'écrit.
+ assert.equal(derive('Gala\u202eslx.exe', dossier), 'Galaslx.exe');
+ // Une paire bien formée, emoji ou idéogramme hors du plan de base, reste.
+ assert.equal(derive('Gala\u{1F600}', dossier), 'Gala\u{1F600}');
+ assert.equal(derive('\u{20000}', dossier), '\u{20000}');
+ });
+
+ surChaqueDossier('le retrait précède toutes les autres règles : composition, caractères refusés, blancs, bords, noms réservés', (dossier) => {
+ const cas = [
+ // Entre une lettre et sa marque combinante, un caractère de format
+ // empêcherait la composition : la base sort en NFC.
+ ['Soire\u200b\u0301e', 'Soir\u00e9e'],
+ ['a<\u200b\u0338b', 'a\u226eb'],
+ // Deux moitiés que sépare un caractère refusé ne se recollent pas.
+ ['Ga\ud83d<\ude00la', 'Gala'],
+ // Retirés d'abord, ils ne séparent ni deux blancs ni un bord.
+ ['Gala \u200b du', 'Gala du'],
+ ['.\u200b.Gala.\u200b.', 'Gala'],
+ [' \u2060 Gala \u2060 ', 'Gala'],
+ // Un nom réservé qu'ils masquent se reconnaît.
+ ['C\u200bON', 'CON_'],
+ ['con\u200e.soir\u00e9e', 'con_.soir\u00e9e'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom]) => derive(nom, dossier)),
+ cas.map(([, attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier('un nom à caractère de format ou à demi-paire isolée heurte le même nom sans eux', (dossier) => {
+ assert.equal(derive('Gala\u200b', dossier, ['gala']), 'Gala (2)');
+ assert.equal(derive('\u200eGALA\u200e', dossier, ['gala', 'Gala (2)']), 'GALA (3)');
+ // Deux noms qui ne diffèrent que par une demi-paire donnent la même base :
+ // le second prend un rang au lieu d'écrire le même fichier.
+ const premier = derive('Gala\ud800', dossier);
+ assert.equal(premier, 'Gala');
+ assert.equal(derive('Gala\udbff', dossier, [premier]), 'Gala (2)');
+ });
+});
+
+describe('deriverBase : les caractères de contrôle et les ignorables par défaut', () => {
+ // Les trois noms que ni les caractères refusés par Windows ni \s ne couvrent :
+ // un contrôle de C1, un remplisseur hangul et un sélecteur de variante. Gardés,
+ // chacun donnerait une base qui ne se voit pas.
+ surChaqueDossier('un nom fait du seul U+0085, contrôle de C1, donne evenement', (dossier) => {
+ assert.equal(visible(derive('\u{85}', dossier)), 'evenement');
+ });
+
+ surChaqueDossier('un nom fait du seul U+3164, remplisseur hangul qui se dessine en blanc, donne evenement', (dossier) => {
+ assert.equal(visible(derive('\u{3164}', dossier)), 'evenement');
+ });
+
+ surChaqueDossier('un nom fait du seul U+FE0F, sélecteur de variante, donne evenement', (dossier) => {
+ assert.equal(visible(derive('\u{fe0f}', dossier)), 'evenement');
+ });
+
+ surChaqueDossier('un nom fait de seuls contrôles ou ignorables par défaut, mêlés ou non de blancs et de points, donne evenement', (dossier) => {
+ const invisibles = [
+ // DEL, puis le premier et le dernier contrôle de C1.
+ '\u{7f}',
+ '\u{80}\u{9f}',
+ '\u{85}\u{85}\u{85}',
+ // Lien de graphèmes, remplisseurs hangul, voyelles inhérentes khmères.
+ '\u{34f}',
+ '\u{115f}\u{1160}\u{3164}\u{ffa0}',
+ '\u{17b4}\u{17b5}',
+ // Sélecteurs de variante : mongols, de base, du supplément.
+ '\u{180b}\u{180c}\u{180d}\u{180f}',
+ '\u{fe00}\u{fe0f}',
+ '\u{e0100}\u{e01ef}',
+ // Ignorables par défaut que rien n'assigne.
+ '\u{2065}\u{fff0}\u{fff8}',
+ // De format sans être ignorables par défaut : signe de nombre arabe, marque de fin d'ayah.
+ '\u{600}\u{6dd}',
+ ' \u{3164} . \u{85} ',
+ ];
+ assert.ok(invisibles.length > 0, 'aucun nom examiné');
+ const ecarts = invisibles
+ .map((nom) => [nom, derive(nom, dossier)])
+ .filter(([, base]) => base !== 'evenement')
+ .map(([nom, base]) => `${visible(nom)} donne ${visible(base)}`);
+ assert.deepEqual(ecarts, []);
+ });
+
+ surChaqueDossier('chaque caractère de contrôle, de format ou ignorable par défaut est retiré, non remplacé', (dossier) => {
+ // Les membres de la classe se lisent au moteur : aucun n'est oublié, quelle
+ // que soit la version d'Unicode qu'il embarque.
+ const membres = [];
+ for (let code = 0; code <= 0x10ffff; code += 1) {
+ if (code >= 0xd800 && code <= 0xdfff) continue;
+ const caractere = String.fromCodePoint(code);
+ if (INVISIBLE.test(caractere)) membres.push(caractere);
+ }
+ assert.ok(membres.length > 4000, `${membres.length} caractères examinés`);
+ const ecarts = membres
+ .map((caractere) => [caractere, derive(`Ga${caractere}la`, dossier)])
+ .filter(([, base]) => base !== 'Gala')
+ .map(([caractere, base]) => `${visible(caractere)} donne ${visible(base)}`);
+ assert.deepEqual(ecarts, []);
+ });
+
+ surChaqueDossier('une tabulation et une fin de ligne sont retirées, non remplacées par une espace', (dossier) => {
+ const cas = [
+ ['Ga\tla', 'Gala'],
+ ['Ga\nla', 'Gala'],
+ ['Ga\r\nla', 'Gala'],
+ ['Gala\tdu\nsoir', 'Galadusoir'],
+ // L'espace, elle, reste.
+ ['Gala du\tsoir', 'Gala dusoir'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom]) => derive(nom, dossier)),
+ cas.map(([, attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier("le retrait précède les autres règles : un contrôle ou un ignorable ne masque ni un nom réservé, ni une collision, ni un bord", (dossier) => {
+ const cas = [
+ ['C\u{3164}ON', [], 'CON_'],
+ ['con\u{85}.soir\u{e9}e', [], 'con_.soir\u{e9}e'],
+ ['Gala\u{3164}', ['gala'], 'Gala (2)'],
+ ['\u{fe0f}Gala\u{fe0f}', [], 'Gala'],
+ ['. \u{85} Gala \u{3164} .', [], 'Gala'],
+ ['Ga\u{85}la', [], 'Gala'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom, existantes]) => visible(derive(nom, dossier, existantes))),
+ cas.map(([, , attendu]) => attendu),
+ );
+ });
+});
+
+describe('deriverBase : les noms réservés (§ 8.6)', () => {
+ surChaqueDossier('con et CON reçoivent « _ », con.soirée aussi, Com1 de même, et console reste', (dossier) => {
+ const cas = [
+ ['con', 'con_'],
+ ['CON', 'CON_'],
+ ['con.soirée', 'con_.soirée'],
+ ['Com1', 'Com1_'],
+ ['console', 'console'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom]) => derive(nom, dossier)),
+ cas.map(([, attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier('chacun des noms réservés, en minuscules, en capitales et capitalisé, reçoit « _ »', (dossier) => {
+ const casses = (nom) => [nom.toLowerCase(), nom, nom[0] + nom.slice(1).toLowerCase()];
+ const cas = NOMS_RESERVES.flatMap(casses);
+ assert.equal(NOMS_RESERVES.length, 4 + 13 + 13 + 2);
+ assert.equal(cas.length, NOMS_RESERVES.length * 3);
+ const ecarts = cas
+ .map((nom) => [nom, derive(nom, dossier)])
+ .filter(([nom, base]) => base !== `${nom}_`)
+ .map(([nom, base]) => `${JSON.stringify(nom)} donne ${JSON.stringify(base)}`);
+ assert.deepEqual(ecarts, []);
+ });
+
+ surChaqueDossier('seule la partie avant le premier point compte : un nom voisin ou réservé après le point reste', (dossier) => {
+ const intacts = [
+ 'COM10', 'COM', 'COMA', 'LPT', 'LPT10', 'CON1', 'CONIN', 'CONOUT', 'NULL', 'AUXE', 'PRN0',
+ 'console', 'COM\u00b9\u00b9', 'CONIN$$', 'xcon', 'con soirée', 'soirée.con', 'a.nul', 'con_', 'conin$x',
+ ];
+ assert.ok(intacts.length > 0, 'aucun nom examiné');
+ assert.deepEqual(intacts.map((nom) => derive(nom, dossier)), intacts);
+ const reserves = [
+ ['Nul.txt', 'Nul_.txt'],
+ ['aux.c.d', 'aux_.c.d'],
+ ['COM\u00b9.x', 'COM\u00b9_.x'],
+ ['lpt9.log', 'lpt9_.log'],
+ ['conin$.x', 'conin$_.x'],
+ ['Con.Soirée.Finale', 'Con_.Soirée.Finale'],
+ ];
+ assert.ok(reserves.length > 0, 'aucun nom examiné');
+ assert.deepEqual(
+ reserves.map(([nom]) => derive(nom, dossier)),
+ reserves.map(([, attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier('un nom réservé se reconnaît après le nettoyage, et des espaces avant le point ne le défont pas', (dossier) => {
+ const cas = [
+ ['c:o:n', 'con_'],
+ ['CO 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom]) => derive(nom, dossier)),
+ cas.map(([, attendu]) => attendu),
+ );
+ });
+});
+
+describe('deriverBase : ce que Windows refuse (§ 8.6)', () => {
+ surChaqueDossier('«Gala»: 2026/11/14? donne «Gala» 20261114, ... donne evenement, Soirée. donne Soirée', (dossier) => {
+ assert.equal(derive('«Gala»: 2026/11/14?', dossier), '«Gala» 20261114');
+ assert.equal(derive('...', dossier), 'evenement');
+ assert.equal(derive('Soirée.', dossier), 'Soirée');
+ });
+
+ surChaqueDossier('un nom en NFD et son NFC donnent la même base, en NFC', (dossier) => {
+ const nfc = 'Soir\u00e9e';
+ const nfd = 'Soire\u0301e';
+ assert.notEqual(nfd, nfc);
+ assert.equal(nfd.length, 7);
+ assert.equal(derive(nfd, dossier), nfc);
+ assert.equal(derive(nfc, dossier), nfc);
+ assert.equal(derive(nfd, dossier).length, 6);
+ });
+
+ surChaqueDossier("la conversion en NFC précède le retrait : un « < » composé avec U+0338 n'est plus refusé", (dossier) => {
+ assert.equal(derive('a<\u0338b', dossier), 'a\u226eb');
+ assert.equal(derive('a>\u0338b', dossier), 'a\u226fb');
+ assert.equal(derive('a=\u0338b', dossier), 'a\u2260b');
+ // Sans U+0338, le caractère refusé part.
+ assert.equal(derive('a {
+ // Le caractère refusé empêche la première NFC de composer. Une fois retiré,
+ // la lettre et sa marque se touchent, et seule une seconde NFC les compose.
+ const cas = [
+ // « < » entre « a » et U+0301 : Gála.
+ ['Ga<\u{301}la', 'G\u{e1}la'],
+ // Deux marques, à remettre dans l'ordre canonique avant de composer.
+ ['a\u{301}<\u{323}', '\u{1ea1}\u{301}'],
+ ['e:\u{302}\u{301}', '\u{1ebf}'],
+ // Les jamos d'un syllabe hangul : initiale, voyelle, finale.
+ ['\u{1100}<\u{1161}', '\u{ac00}'],
+ ['\u{1100}<\u{1161}<\u{11a8}', '\u{ac01}'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ const bases = cas.map(([nom]) => derive(nom, dossier));
+ assert.deepEqual(bases.map(pointsDeCode), cas.map(([, attendu]) => pointsDeCode(attendu)));
+ assert.deepEqual(bases.filter((base) => base !== base.normalize('NFC')).map(pointsDeCode), []);
+ assert.deepEqual(bases.filter((base) => derive(base, dossier) !== base).map(pointsDeCode), []);
+ // Chacun des caractères refusés agit de même entre « a » et U+0301.
+ const refuses = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
+ assert.equal(refuses.length, 9);
+ const ecarts = refuses
+ .map((refuse) => [refuse, derive(`Ga${refuse}\u{301}la`, dossier)])
+ .filter(([, base]) => base !== 'G\u{e1}la')
+ .map(([refuse, base]) => `${refuse} donne ${pointsDeCode(base)}`);
+ assert.deepEqual(ecarts, []);
+ });
+
+ surChaqueDossier('chaque caractère refusé, et chaque code de U+0000 à U+001F, est retiré, non remplacé', (dossier) => {
+ const refuses = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
+ const controles = Array.from({ length: 32 }, (_, code) => String.fromCharCode(code));
+ assert.equal(refuses.length, 9);
+ assert.equal(controles.length, 32);
+ const ecarts = [...refuses, ...controles]
+ .map((caractere) => [caractere, derive(`Ga${caractere}la`, dossier)])
+ .filter(([, base]) => base !== 'Gala')
+ .map(([caractere, base]) => `U+${caractere.charCodeAt(0).toString(16).padStart(4, '0')} donne ${JSON.stringify(base)}`);
+ assert.deepEqual(ecarts, []);
+ // Une tabulation est un code refusé, non un blanc : retirée, elle ne sépare pas.
+ assert.equal(derive('Ga\tla', dossier), 'Gala');
+ // L'espace, qui n'est pas refusée, reste.
+ assert.equal(derive('Ga la', dossier), 'Ga la');
+ });
+
+ surChaqueDossier('les blancs consécutifs se réduisent à une espace ; les espaces et points des deux bouts se retirent', (dossier) => {
+ const cas = [
+ ['Gala du soir', 'Gala du soir'],
+ ['Gala\u00a0du\u202fsoir\u3000fin', 'Gala du soir fin'],
+ [' Gala ', 'Gala'],
+ ['Gala\u00a0\u00a0:', 'Gala'],
+ ['Ga : la', 'Ga la'],
+ ['. Gala .', 'Gala'],
+ ['..Gala..', 'Gala'],
+ ['Soirée. . ', 'Soirée'],
+ ['Gala. ?', 'Gala'],
+ ['v1.2 finale', 'v1.2 finale'],
+ ['a . b', 'a . b'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom]) => derive(nom, dossier)),
+ cas.map(([, attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier("de très longues suites d'espaces et de points se traitent d'une passe", (dossier) => {
+ // Une suite au milieu du nom, aux deux bouts, ou le nom entier : la
+ // dérivation rend sans attendre, là où un retrait par expression régulière
+ // reprendrait la suite à chacune de ses positions.
+ const points = '.'.repeat(200_000);
+ assert.equal(derive(`a${points}b`, dossier), 'a');
+ assert.equal(derive(`${points}a${points}`, dossier), 'a');
+ assert.equal(derive(`a${' .'.repeat(100_000)}b`, dossier), 'a');
+ assert.equal(derive(' .'.repeat(100_000), dossier), 'evenement');
+ });
+
+ surChaqueDossier('un nom dont il ne reste rien donne evenement : vide, blancs, points, caractères refusés', (dossier) => {
+ const vides = ['', ' ', '.', '...', '. .', '. '.repeat(200), '<>:"/\\|?*', ' ? ', '\u0000', '\u00a0\u3000', '*.*'];
+ assert.ok(vides.length > 0, 'aucun nom examiné');
+ assert.deepEqual(vides.map((nom) => derive(nom, dossier)), vides.map(() => 'evenement'));
+ });
+});
+
+describe('deriverBase : les collisions, sans égard à la casse (§ 8.6)', () => {
+ surChaqueDossier('Gala avec gala donne Gala (2), avec gala et GALA (2) donne Gala (3)', (dossier) => {
+ assert.equal(derive('Gala', dossier, ['gala']), 'Gala (2)');
+ assert.equal(derive('Gala', dossier, ['gala', 'GALA (2)']), 'Gala (3)');
+ assert.equal(derive('Gala', dossier, []), 'Gala');
+ });
+
+ surChaqueDossier("des noms qui donnent la même base prennent (2), (3)… dans l'ordre, au-delà de (9) compris", (dossier) => {
+ const noms = ['Gala', 'gala', 'GALA', 'Gala.', ' Gala ', 'Gala?'];
+ const existantes = [];
+ for (const nom of noms) existantes.push(derive(nom, dossier, existantes));
+ assert.deepEqual(existantes, ['Gala', 'gala (2)', 'GALA (3)', 'Gala (4)', 'Gala (5)', 'Gala (6)']);
+ // Le douzième exemplaire porte un rang à deux chiffres.
+ const pris = ['Gala', ...Array.from({ length: 10 }, (_, i) => `Gala (${i + 2})`)];
+ assert.equal(pris.length, 11);
+ assert.equal(derive('Gala', dossier, pris), 'Gala (12)');
+ // Un rang libre au milieu se prend : (2) est libre, (3) est pris.
+ assert.equal(derive('Gala', dossier, ['gala', 'gala (3)']), 'Gala (2)');
+ });
+
+ surChaqueDossier("la casse des lettres accentuées compte pour rien, et un nom en NFD heurte le même nom en NFC", (dossier) => {
+ assert.equal(derive('\u00e9cole', dossier, ['\u00c9COLE']), '\u00e9cole (2)');
+ assert.equal(derive('Soir\u00e9e', dossier, ['Soire\u0301e']), 'Soir\u00e9e (2)');
+ assert.equal(derive('Soire\u0301e', dossier, ['SOIR\u00c9E']), 'Soir\u00e9e (2)');
+ });
+
+ surChaqueDossier("la casse se compare lettre à lettre, sans contexte : un sigma final et un sigma de milieu de mot désignent la même base", (dossier) => {
+ // Windows compare deux noms par leur majuscule, un caractère après l'autre.
+ // toLowerCase, lui, écrit « ς » le Σ qui finit un mot et « σ » les autres :
+ // « ΣΑΣ2 » et « σασ2 » y auraient deux clés pour un seul fichier.
+ const cas = [
+ // ΣΑΣ2 contre σασ2, dans les deux sens, puis contre σας2.
+ ['\u{3a3}\u{391}\u{3a3}2', ['\u{3c3}\u{3b1}\u{3c3}2'], '\u{3a3}\u{391}\u{3a3}2 (2)'],
+ ['\u{3c3}\u{3b1}\u{3c3}2', ['\u{3a3}\u{391}\u{3a3}2'], '\u{3c3}\u{3b1}\u{3c3}2 (2)'],
+ ['\u{3a3}\u{391}\u{3a3}2', ['\u{3c3}\u{3b1}\u{3c2}2'], '\u{3a3}\u{391}\u{3a3}2 (2)'],
+ // Un Σ qui finit le nom : ΟΔΟΣ contre οδοσ.
+ ['\u{39f}\u{394}\u{39f}\u{3a3}', ['\u{3bf}\u{3b4}\u{3bf}\u{3c3}'], '\u{39f}\u{394}\u{39f}\u{3a3} (2)'],
+ ];
+ assert.ok(cas.length > 0, 'aucun cas examiné');
+ assert.deepEqual(
+ cas.map(([nom, existantes]) => derive(nom, dossier, existantes)),
+ cas.map(([, , attendu]) => attendu),
+ );
+ });
+
+ surChaqueDossier("seule une base entière en heurte une autre, et un ensemble d'existantes vaut une liste", (dossier) => {
+ const voisines = ['Gala du printemps', 'Gal', 'Gala (2)', 'un Gala', 'Gala2', 'evenement'];
+ assert.equal(derive('Gala', dossier, voisines), 'Gala');
+ assert.equal(derive('Gala', dossier, new Set(['gala'])), 'Gala (2)');
+ assert.equal(derive('Gala', dossier, new Set()), 'Gala');
+ // Deux noms sans rien d'utilisable donnent le nom générique, puis son rang 2.
+ assert.equal(derive('???', dossier, ['evenement']), 'evenement (2)');
+ assert.equal(derive('...', dossier, ['EVENEMENT', 'evenement (2)']), 'evenement (3)');
+ });
+
+ surChaqueDossier("la collision d'un nom réservé se joue sur la base protégée : con_ puis con_ (2)", (dossier) => {
+ assert.equal(derive('con', dossier, ['CON_']), 'con_ (2)');
+ assert.equal(derive('con', dossier, ['con']), 'con_');
+ });
+});
+
+describe('deriverBase : la borne du chemin complet (§ 8.6, § 14.10)', () => {
+ surChaqueDossier('un nom de 400 caractères se tronque : le plus long chemin écrit tient en 259 unités, corbeille et écriture comprises', (dossier) => {
+ const nom = lettres(400);
+ const limite = longueurMaxDeBase(dossier);
+ const base = derive(nom, dossier);
+ const chemins = cheminsComplets(dossier, base);
+ const plus = plusLong(chemins);
+ assert.ok(plus.length <= LONGUEUR_CHEMIN_MAX, `le plus long chemin compte ${plus.length} unités : ${plus}`);
+ // La plus longue base qui tient : la tête du nom, au chemin de 259 unités.
+ assert.equal(base, nom.slice(0, limite));
+ assert.equal(plus.length, LONGUEUR_CHEMIN_MAX);
+ assert.ok(plus.endsWith('.gtt.json.precedent.ecriture'));
+ assert.ok(plus.includes(`${dossier.separateur}corbeille${dossier.separateur}`));
+ assert.deepEqual(chemins.filter((chemin) => chemin.length > 259), []);
+ // Une base plus longue d'une unité déborde : la borne n'est pas trop large.
+ assert.ok(plusLong(cheminsComplets(dossier, nom.slice(0, limite + 1))).length > 259);
+ });
+
+ surChaqueDossier("une borne sur un seul suffixe, sans la corbeille ou sans l'écriture atomique, laisserait déborder le plus long chemin ; la base dérivée reste en deçà", (dossier) => {
+ const bornes = [
+ ['sur .gtt.json seul', '.gtt.json'],
+ ['sur .gtt-journal.jsonl seul', '.gtt-journal.jsonl'],
+ ['sur .gtt.json.precedent.ecriture sans la corbeille', '.gtt.json.precedent.ecriture'],
+ ['sur la corbeille et .gtt.json.precedent sans .ecriture', `${CORBEILLE_RANG_99}/.gtt.json.precedent`],
+ ['sur la corbeille et .gtt-journal.jsonl.ecriture', `${CORBEILLE_RANG_99}/.gtt-journal.jsonl.ecriture`],
+ ['sur un rang à un chiffre', 'corbeille/2026-10-06_19-02-11_9/.gtt.json.precedent.ecriture'],
+ ];
+ assert.ok(bornes.length > 0, 'aucune borne examinée');
+ const limite = longueurMaxDeBase(dossier);
+ const racine = dossier.racine.length;
+ const base = derive(lettres(400), dossier);
+ for (const [libelle, relatifSansBase] of bornes) {
+ const fautive = 259 - (racine + 1) - relatifSansBase.length;
+ assert.ok(fautive > limite, `${libelle} : ${fautive} unités, la bonne borne en laisse ${limite}`);
+ assert.ok(
+ plusLong(cheminsComplets(dossier, lettres(fautive))).length > 259,
+ `${libelle} : le plus long chemin tiendrait`,
+ );
+ // Un module qui bornerait ainsi laisserait ces unités à la base.
+ assert.ok(base.length < fautive, `${libelle} : la base dérivée compte ${base.length} unités`);
+ }
+ });
+
+ surChaqueDossier("la coupe d'un nom à espaces garde la tête du nom, espaces et points finaux retirés", (dossier) => {
+ const limite = longueurMaxDeBase(dossier);
+ const phrase = 'Grande soirée des lanternes, rencontre annuelle du cercle des jardiniers. '.repeat(10);
+ const base = derive(phrase, dossier);
+ assert.equal(base, phrase.slice(0, limite).replace(/[ .]+$/, ''));
+ assert.ok(plusLong(cheminsComplets(dossier, base)).length <= 259);
+ // La coupe laisse un point et deux espaces en queue : ils partent.
+ assert.equal(derive(`${'a'.repeat(limite - 3)} . ${'b'.repeat(40)}`, dossier), 'a'.repeat(limite - 3));
+ assert.equal(derive(`${'a'.repeat(limite - 1)}.${'b'.repeat(40)}`, dossier), 'a'.repeat(limite - 1));
+ });
+
+ surChaqueDossier("un emoji en position de coupe n'est pas coupé en deux : il tient tout entier ou il est retiré", (dossier) => {
+ const limite = longueurMaxDeBase(dossier);
+ const emoji = '\u{1F600}';
+ assert.equal(emoji.length, 2);
+ // La paire chevauche la limite : une coupe naïve garderait sa moitié haute seule.
+ const chevauche = `${'a'.repeat(limite - 1)}${emoji}${lettres(40)}`;
+ assert.ok(estHaute(chevauche.charCodeAt(limite - 1)) && estBasse(chevauche.charCodeAt(limite)));
+ assert.equal(derive(chevauche, dossier), 'a'.repeat(limite - 1));
+ // La paire finit à la limite : elle tient.
+ const finit = `${'a'.repeat(limite - 2)}${emoji}${lettres(40)}`;
+ assert.equal(derive(finit, dossier), `${'a'.repeat(limite - 2)}${emoji}`);
+ // Une suite d'emoji tient autant de paires entières que la borne en laisse.
+ const base = derive(emoji.repeat(300), dossier);
+ assert.equal(base, emoji.repeat(Math.floor(limite / 2)));
+ assert.ok(plusLong(cheminsComplets(dossier, base)).length <= 259);
+ });
+
+ surChaqueDossier('une collision se règle dans la borne : la base se raccourcit du rang, les blancs de coupe partent', (dossier) => {
+ const limite = longueurMaxDeBase(dossier);
+ const nom = lettres(400);
+ const base = derive(nom, dossier);
+ const casse = base.toUpperCase();
+ assert.notEqual(casse, base);
+ const deux = derive(nom, dossier, [casse]);
+ assert.equal(deux, `${nom.slice(0, limite - 4)} (2)`);
+ assert.equal(deux.length, limite);
+ const trois = derive(nom, dossier, [casse, deux]);
+ assert.equal(trois, `${nom.slice(0, limite - 4)} (3)`);
+ // Un rang à deux chiffres prend une unité de plus.
+ const pris = [casse, ...Array.from({ length: 8 }, (_, i) => `${nom.slice(0, limite - 4)} (${i + 2})`)];
+ assert.equal(derive(nom, dossier, pris), `${nom.slice(0, limite - 5)} (10)`);
+ for (const resultat of [deux, trois]) {
+ assert.ok(plusLong(cheminsComplets(dossier, resultat)).length <= 259);
+ }
+ // La coupe du rang tombe après une espace : elle part, puis le rang s'ajoute.
+ const aEspace = `${'a'.repeat(limite - 5)} ${'b'.repeat(30)}`;
+ const premier = derive(aEspace, dossier);
+ assert.equal(premier, `${'a'.repeat(limite - 5)} bbbb`);
+ assert.equal(derive(aEspace, dossier, [premier]), `${'a'.repeat(limite - 5)} (2)`);
+ });
+
+ test('une racine de 250 caractères ne laisse tenir aucune base : CHEMIN_TROP_LONG nomme la racine', () => {
+ const dossier = dossierDeLongueur(250);
+ assert.equal(dossier.racine.length, 250);
+ refuseTropLong(() => derive('Gala', dossier), dossier.racine);
+ refuseTropLong(() => derive('...', dossier), dossier.racine);
+ });
+
+ test("la racine la plus longue qui laisse une base d'une unité compte 196 caractères ; à 197, aucune base ne tient", () => {
+ const auPlus = dossierDeLongueur(196);
+ const auDela = dossierDeLongueur(197);
+ assert.equal(longueurMaxDeBase(auPlus), 1);
+ assert.equal(longueurMaxDeBase(auDela), 0);
+ assert.equal(derive('Gala', auPlus), 'G');
+ assert.equal(plusLong(cheminsComplets(auPlus, 'G')).length, 259);
+ refuseTropLong(() => derive('Gala', auDela), auDela.racine);
+ });
+
+ test("à la limite, le rang d'une collision tient dans la borne : G (2) en 5 unités, puis plus aucun rang", () => {
+ const cinq = dossierDeLongueur(192);
+ assert.equal(longueurMaxDeBase(cinq), 5);
+ assert.equal(derive('Gala', cinq), 'Gala');
+ assert.equal(derive('Gala', cinq, ['gala']), 'G (2)');
+ assert.equal(derive('Gala', cinq, ['gala', 'g (2)']), 'G (3)');
+ const neuf = ['gala', ...Array.from({ length: 8 }, (_, i) => `g (${i + 2})`)];
+ assert.equal(derive('Gala', cinq, neuf.slice(0, 8)), 'G (9)');
+ refuseTropLong(() => derive('Gala', cinq, neuf), cinq.racine);
+ // Une base d'une unité, déjà prise : le rang 2 ne tient plus.
+ const une = dossierDeLongueur(196);
+ refuseTropLong(() => derive('Gala', une, ['g']), une.racine);
+ });
+
+ test('la coupe ne laisse jamais un nom réservé : « con_ » tient à quatre unités, « co » à trois', () => {
+ const trois = dossierDeLongueur(194);
+ const quatre = dossierDeLongueur(193);
+ assert.equal(longueurMaxDeBase(trois), 3);
+ assert.equal(longueurMaxDeBase(quatre), 4);
+ const surTrois = [['con', 'co'], ['console', 'co'], ['Con.x', 'Co'], ['aux x', 'au'], ['conin$.y', 'co']];
+ assert.ok(surTrois.length > 0, 'aucun nom examiné');
+ assert.deepEqual(
+ surTrois.map(([nom]) => derive(nom, trois)),
+ surTrois.map(([, attendu]) => attendu),
+ );
+ const surQuatre = [['con', 'con_'], ['console', 'cons'], ['con.x', 'con_'], ['aux x', 'aux_'], ['conin$.y', 'coni']];
+ assert.ok(surQuatre.length > 0, 'aucun nom examiné');
+ assert.deepEqual(
+ surQuatre.map(([nom]) => derive(nom, quatre)),
+ surQuatre.map(([, attendu]) => attendu),
+ );
+ for (const [dossier, noms] of [[trois, surTrois], [quatre, surQuatre]]) {
+ for (const [nom] of noms) {
+ const base = derive(nom, dossier);
+ assert.ok(!estReserve(base), `${nom} donne ${base}`);
+ assert.ok(plusLong(cheminsComplets(dossier, base)).length <= 259);
+ }
+ }
+ });
+
+ test("un nom dont le premier caractère tient en deux unités ne tient pas dans une seule : CHEMIN_TROP_LONG", () => {
+ const une = dossierDeLongueur(196);
+ refuseTropLong(() => derive('\u{1F600}Gala', une), une.racine);
+ // À deux unités, la paire tient.
+ const deux = dossierDeLongueur(195);
+ assert.equal(derive('\u{1F600}Gala', deux), '\u{1F600}');
+ });
+
+ test('la racine se compte en unités UTF-16 comme la base : un emoji y pèse deux unités', () => {
+ const dossier = { racine: `C:\\${'\u{1F600}'.repeat(50)}`, separateur: '\\' };
+ assert.equal(dossier.racine.length, 3 + 2 * 50);
+ assert.equal(Array.from(dossier.racine).length, 3 + 50);
+ const limite = longueurMaxDeBase(dossier);
+ assert.equal(limite, 259 - 103 - 1 - 61);
+ const base = derive(lettres(400), dossier);
+ assert.equal(base, lettres(limite));
+ assert.equal(plusLong(cheminsComplets(dossier, base)).length, 259);
+ });
+
+ test("une racine qui finit par le séparateur ne compte pas un séparateur de plus", () => {
+ const nom = lettres(400);
+ const cas = [
+ { racine: 'E:\\soirees\\data\\', separateur: '\\' },
+ { racine: 'E:\\', separateur: '\\' },
+ { racine: '/home/exemple/soirees/', separateur: '/' },
+ { racine: '/home/exemple/soirees', separateur: '/' },
+ { racine: '/', separateur: '/' },
+ ];
+ assert.ok(cas.length > 0, 'aucun dossier examiné');
+ for (const dossier of cas) {
+ const limite = longueurMaxDeBase(dossier);
+ const base = derive(nom, dossier);
+ assert.equal(base, nom.slice(0, limite), dossier.racine);
+ assert.equal(plusLong(cheminsComplets(dossier, base)).length, 259, dossier.racine);
+ }
+ // Avec ou sans le séparateur final, le même dossier donne la même base.
+ assert.equal(derive(nom, cas[0]), derive(nom, DOSSIERS[1]));
+ assert.equal(derive(nom, cas[2]), derive(nom, cas[3]));
+ // Les valeurs : E:\ laisse 195 unités à la base, « / » 197.
+ assert.deepEqual([cas[1], cas[4]].map(longueurMaxDeBase), [195, 197]);
+ });
+});
+
+// Noms que la dérivation doit traverser sans rien rompre : vides, réservés,
+// refusés, en NFD, à emoji, trop longs, à caractères de contrôle, de format ou
+// ignorables par défaut, à demi-paires isolées, à marque combinante que sépare
+// un caractère refusé, à sigma majuscule, médian ou final.
+const NOMS_HOSTILES = [
+ '', ' ', '.', '...', '. . .', '<>:"/\\|?*', '\u0000\u001f', '\u00a0', '*.*',
+ 'con', 'CON', 'Con.txt', 'nul.', 'aux .x', 'COM1', 'lpt9.log', 'CONIN$', 'conout$.x', 'COM\u00b9', 'console',
+ 'c:o:n', ' con ', 'PRN . . ',
+ '«Gala»: 2026/11/14?', 'Soirée.', 'Soire\u0301e', ' Gala du soir ', 'Gala\u00a0du\u202fsoir',
+ '\u{1F600}', '\u{1F600}'.repeat(300), `${'a'.repeat(139)}\u{1F600}${'b'.repeat(50)}`,
+ `${'a'.repeat(140)}\u{1F600}${'b'.repeat(50)}`, `${'a'.repeat(181)}\u{1F600}${'b'.repeat(50)}`,
+ `${'a'.repeat(180)}\u{1F600}${'b'.repeat(50)}`,
+ lettres(400), '\u00e9'.repeat(400), 'e\u0301'.repeat(200), '. '.repeat(250), `con.${lettres(400)}`,
+ `${'a'.repeat(138)} ${'b'.repeat(60)}`, `${'a'.repeat(140)}.${'b'.repeat(60)}`, 'x'.repeat(141), 'x'.repeat(142),
+ '\u200b', '\u200b'.repeat(300), '\u200eGala\u200e', 'Gala\u200b', 'Ga\ufeffla', 'Ga\u00adla', 'Gala\u202eslx.exe',
+ 'C\u200bON', 'Soire\u200b\u0301e', '\ud800', 'Ga\udc00la', '\ude00\ud83d', 'Ga\ud83d<\ude00la',
+ `${'a'.repeat(140)}\u200b${'b'.repeat(50)}`,
+ // Un contrôle de C1, un remplisseur hangul et un sélecteur de variante, seuls ou dans un nom.
+ '\u{85}', '\u{3164}', '\u{fe0f}', 'Ga\u{3164}la', 'C\u{3164}ON', `${'a'.repeat(140)}\u{3164}${'b'.repeat(50)}`,
+ // Une lettre et sa marque combinante, qu'un caractère refusé sépare : retiré, il les laisse se composer.
+ 'Ga<\u{301}la', 'a\u{301}<\u{323}', 'e:\u{302}\u{301}', '\u{1100}<\u{1161}<\u{11a8}',
+ // Un sigma majuscule, médian et final : trois écritures d'un même nom.
+ '\u{3a3}\u{391}\u{3a3}2', '\u{3c3}\u{3b1}\u{3c3}2', '\u{3c3}\u{3b1}\u{3c2}2',
+];
+
+// Les invariants d'une base dérivée : invariantsRompus rend, en une ligne
+// chacun, ceux que la base viole.
+const INTERDIT = /[<>:"/\\|?*\u0000-\u001f]/;
+const BORD = /^[ .]|[ .]$/;
+const SUBSTITUTION_ISOLEE = /[\ud800-\udbff](?![\udc00-\udfff])|(? 259) rompus.push(`chemin de ${longueur} unités`);
+ return rompus;
+}
+
+describe('deriverBase : les invariants de toute base', () => {
+ // Non vide, en NFC, sans caractère refusé ni de format ni bord, jamais
+ // réservée, sans paire de substitution coupée, le plus long chemin dans la
+ // borne ; et stable : une base dérivée redonne la même.
+ surChaqueDossier('chaque nom hostile donne une base valide, qui se dérive en elle-même', (dossier) => {
+ assert.ok(NOMS_HOSTILES.length >= 40, `${NOMS_HOSTILES.length} noms examinés`);
+ const ecarts = NOMS_HOSTILES.flatMap((nom, rang) => {
+ const base = derive(nom, dossier);
+ const rompus = invariantsRompus(base, dossier);
+ if (derive(base, dossier) !== base) rompus.push('base non stable');
+ return rompus.map((rompu) => `nom ${rang} : ${rompu}`);
+ });
+ assert.deepEqual(ecarts, []);
+ });
+
+ surChaqueDossier('des noms hostiles dérivés à la suite donnent des bases deux à deux distinctes, sans égard à la casse', (dossier) => {
+ const existantes = [];
+ const ecarts = [];
+ for (const [rang, nom] of NOMS_HOSTILES.entries()) {
+ const base = derive(nom, dossier, existantes);
+ if (existantes.some((autre) => cle(autre) === cle(base))) ecarts.push(`nom ${rang} : ${base} déjà pris`);
+ for (const rompu of invariantsRompus(base, dossier)) ecarts.push(`nom ${rang} : ${rompu}`);
+ existantes.push(base);
+ }
+ assert.equal(existantes.length, NOMS_HOSTILES.length);
+ assert.equal(new Set(existantes.map(cle)).size, NOMS_HOSTILES.length);
+ assert.deepEqual(ecarts, []);
+ });
+});
+
+describe('dossierCorbeille (§ 8.7)', () => {
+ test("un horodatage donne corbeille/AAAA-MM-JJ_HH-MM-SS : la date et l'heure telles qu'écrites, sans le décalage", () => {
+ assert.equal(dossierCorbeille('2026-10-06T19:02:11-04:00', []), 'corbeille/2026-10-06_19-02-11');
+ assert.equal(dossierCorbeille('2026-10-06T19:02:11+05:30', []), 'corbeille/2026-10-06_19-02-11');
+ assert.equal(dossierCorbeille('2026-12-31T23:59:59-05:00', []), 'corbeille/2026-12-31_23-59-59');
+ assert.equal(dossierCorbeille('2027-01-01T00:00:00+00:00', []), 'corbeille/2027-01-01_00-00-00');
+ });
+
+ test('un nom déjà présent, seul ou avec le préfixe corbeille/, ajoute le rang 2 ; le premier rang libre est pris', () => {
+ const horodatage = '2026-10-06T19:02:11-04:00';
+ const nom = '2026-10-06_19-02-11';
+ assert.equal(dossierCorbeille(horodatage, [nom]), `corbeille/${nom}_2`);
+ assert.equal(dossierCorbeille(horodatage, [`corbeille/${nom}`]), `corbeille/${nom}_2`);
+ assert.equal(dossierCorbeille(horodatage, [nom, `${nom}_2`]), `corbeille/${nom}_3`);
+ assert.equal(dossierCorbeille(horodatage, [`corbeille/${nom}`, `corbeille/${nom}_2`]), `corbeille/${nom}_3`);
+ // Un trou se comble : le rang 2 est libre quand seuls le premier et le 3 sont pris.
+ assert.equal(dossierCorbeille(horodatage, [nom, `${nom}_3`]), `corbeille/${nom}_2`);
+ // Le rang 10 : un nom et ses rangs 2 à 9.
+ const pris = [nom, ...Array.from({ length: 8 }, (_, i) => `${nom}_${i + 2}`)];
+ assert.equal(dossierCorbeille(horodatage, pris), `corbeille/${nom}_10`);
+ });
+
+ test('le nom et ses rangs 2 à 99 pris, CORBEILLE_SATUREE nomme le dossier daté : le rang 100 dépasserait la borne', () => {
+ const horodatage = '2026-10-06T19:02:11-04:00';
+ const nom = '2026-10-06_19-02-11';
+ const rangs = [nom, ...Array.from({ length: 98 }, (_, i) => `${nom}_${i + 2}`)];
+ assert.equal(rangs.length, 99);
+ assert.equal(rangs.at(-1), `${nom}_99`);
+ for (const existants of [rangs, rangs.map((entree) => `corbeille/${entree}`)]) {
+ const erreur = erreurDe(() => dossierCorbeille(horodatage, existants));
+ assert.ok(erreur instanceof ErreurStockage, `${erreur?.name} au lieu d'une ErreurStockage`);
+ assert.equal(erreur.code, 'CORBEILLE_SATUREE');
+ assert.deepEqual(erreur.details, { dossier: `corbeille/${nom}` });
+ }
+ // Seuls les rangs de cet instant comptent : le rang 99 libre se prend, quel
+ // que soit le nombre d'entrées d'autres instants.
+ const autres = Array.from({ length: 5 }, (_, i) => `2026-10-06_19-02-1${i + 2}`);
+ assert.equal(dossierCorbeille(horodatage, [...rangs.slice(0, 98), ...autres]), `corbeille/${nom}_99`);
+ // Un rang libre plus bas se prend, même quand le rang 99 est pris.
+ assert.equal(dossierCorbeille(horodatage, rangs.filter((entree) => entree !== `${nom}_50`)), `corbeille/${nom}_50`);
+ });
+
+ test("les entrées d'un autre instant, ou d'une autre forme, ne heurtent rien ; une liste figée n'est pas modifiée", () => {
+ const horodatage = '2026-10-06T19:02:11-04:00';
+ const voisines = Object.freeze([
+ '2026-10-06_19-02-12', '2026-10-06_19-02-10', '2026-10-07_19-02-11', '2026-10-06_19-02-11_x',
+ '2026-10-06_19-02-11 (2)', 'autre', 'corbeille', '',
+ ]);
+ assert.equal(dossierCorbeille(horodatage, voisines), 'corbeille/2026-10-06_19-02-11');
+ assert.equal(dossierCorbeille(horodatage, new Set(['2026-10-06_19-02-11'])), 'corbeille/2026-10-06_19-02-11_2');
+ assert.equal(voisines.length, 8);
+ });
+
+ test("un horodatage d'une autre forme que AAAA-MM-JJTHH:MM:SS±HH:MM lève TypeError : le chemin n'en porte que des chiffres", () => {
+ const refuses = [
+ '', '2026-10-06', '2026-10-06 19:02:11-04:00', '2026-10-06T19:02:11', '2026-10-06T19:02:11Z',
+ '2026-10-06T19:02:11-04:00 ', '2026-10-06T19:02:11-04:00\n', ' 2026-10-06T19:02:11-04:00',
+ '2026-10-06T19:02:11-04:00/../..', '../../2026-10-06T19:02:11-04:00', '2026-10-06T19:02:11.5-04:00',
+ '26-10-06T19:02:11-04:00', '2026-10-06T19-02-11-04:00', '2026-10-06T19:02:11-0400', '20261006T190211-0400',
+ '\u0662026-10-06T19:02:11-04:00',
+ ];
+ assert.ok(refuses.length > 0, 'aucun horodatage examiné');
+ const ecarts = refuses
+ .map((horodatage) => [horodatage, erreurDe(() => dossierCorbeille(horodatage, []))])
+ .filter(([, erreur]) => !(erreur instanceof TypeError))
+ .map(([horodatage, erreur]) => `${JSON.stringify(horodatage)} : ${erreur?.name}`);
+ assert.deepEqual(ecarts, []);
+ for (const horodatage of [undefined, null, 20261006, ['2026-10-06T19:02:11-04:00']]) {
+ assert.ok(erreurDe(() => dossierCorbeille(horodatage, [])) instanceof TypeError);
+ }
+ });
+});
+
+// Exécute fonction pendant que l'horloge, les sources d'aléa et les
+// comparaisons localisées lèvent à leur lecture, puis les rétablit telles
+// qu'elles étaient, propres ou héritées ; rend ce que rend fonction.
+function sansSourcesVariables(fonction) {
+ const interdite = (nom) => () => {
+ throw new Error(`${nom} lu`);
+ };
+ const remplacees = [
+ [Math, 'random'],
+ [Date, 'now'],
+ [performance, 'now'],
+ [crypto, 'getRandomValues'],
+ [crypto, 'randomUUID'],
+ [String.prototype, 'localeCompare'],
+ [String.prototype, 'toLocaleLowerCase'],
+ [String.prototype, 'toLocaleUpperCase'],
+ ].map(([objet, nom]) => ({ objet, nom, propre: Object.hasOwn(objet, nom), valeur: objet[nom] }));
+ const DateReelle = globalThis.Date;
+ for (const { objet, nom } of remplacees) objet[nom] = interdite(nom);
+ function DateInterdite() {
+ throw new Error('Date lu');
+ }
+ DateInterdite.now = interdite('Date.now');
+ globalThis.Date = DateInterdite;
+ try {
+ return fonction();
+ } finally {
+ globalThis.Date = DateReelle;
+ for (const { objet, nom, propre, valeur } of remplacees) {
+ if (propre) objet[nom] = valeur;
+ else delete objet[nom];
+ }
+ }
+}
+
+describe('déterminisme et pureté (§ 14.7)', () => {
+ test('longueurRelativeMax, deriverBase et dossierCorbeille ne lisent ni horloge, ni aléa, ni comparaison localisée', () => {
+ // Le filet est éprouvé d'abord : chaque source lève pendant qu'il est tendu.
+ for (const lecture of [
+ () => Math.random(),
+ () => Date.now(),
+ () => new Date(),
+ () => performance.now(),
+ () => crypto.getRandomValues(new Uint8Array(1)),
+ () => crypto.randomUUID(),
+ () => 'a'.localeCompare('b'),
+ () => 'A'.toLocaleLowerCase(),
+ () => 'a'.toLocaleUpperCase(),
+ ]) {
+ assert.throws(() => sansSourcesVariables(lecture), / lu$/);
+ }
+ assert.equal(typeof Date.now(), 'number');
+ assert.equal('A'.toLocaleLowerCase(), 'a');
+ const rendre = () => [
+ longueurRelativeMax('Gala'),
+ derive('Gala', DOSSIERS[0], ['gala', 'GALA (2)']),
+ derive(lettres(400), DOSSIERS[1], []),
+ derive('Con.soirée', DOSSIERS[0], ['con_.soirée']),
+ dossierCorbeille('2026-10-06T19:02:11-04:00', ['2026-10-06_19-02-11']),
+ ];
+ assert.deepEqual(sansSourcesVariables(rendre), rendre());
+ });
+
+ test("les arguments ne sont pas modifiés : un contexte et une liste figés passent, et le résultat se répète", () => {
+ const existantes = Object.freeze(['gala', 'GALA (2)']);
+ const dossier = Object.freeze({ racine: DOSSIERS[0].racine, separateur: '\\', existantes });
+ const premier = deriverBase('Gala', dossier);
+ assert.equal(premier, 'Gala (3)');
+ assert.equal(deriverBase('Gala', dossier), premier);
+ assert.deepEqual([...existantes], ['gala', 'GALA (2)']);
+ });
+});