// © 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)']); }); });