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