Compare commits

..

10 commits

Author SHA1 Message Date
201259331d [ADD] csv: apply an import three ways, re-export refusals, export list
An import applies its preview by adding, updating or replacing.
Updating designates one participant by name, first name and, when
given, affiliation; an empty field keeps the stored value, and a row
that designates several is refused. Replacing first counts what it
destroys, reservations and filled titles included. A blocked plan
refuses; a retained one warns of drift. Refused rows come back as a CSV
of the original columns plus line and reason, which reimports as is;
the list exports in the exact form the import reads.

Checked: 59 tests, the round trip field by field and the refusal loop.

--- FR ---

[ADD] csv : import de trois façons, refus réexportés, liste exportée

Un import applique son aperçu en ajoutant, mettant à jour ou
remplaçant. La mise à jour désigne un participant par nom, prénom et,
s'il est donné, appartenance ; un champ vide garde la valeur, et une
ligne qui en désigne plusieurs est refusée. Remplacer compte d'abord ce
qu'il détruit, réservations et titres pourvus compris. Un plan bloqué
refuse ; un plan retenu avertit de la dérive. Les lignes refusées
reviennent en CSV des colonnes d'origine, plus ligne et motif, qui se
réimporte tel quel ; la liste s'exporte dans la forme que l'import lit.

Vérifié : 59 épreuves, l'aller-retour champ par champ et la boucle.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 14:47:25 -04:00
3b00566560 [ADD] storage: working-folder rule, portable data/ or Documents
Once per session the rule picks where events live (§ 8.6): data/ next
to the executable that the portable launcher publishes, unless that
folder sits under per-user application data or a temporary folder, or
a written witness does not read back; Documents otherwise, with the
reason. A data/ that holds events but refuses writes raises a question
instead of a silent switch; an empty folder with events elsewhere
proposes their import. A removable medium and the web platform are
announced. It writes only the probes' witnesses.

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

--- FR ---

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

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

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

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 14:47:22 -04:00
4003b1f833 [ADD] storage: append-only journal, snapshots every fifty, undo threads
The journal is one JSON line per entry, after a line that pairs it with
its event by id. An entry carries its frozen label, a display timestamp,
and either a snapshot or a patch from the line before; a snapshot every
fifty revisions keeps any instant within 49 applications. Reading stops
at the first unreadable line and counts what it drops. Returns never
truncate: undo, redo and jumps are entries, and the current thread is
told from abandoned ones. Past 600 entries it prunes on a snapshot
boundary, never below 500.

Checked: unit tests, and every instant rebuilt over long sequences.

--- FR ---

[ADD] stockage : journal en ajout, instantané tous les cinquante, fils

Le journal est une ligne JSON par entrée, après une ligne qui l'apparie
à son événement par identifiant. Une entrée porte son libellé figé, un
horodatage d'affichage, et soit un instantané, soit un correctif depuis
la ligne précédente ; un instantané toutes les cinquante révisions tient
tout instant à 49 applications. La lecture s'arrête à la première ligne
illisible et compte ce qu'elle écarte. Revenir ne tronque jamais :
défaire, refaire et sauter sont des entrées, et le fil courant se
distingue des fils abandonnés. Au-delà de 600 entrées, élagage sur une
frontière d'instantané, jamais sous 500.

Vérifié : épreuves unitaires ; tous les instants reconstruits.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 14:45:56 -04:00
35cc5f5f75 [ADD] csv: columns by header, closed exclu values, mandatory preview
Columns associate by header, never by position, insensitive to case,
accents and spaces, with the § 10.1 synonyms; two columns for one field
associate neither and ask. The preview imports nothing: it lists valid
rows, refused rows with their reason, each group of merged spellings
with its counts, duplicates never merged, and global refusals, an open
quote or a NUL included. A paste goes through the same parser, and the
separator rule lives once, in lecture.js, whose refusal names its line.

Checked: 185 CSV tests, eight spreadsheet-shaped fixtures; two reviews.

--- FR ---

[ADD] csv : colonnes par en-tête, valeurs d'exclu fermées, aperçu

Les colonnes s'associent par en-tête, jamais par position, sans égard à
la casse, aux accents ni aux espaces, avec les synonymes du § 10.1 ;
deux colonnes d'un même champ ne s'associent pas, et le logiciel
demande. L'aperçu n'importe rien : lignes valides, lignes refusées et
leur motif, chaque groupe d'orthographes fondues et ses comptes,
doublons jamais fondus, refus globaux, guillemet ouvert et nul compris.
Le collage passe par le même analyseur, et la règle du séparateur ne
s'écrit qu'une fois, dans lecture.js, dont le refus nomme sa ligne.

Vérifié : 185 épreuves CSV, huit données de tableur ; deux revues.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:28 -04:00
fdaa79ed32 [ADD] storage: file-system interface, failing test double, contract
Storage code receives its file system as a parameter (§ 13.4): one
interface, and here the implementation tests inject. It keeps files in
memory, shares a disk between two instances to replay a restart, and
fails on demand: a refused rename that leaves the target intact, writes
refused from a moment on, a process killed after n writes, a failing
probe, a removable medium. One contract suite, its assertions passed in,
will run unchanged on the Electron and web implementations.

Checked: 87 tests, the contract suite run on the test double.

--- FR ---

[ADD] stockage : interface du système de fichiers, double, contrat

Le code du stockage reçoit son système de fichiers en paramètre
(§ 13.4) : une interface, et ici l'implémentation que les épreuves
injectent. Elle garde les fichiers en mémoire, partage un disque entre
deux instances pour rejouer un redémarrage, et tombe en panne à la
demande : renommage refusé qui laisse la cible intacte, écritures
refusées à partir d'un instant, processus tué après n écritures, sonde
qui échoue, support amovible. Une suite de contrat, ses assertions
reçues en paramètre, servira telle quelle sous Electron et sous web.

Vérifié : 87 épreuves, la suite de contrat jouée sur le double.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:25 -04:00
f24896c4a1 [ADD] storage: patches between two payloads, keyed by record id
The journal stores a gesture as a patch from the previous payload, not
a full copy. Records with an id are compared by id, so adding a person
does not shift the others; same-length lists compare element by
element; other values are set whole. Moving one person in one round of
a large plan touches two seat lists. Applying never alters its input
and refuses a patch that does not fit, naming the operation.

Checked: 25 tests including a property over generated payloads.

--- FR ---

[ADD] stockage : correctifs entre deux charges, par identifiant

Le journal range un geste comme un correctif depuis la charge
précédente, non comme une copie entière. Les enregistrements à
identifiant se comparent par identifiant, si bien qu'ajouter une
personne ne décale pas les autres ; les listes de même longueur se
comparent élément par élément ; le reste se pose en entier. Déplacer une
personne à un tour d'un grand plan touche deux listes de sièges.
Appliquer ne modifie jamais son entrée et refuse un correctif qui ne
s'applique pas, en nommant l'opération.

Vérifié : 25 épreuves, dont une propriété sur des charges tirées.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:23 -04:00
d0f4081961 [ADD] storage: positional placements, faulty versus stale propositions
Placements are stored positionally: per proposition its seed, stop
count, history length and building version, then per round the ordered
tables and the reserve. A proposition that contradicts itself — wrong
length, repeated, unknown or missing id, an id already taken — is named
and left out, and the event still opens. One that no longer matches the
event — person excluded or added, table removed, capacity or rounds
changed — is kept and reported as drift. A named form serves reading.

Checked: 42 tests on the small demonstration; reviewed, mutants killed.

--- FR ---

[ADD] stockage : placements positionnels, fautes et dérives

Les placements se rangent par position : pour chaque proposition sa
graine, son compte d'arrêt, la longueur d'historique et la version qui
l'a produite, puis par tour les tables ordonnées et la réserve. Une
proposition qui se contredit — longueur fausse, identifiant répété,
inconnu, manquant ou déjà pris — est nommée et écartée, et l'événement
s'ouvre. Celle qui ne décrit plus l'événement — personne exclue ou
ajoutée, table retirée, capacité ou tours changés — est gardée et
signalée comme dérive. Une forme nommée sert à la lecture.

Vérifié : 42 épreuves sur la petite démonstration ; revue, mutants tués.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:20 -04:00
e1ea16d244 [ADD] storage: event file names that Windows accepts, bounded paths
A file name derives from the event name: invisible code points and
characters Windows refuses removed, NFC before and after, a reserved
device name suffixed even before a dot, a collision compared in capitals
as Windows compares. The bound covers the longest path an event writes,
dated trash folder and atomic-write suffix included, under 259 units; a
name that leaves nothing becomes « evenement ». The trash rank stops at
the 99 the bound reserves.

Checked: 91 tests on both working folders, mutants killed, fuzzed names.

--- FR ---

[ADD] stockage : noms de fichiers admis par Windows, chemins bornés

Le nom de fichier dérive du nom de l'événement : points de code
invisibles et caractères que Windows refuse retirés, NFC avant et après,
nom de périphérique réservé suffixé même devant un point, collision
comparée en capitales comme Windows compare. La borne couvre le plus long
chemin qu'un événement écrit, corbeille datée et suffixe d'écriture
atomique compris, sous 259 unités ; un nom qui ne laisse rien devient
« evenement ». Le rang de corbeille s'arrête aux 99 que la borne réserve.

Vérifié : 91 épreuves sur les deux dossiers, mutants tués, noms tirés.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:37 -04:00
1d15ebaf68 [ADD] storage: event document, canonical form, consistency check
An event lives in one state file: a header (format, writing build,
revision, counts) and a payload. The serializer orders keys by one
schema table and records by id, sorts the sets — reserves, placed
participants, unattributed seats — and reads no clock nor randomness:
one state gives one text, byte for byte. Reading refuses an empty,
truncated or self-contradicting file by reason and path, reads a newer
format with its unknown keys ignored, never refuses a whole file for
one proposition. The engine exports its default history length.

Checked: 58 tests and the golden text; two reviews, mutants killed.

--- FR ---

[ADD] stockage : document d'événement, forme canonique, contrôle

Un événement vit dans un fichier d'état : un en-tête (format,
construction qui écrit, révision, comptes) et une charge. Le sérialiseur
range les clés par une seule table de schéma et les enregistrements par
identifiant, trie les ensembles — réserves, participants placés, sièges
non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet
pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire
par raison et chemin, lit un format plus récent en ignorant ses clés
inconnues, et ne refuse jamais un fichier pour une proposition.

Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
dd088f2dd9 [ADD] csv: decode in four steps, choose the separator, cut records
Bytes decode in the order of § 10.1: UTF-8 mark, UTF-16 mark, a NUL in
the first 4 KiB without a mark refused with its remedy, then strict
UTF-8, then windows-1252. A mark its bytes contradict, and any decoded
NUL, are refused: decoded, they would give names no later check sees.
The separator is the candidate among ";", "," and tab that leaves no
quote open and gives one field count above one on the first twenty
records; a lone recognised header reads as one column. Records follow
RFC 4180 and are numbered as a spreadsheet numbers its rows.

Checked: 89 tests, 60 mutants killed; review found no behaviour defect.

--- FR ---

[ADD] csv : décodage en quatre temps, choix du séparateur, découpage

Les octets se décodent dans l'ordre du § 10.1 : marque UTF-8, marque
UTF-16, octet nul dans les 4 Kio sans marque refusé avec son remède,
UTF-8 strict, puis windows-1252. Une marque contredite par ses octets,
et tout nul décodé, sont refusés : décodés, ils donneraient des noms
qu'aucun contrôle ne verrait. Le séparateur parmi « ; », « , » et la
tabulation ne laisse aucun guillemet ouvert et donne un même nombre de
champs, plus d'un, sur vingt enregistrements ; un en-tête seul reconnu
se lit en une colonne. Découpage RFC 4180, rangs comme dans un tableur.

Vérifié : 89 épreuves, 60 mutants tués ; aucun défaut à la revue.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 07:05:21 -04:00
47 changed files with 16747 additions and 1 deletions

1
.gitattributes vendored
View file

@ -1,2 +1,3 @@
* text=auto eol=lf * text=auto eol=lf
*.cmd text eol=crlf *.cmd text eol=crlf
test/fixtures/csv/** -text

314
src/csv/apercu.js Normal file
View file

@ -0,0 +1,314 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Aperçu d'un texte CSV décodé, venu d'un fichier ou d'un collage en bloc
// (§ 10.1, § 10.2). L'aperçu n'importe rien : il dit, ligne par ligne, ce que
// l'import ferait contre les participants existants. Un fichier passe d'abord
// par decoder ; un collage, qui ne porte pas d'octets, arrive ici tel quel ;
// à partir d'ici, les deux suivent le même chemin, et un second analyseur ne
// peut pas diverger du premier.
//
// La lecture suit un ordre fixe, et le premier refus global arrête tout :
// 1. un caractère nul refuse le texte, par la règle de decoder ;
// 2. choisirSeparateur choisit le séparateur ; un texte vide ou blanc est
// refusé AUCUN_ENTETE, tout autre texte sans séparateur
// SEPARATEUR_INTROUVABLE, au rang du défaut que choisirSeparateur
// nomme, sauf quand ce défaut est un guillemet resté ouvert : le texte
// se lit alors sous le candidat qu'il écarte, jusqu'au refus du point 4 ;
// 3. decouper rend les enregistrements ; l'en-tête est le premier non vide ;
// 4. un guillemet resté ouvert refuse le texte : ce qui le suit tiendrait
// dans un seul champ, et deux personnes se fondraient en une ;
// 5. aucun en-tête reconnu, puis aucune colonne associée à nom, refusent ;
// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne,
// valide ou refusée pour un seul motif ; zéro ligne valide refuse ;
// 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1).
import { associer, reconnaitre } from './colonnes.js';
import { contientNul } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
/**
* Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste
* fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce
* que vaut une cellule vide pour une personne créée, non ; l'aperçu rend
* pourtant null pour une cellule vide, qu'une mise à jour lit comme « garder
* la valeur » (voir ChampsLus).
*
* @type {Map<string, boolean>}
*/
export const VALEURS_EXCLU = new Map([
['oui', true],
['o', true],
['vrai', true],
['1', true],
['x', true],
['non', false],
['n', false],
['faux', false],
['0', false],
['', false],
]);
/**
* @typedef {Object} ChampsLus
* @property {string} nom
* @property {string|null} prenom
* @property {string|null} appartenance l'orthographe affichée de son groupe ;
* null pour une cellule vide, blanche ou faite de marques
* combinantes seules : sa clé normalisée est vide
* @property {string|null} courriel
* @property {string|null} titrePressenti
* @property {string|null} notes
* @property {boolean|null} exclu null pour une cellule vide ou une
* colonne absente : vide vaut non pour une personne créée, et laisse
* la valeur d'une personne existante qu'un import met à jour
*
* @typedef {Object} Apercu
* @property {string|null} separateur null pour un fichier à une colonne,
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
* SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc
* @property {string[]} entetes tels qu'écrits
* @property {{colonnes: Object<string, number>, ambigus: Array<{champ: string, rangs: number[]}>,
* nonReconnues: number[]}} association ce que rend associer
* @property {Array<{ligne: number, champs: ChampsLus, brut: string[]}>} lignes
* les lignes valides ; ligne : rang de l'enregistrement à partir de
* 1, l'en-tête compris ; champs : chaînes en NFC, blancs de bord
* retirés, vide → null ; brut : les champs tels que découpés
* @property {Array<{ligne: number, code: string, valeur: string|null, brut: string[]}>} refusees
* code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU ; valeur : la
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
* pour les deux autres codes
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises,
* qui porte une ligne valide, rangé par sa première ligne ;
* orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent
* @property {Array<{cle: string, lignes: number[], existants: number[]}>} doublons
* chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première
* ligne ; cle : le triplet normalisé, en JSON ; existants :
* identifiants croissants
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusions et doublons sont vides, et
* refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous
* SEPARATEUR_INTROUVABLE, celui de l'enregistrement dont le nombre
* de champs écarte le séparateur, quand choisirSeparateur le nomme ;
* null sinon
*/
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
// refus, aucune ligne.
function refuser(
code,
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null,
) {
return {
separateur,
entetes,
association,
lignes: [],
refusees: [],
fusions: [],
doublons: [],
refusGlobal: code,
refusGlobalLigne: ligne,
};
}
// Valeur de la cellule de rang donné : NFC, blancs de bord retirés. Null quand
// le champ n'a pas de colonne, que l'enregistrement s'arrête avant elle, ou
// que la cellule est vide ou blanche.
function valeurDe(brut, rang) {
if (rang === undefined || rang >= brut.length) return null;
const valeur = brut[rang].normalize('NFC').trim();
return valeur === '' ? null : valeur;
}
// Vrai pour une appartenance qui en nomme une : une chaîne dont la clé
// normalisée n'est pas vide. Une appartenance vide, blanche ou faite de
// marques combinantes seules vaut absence (§ 10.1) : la clé de doublon la
// compte vide, comme une appartenance absente, et elle n'est l'orthographe
// d'aucun groupe. La réconciliation et la clé de doublon s'accordent ainsi
// sur chaque appartenance, lue ou existante.
const appartenanceNommee = (texte) => typeof texte === 'string' && cleNormalisee(texte) !== '';
// Lit un enregistrement de données selon les colonnes associées, largeur
// étant le nombre d'en-têtes. Rend { champs }, l'appartenance telle qu'écrite
// ou null quand elle n'en nomme aucune, ou le motif du refus { code, valeur },
// un seul, dans cet ordre : un champ non vide au-delà de l'en-tête, qui dit
// une ligne décalée dont aucune valeur n'est sûre ; un nom vide ; une valeur
// d'« exclu » hors de la liste fermée.
function lireLigne(brut, largeur, colonnes) {
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
const lire = (champ) => valeurDe(brut, colonnes[champ]);
const nom = lire('nom');
if (nom === null) return { code: 'NOM_ABSENT', valeur: null };
const valeurExclu = lire('exclu');
const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu));
if (exclu === undefined) return { code: 'EXCLU_INCONNU', valeur: valeurExclu };
const appartenance = lire('appartenance');
return {
champs: {
nom,
prenom: lire('prenom'),
appartenance: appartenanceNommee(appartenance) ? appartenance : null,
courriel: lire('courriel'),
titrePressenti: lire('titre_pressenti'),
notes: lire('notes'),
exclu,
},
};
}
// Réconcilie les appartenances (§ 10.1). Les orthographes de même clé
// normalisée forment un groupe, que représente la première rencontrée :
// celles des participants existants d'abord, dans l'ordre de existants,
// telles qu'enregistrées, puis celles des lignes valides, dans l'ordre du
// texte. Rend l'orthographe affichée du groupe d'un texte, et les fusions :
// chaque groupe d'au moins deux orthographes qui porte une ligne valide,
// rangé par sa première ligne.
function reconcilier(existants, valides) {
const groupes = new Map();
const noter = (texte, ligne) => {
const cle = cleNormalisee(texte);
let groupe = groupes.get(cle);
if (groupe === undefined) {
groupe = { affichee: texte, orthographes: [], parTexte: new Map(), lignes: [] };
groupes.set(cle, groupe);
}
let orthographe = groupe.parTexte.get(texte);
if (orthographe === undefined) {
orthographe = { texte, lignes: [] };
groupe.orthographes.push(orthographe);
groupe.parTexte.set(texte, orthographe);
}
if (ligne !== null) {
orthographe.lignes.push(ligne);
groupe.lignes.push(ligne);
}
};
for (const { appartenance } of existants) {
if (appartenanceNommee(appartenance)) noter(appartenance, null);
}
for (const { ligne, champs } of valides) {
if (champs.appartenance !== null) noter(champs.appartenance, ligne);
}
const fusions = [...groupes.values()]
.filter((groupe) => groupe.orthographes.length > 1 && groupe.lignes.length > 0)
.sort((a, b) => a.lignes[0] - b.lignes[0])
.map(({ affichee, orthographes }) => ({ affichee, orthographes }));
return { afficheeDe: (texte) => groupes.get(cleNormalisee(texte)).affichee, fusions };
}
// Clé de doublon : le triplet (nom, prénom, appartenance), chacun par sa clé
// normalisée, un prénom ou une appartenance absents comptant comme vides,
// écrit en JSON pour qu'aucun texte ne se confonde avec une frontière.
const cleDoublon = (nom, prenom, appartenance) =>
JSON.stringify([nom, prenom ?? '', appartenance ?? ''].map((texte) => cleNormalisee(texte)));
// Doublons (§ 10.1) : chaque clé de doublon que portent au moins deux
// personnes, dont une ligne valide, avec les rangs de ses lignes et les
// identifiants de ses existants, rangée par sa première ligne. Un doublon est
// signalé, jamais fusionné : deux personnes peuvent porter le même nom.
function doublonsDe(existants, valides) {
const groupes = new Map();
const groupeDe = (cle) => {
if (!groupes.has(cle)) groupes.set(cle, { cle, lignes: [], existants: [] });
return groupes.get(cle);
};
for (const { id, nom, prenom, appartenance } of existants) {
groupeDe(cleDoublon(nom, prenom, appartenance)).existants.push(id);
}
for (const { ligne, champs } of valides) {
groupeDe(cleDoublon(champs.nom, champs.prenom, champs.appartenance)).lignes.push(ligne);
}
return [...groupes.values()]
.filter((groupe) => groupe.lignes.length > 0 && groupe.lignes.length + groupe.existants.length > 1)
.sort((a, b) => a.lignes[0] - b.lignes[0]);
}
/**
* Aperçu d'un texte décodé, import ou collage, contre les participants
* existants : rien n'est importé.
*
* @param {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options]
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants]
* les participants existants, dans n'importe quel ordre : l'identifiant
* ordonne leurs orthographes
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer)
* @returns {Apercu}
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder
*/
export function apercevoir(texte, { participants = [], choix = {} } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (contientNul(texte)) return refuser('CARACTERE_NUL');
let separateur;
try {
({ separateur } = choisirSeparateur(texte, reconnaitre));
} catch (erreur) {
// Sur une chaîne, choisirSeparateur ne lève que SEPARATEUR_INTROUVABLE ;
// toute autre erreur est un défaut, qu'un refus de l'aperçu masquerait.
if (!(erreur instanceof ErreurCsv) || erreur.code !== 'SEPARATEUR_INTROUVABLE') throw erreur;
if (texte.trim() === '') return refuser('AUCUN_ENTETE');
const { cause = null, ligne = null } = erreur.details;
if (cause !== 'GUILLEMET_OUVERT') return refuser('SEPARATEUR_INTROUVABLE', {}, ligne);
// Seul le guillemet resté ouvert écarte ce candidat : le texte se lit
// sous lui, et le refus GUILLEMET_OUVERT porte le séparateur, l'en-tête,
// l'association et le rang, comme au-delà de la fenêtre.
({ separateur } = erreur.details);
}
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
// Le séparateur, retenu ou écarté par le seul guillemet ouvert, a lu un
// enregistrement non vide dans la fenêtre du choix : l'en-tête existe.
const indiceEntete = enregistrements.findIndex((enregistrement) => !enregistrementVide(enregistrement));
const entetes = enregistrements[indiceEntete];
const association = associer(entetes, choix);
const lu = { separateur, entetes, association };
if (guillemetOuvert) return refuser('GUILLEMET_OUVERT', lu, enregistrements.length);
if (association.nonReconnues.length === entetes.length) return refuser('AUCUN_ENTETE', lu);
if (association.colonnes.nom === undefined) return refuser('NOM_NON_ASSOCIE', lu);
const valides = [];
const refusees = [];
for (let indice = indiceEntete + 1; indice < enregistrements.length; indice += 1) {
const brut = enregistrements[indice];
if (enregistrementVide(brut)) continue;
const ligne = indice + 1;
const lecture = lireLigne(brut, entetes.length, association.colonnes);
if (lecture.champs === undefined) refusees.push({ ligne, code: lecture.code, valeur: lecture.valeur, brut });
else valides.push({ ligne, champs: lecture.champs, brut });
}
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides);
const lignes = valides.map(({ ligne, champs, brut }) => ({
ligne,
champs: { ...champs, appartenance: champs.appartenance === null ? null : afficheeDe(champs.appartenance) },
brut,
}));
return {
...lu,
lignes,
refusees,
fusions,
doublons: doublonsDe(existants, lignes),
refusGlobal: null,
refusGlobalLigne: null,
};
}

1126
src/csv/apercu.test.js Normal file

File diff suppressed because it is too large Load diff

87
src/csv/colonnes.js Normal file
View file

@ -0,0 +1,87 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Association des colonnes d'un CSV aux champs d'un participant (§ 10.1). Une
// colonne s'associe par son en-tête, jamais par sa position : un tableur dont
// une colonne a été déplacée importerait sinon les noms dans les
// appartenances, sans rien signaler. Deux colonnes reconnues sous le même
// champ ne se départagent pas toutes seules ; le choix de l'opérateur tranche.
import { cleEntete } from './normalisation.js';
/** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */
export const CHAMPS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
// En-têtes de chaque champ tels que le § 10.1 les écrit : son nom, puis ses
// synonymes.
const ENTETES_DES_CHAMPS = [
['nom', ['nom']],
['prenom', ['prenom']],
['appartenance', ['appartenance', 'organisation', 'entreprise', 'équipe']],
['courriel', ['courriel']],
['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']],
['exclu', ['exclu']],
['notes', ['notes']],
];
// Champ de chaque clé d'en-tête, la clé tirée de l'écriture du § 10.1 par la
// même règle que celle d'un en-tête lu.
const CHAMP_DE_CLE = new Map(
ENTETES_DES_CHAMPS.flatMap(([champ, entetes]) => entetes.map((entete) => [cleEntete(entete), champ])),
);
/**
* Champ qu'un en-tête désigne, synonymes compris, sans égard à la casse, aux
* accents, aux espaces, à « _ » ni à « - » ; null pour un en-tête inconnu,
* comme « ligne » et « motif », les deux colonnes qu'ajoute le CSV des refus.
*
* @param {string} entete
* @returns {string|null} un élément de CHAMPS, ou null
*/
export function reconnaitre(entete) {
return CHAMP_DE_CLE.get(cleEntete(entete)) ?? null;
}
/**
* Associe les colonnes aux champs par leurs en-têtes. Un rang de colonne est
* son indice dans entetes, à partir de 0.
*
* Un champ que reconnaît une seule colonne lui est associé. Un champ que
* reconnaissent plusieurs colonnes n'est associé à aucune et devient une
* ambiguïté, sauf quand choix[champ] désigne l'une d'elles : celle-là est
* associée, et l'ambiguïté levée. Un choix qui ne désigne pas l'une des
* colonnes en conflit d'un champ ambigu est sans effet.
*
* @param {string[]} entetes en-têtes tels qu'écrits
* @param {Object<string, number>} [choix] { champ: rang }
* @returns {{
* colonnes: Object<string, number>,
* ambigus: Array<{champ: string, rangs: number[]}>,
* nonReconnues: number[],
* }}
* colonnes : { champ: rang } des champs associés, dans l'ordre de CHAMPS ;
* ambigus : dans l'ordre de CHAMPS, rangs croissants ; nonReconnues : rangs
* croissants des colonnes dont l'en-tête est inconnu, vide compris.
*/
export function associer(entetes, choix = {}) {
const rangsDesChamps = new Map(CHAMPS.map((champ) => [champ, []]));
const nonReconnues = [];
entetes.forEach((entete, rang) => {
const champ = reconnaitre(entete);
if (champ === null) nonReconnues.push(rang);
else rangsDesChamps.get(champ).push(rang);
});
const colonnes = {};
const ambigus = [];
for (const champ of CHAMPS) {
const rangs = rangsDesChamps.get(champ);
if (rangs.length === 1) {
colonnes[champ] = rangs[0];
} else if (rangs.length > 1) {
if (Object.hasOwn(choix, champ) && rangs.includes(choix[champ])) colonnes[champ] = choix[champ];
else ambigus.push({ champ, rangs });
}
}
return { colonnes, ambigus, nonReconnues };
}

180
src/csv/colonnes.test.js Normal file
View file

@ -0,0 +1,180 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'association des colonnes (§ 10.1) : les champs du CSV, la
// reconnaissance d'un en-tête et de ses synonymes, et l'association par
// en-tête, jamais par position, qui laisse sans colonne un champ que deux
// colonnes revendiquent tant qu'un choix ne tranche pas.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CHAMPS, associer, reconnaitre } from './colonnes.js';
describe('CHAMPS', () => {
test('les sept champs du § 10.1, dans leur ordre, figés', () => {
assert.deepEqual(CHAMPS, ['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
assert.ok(Object.isFrozen(CHAMPS));
});
});
describe('reconnaitre (§ 10.1)', () => {
test('chaque champ par son propre nom', () => {
assert.ok(CHAMPS.length > 0, 'aucun champ');
for (const champ of CHAMPS) assert.equal(reconnaitre(champ), champ);
});
test('les synonymes : organisation, entreprise et équipe ; titre et rôle', () => {
assert.deepEqual(
['organisation', 'entreprise', 'équipe', 'titre', 'rôle'].map((entete) => reconnaitre(entete)),
['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti'],
);
});
test('sans égard à la casse, aux accents ni aux espaces', () => {
const cas = [
['NOM', 'nom'],
[' Prénom ', 'prenom'],
['Équipe', 'appartenance'],
['titre', 'titre_pressenti'],
['Rôle', 'titre_pressenti'],
['E X C L U', 'exclu'],
['Titre_Pressenti', 'titre_pressenti'],
['titre pressenti', 'titre_pressenti'],
['COURRIEL', 'courriel'],
['Notes ', 'notes'],
['ORGANISATION', 'appartenance'],
['Entre-prise', 'appartenance'],
['Equipe', 'appartenance'],
];
assert.deepEqual(
cas.map(([entete]) => reconnaitre(entete)),
cas.map(([, champ]) => champ),
);
});
test('un en-tête inconnu rend null, dont « ligne » et « motif » du CSV des refus', () => {
const inconnus = [
'ligne', 'motif', '', ' ', 'nom de famille', 'société', 'prenoms', 'courriels',
'organisations', 'titres', 'appartenances', 'exclus', 'note', 'titre.pressenti',
];
assert.deepEqual(
inconnus.map((entete) => reconnaitre(entete)),
inconnus.map(() => null),
);
});
});
describe('associer : par en-tête, jamais par position (§ 10.1)', () => {
test('chaque champ reconnu reçoit le rang de sa colonne, à partir de 0', () => {
assert.deepEqual(associer(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']), {
colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6 },
ambigus: [],
nonReconnues: [],
});
});
test('des colonnes déplacées donnent à chaque champ la même valeur', () => {
const entetes = ['nom', 'prenom', 'appartenance', 'courriel'];
const enregistrement = ['Ombrelle', 'Iris', 'Club des Merles', 'iris@exemple.test'];
// La colonne i du fichier déplacé est la colonne deplacement[i] de l'autre.
const deplacement = [3, 2, 0, 1];
const entetesDeplaces = deplacement.map((rang) => entetes[rang]);
const enregistrementDeplace = deplacement.map((rang) => enregistrement[rang]);
// [champ, valeur] de chaque champ associé, dans l'ordre de CHAMPS.
const lire = ({ colonnes }, champs) =>
CHAMPS.filter((champ) => colonnes[champ] !== undefined).map((champ) => [champ, champs[colonnes[champ]]]);
const association = associer(entetes);
const associationDeplacee = associer(entetesDeplaces);
assert.deepEqual(associationDeplacee.colonnes, { nom: 2, prenom: 3, appartenance: 1, courriel: 0 });
assert.deepEqual(lire(associationDeplacee, enregistrementDeplace), lire(association, enregistrement));
assert.deepEqual(lire(association, enregistrement), [
['nom', 'Ombrelle'],
['prenom', 'Iris'],
['appartenance', 'Club des Merles'],
['courriel', 'iris@exemple.test'],
]);
});
test("colonnes suit l'ordre de CHAMPS, quel que soit celui du fichier", () => {
assert.equal(
JSON.stringify(associer(['notes', 'exclu', 'courriel', 'nom']).colonnes),
'{"nom":3,"courriel":2,"exclu":1,"notes":0}',
);
});
test("organisation et entreprise ensemble : ni l'une ni l'autre, une ambiguïté sur appartenance", () => {
assert.deepEqual(associer(['nom', 'organisation', 'entreprise']), {
colonnes: { nom: 0 },
ambigus: [{ champ: 'appartenance', rangs: [1, 2] }],
nonReconnues: [],
});
});
test("le choix tranche l'ambiguïté, pour l'une comme pour l'autre colonne", () => {
const entetes = ['nom', 'organisation', 'entreprise'];
assert.deepEqual(associer(entetes, { appartenance: 2 }), {
colonnes: { nom: 0, appartenance: 2 },
ambigus: [],
nonReconnues: [],
});
assert.deepEqual(associer(entetes, { appartenance: 1 }).colonnes, { nom: 0, appartenance: 1 });
});
test('des colonnes déplacées donnent la même ambiguïté, aux rangs déplacés', () => {
const entetes = ['entreprise', 'nom', 'organisation'];
assert.deepEqual(associer(entetes), {
colonnes: { nom: 1 },
ambigus: [{ champ: 'appartenance', rangs: [0, 2] }],
nonReconnues: [],
});
assert.deepEqual(associer(entetes, { appartenance: 0 }).colonnes, { nom: 1, appartenance: 0 });
});
test("un choix qui ne désigne pas l'une des colonnes en conflit est sans effet", () => {
const entetes = ['nom', 'organisation', 'entreprise', 'inconnue'];
const sansChoix = associer(entetes);
assert.deepEqual(sansChoix.ambigus, [{ champ: 'appartenance', rangs: [1, 2] }]);
const choix = [
{ appartenance: 0 },
{ appartenance: 3 },
{ appartenance: 7 },
{ appartenance: '2' },
{ appartenance: null },
{ nom: 1 },
{ prenom: 3 },
{ inconnu: 1 },
];
for (const un of choix) assert.deepEqual(associer(entetes, un), sansChoix, JSON.stringify(un));
});
test("deux colonnes nom : ni l'une ni l'autre", () => {
assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), {
colonnes: { prenom: 1 },
ambigus: [{ champ: 'nom', rangs: [0, 2] }],
nonReconnues: [],
});
});
test("plusieurs conflits : les ambiguïtés dans l'ordre de CHAMPS, les rangs croissants", () => {
assert.deepEqual(associer(['titre', 'équipe', 'nom', 'rôle', 'organisation', 'entreprise']), {
colonnes: { nom: 2 },
ambigus: [
{ champ: 'appartenance', rangs: [1, 4, 5] },
{ champ: 'titre_pressenti', rangs: [0, 3] },
],
nonReconnues: [],
});
});
test('les colonnes non reconnues, en-tête vide compris, par rang croissant', () => {
assert.deepEqual(associer(['x', 'nom', '', 'ligne', ' ']), {
colonnes: { nom: 1 },
ambigus: [],
nonReconnues: [0, 2, 3, 4],
});
});
test('aucune colonne : rien à associer', () => {
assert.deepEqual(associer([]), { colonnes: {}, ambigus: [], nonReconnues: [] });
});
});

130
src/csv/encodage.js Normal file
View file

@ -0,0 +1,130 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Décodage des octets d'un fichier CSV en texte (§ 10.1). Une détection qui se
// trompe ne fait échouer aucun calcul : un nom décodé de travers se découvre
// sur le plan imprimé. La détection suit donc quatre temps dans un ordre fixe,
// et n'emploie que les décodeurs que nomme le § 10.1. Un fichier que ces temps
// ne lisent pas sûrement est refusé, et le refus nomme le remède.
import { ErreurCsv } from './erreurs.js';
// Quatre kibioctets : la part du fichier où se cherche un octet nul.
const OCTETS_SONDES = 4096;
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const MARQUE_UTF16LE = [0xff, 0xfe];
const MARQUE_UTF16BE = [0xfe, 0xff];
// Vrai quand octets s'ouvre sur marque ; un fichier plus court qu'elle ne la
// porte pas.
const commencePar = (octets, marque) => marque.every((octet, i) => octets[i] === octet);
// Refus d'encodage de code donné. Chaque refus a ses propres détails, et tous
// renvoient au même remède : un fichier réenregistré en UTF-8.
const refuser = (code) => new ErreurCsv(code, { remede: 'ENREGISTRER_EN_UTF8' });
/**
* Vrai quand le texte porte le caractère nul (U+0000), qu'aucun CSV ne porte.
* decoder refuse le texte qu'il décode quand il en porte un ; la même fonction
* contrôle un texte qui n'est pas passé par decoder.
*
* @param {string} texte
* @returns {boolean}
*/
export function contientNul(texte) {
return texte.includes('\0');
}
// Octets de l'entrée : un Uint8Array, dont un Buffer, tel quel ; un ArrayBuffer
// par une vue sur lui. Les deux se reconnaissent à leur étiquette de type, non
// par instanceof, que fait échouer un tampon venu d'un autre contexte
// d'exécution. Tout autre argument est refusé : new Uint8Array en ferait un
// fichier vide, d'une promesse non attendue comme d'un Blob.
function enOctets(entree) {
const genre = Object.prototype.toString.call(entree);
if (genre === '[object Uint8Array]') return entree;
if (genre === '[object ArrayBuffer]') return new Uint8Array(entree);
throw new TypeError('octets : Uint8Array ou ArrayBuffer attendu');
}
// Texte UTF-16 des octets, la marque déjà retirée ; encodage est 'utf-16le' ou
// 'utf-16be', l'étiquette même du décodeur. Le décodage est strict : un nombre
// impair d'octets ou un substitut isolé lève UTF16_INVALIDE, là où le décodeur
// sèmerait des caractères de remplacement dans les noms.
function lireUtf16(octets, encodage) {
try {
return { texte: new TextDecoder(encodage, { fatal: true }).decode(octets), encodage };
} catch {
throw refuser('UTF16_INVALIDE');
}
}
// Texte UTF-8 des octets, ou null quand ils ne sont pas de l'UTF-8 valide.
function lireUtf8(octets) {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(octets);
} catch {
return null;
}
}
// Les quatre temps de decoder, sans le contrôle du caractère nul.
function lireOctets(octets) {
if (commencePar(octets, MARQUE_UTF8)) {
const texte = lireUtf8(octets.subarray(MARQUE_UTF8.length));
if (texte === null) throw refuser('UTF8_INVALIDE');
return { texte, encodage: 'utf-8-bom' };
}
if (commencePar(octets, MARQUE_UTF16LE)) {
return lireUtf16(octets.subarray(MARQUE_UTF16LE.length), 'utf-16le');
}
if (commencePar(octets, MARQUE_UTF16BE)) {
return lireUtf16(octets.subarray(MARQUE_UTF16BE.length), 'utf-16be');
}
if (octets.subarray(0, OCTETS_SONDES).includes(0)) throw refuser('UTF16_SANS_MARQUE');
const texte = lireUtf8(octets);
if (texte !== null) return { texte, encodage: 'utf-8' };
// windows-1252 donne un caractère à chaque octet : le repli n'échoue jamais.
return { texte: new TextDecoder('windows-1252').decode(octets), encodage: 'windows-1252' };
}
/**
* Décode les octets d'un fichier CSV et rend le texte avec l'encodage retenu,
* en quatre temps, dans cet ordre :
* 1. la marque d'ordre d'octets UTF-8, qui déclare l'encodage : des octets
* qui ne sont pas de l'UTF-8 valide sont refusés ;
* 2. la marque UTF-16, LE puis BE, qui déclare l'encodage : des octets qui ne
* sont pas de l'UTF-16 valide, nombre impair d'octets ou substitut isolé,
* sont refusés ;
* 3. sans marque, un octet nul parmi les 4 096 premiers octets : refus ;
* 4. UTF-8 strict, sinon windows-1252.
* La marque n'entre pas dans le texte rendu. Quel que soit le temps qui a
* décodé, un texte qui porte le caractère nul est refusé.
*
* Le troisième temps précède le quatrième parce qu'un texte UTF-16 sans marque
* se lit sans erreur dans l'un ou l'autre des décodeurs suivants : en UTF-8
* quand il n'a pas d'accent, chaque octet nul s'y lisant comme le caractère
* nul, en windows-1252 quand il en a, ce décodeur acceptant tout octet. Le
* texte rendu serait entrelardé de caractères nuls, sans qu'aucun calcul
* n'échoue. Le refus du caractère nul rattrape ce que ce temps ne voit pas :
* un nul au-delà des 4 096 premiers octets, et un texte que sa marque fait lire
* sans erreur, comme l'UTF-32 LE, dont la marque FF FE 00 00 commence par celle
* de l'UTF-16 LE.
*
* Sous la marque UTF-8, un repli sur windows-1252 changerait chaque accent de la
* partie valide en caractères parasites, sans qu'aucun calcul n'échoue : le
* fichier est refusé.
*
* @param {Uint8Array|ArrayBuffer} octets l'un ou l'autre, de n'importe quel
* contexte d'exécution
* @returns {{ texte: string, encodage: 'utf-8-bom'|'utf-16le'|'utf-16be'|'utf-8'|'windows-1252' }}
* @throws {ErreurCsv} UTF16_SANS_MARQUE, UTF16_INVALIDE, UTF8_INVALIDE ou
* CARACTERE_NUL, chacun de détails { remede: 'ENREGISTRER_EN_UTF8' }
* @throws {TypeError} quand octets n'est ni un Uint8Array ni un ArrayBuffer
*/
export function decoder(octets) {
const lu = lireOctets(enOctets(octets));
if (contientNul(lu.texte)) throw refuser('CARACTERE_NUL');
return lu;
}

353
src/csv/encodage.test.js Normal file
View file

@ -0,0 +1,353 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du décodage d'un fichier CSV (§ 10.1, § 14.10) : les cinq
// encodages que rend decoder, les quatre temps dans leur ordre, les quatre
// refus et leur remède — UTF-16 sans marque, UTF-16 invalide sous sa marque,
// UTF-8 invalide sous la marque UTF-8, caractère nul dans le texte décodé —,
// la frontière des quatre premiers kibioctets, la forme de l'entrée, y
// compris un tampon venu d'un autre contexte d'exécution. Les octets se
// construisent ici sans passer par un décodeur : UTF-8 par TextEncoder,
// UTF-16 et UTF-32 unité par unité, windows-1252 octet par octet.
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { describe, test } from '../../test/lanceur.js';
import { contientNul, decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
const BENOIT = 'Benoît';
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const enUtf8 = (texte) => new TextEncoder().encode(texte);
// Les octets de tete, puis ceux de octets.
const avec = (tete, octets) => Uint8Array.from([...tete, ...octets]);
// Octets UTF-16 de texte, une unité à la fois : ordre vaut 'le' (octet de
// poids faible d'abord) ou 'be' ; la marque d'ordre d'octets ouvre le fichier
// quand avecMarque est vrai.
function enUtf16(texte, ordre, avecMarque = true) {
const octets = [];
if (avecMarque) octets.push(...(ordre === 'be' ? [0xfe, 0xff] : [0xff, 0xfe]));
for (let i = 0; i < texte.length; i += 1) {
const unite = texte.charCodeAt(i);
const poids = [unite >> 8, unite & 0xff];
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
}
return Uint8Array.from(octets);
}
// Octets UTF-32 de texte, un point de code à la fois, précédés de la marque
// d'ordre d'octets : FF FE 00 00 pour 'le', 00 00 FE FF pour 'be'.
function enUtf32(texte, ordre) {
const octets = ordre === 'be' ? [0x00, 0x00, 0xfe, 0xff] : [0xff, 0xfe, 0x00, 0x00];
for (const caractere of texte) {
const point = caractere.codePointAt(0);
const poids = [point >>> 24, (point >> 16) & 0xff, (point >> 8) & 0xff, point & 0xff];
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
}
return Uint8Array.from(octets);
}
// Octets windows-1252 de texte : un caractère Latin-1 garde son code, les deux
// signes de 0x80 à 0x9F que les épreuves emploient ont le leur ; tout autre
// caractère fait échouer l'épreuve plutôt que de s'écrire de travers.
const WINDOWS_1252_PARTICULIERS = new Map([
['€', 0x80],
['’', 0x92],
]);
const enWindows1252 = (texte) =>
Uint8Array.from(texte, (caractere) => {
const code = WINDOWS_1252_PARTICULIERS.get(caractere) ?? caractere.codePointAt(0);
assert.ok(code < 0x100, `« ${caractere} » n'existe pas en windows-1252`);
return code;
});
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
return assert.fail("rien n'a été levé");
}
// L'erreur que lève decoder sur ces octets.
const refusDe = (octets) => erreurDe(() => decoder(octets));
// Vérifie que erreur est le refus de code donné, remède compris : tout refus
// d'encodage renvoie au même remède.
function assertRefus(erreur, code) {
assert.ok(erreur instanceof ErreurCsv);
assert.equal(erreur.code, code);
assert.deepEqual(erreur.details, { remede: 'ENREGISTRER_EN_UTF8' });
}
describe('decoder : les cinq encodages (§ 10.1)', () => {
const CAS = [
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8(BENOIT)), 'utf-8-bom'],
['UTF-8 sans marque', enUtf8(BENOIT), 'utf-8'],
['UTF-16 LE avec marque', enUtf16(BENOIT, 'le'), 'utf-16le'],
['UTF-16 BE avec marque', enUtf16(BENOIT, 'be'), 'utf-16be'],
['windows-1252', enWindows1252(BENOIT), 'windows-1252'],
];
for (const [libelle, octets, encodage] of CAS) {
test(`« Benoît » en ${libelle} : le texte, sans marque, et l'encodage retenu`, () => {
assert.deepEqual(decoder(octets), { texte: BENOIT, encodage });
});
}
test('windows-1252 : les octets 0x80 à 0x9F sont des signes, non des caractères de commande', () => {
const texte = 'l’équipe à 5 €';
assert.deepEqual(decoder(enWindows1252(texte)), { texte, encodage: 'windows-1252' });
});
test("UTF-8 : un caractère hors du plan de base et les accents d'un texte plus long", () => {
const texte = 'nom;prenom\r\nÉlodie Témoin;Noël 𝄞\r\n';
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
});
test("UTF-16 : une paire de substitution d'un caractère hors du plan de base, en LE comme en BE", () => {
const texte = 'Odile 𝄞';
assert.deepEqual(decoder(enUtf16(texte, 'le')), { texte, encodage: 'utf-16le' });
assert.deepEqual(decoder(enUtf16(texte, 'be')), { texte, encodage: 'utf-16be' });
});
test('un fichier vide, ou réduit à sa marque, donne un texte vide', () => {
assert.deepEqual(decoder(new Uint8Array(0)), { texte: '', encodage: 'utf-8' });
assert.deepEqual(decoder(Uint8Array.from(MARQUE_UTF8)), { texte: '', encodage: 'utf-8-bom' });
assert.deepEqual(decoder(Uint8Array.from([0xff, 0xfe])), { texte: '', encodage: 'utf-16le' });
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0xff])), { texte: '', encodage: 'utf-16be' });
});
test("une marque incomplète n'en est pas une : ses octets restent du texte", () => {
assert.deepEqual(decoder(enWindows1252('ïle Fictive')), { texte: 'ïle Fictive', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb])), { texte: 'ï»', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb, 0x78])), { texte: 'ï»x', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xff, 0x61])), { texte: 'ÿa', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0x61])), { texte: 'þa', encodage: 'windows-1252' });
});
test("un ArrayBuffer se décode comme les octets qu'il porte", () => {
const octets = avec(MARQUE_UTF8, enUtf8(BENOIT));
assert.deepEqual(decoder(octets.buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("une vue sur une partie d'un tampon plus grand se décode seule, sans les octets voisins", () => {
const tampon = avec([0x78, 0x78], avec(MARQUE_UTF8, avec(enUtf8(BENOIT), [0x79, 0x79])));
const vue = new Uint8Array(tampon.buffer, 2, MARQUE_UTF8.length + enUtf8(BENOIT).length);
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
});
});
describe("decoder : la forme de l'entrée", () => {
// Les octets de « Benoît » en UTF-8 avec marque, écrits dans le contexte
// d'exécution neuf, qui a ses propres ArrayBuffer et Uint8Array.
const OCTETS_ECRITS = JSON.stringify([...avec(MARQUE_UTF8, enUtf8(BENOIT))]);
test("un ArrayBuffer d'un autre contexte d'exécution se décode : instanceof ne le reconnaît pas", () => {
const tampon = runInNewContext(`new Uint8Array(${OCTETS_ECRITS}).buffer`);
assert.ok(!(tampon instanceof ArrayBuffer));
assert.deepEqual(decoder(tampon), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("un Uint8Array d'un autre contexte d'exécution se décode", () => {
const vue = runInNewContext(`new Uint8Array(${OCTETS_ECRITS})`);
assert.ok(!(vue instanceof Uint8Array));
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test('un Buffer de Node, sous-classe de Uint8Array, se décode', () => {
const buffer = Buffer.from(avec(MARQUE_UTF8, enUtf8(BENOIT)));
assert.deepEqual(decoder(buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("une entrée qui n'est pas des octets est refusée par un TypeError, non lue comme un fichier vide", () => {
// Une promesse (arrayBuffer() non attendu), un Blob (le fichier même), une
// chaîne, rien : new Uint8Array en ferait des octets en nombre nul.
const entrees = [
'abc',
undefined,
null,
Promise.resolve(new Uint8Array(0)),
new Blob(['abc']),
[0xef, 0xbb, 0xbf],
];
for (const entree of entrees) {
assert.throws(() => decoder(entree), TypeError);
}
});
});
describe('decoder : un UTF-16 sans marque est refusé, avec son remède (§ 10.1)', () => {
test('« Benoît » en UTF-16 LE sans marque : refus, remède nommé', () => {
assertRefus(refusDe(enUtf16(BENOIT, 'le', false)), 'UTF16_SANS_MARQUE');
});
test('« Benoît » en UTF-16 BE sans marque : refus, remède nommé', () => {
assertRefus(refusDe(enUtf16(BENOIT, 'be', false)), 'UTF16_SANS_MARQUE');
});
test("un contenu sans accent est de l'UTF-8 valide en UTF-16 : refusé de même, non lu en texte entrelardé de caractères nuls", () => {
// Chaque octet nul s'y décode comme le caractère nul : l'ordre à trois
// temps rendrait « n\0o\0m\0… » sans rien refuser.
const octets = enUtf16('nom;prenom\r\nBenoit Exemple;Odile', 'le', false);
assert.doesNotThrow(() => new TextDecoder('utf-8', { fatal: true }).decode(octets));
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
});
test("avec un accent, le texte n'est plus de l'UTF-8 valide : l'ordre à trois temps le rendrait en windows-1252, nuls compris — refusé", () => {
const octets = enUtf16(BENOIT, 'le', false);
assert.throws(() => new TextDecoder('utf-8', { fatal: true }).decode(octets), TypeError);
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
});
test("l'octet nul n'est cherché sans marque que dans les 4 096 premiers octets ; au-delà, c'est le caractère nul qui est refusé", () => {
const octets = new Uint8Array(4097).fill(0x61);
octets[4095] = 0;
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
octets[4095] = 0x61;
octets[4096] = 0;
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
test('la sonde commence au premier octet : « A » en UTF-16 BE sans marque, un seul octet nul', () => {
assertRefus(refusDe(Uint8Array.of(0x00, 0x41)), 'UTF16_SANS_MARQUE');
});
test('un UTF-16 à marque ne se confond pas avec le refus : ses octets nuls ne le refusent pas', () => {
const octets = enUtf16('nom;prenom', 'le');
assert.ok(octets.slice(0, 4096).includes(0));
assert.deepEqual(decoder(octets), { texte: 'nom;prenom', encodage: 'utf-16le' });
});
});
describe('decoder : un UTF-16 sous sa marque doit être valide (§ 10.1)', () => {
test("un nombre impair d'octets : refus UTF16_INVALIDE, remède nommé", () => {
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x62)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0x00, 0x61, 0x00)), 'UTF16_INVALIDE');
});
test('un substitut isolé, haut ou bas, en LE comme en BE : refus UTF16_INVALIDE', () => {
// Haut suivi d'une lettre, bas seul, haut en fin de fichier.
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xd8, 0x61, 0x00)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xdc)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x00, 0xd8)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xd8, 0x00, 0x00, 0x61)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xdc, 0x00)), 'UTF16_INVALIDE');
});
test('une paire de substitution valide reste lue', () => {
assert.deepEqual(decoder(Uint8Array.of(0xff, 0xfe, 0x34, 0xd8, 0x1e, 0xdd)), {
texte: '𝄞',
encodage: 'utf-16le',
});
});
});
describe("decoder : la marque UTF-8 déclare l'encodage (§ 10.1)", () => {
test("une marque UTF-8 suivie d'octets windows-1252 : refus UTF8_INVALIDE, remède nommé", () => {
assertRefus(refusDe(avec(MARQUE_UTF8, enWindows1252(BENOIT))), 'UTF8_INVALIDE');
});
test("une partie valide en UTF-8 puis un octet isolé : refusée, non lue en windows-1252 où chaque accent de la partie valide deviendrait des caractères parasites", () => {
const corps = [...enUtf8('Benoît Exemple;Odile Fictive\r\n'), 0xe9];
// Sans la marque, les mêmes octets se lisent en windows-1252 : « î », en
// UTF-8 C3 AE, y devient « î », sans que rien n'échoue.
const sansMarque = decoder(Uint8Array.from(corps));
assert.equal(sansMarque.encodage, 'windows-1252');
assert.ok(sansMarque.texte.startsWith('Benoît'));
assertRefus(refusDe(avec(MARQUE_UTF8, corps)), 'UTF8_INVALIDE');
});
test('une séquence UTF-8 coupée en fin de fichier : refus UTF8_INVALIDE', () => {
assertRefus(refusDe(avec(MARQUE_UTF8, [...enUtf8('Beno'), 0xc3])), 'UTF8_INVALIDE');
});
});
describe('decoder : un caractère nul dans le texte décodé est refusé (§ 10.1)', () => {
test("un UTF-32 LE, que sa marque FF FE 00 00 fait prendre pour de l'UTF-16 LE : refus CARACTERE_NUL, non un texte entrelardé de caractères nuls", () => {
const octets = enUtf32('nom;prenom\r\nBenoît Exemple;Odile', 'le');
// Sous la marque UTF-16 LE, ces octets se lisent sans erreur, nuls compris.
assert.deepEqual([...octets.slice(0, 2)], [0xff, 0xfe]);
assert.ok(new TextDecoder('utf-16le').decode(octets).includes('\0'));
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
test('un UTF-32 BE ouvre sur des octets nuls : refusé comme un UTF-16 sans marque', () => {
assertRefus(refusDe(enUtf32('nom;prenom', 'be')), 'UTF16_SANS_MARQUE');
});
// 4 097 octets dont le dernier est nul, donc hors de la sonde des 4 096
// premiers : de l'UTF-8 valide, ou, avec un octet isolé en tête, du
// windows-1252.
function octetsAvecNulLoin(invalideEnUtf8) {
const octets = new Uint8Array(4097).fill(0x61);
if (invalideEnUtf8) octets[0] = 0xe9;
octets[4096] = 0;
return octets;
}
// Un caractère nul est refusé quel que soit le temps qui a décodé le texte.
// Sous une marque, les premiers octets portent le caractère nul, ou, en
// UTF-16, des octets nuls : la marque prime sur la recherche de l'octet nul,
// et le refus nomme le caractère, non un UTF-16 sans marque.
const AVEC_NUL = [
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8('a\0b'))],
['UTF-16 LE avec marque', enUtf16('a\0b', 'le')],
['UTF-16 BE avec marque', enUtf16('a\0b', 'be')],
['UTF-8 sans marque, le nul hors de la sonde', octetsAvecNulLoin(false)],
['windows-1252, le nul hors de la sonde', octetsAvecNulLoin(true)],
];
for (const [libelle, octets] of AVEC_NUL) {
test(`${libelle} : un caractère nul dans le texte décodé est refusé, remède nommé`, () => {
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
}
test('seul le caractère nul est refusé : les autres caractères de commande passent', () => {
const texte = 'a\tb\r\nc\u0001d\u007f';
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
});
test('chaque refus porte ses propres détails', () => {
const premier = refusDe(enUtf16(BENOIT, 'le', false));
const second = refusDe(enUtf16(BENOIT, 'le', false));
assert.notEqual(premier.details, second.details);
premier.details.remede = 'AUTRE';
assert.equal(second.details.remede, 'ENREGISTRER_EN_UTF8');
});
});
describe('contientNul', () => {
test("vrai quand le texte porte U+0000, où qu'il soit", () => {
for (const texte of ['\0', 'a\0', '\0a', 'a\0b', 'nom;prenom\r\nBenoît\0;Exemple']) {
assert.equal(contientNul(texte), true, JSON.stringify(texte));
}
});
test('faux sans U+0000, les autres caractères de commande, le chiffre zéro et la barre oblique inverse comprises', () => {
for (const texte of ['', 'Benoît', 'a\tb\r\nc\u0001d\u007f', '0', '\\0', '\u2400']) {
assert.equal(contientNul(texte), false, JSON.stringify(texte));
}
});
});
describe('ErreurCsv', () => {
test('porte son code, ses détails et un message en JSON', () => {
const erreur = new ErreurCsv('UN_CODE', { cle: 1 });
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurCsv');
assert.equal(erreur.code, 'UN_CODE');
assert.deepEqual(erreur.details, { cle: 1 });
assert.equal(erreur.message, 'UN_CODE {"cle":1}');
});
test('sans détails, ses détails sont un objet vide', () => {
assert.deepEqual(new ErreurCsv('SANS_DETAIL').details, {});
});
});

21
src/csv/erreurs.js Normal file
View file

@ -0,0 +1,21 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Erreurs que lève la lecture d'un CSV. Le code d'une ErreurCsv nomme le
// refus ; ses détails portent ce que l'appelant lit pour agir, comme le
// remède d'un refus d'encodage. Le message, code et détails en JSON, sert au
// diagnostic et n'est pas un texte affiché, que fournit la table des libellés
// (§ 14.6).
export class ErreurCsv extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurCsv.prototype.name = 'ErreurCsv';

111
src/csv/export.js Normal file
View file

@ -0,0 +1,111 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'export de la liste des participants (§ 10.2, § 10.3), dans la forme exacte
// que l'import lit : UTF-8 avec marque d'ordre d'octets, « ; », CRLF, les
// champs de l'import pour en-têtes. Exporter puis réimporter rend les mêmes
// participants, champ par champ : le fichier sert de sauvegarde, et de gabarit
// que personne n'a besoin de documenter.
//
// L'écriture d'un CSV est unique : le CSV des lignes refusées d'un import passe
// par ecrireCsv, avec son propre séparateur. Une valeur s'écrit telle quelle,
// même quand elle commence par « = », « + », « - » ou « @ », qu'un tableur
// lirait comme une formule : la neutraliser romprait l'aller-retour. Une
// valeur se relit sans ses blancs de bord, que l'aperçu retire de chaque
// champ ; une valeur importée n'en porte pas.
import { CHAMPS } from './colonnes.js';
/** Séparateur de l'export : celui qu'un tableur francophone rouvre sans manipulation (§ 10.3). */
export const SEPARATEUR = ';';
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
const FIN_DE_LIGNE = '\r\n';
const GUILLEMET = '"';
// Les deux valeurs écrites d'« exclu », que la liste fermée de l'aperçu
// relit : oui pour vrai, non pour faux.
const OUI = 'oui';
const NON = 'non';
// Propriété du participant que porte chaque champ de CHAMPS.
const PROPRIETE_DU_CHAMP = new Map([
['nom', 'nom'],
['prenom', 'prenom'],
['appartenance', 'appartenance'],
['courriel', 'courriel'],
['titre_pressenti', 'titrePressenti'],
['exclu', 'exclu'],
['notes', 'notes'],
]);
// Un champ s'écrit cité (RFC 4180) quand il porte le séparateur, un retour
// chariot ou un saut de ligne, que la découpe lirait sinon comme des
// frontières, ou un guillemet, qui en tête ouvrirait un champ cité.
const SIGNES_A_CITER = /["\r\n]/;
const aCiter = (champ, separateur) => champ.includes(separateur) || SIGNES_A_CITER.test(champ);
// Champ cité : entre guillemets, chaque guillemet doublé.
const citer = (champ) => GUILLEMET + champ.replaceAll(GUILLEMET, GUILLEMET + GUILLEMET) + GUILLEMET;
/**
* Octets d'un CSV : la marque d'ordre d'octets UTF-8, puis chaque
* enregistrement, ses champs joints par le séparateur, terminé par CRLF,
* dernier compris. Un champ qui porte le séparateur, un guillemet, un retour
* chariot ou un saut de ligne s'écrit entre guillemets, les siens doublés ;
* tout autre champ s'écrit tel quel, blancs compris. decouper relit chaque
* enregistrement tel qu'il est donné, sous le même séparateur.
*
* @param {string[][]} enregistrements
* @param {string} separateur un caractère
* @returns {Uint8Array} UTF-8 avec marque
*/
export function ecrireCsv(enregistrements, separateur) {
const lignes = enregistrements.map(
(enregistrement) =>
enregistrement.map((champ) => (aCiter(champ, separateur) ? citer(champ) : champ)).join(separateur) + FIN_DE_LIGNE,
);
return new TextEncoder().encode(MARQUE_ORDRE_OCTETS + lignes.join(''));
}
// Cellule d'un champ de CHAMPS pour le participant de rang donné, à partir
// de 0 : exclu en oui ou non, un texte tel quel, null vide. Lèvent TypeError
// un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un
// autre texte ni chaîne ni null. Écrit sans bruit « non » ou vide, un exclu
// de travers réinviterait au réimport une personne qui s'est désistée ; un
// texte d'un autre genre lèverait plus loin, dans l'écriture, sans nommer ni
// la personne ni le champ.
function cellule(participant, champ, rang) {
const valeur = participant[PROPRIETE_DU_CHAMP.get(champ)];
const refuser = (attendu) => {
const recu = JSON.stringify(valeur) ?? String(valeur);
throw new TypeError(`participant ${rang + 1} de la liste, ${champ} : ${attendu} attendu, reçu ${recu}`);
};
if (champ === 'exclu') {
if (typeof valeur !== 'boolean') refuser('booléen');
return valeur ? OUI : NON;
}
if (valeur === null && champ !== 'nom') return '';
if (typeof valeur !== 'string') refuser(champ === 'nom' ? 'texte' : 'texte ou null');
return valeur;
}
/**
* La liste des participants en CSV, dans la forme exacte que l'import lit
* (§ 10.2) : UTF-8 avec marque, « ; », CRLF ; l'en-tête
* nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes, les champs
* de CHAMPS dans leur ordre ; puis un participant par ligne, dans l'ordre de
* la liste reçue, que la charge range par identifiant. exclu s'écrit oui ou
* non, un champ null vide, un champ qui porte « ; », un guillemet ou une fin
* de ligne entre guillemets. L'identifiant ne s'écrit pas : le réimport en
* attribue de neufs. La liste reçue n'est pas modifiée.
*
* @param {import('../stockage/types.js').Participant[]} participants
* @returns {Uint8Array}
* @throws {TypeError} pour un exclu qui n'est pas un booléen, un nom qui
* n'est pas une chaîne, un autre texte ni chaîne ni null
*/
export function exporterParticipants(participants) {
const lignes = participants.map((participant, rang) => CHAMPS.map((champ) => cellule(participant, champ, rang)));
return ecrireCsv([[...CHAMPS], ...lignes], SEPARATEUR);
}

193
src/csv/export.test.js Normal file
View file

@ -0,0 +1,193 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'export de la liste (§ 10.2, § 10.3) : la forme exacte que
// l'import lit — marque UTF-8, « ; », CRLF, les sept en-têtes de l'import —,
// les champs cités au besoin, et l'aller-retour qui importe, exporte,
// réimporte et compare champ par champ, sur chaque donnée d'épreuve de
// test/fixtures/csv/ et sur une liste aux valeurs difficiles. L'écriture d'un
// CSV, que l'export partage avec le CSV des refus, s'éprouve contre la
// découpe qui la relit.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { creerCharge } from '../stockage/document.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { CHAMPS } from './colonnes.js';
import { decoder } from './encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from './export.js';
import { appliquerImport } from './import.js';
import { decouper } from './lecture.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url);
const MARQUE = '\u{FEFF}';
// Texte des octets, la marque d'ordre d'octets gardée en tête.
const texteDe = (octets) => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(octets);
// Texte d'un CSV : chaque ligne donnée, terminée par CRLF.
const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join('');
// Participant, ses champs omis vides et non exclu.
const personne = (id, nom, autres = {}) => ({
id, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres,
});
// Champs d'un participant, dans l'ordre du fichier d'état.
const CHAMPS_DU_PARTICIPANT = ['id', 'nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu'];
// Participants qu'un import crée, en ajoutant, sur une charge neuve, à
// partir des octets d'un fichier ; l'aperçu ne doit refuser ni le texte ni
// aucune ligne.
function importer(octets, nom) {
const apercu = apercevoir(decoder(octets).texte);
assert.equal(apercu.refusGlobal, null, nom);
assert.deepEqual(apercu.refusees, [], nom);
const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 });
return appliquerImport(charge, apercu, 'ajouter').charge.participants;
}
// Compare deux listes de participants champ par champ, puis en entier.
function memesParticipants(obtenus, attendus, nom) {
assert.equal(obtenus.length, attendus.length, nom);
attendus.forEach((attendu, rang) => {
for (const champ of CHAMPS_DU_PARTICIPANT) {
assert.deepEqual(obtenus[rang][champ], attendu[champ], `${nom}, participant ${rang + 1}, ${champ}`);
}
});
assert.deepEqual(obtenus, attendus, nom);
}
describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", () => {
test("marque UTF-8, « ; », CRLF, les sept en-têtes de l'import dans leur ordre, un participant par ligne", () => {
const octets = exporterParticipants([
personne(1, 'Ombrelle', {
prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test', titrePressenti: 'animation',
}),
personne(2, 'Pervenche', { prenom: 'Théo', notes: 'arrive tard', exclu: true }),
]);
assert.ok(octets instanceof Uint8Array);
assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
assert.equal(
texteDe(octets),
MARQUE +
csv(
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;',
'Pervenche;Théo;;;;oui;arrive tard',
),
);
});
test("les en-têtes sont les champs de l'import, CHAMPS, et le séparateur « ; »", () => {
assert.equal(SEPARATEUR, ';');
assert.equal(CHAMPS.join(';'), 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes');
assert.equal(texteDe(exporterParticipants([])), `${MARQUE}${CHAMPS.join(SEPARATEUR)}\r\n`);
});
test("exclu s'écrit oui ou non, deux valeurs que la liste fermée de l'import relit", () => {
assert.equal(VALEURS_EXCLU.get('oui'), true);
assert.equal(VALEURS_EXCLU.get('non'), false);
const texte = texteDe(exporterParticipants([personne(1, 'Ombrelle', { exclu: true }), personne(2, 'Pervenche')]));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;oui;', 'Pervenche;;;;;non;'));
});
test('un champ qui porte « ; », un guillemet ou une fin de ligne s\'écrit cité, ses guillemets doublés', () => {
const octets = exporterParticipants([
personne(1, 'Ombrelle', { appartenance: 'Club "Les Merles" ; nord', notes: 'premier\r\nsecond' }),
personne(2, '"Pervenche"', { courriel: 'theo\r@exemple.test', notes: 'a\nb' }),
personne(3, 'Sarcelle', { notes: 'virgule, tabulation\tseules' }),
]);
assert.equal(
texteDe(octets),
MARQUE +
csv(
CHAMPS.join(';'),
'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond"',
'"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb"',
'Sarcelle;;;;;non;virgule, tabulation\tseules',
),
);
});
test("les lignes suivent l'ordre de la liste reçue", () => {
const texte = texteDe(exporterParticipants([personne(7, 'Sarcelle'), personne(2, 'Ombrelle')]));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Sarcelle;;;;;non;', 'Ombrelle;;;;;non;'));
});
test("exporterParticipants ne modifie pas la liste reçue", () => {
const participants = [personne(1, 'Ombrelle', { prenom: 'Iris', notes: 'a;b', exclu: true })];
const avant = structuredClone(participants);
exporterParticipants(participants);
assert.deepEqual(participants, avant);
});
test("un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un texte ni chaîne ni null : TypeError qui nomme la personne et le champ, rien d'écrit en silence", () => {
const refuse = (participant, message) =>
assert.throws(() => exporterParticipants([personne(1, 'Sarcelle'), participant]), { name: 'TypeError', message });
refuse(personne(2, 'Ombrelle', { exclu: null }), 'participant 2 de la liste, exclu : booléen attendu, reçu null');
refuse(personne(2, 'Ombrelle', { exclu: 'oui' }), 'participant 2 de la liste, exclu : booléen attendu, reçu "oui"');
refuse(personne(2, 'Ombrelle', { courriel: 5 }), 'participant 2 de la liste, courriel : texte ou null attendu, reçu 5');
refuse(personne(2, undefined), 'participant 2 de la liste, nom : texte attendu, reçu undefined');
refuse(personne(2, null), 'participant 2 de la liste, nom : texte attendu, reçu null');
});
});
describe("exporterParticipants : l'aller-retour, champ par champ (§ 10.2)", () => {
test("chaque donnée d'épreuve de test/fixtures/csv/ : importée, exportée, réimportée, les mêmes participants", () => {
const donnees = readdirSync(FIXTURES).sort();
assert.ok(donnees.length > 0, "aucune donnée d'épreuve");
for (const nom of donnees) {
const importes = importer(readFileSync(new URL(nom, FIXTURES)), nom);
assert.ok(importes.length > 0, nom);
const octets = exporterParticipants(importes);
assert.equal(apercevoir(decoder(octets).texte).separateur, ';', nom);
memesParticipants(importer(octets, nom), importes, nom);
}
});
test('une liste aux valeurs difficiles : guillemets, séparateurs, fins de ligne, formules, accents, exclusions', () => {
const difficiles = [
personne(1, 'Ombrelle', {
prenom: 'Iris', appartenance: 'Club "Les Merles" ; nord', courriel: 'iris@exemple.test',
titrePressenti: 'animation', notes: 'premier\r\nsecond', exclu: true,
}),
personne(2, '"Pervenche"', { prenom: 'Théo', notes: 'virgule, tabulation\tet ; point-virgule' }),
personne(3, '=SOMME(A1:A3)', { prenom: '+33', appartenance: '-moins', courriel: '@arobase' }),
personne(4, 'Sarcelle', { prenom: 'Ondine', appartenance: 'Chœur de l\u{2019}Anse', notes: 'saut\nseul\rretour seul' }),
personne(5, 'Bruyère', { titrePressenti: 'fin"', notes: '"' }),
];
const octets = exporterParticipants(difficiles);
assert.equal(apercevoir(decoder(octets).texte).separateur, ';');
memesParticipants(importer(octets, 'difficiles'), difficiles, 'difficiles');
});
});
describe("ecrireCsv : l'écriture d'un CSV, qu'une découpe relit telle quelle", () => {
test('cite un champ qui porte le séparateur, un guillemet, un retour chariot ou un saut de ligne ; tout autre champ tel quel', () => {
assert.equal(
texteDe(ecrireCsv([['a;b', 'c"d', 'e\rf', 'g\nh', ' i ', '', 'j,k\tl']], ';')),
`${MARQUE}"a;b";"c""d";"e\rf";"g\nh"; i ;;j,k\tl\r\n`,
);
});
test('le séparateur reçu décide : sous la tabulation, « ; » et « , » restent nus', () => {
assert.equal(texteDe(ecrireCsv([['a;b', 'c\td', 'e,f']], '\t')), `${MARQUE}a;b\t"c\td"\te,f\r\n`);
assert.equal(texteDe(ecrireCsv([['a;b', 'c\td', 'e,f']], ',')), `${MARQUE}a;b,c\td,"e,f"\r\n`);
});
test('decouper relit chaque enregistrement tel qu\'écrit, sous chacun des trois séparateurs', () => {
const enregistrements = [
['nom', 'notes', 'courriel'],
['"Ombrelle"', 'a;b,c\td', 'x'],
['x\r\ny', ' ', '""'],
['', 'fin"', 'z\r'],
];
for (const separateur of [';', ',', '\t']) {
const { texte, encodage } = decoder(ecrireCsv(enregistrements, separateur));
assert.equal(encodage, 'utf-8-bom');
assert.deepEqual(decouper(texte, separateur), { enregistrements, guillemetOuvert: false }, separateur);
}
});
});

342
src/csv/import.js Normal file
View file

@ -0,0 +1,342 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'import appliqué (§ 10.1) : ce que remplacer détruirait, à annoncer avant
// d'agir ; l'aperçu appliqué à une charge, selon le mode ; le CSV des lignes
// refusées, qui se corrige et se réimporte tel quel.
//
// appliquerImport n'agit que sur un plan qui n'est pas bloqué, et sur un
// aperçu sans refus global ni ambiguïté. Il rend une charge neuve, qui ne
// partage aucun objet avec celle qu'il reçoit : l'import entier tient dans
// une seule entrée d'historique, que la commande d'import écrit (§ 8.2). Les
// personnes ajoutées prennent leurs identifiants à partir de
// prochainsIds.participant, dans l'ordre du fichier, et le compteur ne recule
// jamais : un identifiant retiré ne revient pas (§ 4).
//
// Le CSV des refus reprend chaque ligne refusée telle que découpée, sous
// l'en-tête d'origine, et ajoute deux colonnes, ligne et motif, qu'aucun
// en-tête ne reconnaît : corrigé, il se réimporte sans qu'on en retire rien.
// Réimporté sans correction, il ne fait entrer aucune ligne que l'aperçu a
// refusée : chacune l'est de nouveau pour le même motif, sauf une ligne aux
// champs en trop parmi les vingt premiers enregistrements, qui fait refuser
// le texte entier, à son rang, par le choix du séparateur.
import { ErreurCsv } from './erreurs.js';
import { SEPARATEUR, ecrireCsv } from './export.js';
import { enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']);
// Champs d'une ligne de l'aperçu, dans l'ordre du participant : ceux que
// porte une personne créée, et que pose une mise à jour quand la cellule
// n'est pas vide.
const CHAMPS_LUS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu']);
// En-têtes des deux colonnes qu'ajoute le CSV des refus, inconnues de
// l'association des colonnes.
const ENTETE_LIGNE = 'ligne';
const ENTETE_MOTIF = 'motif';
/**
* Une ligne refusée, par l'aperçu ou par la mise à jour (§ 10.1). Le CSV des
* refus en écrit une par ligne.
*
* @typedef {Object} LigneRefusee
* @property {number} ligne rang de l'enregistrement, en-tête compris
* @property {string} code CHAMPS_EN_TROP, NOM_ABSENT ou
* EXCLU_INCONNU, de l'aperçu ; PLUSIEURS_CORRESPONDENT ou
* DEJA_DESIGNE, de la mise à jour
* @property {string|null} valeur la valeur d'« exclu » refusée sous
* EXCLU_INCONNU, null sous tout autre code
* @property {number[]} [participants] refus de la mise à jour : les
* identifiants croissants que la ligne désigne
* @property {number} [premiereLigne] DEJA_DESIGNE : la ligne qui désigne
* ce participant la première
* @property {string[]} brut les champs tels que découpés
*
* @typedef {Object} ResumeImport
* @property {number} ajoutes participants créés
* @property {number} misAJour participants dont une valeur au
* moins a changé
* @property {number} inchanges participants désignés par une
* ligne qui ne change rien
* @property {number} retires participants retirés
* @property {number} reservationsRetirees leurs réservations
* @property {LigneRefusee[]} refusees rangées par ligne
* @property {boolean} derive vrai en état retenu
*/
const croissant = (a, b) => a - b;
const parLigne = (a, b) => a.ligne - b.ligne;
// Clé qui apparie une ligne aux participants existants : le nom et le
// prénom, chacun par sa clé normalisée, un prénom absent comptant comme
// vide, écrits en JSON pour qu'aucun texte ne se confonde avec une frontière.
const cleDeNom = (nom, prenom) => JSON.stringify([cleNormalisee(nom), cleNormalisee(prenom ?? '')]);
// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation
// d'une personne non exclue, sur sa place, à un tour au moins : la
// réservation d'une personne exclue est suspendue. La place se lit selon le
// réglage attribuerSieges (§ 2.1, étape 6). Vrai, c'est un siège : une
// réservation sans siège n'y pourvoit aucun titre. Faux, c'est la table, dont
// les titres ne se distinguent pas : à un tour donné, une table de k titres
// en a autant de pourvus que de personnes non exclues qui y sont réservées à
// ce tour, k au plus, et ses titres pourvus sont ceux du tour qui en pourvoit
// le plus.
function titresPourvus({ evenement, reglages, participants, reservations, titres }) {
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
const actives = reservations.filter(({ participant }) => presents.has(participant));
if (reglages.attribuerSieges) {
// Un titre porte toujours un siège : la place d'une réservation sans
// siège, [table, null], n'est celle d'aucun titre.
const place = (table, siege) => JSON.stringify([table, siege]);
const occupees = new Set(actives.map(({ table, siege }) => place(table, siege)));
return titres.filter(({ table, siege }) => occupees.has(place(table, siege))).length;
}
const titresParTable = new Map();
for (const { table } of titres) titresParTable.set(table, (titresParTable.get(table) ?? 0) + 1);
let pourvus = 0;
for (const [table, nombre] of titresParTable) {
let auMieux = 0;
for (let tour = 1; tour <= evenement.tours; tour += 1) {
const reserves = new Set(
actives
.filter((r) => r.table === table && (r.portee === 'tous' || r.tour === tour))
.map(({ participant }) => participant),
);
auMieux = Math.max(auMieux, Math.min(nombre, reserves.size));
}
pourvus += auMieux;
}
return pourvus;
}
/**
* Ce que remplacer détruirait, à annoncer avant d'agir (§ 10.1) : « 260
* personnes seront supprimées » laisse croire qu'on ne perd que des noms.
* Remplacer retire chaque participant et ses réservations ; il garde les
* titres, qui appartiennent aux places, et les propositions, qui deviennent
* périmées (§ 8.9). Le bilan compte :
* - participants : les participants retirés, exclus compris ;
* - reservations : leurs réservations, suspendues comprises, soit toutes :
* chaque réservation d'une charge que l'analyse admet est celle d'un
* participant ;
* - titresPourvus : les titres qu'une réservation pourvoit, et que le
* remplacement laisse non pourvus — par la réservation d'une personne non
* exclue, sur son siège quand les sièges sont attribués, sur sa table
* sinon, à un tour au moins ;
* - propositions : les propositions qui placent au moins une personne
* retirée. Le retenu n'y entre pas : l'import en état retenu porte
* l'avertissement de dérive.
* La charge n'est pas modifiée.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {{participants: number, reservations: number, titresPourvus: number, propositions: number}}
*/
export function bilanRemplacement(charge) {
const retires = new Set(charge.participants.map(({ id }) => id));
return {
participants: charge.participants.length,
reservations: charge.reservations.length,
titresPourvus: titresPourvus(charge),
propositions: charge.propositions.filter(({ participants }) => participants.some((id) => retires.has(id))).length,
};
}
// Participant créé d'une ligne de l'aperçu, ses clés dans l'ordre du
// fichier d'état : un exclu vide vaut non.
const creer = (id, { nom, prenom, appartenance, courriel, titrePressenti, notes, exclu }) => ({
id, nom, prenom, appartenance, courriel, titrePressenti, notes, exclu: exclu ?? false,
});
// Pose sur le participant chaque champ non vide de la ligne : un champ vide
// garde la valeur (§ 10.1). Rend vrai quand une valeur a changé.
function poser(participant, champs) {
let change = false;
for (const champ of CHAMPS_LUS) {
if (champs[champ] !== null && champs[champ] !== participant[champ]) {
participant[champ] = champs[champ];
change = true;
}
}
return change;
}
// Met à jour, en place, les participants que désignent les lignes, et rend
// les lignes à ajouter, les refus et les comptes. Une ligne désigne les
// participants d'avant l'import dont le nom et le prénom ont la même clé
// normalisée que les siens, et l'appartenance aussi quand la ligne en porte
// une : un participant ajouté par une ligne n'est désigné par aucune autre.
// Un prénom absent compte comme vide, sur la ligne comme chez le
// participant : seule une ligne sans prénom désigne un participant sans
// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne
// filtre même un homonyme unique, et un participant sans appartenance ne
// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter,
// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est
// refusée PLUSIEURS_CORRESPONDENT. Le participant qu'une ligne désigne seule
// est mis à jour par la première de ces lignes, dans l'ordre du fichier ;
// chaque suivante est refusée DEJA_DESIGNE : deux lignes ne se fondent pas
// en silence dans une même personne.
function mettreAJour(participants, lignes) {
const parNom = new Map();
for (const participant of participants) {
const cle = cleDeNom(participant.nom, participant.prenom);
if (!parNom.has(cle)) parNom.set(cle, []);
parNom.get(cle).push(participant);
}
const designes = new Map();
const nouvelles = [];
const refusees = [];
let misAJour = 0;
let inchanges = 0;
for (const { ligne, champs, brut } of lignes) {
const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance);
const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter(
(participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance,
);
if (candidats.length === 0) {
nouvelles.push({ champs });
continue;
}
if (candidats.length > 1) {
const ids = candidats.map(({ id }) => id).sort(croissant);
refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] });
continue;
}
const [designe] = candidats;
if (designes.has(designe.id)) {
refusees.push({
ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [designe.id], premiereLigne: designes.get(designe.id),
brut: [...brut],
});
continue;
}
designes.set(designe.id, ligne);
if (poser(designe, champs)) misAJour += 1;
else inchanges += 1;
}
return { nouvelles, refusees, misAJour, inchanges };
}
/**
* Applique un aperçu à une charge (§ 10.1), selon le mode :
* - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se
* fusionne jamais ;
* - 'mettreAJour' : une ligne désigne les participants d'avant l'import de
* même nom et même prénom normalisés, et de même appartenance quand la
* ligne en porte une : un prénom absent compte comme vide, sur la ligne
* comme chez le participant, et un participant sans appartenance n'est
* désigné que par une ligne qui n'en porte pas. Désigné seul, le
* participant prend chaque champ non vide de la ligne, nom et prénom
* compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une
* ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs,
* refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une
* ligne précédente désigne déjà, refusée DEJA_DESIGNE ;
* - 'remplacer' : les participants et leurs réservations sont retirés,
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
* restent (bilanRemplacement).
* Une personne créée prend les champs de sa ligne, exclu vide valant non ;
* les identifiants partent de prochainsIds.participant, dans l'ordre du
* fichier, et le compteur avance d'autant. La charge et l'aperçu reçus ne
* sont pas modifiés ; la charge rendue n'en partage aucun objet.
*
* Le résumé (ResumeImport) compte les participants ajoutés, mis à jour,
* inchangés et retirés, et les réservations retirées. refusees porte les
* lignes refusées par l'aperçu, puis par la mise à jour, rangées par ligne :
* c'est la liste que reçoit exporterRefus. Chaque ligne valide est comptée
* une fois : ajoutée, mise à jour, inchangée ou refusée. Une mise à jour dont
* toutes les lignes sont refusées rend une charge égale à celle reçue.
* derive est vrai en état retenu : l'import y avertit sans bloquer (§ 9).
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('./apercu.js').Apercu} apercu calculé contre les
* participants de la charge : l'appartenance d'une ligne y prend
* l'orthographe d'un existant de même clé, que remplacer garde même quand
* il retire cet existant
* @param {'ajouter'|'mettreAJour'|'remplacer'} mode
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}}
* @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état
* bloqué ; le refus global de l'aperçu, son code, détails { ligne }, le rang
* que nomme l'aperçu ou null ; AMBIGUITE, détails { champs }, les champs
* que l'en-tête laisse ambigus, tant que le choix ne les tranche pas
* @throws {TypeError} pour un mode inconnu
*/
export function appliquerImport(charge, apercu, mode) {
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE');
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
const { ambigus } = apercu.association;
if (ambigus.length > 0) throw new ErreurCsv('AMBIGUITE', { champs: ambigus.map(({ champ }) => champ) });
const resultat = structuredClone(charge);
let retires = 0;
let reservationsRetirees = 0;
let aAjouter = apercu.lignes;
let suite = { refusees: [], misAJour: 0, inchanges: 0 };
if (mode === 'remplacer') {
retires = resultat.participants.length;
reservationsRetirees = resultat.reservations.length;
resultat.participants = [];
resultat.reservations = [];
} else if (mode === 'mettreAJour') {
suite = mettreAJour(resultat.participants, apercu.lignes);
aAjouter = suite.nouvelles;
}
for (const { champs } of aAjouter) {
resultat.participants.push(creer(resultat.prochainsIds.participant, champs));
resultat.prochainsIds.participant += 1;
}
return {
charge: resultat,
resume: {
ajoutes: aAjouter.length,
misAJour: suite.misAJour,
inchanges: suite.inchanges,
retires,
reservationsRetirees,
refusees: [...structuredClone(apercu.refusees), ...suite.refusees].sort(parLigne),
derive: charge.evenement.etat === 'retenu',
},
};
}
/**
* CSV des lignes refusées (§ 10.1, § 14.10), qui se corrige et se réimporte
* tel quel. L'en-tête est celui d'origine, tel qu'écrit, suivi de ligne et
* motif, deux en-têtes qu'aucun champ ne reconnaît. Chaque refus, rangé par
* ligne, écrit ses cellules d'origine telles que découpées, complétées de
* cellules vides jusqu'à la largeur de l'en-tête, puis son rang et son
* motif : ligne et motif restent sous leur en-tête. Des cellules au-delà de
* l'en-tête ne s'écrivent, après le motif, que lorsque l'une n'est pas
* blanche — la ligne refusée CHAMPS_EN_TROP — : réimporté sans correction,
* le fichier ne fait entrer aucune ligne décalée. UTF-8 avec marque, CRLF,
* séparateur d'origine, « ; » pour un fichier à une colonne, qui n'en a pas ;
* sans refus, l'en-tête seul.
*
* @param {import('./apercu.js').Apercu} apercu ses en-têtes et son séparateur
* @param {(code: string, details: Object) => string} motif le texte d'un
* refus, de son code et de ses détails : la LigneRefusee sans code ni
* brut — ligne et valeur, plus participants et premiereLigne pour un refus
* de la mise à jour
* @param {LigneRefusee[]} [refusees] les refus à écrire : par défaut ceux
* de l'aperçu, sous un refus global comme sans ; après l'application,
* resume.refusees, qui porte aussi ceux de la mise à jour
* @returns {Uint8Array}
* @throws {TypeError} quand motif ne rend pas une chaîne
*/
export function exporterRefus(apercu, motif, refusees = apercu.refusees) {
const largeur = apercu.entetes.length;
const enregistrements = [[...apercu.entetes, ENTETE_LIGNE, ENTETE_MOTIF]];
for (const { code, brut, ...details } of [...refusees].sort(parLigne)) {
const texte = motif(code, details);
if (typeof texte !== 'string') {
throw new TypeError(`motif de ${code} : chaîne attendue, reçu ${JSON.stringify(texte) ?? String(texte)}`);
}
const cellules = brut.slice(0, largeur);
while (cellules.length < largeur) cellules.push('');
const auDela = brut.slice(largeur);
enregistrements.push([...cellules, String(details.ligne), texte, ...(enregistrementVide(auDela) ? [] : auDela)]);
}
return ecrireCsv(enregistrements, apercu.separateur ?? SEPARATEUR);
}

858
src/csv/import.test.js Normal file
View file

@ -0,0 +1,858 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'import appliqué (§ 10.1, § 14.10) : ajouter, mettre à jour,
// remplacer, et ce que remplacer annonce avant d'agir ; l'import selon l'état
// du plan ; les refus globaux ; les identifiants jamais réutilisés ; le CSV
// des lignes refusées, sa forme, et la boucle qui le corrige et le réimporte
// tel quel. Les marques combinantes s'écrivent par leur point de code.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { apercevoir } from './apercu.js';
import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { appliquerImport, bilanRemplacement, exporterRefus } from './import.js';
import { decouper } from './lecture.js';
const MARQUE = '\u{FEFF}';
// Texte d'un CSV : chaque ligne donnée, terminée par CRLF.
const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join('');
// Octets d'un fichier UTF-8 avec marque, de texte donné.
const enUtf8 = (texte) => new TextEncoder().encode(MARQUE + texte);
// Octets windows-1252 d'un texte dont chaque caractère est de Latin-1 hors
// des commandes C1 : windows-1252 y donne à chacun l'octet de son point de
// code.
const enWindows1252 = (texte) => Uint8Array.from(texte, (caractere) => caractere.charCodeAt(0));
// Octets UTF-16 LE avec marque d'un texte.
function enUtf16le(texte) {
const octets = new Uint8Array(2 + 2 * texte.length);
octets.set([0xff, 0xfe]);
for (let i = 0; i < texte.length; i += 1) {
const unite = texte.charCodeAt(i);
octets[2 + 2 * i] = unite & 0xff;
octets[3 + 2 * i] = unite >> 8;
}
return octets;
}
// Dix-neuf patronymes inventés : les lignes 2 à 20 d'un texte, la fenêtre où
// se choisit le séparateur. Un enregistrement dont le nombre de champs
// diffère de celui de l'en-tête n'est une ligne refusée qu'au-delà, à partir
// du rang 21 ; dans la fenêtre, il refuse le texte entier.
const REMPLISSAGE = [
'Aubier', 'Bourgeon', 'Cormier', 'Duvet', 'Églantier', 'Frêne', 'Genêt', 'Houx', 'Ibéris', 'Jasmin',
'Lierre', 'Mélèze', 'Noisetier', 'Orme', 'Pommier', 'Quenouille', 'Ronce', 'Saule', 'Tilleul',
];
// Participant, ses champs omis vides et non exclu.
const personne = (id, nom, prenom = null, appartenance = null, autres = {}) => ({
id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres,
});
const table = (id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 250 * (id - 1), y: 0 } });
// Réservation de portée « tous les tours », ou du tour donné.
const reservation = (participant, tableReservee, siege, tour = null) => ({
participant, table: tableReservee, siege, portee: tour === null ? 'tous' : 'tour', tour,
});
const titre = (tableTitree, siege, libelle = 'animation') => ({ table: tableTitree, siege, libelle });
// Proposition de deux tours sur les tables 1 et 2, qui place les
// participants donnés, triés, en alternance.
function proposition(id, participants) {
const tour = (decalage) => ({
sieges: [0, 1].map((rang) => participants.filter((_, i) => (i + decalage) % 2 === rang)),
reserve: [],
});
return {
id, graine: 48271 + id, arret: 200000, historique: 1000, produitVersion: VERSION.affichee, siegesAttribues: false,
tables: [1, 2], capacites: [4, 4], tours: 2, participants, placement: [tour(0), tour(1)],
};
}
// Charge d'un événement de deux tours, tables de quatre sièges par défaut,
// qui porte ce qu'on lui donne. Le compteur des identifiants de participant
// dépasse par défaut le plus grand donné ; ceux des tables et des
// propositions, toujours.
function chargeDe({
participants = [], tables = [], reservations = [], titres = [], propositions = [], retenu = null,
etat = 'brouillon', attribuerSieges = false, prochain,
} = {}) {
const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 });
const auDela = (ids) => ids.reduce((plus, id) => Math.max(plus, id), 0) + 1;
charge.evenement.etat = etat;
charge.reglages.attribuerSieges = attribuerSieges;
charge.prochainsIds = {
participant: prochain ?? auDela(participants.map(({ id }) => id)),
table: auDela(tables.map(({ id }) => id)),
proposition: auDela(propositions.map(({ id }) => id)),
};
charge.participants = participants;
charge.tables = tables;
charge.reservations = reservations;
charge.titres = titres;
charge.propositions = propositions;
charge.retenu = retenu;
return charge;
}
// Une charge garnie : quatre personnes dont une exclue, deux tables, des
// réservations, des titres, deux propositions et un retenu.
function chargeGarnie(autres = {}) {
return chargeDe({
participants: [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' }),
personne(2, 'Grisaille', null, 'Club des Merles', { exclu: true }),
personne(3, 'Pervenche', 'Théo'),
personne(4, 'Lacasse', 'Ondine', 'Société Alpha'),
],
tables: [table(1), table(2)],
reservations: [reservation(1, 1, 1), reservation(2, 1, 2), reservation(3, 2, 1, 2), reservation(4, 2, null)],
titres: [titre(1, 1), titre(1, 2, 'accueil'), titre(2, 1), titre(2, 2)],
propositions: [proposition(1, [1, 2, 3, 4]), proposition(2, [1, 3])],
retenu: retenuDe(proposition(1, [1, 2, 3, 4])),
prochain: 9,
...autres,
});
}
// Retenu tiré d'une proposition : son identifiant d'origine, puis les
// champs du plan, sans ceux qui ne sont qu'à une proposition.
function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) {
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
}
// Aperçu d'un texte contre les participants de la charge, puis import.
function importer(charge, texte, mode, choix = {}) {
return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix }), mode);
}
// Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et
// virgules, que le CSV des refus doit citer.
const MOTIF = (code, details) => `${code} ${JSON.stringify(details)}`;
// Valide une ErreurCsv de code et de détails donnés.
const refus = (code, details) => (erreur) => {
assert.ok(erreur instanceof ErreurCsv, String(erreur));
assert.equal(erreur.code, code);
assert.deepEqual(erreur.details, details);
return true;
};
// Objets et listes d'une donnée JSON : JSON.stringify visite chaque valeur,
// et le remplaçant relève au passage chaque objet et chaque liste.
function objetsDe(valeur) {
const vus = new Set();
JSON.stringify(valeur, (_, v) => {
if (typeof v === 'object' && v !== null) vus.add(v);
return v;
});
return vus;
}
// Résumé d'un import, ses comptes omis à zéro.
const resumeDe = (comptes = {}) => ({
ajoutes: 0, misAJour: 0, inchanges: 0, retires: 0, reservationsRetirees: 0, refusees: [], derive: false, ...comptes,
});
const MODES = ['ajouter', 'mettreAJour', 'remplacer'];
describe('appliquerImport : ajouter (§ 10.1)', () => {
test("chaque ligne valide devient un participant, identifiants depuis prochainsIds dans l'ordre du fichier", () => {
const charge = chargeDe({ participants: [personne(3, 'Lacasse', 'Ondine')], prochain: 5 });
const { charge: apres, resume } = importer(
charge,
csv(
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;arrive tôt',
'Pervenche;Théo;;;;oui;',
'Sarcelle;;;;;;',
),
'ajouter',
);
assert.deepEqual(apres.participants, [
personne(3, 'Lacasse', 'Ondine'),
personne(5, 'Ombrelle', 'Iris', 'Club des Merles', {
courriel: 'iris@exemple.test', titrePressenti: 'animation', notes: 'arrive tôt',
}),
personne(6, 'Pervenche', 'Théo', null, { exclu: true }),
personne(7, 'Sarcelle'),
]);
assert.equal(apres.prochainsIds.participant, 8);
assert.deepEqual(resume, resumeDe({ ajoutes: 3 }));
});
test("sans colonne exclu, ou sa cellule vide, la personne créée n'est pas exclue", () => {
const { charge: apres } = importer(chargeDe(), csv('nom', 'Ombrelle', 'Pervenche'), 'ajouter');
assert.deepEqual(apres.participants.map(({ exclu }) => exclu), [false, false]);
});
test("ajouter ne fusionne jamais un doublon : la ligne d'un participant existant en crée un second", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')] });
const { charge: apres, resume } = importer(
charge, csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles', 'ombrelle;IRIS;club des merles'), 'ajouter',
);
assert.deepEqual(apres.participants, [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(3, 'ombrelle', 'IRIS', 'Club des Merles'),
]);
assert.equal(resume.ajoutes, 2);
});
test("l'appartenance prend l'orthographe que l'aperçu affiche ; une appartenance de clé vide arrive à null", () => {
const charge = chargeDe({ participants: [personne(1, 'Lacasse', 'Ondine', 'Club des Merles')] });
const { charge: apres } = importer(
charge, csv('nom;prenom;appartenance', 'Ombrelle;Iris;CLUB DES MERLES', 'Pervenche;Théo;\u{0301}\u{0301}'), 'ajouter',
);
assert.deepEqual(apres.participants.map(({ appartenance }) => appartenance), ['Club des Merles', 'Club des Merles', null]);
});
test("les lignes refusées de l'aperçu passent au résumé, dans l'ordre des lignes, et ne créent rien", () => {
const { charge: apres, resume } = importer(
chargeDe(), csv('nom;prenom;exclu', 'Ombrelle;Iris;', ';Théo;non', 'Sarcelle;Ondine;peut-être'), 'ajouter',
);
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris')]);
assert.deepEqual(resume, resumeDe({
ajoutes: 1,
refusees: [
{ ligne: 3, code: 'NOM_ABSENT', valeur: null, brut: ['', 'Théo', 'non'] },
{ ligne: 4, code: 'EXCLU_INCONNU', valeur: 'peut-être', brut: ['Sarcelle', 'Ondine', 'peut-être'] },
],
}));
});
});
describe('appliquerImport : mettre à jour (§ 10.1)', () => {
test('un fichier qui ne porte que des noms ne vide aucun courriel, ni aucun autre champ', () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles', {
courriel: 'iris@exemple.test', titrePressenti: 'animation', notes: 'arrive tard', exclu: true,
}),
personne(2, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' }),
];
const charge = chargeDe({ participants: existants });
const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Ombrelle;Iris', 'Pervenche;Théo'), 'mettreAJour');
assert.deepEqual(apres.participants, existants);
assert.deepEqual(resume, resumeDe({ inchanges: 2 }));
});
test('un champ non vide remplace la valeur, un champ vide la garde ; la ligne désigne sans égard à la casse, aux accents ni aux blancs', () => {
const charge = chargeDe({
participants: [personne(1, 'Pervenche', 'Théo', 'Club des Merles', { courriel: 'ancien@exemple.test', notes: 'garde' })],
});
const { charge: apres, resume } = importer(
charge,
csv('nom;prenom;appartenance;courriel;notes', ' PERVENCHE ;theo;club des merles;nouveau@exemple.test;'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [
personne(1, 'PERVENCHE', 'theo', 'Club des Merles', { courriel: 'nouveau@exemple.test', notes: 'garde' }),
]);
assert.deepEqual(resume, resumeDe({ misAJour: 1 }));
});
test('exclu : une cellule vide garde la valeur, non réintègre, oui exclut', () => {
const charge = chargeDe({
participants: [
personne(1, 'Ombrelle', null, null, { exclu: true }),
personne(2, 'Pervenche', null, null, { exclu: true }),
personne(3, 'Sarcelle'),
],
});
const { charge: apres, resume } = importer(charge, csv('nom;exclu', 'Ombrelle;', 'Pervenche;non', 'Sarcelle;oui'), 'mettreAJour');
assert.deepEqual(apres.participants.map(({ exclu }) => exclu), [true, false, true]);
assert.deepEqual(resume, resumeDe({ misAJour: 2, inchanges: 1 }));
});
test("une ligne qui désigne deux participants est refusée PLUSIEURS_CORRESPONDENT, leurs identifiants nommés ; l'appartenance de la ligne les départage", () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'),
];
const charge = chargeDe({ participants: existants });
const { charge: apres, resume } = importer(
charge,
csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;;iris@exemple.test', 'Ombrelle;Iris;chorale du givre;givre@exemple.test'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [existants[0], { ...existants[1], courriel: 'givre@exemple.test' }]);
assert.deepEqual(resume, resumeDe({
misAJour: 1,
refusees: [{
ligne: 2, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: [1, 2],
brut: ['Ombrelle', 'Iris', '', 'iris@exemple.test'],
}],
}));
});
test("une ligne qui ne désigne personne est ajoutée, son identifiant depuis prochainsIds", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris')], prochain: 4 });
const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Ombrelle;Iris', 'Ombrelle;Théo'), 'mettreAJour');
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris'), personne(4, 'Ombrelle', 'Théo')]);
assert.equal(apres.prochainsIds.participant, 5);
assert.deepEqual(resume, resumeDe({ ajoutes: 1, inchanges: 1 }));
});
test("l'appartenance que porte la ligne compte même devant un seul homonyme : d'une autre appartenance, la ligne est ajoutée, et l'homonyme garde ses valeurs", () => {
const existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'),
'mettreAJour',
);
assert.deepEqual(apres.participants, [
existants[0],
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }),
]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
});
test('une ligne sans prénom ne désigne que des participants sans prénom : devant le seul participant de ce nom, qui en porte un, elle est ajoutée, et lui reste tel quel', () => {
// La clé est (nom, prénom), un prénom absent y comptant comme vide : une
// liste de noms sans prénoms ajoute des personnes au lieu de mettre à
// jour les inscrits de même nom.
const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')];
const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour');
assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
});
test('une ligne qui porte un prénom ne désigne pas un participant sans prénom : elle est ajoutée, et lui reste sans prénom', () => {
const existants = [personne(1, 'Grisaille', null, 'Club des Merles')];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour',
);
assert.deepEqual(apres.participants, [...existants, personne(2, 'Grisaille', 'Silas', null, { exclu: true })]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
});
test("un participant sans appartenance n'est désigné que par une ligne qui n'en porte pas : celle qui en porte une est ajoutée, et lui reste sans appartenance", () => {
const existants = [personne(1, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }), csv('nom;prenom;appartenance', 'Pervenche;Théo;Club des Merles'), 'mettreAJour',
);
assert.deepEqual(apres.participants, [...existants, personne(2, 'Pervenche', 'Théo', 'Club des Merles')]);
assert.deepEqual(resume, resumeDe({ ajoutes: 1 }));
});
test("l'appartenance se compare par sa clé normalisée, des deux côtés : la ligne désigne l'homonyme qui l'écrit sans accents ou en capitales, et lui pose l'orthographe affichée", () => {
// L'aperçu donne aux deux lignes l'orthographe du premier existant,
// « Société Alpha » ; les participants qu'elles désignent en écrivent
// chacun une autre.
const existants = [
personne(1, 'Lacasse', 'Ondine', 'Société Alpha'),
personne(2, 'Ombrelle', 'Iris', 'Societe Alpha'),
personne(3, 'Pervenche', 'Théo', 'SOCIÉTÉ ALPHA'),
];
const { charge: apres, resume } = importer(
chargeDe({ participants: existants }),
csv(
'nom;prenom;appartenance;courriel',
'Ombrelle;Iris;société alpha;iris@exemple.test',
'Pervenche;Théo;Societe alpha;theo@exemple.test',
),
'mettreAJour',
);
assert.deepEqual(apres.participants, [
existants[0],
personne(2, 'Ombrelle', 'Iris', 'Société Alpha', { courriel: 'iris@exemple.test' }),
personne(3, 'Pervenche', 'Théo', 'Société Alpha', { courriel: 'theo@exemple.test' }),
]);
assert.deepEqual(resume, resumeDe({ misAJour: 2 }));
});
test('deux lignes qui désignent le même participant : la première le met à jour, la seconde est refusée DEJA_DESIGNE', () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris')] });
const { charge: apres, resume } = importer(
charge, csv('nom;prenom;courriel', 'Ombrelle;Iris;a@exemple.test', 'ombrelle;iris;b@exemple.test'), 'mettreAJour',
);
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris', null, { courriel: 'a@exemple.test' })]);
assert.deepEqual(resume, resumeDe({
misAJour: 1,
refusees: [{
ligne: 3, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 2,
brut: ['ombrelle', 'iris', 'b@exemple.test'],
}],
}));
});
test('une mise à jour dont toutes les lignes sont refusées rend une charge égale à celle reçue ; les identifiants refusés, croissants', () => {
const charge = chargeDe({
participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')],
});
const { charge: apres, resume } = importer(
charge, csv('nom;prenom;courriel', 'Ombrelle;Iris;a@exemple.test', 'OMBRELLE;iris;'), 'mettreAJour',
);
assert.deepEqual(apres, charge);
assert.deepEqual(resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [
[2, 'PLUSIEURS_CORRESPONDENT', [1, 2]],
[3, 'PLUSIEURS_CORRESPONDENT', [1, 2]],
]);
assert.deepEqual(resume, resumeDe({ refusees: resume.refusees }));
});
test("la correspondance porte sur les participants d'avant l'import : deux lignes nouvelles et identiques font deux participants", () => {
const { charge: apres, resume } = importer(chargeDe(), csv('nom;prenom', 'Ombrelle;Iris', 'Ombrelle;Iris'), 'mettreAJour');
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Ombrelle', 'Iris')]);
assert.deepEqual(resume, resumeDe({ ajoutes: 2 }));
});
});
describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', () => {
test("bilanRemplacement compte, avant d'agir, les participants, les réservations, les titres pourvus et les propositions touchées", () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Sarcelle')],
tables: [table(1), table(2)],
reservations: [reservation(1, 1, 1), reservation(2, 2, 3)],
titres: [titre(1, 1), titre(2, 1)],
propositions: [proposition(1, [1, 2, 3])],
attribuerSieges: true,
});
assert.deepEqual(bilanRemplacement(charge), { participants: 3, reservations: 2, titresPourvus: 1, propositions: 1 });
});
test("le bilan annonce ce que remplacer retire : participants et réservations du résumé, titres et propositions gardés", () => {
const charge = chargeGarnie();
const bilan = bilanRemplacement(charge);
const { charge: apres, resume } = importer(charge, csv('nom;prenom', 'Bruyère;Anouk', 'Sarcelle;Ondine'), 'remplacer');
assert.equal(resume.retires, bilan.participants);
assert.equal(resume.reservationsRetirees, bilan.reservations);
assert.deepEqual(resume, resumeDe({ ajoutes: 2, retires: 4, reservationsRetirees: 4 }));
assert.deepEqual(apres.participants, [personne(9, 'Bruyère', 'Anouk'), personne(10, 'Sarcelle', 'Ondine')]);
assert.deepEqual(apres.reservations, []);
for (const cle of ['evenement', 'reglages', 'tables', 'titres', 'propositions', 'retenu']) {
assert.deepEqual(apres[cle], charge[cle], cle);
}
assert.deepEqual(apres.prochainsIds, { ...charge.prochainsIds, participant: 11 });
});
test("lecture par siège : un titre est pourvu par la réservation d'une personne non exclue sur son siège, à un tour au moins", () => {
// Titre (1, 1) : réservé tous les tours. Titre (1, 2) : réservé par une
// personne exclue. Titre (2, 1) : réservé au tour 2 seulement. Titre
// (2, 2) : la réservation de la table 2 ne désigne aucun siège.
assert.deepEqual(bilanRemplacement(chargeGarnie({ attribuerSieges: true })), {
participants: 4, reservations: 4, titresPourvus: 2, propositions: 2,
});
});
test('lecture par table : à chaque tour, autant de titres pourvus que de personnes non exclues réservées, les titres de la table au plus ; le tour le mieux pourvu compte', () => {
const participants = [1, 2, 3, 4, 5, 6, 7, 8].map((id) => personne(id, `Personne ${id}`, null, null, { exclu: id === 7 }));
const charge = chargeDe({
participants,
tables: [table(1), table(2), table(3), table(4)],
reservations: [
// Table 1, trois titres : deux personnes tous les tours, et une exclue.
reservation(1, 1, null), reservation(2, 1, null), reservation(7, 1, null),
// Table 2, un titre : deux personnes tous les tours.
reservation(3, 2, null), reservation(4, 2, null),
// Table 3, deux titres : une personne au tour 1, une autre au tour 2.
reservation(5, 3, null, 1), reservation(6, 3, null, 2),
// Table 4, un titre : une personne au tour 1 seulement.
reservation(8, 4, null, 1),
],
titres: [titre(1, 1), titre(1, 2), titre(1, 3), titre(2, 1), titre(3, 1), titre(3, 2), titre(4, 1)],
});
assert.deepEqual(bilanRemplacement(charge), { participants: 8, reservations: 8, titresPourvus: 5, propositions: 0 });
assert.equal(bilanRemplacement(chargeGarnie()).titresPourvus, 3);
});
test("une proposition qui ne place aucune des personnes de la liste n'est pas touchée", () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')],
tables: [table(1), table(2)],
propositions: [proposition(1, [1, 2]), proposition(2, [7, 8]), proposition(3, [2, 8])],
prochain: 9,
});
assert.equal(bilanRemplacement(charge).propositions, 2);
});
test("identifiants jamais réutilisés : retirer, puis importer, attribue au-delà de prochainsIds", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')], prochain: 6 });
const remplacee = importer(charge, csv('nom', 'Sarcelle', 'Bruyère'), 'remplacer').charge;
assert.deepEqual(remplacee.participants.map(({ id }) => id), [6, 7]);
const ajoutee = importer(remplacee, csv('nom', 'Lacasse'), 'ajouter').charge;
assert.deepEqual(ajoutee.participants.map(({ id }) => id), [6, 7, 8]);
assert.equal(ajoutee.prochainsIds.participant, 9);
});
test('bilanRemplacement ne modifie pas la charge', () => {
const charge = chargeGarnie();
const avant = structuredClone(charge);
bilanRemplacement(charge);
assert.deepEqual(charge, avant);
});
});
describe('appliquerImport : la charge rendue', () => {
test("la charge et l'aperçu reçus ne sont pas modifiés, et la charge rendue n'en partage aucun objet", () => {
for (const mode of MODES) {
const charge = chargeGarnie();
const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), {
participants: charge.participants,
});
const avant = structuredClone({ charge, apercu });
const resultat = appliquerImport(charge, apercu, mode);
assert.deepEqual({ charge, apercu }, avant, mode);
const recus = objetsDe({ charge, apercu });
for (const objet of objetsDe(resultat)) assert.ok(!recus.has(objet), `${mode} : objet partagé ${JSON.stringify(objet)}`);
}
});
test("la charge rendue s'écrit et se relit : l'analyse du stockage l'admet, comptes et références compris", () => {
for (const mode of MODES) {
const { charge: apres } = importer(chargeGarnie(), csv('nom;prenom;exclu', 'Ombrelle;Iris;oui', 'Bruyère;Anouk;'), mode);
const relue = analyser(serialiser(apres, { revision: 2, produitVersion: VERSION.affichee })).charge;
assert.deepEqual(relue.participants, apres.participants, mode);
assert.equal(relue.prochainsIds.participant, apres.prochainsIds.participant, mode);
}
});
});
describe("appliquerImport : selon l'état du plan (§ 9, § 10.1)", () => {
test('brouillon et proposé : appliqué, sans avertissement de dérive', () => {
for (const etat of ['brouillon', 'propose']) {
for (const mode of MODES) {
const { charge: apres, resume } = importer(chargeGarnie({ etat }), csv('nom', 'Bruyère'), mode);
assert.equal(resume.derive, false, `${etat} ${mode}`);
assert.equal(apres.participants.at(-1).nom, 'Bruyère', `${etat} ${mode}`);
}
}
});
test("retenu : appliqué, avec l'avertissement de dérive", () => {
for (const mode of MODES) {
const { charge: apres, resume } = importer(chargeGarnie({ etat: 'retenu' }), csv('nom', 'Bruyère'), mode);
assert.equal(resume.derive, true, mode);
assert.equal(apres.participants.at(-1).nom, 'Bruyère', mode);
assert.equal(apres.evenement.etat, 'retenu', mode);
}
});
test("bloqué : refusé PLAN_BLOQUE dans chaque mode, avant tout autre refus, et rien n'est rendu", () => {
for (const mode of MODES) {
const charge = chargeGarnie({ etat: 'bloque' });
const avant = structuredClone(charge);
assert.throws(() => importer(charge, csv('nom', 'Bruyère'), mode), refus('PLAN_BLOQUE', {}), mode);
assert.throws(() => importer(charge, csv('nom;exclu', 'Bruyère;peut-être'), mode), refus('PLAN_BLOQUE', {}), mode);
assert.deepEqual(charge, avant, mode);
}
});
});
describe('appliquerImport : les refus globaux (§ 10.1)', () => {
test("le refus global de l'aperçu, son rang dans les détails", () => {
const charge = chargeDe();
assert.throws(() => importer(charge, csv('nom;exclu', 'Ombrelle;peut-être'), 'ajouter'), refus('AUCUNE_LIGNE_VALIDE', { ligne: null }));
assert.throws(() => importer(charge, csv('prenom;courriel', 'Iris;'), 'ajouter'), refus('NOM_NON_ASSOCIE', { ligne: null }));
assert.throws(
() => importer(charge, csv('nom;notes', 'Ombrelle;"arrive tard', 'Pervenche;'), 'ajouter'),
refus('GUILLEMET_OUVERT', { ligne: 2 }),
);
assert.throws(
() => importer(charge, csv('nom;notes', 'Ombrelle;arrive tard;vers 20 h', 'Pervenche;'), 'ajouter'),
refus('SEPARATEUR_INTROUVABLE', { ligne: 2 }),
);
});
test('une ambiguïté qui reste refuse : AMBIGUITE, ses champs nommés ; le choix la lève', () => {
const texte = csv('nom;organisation;entreprise', 'Ombrelle;Club des Merles;Société Alpha');
for (const mode of MODES) {
assert.throws(() => importer(chargeDe(), texte, mode), refus('AMBIGUITE', { champs: ['appartenance'] }), mode);
}
const { charge: apres } = importer(chargeDe(), texte, 'ajouter', { appartenance: 2 });
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', null, 'Société Alpha')]);
});
test("le refus global de l'aperçu passe avant une ambiguïté : deux colonnes nom refusent NOM_NON_ASSOCIE", () => {
assert.throws(
() => importer(chargeDe(), csv('nom;nom;organisation;entreprise', 'Ombrelle;Iris;Club des Merles;Alpha'), 'ajouter'),
refus('NOM_NON_ASSOCIE', { ligne: null }),
);
assert.throws(
() => importer(chargeDe(), csv('nom;organisation;entreprise', 'Ombrelle;"Club;', 'Pervenche;;'), 'ajouter'),
refus('GUILLEMET_OUVERT', { ligne: 2 }),
);
});
test('un mode inconnu lève TypeError', () => {
const apercu = apercevoir(csv('nom', 'Ombrelle'));
for (const mode of ['fusionner', 'Ajouter', undefined, null]) {
assert.throws(() => appliquerImport(chargeDe(), apercu, mode), TypeError, String(mode));
}
});
});
describe('exporterRefus : la boucle des refus (§ 10.1, § 14.10)', () => {
// Un exclu inconnu à la ligne 7, au milieu de lignes valides.
const FICHIER = csv(
'nom;prenom;appartenance;exclu',
'Ombrelle;Iris;Club des Merles;non',
'Pervenche;Théo;;',
'Sarcelle;Ondine;Chorale du Givre;oui',
'Bruyère;Anouk;;',
'Grisaille;Silas;Club des Merles;',
'Lacasse;Perrine;Société Alpha;peut-être',
'Ardillon;Maëlle;;non',
);
// Le premier import et son CSV des refus.
function premierImport() {
const charge = chargeDe();
const apercu = apercevoir(decoder(enUtf8(FICHIER)).texte, { participants: charge.participants });
const { charge: apres, resume } = appliquerImport(charge, apercu, 'ajouter');
return { apres, resume, refusees: exporterRefus(apercu, MOTIF, resume.refusees) };
}
test("un exclu inconnu à la ligne 7 revient dans le CSV des refus avec ligne 7 et son motif ; corrigé, le fichier se réimporte tel quel et la ligne entre", () => {
const { apres, resume, refusees } = premierImport();
assert.equal(resume.ajoutes, 6);
assert.deepEqual(resume.refusees.map(({ ligne, code, valeur }) => [ligne, code, valeur]), [[7, 'EXCLU_INCONNU', 'peut-être']]);
const { texte, encodage } = decoder(refusees);
assert.equal(encodage, 'utf-8-bom');
assert.equal(
texte,
csv(
'nom;prenom;appartenance;exclu;ligne;motif',
'Lacasse;Perrine;Société Alpha;peut-être;7;"EXCLU_INCONNU {""ligne"":7,""valeur"":""peut-être""}"',
),
);
// L'opérateur corrige le champ dans ce fichier, et le réimporte tel quel.
const corrige = enUtf8(texte.replace(';peut-être;7;', ';oui;7;'));
const apercu = apercevoir(decoder(corrige).texte, { participants: apres.participants });
assert.deepEqual(apercu.association.nonReconnues, [4, 5]);
assert.deepEqual(apercu.refusees, []);
const fin = appliquerImport(apres, apercu, 'ajouter');
assert.deepEqual(fin.resume, resumeDe({ ajoutes: 1 }));
assert.deepEqual(fin.charge.participants.at(-1), personne(7, 'Lacasse', 'Perrine', 'Société Alpha', { exclu: true }));
});
test('réimporté sans correction, le CSV des refus refuse la même ligne pour le même motif, et son propre CSV des refus garde les deux rangs', () => {
const { apres, refusees } = premierImport();
const apercu = apercevoir(decoder(refusees).texte, { participants: apres.participants });
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE');
assert.deepEqual(apercu.refusees.map(({ ligne, code, valeur }) => [ligne, code, valeur]), [[2, 'EXCLU_INCONNU', 'peut-être']]);
const { texte } = decoder(exporterRefus(apercu, MOTIF));
assert.deepEqual(decouper(texte, ';').enregistrements.map((enregistrement) => enregistrement.slice(4)), [
['ligne', 'motif', 'ligne', 'motif'],
['7', 'EXCLU_INCONNU {"ligne":7,"valeur":"peut-être"}', '2', 'EXCLU_INCONNU {"ligne":2,"valeur":"peut-être"}'],
]);
});
});
describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => {
test("UTF-8 avec marque, CRLF, les en-têtes d'origine tels qu'écrits, puis ligne et motif", () => {
const apercu = apercevoir(csv(' Nom ;Prénom;Équipe;Exclu', 'Ombrelle;Iris;Club des Merles;peut-être', 'Pervenche;Théo;;non'));
const octets = exporterRefus(apercu, MOTIF);
assert.ok(octets instanceof Uint8Array);
assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
assert.equal(
new TextDecoder('utf-8', { ignoreBOM: true }).decode(octets),
MARQUE +
csv(
' Nom ;Prénom;Équipe;Exclu;ligne;motif',
'Ombrelle;Iris;Club des Merles;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"',
),
);
});
test("le séparateur d'origine : la virgule, la tabulation", () => {
const virgule = apercevoir(csv('nom,prenom,exclu', 'Ombrelle,Iris,peut-être', 'Pervenche,Théo,non'));
assert.equal(
decoder(exporterRefus(virgule, MOTIF)).texte,
csv('nom,prenom,exclu,ligne,motif', 'Ombrelle,Iris,peut-être,2,"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"'),
);
const tabulation = apercevoir(csv('nom\tprenom\texclu', 'Ombrelle\tIris\tpeut-être', 'Pervenche\tThéo\tnon'));
assert.equal(
decoder(exporterRefus(tabulation, MOTIF)).texte,
csv('nom\tprenom\texclu\tligne\tmotif', 'Ombrelle\tIris\tpeut-être\t2\t"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"'),
);
});
test("un fichier à une colonne, sans séparateur établi : le CSV des refus prend « ; », et se relit à trois colonnes", () => {
const charge = chargeDe({
participants: [
personne(1, 'Ombrelle', null, 'Club des Merles'),
personne(2, 'Ombrelle', null, 'Chorale du Givre'),
personne(3, 'Pervenche'),
],
});
const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants });
assert.equal(apercu.separateur, null);
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees));
assert.equal(
texte,
csv('nom;ligne;motif', 'Ombrelle;2;"PLUSIEURS_CORRESPONDENT {""ligne"":2,""valeur"":null,""participants"":[1,2]}"'),
);
const relu = apercevoir(texte);
assert.equal(relu.separateur, ';');
assert.deepEqual(relu.lignes.map(({ champs }) => champs.nom), ['Ombrelle']);
});
test('un champ qui porte le séparateur, un guillemet ou une fin de ligne s\'écrit cité : chaque cellule se relit telle que découpée', () => {
const apercu = apercevoir(csv(
'nom;prenom;notes;exclu',
'"Ombrelle ""dite Iris""";Iris;"premier ; second',
'troisième";peut-être',
'Pervenche;Théo;;non',
));
const { brut } = apercu.refusees[0];
assert.deepEqual(brut, ['Ombrelle "dite Iris"', 'Iris', 'premier ; second\r\ntroisième', 'peut-être']);
const { enregistrements } = decouper(decoder(exporterRefus(apercu, MOTIF)).texte, ';');
assert.deepEqual(enregistrements[1], [...brut, '2', MOTIF('EXCLU_INCONNU', { ligne: 2, valeur: 'peut-être' })]);
});
test("une ligne trop courte se complète de cellules vides, des cellules blanches au-delà de l'en-tête ne s'écrivent pas : ligne et motif restent sous leur en-tête", () => {
const apercu = apercevoir(csv(
'nom;prenom;notes',
...REMPLISSAGE.map((nom) => `${nom};;`),
';Théo',
' ;Anouk;;; ',
));
assert.deepEqual(apercu.refusees.map(({ ligne, code, brut }) => [ligne, code, brut]), [
[21, 'NOM_ABSENT', ['', 'Théo']],
[22, 'NOM_ABSENT', [' ', 'Anouk', '', '', ' ']],
]);
assert.equal(
decoder(exporterRefus(apercu, MOTIF)).texte,
csv(
'nom;prenom;notes;ligne;motif',
';Théo;;21;"NOM_ABSENT {""ligne"":21,""valeur"":null}"',
' ;Anouk;;22;"NOM_ABSENT {""ligne"":22,""valeur"":null}"',
),
);
});
test("une ligne aux champs en trop garde ses cellules au-delà de ligne et motif : réimporté sans correction, le CSV ne fait entrer aucune ligne décalée ; corrigé, il se réimporte", () => {
const apercu = apercevoir(csv('nom;prenom;notes', ...REMPLISSAGE.map((nom) => `${nom};;`), 'Ombrelle;Iris;arrive tard;vers 20 h'));
assert.deepEqual(apercu.refusees.map(({ ligne, code }) => [ligne, code]), [[21, 'CHAMPS_EN_TROP']]);
const { texte } = decoder(exporterRefus(apercu, MOTIF));
assert.equal(
texte,
csv('nom;prenom;notes;ligne;motif', 'Ombrelle;Iris;arrive tard;21;"CHAMPS_EN_TROP {""ligne"":21,""valeur"":null}";vers 20 h'),
);
const charge = chargeDe();
assert.throws(() => importer(charge, texte, 'ajouter'), refus('SEPARATEUR_INTROUVABLE', { ligne: 2 }));
const corrige = texte.replace('arrive tard;21;', 'arrive tard, vers 20 h;21;').replace(';vers 20 h\r\n', '\r\n');
const { charge: apres } = importer(charge, corrige, 'ajouter');
assert.deepEqual(apres.participants, [personne(1, 'Ombrelle', 'Iris', null, { notes: 'arrive tard, vers 20 h' })]);
});
test("réimporté sans correction, le CSV des refus ne fait entrer aucune ligne : chacune est refusée de nouveau pour le même motif, champs en trop compris au-delà de la fenêtre", () => {
// Dix-neuf noms absents, puis des champs en trop et un exclu inconnu :
// dans le CSV des refus, la ligne aux champs en trop tombe au rang 21,
// hors de la fenêtre du séparateur.
const apercu = apercevoir(csv(
'nom;prenom;exclu',
...REMPLISSAGE.map((nom) => `${nom};;`),
...REMPLISSAGE.map((prenom) => `;${prenom};`),
'Ombrelle;Iris;non;en trop',
'Pervenche;Théo;peut-être',
));
const codes = (refusees) => refusees.map(({ code, valeur }) => [code, valeur]);
assert.deepEqual(codes(apercu.refusees), [
...REMPLISSAGE.map(() => ['NOM_ABSENT', null]), ['CHAMPS_EN_TROP', null], ['EXCLU_INCONNU', 'peut-être'],
]);
const relu = apercevoir(decoder(exporterRefus(apercu, MOTIF)).texte);
assert.equal(relu.separateur, ';');
assert.equal(relu.refusGlobal, 'AUCUNE_LIGNE_VALIDE');
assert.deepEqual(codes(relu.refusees), codes(apercu.refusees));
assert.deepEqual(relu.refusees.map(({ ligne }) => ligne).slice(-2), [21, 22]);
});
test("les refus de l'application, passés en troisième argument, entrent dans le CSV avec ceux de l'aperçu, rangés par ligne", () => {
const charge = chargeDe({
participants: [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'),
personne(3, 'Pervenche', 'Théo'),
],
});
const apercu = apercevoir(
csv(
'nom;prenom;courriel;exclu',
'Ombrelle;Iris;iris@exemple.test;',
'Pervenche;Théo;theo@exemple.test;',
';Anouk;;',
'pervenche;theo;autre@exemple.test;',
'Sarcelle;Ondine;;peut-être',
),
{ participants: charge.participants },
);
const { resume } = appliquerImport(charge, apercu, 'mettreAJour');
assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [
[2, 'PLUSIEURS_CORRESPONDENT'], [4, 'NOM_ABSENT'], [5, 'DEJA_DESIGNE'], [6, 'EXCLU_INCONNU'],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 1, refusees: resume.refusees }));
assert.equal(
decoder(exporterRefus(apercu, MOTIF, resume.refusees)).texte,
csv(
'nom;prenom;courriel;exclu;ligne;motif',
'Ombrelle;Iris;iris@exemple.test;;2;"PLUSIEURS_CORRESPONDENT {""ligne"":2,""valeur"":null,""participants"":[1,2]}"',
';Anouk;;;4;"NOM_ABSENT {""ligne"":4,""valeur"":null}"',
'pervenche;theo;autre@exemple.test;;5;"DEJA_DESIGNE {""ligne"":5,""valeur"":null,""participants"":[3],""premiereLigne"":3}"',
'Sarcelle;Ondine;;peut-être;6;"EXCLU_INCONNU {""ligne"":6,""valeur"":""peut-être""}"',
),
);
assert.deepEqual(
exporterRefus(apercu, MOTIF, [...resume.refusees].reverse()),
exporterRefus(apercu, MOTIF, resume.refusees),
);
});
test("les lignes refusées d'un aperçu sans ligne valide s'exportent depuis l'aperçu seul", () => {
const apercu = apercevoir(csv('nom;exclu', 'Ombrelle;peut-être', ';non'));
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE');
assert.equal(
decoder(exporterRefus(apercu, MOTIF)).texte,
csv(
'nom;exclu;ligne;motif',
'Ombrelle;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"',
';non;3;"NOM_ABSENT {""ligne"":3,""valeur"":null}"',
),
);
});
test("motif reçoit le code et les détails de chaque refus ; un motif qui ne rend pas une chaîne lève TypeError", () => {
const apercu = apercevoir(csv('nom;exclu', 'Ombrelle;peut-être', ';non', 'Pervenche;'));
const appels = [];
exporterRefus(apercu, (code, details) => {
appels.push([code, details]);
return code;
});
assert.deepEqual(appels, [
['EXCLU_INCONNU', { ligne: 2, valeur: 'peut-être' }],
['NOM_ABSENT', { ligne: 3, valeur: null }],
]);
for (const [rendu, recu] of [[undefined, 'undefined'], [7, '7'], [['a'], '["a"]']]) {
assert.throws(() => exporterRefus(apercu, () => rendu), {
name: 'TypeError',
message: `motif de EXCLU_INCONNU : chaîne attendue, reçu ${recu}`,
});
}
});
test("un fichier en windows-1252 ou en UTF-16 : le CSV des refus s'écrit en UTF-8 avec marque, ses accents intacts", () => {
const texte = csv('nom;prenom;exclu', 'Pervenche;Théo;peut-être', 'Ombrelle;Iris;non');
const attendu = csv('nom;prenom;exclu;ligne;motif', 'Pervenche;Théo;peut-être;2;"EXCLU_INCONNU {""ligne"":2,""valeur"":""peut-être""}"');
for (const [octets, encodageLu] of [[enWindows1252(texte), 'windows-1252'], [enUtf16le(texte), 'utf-16le']]) {
const lu = decoder(octets);
assert.equal(lu.encodage, encodageLu);
const refusees = decoder(exporterRefus(apercevoir(lu.texte), MOTIF));
assert.deepEqual(refusees, { texte: attendu, encodage: 'utf-8-bom' }, encodageLu);
}
});
});

197
src/csv/lecture.js Normal file
View file

@ -0,0 +1,197 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Lecture d'un texte CSV déjà décodé (§ 10.1, § 10.2) : sa découpe en
// enregistrements et le choix de son séparateur. L'import d'un fichier et le
// collage en bloc passent par ces mêmes fonctions : le texte qu'elles reçoivent
// n'a plus d'origine, et un second analyseur « simplifié » divergerait du
// premier sur les accents et les guillemets.
import { ErreurCsv } from './erreurs.js';
const GUILLEMET = '"';
// Candidats du séparateur, dans l'ordre qui départage deux candidats à égalité
// de tout le reste.
const CANDIDATS = [';', ',', '\t'];
// Nombre d'enregistrements que le choix du séparateur lit.
const FENETRE = 20;
// Parcourt texte en un seul passage et rend ses enregistrements, avec l'état
// des guillemets à la fin de la lecture. La lecture s'arrête après limite
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture
// s'arrêtant hors d'un champ cité.
//
// La découpe suit RFC 4180, avec ces précisions :
// - un guillemet ouvre un champ cité quand il en est le premier caractère ;
// ailleurs, c'est un caractère comme un autre ;
// - dans un champ cité, le séparateur et les fins de ligne font partie du
// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ;
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
// fin ;
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
// qui suit la dernière fin de ligne ne fait pas d'enregistrement ;
// - un séparateur null ne sépare rien : chaque enregistrement est un champ ;
// - aucun blanc n'est retiré.
function analyser(texte, separateur, limite) {
const enregistrements = [];
let enregistrement = [];
let champ = '';
let cite = false;
let debutDeChamp = true;
let debutDeLigne = true;
for (let i = 0; i < texte.length; i += 1) {
const c = texte[i];
if (cite) {
if (c !== GUILLEMET) {
champ += c;
} else if (texte[i + 1] === GUILLEMET) {
champ += GUILLEMET;
i += 1;
} else {
cite = false;
}
continue;
}
if (c === '\n' || c === '\r') {
if (c === '\r' && texte[i + 1] === '\n') i += 1;
enregistrement.push(champ);
enregistrements.push(enregistrement);
if (enregistrements.length >= limite) return { enregistrements, guillemetOuvert: false };
enregistrement = [];
champ = '';
debutDeChamp = true;
debutDeLigne = true;
continue;
}
debutDeLigne = false;
if (c === separateur) {
enregistrement.push(champ);
champ = '';
debutDeChamp = true;
} else if (c === GUILLEMET && debutDeChamp) {
cite = true;
} else {
champ += c;
debutDeChamp = false;
}
}
if (!debutDeLigne) {
enregistrement.push(champ);
enregistrements.push(enregistrement);
}
return { enregistrements, guillemetOuvert: cite };
}
/**
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
* guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de
* ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas
* d'enregistrement. Aucun blanc n'est retiré des champs.
*
* Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris :
* le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs
* lignes ne fait qu'un enregistrement ; la découpe ne rend aucun numéro de
* ligne du texte.
*
* @param {string} texte
* @param {string|null} separateur un caractère, ou null pour un fichier à une
* colonne, dont chaque ligne est un seul champ
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le
* dernier enregistrement porte alors le texte lu jusque-là.
*/
export function decouper(texte, separateur) {
return analyser(texte, separateur, Infinity);
}
/**
* Vrai quand chaque champ de l'enregistrement est vide ou réduit à des blancs :
* une ligne vide, une ligne de séparateurs seuls, une ligne de blancs. Le choix
* du séparateur ne compte pas ces enregistrements.
*
* @param {string[]} enregistrement
* @returns {boolean}
*/
export function enregistrementVide(enregistrement) {
return enregistrement.every((champ) => champ.trim() === '');
}
// Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est
// le premier enregistrement non vide parmi ceux que la fenêtre termine : un
// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement
// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête
// manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat
// n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le
// premier défaut qui écarte le candidat, ou null : un enregistrement non vide
// dont le nombre de champs diffère de celui de l'en-tête, à son rang ; à
// défaut, le guillemet resté ouvert, au rang où il s'ouvre.
function lireAvec(texte, candidat, reconnaitre) {
const { enregistrements, guillemetOuvert } = analyser(texte, candidat, FENETRE);
const termines = guillemetOuvert ? enregistrements.slice(0, -1) : enregistrements;
const entete = termines.find((e) => !enregistrementVide(e));
if (entete === undefined || entete.length < 2) return null;
const ecart = termines.findIndex((e) => !enregistrementVide(e) && e.length !== entete.length);
let defaut = null;
if (ecart !== -1) defaut = { ligne: ecart + 1, cause: 'NOMBRE_DE_CHAMPS' };
else if (guillemetOuvert) defaut = { ligne: enregistrements.length, cause: 'GUILLEMET_OUVERT' };
return { separateur: candidat, reconnus: entete.filter((champ) => reconnaitre(champ)).length, defaut };
}
// La lecture qui l'emporte, de la meilleure jusqu'ici et d'une suivante dans
// l'ordre des candidats : celle qui reconnaît le plus d'en-têtes, la première
// à égalité.
const meilleure = (jusquIci, suivante) =>
jusquIci === null || suivante.reconnus > jusquIci.reconnus ? suivante : jusquIci;
/**
* Choisit le séparateur parmi « ; », « , » et la tabulation en lisant les vingt
* premiers enregistrements avec chacun. Un candidat convient quand la lecture
* s'achève sans guillemet ouvert et que les enregistrements non vides ont tous
* le même nombre de champs, supérieur à 1 : sur un fichier séparé par des
* virgules, « ; » donne lui aussi un nombre de champs constant, un seul, et la
* constance ne suffit pas.
*
* À égalité, le candidat dont le premier enregistrement non vide compte le plus
* d'en-têtes reconnus l'emporte ; à égalité encore, le premier dans l'ordre
* « ; », « , », tabulation.
*
* Quand aucun candidat ne convient et que la première ligne entière, ses blancs
* de bord retirés, est un en-tête reconnu, le fichier n'a qu'une colonne :
* separateur est null, et un nom qui porte une virgule reste entier. Sinon,
* ErreurCsv('SEPARATEUR_INTROUVABLE'), dont les détails nomment ce qui écarte
* le candidat que le même départage désigne parmi ceux dont l'en-tête a au
* moins deux champs : { separateur, ligne, cause }, ligne étant le rang de
* l'enregistrement fautif et cause NOMBRE_DE_CHAMPS — le premier
* enregistrement non vide dont le nombre de champs diffère de celui de
* l'en-tête — ou GUILLEMET_OUVERT — sans écart, le guillemet resté ouvert,
* au rang où il s'ouvre. Sans un tel candidat, les détails sont vides.
*
* @param {string} texte
* @param {(entete: string) => unknown} reconnaitre rend une valeur vraie pour
* un en-tête connu ; reçoit chaque champ du premier enregistrement non vide
* tel qu'il est écrit, ou la première ligne entière d'un fichier à une
* colonne, ses blancs de bord retirés
* @returns {{ separateur: string|null }}
* @throws {ErreurCsv} SEPARATEUR_INTROUVABLE, détails
* { separateur, ligne, cause } ou {}
*/
export function choisirSeparateur(texte, reconnaitre) {
let retenu = null;
let ecarte = null;
for (const candidat of CANDIDATS) {
const lecture = lireAvec(texte, candidat, reconnaitre);
if (lecture === null) continue;
if (lecture.defaut === null) retenu = meilleure(retenu, lecture);
else ecarte = meilleure(ecarte, lecture);
}
if (retenu !== null) return { separateur: retenu.separateur };
const premiere = analyser(texte, null, FENETRE).enregistrements.find((e) => !enregistrementVide(e));
if (premiere !== undefined && reconnaitre(premiere[0].trim())) return { separateur: null };
if (ecarte === null) throw new ErreurCsv('SEPARATEUR_INTROUVABLE');
throw new ErreurCsv('SEPARATEUR_INTROUVABLE', { separateur: ecarte.separateur, ...ecarte.defaut });
}

564
src/csv/lecture.test.js Normal file
View file

@ -0,0 +1,564 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la lecture d'un texte CSV (§ 10.1, § 14.10) : la découpe en
// enregistrements — guillemets, guillemets doublés, fins de ligne, ligne vide,
// guillemet resté ouvert —, le choix du séparateur sur les vingt premiers
// enregistrements, et la propriété du § 14.12 qui le retrouve dans un tableau
// rendu par construction. Les en-têtes se reconnaissent ici par un double de
// l'épreuve : le choix ne dépend que du verdict, non de la manière de le
// rendre.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
const SEPARATEURS = [';', ',', '\t'];
// Double de la reconnaissance d'en-têtes que reçoit choisirSeparateur : les
// trois noms de colonne, tels quels.
const ENTETES = new Set(['nom', 'prenom', 'appartenance']);
const reconnaitre = (entete) => ENTETES.has(entete);
// Une reconnaissance rend un booléen, ou le nom du champ reconnu et null
// sinon : choisirSeparateur n'en lit que la valeur de vérité.
const RECONNAISSANCES = [
['qui rend un booléen', reconnaitre],
['qui rend le champ reconnu ou null', (entete) => (ENTETES.has(entete) ? entete : null)],
];
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
return assert.fail("rien n'a été levé");
}
// Vérifie que fonction lève le refus d'un séparateur introuvable, avec ses
// détails : le candidat écarté, le rang de l'enregistrement fautif et la
// cause, ou rien quand aucun candidat ne donne à l'en-tête deux champs.
function assertSeparateurIntrouvable(fonction, details = {}) {
const erreur = erreurDe(fonction);
assert.ok(erreur instanceof ErreurCsv);
assert.equal(erreur.code, 'SEPARATEUR_INTROUVABLE');
assert.deepEqual(erreur.details, details);
}
// Détails d'un refus dont le candidat est écarté par le nombre de champs d'un
// enregistrement, ou par un guillemet resté ouvert.
const ecart = (separateur, ligne) => ({ separateur, ligne, cause: 'NOMBRE_DE_CHAMPS' });
const ouvert = (separateur, ligne) => ({ separateur, ligne, cause: 'GUILLEMET_OUVERT' });
// Enregistrements que decouper rend, une fois vérifié qu'aucun guillemet ne
// reste ouvert.
function enregistrementsDe(texte, separateur = ';') {
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
assert.equal(guillemetOuvert, false);
return enregistrements;
}
// Nombre de champs de chaque enregistrement que donne le séparateur, guillemet
// resté ouvert ou non.
const largeursLues = (texte, separateur) => decouper(texte, separateur).enregistrements.map((e) => e.length);
// Le même nombre, une fois vérifié qu'aucun guillemet ne reste ouvert.
const largeurs = (texte, separateur) => enregistrementsDe(texte, separateur).map((e) => e.length);
describe('decouper : enregistrements et champs (§ 10.1)', () => {
test('des champs séparés, une dernière ligne sans fin de ligne', () => {
assert.deepEqual(decouper('nom;prenom\nBenoît;Exemple', ';'), {
enregistrements: [
['nom', 'prenom'],
['Benoît', 'Exemple'],
],
guillemetOuvert: false,
});
});
test('un champ cité garde le séparateur ; deux guillemets y font un guillemet', () => {
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"a ; b ""c"" d"\r\n'), [
['nom', 'notes'],
['Benoît Exemple', 'a ; b "c" d'],
]);
});
test('un champ cité sur deux lignes est un seul enregistrement, ses fins de ligne gardées telles quelles', () => {
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"ligne 1\r\nligne 2"\nSuivant;x\r\n'), [
['nom', 'notes'],
['Benoît Exemple', 'ligne 1\r\nligne 2'],
['Suivant', 'x'],
]);
assert.deepEqual(enregistrementsDe('a;"x\ny"'), [['a', 'x\ny']]);
assert.deepEqual(enregistrementsDe('a;"x\ry"'), [['a', 'x\ry']]);
});
test('CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule fin', () => {
const attendu = [
['a', '1'],
['b', '2'],
['c', '3'],
];
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\r\nc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\nb;2\nc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\rb;2\rc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\nc;3\r'), attendu);
});
test('la dernière ligne, avec ou sans fin de ligne, donne les mêmes enregistrements', () => {
for (const fin of ['', '\n', '\r\n', '\r']) {
assert.deepEqual(enregistrementsDe(`a;b${fin}`), [['a', 'b']], JSON.stringify(fin));
}
});
test("une ligne vide au milieu est un enregistrement d'un champ vide, qui garde son rang", () => {
assert.deepEqual(enregistrementsDe('nom;prenom\r\n\r\nBenoît;Exemple\r\n'), [
['nom', 'prenom'],
[''],
['Benoît', 'Exemple'],
]);
});
test("un enregistrement se désigne par son rang à partir de 1, l'en-tête compris : celui d'une ligne de tableur", () => {
const texte = [
'nom;notes', // rang 1
'Benoît Exemple;1', // rang 2
'', // rang 3 : ligne vide
'Odile Fictive;"deux\nlignes"', // rang 4 : une cellule, une seule ligne de tableur
'Rémi Témoin;3', // rang 5
].join('\r\n');
const enregistrements = enregistrementsDe(texte);
const rang = (n) => enregistrements[n - 1];
assert.equal(enregistrements.length, 5);
assert.deepEqual(rang(1), ['nom', 'notes']);
assert.deepEqual(rang(3), ['']);
assert.deepEqual(rang(4), ['Odile Fictive', 'deux\nlignes']);
assert.deepEqual(rang(5), ['Rémi Témoin', '3']);
});
test("seule la ligne vide qui suit la dernière fin de ligne n'est pas un enregistrement", () => {
assert.deepEqual(enregistrementsDe(''), []);
assert.deepEqual(enregistrementsDe('\n'), [['']]);
assert.deepEqual(enregistrementsDe('a\n\n'), [['a'], ['']]);
assert.deepEqual(enregistrementsDe('a\r\n\r\n'), [['a'], ['']]);
});
test('un dernier enregistrement réduit à un champ cité vide est un enregistrement, avec ou sans fin de ligne', () => {
assert.deepEqual(decouper('a\n""', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
assert.deepEqual(decouper('a\n""\n', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
assert.deepEqual(decouper('""', ';'), { enregistrements: [['']], guillemetOuvert: false });
});
test('un guillemet resté ouvert est signalé, et le texte lu jusque-là est rendu', () => {
assert.deepEqual(decouper('nom;notes\nBenoît;"jamais fermé\nsuite', ';'), {
enregistrements: [
['nom', 'notes'],
['Benoît', 'jamais fermé\nsuite'],
],
guillemetOuvert: true,
});
});
test('un champ cité refermé en toute fin ne laisse rien ouvert, un guillemet doublé en toute fin le laisse ouvert', () => {
assert.equal(decouper('a;"b"', ';').guillemetOuvert, false);
assert.equal(decouper('a;"b', ';').guillemetOuvert, true);
assert.equal(decouper('a;"b""', ';').guillemetOuvert, true);
assert.equal(decouper('a;"', ';').guillemetOuvert, true);
});
test('deux guillemets font un champ cité vide ; quatre, un champ réduit à un guillemet', () => {
assert.deepEqual(enregistrementsDe('a;"";c'), [['a', '', 'c']]);
assert.deepEqual(enregistrementsDe('"""";x'), [['"', 'x']]);
});
test("un guillemet qui n'ouvre pas un champ est un caractère comme un autre", () => {
assert.deepEqual(enregistrementsDe('Benoît 5" Exemple;x\nsuite;y'), [
['Benoît 5" Exemple', 'x'],
['suite', 'y'],
]);
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
});
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => {
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]);
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]);
});
test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => {
assert.deepEqual(enregistrementsDe(' Benoît ; Exemple ;" Odile "'), [[' Benoît ', ' Exemple ', ' Odile ']]);
});
test('les champs vides, aux deux bouts comme au milieu', () => {
assert.deepEqual(enregistrementsDe(';a;;b;'), [['', 'a', '', 'b', '']]);
assert.deepEqual(enregistrementsDe(';;'), [['', '', '']]);
});
test('la tabulation sépare, et se cite comme tout séparateur', () => {
assert.deepEqual(enregistrementsDe('nom\tprenom\nExemple\t"a\tb"', '\t'), [
['nom', 'prenom'],
['Exemple', 'a\tb'],
]);
});
test('sans séparateur (null), chaque enregistrement est un seul champ, virgules et points-virgules compris', () => {
assert.deepEqual(enregistrementsDe('nom\nBenoît Exemple, Odile\n"Rémi; ""R"""', null), [
['nom'],
['Benoît Exemple, Odile'],
['Rémi; "R"'],
]);
});
});
describe('enregistrementVide', () => {
test('vrai quand chaque champ est vide ou réduit à des blancs', () => {
assert.equal(enregistrementVide(['']), true);
assert.equal(enregistrementVide(['', '', '']), true);
assert.equal(enregistrementVide([' ', '\t']), true);
});
test("faux dès qu'un champ porte un caractère", () => {
assert.equal(enregistrementVide(['', 'a']), false);
assert.equal(enregistrementVide(['0']), false);
});
});
describe('choisirSeparateur : le candidat retenu (§ 10.1)', () => {
test("un fichier séparé par des virgules : « ; » est écarté parce qu'il donne un champ, non faute de constance", () => {
// « ; » donne un champ à chaque enregistrement : la constance seule le
// retiendrait, et, sans en-tête reconnu pour départager, l'ordre des
// candidats le placerait devant « , ».
const texte = 'a,b,c\n1,2,3\n';
assert.deepEqual(largeurs(texte, ';'), [1, 1]);
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: ',' });
});
test('un fichier séparé par des virgules, ses en-têtes reconnus', () => {
const texte = 'nom,prenom,appartenance\nBenoît,Exemple,Groupe Azur\nOdile,Fictive,Groupe Ambre\n';
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ',' });
});
test('un collage de tableur est séparé par des tabulations, même quand un champ porte une virgule', () => {
const texte = 'nom\tprenom\tappartenance\nBenoît\tExemple\tGroupe Azur, nord\nOdile\tFictive\tGroupe Ambre\n';
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: '\t' });
});
// « ; » donne deux champs, « , » trois, l'un et l'autre constants : seule la
// reconnaissance des en-têtes les départage.
const TEXTE_A_EGALITE = 'nom,prenom,appartenance;x\nBenoît,Exemple,Groupe Azur;y\n';
for (const [libelle, reconnaissance] of RECONNAISSANCES) {
test(`égalité de constance : le candidat aux plus d'en-têtes reconnus, fût-il le dernier essayé (reconnaissance ${libelle})`, () => {
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ';'), [2, 2]);
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ','), [3, 3]);
// « , » reconnaît deux en-têtes (nom, prenom), « ; » aucun.
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, reconnaissance), { separateur: ',' });
});
}
test("la même égalité, une reconnaissance qui favorise l'autre lecture : le choix suit le verdict", () => {
const lectureParPointVirgule = (entete) => entete === 'x' || entete === 'nom,prenom,appartenance';
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, lectureParPointVirgule), { separateur: ';' });
});
test("égalité complète : l'ordre « ; », « , », tabulation départage", () => {
const aucune = () => false;
assert.deepEqual(choisirSeparateur('a,b;c\nd,e;f\n', aucune), { separateur: ';' });
assert.deepEqual(choisirSeparateur('a,b\tc\nd,e\tf\n', aucune), { separateur: ',' });
});
test("un en-tête non reconnu n'écarte pas le candidat : la reconnaissance ne fait que départager", () => {
assert.deepEqual(choisirSeparateur('a;b\nc;d\n', () => false), { separateur: ';' });
});
test("reconnaitre reçoit les champs du premier enregistrement non vide, tels qu'écrits", () => {
const appels = [];
choisirSeparateur('\n Nom ;prenom\nA;B\n', (entete) => {
appels.push(entete);
return false;
});
assert.deepEqual(appels, [' Nom ', 'prenom']);
});
test("une colonne d'en-tête vide compte comme un champ", () => {
assert.deepEqual(choisirSeparateur('nom;;appartenance\nBenoît;;Groupe Azur\n', reconnaitre), { separateur: ';' });
assert.deepEqual(choisirSeparateur('nom;prenom;\nBenoît;Exemple;\n', reconnaitre), { separateur: ';' });
});
test('un fichier réduit à son en-tête donne son séparateur', () => {
assert.deepEqual(choisirSeparateur('nom;prenom', reconnaitre), { separateur: ';' });
});
test('les enregistrements vides — ligne vide, champs vides, blancs seuls — ne rompent pas la constance', () => {
const texte = 'nom;prenom\r\n\r\nBenoît;Exemple\r\n;\r\n \r\nOdile;Fictive\r\n';
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
});
describe('la fenêtre des vingt premiers enregistrements', () => {
// Vingt-cinq enregistrements de trois champs, dont celui du rang donné,
// à partir de 1, est remplacé.
const texteAvec = (rang, remplacement) => {
const lignes = [
'nom;prenom;appartenance',
...Array.from({ length: 24 }, (_, i) => `n${i};p${i};a${i}`),
];
lignes[rang - 1] = remplacement;
return lignes.join('\n');
};
test('un enregistrement plus long au rang 20 écarte « ; », au rang 21 non', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y;z;t'), reconnaitre), ecart(';', 20));
assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y;z;t'), reconnaitre), { separateur: ';' });
});
test('un enregistrement plus court au rang 20 écarte « ; », au rang 21 non', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y'), reconnaitre), ecart(';', 20));
assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y'), reconnaitre), { separateur: ';' });
});
test('un guillemet resté ouvert depuis le rang 20 écarte « ; », depuis le rang 21 non', () => {
// Le dernier champ de l'enregistrement avale la fin du fichier : le
// nombre de champs reste trois, seul le guillemet ouvert écarte « ; ».
const guillemet = 'x;y;"ouvert';
assert.deepEqual(largeursLues(texteAvec(20, guillemet), ';'), new Array(20).fill(3));
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, guillemet), reconnaitre), ouvert(';', 20));
const texte = texteAvec(21, guillemet);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
// Le découpage du fichier entier, lui, voit le guillemet resté ouvert.
assert.equal(decouper(texte, ';').guillemetOuvert, true);
});
});
});
describe('choisirSeparateur : un fichier à une seule colonne (§ 10.1)', () => {
test('une colonne « nom » dont une ligne porte une virgule : une colonne, la virgule gardée dans le nom', () => {
const texte = 'nom\nBenoît Exemple, Odile\nRémi Témoin\n';
// La virgule donne un champ, puis deux, puis un : elle n'est pas constante.
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: null });
assert.deepEqual(enregistrementsDe(texte, null), [['nom'], ['Benoît Exemple, Odile'], ['Rémi Témoin']]);
});
test("la première ligne entière, ses blancs de bord retirés, est celle qu'on reconnaît", () => {
assert.deepEqual(choisirSeparateur(' nom \nBenoît Exemple\n', reconnaitre), { separateur: null });
});
test("une ligne vide avant l'en-tête n'empêche pas de le reconnaître", () => {
assert.deepEqual(choisirSeparateur('\r\n nom\r\nBenoît Exemple\r\n', reconnaitre), { separateur: null });
});
test('un en-tête cité se reconnaît comme le même sans guillemets', () => {
assert.deepEqual(choisirSeparateur('"nom"\nBenoît Exemple\n', reconnaitre), { separateur: null });
});
test("une colonne unique n'est retenue que faute de candidat", () => {
assert.deepEqual(choisirSeparateur('nom;prenom\nBenoît;Exemple\n', reconnaitre), { separateur: ';' });
});
});
describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => {
test("une seule colonne dont la première ligne n'est pas un en-tête reconnu", () => {
assertSeparateurIntrouvable(() => choisirSeparateur('Benoît Exemple\nOdile Fictive\n', reconnaitre));
});
test('un nombre de champs qui varie, pour chaque candidat : un enregistrement plus long', () => {
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;Exemple;Groupe Azur\n', reconnaitre),
ecart(';', 2),
);
});
test("un nombre de champs qui varie, pour chaque candidat : un enregistrement plus court, jusqu'à un seul champ", () => {
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\n', reconnaitre),
ecart(';', 2),
);
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;Exemple\nune ligne sans séparateur\n', reconnaitre),
ecart(';', 3),
);
});
test('un guillemet resté ouvert dans les premiers enregistrements, même quand le nombre de champs reste constant', () => {
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\n"Benoît;Exemple\nOdile;Fictive\n', reconnaitre),
ouvert(';', 2),
);
// Deux champs partout, mais le second de la dernière ligne avale la fin du
// fichier : seul le guillemet resté ouvert écarte « ; ».
const texte = 'nom;prenom\nBenoît;"Exemple\nOdile;Fictive\n';
const lu = decouper(texte, ';');
assert.deepEqual(lu.enregistrements.map((e) => e.length), [2, 2]);
assert.equal(lu.guillemetOuvert, true);
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2));
});
test('un texte vide, ou fait de lignes vides', () => {
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));
});
test("le refus nomme le premier défaut dans l'ordre du texte, à son rang, les enregistrements vides comptés", () => {
// Un enregistrement plus court passe avant le guillemet ouvert qui le suit.
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\nOdile;"Fictive\n', reconnaitre),
ecart(';', 2),
);
// Une ligne vide et une ligne de séparateurs seuls ne rompent rien, mais
// comptent dans le rang : le numéro de ligne qu'un tableur donne.
assertSeparateurIntrouvable(
() => choisirSeparateur('\nnom;prenom\n\n;\nBenoît;Exemple;x\n', reconnaitre),
ecart(';', 5),
);
// Sans écart, le guillemet ouvert, au rang de l'enregistrement où il
// s'ouvre ; ce qui le suit tient dans son champ.
assertSeparateurIntrouvable(
() => choisirSeparateur('nom;prenom\nBenoît;Exemple\n\nOdile;"Fictive\nRémi;Témoin\n', reconnaitre),
ouvert(';', 4),
);
});
test("parmi les candidats écartés, le refus nomme celui aux plus d'en-têtes reconnus, puis le premier dans l'ordre", () => {
// « ; » et « , » donnent deux champs à l'en-tête, et chacun s'écarte sur
// son propre enregistrement : « , » au rang 2, « ; » au rang 3. Sous « , »,
// « prenom » est reconnu ; sous « ; », rien.
const texte = 'x;nom,prenom\na;b,c,d\ne;f;g\n';
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ecart(',', 2));
assertSeparateurIntrouvable(() => choisirSeparateur(texte, () => false), ecart(';', 3));
// Un guillemet en tête de ligne s'ouvre sous chaque candidat.
assertSeparateurIntrouvable(() => choisirSeparateur('a;b,c\n"d;e,f\n', () => false), ouvert(';', 2));
assertSeparateurIntrouvable(() => choisirSeparateur('a,b\tc\n"d,e\tf\n', () => false), ouvert(',', 2));
});
test("aucun candidat ne donne à l'en-tête deux champs : le refus ne nomme rien", () => {
// Une colonne d'en-tête inconnu ; un séparateur hors des candidats ; un
// guillemet ouvert dès l'en-tête, qui n'en termine aucun ; vingt lignes
// vides, qui laissent la fenêtre sans en-tête.
const textes = [
'Benoît Exemple\n"Odile Fictive\n',
'nom|prenom\nBenoît|"Exemple\n',
'nom;"prenom\nBenoît;Exemple\n',
`${'\n'.repeat(20)}nom;prenom\nBenoît;Exemple;x\n`,
];
for (const texte of textes) {
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre));
}
});
test('un fichier à une colonne dont la ligne entière est un en-tête reconnu passe avant un candidat écarté', () => {
// « ; » donne deux champs à l'en-tête, puis trois : il est écarté au rang 2.
const reconnaitreLigne = (entete) => entete === 'nom;prenom';
assert.deepEqual(choisirSeparateur('nom;prenom\nBenoît;Exemple;x\n', reconnaitreLigne), { separateur: null });
});
});
describe("de l'octet à l'enregistrement : un CSV tel qu'un tableur l'écrit", () => {
test('marque UTF-8 et CRLF, champ cité à guillemets doublés, ligne vide, blancs, colonne sans en-tête, dernière ligne sans fin', () => {
const texteTableur = [
'nom;prenom;appartenance;;notes',
'Exemple;Benoît;Groupe Azur;;"dit ""bonjour"" ; puis part"',
'',
' Fictif ; Odile ;;;',
'Témoin;Rémi;Groupe Ambre;;fin',
].join('\r\n');
const octets = Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(texteTableur)]);
const { texte, encodage } = decoder(octets);
assert.equal(encodage, 'utf-8-bom');
const { separateur } = choisirSeparateur(texte, reconnaitre);
assert.equal(separateur, ';');
assert.deepEqual(decouper(texte, separateur), {
enregistrements: [
['nom', 'prenom', 'appartenance', '', 'notes'],
['Exemple', 'Benoît', 'Groupe Azur', '', 'dit "bonjour" ; puis part'],
[''],
[' Fictif ', ' Odile ', '', '', ''],
['Témoin', 'Rémi', 'Groupe Ambre', '', 'fin'],
],
guillemetOuvert: false,
});
});
});
describe('choisirSeparateur et decouper : propriété par construction (§ 14.12)', () => {
// Graine fixée, écrite ici : un contre-exemple se rejoue.
const GRAINE = 161_803;
const TIRAGES = 300;
// Unités des champs engendrés : trois fois sur quatre une lettre, un accent
// ou un blanc, sinon un guillemet, une fin de ligne ou le séparateur connu,
// qui forcent la citation. Les deux autres candidats en sont absents : un
// tableau qui les porte se lit de plusieurs façons, et le départage par les
// en-têtes s'éprouve à part.
const unite = (separateur) =>
fc.oneof(
{ arbitrary: fc.constantFrom('a', 'Z', 'é', ' '), weight: 3 },
{ arbitrary: fc.constantFrom('"', '\n', '\r', separateur), weight: 1 },
);
const champ = (separateur) => fc.string({ unit: unite(separateur), maxLength: 6 });
// Un champ qui commence par une lettre n'est jamais vide, même une fois ses
// blancs retirés : aucun enregistrement du tableau n'est un enregistrement
// vide, que le choix du séparateur ignore.
const premierChamp = (separateur) =>
fc
.tuple(fc.constantFrom('a', 'Z', 'é'), champ(separateur))
.map(([tete, suite]) => tete + suite);
// Un tableau de 1 à 25 enregistrements, de 2 à 5 champs chacun, et la
// manière de l'écrire : séparateur, fin de ligne, fin de ligne finale ou non.
const cas = fc.constantFrom(...SEPARATEURS).chain((separateur) =>
fc.record({
separateur: fc.constant(separateur),
tableau: fc.integer({ min: 2, max: 5 }).chain((largeur) =>
fc.array(
fc
.tuple(premierChamp(separateur), fc.array(champ(separateur), { minLength: largeur - 1, maxLength: largeur - 1 }))
.map(([premier, autres]) => [premier, ...autres]),
{ minLength: 1, maxLength: 25, size: 'max' },
),
),
fin: fc.constantFrom('\r\n', '\n', '\r'),
finale: fc.boolean(),
}),
);
// Écrit le tableau comme un tableur : un champ qui porte le séparateur, un
// guillemet ou une fin de ligne se cite, ses guillemets doublés.
function ecrire({ separateur, tableau, fin, finale }) {
const ecrireChamp = (valeur) =>
valeur.includes(separateur) || /["\r\n]/.test(valeur) ? `"${valeur.replaceAll('"', '""')}"` : valeur;
const lignes = tableau.map((ligne) => ligne.map(ecrireChamp).join(separateur));
return lignes.join(fin) + (finale ? fin : '');
}
test('le séparateur est retrouvé, et le découpage rend le tableau', () => {
const bilan = { tirages: 0, separateurCite: 0, champSurPlusieursLignes: 0, auDelaDeLaFenetre: 0 };
fc.assert(
fc.property(cas, (tirage) => {
const texte = ecrire(tirage);
bilan.tirages += 1;
const champs = tirage.tableau.flat();
if (champs.some((valeur) => valeur.includes(tirage.separateur))) bilan.separateurCite += 1;
if (champs.some((valeur) => /[\r\n]/.test(valeur))) bilan.champSurPlusieursLignes += 1;
if (tirage.tableau.length > 20) bilan.auDelaDeLaFenetre += 1;
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: tirage.separateur });
assert.deepEqual(decouper(texte, tirage.separateur), {
enregistrements: tirage.tableau,
guillemetOuvert: false,
});
}),
{ seed: GRAINE, numRuns: TIRAGES },
);
// Un générateur qui n'engendre pas les cas difficiles rend la propriété
// vraie sans rien éprouver : chaque famille de cas a son plancher.
assert.equal(bilan.tirages, TIRAGES);
assert.ok(bilan.separateurCite >= 100, `séparateur cité : ${bilan.separateurCite}`);
assert.ok(bilan.champSurPlusieursLignes >= 100, `champ sur plusieurs lignes : ${bilan.champSurPlusieursLignes}`);
assert.ok(bilan.auDelaDeLaFenetre >= 20, `au-delà de la fenêtre : ${bilan.auDelaDeLaFenetre}`);
});
});

45
src/csv/normalisation.js Normal file
View file

@ -0,0 +1,45 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Clés de comparaison des textes saisis (§ 10.1). Deux textes qu'un lecteur
// tient pour le même — casse, accents, blancs mis à part — ont la même clé.
// La réconciliation des appartenances et la clé de doublon comparent par la
// même clé : deux règles divergeraient sans qu'aucune épreuve de l'une ne le
// voie. La clé sert à comparer, jamais à afficher.
// Toute marque combinante : accents, cédille, ogonek, marques englobantes.
const MARQUES = /\p{M}/gu;
// Une suite de blancs : espace, tabulation, fins de ligne, espaces
// insécables et typographiques, marque d'ordre d'octets.
const BLANCS = /\s+/gu;
// Ce qu'une clé d'en-tête ignore en plus : l'espace, le trait de
// soulignement et le trait d'union.
const SEPARATEURS_D_ENTETE = /[ _-]/g;
/**
* Clé de comparaison d'un texte : décomposition canonique (NFD), marques
* combinantes retirées, minuscules sans égard à la langue, chaque suite de
* blancs réduite à une espace, blancs de bord retirés. Une lettre sans
* décomposition canonique, comme « œ » ou une ligature, reste ; la clé d'une
* clé est elle-même.
*
* @param {string} texte
* @returns {string}
*/
export function cleNormalisee(texte) {
return texte.normalize('NFD').replace(MARQUES, '').toLowerCase().replace(BLANCS, ' ').trim();
}
/**
* Clé d'un en-tête de colonne : la clé normalisée, sans espace, « _ » ni « - »,
* si bien que « Titre-Pressenti », « titre pressenti » et « titre_pressenti »
* se reconnaissent ensemble.
*
* @param {string} texte
* @returns {string}
*/
export function cleEntete(texte) {
return cleNormalisee(texte).replace(SEPARATEURS_D_ENTETE, '');
}

View file

@ -0,0 +1,110 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des clés de comparaison (§ 10.1) : la clé normalisée, que
// partagent la réconciliation des appartenances et la clé de doublon, et la
// clé d'en-tête, qui en dérive. Les lettres décomposées et les blancs autres
// que l'espace s'écrivent par leur point de code : un éditeur qui recompose
// une lettre ou remplace un caractère invisible changerait l'épreuve sans
// qu'elle le montre.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { cleEntete, cleNormalisee } from './normalisation.js';
describe('cleNormalisee (§ 10.1)', () => {
test('la casse et les accents sont ignorés', () => {
for (const texte of ['Coopérative Bleue', 'COOPÉRATIVE BLEUE', 'coopérative bleue', 'Cooperative Bleue']) {
assert.equal(cleNormalisee(texte), 'cooperative bleue', texte);
}
assert.equal(cleNormalisee('Ça gèle à Noël, où ?'), 'ca gele a noel, ou ?');
});
test('une lettre accentuée, composée ou décomposée, donne la même clé', () => {
const composee = 'Th\u{E9}o';
const decomposee = 'The\u{301}o';
assert.notEqual(composee, decomposee);
assert.equal(cleNormalisee(composee), 'theo');
assert.equal(cleNormalisee(decomposee), 'theo');
});
test('toute marque combinante est retirée, pas seulement les accents du français', () => {
// Cédille, rond en chef, tréma, double accent aigu, ogonek, puis un
// cercle englobant, marque combinante qui n'est pas un accent.
assert.equal(cleNormalisee('Gar\u{E7}on'), 'garcon');
assert.equal(cleNormalisee('\u{C5}ngstr\u{F6}m'), 'angstrom');
assert.equal(cleNormalisee('\u{150}rs\u{105}'), 'orsa');
assert.equal(cleNormalisee('a\u{20DD}b'), 'ab');
});
test('la décomposition est canonique : une ligature ou une lettre sans décomposition reste', () => {
assert.equal(cleNormalisee('\u{152}uvre'), '\u{153}uvre');
assert.equal(cleNormalisee('\u{FB01}n'), '\u{FB01}n');
assert.equal(cleNormalisee('\u{141}ukasz'), '\u{142}ukasz');
});
test('les blancs se réduisent à une espace, les bouts retirés', () => {
assert.equal(cleNormalisee(' cooperative bleue '), 'cooperative bleue');
assert.equal(cleNormalisee('club\tdes\u{A0}merles\r\nnord'), 'club des merles nord');
assert.equal(cleNormalisee('\u{202F}chorale\u{3000}du\u{2009}givre\u{FEFF}'), 'chorale du givre');
});
test("le trait d'union, le trait de soulignement et l'espace restent : seule la clé d'en-tête les retire", () => {
// La réconciliation des appartenances et la clé de doublon comparent par
// cette clé : « Club-des-Merles » et « Club des Merles » y restent deux.
assert.equal(cleNormalisee('Club-des-Merles'), 'club-des-merles');
assert.equal(cleNormalisee('Club_des_Merles'), 'club_des_merles');
assert.equal(cleNormalisee('Clubdes Merles'), 'clubdes merles');
assert.notEqual(cleNormalisee('Val-Brume'), cleNormalisee('Val Brume'));
});
test('un texte vide ou blanc donne la clé vide', () => {
for (const texte of ['', ' ', '\t\r\n', '\u{A0}\u{3000}\u{202F}']) {
assert.equal(cleNormalisee(texte), '', JSON.stringify(texte));
}
});
test('les minuscules ne suivent aucune langue', () => {
// Une minuscule turque ferait de I un ı sans point.
assert.equal(cleNormalisee('IRIS'), 'iris');
// I à point suscrit : le point est une marque, retirée.
assert.equal(cleNormalisee('\u{130}LE'), 'ile');
});
test('la clé est un point fixe : la normaliser de nouveau ne la change pas', () => {
for (const texte of ['Coopérative Bleue', ' The\u{301}o PERVENCHE ', '\u{130}LE', 'a\u{20DD}b', '\u{152}UVRE']) {
const cle = cleNormalisee(texte);
assert.equal(cleNormalisee(cle), cle, texte);
}
});
});
describe('cleEntete (§ 10.1)', () => {
test('sans égard à la casse, aux accents, aux espaces, à « _ » ni à « - »', () => {
const cas = [
['NOM', 'nom'],
[' Prénom ', 'prenom'],
['Équipe', 'equipe'],
['Rôle', 'role'],
['E X C L U', 'exclu'],
['titre_pressenti', 'titrepressenti'],
['Titre-Pressenti', 'titrepressenti'],
['titre pressenti', 'titrepressenti'],
['titre\u{A0}_\u{9}pressenti', 'titrepressenti'],
[' Ti_tre--Pres sen-ti ', 'titrepressenti'],
];
assert.deepEqual(
cas.map(([entete]) => cleEntete(entete)),
cas.map(([, cle]) => cle),
);
});
test('seuls les blancs, « _ » et « - » sont retirés', () => {
assert.equal(cleEntete('titre.pressenti'), 'titre.pressenti');
assert.equal(cleEntete('nom/prenom'), 'nom/prenom');
assert.equal(cleEntete('titre\u{2013}pressenti'), 'titre\u{2013}pressenti');
});
test('un en-tête vide ou fait de blancs, de « _ » et de « - » donne la clé vide', () => {
for (const entete of ['', ' ', '_-_', ' - ']) assert.equal(cleEntete(entete), '', JSON.stringify(entete));
});
});

View file

@ -148,7 +148,7 @@ const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]);
// Itérations entre deux appels de progression, et entre deux lectures du // Itérations entre deux appels de progression, et entre deux lectures du
// signal. // signal.
const CADENCE = 1_000; const CADENCE = 1_000;
const HISTORIQUE_PAR_DEFAUT = 1_000; export const HISTORIQUE_PAR_DEFAUT = 1_000;
// Longueurs d'historique sans changement du score courant au-delà desquelles // Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance, dans l'ordre des contraintes seulement. // une descente se relance, dans l'ordre des contraintes seulement.
const PATIENCE = 10; const PATIENCE = 10;

187
src/stockage/canonique.js Normal file
View file

@ -0,0 +1,187 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le texte canonique du fichier d'état (§ 8.8, § 8.9), par le seul
// sérialiseur du stockage, qui suit le schéma du document. L'ordre des clés
// est celui des champs du schéma, jamais celui dans lequel un objet a reçu
// les siennes ; chaque liste se trie selon sa règle ; les valeurs s'écrivent
// par JSON.stringify. Le texte ne dépend que des arguments : ni horloge ni
// tirage n'y entrent au moment d'écrire.
//
// Mise en page, deux espaces par niveau. Un objet de mise « lignes » — le
// fichier, la charge — porte un champ par ligne, « "clé": valeur ». Une
// liste de mise « lignes » — les collections de la charge, le placement —
// porte un élément par ligne, et s'écrit [] sur la ligne de sa clé quand
// elle est vide. Un objet de mise « ouverte » — une proposition, le retenu —
// s'écrit compact sur la ligne qui l'ouvre, chaque champ selon sa propre
// règle : son placement y ouvre un crochet, porte un tour par ligne un
// niveau plus bas, et le referme au niveau de l'objet. Toute autre valeur
// s'écrit compacte, sur la ligne de sa clé.
//
// Un retenu que sa règle refuse, l'analyse l'admet, puisqu'elle n'en lit
// que le conteneur, et le contrôle des placements le garde en le signalant
// (§ 8.9, point 3). Le schéma ne décrit pas une telle forme : le retenu se
// recopie hors du schéma, ses clés rangées, ses listes dans l'ordre écrit,
// et s'écrit compact à la clé retenu. Le texte se relit ainsi au même
// retenu, et les gestes qui suivent s'enregistrent.
import { FORMAT, SCHEMA, clesRangees, comptesDe, premiereFaute } from './document.js';
// Règle du champ cle d'un objet du schéma.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const ENTETE = regleDuChamp(SCHEMA, 'entete');
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const RETENU = regleDuChamp(CHARGE, 'retenu');
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
/**
* Vrai quand valeur, lue à la place que regle décrit, est un retenu que sa
* règle refuse (premiereFaute) : un objet, que l'analyse admet et que le
* contrôle des placements garde (§ 8.9, point 3). canoniser le recopie hors
* du schéma, le texte l'écrit compact, et difference le pose entier. Faux
* pour toute autre règle, pour null et pour un retenu qui n'est pas un
* objet, que l'analyse refuse.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @returns {boolean}
*/
export function retenuHorsDeSaRegle(valeur, regle) {
return regle === RETENU && estObjet(valeur) && premiereFaute(valeur, RETENU) !== null;
}
// Copie de valeur hors du schéma : chaque objet refait, ses clés posées par
// unités UTF-16 croissantes, chaque liste dans son ordre, toute autre valeur
// telle quelle. JSON.stringify écrit les clés de la copie dans cet ordre,
// les clés d'index entier en tête, croissantes, comme JavaScript les
// énumère : le texte ne dépend que de l'ensemble des clés. Object.fromEntries
// pose chaque clé en propre, « __proto__ » comprise.
function copierHorsDuSchema(valeur) {
if (Array.isArray(valeur)) return valeur.map(copierHorsDuSchema);
if (!estObjet(valeur)) return valeur;
return Object.fromEntries(clesRangees(valeur).map((cle) => [cle, copierHorsDuSchema(valeur[cle])]));
}
// Copie de valeur selon sa règle : chaque objet refait dans l'ordre de ses
// champs, chaque liste copiée, puis triée quand sa règle le demande ; l'ordre
// de la règle compare ainsi des copies déjà canoniques. Un objet qui a un
// champ siegesAttribues — une proposition, le retenu — en transmet la valeur
// à ce qu'il contient : ses listes de table ne se trient que quand il vaut
// faux. Le réglage attribuerSieges de la charge n'y entre pas. Les clés hors
// du schéma ne sont pas copiées. Un retenu que sa règle refuse se copie hors
// du schéma, entier. Ailleurs, une clé du schéma absente lève TypeError en
// nommant son chemin : JSON.stringify la tairait, et le texte ne se relirait
// pas.
function copier(valeur, regle, chemin, siegesAttribues) {
if (valeur === null) return null;
if (retenuHorsDeSaRegle(valeur, regle)) return copierHorsDuSchema(valeur);
if (regle.genre === 'objet') {
const drapeau = regle.cles.has('siegesAttribues') ? valeur.siegesAttribues : siegesAttribues;
const copie = {};
for (const [cle, regleDeCle] of regle.champs) {
const cheminDeCle = `${chemin}.${cle}`;
if (valeur[cle] === undefined) throw new TypeError(`${cheminDeCle} absente`);
copie[cle] = copier(valeur[cle], regleDeCle, cheminDeCle, drapeau);
}
return copie;
}
if (regle.genre === 'liste') {
const copie = valeur.map((element, rang) => copier(element, regle.element, `${chemin}[${rang}]`, siegesAttribues));
if (regle.tri !== undefined) copie.sort(regle.tri);
if (regle.triSansAttribution !== undefined && siegesAttribues === false) copie.sort(regle.triSansAttribution);
return copie;
}
return valeur;
}
// Texte de valeur, déjà canonique, selon sa règle, au retrait de sa ligne :
// la première ligne continue celle de la clé, les suivantes portent leur
// propre retrait. Un retenu que sa règle refuse s'écrit compact.
function rendre(valeur, regle, retrait) {
if (valeur === null || regle.mise === undefined || retenuHorsDeSaRegle(valeur, regle)) {
return JSON.stringify(valeur);
}
const dedans = ' '.repeat(retrait + 2);
const fin = ' '.repeat(retrait);
if (regle.genre === 'liste') {
if (valeur.length === 0) return '[]';
const lignes = valeur.map((element) => dedans + rendre(element, regle.element, retrait + 2));
return `[\n${lignes.join(',\n')}\n${fin}]`;
}
if (regle.mise === 'lignes') {
const lignes = regle.champs.map(
([cle, regleDeCle]) => `${dedans}${JSON.stringify(cle)}: ${rendre(valeur[cle], regleDeCle, retrait + 2)}`,
);
return `{\n${lignes.join(',\n')}\n${fin}}`;
}
// Mise « ouverte » : la syntaxe compacte, chaque champ selon sa règle au
// retrait de l'objet.
const champs = regle.champs.map(
([cle, regleDeCle]) => `${JSON.stringify(cle)}:${rendre(valeur[cle], regleDeCle, retrait)}`,
);
return `{${champs.join(',')}}`;
}
/**
* Copie de la charge dans l'ordre canonique (§ 8.8, § 8.9). Les clés de
* chaque objet y sont posées dans l'ordre du schéma : JSON.stringify de la
* copie est canonique lui aussi. Participants et tables s'y rangent par
* identifiant ; propositions par identifiant, puis graine, compte d'arrêt,
* historique et texte canonique ; réservations par participant, table,
* portée — tous avant tour —, tour, siège, null en tête ; titres par table,
* siège, libellé. Une proposition, ou le retenu, dont siegesAttribues est
* faux a chaque liste de table de son placement par identifiant croissant ;
* vrai, l'ordre est celui des sièges et reste tel quel. C'est le drapeau de
* la proposition qui décide, jamais le réglage attribuerSieges : le changer
* ne détruit pas l'ordre des sièges d'une proposition déjà produite. Ses
* participants et chaque réserve, des ensembles, se rangent par identifiant
* croissant quel que soit le drapeau. L'ordre des tables d'une proposition,
* qui apparie chaque liste à sa table, est gardé. La charge reçue n'est pas
* modifiée, et la copie n'en partage aucun objet.
*
* La charge est celle que rend examiner (placements.js) : l'analyse l'admet,
* et chaque proposition suit sa règle ; une clé du schéma absente, ailleurs
* que dans le retenu, lève TypeError. Un retenu que sa règle refuse, et que
* l'analyse admet, se copie hors du schéma (retenuHorsDeSaRegle) : chaque
* objet refait, ses clés rangées par unités UTF-16, chaque liste dans
* l'ordre reçu, ses clés inconnues gardées. Un retenu qui n'est pas un
* objet lève TypeError.
*
* @param {import('./types.js').Charge} charge
* @returns {import('./types.js').Charge}
*/
export function canoniser(charge) {
return copier(charge, CHARGE, 'charge');
}
/**
* Texte canonique du fichier d'état (§ 8.8) : l'en-tête — FORMAT,
* produitVersion, revision et les comptes de la charge —, la charge
* canonique, et une fin de ligne finale. Deux charges qui ne diffèrent que
* par l'ordre de leurs clés, ou par celui d'une liste que canoniser range,
* s'écrivent octet pour octet pareil : chaque ordre est total. revision ou
* produitVersion absents lèvent TypeError.
*
* @param {import('./types.js').Charge} charge
* @param {{revision: number, produitVersion: string}} entete
* @returns {string}
*/
export function serialiser(charge, { revision, produitVersion }) {
const copie = canoniser(charge);
const entete = copier({ format: FORMAT, produitVersion, revision, comptes: comptesDe(copie) }, ENTETE, 'entete');
return `${rendre({ entete, charge: copie }, SCHEMA, 0)}\n`;
}
/**
* Texte canonique de la charge seule, dans la mise en page du fichier, sans
* en-tête, et une fin de ligne finale : ce que comparent l'empreinte des
* démonstrations et l'aller-retour des fichiers livrés, qui ne dépendent pas
* de la construction qui a écrit (§ 8.8, § 15.5).
*
* @param {import('./types.js').Charge} charge
* @returns {string}
*/
export function serialiserCharge(charge) {
return `${rendre(canoniser(charge), CHARGE, 0)}\n`;
}

View file

@ -0,0 +1,703 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la forme canonique du fichier d'état (§ 8.8, § 8.9) : le texte
// exact que le contrat de données attend du sérialiseur, la canonicité quel
// que soit l'ordre dans lequel une charge s'est construite, la charge seule
// dans la mise en page du fichier, l'aller-retour par l'analyse, le retenu
// que sa règle refuse, recopié hors du schéma, et un sérialiseur dont la
// sortie ne dépend que de ses arguments. Les noms des charges d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { VERSION } from '../version.genere.js';
import { canoniser, serialiser, serialiserCharge } from './canonique.js';
import { analyser } from './document.js';
// En-tête du texte du contrat.
const ENTETE = Object.freeze({ revision: 3, produitVersion: VERSION.affichee });
// Texte exact que le contrat de données (types.js) attend de serialiser pour
// chargeContrat() sous ENTETE, fin de ligne finale comprise.
const TEXTE_CONTRAT = `{
"entete": {"format":1,"produitVersion":"${VERSION.affichee}","revision":3,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},
"charge": {
"evenement": {"id":"evt-essai","nom":"Soirée d'essai","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm","etat":"propose","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":5,"table":3,"proposition":2},
"participants": [
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Club des Merles","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":2,"nom":"Grisaille","prenom":null,"appartenance":"Club des Merles","courriel":"g@exemple.test","titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":"arrive tard","exclu":false},
{"id":4,"nom":"Lacasse","prenom":"Ondine","appartenance":"Société Alpha","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":null,"forme":"carree","position":{"x":250,"y":0}}
],
"reservations": [
{"participant":1,"table":1,"siege":1,"portee":"tous","tour":null}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"}
],
"propositions": [
{"id":1,"graine":48271,"arret":200000,"historique":1000,"produitVersion":"${VERSION.affichee}","siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":2,"participants":[1,2,3,4],"placement":[
{"sieges":[[1,3],[2,4]],"reserve":[]},
{"sieges":[[1,4],[2,3]],"reserve":[]}
]}
],
"retenu": null
}
}
`;
// La charge que décrit TEXTE_CONTRAT, une copie neuve à chaque appel.
function chargeContrat() {
return {
evenement: {
id: 'evt-essai',
nom: "Soirée d'essai",
date: null,
siegesParDefaut: 2,
tours: 2,
unite: 'cm',
etat: 'propose',
filiation: null,
},
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { nombre: 5, arret: 200_000, historique: 1_000 },
},
prochainsIds: { participant: 5, table: 3, proposition: 2 },
participants: [
{ id: 1, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', courriel: null,
titrePressenti: 'animation', notes: null, exclu: false },
{ id: 2, nom: 'Grisaille', prenom: null, appartenance: 'Club des Merles', courriel: 'g@exemple.test',
titrePressenti: null, notes: null, exclu: false },
{ id: 3, nom: 'Pervenche', prenom: 'Théo', appartenance: null, courriel: null,
titrePressenti: null, notes: 'arrive tard', exclu: false },
{ id: 4, nom: 'Lacasse', prenom: 'Ondine', appartenance: 'Société Alpha', courriel: null,
titrePressenti: null, notes: null, exclu: false },
],
tables: [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 2, numero: 2, sieges: null, forme: 'carree', position: { x: 250, y: 0 } },
],
reservations: [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }],
titres: [{ table: 1, siege: 1, libelle: 'animation' }],
propositions: [
{
id: 1,
graine: 48_271,
arret: 200_000,
historique: 1_000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: [1, 2],
capacites: [2, 2],
tours: 2,
participants: [1, 2, 3, 4],
placement: [
{ sieges: [[1, 3], [2, 4]], reserve: [] },
{ sieges: [[1, 4], [2, 3]], reserve: [] },
],
},
],
retenu: null,
};
}
// texte où avant, qui doit y figurer exactement une fois, devient apres.
// Une épreuve qui dérive son attendu d'un autre texte échoue ainsi quand le
// remplacement ne trouve rien, au lieu de comparer deux textes inchangés.
function remplacer(texte, avant, apres) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
return texte.replace(avant, () => apres);
}
// La charge du contrat avec un retenu à sièges non attribués dont les listes
// de table arrivent non triées, et le texte qui l'écrit : le retenu prend la
// mise en page d'une proposition, à la clé retenu, et l'en-tête le compte.
function chargeAvecRetenu() {
const charge = chargeContrat();
charge.retenu = {
proposition: 1,
siegesAttribues: false,
tables: [1, 2],
capacites: [2, 2],
tours: 2,
participants: [1, 2, 3, 4],
placement: [
{ sieges: [[3, 1], [2, 4]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
],
};
return charge;
}
const TEXTE_AVEC_RETENU = remplacer(
remplacer(TEXTE_CONTRAT, '"retenu":0}}', '"retenu":1}}'),
' "retenu": null\n',
[
' "retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":2,"participants":[1,2,3,4],"placement":[',
' {"sieges":[[1,3],[2,4]],"reserve":[]},',
' {"sieges":[[1,4],[2,3]],"reserve":[]}',
' ]}',
'',
].join('\n'),
);
// Une charge dont toutes les listes sont vides, avec une filiation, et dont
// le nom porte des guillemets droits ; puis le texte qui l'écrit, révision 1.
function chargeSansListe() {
return {
evenement: {
id: 'evt-neuf',
nom: 'Atelier "jeudi"',
date: '2026-11-12',
siegesParDefaut: 8,
tours: 3,
unite: 'cm',
etat: 'brouillon',
filiation: {
source: { id: 'evt-modele', nom: 'Atelier modèle' },
instant: { revision: 7, libelle: 'Tables posées' },
},
},
reglages: {
separerAppartenances: false,
nouveauxVoisins: true,
nouvelleTable: false,
varierAppartenances: true,
attribuerSieges: false,
generation: { nombre: 2, arret: 1_000, historique: 10 },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
};
}
const TEXTE_SANS_LISTE = `{
"entete": {"format":1,"produitVersion":"${VERSION.affichee}","revision":1,"comptes":{"participants":0,"tables":0,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"evt-neuf","nom":"Atelier \\"jeudi\\"","date":"2026-11-12","siegesParDefaut":8,"tours":3,"unite":"cm","etat":"brouillon","filiation":{"source":{"id":"evt-modele","nom":"Atelier modèle"},"instant":{"revision":7,"libelle":"Tables posées"}}},
"reglages": {"separerAppartenances":false,"nouveauxVoisins":true,"nouvelleTable":false,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":2,"arret":1000,"historique":10}},
"prochainsIds": {"participant":1,"table":1,"proposition":1},
"participants": [],
"tables": [],
"reservations": [],
"titres": [],
"propositions": [],
"retenu": null
}
}
`;
// Copie de valeur dont chaque objet reçoit ses clés dans l'ordre inverse :
// un sérialiseur qui suivrait l'ordre d'insertion écrirait un autre texte.
// Object.fromEntries pose chaque clé en propre, « __proto__ » comprise.
function aRebours(valeur) {
if (Array.isArray(valeur)) return valeur.map(aRebours);
if (typeof valeur !== 'object' || valeur === null) return valeur;
return Object.fromEntries(Object.keys(valeur).reverse().map((cle) => [cle, aRebours(valeur[cle])]));
}
// Gèle valeur et tout ce qu'elle contient : une écriture y lève TypeError,
// le module s'exécutant en mode strict.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
// Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur
// lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ;
// rend ce que rend fonction.
function sansHorlogeNiAlea(fonction) {
const interdite = (nom) => () => {
throw new Error(`${nom} lu`);
};
const remplacees = [
[Math, 'random'],
[Date, 'now'],
[performance, 'now'],
[crypto, 'getRandomValues'],
[crypto, 'randomUUID'],
].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('serialiser : le texte du contrat de données (§ 8.8)', () => {
test('la charge du contrat, révision 3, rend le texte du contrat octet pour octet, fin de ligne finale comprise', () => {
assert.equal(serialiser(chargeContrat(), ENTETE), TEXTE_CONTRAT);
});
test("un retenu non nul suit la mise en page d'une proposition, à la clé retenu, et l'en-tête le compte", () => {
assert.equal(serialiser(chargeAvecRetenu(), ENTETE), TEXTE_AVEC_RETENU);
});
test('une liste vide s\'écrit [] sur la ligne de sa clé ; les chaînes passent par JSON.stringify', () => {
assert.equal(serialiser(chargeSansListe(), { revision: 1, produitVersion: VERSION.affichee }), TEXTE_SANS_LISTE);
});
test('un placement vide s\'écrit [] sur la ligne qui ouvre sa proposition', () => {
const charge = chargeContrat();
charge.propositions[0].placement = [];
const attendu = remplacer(
TEXTE_CONTRAT,
'"placement":[\n {"sieges":[[1,3],[2,4]],"reserve":[]},\n {"sieges":[[1,4],[2,3]],"reserve":[]}\n ]}',
'"placement":[]}',
);
assert.equal(serialiser(charge, ENTETE), attendu);
});
});
describe('canonicité (§ 8.8, § 8.9)', () => {
test('la même charge construite dans un autre ordre — participants 4, 2, 1, 3, clés à rebours, listes de table non triées — rend le même texte', () => {
const desordre = chargeContrat();
desordre.participants = [4, 2, 1, 3].map((id) => desordre.participants.find((p) => p.id === id));
desordre.propositions[0].placement = [
{ sieges: [[3, 1], [4, 2]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
];
const renversee = aRebours(desordre);
// L'épreuve n'a d'objet que si l'ordre a réellement changé.
assert.deepEqual(Object.keys(renversee.evenement), Object.keys(chargeContrat().evenement).reverse());
assert.deepEqual(Object.keys(renversee)[0], 'retenu');
assert.deepEqual(renversee.participants.map((p) => p.id), [4, 2, 1, 3]);
assert.equal(serialiser(renversee, ENTETE), serialiser(chargeContrat(), ENTETE));
assert.equal(serialiser(renversee, ENTETE), TEXTE_CONTRAT);
});
test("une proposition, ou le retenu, à siegesAttribues vrai garde l'ordre de ses listes de table ; à faux, chacune se trie", () => {
const charge = chargeContrat();
charge.propositions[0].siegesAttribues = true;
charge.propositions[0].placement[0].sieges = [[3, 1], [4, 2]];
const attendu = remplacer(
remplacer(
TEXTE_CONTRAT,
`"produitVersion":"${VERSION.affichee}","siegesAttribues":false`,
`"produitVersion":"${VERSION.affichee}","siegesAttribues":true`,
),
'{"sieges":[[1,3],[2,4]],"reserve":[]}',
'{"sieges":[[3,1],[4,2]],"reserve":[]}',
);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.deepEqual(canoniser(charge).propositions[0].placement[0].sieges, [[3, 1], [4, 2]]);
const retenu = chargeAvecRetenu();
assert.deepEqual(canoniser(retenu).retenu.placement[1].sieges, [[1, 4], [2, 3]]);
retenu.retenu.siegesAttribues = true;
assert.deepEqual(canoniser(retenu).retenu.placement[1].sieges, [[4, 1], [3, 2]]);
});
test("basculer le réglage attribuerSieges ne change l'ordre d'aucune proposition déjà écrite", () => {
// Deux propositions, l'une à sièges attribués dont les listes ne sont
// pas croissantes, l'autre à sièges non attribués ; un retenu à sièges
// attribués. Le réglage vaut tour à tour faux et vrai.
const charge = chargeAvecRetenu();
charge.retenu.siegesAttribues = true;
charge.propositions.push({
...charge.propositions[0],
id: 2,
siegesAttribues: true,
placement: [
{ sieges: [[3, 1], [4, 2]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
],
});
const textes = [false, true].map((attribuerSieges) => {
charge.reglages.attribuerSieges = attribuerSieges;
return serialiser(charge, ENTETE).split('\n');
});
assert.equal(textes[1].length, textes[0].length);
const differentes = textes[0].flatMap((ligne, i) => (ligne === textes[1][i] ? [] : [i]));
assert.deepEqual(differentes.map((i) => textes[0][i].slice(0, 16)), [' "reglages": ']);
for (const lignes of textes) {
assert.ok(lignes.includes(' {"sieges":[[1,3],[2,4]],"reserve":[]},'));
assert.ok(lignes.includes(' {"sieges":[[3,1],[4,2]],"reserve":[]},'));
assert.ok(lignes.includes(' {"sieges":[[4,1],[3,2]],"reserve":[]}'));
}
});
test("l'ordre des tables d'une proposition est gardé : il apparie chaque liste à sa table", () => {
const charge = chargeContrat();
Object.assign(charge.propositions[0], {
tables: [2, 1],
capacites: [2, 2],
placement: [
{ sieges: [[4, 2], [3, 1]], reserve: [] },
{ sieges: [[3, 2], [4, 1]], reserve: [] },
],
});
const proposition = canoniser(charge).propositions[0];
assert.deepEqual(proposition.tables, [2, 1]);
assert.deepEqual(proposition.placement.map((tour) => tour.sieges), [[[2, 4], [1, 3]], [[2, 3], [1, 4]]]);
});
test("les participants d'un plan et chaque réserve sont des ensembles : ils se rangent par identifiant croissant, quel que soit siegesAttribues", () => {
// Au tour 1 du retenu et au tour 2 de la proposition, deux personnes
// attendent hors des tables. croissants faux pose chaque réserve et
// chaque liste de participants à rebours ; les listes de table, elles,
// sont les mêmes dans les deux charges.
const plans = (siegesAttribues, croissants) => {
const ordonner = (ids) => (croissants ? ids : [...ids].reverse());
const charge = chargeAvecRetenu();
for (const plan of [charge.propositions[0], charge.retenu]) {
Object.assign(plan, { siegesAttribues, participants: ordonner([1, 2, 3, 4]) });
}
charge.retenu.placement[0] = { sieges: [[1], [4]], reserve: ordonner([2, 3]) };
charge.propositions[0].placement[1] = { sieges: [[1], [2]], reserve: ordonner([3, 4]) };
return charge;
};
for (const siegesAttribues of [false, true]) {
assert.equal(serialiser(plans(siegesAttribues, false), ENTETE), serialiser(plans(siegesAttribues, true), ENTETE));
const copie = canoniser(plans(siegesAttribues, false));
assert.deepEqual(copie.propositions[0].participants, [1, 2, 3, 4]);
assert.deepEqual(copie.retenu.participants, [1, 2, 3, 4]);
assert.deepEqual(copie.retenu.placement[0].reserve, [2, 3]);
assert.deepEqual(copie.propositions[0].placement[1].reserve, [3, 4]);
}
});
test('tables et propositions se trient par identifiant', () => {
const charge = chargeContrat();
charge.tables.reverse();
const [premiere] = charge.propositions;
charge.propositions = [{ ...premiere, id: 3 }, premiere, { ...premiere, id: 2 }];
const copie = canoniser(charge);
assert.deepEqual(copie.tables.map((table) => table.id), [1, 2]);
assert.deepEqual(copie.propositions.map((proposition) => proposition.id), [1, 2, 3]);
});
test("des propositions de même identifiant se rangent par graine, arrêt, historique, puis texte canonique : ni l'ordre reçu ni l'écriture de chacune n'y entrent", () => {
// L'analyse n'examine pas les propositions : elle admet deux
// propositions de même identifiant. Les voici dans leur ordre canonique,
// chacune ne différant de la précédente que par ce qui les départage.
// Chaque clé numérique oppose 9 à 10, que l'ordre des textes rangerait à
// l'inverse. Les trois égales sur ces clés ne diffèrent que par leur
// placement : seul le texte canonique les départage. Une proposition 2
// de graine 1 les suit toutes.
const [premiere] = chargeContrat().propositions;
const [tourA, tourB] = premiere.placement;
const egales = [
[tourA, tourB],
[tourB, tourA],
[
{ sieges: [[2, 3], [1, 4]], reserve: [] },
{ sieges: [[2, 4], [1, 3]], reserve: [] },
],
].map((placement) => ({ ...premiere, graine: 10, arret: 10, historique: 10, placement }));
const rangees = [
{ ...premiere, graine: 9 },
{ ...premiere, graine: 10, arret: 9 },
{ ...premiere, graine: 10, arret: 10, historique: 9 },
...egales,
{ ...premiere, id: 2, graine: 1 },
];
// Deux écritures qui changent le JSON d'une proposition sans changer sa
// copie canonique : ses clés à rebours ; ou ses participants, ses
// réserves et, à sièges non attribués, ses listes de table à rebours.
const listesARebours = (proposition) => ({
...proposition,
participants: [...proposition.participants].reverse(),
placement: proposition.placement.map(({ sieges, reserve }) => ({
sieges: sieges.map((liste) => [...liste].reverse()),
reserve: [...reserve].reverse(),
})),
});
const avec = (propositions) => {
const charge = chargeContrat();
charge.propositions = propositions;
charge.prochainsIds.proposition = 3;
return charge;
};
const parTexte = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const texte = serialiser(avec(rangees), ENTETE);
for (const ecrire of [aRebours, listesARebours]) {
for (const parite of [0, 1]) {
// Une proposition sur deux change d'écriture, selon la parité de son
// rang canonique.
const ecrites = rangees.map((proposition, rang) => (rang % 2 === parite ? ecrire(proposition) : proposition));
const cas = `${ecrire.name}, parité ${parite}`;
// L'épreuve n'a d'objet que si le JSON des propositions égales, telles
// qu'écrites, les rangerait autrement que leur texte canonique.
const brutes = egales.map((proposition) => JSON.stringify(ecrites[rangees.indexOf(proposition)]));
assert.notDeepEqual([...brutes].sort(parTexte), brutes, cas);
for (const ordre of [[6, 5, 4, 3, 2, 1, 0], [2, 6, 0, 5, 3, 1, 4], [1, 0, 3, 2, 5, 4, 6]]) {
const recues = ordre.map((rang) => ecrites[rang]);
assert.deepEqual(canoniser(avec(recues)).propositions, rangees, `${cas}, ordre reçu ${ordre}`);
assert.equal(serialiser(avec(recues), ENTETE), texte, `${cas}, ordre reçu ${ordre}`);
// Le même désordre dans un texte que l'analyse admet se réécrit pareil.
const fichier = JSON.parse(texte);
fichier.charge.propositions = recues;
const relue = analyser(JSON.stringify(fichier)).charge;
assert.equal(serialiser(relue, ENTETE), texte, `texte lu, ${cas}, ordre ${ordre}`);
}
}
}
});
test('réservations triées par participant, table, portée — tous avant tour —, tour puis siège, null en tête', () => {
const reservation = (participant, table, portee, tour, siege) => ({ participant, table, siege, portee, tour });
const attendues = [
reservation(1, 1, 'tous', null, null),
reservation(1, 1, 'tous', null, 2),
reservation(1, 1, 'tour', 1, null),
reservation(1, 1, 'tour', 1, 1),
reservation(1, 1, 'tour', 1, 2),
reservation(1, 1, 'tour', 2, null),
reservation(1, 2, 'tour', 1, null),
reservation(2, 1, 'tour', 2, null),
];
const charge = chargeContrat();
// Chaque réservation arrive après celle qui la suit : seul le tri les
// remet en ordre.
charge.reservations = [...attendues].reverse();
assert.deepEqual(canoniser(charge).reservations, attendues);
});
test('titres triés par table puis siège, et par libellé à égalité', () => {
const titre = (table, siege, libelle) => ({ table, siege, libelle });
const charge = chargeContrat();
charge.titres = [titre(2, 1, 'b'), titre(1, 3, 'a'), titre(1, 1, 'z'), titre(1, 1, 'm')];
assert.deepEqual(canoniser(charge).titres, [titre(1, 1, 'm'), titre(1, 1, 'z'), titre(1, 3, 'a'), titre(2, 1, 'b')]);
});
test("deux textes se départagent unité UTF-16 par unité, ni selon la langue ni sans égard à la casse : libellés d'une même place, puis propositions qui ne diffèrent que par leur version", () => {
// Les capitales (0x41, 0x43) précèdent les minuscules, et é (0xE9) suit
// f (0x66) ; l'ordre de la langue rangerait a, A, b, C, é, f, et un
// ordre sans casse laisserait l'ordre reçu départager a et A.
const libelles = ['b', 'C', 'a', 'A', 'f', '\u{e9}'];
const [premiere] = chargeContrat().propositions;
for (const ordre of [libelles, [...libelles].reverse()]) {
const charge = chargeContrat();
charge.titres = ordre.map((libelle) => ({ table: 1, siege: 1, libelle }));
assert.deepEqual(canoniser(charge).titres.map(({ libelle }) => libelle), ['A', 'C', 'a', 'b', 'f', '\u{e9}']);
}
for (const ordre of [['b', 'C'], ['C', 'b']]) {
const charge = chargeContrat();
charge.propositions = ordre.map((produitVersion) => ({ ...premiere, produitVersion }));
assert.deepEqual(canoniser(charge).propositions.map(({ produitVersion }) => produitVersion), ['C', 'b']);
}
});
test("canoniser, serialiser et serialiserCharge laissent intacte la charge reçue, et la copie s'en détache", () => {
const desordre = chargeAvecRetenu();
desordre.participants.reverse();
desordre.propositions[0].placement[0].sieges = [[3, 1], [4, 2]];
const charge = geler(aRebours(desordre));
const avant = JSON.stringify(charge);
const copie = canoniser(charge);
serialiser(charge, ENTETE);
serialiserCharge(charge);
copie.participants[0].nom = 'Autre';
copie.propositions[0].placement[0].sieges[0].push(9);
copie.retenu.placement[0].reserve.push(9);
assert.equal(JSON.stringify(charge), avant);
});
test("les clés de la copie suivent l'ordre du schéma : JSON.stringify de la copie est canonique", () => {
const attendu = JSON.stringify(JSON.parse(TEXTE_CONTRAT).charge);
assert.equal(JSON.stringify(canoniser(aRebours(chargeContrat()))), attendu);
});
test('une clé du schéma absente lève TypeError au lieu de disparaître du texte', () => {
const charge = chargeContrat();
delete charge.participants[2].notes;
assert.throws(() => serialiser(charge, ENTETE), {
name: 'TypeError',
message: /charge\.participants\[2\]\.notes/,
});
assert.throws(() => canoniser(charge), { name: 'TypeError', message: /charge\.participants\[2\]\.notes/ });
const sansDrapeau = chargeContrat();
delete sansDrapeau.propositions[0].siegesAttribues;
assert.throws(() => canoniser(sansDrapeau), {
name: 'TypeError',
message: /charge\.propositions\[0\]\.siegesAttribues/,
});
assert.throws(() => serialiser(chargeContrat(), { produitVersion: VERSION.affichee }), {
name: 'TypeError',
message: /entete\.revision/,
});
});
});
describe('serialiserCharge : la charge seule (§ 8.8)', () => {
test("la charge s'écrit dans la mise en page du fichier, sans en-tête, et finit sur une fin de ligne", () => {
// Les lignes de la charge dans le texte du contrat, un niveau plus haut :
// l'accolade qui ouvre la clé charge, puis tout jusqu'à celle qui la ferme.
const lignes = TEXTE_CONTRAT.split('\n');
assert.equal(lignes[2], ' "charge": {');
assert.deepEqual(lignes.slice(-3), [' }', '}', '']);
const attendu = ['{', ...lignes.slice(3, -3).map((ligne) => ligne.slice(2)), '}', ''].join('\n');
assert.equal(serialiserCharge(chargeContrat()), attendu);
assert.equal(serialiserCharge(aRebours(chargeContrat())), attendu);
});
test("révision et produitVersion ne changent que la ligne de l'en-tête", () => {
const autre = versionVoisine(VERSION.affichee);
const avant = serialiser(chargeContrat(), ENTETE).split('\n');
const apres = serialiser(chargeContrat(), { revision: 41, produitVersion: autre }).split('\n');
assert.equal(apres.length, avant.length);
assert.deepEqual(avant.flatMap((ligne, i) => (ligne === apres[i] ? [] : [i])), [1]);
assert.equal(
apres[1],
` "entete": {"format":1,"produitVersion":"${autre}","revision":41,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},`,
);
});
});
describe('aller-retour par analyser (§ 8.9)', () => {
test("serialiser(analyser(t).charge, en-tête de t) rend t : texte du contrat, retenu, listes vides", () => {
const textes = [TEXTE_CONTRAT, TEXTE_AVEC_RETENU, TEXTE_SANS_LISTE];
for (const texte of textes) {
const { entete, charge } = analyser(texte);
assert.equal(serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
}
});
test("le sérialiseur ne normalise aucune chaîne : un nom en NFD s'écrit tel quel, et le texte relu se réécrit octet pour octet", () => {
// La conversion en NFC se fait à l'entrée, jamais à l'écriture : un
// fichier écrit à la main en NFD garde ses octets.
const nfd = 'Re\u{301}glisse';
assert.equal(nfd.length, 9);
assert.notEqual(nfd.normalize('NFC'), nfd);
const charge = chargeContrat();
charge.participants[3].nom = nfd;
const texte = serialiser(charge, ENTETE);
assert.ok(texte.includes(`"nom":"${nfd}"`));
const { entete, charge: relue } = analyser(texte);
assert.equal(relue.participants[3].nom, nfd);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
});
});
describe('un retenu hors de sa règle (§ 8.9, point 3)', () => {
// Façons d'abîmer le retenu de chargeAvecRetenu, chacune en place : sa
// règle le refuse, l'analyse, qui n'en lit que le conteneur, l'admet, et
// examiner le garde en le signalant.
const FACONS = [
['« tours » renommé « toura »', (r) => {
r.toura = r.tours;
delete r.tours;
}],
['une clé du schéma retirée', (r) => { delete r.siegesAttribues; }],
['un placement objet', (r) => { r.placement = {}; }],
['des capacités nombre', (r) => { r.capacites = 2; }],
['un tour devenu la liste de ses tables', (r) => { r.placement[0] = r.placement[0].sieges; }],
['une clé inconnue', (r) => { r.note = 'à revoir'; }],
['une clé inconnue dans un tour', (r) => { r.placement[1].commentaire = 'tour calme'; }],
['tours à 0', (r) => { r.tours = 0; }],
['une clé « __proto__ »', (r) => {
Object.defineProperty(r, '__proto__', { value: 'x', enumerable: true, writable: true, configurable: true });
}],
["des clés d'index entier", (r) => {
r['9'] = 'neuf';
r['10'] = 'dix';
}],
['un objet vide', (r) => {
for (const cle of Object.keys(r)) delete r[cle];
}],
];
const abimee = (facon) => {
const charge = chargeAvecRetenu();
facon(charge.retenu);
return charge;
};
const LIGNE_DU_RETENU = ' "retenu": ';
test("un retenu que sa règle refuse s'écrit compact à la clé retenu, recopié hors du schéma : ses clés rangées par unités UTF-16, ses listes dans l'ordre écrit, ses clés inconnues gardées", () => {
const charge = abimee(FACONS[0][1]);
charge.retenu.note = 'à revoir';
const attendu = remplacer(
remplacer(TEXTE_CONTRAT, '"retenu":0}}', '"retenu":1}}'),
' "retenu": null\n',
`${LIGNE_DU_RETENU}{"capacites":[2,2],"note":"à revoir","participants":[1,2,3,4],"placement":[{"reserve":[],"sieges":[[3,1],[2,4]]},{"reserve":[],"sieges":[[4,1],[3,2]]}],"proposition":1,"siegesAttribues":false,"tables":[1,2],"toura":2}\n`,
);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.equal(serialiser(aRebours(charge), ENTETE), attendu);
});
test("chaque retenu que sa règle refuse et que l'analyse admet s'écrit, se relit au même retenu et se réécrit octet pour octet, quel que soit l'ordre de ses clés ; canoniser en rend une copie détachée", () => {
assert.ok(FACONS.length > 0, 'aucune façon examinée');
const ecarts = FACONS.flatMap(([libelle, facon]) => {
const charge = abimee(facon);
try {
const texte = serialiser(charge, ENTETE);
const ligne = texte.split('\n').find((candidate) => candidate.startsWith(LIGNE_DU_RETENU));
assert.deepEqual(JSON.parse(ligne.slice(LIGNE_DU_RETENU.length)), charge.retenu);
const { entete, charge: relue } = analyser(texte);
assert.deepEqual(relue.retenu, charge.retenu);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
assert.equal(serialiser(aRebours(charge), ENTETE), texte);
assert.equal(serialiserCharge(charge), serialiserCharge(relue));
const avant = JSON.stringify(charge.retenu);
const copie = canoniser(geler(structuredClone(charge))).retenu;
assert.deepEqual(copie, charge.retenu);
assert.equal(JSON.stringify(canoniser(charge).retenu), JSON.stringify(copie));
assert.equal(JSON.stringify(charge.retenu), avant);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.name} ${erreur.message.split('\n')[0]}`];
}
});
assert.deepEqual(ecarts, []);
});
test("un retenu qui n'est pas un objet, que l'analyse refuse, lève TypeError : aucun texte qu'elle refuserait ne s'écrit", () => {
for (const retenu of [[], 42, 'retenu', false]) {
const charge = chargeContrat();
charge.retenu = retenu;
assert.throws(() => serialiser(charge, ENTETE), TypeError, JSON.stringify(retenu));
assert.throws(() => canoniser(charge), TypeError, JSON.stringify(retenu));
}
});
});
describe('déterminisme (§ 8.8, § 14.7)', () => {
test("serialiser, serialiserCharge et canoniser ne lisent ni horloge ni aléa : leur sortie ne dépend que de leurs arguments", () => {
// 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(),
]) {
assert.throws(() => sansHorlogeNiAlea(lecture), / lu$/);
}
assert.equal(typeof Date.now(), 'number');
const rendre = () => {
const charge = chargeAvecRetenu();
return [serialiser(charge, ENTETE), serialiserCharge(charge), JSON.stringify(canoniser(charge))];
};
assert.deepEqual(sansHorlogeNiAlea(rendre), rendre());
});
});

336
src/stockage/correctifs.js Normal file
View file

@ -0,0 +1,336 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les correctifs du journal (§ 8.6, § 8.9). Un correctif est la liste des
// opérations qui mènent d'une charge à une autre ; le journal en porte un
// dans chaque entrée qui n'est pas un instantané.
//
// Une opération pose une valeur au bout d'un chemin, ou retire
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
// { op: 'retirer', chemin }. Un chemin est une liste d'étapes depuis la
// charge : une clé d'objet, chaîne ; un rang de liste, entier ≥ 0 ; ou
// { id }, qui désigne dans une liste d'enregistrements à identifiant —
// participants, tables, propositions — l'enregistrement de cet identifiant,
// et non une place.
//
// Les deux fonctions travaillent sur les copies canoniques de canonique.js :
// un rang y désigne une place de l'ordre canonique, que la charge seule
// détermine, et non de l'ordre dans lequel une liste s'est remplie en
// mémoire. Un correctif calculé depuis une charge s'applique ainsi à toute
// charge qui lui est égale à l'ordre près, quelle que soit la façon dont
// l'une ou l'autre s'est construite.
//
// Le correctif reste local (§ 8.9). Une personne qui prend, au même tour
// d'une proposition, la place d'une autre à une autre table touche les deux
// listes de ces tables et rien d'autre : une entrée de chacune quand chaque
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
// ils sont attribués. Sans attribution, chaque liste se trie par
// identifiant, et l'entrée qui change de rang décale celles qu'elle
// franchit dans sa liste. Une liste qui change de longueur se pose entière.
//
// Une charge que rend appliquer, l'analyse l'admet à la forme près comme
// celle qu'il reçoit : une valeur posée doit être admise à sa place, lue
// par le parcours même de l'analyse (premiereFauteEnPlace). Dans les
// propositions et le retenu, l'analyse ne lit que le conteneur, et le
// contrôle des placements juge le reste (§ 8.9) ; une valeur posée plus bas
// ne s'examine donc pas. Un retenu que sa règle refuse, que la lecture
// admet et que le contrôle des placements garde, canonique.js le recopie
// hors du schéma, et difference le pose entier : le journal le libère, le
// remet en place ou le remplace tel quel, quelle que soit sa faute.
import { canoniser, retenuHorsDeSaRegle } from './canonique.js';
import { SCHEMA, premiereFauteEnPlace } from './document.js';
import { ErreurStockage } from './erreurs.js';
/**
* Une étape d'un chemin : la clé d'un objet, le rang d'un élément de liste,
* ou { id }, l'enregistrement de cet identifiant dans une liste
* d'enregistrements à identifiant.
* @typedef {string|number|{id: number}} Etape
*
* @typedef {Object} OperationPoser
* @property {'poser'} op
* @property {Etape[]} chemin non vide
* @property {*} valeur valeur JSON, null comprise
*
* @typedef {Object} OperationRetirer
* @property {'retirer'} op
* @property {Etape[]} chemin non vide, terminé par { id }
*
* @typedef {OperationPoser|OperationRetirer} Operation
*/
// Règle de la charge dans le schéma du fichier d'état.
const CHARGE = SCHEMA.champs.find(([cle]) => cle === 'charge')[1];
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
// Vrai quand chaque élément de liste est un objet dont l'id est un entier
// exact, et qu'aucun id ne s'y répète : la seule forme où { id } désigne un
// enregistrement et un seul. Une copie canonique ne porte que les clés du
// schéma, où seuls les participants, les tables et les propositions ont un
// champ id : aucune autre liste non vide n'a cette forme. Une liste vide
// l'a, quelle que soit sa sorte, sans que comparer en tire un { id } : deux
// listes vides ne donnent rien, et contre une liste non vide, c'est la
// forme de celle-ci qui décide.
function identifiantsDistincts(liste) {
const vus = new Set();
for (const element of liste) {
if (!estObjet(element) || !Number.isSafeInteger(element.id) || vus.has(element.id)) return false;
vus.add(element.id);
}
return true;
}
// Ajoute à operations ce qui mène de a à b, deux valeurs canoniques de la
// règle regle, au bout de chemin. null contre une valeur, et deux scalaires
// différents, posent b. Un retenu que sa règle refuse, d'un côté ou de
// l'autre, n'a pas de champs que le schéma décrive : b se pose entier quand
// les deux copies canoniques s'écrivent autrement, et rien sinon. Deux
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
// d'enregistrements à identifiants distincts se comparent par identifiant ;
// deux autres listes, rang par rang quand elles ont la même longueur, et b
// se pose entière sinon.
function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
} else if (retenuHorsDeSaRegle(a, regle) || retenuHorsDeSaRegle(b, regle)) {
if (JSON.stringify(a) !== JSON.stringify(b)) operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.genre === 'objet') {
for (const [cle, regleDeCle] of regle.champs) comparer(a[cle], b[cle], regleDeCle, [...chemin, cle], operations);
} else if (identifiantsDistincts(a) && identifiantsDistincts(b)) {
comparerParIdentifiant(a, b, regle.element, chemin, operations);
} else if (a.length === b.length) {
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
} else {
operations.push({ op: 'poser', chemin, valeur: b });
}
}
// Fusion de deux listes d'enregistrements rangées par identifiant croissant,
// comme les laisse canoniser : un identifiant de a seul est retiré, un
// identifiant de b seul est posé entier, un identifiant commun se compare
// champ par champ. Les opérations suivent l'ordre des identifiants.
function comparerParIdentifiant(a, b, regleElement, chemin, operations) {
let i = 0;
let j = 0;
while (i < a.length || j < b.length) {
const idA = i < a.length ? a[i].id : Infinity;
const idB = j < b.length ? b[j].id : Infinity;
if (idA < idB) {
operations.push({ op: 'retirer', chemin: [...chemin, { id: idA }] });
i += 1;
} else if (idB < idA) {
operations.push({ op: 'poser', chemin: [...chemin, { id: idB }], valeur: b[j] });
j += 1;
} else {
comparer(a[i], b[j], regleElement, [...chemin, { id: idA }], operations);
i += 1;
j += 1;
}
}
}
/**
* Correctif menant de a à b (§ 8.6), calculé entre leurs copies canoniques :
* [] quand elles sont égales, c'est-à-dire quand a et b ne diffèrent que par
* l'ordre de leurs clés ou d'une liste que canoniser range. Les opérations
* suivent l'ordre du schéma, puis celui des identifiants, puis celui des
* rangs : le correctif ne dépend que des deux charges. Une valeur posée est
* prise à la copie canonique de b, et ne partage aucun objet avec b. Ni a ni
* b ne sont modifiées.
*
* Règles : les objets clé par clé dans l'ordre du schéma ; les listes
* d'enregistrements à identifiant — participants, tables, propositions —
* par identifiant : retirés, posés entiers, communs comparés ; une telle
* liste dont un identifiant se répète, ce que la lecture admet pour deux
* propositions, se compare comme les autres listes ; les autres listes de
* même longueur rang par rang, et de longueurs différentes posées
* entières ; null contre une valeur, et deux scalaires différents, posent la
* valeur de b ; un retenu que sa règle refuse, dans a ou dans b, se pose
* entier quand les deux diffèrent, sans rien pour lui sinon.
*
* @param {import('./types.js').Charge} a
* @param {import('./types.js').Charge} b
* @returns {Operation[]}
*/
export function difference(a, b) {
const operations = [];
comparer(canoniser(a), canoniser(b), CHARGE, [], operations);
return operations;
}
// Règle de ce que désigne etape dans une valeur de règle regle, lue dans le
// schéma : pour un objet, le champ de cette clé ; pour une liste, son
// élément, la valeur refusant toute étape qui n'y est ni un rang ni { id }.
// undefined quand l'étape sort du schéma — une clé qu'une valeur posée dans
// une proposition ou dans le retenu apporte, par exemple —, et pour toute
// étape qui la suit.
function regleSuivante(regle, etape) {
if (regle?.genre === 'objet') return regle.champs.find(([cle]) => cle === etape)?.[1];
return regle?.genre === 'liste' ? regle.element : undefined;
}
// Vrai quand regle est celle d'une liste d'enregistrements à identifiant,
// dont l'élément a un champ id : participants, tables, propositions.
const estListeDEnregistrements = (regle) => regle?.element?.cles?.has('id') === true;
// Lecture de chemin dans le schéma, depuis la charge : regle, la règle de la
// place qu'il désigne, undefined quand une étape sort du schéma ; et
// sousAPart, vrai quand une règle aPart le précède, c'est-à-dire quand la
// place est dans une proposition ou dans le retenu, que l'analyse n'examine
// que comme conteneurs (§ 8.9). null quand une étape { id } ne tombe pas
// dans une liste d'enregistrements à identifiant. C'est le schéma qui en
// décide, et non le contenu de la liste : une liste vide n'a aucun élément
// qui montre la forme des autres, et tout identifiant y est absent, ce qui
// ouvrirait une insertion par { id } dans une réserve, une liste de table
// ou les réservations. Une clé et un rang se contrôlent sur la valeur, qui
// les porte ou non.
function lireChemin(chemin) {
let regle = CHARGE;
let sousAPart = false;
for (const etape of chemin) {
if (estObjet(etape) && !estListeDEnregistrements(regle)) return null;
sousAPart ||= regle?.aPart === true;
regle = regleSuivante(regle, etape);
}
return { regle, sousAPart };
}
// Vrai quand l'analyse admettrait valeur à la place que lecture désigne
// (§ 8.8) : hors des propositions et du retenu, sa forme entière ; à la
// place de la liste des propositions ou du retenu, son conteneur ; plus bas,
// aucune valeur ne s'examine, et le contrôle des placements juge ce qu'elle
// y fait (§ 8.9). Une place hors du schéma n'en admet aucune.
function valeurAdmise(valeur, { regle, sousAPart }) {
if (sousAPart) return true;
return regle !== undefined && premiereFauteEnPlace(valeur, regle) === null;
}
// Rangs des éléments de liste dont l'id vaut id, ou null quand un élément
// n'est pas un objet à id entier exact, ce qu'une valeur posée dans la liste
// des propositions peut y mettre : { id } n'y désigne rien. liste est une
// liste d'enregistrements du schéma, que valeurAdmise garde une liste.
function rangsDeLIdentifiant(liste, id) {
if (!Number.isSafeInteger(id)) return null;
const rangs = [];
for (let rang = 0; rang < liste.length; rang += 1) {
const element = liste[rang];
if (!estObjet(element) || !Number.isSafeInteger(element.id)) return null;
if (element.id === id) rangs.push(rang);
}
return rangs;
}
// Vrai quand etape est une clé propre de l'objet conteneur. Une clé héritée
// du prototype commun des objets — __proto__, constructor, toString — n'en
// est pas une : aucune opération ne lit ni n'écrit ce prototype, que partage
// tout le processus.
const estCleDe = (conteneur, etape) =>
typeof etape === 'string' && estObjet(conteneur) && Object.hasOwn(conteneur, etape);
// Vrai quand etape est un rang de la liste conteneur.
const estRangDe = (conteneur, etape) =>
Number.isSafeInteger(etape) && etape >= 0 && Array.isArray(conteneur) && etape < conteneur.length;
// Rangs que désigne l'étape { id } dans conteneur, ou null.
const rangsDe = (conteneur, etape) => (estObjet(etape) ? rangsDeLIdentifiant(conteneur, etape.id) : null);
// Valeur que désigne etape dans conteneur, ou undefined : une clé absente,
// un rang hors de la liste, un { id } qui ne désigne pas exactement un
// enregistrement, une étape d'une autre sorte. Aucune valeur d'une charge
// n'est undefined.
function descendre(conteneur, etape) {
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) return conteneur[etape];
const rangs = rangsDe(conteneur, etape);
return rangs?.length === 1 ? conteneur[rangs[0]] : undefined;
}
// Pose une copie de valeur à la place que désigne etape dans conteneur :
// une clé qu'il porte déjà, un rang qu'il contient, ou { id } absent de la
// liste, quand valeur est l'enregistrement de cet id ; l'enregistrement
// s'insère alors avant le premier d'id plus grand, ce qui garde une liste
// rangée par identifiant croissant. Rend faux quand etape ne désigne
// aucune de ces places.
function poser(conteneur, etape, valeur) {
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) {
conteneur[etape] = structuredClone(valeur);
return true;
}
if (rangsDe(conteneur, etape)?.length !== 0 || !estObjet(valeur) || valeur.id !== etape.id) return false;
const suivant = conteneur.findIndex((element) => element.id > etape.id);
conteneur.splice(suivant === -1 ? conteneur.length : suivant, 0, structuredClone(valeur));
return true;
}
// Retire de conteneur l'enregistrement que désigne l'étape { id } ; rend
// faux quand elle n'en désigne pas exactement un. Une clé ou un rang ne se
// retirent pas : les objets de la charge ont les clés du schéma, et une
// liste sans identifiants se pose entière quand sa longueur change.
function retirer(conteneur, etape) {
const rangs = rangsDe(conteneur, etape);
if (rangs?.length !== 1) return false;
conteneur.splice(rangs[0], 1);
return true;
}
// Applique operation à charge, en place ; rend faux, sans rien changer,
// quand elle est mal formée, qu'une étape { id } de son chemin tombe hors
// d'une liste d'enregistrements, que ce chemin ne désigne pas une place de
// charge, ou que la valeur posée n'y serait pas admise par l'analyse. Un
// chemin vide n'a pas de dernière étape, et undefined n'en désigne aucune.
// Une opération poser porte une valeur : JSON n'en écrit pas d'undefined.
function executer(charge, operation) {
if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false;
const { op, chemin, valeur } = operation;
const lecture = lireChemin(chemin);
if (lecture === null) return false;
let conteneur = charge;
for (const etape of chemin.slice(0, -1)) {
conteneur = descendre(conteneur, etape);
if (conteneur === undefined) return false;
}
const derniere = chemin[chemin.length - 1];
if (op === 'poser') {
return valeur !== undefined && valeurAdmise(valeur, lecture) && poser(conteneur, derniere, valeur);
}
if (op === 'retirer') return retirer(conteneur, derniere);
return false;
}
/**
* Applique un correctif à la copie canonique de a (§ 8.6), opération après
* opération, et rend cette copie : une charge neuve, qui ne partage aucun
* objet avec a ni avec le correctif. a n'est pas modifiée. Appliqué à a, le
* correctif de difference(a, b) rend la copie canonique de b. Une insertion
* par { id } garde l'ordre des identifiants.
*
* Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne
* s'applique pas — rang est sa place dans le correctif, à partir de 0 — :
* une opération mal formée ; une étape { id } hors des participants, des
* tables et des propositions, même dans une liste vide ; une étape qui ne
* trouve pas sa clé, que l'objet porte en propre et n'hérite pas, son rang
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte
* pas ou au-delà de la fin d'une liste ; poser par { id } un identifiant
* déjà présent, ou une valeur qui n'est pas l'enregistrement de cet
* identifiant ; poser une valeur que l'analyse refuserait à sa place —
* hors des propositions et du retenu, une valeur qui sort de sa règle, en
* genre, en domaine, null compris, ou en clés ; à la place de la liste des
* propositions ou du retenu, un autre conteneur — ; retirer autre chose
* qu'un enregistrement par { id }. Le contrôle porte sur la forme : une
* opération ne porte pas la valeur qu'elle remplace, et un correctif dont
* chaque chemin existe aussi dans une autre charge s'y applique sans lever.
* Un correctif qui n'est pas une liste lève TypeError.
*
* @param {import('./types.js').Charge} a
* @param {Operation[]} correctif
* @returns {import('./types.js').Charge}
*/
export function appliquer(a, correctif) {
if (!Array.isArray(correctif)) {
throw new TypeError(`appliquer : liste d'opérations attendue, reçu ${JSON.stringify(correctif)}`);
}
const charge = canoniser(a);
correctif.forEach((operation, rang) => {
if (!executer(charge, operation)) throw new ErreurStockage('CORRECTIF', { rang });
});
return charge;
}

File diff suppressed because it is too large Load diff

641
src/stockage/document.js Normal file
View file

@ -0,0 +1,641 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le document d'un événement (§ 4, § 6.1, § 8.1, § 8.8, § 9).
//
// SCHEMA décrit le fichier d'état en une seule table de règles : l'ordre des
// champs de chaque objet fait l'ordre des clés du texte canonique, et
// l'analyse parcourt la table pour contrôler la forme. Chaque règle porte
// aussi ce que canonique.js lit pour écrire : la mise en page et l'ordre des
// listes. Les formes sont décrites dans types.js. Ce parcours de la forme
// est le seul : premiereFaute le rend au contrôle des placements, et
// premiereFauteEnPlace aux correctifs.
//
// analyser lit un texte sans rien écrire et lève, à la première faute,
// ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), en contrôlant dans
// cet ordre : le texte vide (VIDE) ; le JSON (JSON) ; le format, lu avant
// tout le reste parce qu'il dit comment lire le reste (FORME s'il manque,
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, et le tour des réservations (FORME) ; les
// comptes de l'en-tête (COMPTES) ; les références (REFERENCE). Un fichier
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
// connaît, et ce qui serait FORME au format courant y porte la raison
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
// format. Ses comptes et ses références gardent leur raison, une
// corruption quel que soit le format ; tout refus d'un tel fichier porte le
// format lu dans ses détails, qui disent au dépôt d'où il vient. Le dépôt
// complète les détails — base, secours — et pose lui-même la raison ABSENT,
// quand le journal existe sans l'état. Des propositions et du retenu,
// l'analyse ne lit que le conteneur : leur forme, leur cohérence et leurs
// identifiants, comparés entre eux et à prochainsIds.proposition, sont
// l'affaire du contrôle des placements, qui écarte une proposition fautive
// et signale un retenu fautif sans refuser le fichier (§ 8.9, point 3).
//
// Les autres fonctions créent une charge ou dérivent d'elle la configuration
// du moteur, l'état qu'impose son contenu et la capacité d'une table. Aucune
// ne modifie ce qu'elle reçoit.
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ErreurStockage } from './erreurs.js';
/** Version de format que ce code écrit (§ 8.8). */
export const FORMAT = 1;
/**
* Réglages de génération d'une charge neuve : cinq propositions de 200 000
* mouvements, valeurs de départ non mesurées (§ 5.10), et l'historique
* d'acceptation que le moteur prend par défaut.
*/
export const GENERATION_PAR_DEFAUT = Object.freeze({ nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
// Une règle du schéma ; types.js en décrit les propriétés (Regle). Un objet
// porte ses champs, paires [clé, règle] dans l'ordre des clés, et l'ensemble
// de leurs clés ; une liste porte la règle de ses éléments.
function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, ...proprietes });
}
const chaine = (options) => regle('chaine', options);
const entier = (min, max = Infinity, options = {}) => regle('entier', { min, max, ...options });
const parmi = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs) });
const liste = (element, options) => regle('liste', { element, ...options });
const objet = (champs, options) =>
regle('objet', {
champs: Object.freeze(champs.map((champ) => Object.freeze(champ))),
cles: new Set(champs.map(([cle]) => cle)),
...options,
});
const CHAINE = chaine();
const CHAINE_OU_NUL = chaine({ nul: true });
const BOOLEEN = regle('booleen');
const NOMBRE = regle('nombre');
const IDENTIFIANT = entier(1);
const DATE_OU_NUL = regle('date', { nul: true });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
// copies canoniques des éléments : clés dans l'ordre du schéma, listes
// intérieures déjà rangées. Chaque ordre est total : deux éléments qu'il
// tient pour égaux s'écrivent pareil, et le tri rend la même liste quel que
// soit l'ordre reçu. Participants et tables se rangent par identifiant,
// unique sur une charge que l'analyse admet ; réservations et titres se
// comparent sur tous leurs champs ; une liste d'identifiants, par valeur.
const croissant = (a, b) => a - b;
const parIdentifiant = (a, b) => a.id - b.id;
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// null avant tout entier, puis les entiers croissants.
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
// La portée « tous » avant « tour », que l'ordre des chaînes inverserait.
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
nulEnTete(a.siege, b.siege);
// Deux titres d'une même place se départagent par leur libellé.
const ordreDesTitres = (a, b) => a.table - b.table || a.siege - b.siege || comparerTextes(a.libelle, b.libelle);
// L'analyse n'examine des propositions que leur liste : deux d'entre elles
// peuvent partager un identifiant. À identifiant égal, la graine, le compte
// d'arrêt et l'historique les départagent, puis le JSON de leur copie
// canonique, qui ne coïncide que pour deux propositions écrites pareil.
const ordreDesPropositions = (a, b) =>
a.id - b.id ||
a.graine - b.graine ||
a.arret - b.arret ||
a.historique - b.historique ||
comparerTextes(JSON.stringify(a), JSON.stringify(b));
const COMPTES = objet([
['participants', entier(0)],
['tables', entier(0)],
['reservations', entier(0)],
['titres', entier(0)],
['propositions', entier(0)],
['retenu', entier(0, 1)],
]);
const ENTETE = objet([
['format', entier(1)],
['produitVersion', CHAINE],
['revision', entier(1)],
['comptes', COMPTES],
]);
const FILIATION = objet(
[
['source', objet([['id', CHAINE], ['nom', CHAINE]])],
['instant', objet([['revision', entier(1)], ['libelle', CHAINE]])],
],
{ nul: true },
);
const EVENEMENT = objet([
['id', CHAINE],
['nom', CHAINE],
['date', DATE_OU_NUL],
['siegesParDefaut', entier(2)],
['tours', entier(1)],
['unite', parmi('cm')],
['etat', parmi('brouillon', 'propose', 'retenu', 'bloque')],
['filiation', FILIATION],
]);
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
['nouvelleTable', BOOLEEN],
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
]);
const PARTICIPANT = objet([
['id', IDENTIFIANT],
['nom', CHAINE],
['prenom', CHAINE_OU_NUL],
['appartenance', CHAINE_OU_NUL],
['courriel', CHAINE_OU_NUL],
['titrePressenti', CHAINE_OU_NUL],
['notes', CHAINE_OU_NUL],
['exclu', BOOLEEN],
]);
const TABLE = objet([
['id', IDENTIFIANT],
['numero', entier(1)],
['sieges', entier(2, undefined, { nul: true })],
['forme', parmi('ronde', 'carree')],
['position', objet([['x', NOMBRE], ['y', NOMBRE]])],
]);
const RESERVATION = objet([
['participant', IDENTIFIANT],
['table', IDENTIFIANT],
['siege', entier(1, undefined, { nul: true })],
['portee', parmi('tous', 'tour')],
['tour', entier(1, undefined, { nul: true })],
]);
const TITRE = objet([
['table', IDENTIFIANT],
['siege', entier(1)],
['libelle', CHAINE],
]);
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
// chacun sur sa ligne. sieges porte une liste d'identifiants par table
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
// trie par identifiant croissant, quel que soit le drapeau.
const PLACEMENT = liste(
objet([
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))],
['reserve', liste(IDENTIFIANT, { tri: croissant })],
]),
{ mise: 'lignes' },
);
// Champs qu'une proposition et le retenu partagent, après ce qui les
// identifie : tables et capacités se correspondent rang à rang, et leur
// ordre se garde ; participants est l'ensemble des identifiants que le plan
// place, rangé par identifiant croissant.
const PLAN = [
['tables', liste(IDENTIFIANT)],
['capacites', liste(entier(2))],
['tours', entier(1)],
['participants', liste(IDENTIFIANT, { tri: croissant })],
['placement', PLACEMENT],
];
const PROPOSITION = objet(
[
['id', IDENTIFIANT],
['graine', entier(0, 2 ** 32 - 1)],
['arret', entier(1)],
['historique', entier(1)],
['produitVersion', CHAINE],
['siegesAttribues', BOOLEEN],
...PLAN,
],
{ mise: 'ouverte' },
);
const RETENU = objet([['proposition', IDENTIFIANT], ['siegesAttribues', BOOLEEN], ...PLAN], {
nul: true,
mise: 'ouverte',
aPart: true,
});
const CHARGE = objet(
[
['evenement', EVENEMENT],
['reglages', REGLAGES],
['prochainsIds', objet([['participant', IDENTIFIANT], ['table', IDENTIFIANT], ['proposition', IDENTIFIANT]])],
['participants', liste(PARTICIPANT, { mise: 'lignes', tri: parIdentifiant })],
['tables', liste(TABLE, { mise: 'lignes', tri: parIdentifiant })],
['reservations', liste(RESERVATION, { mise: 'lignes', tri: ordreDesReservations })],
['titres', liste(TITRE, { mise: 'lignes', tri: ordreDesTitres })],
['propositions', liste(PROPOSITION, { mise: 'lignes', tri: ordreDesPropositions, aPart: true })],
['retenu', RETENU],
],
{ mise: 'lignes' },
);
/**
* Le fichier d'état (§ 8.8) : l'en-tête, puis la charge. Une règle par
* valeur, dont types.js décrit la forme (Regle) ; l'ordre des champs de
* chaque objet est l'ordre des clés du texte canonique.
* @type {import('./types.js').Regle}
*/
export const SCHEMA = objet([['entete', ENTETE], ['charge', CHARGE]], { mise: 'lignes' });
/**
* Comptes de l'en-tête (§ 8.8) : pour chaque clé des comptes, la longueur de
* la liste de la charge du même nom, et 0 ou 1 pour le retenu. Les clés
* suivent l'ordre du schéma. canonique.js les écrit ; analyser les compare.
*
* @param {import('./types.js').Charge} charge
* @returns {import('./types.js').Comptes}
*/
export function comptesDe(charge) {
const comptes = {};
for (const [cle] of COMPTES.champs) {
const valeur = charge[cle];
comptes[cle] = Array.isArray(valeur) ? valeur.length : valeur === null ? 0 : 1;
}
return comptes;
}
/**
* Charge neuve (§ 4, § 9) : brouillon, ni participant ni table, aucune
* proposition, identifiants attribués à partir de 1. Les réglages activent
* les quatre contraintes, laissent les sièges non attribués et copient
* GENERATION_PAR_DEFAUT. Chaque appel rend des objets neufs. Les arguments
* ne sont pas contrôlés ici : ce contrôle appartient à la commande qui crée
* l'événement.
*
* @param {{id: string, nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
* @returns {import('./types.js').Charge}
*/
export function creerCharge({ id, nom, date = null, siegesParDefaut, tours }) {
return {
evenement: { id, nom, date, siegesParDefaut, tours, unite: 'cm', etat: 'brouillon', filiation: null },
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { ...GENERATION_PAR_DEFAUT },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
};
}
const MARQUE_ORDRE_OCTETS = '\uFEFF';
// Lève le refus d'un état illisible ; plus ajoute ses détails après la
// raison et le chemin.
function illisible(raison, chemin, plus = {}) {
throw new ErreurStockage('ETAT_ILLISIBLE', { raison, chemin, ...plus });
}
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
// Chemin de la clé cle sous chemin : après un point quand elle s'écrit comme
// un identifiant, entre crochets en JSON sinon ; la racine est ''.
const IDENTIFIANT_JS = /^[A-Za-z_$][\w$]*$/;
function joindre(chemin, cle) {
if (!IDENTIFIANT_JS.test(cle)) return `${chemin}[${JSON.stringify(cle)}]`;
return chemin === '' ? cle : `${chemin}.${cle}`;
}
// AAAA-MM-JJ d'un jour du calendrier grégorien : mois de 1 à 12, jour de 1
// au dernier du mois, février à 29 jours les années bissextiles.
const AAAA_MM_JJ = /^(\d{4})-(\d{2})-(\d{2})$/;
const JOURS_PAR_MOIS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function estJour(texte) {
const morceaux = AAAA_MM_JJ.exec(texte);
if (morceaux === null) return false;
const [annee, mois, jour] = morceaux.slice(1).map(Number);
const bissextile = annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0);
const dernier = mois === 2 && bissextile ? 29 : JOURS_PAR_MOIS[mois - 1];
return mois >= 1 && mois <= 12 && jour >= 1 && jour <= dernier;
}
// Vrai quand valeur, non nulle, a le genre de sa règle et tient dans son
// domaine. Un entier du fichier est un entier exact, au plus 2^53 − 1 :
// au-delà, deux entiers distincts se lisent comme un seul, et la relecture
// rend un autre texte.
function conforme(valeur, { genre, min, max, valeurs }) {
switch (genre) {
case 'objet':
return estObjet(valeur);
case 'liste':
return Array.isArray(valeur);
case 'chaine':
return typeof valeur === 'string' && valeur !== '';
case 'entier':
return Number.isSafeInteger(valeur) && valeur >= min && valeur <= max;
case 'nombre':
return Number.isFinite(valeur);
case 'booleen':
return typeof valeur === 'boolean';
case 'parmi':
return valeurs.includes(valeur);
case 'date':
return typeof valeur === 'string' && estJour(valeur);
}
}
/**
* Clés propres de l'objet valeur, rangées par unités UTF-16 croissantes :
* leur ordre ne dépend que de leur ensemble, non de l'ordre dans lequel
* l'objet les a reçues. C'est le seul parcours des clés d'un objet du
* stockage : l'analyse y cherche une clé inconnue, et canonique.js recopie
* ainsi ce que le schéma ne décrit pas.
*
* @param {Object} valeur
* @returns {string[]}
*/
export function clesRangees(valeur) {
return Object.keys(valeur).sort(comparerTextes);
}
// Plus petite des clés de valeur absentes de cles, en comparant les unités
// UTF-16, ou undefined.
const cleInconnue = (valeur, cles) => clesRangees(valeur).find((cle) => !cles.has(cle));
// Format du fichier lu, contrôlé avant toute autre règle : il dit comment
// lire le reste. Une racine qui n'est pas un objet, un en-tête ou un format
// absents sont des fautes de forme ; un format qui n'est pas un entier ≥ 1
// est inconnu.
function lireFormat(lu) {
if (!estObjet(lu)) illisible('FORME', '');
if (!estObjet(lu.entete)) illisible('FORME', 'entete');
if (lu.entete.format === undefined) illisible('FORME', 'entete.format');
const { format } = lu.entete;
if (!Number.isSafeInteger(format) || format < 1) illisible('FORMAT_INCONNU', 'entete.format');
return format;
}
// Valeur réduite aux clés que sa règle connaît, en profondeur, chaque objet
// refait dans l'ordre du schéma, propositions et retenu compris : la lecture
// d'un format plus récent ignore les clés qu'elle ne connaît pas, et un tel
// fichier ne se réécrit jamais. Une valeur qui n'a pas la sorte de sa règle
// passe telle quelle : le contrôle de forme la nomme.
function reduire(valeur, regleDeValeur) {
if (regleDeValeur.genre === 'objet' && estObjet(valeur)) {
const reduite = {};
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
if (valeur[cle] !== undefined) reduite[cle] = reduire(valeur[cle], regleDuChamp);
}
return reduite;
}
if (regleDeValeur.genre === 'liste' && Array.isArray(valeur)) {
return valeur.map((element) => reduire(element, regleDeValeur.element));
}
return valeur;
}
// Chemin de la première valeur qui sort de sa règle, ou null. Une règle
// aPart rencontrée sous une autre n'examine que son conteneur ; racine
// vrai fait examiner tout le contenu de la règle donnée, même aPart, et
// faux la lit à sa place sous une autre, comme l'analyse.
function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
if (valeur === null && regleDeValeur.nul) return null;
if (!conforme(valeur, regleDeValeur)) return chemin;
if (regleDeValeur.aPart && !racine) return null;
if (regleDeValeur.genre === 'objet') {
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
const faute = fauteDeForme(valeur[cle], regleDuChamp, joindre(chemin, cle), false);
if (faute !== null) return faute;
}
const inconnue = cleInconnue(valeur, regleDeValeur.cles);
return inconnue === undefined ? null : joindre(chemin, inconnue);
}
if (regleDeValeur.genre === 'liste') {
for (let rang = 0; rang < valeur.length; rang += 1) {
const faute = fauteDeForme(valeur[rang], regleDeValeur.element, `${chemin}[${rang}]`, false);
if (faute !== null) return faute;
}
}
return null;
}
/**
* Chemin de la première valeur qui sort de sa règle, en profondeur, ou null
* quand valeur est conforme ; ne lève jamais. D'un objet, chaque champ dans
* l'ordre du schéma, puis la plus petite de ses clés inconnues ; d'une
* liste, chaque élément dans l'ordre des rangs. null est conforme à une
* règle qui l'admet. Un champ absent se lit undefined, qu'aucune règle
* n'admet, et se nomme par son chemin comme une valeur fautive. Le chemin
* suit la convention d'analyser, à partir de chemin, '' par défaut.
*
* Une règle aPart rencontrée sous une autre n'examine que son conteneur :
* son contenu s'examine à part, la règle donnée ici pour racine. analyser
* contrôle ainsi le fichier sans entrer dans les propositions ni dans le
* retenu ; le contrôle des placements les examine chacun sous leur règle.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFaute(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, true);
}
/**
* premiereFaute de valeur lue à la place d'une règle regle sous une autre,
* comme l'analyse la lit : une règle aPart n'y examine que son conteneur,
* même donnée ici. Les correctifs contrôlent ainsi une valeur posée là où
* l'analyse la lirait.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFauteEnPlace(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, false);
}
// Identifiants des participants, puis des tables : chacun unique, et sous
// prochainsIds, qui ne recule jamais (§ 4) ; d'un doublon, le second est
// fautif. Ceux des propositions et la proposition d'origine du retenu ne se
// comparent pas ici : une proposition qui contredit prochainsIds.proposition
// tombe seule, et le retenu se signale sans fermer la saisie (§ 8.9, point
// 3). faute(chemin) lève l'écart.
function examinerIdentifiants({ prochainsIds, participants, tables }, faute) {
for (const [cle, enregistrements, prochain] of [
['participants', participants, prochainsIds.participant],
['tables', tables, prochainsIds.table],
]) {
const vus = new Set();
enregistrements.forEach(({ id }, rang) => {
if (id >= prochain || vus.has(id)) faute(`charge.${cle}[${rang}].id`);
vus.add(id);
});
}
}
// Tour de chaque réservation : null pour la portée « tous », de 1 à R pour
// la portée « tour » ; faute(chemin) lève l'écart.
function examinerTours({ evenement, reservations }, faute) {
reservations.forEach(({ portee, tour }, rang) => {
const admis = portee === 'tous' ? tour === null : tour !== null && tour <= evenement.tours;
if (!admis) faute(`charge.reservations[${rang}].tour`);
});
}
// Chaque compte de l'en-tête contre la charge, dans l'ordre du schéma ;
// plus s'ajoute aux détails du refus.
function examinerComptes(comptes, charge, plus) {
const reels = comptesDe(charge);
for (const [cle] of COMPTES.champs) {
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
}
}
// Références des réservations, puis des titres : la personne et la table
// désignées existent, et le siège, quand il est donné, tient dans la
// capacité courante de la table (§ 6.2). La réservation d'une personne
// exclue reste admise : elle est suspendue, non fautive (§ 4.4). plus
// s'ajoute aux détails du refus.
function examinerReferences(charge, plus) {
const personnes = new Set(charge.participants.map(({ id }) => id));
const tables = new Map(charge.tables.map((table) => [table.id, table]));
const examinerPlace = ({ table, siege }, chemin) => {
const designee = tables.get(table);
if (designee === undefined) illisible('REFERENCE', `${chemin}.table`, plus);
if (siege !== null && siege > capacite(charge, designee)) illisible('REFERENCE', `${chemin}.siege`, plus);
};
charge.reservations.forEach((reservation, rang) => {
const chemin = `charge.reservations[${rang}]`;
if (!personnes.has(reservation.participant)) illisible('REFERENCE', `${chemin}.participant`, plus);
examinerPlace(reservation, chemin);
});
charge.titres.forEach((titre, rang) => examinerPlace(titre, `charge.titres[${rang}]`));
}
/**
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge et
* formatPlusRecent, vrai quand le format dépasse FORMAT. Au format courant,
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
* charge rendus sont des copies réduites aux clés que le schéma connaît, et
* une faute de forme y porte la raison FORMAT_PLUS_RECENT ; les comptes et
* les références gardent leur raison, et chaque refus porte le format lu
* dans ses détails. Des propositions et du retenu, seul le conteneur se
* contrôle ici : examiner (placements.js) juge le reste, identifiants
* compris, sans refuser le fichier (§ 8.9, point 3).
*
* Lève ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), et format
* pour un fichier plus récent, à la première faute, dans l'ordre que donne
* l'en-tête du module. chemin désigne l'élément fautif : clés séparées par
* un point, rangs entre crochets, une clé qui ne s'écrit pas comme un
* identifiant entre crochets en JSON ; '' pour la racine ; null quand le
* texte n'a rien à désigner (VIDE, JSON). D'un objet, un champ du schéma
* fautif est nommé avant une clé inconnue, et de plusieurs clés inconnues,
* la plus petite.
*
* @param {string} texte
* @returns {import('./types.js').Lecture}
*/
export function analyser(texte) {
const sansMarque = texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte;
if (sansMarque.trim() === '') illisible('VIDE', null);
let lu;
try {
lu = JSON.parse(sansMarque);
} catch {
illisible('JSON', null);
}
const format = lireFormat(lu);
const formatPlusRecent = format > FORMAT;
// Tout refus d'un fichier plus récent porte le format lu.
const plus = formatPlusRecent ? { format } : {};
const fichier = formatPlusRecent ? reduire(lu, SCHEMA) : lu;
const faute = (chemin) => illisible(formatPlusRecent ? 'FORMAT_PLUS_RECENT' : 'FORME', chemin, plus);
const cheminFautif = premiereFaute(fichier, SCHEMA);
if (cheminFautif !== null) faute(cheminFautif);
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
return { entete, charge, formatPlusRecent };
}
/**
* Configuration du moteur tirée d'une charge : les participants avec leur
* appartenance et leur exclusion ; les tables avec leur capacité courante ;
* le nombre de tours ; les réservations sans leur siège, que le moteur ne
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
* réglages. Les listes gardent l'ordre de la charge : le rang d'une
* réservation que nomme une ErreurConfiguration est son rang dans la charge.
*
* @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration}
*/
export function configurationDepuisCharge(charge) {
const { evenement, reglages } = charge;
return {
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
tables: charge.tables.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
tours: evenement.tours,
reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },
),
contraintes: {
separerAppartenances: reglages.separerAppartenances,
nouveauxVoisins: reglages.nouveauxVoisins,
nouvelleTable: reglages.nouvelleTable,
varierAppartenances: reglages.varierAppartenances,
},
};
}
/**
* État que le contenu impose à une copie (§ 8.7, § 9) : retenu quand un
* placement est retenu, proposé quand des propositions existent, brouillon
* sinon. L'état inscrit dans l'événement n'est pas lu : la copie d'un plan
* bloqué n'est pas bloquée.
*
* @param {import('./types.js').Charge} charge
* @returns {'brouillon'|'propose'|'retenu'}
*/
export function etatDeduit({ retenu, propositions }) {
if (retenu !== null) return 'retenu';
return propositions.length > 0 ? 'propose' : 'brouillon';
}
/**
* Capacité courante d'une table (§ 6.1) : ses sièges quand elle est
* surchargée, le défaut de l'événement quand elle le suit (sieges null).
* table est l'enregistrement de charge.tables : un identifiant lève
* TypeError, au lieu de rendre le défaut sans bruit.
*
* @param {import('./types.js').Charge} charge
* @param {import('./types.js').Table} table
* @returns {number}
*/
export function capacite(charge, table) {
if (!estObjet(table)) {
throw new TypeError(`capacite : enregistrement de table attendu, reçu ${JSON.stringify(table)}`);
}
return table.sieges ?? charge.evenement.siegesParDefaut;
}

View file

@ -0,0 +1,953 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du document d'événement (§ 4, § 6.1, § 8.1, § 8.8, § 9) : la
// charge neuve ; l'analyse d'un texte, ce qu'elle lit et chacun de ses
// refus, nommé par sa raison et son chemin ; la lecture d'un format plus
// récent ; la configuration que reçoit le moteur ; l'état qu'impose le
// contenu ; la capacité d'une table ; l'erreur de stockage ; le parcours
// de forme que partagent l'analyse, le contrôle des placements et les
// correctifs. Les textes d'épreuve sont du JSON compact : l'analyse ne
// dépend pas de la mise en page. Les noms sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { STATUT, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import {
FORMAT,
GENERATION_PAR_DEFAUT,
SCHEMA,
analyser,
capacite,
configurationDepuisCharge,
creerCharge,
etatDeduit,
premiereFaute,
premiereFauteEnPlace,
} from './document.js';
import { ErreurStockage } from './erreurs.js';
// Un document valide : quatre participants, dont un exclu, l'identifiant 4
// retiré et jamais réattribué ; deux tables, dont une surchargée ; une
// réservation de portée « tous » à siège donné et une de tour désigné sans
// siège ; un titre ; une proposition ; une filiation. Une copie neuve à
// chaque appel, que chaque épreuve abîme à sa façon.
function documentValide() {
return {
entete: {
format: 1,
produitVersion: VERSION.affichee,
revision: 12,
comptes: { participants: 4, tables: 2, reservations: 2, titres: 1, propositions: 1, retenu: 0 },
},
charge: {
evenement: {
id: 'evt-lucioles',
nom: 'Veillée des Lucioles',
date: '2026-11-12',
siegesParDefaut: 3,
tours: 2,
unite: 'cm',
etat: 'propose',
filiation: {
source: { id: 'evt-modele', nom: 'Veillée modèle' },
instant: { revision: 7, libelle: 'Tables posées' },
},
},
reglages: {
separerAppartenances: true,
nouveauxVoisins: false,
nouvelleTable: true,
varierAppartenances: false,
attribuerSieges: false,
generation: { nombre: 3, arret: 50_000, historique: 500 },
},
prochainsIds: { participant: 6, table: 4, proposition: 2 },
participants: [
{ id: 1, nom: 'Brindille', prenom: 'Anouk', appartenance: 'Chorale du Vallon',
courriel: 'anouk@exemple.test', titrePressenti: 'animation', notes: null, exclu: false },
{ id: 2, nom: 'Sarrasin', prenom: null, appartenance: 'Chorale du Vallon', courriel: null,
titrePressenti: null, notes: 'part à 21 h', exclu: false },
{ id: 3, nom: 'Coquelicot', prenom: 'Basile', appartenance: null, courriel: null,
titrePressenti: null, notes: null, exclu: true },
{ id: 5, nom: 'Mirabelle', prenom: 'Ysé', appartenance: 'Cercle Gamma', courriel: null,
titrePressenti: null, notes: null, exclu: false },
],
tables: [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 3, numero: 2, sieges: 4, forme: 'carree', position: { x: 120.5, y: -40 } },
],
reservations: [
{ participant: 1, table: 3, siege: 4, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: null, portee: 'tour', tour: 2 },
],
titres: [{ table: 3, siege: 4, libelle: 'animation' }],
propositions: [
{
id: 1,
graine: 7,
arret: 50_000,
historique: 500,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: [1, 3],
capacites: [3, 4],
tours: 2,
participants: [1, 2, 5],
placement: [
{ sieges: [[2, 5], [1]], reserve: [] },
{ sieges: [[2], [1, 5]], reserve: [] },
],
},
],
retenu: null,
},
};
}
// Un retenu bien formé pour le document valide.
const retenuValide = () => ({
proposition: 1,
siegesAttribues: false,
tables: [1, 3],
capacites: [3, 4],
tours: 2,
participants: [1, 2, 5],
placement: [
{ sieges: [[2, 5], [1]], reserve: [] },
{ sieges: [[2], [1, 5]], reserve: [] },
],
});
// Texte du document valide, une fois modifier passé sur lui.
function abime(modifier) {
const copie = documentValide();
modifier(copie);
return JSON.stringify(copie);
}
// texte où avant, qui doit y figurer exactement une fois, devient apres :
// pour une valeur que JSON.stringify n'écrit pas. Un remplacement qui ne
// trouve rien échoue au lieu de rendre le texte inchangé.
function remplacerUneFois(texte, avant, apres) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
return texte.replace(avant, () => apres);
}
// Écart entre ce que fait analyser(texte) et le refus ETAT_ILLISIBLE dont
// les détails sont la raison et le chemin attendus, puis ceux de plus ; null
// quand ils concordent.
function ecartDeRefus(texte, raison, chemin, plus = {}) {
const attendus = { raison, chemin, ...plus };
try {
analyser(texte);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) {
return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
}
if (erreur.code !== 'ETAT_ILLISIBLE') return `code ${erreur.code} au lieu de ETAT_ILLISIBLE`;
try {
assert.deepEqual(erreur.details, attendus);
} catch {
return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(attendus)}`;
}
return null;
}
return 'aucun refus';
}
// Chaque cas [libellé, texte, raison, chemin, détails en plus] dont le refus
// s'écarte de l'attendu, en une ligne lisible.
function ecartsDeRefus(cas) {
assert.ok(cas.length > 0, 'aucun cas examiné');
return cas.flatMap(([libelle, texte, raison, chemin, plus]) => {
const ecart = ecartDeRefus(texte, raison, chemin, plus);
return ecart === null ? [] : [`${libelle} : ${ecart}`];
});
}
// Étapes — clés d'objet et rangs de liste — menant à chaque valeur que
// l'analyse examine dans valeur, parents avant enfants, hors de l'intérieur
// des propositions et du retenu, que l'analyse laisse au contrôle des
// placements.
function etapesDe(valeur, prefixe = []) {
const aPart =
prefixe.length === 2 && prefixe[0] === 'charge' && (prefixe[1] === 'propositions' || prefixe[1] === 'retenu');
if (aPart || typeof valeur !== 'object' || valeur === null) return [];
const suivantes = Array.isArray(valeur) ? valeur.map((_, rang) => rang) : Object.keys(valeur);
return suivantes.flatMap((etape) => [[...prefixe, etape], ...etapesDe(valeur[etape], [...prefixe, etape])]);
}
// Chemin d'ETAT_ILLISIBLE de ces étapes : clés séparées par un point, rangs
// entre crochets.
const cheminDe = (etapes) =>
etapes.map((etape, i) => (typeof etape === 'number' ? `[${etape}]` : i === 0 ? etape : `.${etape}`)).join('');
// Valeur au bout de ces étapes dans racine.
const valeurEn = (racine, etapes) => etapes.reduce((valeur, etape) => valeur[etape], racine);
// Chemin de ces étapes, rangs effacés : charge.participants[].nom.
const sansRangs = (etapes) => cheminDe(etapes).replace(/\[\d+\]/g, '[]');
// Chemins, rangs effacés, des valeurs que le contrat de données admet
// nulles. La liste s'écrit d'après le contrat, sans lire le schéma : un
// champ que le schéma rendrait nullable à tort reste ainsi éprouvé.
const ADMETTENT_NULL = new Set([
'charge.evenement.date',
'charge.evenement.filiation',
'charge.participants[].prenom',
'charge.participants[].appartenance',
'charge.participants[].courriel',
'charge.participants[].titrePressenti',
'charge.participants[].notes',
'charge.tables[].sieges',
'charge.reservations[].siege',
'charge.reservations[].tour',
'charge.retenu',
]);
// Gèle valeur et tout ce qu'elle contient : une écriture y lève TypeError,
// le module s'exécutant en mode strict.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
describe('ErreurStockage (erreurs.js)', () => {
test('porte son code et ses détails ; le message les écrit, code puis détails en JSON', () => {
const erreur = new ErreurStockage('ABSENT', { chemin: 'veillee.gtt.json' });
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurStockage');
assert.equal(erreur.code, 'ABSENT');
assert.deepEqual(erreur.details, { chemin: 'veillee.gtt.json' });
assert.equal(erreur.message, 'ABSENT {"chemin":"veillee.gtt.json"}');
const sansDetails = new ErreurStockage('CORRECTIF');
assert.deepEqual(sansDetails.details, {});
assert.equal(sansDetails.message, 'CORRECTIF {}');
});
});
describe('creerCharge (§ 4, § 6.1, § 9)', () => {
test('une charge neuve est un brouillon sans participant ni table, au format 1, aux réglages par défaut', () => {
assert.equal(FORMAT, 1);
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', date: '2026-11-12', siegesParDefaut: 8, tours: 3 });
assert.deepEqual(charge, {
evenement: {
id: 'evt-neuf',
nom: 'Atelier du jeudi',
date: '2026-11-12',
siegesParDefaut: 8,
tours: 3,
unite: 'cm',
etat: 'brouillon',
filiation: null,
},
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
});
});
test("GENERATION_PAR_DEFAUT : cinq propositions de 200 000 mouvements, l'historique par défaut du moteur ; figée, copiée dans chaque charge neuve", () => {
assert.deepEqual(GENERATION_PAR_DEFAUT, { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
assert.ok(Object.isFrozen(GENERATION_PAR_DEFAUT));
const { generation } = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 }).reglages;
assert.deepEqual(generation, GENERATION_PAR_DEFAUT);
assert.notEqual(generation, GENERATION_PAR_DEFAUT);
assert.ok(!Object.isFrozen(generation));
});
test('sans date, la date est nulle ; deux charges neuves ne partagent aucun objet', () => {
const premiere = creerCharge({ id: 'evt-a', nom: 'Veillée A', siegesParDefaut: 6, tours: 2 });
const seconde = creerCharge({ id: 'evt-b', nom: 'Veillée B', siegesParDefaut: 6, tours: 2 });
assert.equal(premiere.evenement.date, null);
premiere.reglages.generation.nombre = 9;
premiere.prochainsIds.participant = 2;
premiere.participants.push({ id: 1 });
assert.equal(seconde.reglages.generation.nombre, 5);
assert.equal(seconde.prochainsIds.participant, 1);
assert.deepEqual(seconde.participants, []);
});
test("une charge neuve s'écrit, se relit à l'identique, et son état déduit est brouillon", () => {
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', siegesParDefaut: 8, tours: 3 });
const lecture = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
assert.deepEqual(lecture.charge, charge);
assert.equal(lecture.formatPlusRecent, false);
assert.equal(etatDeduit(lecture.charge), 'brouillon');
});
});
describe('analyser : ce qui se lit (§ 8.8)', () => {
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
const { entete, charge } = documentValide();
assert.deepEqual(analyser(JSON.stringify(documentValide())), { entete, charge, formatPlusRecent: false });
});
test('variantes admises : chacune se lit sans refus', () => {
const variantes = [
['date nulle', abime((d) => { d.charge.evenement.date = null; })],
["29 février d'une année bissextile", abime((d) => { d.charge.evenement.date = '2024-02-29'; })],
['29 février 2000, séculaire divisible par 400', abime((d) => { d.charge.evenement.date = '2000-02-29'; })],
['31 décembre', abime((d) => { d.charge.evenement.date = '2026-12-31'; })],
['filiation nulle', abime((d) => { d.charge.evenement.filiation = null; })],
['table de deux sièges', abime((d) => {
d.charge.tables[1].sieges = 2;
d.charge.reservations[0].siege = 2;
d.charge.titres[0].siege = 1;
})],
["réservation d'une personne exclue", abime((d) => {
d.charge.reservations.push({ participant: 3, table: 1, siege: null, portee: 'tous', tour: null });
d.entete.comptes.reservations = 3;
})],
['réservation au dernier tour', abime((d) => { d.charge.reservations[1].tour = 2; })],
["texte précédé d'une marque d'ordre d'octets", `\uFEFF${JSON.stringify(documentValide())}`],
['texte mis en page autrement', JSON.stringify(documentValide(), null, 4)],
["propositions fautives, que l'analyse ne contrôle pas", abime((d) => {
const [premiere] = d.charge.propositions;
d.charge.propositions.push(42, { id: 1, inconnue: true }, { ...premiere, placement: [{ sieges: [[9]] }] });
d.entete.comptes.propositions = 4;
})],
['retenu quelconque', abime((d) => {
d.charge.retenu = { inconnue: [] };
d.entete.comptes.retenu = 1;
})],
// Un identifiant de proposition qui n'en est pas un ne s'attribue pas :
// il ne se compare pas à prochainsIds.proposition, et le contrôle des
// placements écarte la proposition ou signale le retenu.
["identifiants de proposition qui n'en sont pas, au-delà de prochainsIds.proposition", abime((d) => {
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 53 }, [9]);
d.entete.comptes.propositions = 6;
d.charge.retenu = { ...retenuValide(), proposition: '9' };
d.entete.comptes.retenu = 1;
})],
['deux propositions de même identifiant, sous prochainsIds.proposition', abime((d) => {
d.charge.propositions.push({ ...d.charge.propositions[0], graine: 8 });
d.entete.comptes.propositions = 2;
})],
['retenu bien formé', abime((d) => {
d.charge.retenu = retenuValide();
d.entete.comptes.retenu = 1;
})],
['toutes les listes vides', abime((d) => {
for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) {
d.charge[cle] = [];
d.entete.comptes[cle] = 0;
}
})],
];
const refusees = variantes.flatMap(([libelle, texte]) => {
try {
analyser(texte);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.message}`];
}
});
assert.deepEqual(refusees, []);
});
});
describe('analyser : les refus que le plan nomme (§ 8.8)', () => {
test('vide, JSON, clé inconnue, identifiant égal à prochainsIds, comptes, référence, format 0 : chacun avec sa raison et son chemin', () => {
assert.deepEqual(ecartsDeRefus([
['texte vide', '', 'VIDE', null],
['« { »', '{', 'JSON', null],
['clé inconnue dans un participant',
abime((d) => { d.charge.participants[1].surnom = 'Sasa'; }), 'FORME', 'charge.participants[1].surnom'],
['identifiant de participant égal à prochainsIds.participant',
abime((d) => { d.charge.participants[3].id = 6; }), 'FORME', 'charge.participants[3].id'],
['comptes faux',
abime((d) => { d.entete.comptes.participants = 5; }), 'COMPTES', 'entete.comptes.participants'],
['réservation vers la personne 9, absente',
abime((d) => { d.charge.reservations[0].participant = 9; }), 'REFERENCE', 'charge.reservations[0].participant'],
['format 0', abime((d) => { d.entete.format = 0; }), 'FORMAT_INCONNU', 'entete.format'],
]), []);
});
});
describe('analyser : texte vide, JSON, racine et format', () => {
test('chaque refus porte sa raison et son chemin ; null quand aucun élément ne se désigne', () => {
const valide = JSON.stringify(documentValide());
assert.deepEqual(ecartsDeRefus([
['blancs seuls', ' \n\t\r\n ', 'VIDE', null],
["marque d'ordre d'octets suivie de blancs", '\uFEFF \n', 'VIDE', null],
['texte tronqué', valide.slice(0, -20), 'JSON', null],
['deux documents à la suite', `${valide}${valide}`, 'JSON', null],
['racine qui est une liste', '[]', 'FORME', ''],
['racine nulle', 'null', 'FORME', ''],
['racine qui est un nombre', '3', 'FORME', ''],
['en-tête absent', abime((d) => { delete d.entete; }), 'FORME', 'entete'],
['en-tête qui est un nombre', abime((d) => { d.entete = 1; }), 'FORME', 'entete'],
['format absent', abime((d) => { delete d.entete.format; }), 'FORME', 'entete.format'],
['format négatif', abime((d) => { d.entete.format = -1; }), 'FORMAT_INCONNU', 'entete.format'],
['format non entier', abime((d) => { d.entete.format = 1.5; }), 'FORMAT_INCONNU', 'entete.format'],
['format en texte', abime((d) => { d.entete.format = '1'; }), 'FORMAT_INCONNU', 'entete.format'],
['format nul', abime((d) => { d.entete.format = null; }), 'FORMAT_INCONNU', 'entete.format'],
]), []);
});
});
describe('analyser : forme (§ 8.8)', () => {
test('chaque clé du contrat, retirée, est nommée par son chemin', () => {
const etapes = etapesDe(documentValide()).filter((e) => typeof e.at(-1) === 'string');
assert.ok(etapes.length >= 100, `${etapes.length} clés seulement`);
assert.deepEqual(ecartsDeRefus(etapes.map((e) => [
`sans ${cheminDe(e)}`,
abime((d) => { delete valeurEn(d, e.slice(0, -1))[e.at(-1)]; }),
'FORME',
cheminDe(e),
])), []);
});
test('une clé inconnue, dans chaque objet que le contrat décrit, racine comprise, est nommée par son chemin', () => {
const objets = [[], ...etapesDe(documentValide())].filter((e) => {
const valeur = valeurEn(documentValide(), e);
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
});
assert.ok(objets.length >= 20, `${objets.length} objets seulement`);
assert.deepEqual(ecartsDeRefus(objets.map((e) => [
`inconnue sous ${cheminDe(e) || 'la racine'}`,
abime((d) => { valeurEn(d, e).inconnue = 1; }),
'FORME',
cheminDe([...e, 'inconnue']),
])), []);
});
test("chaque valeur remplacée par une valeur d'une autre sorte est nommée par son chemin", () => {
// Une liste devient un objet, toute autre valeur une liste : aucune règle
// du contrat n'admet l'un pour l'autre. Le format, lu avant tout le reste,
// a sa propre raison.
const etapes = etapesDe(documentValide());
assert.deepEqual(ecartsDeRefus(etapes.map((e) => {
const chemin = cheminDe(e);
return [
`${chemin} d'une autre sorte`,
abime((d) => {
const porteur = valeurEn(d, e.slice(0, -1));
porteur[e.at(-1)] = Array.isArray(porteur[e.at(-1)]) ? {} : [];
}),
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
chemin,
];
})), []);
});
test('chaque valeur hors de son domaine est nommée par son chemin', () => {
const cas = [
['produitVersion vide', (d) => { d.entete.produitVersion = ''; }, 'entete.produitVersion'],
['révision 0', (d) => { d.entete.revision = 0; }, 'entete.revision'],
['révision non entière', (d) => { d.entete.revision = 2.5; }, 'entete.revision'],
['compte négatif', (d) => { d.entete.comptes.titres = -1; }, 'entete.comptes.titres'],
['compte de retenu 2', (d) => { d.entete.comptes.retenu = 2; }, 'entete.comptes.retenu'],
["identifiant d'événement vide", (d) => { d.charge.evenement.id = ''; }, 'charge.evenement.id'],
["nom d'événement vide", (d) => { d.charge.evenement.nom = ''; }, 'charge.evenement.nom'],
['30 février', (d) => { d.charge.evenement.date = '2026-02-30'; }, 'charge.evenement.date'],
["29 février d'une année ordinaire", (d) => { d.charge.evenement.date = '2026-02-29'; }, 'charge.evenement.date'],
['29 février 1900, séculaire', (d) => { d.charge.evenement.date = '1900-02-29'; }, 'charge.evenement.date'],
['31 avril', (d) => { d.charge.evenement.date = '2026-04-31'; }, 'charge.evenement.date'],
['mois 13', (d) => { d.charge.evenement.date = '2026-13-01'; }, 'charge.evenement.date'],
['mois 00', (d) => { d.charge.evenement.date = '2026-00-10'; }, 'charge.evenement.date'],
['jour 00', (d) => { d.charge.evenement.date = '2026-11-00'; }, 'charge.evenement.date'],
['date dans un autre ordre', (d) => { d.charge.evenement.date = '12/11/2026'; }, 'charge.evenement.date'],
['date suivie d\'une heure', (d) => { d.charge.evenement.date = '2026-11-12T19:00'; }, 'charge.evenement.date'],
['date vide', (d) => { d.charge.evenement.date = ''; }, 'charge.evenement.date'],
['un seul siège par défaut', (d) => { d.charge.evenement.siegesParDefaut = 1; }, 'charge.evenement.siegesParDefaut'],
['aucun tour', (d) => { d.charge.evenement.tours = 0; }, 'charge.evenement.tours'],
['unité autre que le centimètre', (d) => { d.charge.evenement.unite = 'mm'; }, 'charge.evenement.unite'],
['état inconnu', (d) => { d.charge.evenement.etat = 'archive'; }, 'charge.evenement.etat'],
['source de filiation sans nom', (d) => { d.charge.evenement.filiation.source.nom = ''; },
'charge.evenement.filiation.source.nom'],
['instant de filiation à la révision 0', (d) => { d.charge.evenement.filiation.instant.revision = 0; },
'charge.evenement.filiation.instant.revision'],
['réglage non booléen', (d) => { d.charge.reglages.attribuerSieges = 'non'; }, 'charge.reglages.attribuerSieges'],
["compte d'arrêt nul", (d) => { d.charge.reglages.generation.arret = 0; }, 'charge.reglages.generation.arret'],
['aucune proposition demandée', (d) => { d.charge.reglages.generation.nombre = 0; },
'charge.reglages.generation.nombre'],
['historique de longueur 0', (d) => { d.charge.reglages.generation.historique = 0; },
'charge.reglages.generation.historique'],
['prochain identifiant 0', (d) => { d.charge.prochainsIds.participant = 0; }, 'charge.prochainsIds.participant'],
['prochain identifiant de table 0', (d) => { d.charge.prochainsIds.table = 0; }, 'charge.prochainsIds.table'],
['prochain identifiant de proposition 0', (d) => { d.charge.prochainsIds.proposition = 0; },
'charge.prochainsIds.proposition'],
['identifiant 0', (d) => { d.charge.participants[0].id = 0; }, 'charge.participants[0].id'],
['identifiant en texte', (d) => { d.charge.participants[0].id = '1'; }, 'charge.participants[0].id'],
['révision au-delà des entiers exacts', (d) => { d.entete.revision = 2 ** 53; }, 'entete.revision'],
['nom de participant vide', (d) => { d.charge.participants[1].nom = ''; }, 'charge.participants[1].nom'],
['prénom vide au lieu de null', (d) => { d.charge.participants[1].prenom = ''; }, 'charge.participants[1].prenom'],
['notes vides au lieu de null', (d) => { d.charge.participants[2].notes = ''; }, 'charge.participants[2].notes'],
['courriel numérique', (d) => { d.charge.participants[0].courriel = 42; }, 'charge.participants[0].courriel'],
['exclu en texte', (d) => { d.charge.participants[2].exclu = 'oui'; }, 'charge.participants[2].exclu'],
['exclu nul', (d) => { d.charge.participants[2].exclu = null; }, 'charge.participants[2].exclu'],
['numéro de table 0', (d) => { d.charge.tables[0].numero = 0; }, 'charge.tables[0].numero'],
['table d\'un seul siège', (d) => { d.charge.tables[1].sieges = 1; }, 'charge.tables[1].sieges'],
['sièges non entiers', (d) => { d.charge.tables[1].sieges = 3.5; }, 'charge.tables[1].sieges'],
['forme inconnue', (d) => { d.charge.tables[0].forme = 'ovale'; }, 'charge.tables[0].forme'],
['position en texte', (d) => { d.charge.tables[0].position.x = '0'; }, 'charge.tables[0].position.x'],
['position nulle', (d) => { d.charge.tables[1].position.y = null; }, 'charge.tables[1].position.y'],
['portée inconnue', (d) => { d.charge.reservations[0].portee = 'toujours'; }, 'charge.reservations[0].portee'],
['siège réservé 0', (d) => { d.charge.reservations[0].siege = 0; }, 'charge.reservations[0].siege'],
['tour 0', (d) => { d.charge.reservations[1].tour = 0; }, 'charge.reservations[1].tour'],
['libellé de titre vide', (d) => { d.charge.titres[0].libelle = ''; }, 'charge.titres[0].libelle'],
['titre sans siège', (d) => { d.charge.titres[0].siege = null; }, 'charge.titres[0].siege'],
];
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, modifier, chemin]) => [libelle, abime(modifier), 'FORME', chemin])), []);
});
test("une position que JSON.parse lit infinie, 1e400 ou -1e400, est nommée par son chemin", () => {
// JSON.stringify n'écrit jamais un nombre infini : le cas s'écrit dans le
// texte, et l'épreuve vérifie d'abord qu'il se lit bien infini.
const valide = JSON.stringify(documentValide());
const cas = [
['abscisse 1e400',
remplacerUneFois(valide, '"position":{"x":0,"y":0}', '"position":{"x":1e400,"y":0}'),
'FORME', 'charge.tables[0].position.x'],
['ordonnée -1e400',
remplacerUneFois(valide, '"position":{"x":120.5,"y":-40}', '"position":{"x":120.5,"y":-1e400}'),
'FORME', 'charge.tables[1].position.y'],
];
assert.equal(JSON.parse(cas[0][1]).charge.tables[0].position.x, Infinity);
assert.equal(JSON.parse(cas[1][1]).charge.tables[1].position.y, -Infinity);
assert.deepEqual(ecartsDeRefus(cas), []);
});
test("null, posé à chaque valeur dont le contrat ne l'admet pas, est nommé par son chemin", () => {
const etapes = etapesDe(documentValide());
// Chaque chemin admis nul désigne au moins une valeur du document : la
// liste suit le contrat, sans entrée qui ne désigne plus rien.
for (const chemin of ADMETTENT_NULL) {
assert.ok(etapes.some((e) => sansRangs(e) === chemin), `${chemin} : aucune valeur du document`);
}
const refusent = etapes.filter((e) => !ADMETTENT_NULL.has(sansRangs(e)));
assert.ok(refusent.length >= 80, `${refusent.length} valeurs seulement`);
// Le format, lu avant tout le reste, a sa propre raison.
assert.deepEqual(ecartsDeRefus(refusent.map((e) => {
const chemin = cheminDe(e);
return [
`${chemin} nul`,
abime((d) => { valeurEn(d, e.slice(0, -1))[e.at(-1)] = null; }),
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
chemin,
];
})), []);
});
test("de plusieurs clés inconnues, la plus petite en unités UTF-16 est nommée, quel que soit l'ordre où elles arrivent", () => {
const avec = (cles) => abime((d) => {
for (const cle of cles) d.charge.participants[1][cle] = 1;
});
// Une clé « __proto__ » ne s'écrit que dans le texte : une affectation en
// JavaScript changerait le prototype au lieu d'ajouter une clé.
const avecProto = JSON.stringify(documentValide()).replace('"nom":"Sarrasin"', '"nom":"Sarrasin","__proto__":{}');
assert.deepEqual(ecartsDeRefus([
['zeta puis alpha', avec(['zeta', 'alpha']), 'FORME', 'charge.participants[1].alpha'],
['alpha puis zeta', avec(['alpha', 'zeta']), 'FORME', 'charge.participants[1].alpha'],
['une majuscule précède une minuscule', avec(['alpha', 'Zeta']), 'FORME', 'charge.participants[1].Zeta'],
["une clé qui n'est pas un identifiant", avec(['nom complet']), 'FORME', 'charge.participants[1]["nom complet"]'],
['une clé vide', avec(['']), 'FORME', 'charge.participants[1][""]'],
['une clé entière', avec(['7']), 'FORME', 'charge.participants[1]["7"]'],
['une clé « __proto__ »', avecProto, 'FORME', 'charge.participants[1].__proto__'],
]), []);
});
});
describe('analyser : identifiants et tours (§ 4)', () => {
test('un identifiant en double, ou atteignant prochainsIds, est nommé ; un tour hors de sa portée aussi', () => {
assert.deepEqual(ecartsDeRefus([
['identifiant de participant au-delà de prochainsIds',
abime((d) => { d.charge.participants[3].id = 9; }), 'FORME', 'charge.participants[3].id'],
['identifiant de participant en double, le second fautif',
abime((d) => { d.charge.participants[2].id = 1; }), 'FORME', 'charge.participants[2].id'],
['identifiant de table égal à prochainsIds.table',
abime((d) => { d.charge.tables[1].id = 4; }), 'FORME', 'charge.tables[1].id'],
['identifiant de table en double',
abime((d) => { d.charge.tables[1].id = 1; }), 'FORME', 'charge.tables[1].id'],
['portée « tous » avec un tour',
abime((d) => { d.charge.reservations[0].tour = 1; }), 'FORME', 'charge.reservations[0].tour'],
['portée « tour » sans tour',
abime((d) => { d.charge.reservations[1].tour = null; }), 'FORME', 'charge.reservations[1].tour'],
['tour au-delà du nombre de tours',
abime((d) => { d.charge.reservations[1].tour = 3; }), 'FORME', 'charge.reservations[1].tour'],
]), []);
});
test("l'identifiant d'une proposition et la proposition d'origine du retenu ne se comparent pas à prochainsIds.proposition : le fichier se lit tel qu'écrit, au format courant comme plus récent, et le contrôle des placements les juge (§ 8.9, point 3)", () => {
// La proposition 1 est seule, et prochainsIds.proposition vaut 2.
const avecRetenu = (proposition) => (d) => {
d.charge.retenu = { ...retenuValide(), proposition };
d.entete.comptes.retenu = 1;
};
const seconde = (id) => (d) => {
d.charge.propositions.push({ ...d.charge.propositions[0], id });
d.entete.comptes.propositions = 2;
};
const cas = [
['identifiant de proposition égal à prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 2; }],
['identifiant de proposition au-delà de prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 7; }],
['la seconde proposition au-delà, la première en deçà', seconde(3)],
["proposition d'origine du retenu égale à prochainsIds.proposition", avecRetenu(2)],
["proposition d'origine du retenu au-delà, sans aucune proposition", (d) => {
avecRetenu(5)(d);
d.charge.propositions = [];
d.entete.comptes.propositions = 0;
}],
['compteur abaissé à 1, sous chaque identifiant et sous le retenu', (d) => {
avecRetenu(1)(d);
seconde(3)(d);
d.charge.prochainsIds.proposition = 1;
}],
];
// Chaque cas se lit à chaque format, l'en-tête et la charge rendus tels
// que le document les porte.
const ecarts = cas.flatMap(([libelle, modifier]) =>
[FORMAT, FORMAT + 1].flatMap((format) => {
const attendu = documentValide();
modifier(attendu);
attendu.entete.format = format;
try {
assert.deepEqual(analyser(JSON.stringify(attendu)), { ...attendu, formatPlusRecent: format > FORMAT });
return [];
} catch (erreur) {
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
}
}),
);
assert.ok(cas.length > 0, 'aucun cas examiné');
assert.deepEqual(ecarts, []);
// Un identifiant de table en double reste refusé, quels que soient ceux
// des propositions.
assert.deepEqual(ecartsDeRefus([
['une table en double, une proposition au-delà du compteur', abime((d) => {
seconde(3)(d);
d.charge.tables[1].id = 1;
}), 'FORME', 'charge.tables[1].id'],
]), []);
});
});
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
test('chaque compte se compare à sa liste, le retenu à 0 ou 1', () => {
assert.deepEqual(ecartsDeRefus([
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
['une réservation annoncée en moins',
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
['aucune proposition annoncée',
abime((d) => { d.entete.comptes.propositions = 0; }), 'COMPTES', 'entete.comptes.propositions'],
['un retenu annoncé, aucun écrit', abime((d) => { d.entete.comptes.retenu = 1; }), 'COMPTES', 'entete.comptes.retenu'],
['un retenu écrit, aucun annoncé',
abime((d) => { d.charge.retenu = retenuValide(); }), 'COMPTES', 'entete.comptes.retenu'],
]), []);
});
});
describe('analyser : références (§ 4, § 6.2)', () => {
test("une réservation ou un titre vers une table ou une personne absente, ou un siège au-delà de la capacité, est nommé", () => {
assert.deepEqual(ecartsDeRefus([
["réservation d'une personne retirée",
abime((d) => { d.charge.reservations[1].participant = 4; }), 'REFERENCE', 'charge.reservations[1].participant'],
['réservation vers une table absente',
abime((d) => { d.charge.reservations[0].table = 2; }), 'REFERENCE', 'charge.reservations[0].table'],
['siège réservé au-delà de la capacité par défaut',
abime((d) => { d.charge.reservations[1].siege = 4; }), 'REFERENCE', 'charge.reservations[1].siege'],
['siège réservé au-delà de la surcharge',
abime((d) => { d.charge.reservations[0].siege = 5; }), 'REFERENCE', 'charge.reservations[0].siege'],
['titre vers une table absente',
abime((d) => { d.charge.titres[0].table = 7; }), 'REFERENCE', 'charge.titres[0].table'],
['titre au-delà de la capacité',
abime((d) => { d.charge.titres[0].siege = 5; }), 'REFERENCE', 'charge.titres[0].siege'],
]), []);
});
});
describe('analyser : ordre des refus', () => {
test('format, forme de chaque champ, identifiants et tours, comptes, puis références ; dans chacun, l\'ordre du schéma puis des rangs', () => {
assert.deepEqual(ecartsDeRefus([
['le format avant la forme', abime((d) => {
d.entete.format = 0;
d.charge.participants[0].nom = '';
}), 'FORMAT_INCONNU', 'entete.format'],
["l'ordre du schéma dans la forme", abime((d) => {
d.charge.participants[0].nom = '';
d.charge.evenement.nom = '';
}), 'FORME', 'charge.evenement.nom'],
['les rangs dans leur ordre', abime((d) => {
d.charge.participants[2].nom = '';
d.charge.participants[1].nom = '';
}), 'FORME', 'charge.participants[1].nom'],
["un champ connu avant une clé inconnue du même objet", abime((d) => {
d.charge.participants[0].alpha = 1;
d.charge.participants[0].nom = '';
}), 'FORME', 'charge.participants[0].nom'],
['la forme de chaque champ avant les identifiants', abime((d) => {
d.charge.participants[1].id = 1;
d.charge.titres[0].libelle = '';
}), 'FORME', 'charge.titres[0].libelle'],
['la forme avant les comptes', abime((d) => {
d.entete.comptes.participants = 5;
d.charge.participants[0].nom = '';
}), 'FORME', 'charge.participants[0].nom'],
['les comptes avant les références', abime((d) => {
d.charge.participants.shift();
}), 'COMPTES', 'entete.comptes.participants'],
['la forme avant les références, même plus loin dans le texte', abime((d) => {
d.charge.reservations[0].participant = 9;
d.charge.titres[0].libelle = '';
}), 'FORME', 'charge.titres[0].libelle'],
]), []);
});
});
describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
test("un format 2 se lit, formatPlusRecent vrai, ses clés inconnues ignorées : ni l'en-tête ni la charge rendus ne les portent", () => {
const simple = analyser(abime((d) => { d.entete.format = 2; }));
assert.equal(simple.formatPlusRecent, true);
assert.equal(simple.entete.format, 2);
const { entete, charge } = documentValide();
const etendu = analyser(abime((d) => {
d.entete.format = 2;
d.annexe = { couleur: 'vert' };
d.entete.signature = null;
d.entete.comptes.lieux = 1;
d.charge.evenement.lieu = 'Salle des fêtes';
d.charge.participants[0].pronom = 'elle';
d.charge.tables[1].position.z = 0;
d.charge.propositions[0].note = 'refaite';
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
}));
assert.deepEqual(etendu, { entete: { ...entete, format: 2 }, charge, formatPlusRecent: true });
});
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
const plusRecent = (format, modifier) => abime((d) => {
d.entete.format = format;
d.charge.evenement.lieu = 'Salle des fêtes';
modifier(d);
});
assert.deepEqual(ecartsDeRefus([
['état « archive »', plusRecent(2, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 2 }],
['état « archive », format 3', plusRecent(3, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 3 }],
['nom vide', plusRecent(2, (d) => { d.charge.participants[0].nom = ''; }),
'FORMAT_PLUS_RECENT', 'charge.participants[0].nom', { format: 2 }],
['clé connue absente', plusRecent(2, (d) => { delete d.charge.tables[0].forme; }),
'FORMAT_PLUS_RECENT', 'charge.tables[0].forme', { format: 2 }],
['charge qui est une liste', plusRecent(2, (d) => { d.charge = []; }),
'FORMAT_PLUS_RECENT', 'charge', { format: 2 }],
['identifiant en double', plusRecent(2, (d) => { d.charge.participants[2].id = 1; }),
'FORMAT_PLUS_RECENT', 'charge.participants[2].id', { format: 2 }],
['tour au-delà du nombre de tours', plusRecent(2, (d) => { d.charge.reservations[1].tour = 3; }),
'FORMAT_PLUS_RECENT', 'charge.reservations[1].tour', { format: 2 }],
['comptes faux', plusRecent(2, (d) => { d.entete.comptes.tables = 1; }),
'COMPTES', 'entete.comptes.tables', { format: 2 }],
['référence absente', plusRecent(2, (d) => { d.charge.titres[0].table = 7; }),
'REFERENCE', 'charge.titres[0].table', { format: 2 }],
['comptes faux, format 3', plusRecent(3, (d) => { d.entete.comptes.titres = 0; }),
'COMPTES', 'entete.comptes.titres', { format: 3 }],
['référence absente, format 3', plusRecent(3, (d) => { d.charge.reservations[1].participant = 4; }),
'REFERENCE', 'charge.reservations[1].participant', { format: 3 }],
['siège au-delà de la capacité', plusRecent(2, (d) => { d.charge.reservations[1].siege = 4; }),
'REFERENCE', 'charge.reservations[1].siege', { format: 2 }],
['au format courant, la même faute reste FORME',
abime((d) => { d.charge.evenement.etat = 'archive'; }), 'FORME', 'charge.evenement.etat'],
]), []);
});
test("une collection qu'un format plus récent ajoute, ignorée à la lecture, laisse une référence ou un compte sans objet : la raison reste REFERENCE ou COMPTES, et les détails portent le format lu", () => {
// Le format 2 de l'épreuve range une table d'appoint hors de tables, et
// une réservation la désigne ; la lecture ne garde que les clés connues.
const avecAppoint = (modifier) => abime((d) => {
d.entete.format = 2;
d.charge.tablesAppoint = [{ id: 4, numero: 3, sieges: 4, forme: 'ronde', position: { x: 0, y: 200 } }];
d.charge.prochainsIds.table = 5;
d.charge.reservations.push({ participant: 5, table: 4, siege: 1, portee: 'tous', tour: null });
d.entete.comptes.reservations = 3;
modifier(d);
});
assert.deepEqual(ecartsDeRefus([
["la réservation de la table d'appoint", avecAppoint(() => {}),
'REFERENCE', 'charge.reservations[2].table', { format: 2 }],
["la table d'appoint comptée parmi les tables", avecAppoint((d) => { d.entete.comptes.tables = 3; }),
'COMPTES', 'entete.comptes.tables', { format: 2 }],
]), []);
});
});
describe('configurationDepuisCharge (§ 6.1)', () => {
test('une table qui suit le défaut le prend, une table surchargée garde sa valeur, et changer le défaut ne touche pas la surchargée', () => {
const { charge } = documentValide();
assert.deepEqual(configurationDepuisCharge(charge).tables, [
{ id: 1, numero: 1, capacite: 3 },
{ id: 3, numero: 2, capacite: 4 },
]);
charge.evenement.siegesParDefaut = 6;
assert.deepEqual(configurationDepuisCharge(charge).tables.map((table) => table.capacite), [6, 4]);
});
test('la configuration porte les participants, les tours, les réservations sans siège et les quatre contraintes', () => {
assert.deepEqual(configurationDepuisCharge(documentValide().charge), {
participants: [
{ id: 1, nom: 'Brindille', appartenance: 'Chorale du Vallon', exclu: false },
{ id: 2, nom: 'Sarrasin', appartenance: 'Chorale du Vallon', exclu: false },
{ id: 3, nom: 'Coquelicot', appartenance: null, exclu: true },
{ id: 5, nom: 'Mirabelle', appartenance: 'Cercle Gamma', exclu: false },
],
tables: [
{ id: 1, numero: 1, capacite: 3 },
{ id: 3, numero: 2, capacite: 4 },
],
tours: 2,
reservations: [
{ participant: 1, table: 3, portee: 'tous' },
{ participant: 2, table: 1, portee: 'tour', tour: 2 },
],
contraintes: {
separerAppartenances: true,
nouveauxVoisins: false,
nouvelleTable: true,
varierAppartenances: false,
},
});
});
test("les listes gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
const { charge } = documentValide();
charge.participants.reverse();
charge.tables.reverse();
charge.reservations.reverse();
const configuration = configurationDepuisCharge(charge);
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
assert.deepEqual(configuration.tables.map(({ id }) => id), [3, 1]);
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
// La seconde réservation de la charge désigne une table absente : le
// moteur la nomme à son rang dans la charge, 1.
charge.reservations[1].table = 9;
assert.throws(
() => normaliser(configurationDepuisCharge(charge)),
(erreur) =>
erreur instanceof ErreurConfiguration &&
erreur.code === 'RESERVATION_INCONNUE' &&
erreur.details.reservation === 1 &&
erreur.details.table === 9,
);
});
test('le résultat passe normaliser du moteur : exclu écarté, ancrage dérivé de la portée « tous », capacités résolues', () => {
const charge = geler(documentValide().charge);
const instance = normaliser(configurationDepuisCharge(charge));
assert.deepEqual(instance.ids, [1, 2, 5]);
assert.deepEqual([...instance.exclus], [3]);
assert.deepEqual(instance.idsTables, [1, 3]);
assert.deepEqual(instance.capacite, Int32Array.from([3, 4]));
assert.deepEqual(instance.statut, Uint8Array.from([STATUT.ANCRE, STATUT.PARTIELLEMENT_FIXE, STATUT.MOBILE]));
assert.equal(instance.R, 2);
});
});
describe('etatDeduit (§ 8.7, § 9)', () => {
test('sans proposition, brouillon ; avec, proposé ; avec un retenu, retenu', () => {
assert.equal(etatDeduit(creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 })), 'brouillon');
const { charge } = documentValide();
assert.equal(etatDeduit(charge), 'propose');
charge.retenu = retenuValide();
assert.equal(etatDeduit(charge), 'retenu');
charge.propositions = [];
assert.equal(etatDeduit(charge), 'retenu');
});
test("un plan bloqué donne l'un des trois, jamais bloqué : le contenu décide, pas l'état inscrit", () => {
const bloquee = (modifier) => {
const { charge } = documentValide();
charge.evenement.etat = 'bloque';
modifier(charge);
return etatDeduit(charge);
};
assert.deepEqual(
[
bloquee((c) => { c.propositions = []; }),
bloquee(() => {}),
bloquee((c) => { c.retenu = retenuValide(); }),
],
['brouillon', 'propose', 'retenu'],
);
const { charge } = documentValide();
charge.evenement.etat = 'retenu';
charge.propositions = [];
assert.equal(etatDeduit(charge), 'brouillon');
});
});
describe('capacite (§ 6.1)', () => {
test("les sièges de la table, ou le défaut quand elle le suit ; un identifiant au lieu de l'enregistrement lève TypeError", () => {
const { charge } = documentValide();
assert.equal(capacite(charge, charge.tables[0]), 3);
assert.equal(capacite(charge, charge.tables[1]), 4);
charge.evenement.siegesParDefaut = 10;
assert.equal(capacite(charge, charge.tables[0]), 10);
assert.equal(capacite(charge, charge.tables[1]), 4);
assert.throws(() => capacite(charge, 3), TypeError);
assert.throws(() => capacite(charge, null), TypeError);
});
});
describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', () => {
test("le chemin de la première faute, ou null ; une règle aPart n'examine que son conteneur sous une autre règle, et tout son contenu donnée pour racine", () => {
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const RETENU = regleDe(regleDe(SCHEMA, 'charge'), 'retenu');
const valide = documentValide();
valide.charge.retenu = retenuValide();
assert.equal(premiereFaute(valide, SCHEMA), null);
assert.equal(premiereFaute(valide.charge.retenu, RETENU), null);
const fautif = { ...retenuValide(), tours: 0 };
valide.charge.retenu = fautif;
assert.equal(premiereFaute(valide, SCHEMA), null);
assert.equal(premiereFaute(fautif, RETENU), 'tours');
assert.equal(premiereFaute(fautif, RETENU, 'charge.retenu'), 'charge.retenu.tours');
assert.equal(premiereFaute(null, RETENU), null);
valide.charge.retenu = [];
assert.equal(premiereFaute(valide, SCHEMA), 'charge.retenu');
});
test("premiereFauteEnPlace lit une valeur comme l'analyse la lit à sa place : une règle aPart n'y examine que son conteneur, toute autre comme premiereFaute", () => {
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDe(SCHEMA, 'charge');
const RETENU = regleDe(CHARGE, 'retenu');
const PROPOSITIONS = regleDe(CHARGE, 'propositions');
const EVENEMENT = regleDe(CHARGE, 'evenement');
const fautif = { ...retenuValide(), tours: 0 };
assert.equal(premiereFaute(fautif, RETENU), 'tours');
assert.equal(premiereFauteEnPlace(fautif, RETENU), null);
assert.equal(premiereFauteEnPlace(null, RETENU), null);
assert.equal(premiereFauteEnPlace([], RETENU), '');
assert.equal(premiereFauteEnPlace(5, RETENU, 'charge.retenu'), 'charge.retenu');
assert.equal(premiereFaute([42], PROPOSITIONS), '[0]');
assert.equal(premiereFauteEnPlace([42, { id: 'x' }], PROPOSITIONS), null);
assert.equal(premiereFauteEnPlace({}, PROPOSITIONS), '');
assert.equal(premiereFauteEnPlace(null, PROPOSITIONS), '');
const { evenement } = documentValide().charge;
for (const valeur of [evenement, { ...evenement, nom: '' }, { ...evenement, lieu: 'Salle' }, 5]) {
assert.equal(premiereFauteEnPlace(valeur, EVENEMENT), premiereFaute(valeur, EVENEMENT), JSON.stringify(valeur));
}
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
});
});

View file

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

View file

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

22
src/stockage/erreurs.js Normal file
View file

@ -0,0 +1,22 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Erreurs que lève le stockage. Le code d'une ErreurStockage nomme l'échec,
// et ses détails ce que l'appelant en lit — chemin, raison, révisions de
// secours — ; types.js en donne la table, code par code. L'appelant lit le
// code et les détails ; le message, code et détails en JSON, sert aux traces
// de diagnostic et n'est pas un texte affiché, que fournit la table des
// libellés de l'application (§ 14.6).
export class ErreurStockage extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurStockage.prototype.name = 'ErreurStockage';

459
src/stockage/journal.js Normal file
View file

@ -0,0 +1,459 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le journal d'un événement (§ 8.2, § 8.3, § 8.6, § 8.8) : le fichier
// <base>.gtt-journal.jsonl, un objet JSON par ligne, fin de ligne LF, dont
// types.js décrit chaque sorte de ligne. La première ligne l'apparie à son
// état par l'identifiant de l'événement ; suivent les entrées, une par geste
// achevé, et les jalons, qui nomment l'instant d'une révision sans changer la
// charge. L'ordre est celui des lignes, jamais celui des horodatages, qui ne
// servent qu'à l'affichage.
//
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
// texte entier d'un journal, restitue la charge d'un instant, suit le fil
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
// refuserait la forme. La suite des révisions et la cadence des instantanés,
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
// appartiennent à l'appelant.
//
// Une entrée porte soit l'instantané de la charge qui en résulte, soit le
// correctif qui y mène depuis la charge de l'entrée qui la précède dans
// l'ordre des lignes (correctifs.js). L'instantané tombe sur la première
// entrée du journal, puis toutes les INTERVALLE_INSTANTANE révisions comptées
// depuis elle : restituer un instant part de l'instantané le plus proche, à
// ou avant lui, et applique au plus INTERVALLE_INSTANTANE − 1 correctifs.
//
// La lecture s'arrête à la première ligne illisible : JSON invalide, forme
// fausse, révision qui ne suit pas la précédente, cadence des instantanés
// rompue, jalon d'une révision qui n'est pas lue, ligne finale sans fin de
// ligne, que laisse une écriture interrompue. Cette ligne et toutes celles
// qui la suivent sont écartées et comptées : reprendre après elle ferait
// suivre un correctif à un instant qui n'est pas le sien (§ 8.6).
import { canoniser } from './canonique.js';
import { appliquer, difference } from './correctifs.js';
import { FORMAT, SCHEMA, clesRangees, premiereFaute } from './document.js';
/** Révisions d'un instantané au suivant (§ 8.6). */
export const INTERVALLE_INSTANTANE = 50;
/** Entrées que l'élagage garde au moins : le plancher l'emporte (§ 8.6). */
export const PLANCHER_ENTREES = 500;
/** Entrées au-delà desquelles le journal s'élague (§ 8.6). */
export const PLAFOND_ENTREES = 600;
/**
* Un journal lu (lireJournal) : l'identifiant de l'événement, null quand
* l'ouverture est illisible ; les entrées, puis les jalons, chacun dans
* l'ordre des lignes, tels que le texte les porte ; et le nombre de lignes
* écartées. Les révisions des entrées se suivent ; la première porte un
* instantané et une version.
*
* @typedef {Object} Journal
* @property {string|null} evenement
* @property {import('./types.js').LigneEntree[]} entrees
* @property {import('./types.js').LigneJalon[]} jalons
* @property {number} ecartees
*/
// Règles de la charge et d'une proposition dans le schéma du fichier d'état.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
const SENS = [null, 'defaire', 'refaire', 'revenir'];
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1;
const estTexte = (valeur) => typeof valeur === 'string' && valeur !== '';
const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur);
// Chemin de la première valeur de charge que l'analyse du fichier d'état
// refuserait, ou null : la forme de la charge, propositions et retenu lus
// comme conteneurs, puis la forme de chaque proposition, que canoniser
// exige. Un retenu hors de sa règle reste admis, comme le contrôle des
// placements le garde (§ 8.9).
function fauteDeCharge(charge) {
return (
premiereFaute(charge, CHARGE, 'charge') ??
charge.propositions
.map((proposition, rang) => premiereFaute(proposition, PROPOSITION, `charge.propositions[${rang}]`))
.find((faute) => faute !== null) ??
null
);
}
// Une sorte de ligne : ses champs, paires [clé, admet] dans l'ordre des clés
// du texte, et l'ensemble de ces clés. admet(valeur, ligne) dit si une valeur
// est admise à sa clé ; il peut lire les clés qui la précèdent, déjà admises.
const sorte = (champs) => ({ champs, cles: new Set(champs.map(([cle]) => cle)) });
const OUVERTURE = sorte([
['type', (valeur) => valeur === 'journal'],
['format', (valeur) => valeur === FORMAT],
['evenement', estTexte],
]);
const ENTREE = sorte([
['type', (valeur) => valeur === 'entree'],
['revision', estRevision],
['libelle', estTexte],
['horodatage', estHorodatage],
['sens', (valeur) => SENS.includes(valeur)],
// Un retour vise une révision antérieure à la sienne ; un geste n'en vise
// aucune.
['retour', (valeur, { sens, revision }) => (sens === null ? valeur === null : estRevision(valeur) && valeur < revision)],
['produitVersion', (valeur) => valeur === null || estTexte(valeur)],
['instantane', (valeur) => valeur === null || fauteDeCharge(valeur) === null],
// Exactement l'un des deux : le correctif quand l'instantané manque.
['correctif', (valeur, { instantane }) => (instantane === null ? Array.isArray(valeur) : valeur === null)],
]);
const JALON = sorte([
['type', (valeur) => valeur === 'jalon'],
['revision', estRevision],
['nom', estTexte],
['horodatage', estHorodatage],
]);
// Première clé de ligne hors de la règle de sa sorte, ou null : '' quand la
// ligne n'est pas un objet, puis chaque champ dans l'ordre de la sorte,
// absent ou refusé, puis la plus petite clé inconnue.
function champFautif(ligne, { champs, cles }) {
if (!estObjet(ligne)) return '';
for (const [cle, admet] of champs) {
if (!Object.hasOwn(ligne, cle) || !admet(ligne[cle], ligne)) return cle;
}
return clesRangees(ligne).find((cle) => !cles.has(cle)) ?? null;
}
// Texte d'une ligne, ses clés dans l'ordre de sa sorte, sans fin de ligne ;
// JSON.stringify n'écrit aucune fin de ligne brute. Lève TypeError, au nom
// de fonction, quand la lecture refuserait la ligne.
function ecrire(sorteDeLigne, valeurs, fonction) {
const ligne = Object.fromEntries(sorteDeLigne.champs.map(([cle]) => [cle, valeurs[cle]]));
const cle = champFautif(ligne, sorteDeLigne);
if (cle !== null) throw new TypeError(`${fonction} : ${cle} hors de sa règle`);
return JSON.stringify(ligne);
}
/**
* Première ligne d'un journal, qui l'apparie à son état par l'identifiant de
* l'événement (§ 8.6) ; sans fin de ligne. Le format est FORMAT, celui des
* charges que le journal porte. Lève TypeError quand evenement n'est pas une
* chaîne non vide.
*
* @param {string} evenement
* @returns {string}
*/
export function ligneOuverture(evenement) {
return ecrire(OUVERTURE, { type: 'journal', format: FORMAT, evenement }, 'ligneOuverture');
}
/**
* Ligne d'une entrée (§ 8.2, § 8.3), sans fin de ligne : sa révision, son
* libellé figé, son horodatage d'affichage, sens et retour pour une entrée
* de retour, la version de la construction quand elle se note (§ 8.8), puis
* l'instantané de la copie canonique de apres quand instantane est vrai, ou
* le correctif de avant à apres sinon (difference). L'instantané et le
* correctif ne dépendent que des deux charges, non de l'ordre de leurs clés
* ni de leurs listes. Ni avant ni apres ne sont modifiées.
*
* L'appelant passe instantane = estInstantane(revision, première révision
* du journal), et produitVersion à la première entrée et quand la
* construction change ; la lecture refuse une ligne qui rompt l'un ou
* l'autre.
*
* Lève TypeError quand la lecture refuserait la ligne — révision qui n'est
* pas un entier ≥ 1, libellé vide, horodatage qui n'a pas la forme
* AAAA-MM-JJTHH:MM:SS±HH:MM, sens inconnu, retour présent sans sens, absent
* avec un sens, ou qui ne précède pas la révision, version vide —, quand
* instantane n'est pas un booléen, quand apres n'a pas la forme que
* l'analyse du fichier d'état admet — clés inconnues comprises —, chaque
* proposition selon sa règle, et, pour un correctif, quand avant manque ou
* ne se canonise pas.
*
* @param {Object} entree
* @param {number} entree.revision
* @param {string} entree.libelle
* @param {string} entree.horodatage
* @param {null|'defaire'|'refaire'|'revenir'} [entree.sens]
* @param {number|null} [entree.retour]
* @param {string|null} [entree.produitVersion]
* @param {import('./types.js').Charge} [entree.avant] requise pour un correctif
* @param {import('./types.js').Charge} entree.apres
* @param {boolean} entree.instantane
* @returns {string}
*/
export function ligneEntree({
revision,
libelle,
horodatage,
sens = null,
retour = null,
produitVersion = null,
avant,
apres,
instantane,
}) {
if (typeof instantane !== 'boolean') {
throw new TypeError(`ligneEntree : instantane booléen attendu, reçu ${JSON.stringify(instantane)}`);
}
// La charge reçue, telle quelle : une clé hors du schéma, que la copie
// canonique tairait, est refusée comme l'analyse la refuserait.
const faute = fauteDeCharge(apres);
if (faute !== null) throw new TypeError(`ligneEntree : ${faute} hors de sa règle`);
const ligne = {
type: 'entree',
revision,
libelle,
horodatage,
sens,
retour,
produitVersion,
instantane: instantane ? canoniser(apres) : null,
correctif: instantane ? null : difference(avant, apres),
};
return ecrire(ENTREE, ligne, 'ligneEntree');
}
/**
* Ligne d'un jalon (§ 8.3), sans fin de ligne : il nomme l'instant de la
* révision donnée, ne change pas la charge et ne prend pas de révision.
* Lève TypeError quand la révision n'est pas un entier ≥ 1, le nom vide, ou
* l'horodatage hors de sa forme.
*
* @param {{revision: number, nom: string, horodatage: string}} jalon
* @returns {string}
*/
export function ligneJalon({ revision, nom, horodatage }) {
return ecrire(JALON, { type: 'jalon', revision, nom, horodatage }, 'ligneJalon');
}
/**
* Vrai quand l'entrée revision porte un instantané, dans un journal dont la
* première entrée est premiere : premiere elle-même, puis toutes les
* INTERVALLE_INSTANTANE révisions comptées depuis elle ; jamais une révision
* qui précède premiere.
*
* @param {number} revision
* @param {number} premiere
* @returns {boolean}
*/
export function estInstantane(revision, premiere) {
return revision >= premiere && (revision - premiere) % INTERVALLE_INSTANTANE === 0;
}
// Valeur JSON d'une ligne, ou undefined quand elle n'en est pas une.
function analyserLigne(ligne) {
try {
return JSON.parse(ligne);
} catch {
return undefined;
}
}
// Vrai quand ligne est l'entrée qui suit entrees : sa forme admise, et la
// révision qui suit la dernière, l'instantané là où la cadence le veut ; la
// première entrée, de révision quelconque, porte un instantané et sa
// version.
function entreeSuivante(ligne, entrees) {
if (champFautif(ligne, ENTREE) !== null) return false;
if (entrees.length === 0) return ligne.produitVersion !== null && ligne.instantane !== null;
return (
ligne.revision === entrees[entrees.length - 1].revision + 1 &&
(ligne.instantane !== null) === estInstantane(ligne.revision, entrees[0].revision)
);
}
// Vrai quand ligne est un jalon d'une révision déjà lue.
function jalonDe(ligne, entrees) {
return (
champFautif(ligne, JALON) === null &&
entrees.length > 0 &&
ligne.revision >= entrees[0].revision &&
ligne.revision <= entrees[entrees.length - 1].revision
);
}
// Lecture d'un texte de journal : le journal lu ; les lignes lues après
// l'ouverture, entrées et jalons dans l'ordre du texte ; et les segments du
// texte coupé à chaque fin de ligne, la marque d'ordre d'octets retirée, le
// dernier segment suivant la dernière fin de ligne. Chaque segment suivi
// d'une fin de ligne est une ligne ; le dernier aussi quand il n'est pas
// vide, et la lecture l'écarte.
function lire(texte) {
const segments = (texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte).split('\n');
const completes = segments.length - 1;
const lignes = segments[completes] === '' ? completes : completes + 1;
const journal = { evenement: null, entrees: [], jalons: [], ecartees: lignes };
const lues = [];
const ouverture = completes > 0 ? analyserLigne(segments[0]) : undefined;
if (champFautif(ouverture, OUVERTURE) !== null) return { journal, lues, segments };
journal.evenement = ouverture.evenement;
for (let rang = 1; rang < completes; rang += 1) {
const ligne = analyserLigne(segments[rang]);
if (entreeSuivante(ligne, journal.entrees)) journal.entrees.push(ligne);
else if (jalonDe(ligne, journal.entrees)) journal.jalons.push(ligne);
else break;
lues.push(ligne);
}
journal.ecartees = lignes - 1 - lues.length;
return { journal, lues, segments };
}
/**
* Lit le texte d'un journal (§ 8.6), sans rien écrire (§ 8.4). Une marque
* d'ordre d'octets en tête est ignorée ; un retour chariot avant la fin de
* ligne aussi, que JSON tient pour un blanc.
*
* La lecture s'arrête à la première ligne illisible : JSON invalide ; forme
* fausse, toute clé absente, refusée ou inconnue, et l'instantané d'une
* forme que l'analyse du fichier d'état refuse ; une ouverture d'un autre
* format ; une entrée dont la révision ne suit pas la précédente, ou dont
* l'instantané manque ou abonde au regard de la cadence (estInstantane) ;
* une première entrée sans version ; un jalon d'une révision qui n'est pas
* encore lue ; une ligne finale sans fin de ligne. Cette ligne et toutes
* celles qui la suivent, entrées et jalons confondus — une ligne illisible
* ne dit pas sa sorte —, sont écartées et comptées dans ecartees. Une
* ouverture illisible écarte tout, et evenement vaut null.
*
* Un correctif se lit sans s'appliquer : reconstruire lève CORRECTIF sur
* celui qui ne s'applique pas.
*
* @param {string} texte
* @returns {Journal}
*/
export function lireJournal(texte) {
return lire(texte).journal;
}
/**
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —
* au plus INTERVALLE_INSTANTANE − 1. Rend une charge neuve, dans l'ordre
* canonique, qui ne partage rien avec le journal. Une entrée de retour rend
* la charge de sa cible, que son correctif ou son instantané portent.
*
* Lève ErreurStockage('CORRECTIF', { rang }) d'appliquer (correctifs.js) sur
* un correctif qui ne s'applique pas : il gâte les révisions de la sienne à
* l'instantané suivant, et aucune autre. Lève RangeError quand revision
* n'est pas celle d'une entrée du journal.
*
* @param {Journal} journal
* @param {number} revision
* @returns {import('./types.js').Charge}
*/
export function reconstruire({ entrees }, revision) {
const rang = entrees.findIndex((entree) => entree.revision === revision);
if (rang === -1) throw new RangeError(`reconstruire : révision ${String(revision)} absente du journal`);
let base = rang;
while (entrees[base].instantane === null) base -= 1;
let charge = canoniser(entrees[base].instantane);
for (let suivante = base + 1; suivante <= rang; suivante += 1) {
charge = appliquer(charge, entrees[suivante].correctif);
}
return charge;
}
/**
* Le fil courant (§ 8.3), par le rejeu des entrées dans l'ordre des lignes.
* La position d'une entrée ordinaire est elle-même ; celle d'une entrée de
* retour, la position de l'entrée qu'elle vise. Une entrée ordinaire pose la
* position et vide la pile de refaire ; un retour defaire ou revenir empile
* la position courante et pose celle de sa cible ; un retour refaire dépile
* et pose celle de sa cible.
*
* Le prédécesseur d'une position est la position de l'entrée qui la précède
* dans l'ordre des lignes, null pour la première entrée du journal. Un retour
* dont la cible précède la première entrée — l'élagage l'a retirée — porte
* la charge de cette cible sans en connaître la position : il est sa propre
* position, sans prédécesseur, et le fil s'arrête à lui.
*
* Rend position, la position courante, null pour un journal sans entrée ;
* pile, les positions à refaire, le sommet en dernier ; courant, le fil
* courant, la chaîne des prédécesseurs depuis la position, de la plus récente
* à la plus ancienne ; cibleDefaire, le prédécesseur de la position, et
* cibleRefaire, le sommet de la pile, null quand ils n'existent pas. Les
* entrées ordinaires hors du fil courant forment les fils abandonnés.
*
* @param {Journal} journal
* @returns {{position: number|null, pile: number[], courant: number[], cibleDefaire: number|null, cibleRefaire: number|null}}
*/
export function fil({ entrees }) {
const positions = new Map();
const sansPredecesseur = new Set();
const pile = [];
let position = null;
for (const { revision, sens, retour } of entrees) {
let cible = revision;
if (sens === null) {
pile.length = 0;
} else {
if (positions.has(retour)) cible = positions.get(retour);
else sansPredecesseur.add(revision);
if (sens === 'refaire') pile.pop();
else if (position !== null) pile.push(position);
}
positions.set(revision, cible);
position = cible;
}
const predecesseur = (p) => (sansPredecesseur.has(p) ? null : (positions.get(p - 1) ?? null));
const courant = [];
for (let p = position; p !== null; p = predecesseur(p)) courant.push(p);
return { position, pile, courant, cibleDefaire: courant[1] ?? null, cibleRefaire: pile.at(-1) ?? null };
}
/**
* Version de la construction qui a produit l'entrée revision (§ 8.8) : la
* dernière produitVersion non nulle à ou avant elle. null quand revision
* n'est pas celle d'une entrée du journal.
*
* @param {Journal} journal
* @param {number} revision
* @returns {string|null}
*/
export function versionDe({ entrees }, revision) {
for (let rang = entrees.findIndex((entree) => entree.revision === revision); rang >= 0; rang -= 1) {
if (entrees[rang].produitVersion !== null) return entrees[rang].produitVersion;
}
return null;
}
/**
* Texte du journal élagué (§ 8.6), ou null quand ses entrées lisibles ne
* dépassent pas PLAFOND_ENTREES. La coupe tombe sur l'instantané le plus
* récent qui garde au moins PLANCHER_ENTREES entrées : le plancher l'emporte
* sur le plafond. Le texte rendu porte l'ouverture, puis les lignes lues dont
* la révision atteint la coupe — les jalons des entrées retirées partent
* avec elles —, puis les lignes que la lecture écarte, telles quelles.
* L'entrée de la coupe porte la version de la construction qui l'a produite
* (versionDe), qu'elle hérite sinon d'une entrée retirée ; toute autre
* ligne gardée se recopie octet pour octet. La marque d'ordre d'octets ne
* se recopie pas. Les instantanés gardés suivent la cadence comptée depuis
* la coupe, celle d'avant : le texte rendu se relit, et chaque instant gardé
* s'y restitue comme avant.
*
* @param {string} texte
* @returns {string|null}
*/
export function elaguer(texte) {
const { journal, lues, segments } = lire(texte);
const { entrees } = journal;
if (entrees.length <= PLAFOND_ENTREES) return null;
let rang = entrees.length - PLANCHER_ENTREES;
while (entrees[rang].instantane === null) rang -= 1;
const coupe = entrees[rang];
const gardees = [ligneOuverture(journal.evenement)];
lues.forEach((ligne, i) => {
if (ligne.revision < coupe.revision) return;
if (ligne !== coupe || coupe.produitVersion !== null) gardees.push(segments[i + 1]);
else gardees.push(ecrire(ENTREE, { ...coupe, produitVersion: versionDe(journal, coupe.revision) }, 'elaguer'));
});
return [...gardees, ...segments.slice(1 + lues.length)].join('\n');
}

View file

@ -0,0 +1,392 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves longues du journal (§ 14.10, série node-long) : une séance de 160
// gestes sur la grande démonstration, propositions comprises, dont chaque
// instant se restitue tel qu'il s'est écrit ; puis la même égalité sur
// cinquante séances tirées sur la petite démonstration, graine écrite
// (§ 14.12). Une séance s'écrit ligne après ligne comme l'écrit
// l'enregistreur : défaire et refaire visent ce que le fil courant désigne à
// cet instant, revenir une révision précédente. Chaque charge écrite passe
// l'analyse du fichier d'état et le contrôle des placements. Les noms des
// personnes ajoutées sont inventés, hors des réservoirs des démonstrations.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { rechercher } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { serialiser, serialiserCharge } from './canonique.js';
import { analyser, configurationDepuisCharge, creerCharge, etatDeduit } from './document.js';
import {
INTERVALLE_INSTANTANE,
estInstantane,
fil,
ligneEntree,
ligneJalon,
ligneOuverture,
lireJournal,
reconstruire,
} from './journal.js';
import { examiner, versFichier } from './placements.js';
// Graine et nombre des séances tirées, écrits ici pour que chaque exécution
// tire les mêmes (§ 14.12), et nombre de pas de la plus longue.
const GRAINE = 61_803;
const TIRAGES = 50;
const PAS_MAX = 110;
// Compte d'arrêt des générations : court, la recherche n'est pas éprouvée ici.
const ARRET = 100;
const copie = (valeur) => structuredClone(valeur);
const texteCanonique = (charge) => serialiserCharge(charge);
// Entiers de a à b inclus, croissants.
const de = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
// Horodatage d'affichage de la révision r : une minute par révision à partir
// de 8 h, le 17 mai 2031, à l'heure de l'Est.
function horodatage(r) {
const minutes = 8 * 60 + r;
const deux = (n) => String(n).padStart(2, '0');
return `2031-05-17T${deux(Math.floor(minutes / 60) % 24)}:${deux(minutes % 60)}:00-04:00`;
}
// Charge d'une démonstration du catalogue (§ 15) : ses personnes, ses tables
// au défaut de l'événement quand leur capacité y est égale, ses réservations
// sans siège, aucune proposition.
function chargeDe(cle) {
const entree = CATALOGUE.find((candidate) => candidate.cle === cle);
const configuration = entree.construire();
const siegesParDefaut = Math.max(...configuration.tables.map(({ capacite }) => capacite));
const charge = creerCharge({ id: `evt-${cle}`, nom: entree.nom, siegesParDefaut, tours: configuration.tours });
charge.participants = configuration.participants.map(({ id, nom, prenom, appartenance }) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = configuration.tables.map(({ id, numero, capacite }, rang) => ({
id,
numero,
sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde',
position: { x: 250 * (rang % 6), y: 250 * Math.floor(rang / 6) },
}));
charge.reservations = configuration.reservations.map(({ participant, table, portee }) => ({
participant,
table,
siege: null,
portee,
tour: null,
}));
charge.prochainsIds = { participant: charge.participants.length + 1, table: charge.tables.length + 1, proposition: 1 };
return charge;
}
// --- Les gestes ------------------------------------------------------------
//
// Chaque geste reçoit la charge courante, qu'il ne modifie pas, et un entier
// d'où il tire ses paramètres ; il rend une charge neuve, ou la charge reçue
// quand il n'a pas d'objet.
const NOMS_AJOUTES = ['Ombrelle', 'Grisaille', 'Pervenche', 'Lacasse', 'Mirabelle', 'Quenouille', 'Sarbacane'];
const PRENOMS_AJOUTES = ['Iris', 'Théo', 'Ondine', 'Aurèle', null];
const APPARTENANCES_AJOUTEES = ['Club des Merles', 'Société Alpha', null];
const REGLAGES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
function ajouter(charge, n) {
const suivante = copie(charge);
const id = suivante.prochainsIds.participant;
suivante.prochainsIds.participant = id + 1;
suivante.participants.push({
id,
nom: NOMS_AJOUTES[n % NOMS_AJOUTES.length],
prenom: PRENOMS_AJOUTES[n % PRENOMS_AJOUTES.length],
appartenance: APPARTENANCES_AJOUTEES[n % APPARTENANCES_AJOUTEES.length],
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
return suivante;
}
function modifier(charge, n) {
const suivante = copie(charge);
const personne = suivante.participants[n % suivante.participants.length];
personne.notes = `note ${n}`;
personne.courriel = n % 2 === 0 ? `p${n}@exemple.test` : null;
return suivante;
}
function exclure(charge, n) {
const suivante = copie(charge);
const personne = suivante.participants[(3 * n) % suivante.participants.length];
personne.exclu = !personne.exclu;
return suivante;
}
function deplacer(charge, n) {
const suivante = copie(charge);
const table = suivante.tables[n % suivante.tables.length];
table.position = { x: table.position.x + 10, y: (n % 7) * 25 };
return suivante;
}
function regler(charge, n) {
const suivante = copie(charge);
const cle = REGLAGES[n % REGLAGES.length];
suivante.reglages[cle] = !suivante.reglages[cle];
return suivante;
}
// Pose deux propositions du moteur, numérotées au-delà du compteur ; quand
// les présents dépassent les places, ajoute une table à la place.
function generer(charge, n) {
const configuration = configurationDepuisCharge(charge);
const places = configuration.tables.reduce((somme, { capacite }) => somme + capacite, 0);
const presents = configuration.participants.filter(({ exclu }) => !exclu).length;
const suivante = copie(charge);
if (presents > places) {
const id = suivante.prochainsIds.table;
suivante.prochainsIds.table = id + 1;
suivante.tables.push({ id, numero: id, sieges: null, forme: 'carree', position: { x: 0, y: 300 } });
return suivante;
}
const decalage = suivante.prochainsIds.proposition - 1;
const options = { produitVersion: VERSION.affichee, attribuerSieges: suivante.reglages.attribuerSieges, decalage };
const posees = rechercher(configuration, { graine: n, arret: ARRET, nombre: 2 }).map((proposition) =>
versFichier(proposition, configuration, options),
);
suivante.propositions.push(...posees);
suivante.prochainsIds.proposition = decalage + posees.length + 1;
return suivante;
}
// Retient une proposition : le retenu en reprend le plan.
function retenir(charge, n) {
if (charge.propositions.length === 0) return charge;
const { id, siegesAttribues, tables, capacites, tours, participants, placement } = copie(
charge.propositions[n % charge.propositions.length],
);
const suivante = copie(charge);
suivante.retenu = { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
return suivante;
}
// Retouche le retenu à la main (§ 5.8) : au tour que n désigne, les
// premières personnes de deux tables voisines échangent leurs tables.
function retoucher(charge, n) {
if (charge.retenu === null) return charge;
const suivante = copie(charge);
const { sieges } = suivante.retenu.placement[n % suivante.retenu.tours];
const a = n % sieges.length;
const b = (a + 1) % sieges.length;
if (a === b || sieges[a].length === 0 || sieges[b].length === 0) return charge;
[sieges[a][0], sieges[b][0]] = [sieges[b][0], sieges[a][0]];
return suivante;
}
// Supprime la dernière personne de la liste, et ses réservations.
function supprimer(charge) {
const suivante = copie(charge);
const [retiree] = suivante.participants.splice(-1, 1);
suivante.reservations = suivante.reservations.filter(({ participant }) => participant !== retiree.id);
return suivante;
}
// Efface les propositions ; le retenu et le compteur restent.
function effacer(charge) {
if (charge.propositions.length === 0) return charge;
const suivante = copie(charge);
suivante.propositions = [];
return suivante;
}
const GESTES = [
['ajouter', ajouter],
['modifier', modifier],
['exclure', exclure],
['generer', generer],
['retenir', retenir],
['retoucher', retoucher],
['deplacer', deplacer],
['regler', regler],
['supprimer', supprimer],
['effacer', effacer],
];
// Les codes qui suivent ceux des gestes sont des retours, dans cet ordre.
const SENS = ['defaire', 'refaire', 'revenir'];
const CODES = GESTES.length + SENS.length;
const code = (nom) => (SENS.includes(nom) ? GESTES.length + SENS.indexOf(nom) : GESTES.findIndex(([geste]) => geste === nom));
// Une séance écrite : la charge de base à la révision 1, puis un pas par
// paire [code, n]. Un code de geste applique son geste, et un geste qui ne
// change pas la charge cède la place à un réglage basculé. Un code de
// retour défait ou refait vers ce que le fil courant désigne, ou revient
// vers la révision 1 + n mod r, r étant la dernière écrite ; quand le fil ne
// désigne rien, le pas applique un geste. Un pas dont n est multiple de 9
// pose un jalon. Rend les charges écrites, révision par révision, le texte
// du journal et le compte de chaque sorte de pas : geste, poser pour une
// génération qui pose des propositions, sens d'un retour.
function ecrireSeance(base, pas) {
const charges = [];
const lignes = [ligneOuverture(base.evenement.id)];
const entrees = [];
const compte = new Map();
const ecrire = (apres, sorte, jalon, { sens = null, retour = null } = {}) => {
const revision = charges.length + 1;
const ligne = ligneEntree({
revision,
libelle: `${sorte} ${revision}`,
horodatage: horodatage(revision),
sens,
retour,
produitVersion: revision === 1 ? VERSION.affichee : null,
avant: charges.at(-1),
apres,
instantane: estInstantane(revision, 1),
});
lignes.push(ligne);
entrees.push(JSON.parse(ligne));
charges.push(apres);
compte.set(sorte, (compte.get(sorte) ?? 0) + 1);
if (jalon) lignes.push(ligneJalon({ revision, nom: `Jalon ${revision}`, horodatage: horodatage(revision) }));
};
ecrire(base, 'creer', true);
for (const [codeDuPas, n] of pas) {
const courante = charges.at(-1);
const jalon = n % 9 === 0;
if (codeDuPas >= GESTES.length) {
const sens = SENS[codeDuPas - GESTES.length];
const { cibleDefaire, cibleRefaire } = fil({ entrees });
const cible = sens === 'defaire' ? cibleDefaire : sens === 'refaire' ? cibleRefaire : 1 + (n % charges.length);
if (cible !== null) {
ecrire(copie(charges[cible - 1]), sens, jalon, { sens, retour: cible });
continue;
}
}
let [sorte, faire] = GESTES[codeDuPas % GESTES.length];
let apres = faire(courante, n);
if (texteCanonique(apres) === texteCanonique(courante)) [sorte, apres] = ['regler', regler(courante, n)];
if (sorte === 'generer' && apres.propositions.length > courante.propositions.length) sorte = 'poser';
apres.evenement.etat = etatDeduit(apres);
ecrire(apres, sorte, jalon);
}
return { charges, texte: `${lignes.join('\n')}\n`, compte };
}
// Le fil courant d'un journal sans élagage selon la définition du § 8.3, un
// parcours arrière depuis la dernière entrée : le prédécesseur d'une entrée
// ordinaire est celle qui la précède, celui d'une entrée de retour l'entrée
// qu'elle vise ; le fil est la suite des entrées ordinaires rencontrées. Ce
// parcours ne connaît ni position ni pile : il sert de témoin, par une autre
// définition, au rejeu que fait le module.
function filParcouru(entrees) {
const parRevision = new Map(entrees.map((entree) => [entree.revision, entree]));
const courant = [];
for (let entree = entrees.at(-1); entree !== undefined; ) {
if (entree.sens === null) courant.push(entree.revision);
entree = parRevision.get(entree.sens === null ? entree.revision - 1 : entree.retour);
}
return courant;
}
// Vérifie le fil de chaque préfixe des entrées : le fil courant et la cible
// de défaire sont ceux du parcours arrière ; la pile compte les défaire et
// revenir qui suivent le dernier geste, moins les refaire (refaire n'existe
// que tant que les dernières entrées sont des retours) ; et après un défaire
// ou un revenir, refaire vise la position qu'il vient de quitter.
function verifierFil(entrees) {
let enAttente = 0;
entrees.forEach((entree, rang) => {
const prefixe = entrees.slice(0, rang + 1);
const { position, pile, courant, cibleDefaire, cibleRefaire } = fil({ entrees: prefixe });
const parcouru = filParcouru(prefixe);
assert.deepEqual([position, courant, cibleDefaire], [parcouru[0], parcouru, parcouru[1] ?? null], `révision ${entree.revision}`);
enAttente = entree.sens === null ? 0 : enAttente + (entree.sens === 'refaire' ? -1 : 1);
assert.equal(pile.length, enAttente, `révision ${entree.revision}`);
if (entree.sens === 'defaire' || entree.sens === 'revenir') {
assert.equal(cibleRefaire, filParcouru(entrees.slice(0, rang))[0], `révision ${entree.revision}`);
}
});
}
// Vérifie une séance écrite : chaque charge admise par l'analyse du fichier
// d'état et par le contrôle des placements ; le journal relu en entier ;
// chaque instant restitué tel qu'il s'est écrit ; la position courante du
// fil porte la dernière charge ; et le fil de chaque préfixe suit le § 8.3.
function verifier({ charges, texte }) {
charges.forEach((charge, rang) => {
analyser(serialiser(charge, { revision: rang + 1, produitVersion: VERSION.affichee }));
const { fautives, retenu } = examiner(charge);
assert.deepEqual([fautives, retenu.fautes], [[], []], `révision ${rang + 1}`);
});
const journal = lireJournal(texte);
assert.deepEqual([journal.entrees.length, journal.ecartees], [charges.length, 0]);
charges.forEach((charge, rang) => {
assert.equal(texteCanonique(reconstruire(journal, rang + 1)), texteCanonique(charge), `révision ${rang + 1}`);
});
assert.equal(texteCanonique(reconstruire(journal, fil(journal).position)), texteCanonique(charges.at(-1)));
verifierFil(journal.entrees);
}
describe('chaque instant d’une longue séance se reconstruit (§ 8.3, § 14.10)', () => {
test('160 gestes sur la grande démonstration, propositions posées, retenues et retouchées, retours compris : reconstruire(journal, r) égale la charge écrite à la révision r, pour chacune', () => {
const cycle = [
'ajouter',
'modifier',
'generer',
'retenir',
'retoucher',
'exclure',
'defaire',
'refaire',
'deplacer',
'generer',
'retoucher',
'regler',
'supprimer',
'revenir',
'modifier',
'effacer',
].map(code);
// Le paramètre d'un pas n'est pas sa révision : revenir vers 1 + r mod (r − 1)
// viserait toujours la révision 2. 7 919 et 10 007 sont premiers : les
// paramètres des 159 pas sont distincts.
const pas = de(2, 160).map((r) => [cycle[r % cycle.length], (r * 7_919) % 10_007]);
const seance = ecrireSeance(chargeDe('grande'), pas);
assert.equal(seance.charges.length, 160);
for (const sorte of ['ajouter', 'modifier', 'exclure', 'poser', 'retenir', 'retoucher', 'defaire', 'refaire', 'revenir']) {
assert.ok((seance.compte.get(sorte) ?? 0) >= 3, `${sorte} : ${seance.compte.get(sorte) ?? 0} fois`);
}
assert.ok(Math.max(...seance.charges.map(({ propositions }) => propositions.length)) >= 4);
verifier(seance);
});
test('50 séances tirées sur la petite démonstration, graine écrite : chaque instant se restitue tel qu’il s’est écrit', () => {
const bilan = { longues: 0, poser: 0, defaire: 0, refaire: 0, revenir: 0 };
const pas = fc.array(fc.tuple(fc.nat({ max: CODES - 1 }), fc.nat({ max: 9_999 })), {
minLength: 1,
maxLength: PAS_MAX,
size: 'max',
});
fc.assert(
fc.property(pas, (tires) => {
const seance = ecrireSeance(chargeDe('petite'), tires);
verifier(seance);
if (seance.charges.length > INTERVALLE_INSTANTANE) bilan.longues += 1;
for (const sorte of ['poser', 'defaire', 'refaire', 'revenir']) bilan[sorte] += seance.compte.get(sorte) ?? 0;
}),
{ seed: GRAINE, numRuns: TIRAGES },
);
// Les séances franchissent des frontières d'instantané, et chaque sorte de
// pas rare s'y présente.
assert.ok(bilan.longues >= 15, `${bilan.longues} séances de plus de ${INTERVALLE_INSTANTANE} entrées`);
for (const sorte of ['poser', 'defaire', 'refaire', 'revenir']) assert.ok(bilan[sorte] >= 20, `${sorte} : ${bilan[sorte]}`);
});
});

1095
src/stockage/journal.test.js Normal file

File diff suppressed because it is too large Load diff

287
src/stockage/noms.js Normal file
View file

@ -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/<base>.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<string>} 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<string>} 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}`;
}

1030
src/stockage/noms.test.js Normal file

File diff suppressed because it is too large Load diff

545
src/stockage/placements.js Normal file
View file

@ -0,0 +1,545 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les placements du fichier d'état, sous leur forme positionnelle (§ 8.9) :
// la proposition du moteur mise en forme de fichier, et le plan du moteur
// qu'on en tire ; le contrôle de cohérence d'une proposition ou du retenu ;
// leur dérive contre la charge courante ; leur forme nommée ; le partage
// d'une charge lue entre propositions gardées et écartées. Les formes sont
// décrites dans types.js et dans ce module.
//
// Fautif et périmé sont deux choses (§ 8.9, point 3). Un placement fautif se
// contredit lui-même : fautes nomme chaque contradiction, et examiner écarte
// la proposition fautive de la charge sans refuser le fichier ; chacune tombe
// seule. examiner compare aussi les identifiants des propositions entre eux
// et à prochainsIds.proposition, que l'analyse du fichier ne lit pas, et
// rend ce compteur relevé au-delà de chaque identifiant lu : aucun ne se
// réattribue. Un placement cohérent qui ne décrit plus la configuration
// courante est périmé : derive dit pourquoi, et examiner le garde (§ 9). Le
// retenu fautif est signalé et gardé : sa reprise par le .precedent
// appartient au dépôt (§ 8.8).
//
// La forme d'une proposition et du retenu suit les règles de SCHEMA, le
// schéma du fichier d'état, dont l'analyse n'applique que celle du
// conteneur : fautes applique les autres, avant toute cohérence, par le
// parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa
// manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit
// l'horloge ni un aléa.
import { SCHEMA, capacite, premiereFaute } from './document.js';
/**
* Une faute d'un placement ; types.js en donne la table des codes. FORME :
* une valeur sort de sa règle, ou la déclaration se contredit ; chemin
* désigne l'élément fautif dans le placement, à la manière de l'analyse du
* fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite
* contredit celle que le placement déclare ; tour et table null désignent le
* nombre de tours, table null seule le nombre de listes du tour. DOUBLON,
* INCONNU, MANQUANT : un identifiant écrit plus d'une fois, écrit sans être
* déclaré, déclaré sans être écrit, dans les listes de table et la réserve
* d'un tour. Les tours se numérotent à partir de 1, tables et participants
* par identifiant. Ces quatre codes sont des contradictions internes, que
* fautes nomme. IDENTIFIANT_REPETE : une proposition cohérente porte
* l'identifiant d'une proposition gardée avant elle.
* IDENTIFIANT_HORS_COMPTEUR : une proposition cohérente, ou le retenu par sa
* proposition d'origine, porte un identifiant qui atteint
* prochainsIds.proposition. examiner seul nomme ces deux-là, en comparant
* les propositions entre elles et à la charge.
*
* @typedef {{code: 'FORME', chemin: string}
* | {code: 'LONGUEUR', tour: number|null, table: number|null, declare: number, ecrit: number}
* | {code: 'DOUBLON'|'INCONNU'|'MANQUANT', tour: number, participant: number}
* | {code: 'IDENTIFIANT_REPETE'|'IDENTIFIANT_HORS_COMPTEUR'}} Faute
*
* Une raison de dérive d'un placement cohérent contre la charge courante.
*
* @typedef {{code: 'PARTICIPANT_EXCLU'|'PARTICIPANT_SUPPRIME'|'PARTICIPANT_NON_PLACE', participant: number}
* | {code: 'TABLE_SUPPRIMEE'|'TABLE_AJOUTEE', table: number}
* | {code: 'CAPACITE_CHANGEE', table: number, avant: number, maintenant: number}
* | {code: 'TOURS_CHANGES', avant: number, maintenant: number}} Raison
*
* Une ligne de la forme nommée : une personne placée à un tour.
*
* @typedef {Object} Ligne
* @property {number} tour
* @property {number|null} table identifiant ; null pour la réserve
* @property {number|null} numero numéro affiché de la table dans la
* charge ; null pour la réserve et pour
* une table que la charge n'a plus
* @property {number|null} siege rang dans la liste, à partir de 1, quand
* les sièges sont attribués ; null sinon
* @property {number} participant
* @property {string|null} nom null pour une personne que la charge
* n'a plus
* @property {string|null} prenom
*
* Ce que rend examiner.
*
* @typedef {Object} Examen
* @property {import('./types.js').Charge} charge sans les propositions
* fautives, son compteur des propositions relevé au-delà de chaque
* identifiant lu
* @property {Array<{rang: number, id: number|null, fautes: Faute[]}>} fautives
* par rang dans la liste lue ; id null quand il ne se lit pas
* @property {Array<{id: number, raisons: Raison[]}>} derives par identifiant
* @property {{fautes: Faute[], raisons: Raison[]}} retenu
*/
// Règle du champ cle d'un objet du schéma.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
// Règles d'une proposition et du retenu. Aucune des valeurs qu'elles
// contiennent n'admet null ; examiner traite le retenu nul, son absence,
// avant de lire le retenu.
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const RETENU = regleDuChamp(CHARGE, 'retenu');
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
const croissant = (a, b) => a - b;
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Chemin du second exemplaire du premier identifiant que liste porte deux
// fois, ou null.
function doublon(liste, nom) {
const vus = new Set();
for (let rang = 0; rang < liste.length; rang += 1) {
if (vus.has(liste[rang])) return `${nom}[${rang}]`;
vus.add(liste[rang]);
}
return null;
}
// Chemin de la première contradiction de la déclaration d'un placement de
// forme conforme, ou null, dans l'ordre du schéma : une table déclarée deux
// fois ; des capacités qui ne correspondent pas aux tables rang à rang ; un
// participant déclaré deux fois. D'un doublon, le second est nommé, comme
// l'analyse le fait des identifiants de la charge.
function fauteDeDeclaration({ tables, capacites, participants }) {
return (
doublon(tables, 'tables')
?? (capacites.length === tables.length ? null : 'capacites')
?? doublon(participants, 'participants')
);
}
// Fautes de cohérence d'un placement de forme et de déclaration conformes
// (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui
// déclaré. Puis, tour après tour : le nombre de listes de table contre celui
// des tables déclarées ; la longueur de chaque liste appariée à une table
// déclarée, dans l'ordre déclaré, contre sa capacité ; le multiensemble du
// tour, listes de table et réserve comprises, où chaque identifiant déclaré
// figure exactement une fois — doublons, inconnus, puis manquants, chacun
// nommé une fois par tour, par identifiant croissant.
//
// La capacité borne une liste sans la fixer : quand la salle offre plus de
// sièges que de personnes, des tables gardent des places vides, et une liste
// plus courte que sa capacité n'est pas fautive. Un décalage qui remplit une
// table au-delà de sa capacité l'est.
//
// Les occurrences d'un tour se comptent dans un Map ; aucune décision ne
// suit son ordre : doublons et manquants se lisent dans l'ordre des
// identifiants déclarés, triés, et les inconnus se trient avant d'être
// nommés.
function fautesDeCoherence({ tables, capacites, tours, participants, placement }) {
const liste = [];
if (placement.length !== tours) {
liste.push({ code: 'LONGUEUR', tour: null, table: null, declare: tours, ecrit: placement.length });
}
const declares = [...participants].sort(croissant);
const estDeclare = new Set(declares);
placement.forEach(({ sieges, reserve }, rang) => {
const tour = rang + 1;
if (sieges.length !== tables.length) {
liste.push({ code: 'LONGUEUR', tour, table: null, declare: tables.length, ecrit: sieges.length });
}
const appariees = Math.min(sieges.length, tables.length);
for (let i = 0; i < appariees; i += 1) {
if (sieges[i].length > capacites[i]) {
liste.push({ code: 'LONGUEUR', tour, table: tables[i], declare: capacites[i], ecrit: sieges[i].length });
}
}
const occurrences = new Map();
const inconnus = [];
for (const ids of [...sieges, reserve]) {
for (const id of ids) {
const deja = occurrences.get(id) ?? 0;
occurrences.set(id, deja + 1);
if (deja === 0 && !estDeclare.has(id)) inconnus.push(id);
}
}
for (const id of declares) {
if (occurrences.get(id) > 1) liste.push({ code: 'DOUBLON', tour, participant: id });
}
for (const id of inconnus.sort(croissant)) liste.push({ code: 'INCONNU', tour, participant: id });
for (const id of declares) {
if (!occurrences.has(id)) liste.push({ code: 'MANQUANT', tour, participant: id });
}
});
return liste;
}
// Fautes de valeur sous regle : la première faute de forme ou de
// déclaration seule, qui rend la cohérence illisible ; sinon les fautes de
// cohérence.
function fautesSelon(valeur, regle) {
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur);
return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }];
}
/**
* Fautes internes d'un placement, proposition ou retenu (§ 8.9, points 1 et
* 2) : il se compare à lui-même, jamais à la charge. La règle est celle du
* retenu quand p porte la clé proposition, celle d'une proposition sinon.
*
* La forme se contrôle d'abord, dans l'ordre du schéma, puis la
* déclaration : la première faute de l'une ou de l'autre est seule rendue,
* { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables :
* { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le
* nombre de tours, rendu en premier ; table null pour le nombre de listes
* d'un tour ; une table pour une liste plus longue que sa capacité — puis,
* dans chaque tour, { code: 'DOUBLON'|'INCONNU'|'MANQUANT', tour,
* participant } par identifiant croissant. Rend [] pour un placement
* cohérent ; ne lève jamais.
*
* Une permutation dans une table, ou l'échange du contenu de deux tables de
* même capacité, gardent longueurs et multiensemble : rien dans le fichier
* ne les distingue d'un placement légitime, et fautes rend [].
*
* @param {*} p
* @returns {Faute[]}
*/
export function fautes(p) {
const retenu = typeof p === 'object' && p !== null && Object.hasOwn(p, 'proposition');
return fautesSelon(p, retenu ? RETENU : PROPOSITION);
}
// Lève TypeError, au nom de fonction, quand p n'est pas un placement
// cohérent : le lire rendrait sans le dire une lecture fausse.
function exigerCoherent(p, fonction) {
const [premiere] = fautes(p);
if (premiere !== undefined) throw new TypeError(`${fonction} : placement fautif ${JSON.stringify(premiere)}`);
}
/**
* Forme de fichier d'une proposition du moteur (§ 8.9). Son identifiant est
* decalage + proposition.id : la place de la proposition dans la suite des
* graines dérivées de sa génération (§ 5.7), que le moteur numérote à partir
* de 1, décalée au-delà de tout identifiant de proposition jamais attribué.
* L'appelant passe pour decalage prochainsIds.proposition − 1, ce compteur
* lu dans la charge qu'examiner rend, puis porte le compteur au-delà du
* dernier identifiant écrit. examiner le relève au-delà de chaque
* identifiant que le fichier porte, propositions écartées et origine du
* retenu comprises : ce compteur dépasse les identifiants de la liste, celui
* de la proposition d'origine du retenu et ceux qu'un effacement a retirés,
* et ne recule jamais, même abaissé à la main dans le fichier. Les
* générations s'accumulent ainsi (§ 5.7) sans répéter ni réattribuer un
* identifiant, et examiner, qui écarte à l'ouverture la seconde proposition
* d'un identifiant répété, les garde toutes.
*
* Elle porte ensuite graine, arret et historique de la proposition,
* produitVersion, et siegesAttribues, qui vaut attribuerSieges au moment où
* elle est produite ; puis ce que la configuration déclare : les tables du
* plan, dans l'ordre de ses listes, chacune avec sa capacité dans la
* configuration, lue par identifiant ; le nombre de tours ; les participants
* non exclus, par identifiant croissant. Le placement reprend le plan tour
* par tour : sans attribution de sièges, chaque liste de table se trie par
* identifiant croissant ; avec, elle garde son ordre, celui des sièges. La
* réserve, un ensemble, se trie dans les deux cas. Les clés suivent l'ordre
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
* modifiés.
*
* Lève TypeError, sans rien rendre, quand decalage n'est pas un entier ≥ 0,
* ou quand la forme produite serait fautive : attribuerSieges qui n'est pas
* un booléen, version vide ou absente, réglage hors de son domaine, plan que
* la configuration contredit — table inconnue d'elle, nombre de tours,
* personne non placée, liste au-delà d'une capacité.
*
* @param {import('../moteur/recherche.js').Proposition} proposition
* @param {import('../moteur/types.js').Configuration} configuration
* @param {{produitVersion: string, attribuerSieges: boolean, decalage: number}} options
* @returns {import('./types.js').PropositionFichier}
*/
export function versFichier(proposition, configuration, { produitVersion, attribuerSieges, decalage }) {
// Le décalage se contrôle avant la forme : négatif, il peut rendre un
// identifiant que la forme admet et qu'une autre proposition porte déjà.
if (!Number.isSafeInteger(decalage) || decalage < 0) {
throw new TypeError(`versFichier : décalage entier ≥ 0 attendu, reçu ${JSON.stringify(decalage)}`);
}
const { plan } = proposition;
const capaciteDe = new Map(configuration.tables.map(({ id, capacite: places }) => [id, places]));
const ordonner = (liste) => (attribuerSieges ? [...liste] : [...liste].sort(croissant));
const fichier = {
id: decalage + proposition.id,
graine: proposition.graine,
arret: proposition.arret,
historique: proposition.historique,
produitVersion,
siegesAttribues: attribuerSieges,
tables: [...plan.tables],
capacites: plan.tables.map((id) => capaciteDe.get(id)),
tours: configuration.tours,
participants: configuration.participants
.filter(({ exclu }) => exclu !== true)
.map(({ id }) => id)
.sort(croissant),
placement: plan.tours.map((listes, rang) => ({
sieges: listes.map((liste) => ordonner(liste)),
reserve: [...plan.reserves[rang]].sort(croissant),
})),
};
exigerCoherent(fichier, 'versFichier');
return fichier;
}
/**
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
* tables dans l'ordre déclaré, chaque liste de table et chaque réserve par
* identifiant croissant, comme le moteur les rend ; l'ordre des sièges n'y
* entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
* n'est pas cohérent (fautes).
*
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
* @returns {import('../moteur/types.js').Plan}
*/
export function planDepuisFichier(p) {
exigerCoherent(p, 'planDepuisFichier');
return {
tables: [...p.tables],
tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))),
reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)),
};
}
// Code, comparé unité UTF-16 par unité, puis identifiant du participant ou
// de la table que la raison désigne ; TOURS_CHANGES n'en désigne aucun et
// n'apparaît qu'une fois.
const ordreDesRaisons = (a, b) =>
comparerTextes(a.code, b.code) || (a.participant ?? a.table ?? 0) - (b.participant ?? b.table ?? 0);
// Raisons de dérive d'un placement cohérent, sans contrôle : derive les
// rend après l'avoir contrôlé, examiner après l'avoir fait lui-même.
function raisonsDeDerive({ tables, capacites, tours, participants }, charge) {
const raisons = [];
const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne]));
const places = new Set(participants);
for (const id of participants) {
const personne = personneDe.get(id);
if (personne === undefined) raisons.push({ code: 'PARTICIPANT_SUPPRIME', participant: id });
else if (personne.exclu) raisons.push({ code: 'PARTICIPANT_EXCLU', participant: id });
}
for (const { id, exclu } of charge.participants) {
if (!exclu && !places.has(id)) raisons.push({ code: 'PARTICIPANT_NON_PLACE', participant: id });
}
const tableDe = new Map(charge.tables.map((table) => [table.id, table]));
tables.forEach((id, rang) => {
const table = tableDe.get(id);
if (table === undefined) {
raisons.push({ code: 'TABLE_SUPPRIMEE', table: id });
return;
}
const maintenant = capacite(charge, table);
if (maintenant !== capacites[rang]) {
raisons.push({ code: 'CAPACITE_CHANGEE', table: id, avant: capacites[rang], maintenant });
}
});
const declarees = new Set(tables);
for (const { id } of charge.tables) {
if (!declarees.has(id)) raisons.push({ code: 'TABLE_AJOUTEE', table: id });
}
if (tours !== charge.evenement.tours) {
raisons.push({ code: 'TOURS_CHANGES', avant: tours, maintenant: charge.evenement.tours });
}
return raisons.sort(ordreDesRaisons);
}
/**
* Raisons de dérive d'un placement cohérent, proposition ou retenu, contre
* la charge courante (§ 8.9, point 3 ; § 9) : ce qu'il déclare comparé à la
* configuration d'aujourd'hui. Une personne qu'il place et que la charge
* exclut (§ 4.4), ou n'a plus : PARTICIPANT_EXCLU, PARTICIPANT_SUPPRIME
* {participant}. Une personne non exclue qu'il ne place pas, ajoutée ou
* réintégrée depuis : PARTICIPANT_NON_PLACE {participant}. Une table qu'il
* déclare et que la charge n'a plus, ou une table de la charge qu'il ne
* déclare pas : TABLE_SUPPRIMEE, TABLE_AJOUTEE {table}. Une capacité
* déclarée qui n'est plus la capacité courante de sa table :
* CAPACITE_CHANGEE {table, avant, maintenant}. Un nombre de tours qui n'est
* plus celui de l'événement : TOURS_CHANGES {avant, maintenant}.
*
* Triées par code, unité UTF-16 par unité, puis par identifiant ; [] quand
* le placement décrit la configuration courante : son plan passe alors
* indexerPlan du moteur sur elle. Noms, appartenances, numéros, positions,
* formes de table et réglages n'entrent pas dans la dérive. Lève TypeError
* quand p n'est pas cohérent (fautes).
*
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
* @param {import('./types.js').Charge} charge
* @returns {Raison[]}
*/
export function derive(p, charge) {
exigerCoherent(p, 'derive');
return raisonsDeDerive(p, charge);
}
// Ordre d'affichage des tables : numéro croissant, une table absente de la
// charge — numéro null — après les autres ; à numéro égal, identifiant
// croissant.
const ordreDesTables = (a, b) =>
(a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id;
/**
* Forme nommée d'un placement cohérent, proposition ou retenu (§ 8.9,
* point 4) : une ligne par personne placée à un tour, { tour, table,
* numero, siege, participant, nom, prenom }, pour la lecture et le
* signalement d'un défaut. Les lignes se rangent par tour, puis par numéro
* de table — une table que la charge n'a plus vient après les autres, sans
* numéro, et l'identifiant départage deux numéros égaux —, puis par rang
* dans la liste ; la réserve suit les tables de son tour, table et numéro
* null. siege est le rang à partir de 1 quand le placement attribue les
* sièges, null sinon et dans la réserve. Nom et prénom viennent de la
* charge ; une personne qu'elle n'a plus les a null. Lève TypeError quand p
* n'est pas cohérent (fautes).
*
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
* @param {import('./types.js').Charge} charge
* @returns {Ligne[]}
*/
export function formeNommee(p, charge) {
exigerCoherent(p, 'formeNommee');
const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne]));
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
const tables = p.tables
.map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null }))
.sort(ordreDesTables);
const lignes = [];
const ajouter = (tour, table, numero, siege, participant) => {
const personne = personneDe.get(participant);
lignes.push({
tour,
table,
numero,
siege,
participant,
nom: personne?.nom ?? null,
prenom: personne?.prenom ?? null,
});
};
p.placement.forEach(({ sieges, reserve }, rangDuTour) => {
const tour = rangDuTour + 1;
for (const { id, rang, numero } of tables) {
sieges[rang].forEach((participant, position) => {
ajouter(tour, id, numero, p.siegesAttribues ? position + 1 : null, participant);
});
}
for (const participant of reserve) ajouter(tour, null, null, null, participant);
});
return lignes;
}
// valeur quand elle est un identifiant de proposition, null sinon.
const identifiantLisible = (valeur) => (premiereFaute(valeur, IDENTIFIANT) === null ? valeur : null);
// Compteur des propositions de la charge que rend examiner : celui de la
// charge, relevé au-delà de plusGrand, le plus grand identifiant lu, 0 quand
// aucun ne se lit ; il ne recule jamais. Le compteur est un identifiant, un
// entier exact : relevé, il s'arrête à 2^53 − 1, et un identifiant de cette
// valeur reste hors de lui.
const compteurReleve = (charge, plusGrand) =>
Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, Number.MAX_SAFE_INTEGER));
// Faute d'un identifiant de proposition que la charge a attribué au-delà de
// son compteur, ou [] : prochainsIds.proposition dépasse tout identifiant
// attribué (§ 5.7).
const fautesDuCompteur = (id, charge) =>
id >= charge.prochainsIds.proposition ? [{ code: 'IDENTIFIANT_HORS_COMPTEUR' }] : [];
// Fautes et raisons du retenu, examiné sous sa propre règle. Un retenu qui
// se contredit n'a que ses fautes ; un retenu cohérent a ses raisons de
// dérive, et la faute d'une proposition d'origine qui atteint le compteur ;
// un retenu nul n'a ni l'un ni l'autre.
function examinerRetenu(charge) {
const { retenu } = charge;
if (retenu === null) return { fautes: [], raisons: [] };
const liste = fautesSelon(retenu, RETENU);
if (liste.length > 0) return { fautes: liste, raisons: [] };
return { fautes: fautesDuCompteur(retenu.proposition, charge), raisons: raisonsDeDerive(retenu, charge) };
}
/**
* Partage les placements d'une charge lue (§ 8.9, point 3). L'analyse du
* fichier admet toute proposition et tout retenu ; examiner les contrôle.
*
* Chaque élément de la liste est examiné dans son ordre, sous la règle
* d'une proposition, même quand il porte la clé d'un retenu : fautif, il
* est écarté et nommé dans fautives, { rang, id, fautes }, rang étant sa
* place dans la liste et id son identifiant, null quand il n'en est pas un ;
* cohérente, elle est gardée, et ses raisons de dérive, s'il y en a, vont
* dans derives, { id, raisons }, rangées par identifiant. Une proposition
* cohérente qui porte l'identifiant d'une proposition déjà gardée est
* fautive, { code: 'IDENTIFIANT_REPETE' } ; celle dont l'identifiant atteint
* prochainsIds.proposition aussi, { code: 'IDENTIFIANT_HORS_COMPTEUR' } :
* l'identifiant d'une proposition est unique dans la charge rendue, et sous
* le compteur, et le fichier s'ouvre sans elle. Un compteur abaissé écarte
* ainsi les propositions qu'il ne couvre plus, qui se recalculent, sans
* fermer la saisie. Une proposition écartée n'en écarte aucune autre : une
* cohérente qui porte l'identifiant d'une fautive n'est pas une répétition,
* et une fautive se nomme par ses propres fautes.
*
* Le retenu n'est jamais retiré : retenu porte ses fautes, sous la règle du
* retenu. Cohérent, il porte aussi ses raisons de dérive, et pour faute
* { code: 'IDENTIFIANT_HORS_COMPTEUR' } quand sa proposition d'origine
* atteint prochainsIds.proposition. Un retenu nul n'a ni l'un ni l'autre.
*
* Ces fautes se jugent contre le compteur reçu. La charge rendue porte le
* compteur relevé au-delà de chaque identifiant lu : prochainsIds.proposition
* y vaut le plus grand du compteur reçu et du plus grand identifiant lu plus
* un — celui de chaque élément de la liste, gardé ou écarté, quelle que soit
* sa faute, et la proposition d'origine du retenu, fautif ou non ; une
* valeur qui n'est pas un identifiant n'y entre pas. Le compteur ne recule
* donc jamais, et une génération numérotée depuis lui, comme le dit
* versFichier, ne réattribue ni l'identifiant d'une proposition écartée ni
* celui de l'origine du retenu : le retenu ne désigne jamais une autre
* proposition que la sienne. Le compteur reste un entier exact, qui s'arrête
* à 2^53 − 1 ; un identifiant de cette valeur reste hors de lui, et
* IDENTIFIANT_HORS_COMPTEUR le nomme tant que le fichier le porte.
*
* Rend une charge neuve, dont propositions ne porte que les gardées, dans
* l'ordre de la liste, et prochainsIds un objet neuf, son compteur des
* propositions relevé ; tout le reste, propositions gardées et retenu
* compris, est celui de la charge reçue, qui n'est pas modifiée. Cette
* charge s'écrit et se compare telle quelle : chaque proposition y suit sa
* règle, et un retenu qui ne suit pas la sienne, canonique.js le recopie
* hors du schéma (retenuHorsDeSaRegle).
*
* @param {import('./types.js').Charge} charge
* @returns {Examen}
*/
export function examiner(charge) {
const gardees = [];
const fautives = [];
const derives = [];
const identifiants = new Set();
let plusGrand = identifiantLisible(charge.retenu?.proposition) ?? 0;
charge.propositions.forEach((proposition, rang) => {
const id = identifiantLisible(proposition?.id);
plusGrand = Math.max(plusGrand, id ?? 0);
let liste = fautesSelon(proposition, PROPOSITION);
if (liste.length === 0 && identifiants.has(proposition.id)) liste = [{ code: 'IDENTIFIANT_REPETE' }];
if (liste.length === 0) liste = fautesDuCompteur(proposition.id, charge);
if (liste.length > 0) {
fautives.push({ rang, id, fautes: liste });
return;
}
identifiants.add(proposition.id);
gardees.push(proposition);
const raisons = raisonsDeDerive(proposition, charge);
if (raisons.length > 0) derives.push({ id: proposition.id, raisons });
});
derives.sort((a, b) => a.id - b.id);
const prochainsIds = { ...charge.prochainsIds, proposition: compteurReleve(charge, plusGrand) };
return {
charge: { ...charge, prochainsIds, propositions: gardees },
fautives,
derives,
retenu: examinerRetenu(charge),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,161 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le système de fichiers, frontière de plateforme du stockage (§ 13.4). Ce
// module en déclare l'interface, que trois implémentations portent : celle de
// la coquille electron, livrée ; celle de la plateforme web, sur l'OPFS ; celle
// d'épreuve, en mémoire, qui sait tomber en panne (test/fichiers_simules.js).
// Toute fonction du stockage qui touche un fichier reçoit un SystemeFichiers
// en paramètre (§ 14.10) ; la suite de contrat test/contrat_fichiers.js
// éprouve les trois implémentations par les mêmes épreuves.
//
// Une primitive ne reçoit jamais de chemin absolu : une racine, connue par son
// identifiant, et un chemin relatif à elle, aux segments séparés par « / »,
// sans « .. » ni chemin absolu ; '' désigne la racine elle-même. Le chemin
// d'une racine sert à l'affichage, jamais à résoudre un fichier. Toutes les
// primitives sont asynchrones. Un échec lève une ErreurStockage :
//
// ABSENT {chemin} lecture d'un fichier, ou liste
// d'un dossier, qui n'existe pas
// ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
// renommage refusés ; la cible est
// intacte. chemin est le chemin
// relatif reçu, dossier le chemin
// affichable du dossier qui le
// porte — la racine elle-même pour
// '' —, cause celle que rapporte
// la plateforme (EBUSY, EROFS…)
// EXISTE {chemin} deplacer vers une cible existante
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
// inconnue
//
// types.js donne la table entière des codes du stockage.
import { ErreurStockage } from './erreurs.js';
/**
* Une racine : un dossier que l'implémentation connaît par son identifiant
* — portable, documents, ou un dossier choisi.
*
* @typedef {Object} Racine
* @property {string} id
* @property {string} chemin chemin absolu, affichable
*/
/**
* @typedef {Object} Emplacements
* @property {string|null} executable dossier publié par le lanceur portable
* @property {string[]} donneesApplicatives AppData, LocalAppData, temporaire, en absolu
* @property {boolean} insensibleCasse les chemins se comparent sans égard à la casse
* @property {'\\'|'/'} separateur
*/
/**
* Une entrée d'un dossier.
*
* @typedef {Object} Entree
* @property {string} nom
* @property {'fichier'|'dossier'} type
* @property {number} taille octets du fichier en UTF-8 ; 0 pour un dossier
* @property {number} modifie millisecondes, pour l'affichage seulement :
* aucune décision ne s'y fonde, puisque recopier un dossier suffit
* à les fausser (§ 14.10)
*/
/**
* Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon,
* la séance qui le tient, l'instant de sa prise, et vivant : vrai quand son
* processus tourne sur la même machine, faux quand il y est mort, null quand
* il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance,
* depuis et vivant à null.
*
* @typedef {{pris: true}
* | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou
*/
/**
* @typedef {Object} SystemeFichiers
* @property {'electron'|'web'|'epreuve'} nature
* @property {boolean} renommageAtomique faux sous web (§ 8.8)
* @property {boolean} verrouDisponible faux sous web (§ 8.8) : verrouiller
* rend alors { pris: true } à chaque appel, et deverrouiller n'efface rien
* @property {() => Promise<Emplacements>} emplacements
* @property {() => Promise<{portable: Racine|null, documents: Racine}>} racines
* portable : data/ à côté de l'exécutable publié ; documents : le
* dossier du produit dans les Documents (§ 8.6)
* @property {() => Promise<Racine|null>} choisirDossier dialogue natif ;
* null quand l'opérateur annule
* @property {(r: Racine) => Promise<{inscriptible: boolean, cause: string|null}>} sonder
* crée la racine, parents compris, efface un .gtt-temoin resté
* d'une séance précédente, écrit .gtt-temoin, le relit, l'efface
* (§ 8.6) ; un échec rend sa cause, sans lever
* @property {(r: Racine) => Promise<'amovible'|'fixe'|'inconnu'>} typeSupport
* @property {(r: Racine, chemin: string) => Promise<string>} lireTexte
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
* forme de normalisation comprises ; ABSENT pour un fichier absent
* ou un dossier
* @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où
* renommageAtomique est faux, l'écriture va droit sur la cible. Le
* dossier qui porte la cible doit exister : un dossier absent lève
* ECRITURE, car un dossier de travail disparu — un support retiré —
* ne se recrée pas en silence
* @property {(r: Racine, chemin: string, ligne: string) => Promise<void>} ajouterLigne
* ajoute la ligne puis une fin de ligne LF après le dernier octet du
* fichier, sans rien insérer avant, vidé sur le disque ; crée le
* fichier absent, jamais son dossier. La ligne ne porte aucune fin
* de ligne
* @property {(r: Racine, dossier: string) => Promise<Entree[]>} lister
* les entrées directes du dossier, triées par nom, unités UTF-16
* comparées une à une ; ABSENT pour un dossier absent
* @property {(r: Racine, chemin: string) => Promise<void>} creerDossier
* parents compris ; un dossier existant n'est pas une faute
* @property {(r: Racine, de: string, vers: string) => Promise<void>} deplacer
* un fichier ; refuse une cible existante (EXISTE), comparée comme le
* système compare les noms, si bien que sans égard à la casse une
* autre casse du même nom est une cible existante ; ABSENT pour une
* source absente ; le dossier de la cible doit exister
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
* un fichier ; un fichier absent n'est pas une faute
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
* création exclusive de {seance, pid, hote, depuis} : un verrou
* présent n'est jamais repris, fût-il de la même séance
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
* n'efface que le verrou de cette séance ; un verrou absent n'est
* pas une faute
* @property {(r: Racine) => Promise<void>} ouvrirDansExplorateur
* @property {() => Promise<{nom: string, octets: Uint8Array}|null>} choisirFichierAImporter
* null quand l'opérateur annule
* @property {(nomPropose: string, octets: Uint8Array) => Promise<string|null>} enregistrerSous
* le chemin choisi, ou null quand l'opérateur annule
*/
// Motif d'un segment que toute implémentation accepte. Un segment vide
// vient d'un chemin absolu (« /x »), d'un séparateur doublé ou final. Un
// segment qui finit par un point ou une espace comprend « . » et « .. », et
// ce que Windows ramène à eux ou à un autre nom en retirant points et espaces
// finaux : « ... », « .. » suivi d'une espace, « soiree. ». La barre oblique
// inverse sépare les segments sous Windows ; le deux-points y désigne un
// lecteur (« C:\x », « C:x ») ou un flux de données secondaire ; le
// caractère nul tronque un chemin là où la plateforme le lit en C.
const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u;
/**
* Segments d'un chemin relatif à une racine : [] pour '', la racine
* elle-même. Le contrôle ne lit que le texte du chemin ; il ne connaît ni la
* racine ni les liens, que résout chaque implémentation.
*
* @param {string} chemin
* @returns {string[]}
* @throws {ErreurStockage} CHEMIN_REFUSE {chemin} : autre chose qu'une chaîne,
* ou un segment que SEGMENT_ADMIS refuse
*/
export function exigerCheminRelatif(chemin) {
if (typeof chemin !== 'string') throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
if (chemin === '') return [];
const segments = chemin.split('/');
if (!segments.every((segment) => SEGMENT_ADMIS.test(segment))) {
throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
}
return segments;
}

View file

@ -0,0 +1,84 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du contrôle des chemins relatifs à une racine (§ 13.4, § 14.10) :
// exigerCheminRelatif, que partagent l'implémentation web et celle
// d'épreuve, rend les segments d'un chemin admis, et refuse par
// CHEMIN_REFUSE tout chemin absolu, remontant, ou que Windows lirait
// autrement. Le reste du module est l'interface en JSDoc, que la suite de
// contrat test/contrat_fichiers.js éprouve sur chaque implémentation. Les
// noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { ErreurStockage } from './erreurs.js';
import { exigerCheminRelatif } from './systeme_fichiers.js';
// Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
assert.fail('aucune erreur levée');
}
describe('exigerCheminRelatif', () => {
test("rend les segments d'un chemin relatif, et aucun pour la racine", () => {
const cas = [
['', []],
['soiree.gtt.json', ['soiree.gtt.json']],
[
'corbeille/2026-01-02_03-04-05/soiree.gtt.json',
['corbeille', '2026-01-02_03-04-05', 'soiree.gtt.json'],
],
['.gtt-temoin', ['.gtt-temoin']],
['..soiree', ['..soiree']],
[' devant', [' devant']],
['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json', ['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json']],
];
for (const [chemin, segments] of cas) assert.deepEqual(exigerCheminRelatif(chemin), segments, chemin);
});
test('refuse un chemin absolu, remontant, ou que Windows lirait autrement : CHEMIN_REFUSE {chemin}', () => {
const refuses = [
// absolus, ou porteurs d'un segment vide
'/x',
'//serveur/partage/x',
'a//b',
'a/',
// remontants ou désignant le dossier courant
'..',
'../x',
'a/../../x',
'.',
'./x',
'a/./b',
// ce que Windows ramène à eux, ou à un autre nom, en retirant points et
// espaces finaux
'...',
'a/.. /x',
'soiree.',
'soiree ',
// lecteur, partage, séparateur de Windows, flux de données secondaire
'C:\\x',
'C:x',
'C:/x',
'\\\\serveur\\partage\\x',
'a\\b',
'soiree.gtt.json:flux',
// caractère nul
'a\u{0}b',
// autre chose qu'une chaîne
null,
undefined,
42,
];
for (const chemin of refuses) {
const erreur = erreurDe(() => exigerCheminRelatif(chemin));
assert.ok(erreur instanceof ErreurStockage, `${String(chemin)} : ${erreur}`);
assert.equal(erreur.code, 'CHEMIN_REFUSE', String(chemin));
assert.deepEqual(erreur.details, { chemin }, String(chemin));
}
});
});

425
src/stockage/types.js Normal file
View file

@ -0,0 +1,425 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Contrat de données du stockage : le fichier d'état, le journal, les
// fichiers voisins d'un événement, les erreurs que lève le stockage et les
// codes des fautes de placement. Ce module ne porte que des définitions
// JSDoc et n'exécute rien ; un module cite un type par
// import('./types.js').Charge. Les valeurs que ces formes portent sont
// définies par leurs modules : FORMAT, GENERATION_PAR_DEFAUT et SCHEMA par
// le module du document, les constantes du journal par journal.js, les
// suffixes des fichiers par noms.js ; l'interface du système de fichiers
// est déclarée par systeme_fichiers.js.
//
// Les chaînes de saisie sont en NFC : la conversion se fait à l'entrée — CSV,
// commandes —, jamais à l'écriture. Une chaîne « non vide » compte au moins
// une unité ; un champ « chaîne ou null » porte null plutôt qu'une chaîne
// vide. Un entier est un entier exact, au plus 2^53 − 1.
/**
* Le fichier d'état, format 1 (§ 8.8, § 8.9). Deux régions : l'en-tête et la
* charge. Les comparaisons qui traversent les constructions — empreinte des
* démonstrations, aller-retour des fichiers livrés — portent sur la charge
* seule. Toute clé hors de ces formes est une faute de forme. Le texte est
* en UTF-8 sans marque d'ordre d'octets, fin de ligne LF, une seule fin de
* ligne finale, dans la forme canonique qu'écrit canonique.js.
*
* @typedef {Object} Fichier
* @property {Entete} entete
* @property {Charge} charge
*
* @typedef {Object} Entete
* @property {number} format FORMAT ; un format plus récent se lit et
* s'ouvre en lecture seule (§ 8.8)
* @property {string} produitVersion non vide : version affichée de la
* construction qui a écrit en dernier (§ 18.6)
* @property {number} revision entier ≥ 1 : révision de la dernière
* entrée de journal que l'état reflète
* @property {Comptes} comptes
*
* @typedef {Object} Comptes longueur de la liste de la charge du même nom
* @property {number} participants
* @property {number} tables
* @property {number} reservations
* @property {number} titres
* @property {number} propositions
* @property {number} retenu 0 ou 1
*
* @typedef {Object} Charge
* @property {Evenement} evenement
* @property {Reglages} reglages
* @property {ProchainsIds} prochainsIds
* @property {Participant[]} participants triés par id
* @property {Table[]} tables triées par id
* @property {Reservation[]} reservations triées par participant, table,
* portée (tous avant tour), tour puis
* siège, null en tête
* @property {Titre[]} titres triés par table, siège puis libellé
* @property {PropositionFichier[]} propositions triées par id, puis
* graine, arret, historique et
* texte canonique : la lecture du
* document n'en contrôle que la
* liste, et admet deux propositions
* de même id ; leur forme, leur
* cohérence et leurs identifiants se
* contrôlent à part (placements.js)
* @property {Retenu|null} retenu le placement en vigueur
*
* @typedef {Object} Evenement
* @property {string} id non vide : identifiant interne, qui
* apparie l'état et le journal (§ 8.6)
* @property {string} nom non vide : l'autorité, dont le nom de
* fichier dérive
* @property {string|null} date AAAA-MM-JJ d'un jour du calendrier
* @property {number} siegesParDefaut entier ≥ 2 (§ 6.1)
* @property {number} tours R, entier ≥ 1
* @property {'cm'} unite (§ 7.2)
* @property {'brouillon'|'propose'|'retenu'|'bloque'} etat (§ 9)
* @property {Filiation|null} filiation (§ 8.7)
*
* @typedef {Object} Filiation
* @property {{id: string, nom: string}} source chaînes non vides
* @property {{revision: number, libelle: string}} instant révision ≥ 1,
* libellé non vide
*
* @typedef {Object} Reglages
* @property {boolean} separerAppartenances
* @property {boolean} nouveauxVoisins
* @property {boolean} nouvelleTable
* @property {boolean} varierAppartenances
* @property {boolean} attribuerSieges le réglage que reçoit la prochaine
* génération ; il ne décide d'aucun
* tri : chaque proposition porte son
* propre siegesAttribues
* @property {{nombre: number, arret: number, historique: number}} generation
* entiers ≥ 1
*
* @typedef {Object} ProchainsIds entiers ≥ 1, au-delà de tout identifiant
* attribué ; ils ne reculent jamais, et un
* identifiant ne se réattribue pas (§ 4)
* @property {number} participant
* @property {number} table
* @property {number} proposition au-delà de chaque identifiant de
* proposition et de retenu.proposition ;
* unique à travers les générations
* accumulées, il ne revient pas après un
* effacement (§ 5.7). Le contrôle des
* placements le compare : une proposition
* qui l'atteint s'écarte, un retenu se
* signale (IDENTIFIANT_HORS_COMPTEUR) ; la
* charge qu'il rend le relève au-delà de
* chaque identifiant lu, écartées et
* origine du retenu comprises : abaissé à
* la main, il ne fait réattribuer aucun
* d'eux
*
* @typedef {Object} Participant
* @property {number} id entier ≥ 1, unique
* @property {string} nom non vide
* @property {string|null} prenom
* @property {string|null} appartenance
* @property {string|null} courriel
* @property {string|null} titrePressenti informatif (§ 4)
* @property {string|null} notes
* @property {boolean} exclu (§ 4.4)
*
* @typedef {Object} Table
* @property {number} id entier ≥ 1, unique
* @property {number} numero affiché, entier ≥ 1
* @property {number|null} sieges null : suit le défaut ; entier ≥ 2 :
* surcharge (§ 6.1)
* @property {'ronde'|'carree'} forme
* @property {{x: number, y: number}} position nombres finis, en cm
*
* @typedef {Object} Reservation
* @property {number} participant id d'un participant de la charge, exclu
* compris : sa réservation est suspendue
* @property {number} table id d'une table
* @property {number|null} siege de 1 à la capacité de la table, ou null
* @property {'tous'|'tour'} portee (§ 4.3)
* @property {number|null} tour null pour la portée tous, de 1 à R sinon
*
* @typedef {Object} Titre
* @property {number} table id d'une table
* @property {number} siege de 1 à la capacité de la table
* @property {string} libelle non vide
*/
/**
* Forme positionnelle d'un placement (§ 8.9). tables et capacites se
* correspondent rang à rang ; placement porte un tour par élément :
* sieges[i] liste les occupants de tables[i] dans l'ordre des sièges —
* triés par identifiant quand siegesAttribues est faux —, reserve ceux qui
* ne sont assis nulle part à ce tour. siegesAttribues appartient à la
* proposition, ou au retenu, et non au réglage courant : changer le réglage
* ne détruit pas l'ordre des sièges d'une proposition déjà produite.
* participants et chaque reserve sont des ensembles, triés par identifiant
* croissant quel que soit siegesAttribues.
*
* @typedef {Object} TourDePlacement
* @property {number[][]} sieges
* @property {number[]} reserve triée par identifiant croissant
*
* @typedef {Object} PropositionFichier
* @property {number} id entier ≥ 1 : place dans la suite des
* graines dérivées (§ 5.7)
* @property {number} graine graine dérivée, 0 ≤ g < 2^32
* @property {number} arret compte d'arrêt, ≥ 1
* @property {number} historique longueur de l'historique d'acceptation, ≥ 1
* @property {string} produitVersion construction qui l'a produite
* @property {boolean} siegesAttribues l'ordre dans une table est un numéro
* de siège ; faux : chaque liste de table
* se trie par identifiant croissant
* @property {number[]} tables identifiants de table, dans l'ordre des
* listes de chaque tour
* @property {number[]} capacites capacité de chacune
* @property {number} tours
* @property {number[]} participants identifiants qu'elle place, triés par
* identifiant croissant
* @property {TourDePlacement[]} placement
*
* @typedef {Object} Retenu le placement en vigueur ; une retouche à la
* main le modifie, jamais la proposition d'origine.
* Un retenu que cette forme refuse, la lecture
* l'admet et le contrôle des placements le garde
* en le signalant (§ 8.9, point 3) : canonique.js
* l'écrit compact à la clé retenu, ses clés
* rangées, ses listes dans l'ordre écrit
* @property {number} proposition id de la proposition d'origine, sous
* prochainsIds.proposition
* @property {boolean} siegesAttribues
* @property {number[]} tables
* @property {number[]} capacites
* @property {number} tours
* @property {number[]} participants triés par identifiant croissant
* @property {TourDePlacement[]} placement
*/
/**
* Ce que rend analyser.
*
* @typedef {Object} Lecture
* @property {Entete} entete
* @property {Charge} charge telle que le texte la porte ;
* réduite aux clés connues quand le
* format est plus récent
* @property {boolean} formatPlusRecent format > FORMAT : lecture seule
*/
/**
* Une règle de SCHEMA, qui décrit une valeur du fichier d'état.
*
* @typedef {Object} Regle
* @property {'objet'|'liste'|'chaine'|'entier'|'nombre'|'booleen'|'parmi'|'date'} genre
* chaine : non vide ; nombre : fini ; parmi : l'une de valeurs ;
* date : AAAA-MM-JJ d'un jour du calendrier
* @property {boolean} nul null est admis
* @property {Array<[string, Regle]>} [champs] objet : ses champs, dans
* l'ordre des clés du texte canonique
* @property {Set<string>} [cles] objet : les clés de ses champs
* @property {Regle} [element] liste : la règle de ses éléments
* @property {number} [min] entier : bornes incluses
* @property {number} [max]
* @property {string[]} [valeurs] parmi
* @property {'lignes'|'ouverte'} [mise] absente : la valeur s'écrit
* compacte. lignes : un champ, ou un élément, par ligne. ouverte :
* l'objet s'écrit compact sur la ligne qui l'ouvre, chaque champ
* selon sa propre mise
* @property {function(*, *): number} [tri] liste : ordre canonique de ses
* éléments, total, qui compare leurs copies canoniques
* @property {function(*, *): number} [triSansAttribution] liste : ordre
* canonique quand l'objet qui porte le placement a
* siegesAttribues faux
* @property {boolean} [aPart] la lecture du document n'en
* contrôle que le conteneur ; le contenu se contrôle à part
* (placements.js)
*/
/**
* Le journal, <base>.gtt-journal.jsonl (§ 8.2, § 8.3, § 8.6) : un objet JSON
* par ligne, clés dans l'ordre écrit ici, fin de ligne LF. La première ligne
* l'apparie à son état ; suivent des entrées et des jalons, dont l'ordre est
* celui des lignes, jamais celui des horodatages.
*
* @typedef {Object} LigneOuverture
* @property {'journal'} type
* @property {number} format
* @property {string} evenement id de l'événement
*
* @typedef {Object} LigneEntree
* @property {'entree'} type
* @property {number} revision la suivante, consécutive
* @property {string} libelle texte figé à l'écriture, jamais un code
* @property {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM, pour l'affichage
* @property {null|'defaire'|'refaire'|'revenir'} sens null pour un geste
* @property {number|null} retour révision visée par une entrée de retour
* @property {string|null} produitVersion non nul à la première entrée et à
* chaque entrée dont la construction diffère de la précédente
* @property {Charge|null} instantane la charge résultante, quand la
* révision ouvre un intervalle : la première entrée du journal,
* puis toutes les INTERVALLE_INSTANTANE révisions comptées depuis
* elle
* @property {Array<Object>|null} correctif sinon : le correctif depuis la
* charge de l'entrée précédente dans l'ordre des lignes
*
* @typedef {Object} LigneJalon nomme l'instant d'une révision ; ne change
* pas la charge, ne prend pas de révision
* @property {'jalon'} type
* @property {number} revision
* @property {string} nom
* @property {string} horodatage
*
* Fil courant (§ 8.3) : la position d'une entrée ordinaire est elle-même,
* celle d'une entrée de retour la position de l'entrée qu'elle vise. Le
* rejeu des lignes tient une position et une pile : une entrée ordinaire
* pose la position et vide la pile ; un retour defaire ou revenir empile la
* position courante et pose celle de sa cible ; un retour refaire dépile.
* Défaire vise la position de l'entrée qui précède, dans l'ordre des lignes,
* l'entrée de la position courante ; refaire vise le sommet de la pile, et
* n'existe que si elle n'est pas vide. Le fil courant est la chaîne des
* positions remontée depuis la position courante ; les autres entrées
* ordinaires forment des fils abandonnés.
*
* Lecture : la première ligne illisible — JSON invalide, forme fausse,
* révision non consécutive, ligne finale sans fin de ligne — arrête la
* lecture ; elle et tout ce qui la suit sont écartés et comptés (§ 8.6).
* Élagage : au-delà de PLAFOND_ENTREES entrées, le journal se réécrit à
* partir de la frontière d'instantané la plus récente qui en garde au moins
* PLANCHER_ENTREES ; les jalons des entrées retirées partent avec elles.
*/
/**
* Fichiers voisins d'un événement, nommés par un suffixe ajouté à sa base :
* .gtt.json, l'état ; .gtt.json.precedent, l'état d'avant le dernier geste
* (§ 8.8) ; .gtt-journal.jsonl, le journal ; .gtt.verrou, présent tant
* qu'une séance est en écriture ; .ecriture, ajouté au nom d'un fichier
* pendant son écriture atomique. Le dossier de travail porte aussi
* reglages_locaux.json (§ 8.5) et corbeille/AAAA-MM-JJ_HH-MM-SS/ (§ 8.7).
*
* @typedef {Object} ContenuVerrou contenu de .gtt.verrou
* @property {string} seance
* @property {number} pid
* @property {string} hote
* @property {string} depuis
*
* @typedef {Object} ReglagesLocaux reglages_locaux.json
* @property {number} format
* @property {null|'A4'|'Lettre'} papier
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
* triés par identifiant d'événement
*/
/**
* Codes d'une ErreurStockage, et ses détails :
*
* ABSENT {chemin} lecture d'un fichier qui
* n'existe pas
* ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
* renommage refusés ; la
* cible est intacte
* EXISTE {chemin} déplacer vers une cible
* existante
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine
* inconnue
* ETAT_ILLISIBLE DetailsIllisible un fichier d'état qui ne
* se lit pas
* CORRECTIF {rang} un correctif qui ne
* s'applique pas
* CHEMIN_TROP_LONG {racine} aucune base ne tient sous
* la borne (§ 8.6)
* IDENTIFIANT_PRESENT {id, base} importer un événement dont
* l'identifiant est déjà
* dans le dossier
* VERROU_PRIS {seance, depuis, vivant} passer en écriture quand
* une autre séance tient le
* verrou
* LECTURE_SEULE {raison} un geste sur un fichier
* d'un format plus récent,
* ou sur un plan bloqué
* ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal,
* l'état n'a pas pu s'écrire
* (§ 8.2)
* CORBEILLE_SATUREE {dossier} cent suppressions dans la
* même seconde : le rang de
* collision dépasserait les
* deux chiffres que la borne
* réserve (§ 8.7)
*
* @typedef {'ABSENT'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
* par analyser
* VIDE zéro octet, ou des blancs seulement
* JSON le texte n'est pas du JSON
* FORME type, clé manquante ou inconnue, valeur hors de son
* domaine, identifiant de participant ou de table en
* double ou atteignant prochainsIds, tour d'une
* réservation hors de sa portée ; des propositions et du
* retenu, seul le conteneur
* COMPTES un compte de l'en-tête contredit sa liste
* REFERENCE réservation ou titre vers une table ou une personne
* absente, siège au-delà de la capacité
* FORMAT_INCONNU format présent, mais non entier ou < 1
* FORMAT_PLUS_RECENT format > FORMAT : ses clés inconnues sont ignorées,
* et une faute qui serait FORME au format courant porte
* cette raison ; COMPTES et REFERENCE gardent la leur, une
* corruption quel que soit le format ; tout refus d'un tel
* fichier porte le format lu
*
* @typedef {Object} DetailsIllisible
* @property {string} base base de l'événement
* @property {RaisonIllisible} raison
* @property {string|null} chemin premier élément fautif, par exemple
* charge.participants[2].nom : clés séparées par un point, rangs
* entre crochets, une clé qui ne s'écrit pas comme un identifiant
* entre crochets en JSON ; '' pour la racine ; null quand rien ne se
* désigne (ABSENT, VIDE, JSON)
* @property {number} [format] le format lu quand il dépasse FORMAT :
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
* récent
* @property {{precedent: number|null, journal: number|null}} secours
* révision lisible du .precedent et dernier instant du journal ;
* null pour ce qui ne se lit pas
*
* analyser ne connaît que le texte : il lève {raison, chemin}, et format
* pour un fichier plus récent, que le dépôt complète de base et de secours.
*/
/**
* Codes d'une faute de placement, que rendent fautes et examiner
* (placements.js, Faute), et ses détails. Une proposition fautive s'écarte
* à l'ouverture, et le fichier s'ouvre sans elle ; un retenu fautif est
* signalé et gardé (§ 8.9, point 3). Les raisons de dérive d'un placement
* cohérent sont décrites avec derive (placements.js, Raison).
*
* FORME {chemin} une valeur sort de sa règle, ou la
* déclaration se contredit ; chemin
* dans le placement, '' pour lui-même
* LONGUEUR {tour, table, une longueur écrite contredit celle
* declare, ecrit} que le placement déclare : nombre de
* tours (tour et table null), de listes
* d'un tour (table null), liste d'une
* table au-delà de sa capacité
* DOUBLON {tour, un identifiant écrit plus d'une fois
* participant} dans un tour, réserve comprise
* INCONNU {tour, un identifiant écrit sans être
* participant} déclaré
* MANQUANT {tour, un identifiant déclaré sans être
* participant} écrit
* IDENTIFIANT_REPETE {} une proposition cohérente porte
* l'identifiant d'une proposition
* gardée avant elle dans la liste ;
* examiner seul la nomme, et l'entrée
* de fautives porte son rang et son id
* IDENTIFIANT_HORS_COMPTEUR
* {} une proposition cohérente porte un
* identifiant qui atteint
* prochainsIds.proposition, ou le
* retenu cohérent une proposition
* d'origine qui l'atteint ; examiner
* seul la nomme : la proposition
* s'écarte, le retenu reste et garde
* ses raisons de dérive ; la charge
* qu'examiner rend relève le compteur
* au-delà de l'identifiant, qui ne se
* réattribue pas
*/

314
test/contrat_fichiers.js Normal file
View file

@ -0,0 +1,314 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Suite de contrat du système de fichiers (§ 13.4, § 14.10) : ce que les trois
// implémentations de l'interface de src/stockage/systeme_fichiers.js —
// electron, web et celle d'épreuve — promettent pareillement au stockage. La
// suite ne connaît que l'interface, et reçoit de l'appelant son lanceur et
// ses assertions : elle se joue sous node avec node:assert comme dans le
// navigateur avec expect.
//
// describe(nom, corps), test(nom, corps) ceux du lanceur de l'appelant
// egal(reel, attendu, message?) égalité profonde et stricte
// vrai(valeur, message?) valeur vraie
// rejette(promesse, message?) échoue quand la promesse se
// résout ; rend la raison du rejet
//
// Chaque épreuve demande à fabrique() un système neuf et une racine qui
// existe et ne porte rien : aucune ne voit ce qu'une autre a écrit. Les
// pannes — renommage refusé, support qui refuse l'écriture, processus coupé —
// ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté
// d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la
// suite exige du verrou ce que la plateforme promet à la place : verrouiller
// rend { pris: true } à chaque appel, et deverrouiller ne lève pas.
import { ErreurStockage } from '../src/stockage/erreurs.js';
// Les primitives de l'interface, dans l'ordre où elle les déclare.
const PRIMITIVES = [
'emplacements',
'racines',
'choisirDossier',
'sonder',
'typeSupport',
'lireTexte',
'ecrireAtomique',
'ajouterLigne',
'lister',
'creerDossier',
'deplacer',
'supprimer',
'verrouiller',
'deverrouiller',
'ouvrirDansExplorateur',
'choisirFichierAImporter',
'enregistrerSous',
];
// Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix,
// absolu sous Windows, remontant après un détour.
const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x'];
// Les primitives qui reçoivent un chemin, chacune appelée avec le chemin
// donné à la place qu'elle éprouve. deplacer s'éprouve par sa source, vers
// une cible absente, puis par sa cible, depuis ici.txt, que l'épreuve pose :
// seul le chemin éprouvé peut alors faire échouer l'appel.
const APPELS_A_CHEMIN = [
['lireTexte', (fs, racine, chemin) => fs.lireTexte(racine, chemin)],
['ecrireAtomique', (fs, racine, chemin) => fs.ecrireAtomique(racine, chemin, 'texte')],
['ajouterLigne', (fs, racine, chemin) => fs.ajouterLigne(racine, chemin, 'ligne')],
['lister', (fs, racine, chemin) => fs.lister(racine, chemin)],
['creerDossier', (fs, racine, chemin) => fs.creerDossier(racine, chemin)],
['deplacer, source', (fs, racine, chemin) => fs.deplacer(racine, chemin, 'ailleurs.txt')],
['deplacer, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)],
['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)],
['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a')],
['deverrouiller', (fs, racine, chemin) => fs.deverrouiller(racine, chemin, 'seance-a')],
];
// Vrai pour la description d'une racine : un identifiant et un chemin
// affichable, chaînes non vides.
const estRacine = (racine) =>
racine !== null &&
typeof racine === 'object' &&
typeof racine.id === 'string' &&
racine.id !== '' &&
typeof racine.chemin === 'string' &&
racine.chemin !== '';
// Noms des entrées d'un dossier, dans l'ordre rendu.
const noms = (entrees) => entrees.map((entree) => entree.nom);
/**
* Enregistre la suite de contrat d'une implémentation.
*
* @param {string} nom nom de l'implémentation, repris dans celui de la suite
* @param {() => Promise<{fs: import('../src/stockage/systeme_fichiers.js').SystemeFichiers,
* racine: import('../src/stockage/systeme_fichiers.js').Racine}>} fabrique
* rend à chaque appel un système neuf, et une racine qui existe et ne
* porte rien
* @param {Object} outils describe, test, egal, vrai, rejette, décrits en tête
*/
export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rejette }) {
// Attend le rejet d'une ErreurStockage de ce code et, quand ils sont
// donnés, de ces détails exactement ; rend l'erreur.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
vrai(erreur instanceof ErreurStockage, `${message} : ${erreur}`);
egal(erreur.code, code, message);
if (details !== undefined) egal(erreur.details, details, message);
return erreur;
}
// Attend un refus d'écriture : ECRITURE, dont les détails nomment l'un des
// chemins admis, le dossier et la cause, chaînes non vides.
async function refuseEcriture(promesse, ...chemins) {
const { details } = await echoue(promesse, 'ECRITURE', undefined, chemins[0]);
vrai(chemins.includes(details.chemin), `chemin ${details.chemin}`);
vrai(typeof details.dossier === 'string' && details.dossier !== '', `dossier ${details.dossier}`);
vrai(typeof details.cause === 'string' && details.cause !== '', `cause ${details.cause}`);
}
describe(`contrat du système de fichiers : ${nom}`, () => {
test("l'interface porte sa nature, ses deux garanties et ses dix-sept primitives, et décrit ses emplacements et ses racines", async () => {
const { fs, racine } = await fabrique();
vrai(['electron', 'web', 'epreuve'].includes(fs.nature), `nature ${fs.nature}`);
egal(typeof fs.renommageAtomique, 'boolean', 'renommageAtomique');
egal(typeof fs.verrouDisponible, 'boolean', 'verrouDisponible');
for (const primitive of PRIMITIVES) egal(typeof fs[primitive], 'function', primitive);
const emplacements = await fs.emplacements();
vrai(emplacements.executable === null || typeof emplacements.executable === 'string', 'executable');
vrai(
Array.isArray(emplacements.donneesApplicatives) &&
emplacements.donneesApplicatives.every((chemin) => typeof chemin === 'string'),
'donneesApplicatives',
);
egal(typeof emplacements.insensibleCasse, 'boolean', 'insensibleCasse');
vrai(emplacements.separateur === '\\' || emplacements.separateur === '/', 'separateur');
const racines = await fs.racines();
vrai(estRacine(racines.documents), 'racine documents');
vrai(racines.portable === null || estRacine(racines.portable), 'racine portable');
vrai(['amovible', 'fixe', 'inconnu'].includes(await fs.typeSupport(racine)), 'typeSupport');
});
test("lireTexte rend le texte écrit tel quel : marque d'ordre d'octets, accents composés ou non, CRLF, caractère hors du plan de base", async () => {
const { fs, racine } = await fabrique();
const texte = '\u{FEFF}Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}\r\n\u{C9}lan \u{AB} \u{F4} \u{BB} e\u{301}\rfin\n\u{1D11E}';
const chemin = 'Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.txt';
await fs.ecrireAtomique(racine, chemin, texte);
egal(await fs.lireTexte(racine, chemin), texte);
});
test('ecrireAtomique remplace le contenu entier et ne laisse aucun fichier .ecriture', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'etat.json', 'premier contenu, le plus long des deux');
await fs.ecrireAtomique(racine, 'etat.json', 'second');
egal(await fs.lireTexte(racine, 'etat.json'), 'second');
egal(noms(await fs.lister(racine, '')), ['etat.json']);
});
test('ajouterLigne ajoute la ligne et une fin de ligne LF après le dernier octet, et crée le fichier absent', async () => {
const { fs, racine } = await fabrique();
await fs.ajouterLigne(racine, 'journal.jsonl', '{"type":"journal"}');
egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n');
await fs.ajouterLigne(racine, 'journal.jsonl', '{"revision":1}');
egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n{"revision":1}\n');
// Une dernière ligne sans fin de ligne reste telle : l'ajout la prolonge.
await fs.ecrireAtomique(racine, 'tronque.jsonl', '{"type":"jour');
await fs.ajouterLigne(racine, 'tronque.jsonl', 'nal"}');
egal(await fs.lireTexte(racine, 'tronque.jsonl'), '{"type":"journal"}\n');
});
test("lister rend les entrées directes du dossier, triées par unités UTF-16, typées, la taille d'un fichier en octets UTF-8 et 0 pour un dossier", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'dossier');
await fs.ecrireAtomique(racine, 'dossier/interne.txt', 'x');
await fs.ecrireAtomique(racine, 'b.txt', 'bb');
await fs.ecrireAtomique(racine, '\u{E9}.txt', '\u{E9}');
await fs.ecrireAtomique(racine, 'a.txt', '');
await fs.ecrireAtomique(racine, 'Z.txt', '\u{1D11E}');
const entrees = await fs.lister(racine, '');
egal(
entrees.map(({ nom: nomEntree, type, taille }) => ({ nom: nomEntree, type, taille })),
[
{ nom: 'Z.txt', type: 'fichier', taille: 4 },
{ nom: 'a.txt', type: 'fichier', taille: 0 },
{ nom: 'b.txt', type: 'fichier', taille: 2 },
{ nom: 'dossier', type: 'dossier', taille: 0 },
{ nom: '\u{E9}.txt', type: 'fichier', taille: 2 },
],
);
vrai(entrees.every((entree) => Number.isFinite(entree.modifie)), 'modifie');
egal(noms(await fs.lister(racine, 'dossier')), ['interne.txt']);
});
test("creerDossier crée les dossiers parents, et un dossier existant n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
egal(
(await fs.lister(racine, '')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })),
[{ nom: 'corbeille', type: 'dossier' }],
);
egal(
(await fs.lister(racine, 'corbeille')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })),
[{ nom: '2026-01-02_03-04-05', type: 'dossier' }],
);
egal(await fs.lister(racine, 'corbeille/2026-01-02_03-04-05'), []);
await fs.creerDossier(racine, 'corbeille');
egal(noms(await fs.lister(racine, 'corbeille')), ['2026-01-02_03-04-05']);
});
test("deplacer porte le fichier sous son nouveau chemin et retire l'ancien", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'corbeille');
await fs.ecrireAtomique(racine, 'soiree.gtt.json', '\u{E9}tat');
await fs.deplacer(racine, 'soiree.gtt.json', 'corbeille/soiree.gtt.json');
egal(await fs.lireTexte(racine, 'corbeille/soiree.gtt.json'), '\u{E9}tat');
egal(noms(await fs.lister(racine, '')), ['corbeille']);
});
test('deplacer refuse une cible existante (EXISTE) et laisse les deux fichiers intacts', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'source.txt', 'source');
await fs.ecrireAtomique(racine, 'cible.txt', 'cible');
await echoue(fs.deplacer(racine, 'source.txt', 'cible.txt'), 'EXISTE', { chemin: 'cible.txt' });
egal(await fs.lireTexte(racine, 'source.txt'), 'source');
egal(await fs.lireTexte(racine, 'cible.txt'), 'cible');
});
test('deplacer une source absente lève ABSENT', async () => {
const { fs, racine } = await fabrique();
await echoue(fs.deplacer(racine, 'absent.txt', 'ailleurs.txt'), 'ABSENT', { chemin: 'absent.txt' });
egal(await fs.lister(racine, ''), []);
});
test("lireTexte d'un fichier absent et lister d'un dossier absent lèvent ABSENT", async () => {
const { fs, racine } = await fabrique();
await echoue(fs.lireTexte(racine, 'absent.gtt.json'), 'ABSENT', { chemin: 'absent.gtt.json' });
await echoue(fs.lister(racine, 'absent'), 'ABSENT', { chemin: 'absent' });
});
test('écrire, ajouter ou déplacer dans un dossier absent lève ECRITURE, sans créer le dossier', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
await refuseEcriture(fs.ecrireAtomique(racine, 'disparu/etat.json', 'x'), 'disparu/etat.json');
await refuseEcriture(fs.ajouterLigne(racine, 'disparu/journal.jsonl', 'x'), 'disparu/journal.jsonl');
await refuseEcriture(fs.deplacer(racine, 'ici.txt', 'disparu/ici.txt'), 'disparu/ici.txt', 'ici.txt');
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test("supprimer retire le fichier, et supprimer un absent n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'garde.txt', 'garde');
await fs.ecrireAtomique(racine, 'retire.txt', 'retire');
await fs.supprimer(racine, 'retire.txt');
await echoue(fs.lireTexte(racine, 'retire.txt'), 'ABSENT', { chemin: 'retire.txt' });
await fs.supprimer(racine, 'retire.txt');
egal(noms(await fs.lister(racine, '')), ['garde.txt']);
});
test('un chemin absolu ou remontant lève CHEMIN_REFUSE {chemin} à chaque primitive, sans rien écrire', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
for (const chemin of CHEMINS_REFUSES) {
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await echoue(appel(fs, racine, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`);
}
}
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test("une racine inconnue lève CHEMIN_REFUSE, même sous le chemin d'une racine connue", async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
const inconnue = { id: 'racine-inconnue', chemin: racine.chemin };
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await echoue(appel(fs, inconnue, 'ici.txt'), 'CHEMIN_REFUSE', undefined, primitive);
}
await echoue(fs.sonder(inconnue), 'CHEMIN_REFUSE', undefined, 'sonder');
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test('verrouiller est exclusif : un second appel rend la séance du premier, vivante', async () => {
const { fs, racine } = await fabrique();
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
if (!fs.verrouDisponible) {
egal(second, { pris: true });
return;
}
vrai(typeof second.depuis === 'string' && second.depuis !== '', `depuis ${second.depuis}`);
egal(second, { pris: false, seance: 'seance-a', depuis: second.depuis, vivant: true });
});
test("deverrouiller n'efface que le verrou de sa séance, et un verrou absent n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
if (!fs.verrouDisponible) {
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
return;
}
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c');
egal([tenu.pris, tenu.seance], [false, 'seance-a']);
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
});
test('sonder rend inscriptible, efface le témoin resté et laisse les autres fichiers', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'garde.txt', 'garde');
await fs.ecrireAtomique(racine, '.gtt-temoin', 'resté d\u{2019}une séance précédente');
egal(await fs.sonder(racine), { inscriptible: true, cause: null });
egal(noms(await fs.lister(racine, '')), ['garde.txt']);
egal(await fs.lireTexte(racine, 'garde.txt'), 'garde');
});
});
}

517
test/fichiers_simules.js Normal file
View file

@ -0,0 +1,517 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Système de fichiers d'épreuve (§ 13.4) : la troisième implémentation de
// l'interface de src/stockage/systeme_fichiers.js, en mémoire, injectée par
// les épreuves. Elle simule l'échec de renommage du § 8.8 et les cinq issues
// du § 8.6 — chemin publié par le lanceur portable, dossier sous un
// emplacement de données applicatives, sonde qui échoue, perte
// d'inscriptibilité en cours de séance, support amovible —, et compte ses
// lectures et ses écritures : une épreuve affirme ainsi qu'ouvrir n'écrit
// rien (§ 8.4).
//
// Une instance est un processus. Le disque, une Map, se partage : une
// instance créée sur le disque d'une autre est ce processus redémarré, ou un
// second processus de la même machine, d'une autre quand son hôte diffère.
// Le disque porte les fichiers et les dossiers sous leur chemin absolu, replié
// quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et
// sa table des processus. Les pannes et les compteurs sont ceux de l'instance.
//
// Une racine n'existe qu'une fois créée — par sonder, creerDossier ou, sans
// compter, deposerDossier —, et aucune écriture ne crée le dossier qui porte
// sa cible : l'épreuve voit ainsi le dossier de travail qu'aucun code n'a
// créé, ou qui a disparu, comme le verrait la coquille.
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
/**
* Levée par chaque primitive d'une instance dont le processus est coupé
* (pannes.couperApres). Ce n'est pas une ErreurStockage : le stockage ne la
* traite pas, il meurt avec le processus.
*/
export class PanneSimulee extends Error {
constructor() {
super('panne simulée : le processus de cette instance est arrêté');
}
}
PanneSimulee.prototype.name = 'PanneSimulee';
// Suffixe du fichier qu'écrit l'écriture atomique avant de le renommer
// par-dessus sa cible, et nom du témoin de la sonde (§ 8.6, § 8.8).
const SUFFIXE_ECRITURE = '.ecriture';
const TEMOIN = '.gtt-temoin';
// Réessais d'un renommage refusé après le premier essai, ceux de
// l'implémentation de la coquille, sans ses pauses.
const REESSAIS_RENOMMAGE = 10;
// Horloge d'un disque neuf, en millisecondes depuis l'époque Unix : le
// 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une
// primitive l'avance d'une seconde, si bien que modifie et la prise d'un
// verrou ne dépendent que de la suite des écritures.
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
const PAS_HORLOGE = 1000;
// La taille d'un fichier compte les octets de son texte en UTF-8.
const UTF8 = new TextEncoder();
// État de chaque disque, hors de sa Map : la casse et le séparateur de sa
// première instance, son horloge, le prochain pid, et ses processus par pid.
const ETATS_DISQUE = new WeakMap();
function etatDuDisque(disque, insensibleCasse, separateur) {
const etat = ETATS_DISQUE.get(disque);
if (etat === undefined) {
const neuf = { insensibleCasse, separateur, horloge: ORIGINE_HORLOGE, prochainPid: 1, processus: new Map() };
ETATS_DISQUE.set(disque, neuf);
return neuf;
}
if (etat.insensibleCasse !== insensibleCasse || etat.separateur !== separateur) {
throw new Error('un disque partagé garde la casse et le séparateur de sa première instance');
}
return etat;
}
// Clé d'un chemin quand la casse ne compte pas : chaque caractère vers sa
// majuscule, quand elle tient en un caractère, sans regarder le contexte,
// comme Windows compare deux noms. Un sigma final rejoint ainsi sa
// majuscule, et ß, dont la majuscule s'écrit SS, reste lui-même.
function replier(texte) {
return Array.from(texte, (caractere) => {
const majuscule = caractere.toUpperCase();
return Array.from(majuscule).length === 1 ? majuscule : caractere;
}).join('');
}
// Un instant de l'horloge sous la forme de celle de l'application,
// AAAA-MM-JJTHH:MM:SS±HH:MM.
const horodatage = (millisecondes) => `${new Date(millisecondes).toISOString().slice(0, 19)}+00:00`;
function exigerChaine(valeur, quoi) {
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
}
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Vrai pour le contenu lu d'un verrou : {seance, pid, hote, depuis}.
const estContenuVerrou = (contenu) =>
contenu !== null &&
typeof contenu === 'object' &&
typeof contenu.seance === 'string' &&
Number.isInteger(contenu.pid) &&
typeof contenu.hote === 'string' &&
typeof contenu.depuis === 'string';
/**
* Système de fichiers d'épreuve, en mémoire, qui sait tomber en panne.
* `disque` se partage entre deux instances : une instance « redémarrée » relit
* ce que l'autre a écrit. Racines : portable dès qu'executable est donné — la
* règle du dossier de travail décide de s'en servir (§ 8.6) —, et documents.
*
* @param {Object} [reglages]
* @param {Map} [reglages.disque] une Map neuve, ou le disque d'une autre
* instance, dont il faut garder la casse et le séparateur
* @param {string|null} [reglages.executable] dossier publié par le lanceur ;
* la racine portable est son data/
* @param {string[]} [reglages.donneesApplicatives]
* @param {string} [reglages.documents] chemin de la racine documents
* @param {'\\'|'/'} [reglages.separateur]
* @param {boolean} [reglages.insensibleCasse]
* @param {'electron'|'web'|'epreuve'} [reglages.nature]
* @param {boolean} [reglages.renommageAtomique] faux : ecrireAtomique écrit
* droit sur la cible, sans fichier .ecriture ni renommage
* @param {boolean} [reglages.verrouDisponible] faux : verrouiller rend
* { pris: true } sans rien écrire, deverrouiller ne fait rien
* @param {Object<string, string>} [reglages.sonde] cause d'échec par racine,
* { portable: 'EACCES' } : la sonde de cette racine la rend, et la
* racine refuse toute écriture avec elle, comme après pannes.ecriture
* @param {Object<string, 'amovible'|'fixe'|'inconnu'>} [reglages.support]
* ce que rend typeSupport, 'fixe' pour une racine absente de l'objet
* @param {string} [reglages.hote] machine du processus ; un verrou d'un autre
* hôte rend vivant à null
* @returns {import('../src/stockage/systeme_fichiers.js').SystemeFichiers & Object}
* le système, et pour l'épreuve :
* - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque
* se suivent, 1, 2, 3…, dans l'ordre de création des instances ;
* - pannes.renommage(racineId, n = Infinity, cause = 'EBUSY') : les n
* prochains renommages de la racine sont refusés — chaque essai de
* l'écriture atomique, qui en fait onze, et deplacer ;
* - pannes.ecriture(racineId, cause = 'EROFS') : dès lors, toute primitive
* d'écriture de la racine lève ECRITURE, et sa sonde rend la cause ;
* - pannes.couperApres(n) : après n primitives d'écriture réussies —
* rendues sans lever —, toute primitive lève PanneSimulee : le processus
* est mort, la table des processus du disque le dit à ses verrous, et
* l'on rouvre par une nouvelle instance sur le même disque ;
* - compteurs.ecritures, compteurs.lectures : les appels des primitives
* d'écriture (ecrireAtomique, ajouterLigne, creerDossier, deplacer,
* supprimer, verrouiller, deverrouiller, sonder) et de lecture (lireTexte,
* lister), refusés compris ; les autres ne comptent pas ;
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
* fichier, dossiers parents compris, sans compter ni avancer l'horloge ;
* - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris,
* de même ;
* - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null
* pour un absent ou un dossier.
* deposer, deposerDossier et contenu servent encore après une coupure : ils
* sont les mains de l'épreuve sur le disque, non celles du processus.
*/
export function creerFichiersSimules({
disque = new Map(),
executable = null,
donneesApplicatives = ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'],
documents = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre',
separateur = '\\',
insensibleCasse = true,
nature = 'epreuve',
renommageAtomique = true,
verrouDisponible = true,
sonde = {},
support = {},
hote = 'poste-epreuve',
} = {}) {
const etat = etatDuDisque(disque, insensibleCasse, separateur);
const pid = etat.prochainPid;
etat.prochainPid += 1;
// Chemin absolu de chaque racine connue, par identifiant.
const joindre = (dossier, nom) => (dossier.endsWith(separateur) ? dossier + nom : dossier + separateur + nom);
const racines = new Map([['documents', documents]]);
if (executable !== null) racines.set('portable', joindre(executable, 'data'));
// Cause qui refuse toute écriture d'une racine : celle de sa sonde déclarée,
// puis celle de pannes.ecriture. Renommages refusés restants d'une racine,
// et leur cause.
const refusEcriture = new Map();
for (const id of racines.keys()) if (Object.hasOwn(sonde, id)) refusEcriture.set(id, sonde[id]);
const refusRenommage = new Map();
// Écritures réussies que le processus fait encore avant sa coupure ; null
// tant que couperApres n'est pas appelé. Le processus meurt à zéro.
let restantes = null;
const estMort = () => restantes === 0;
etat.processus.set(pid, { hote, estMort });
const compteurs = { ecritures: 0, lectures: 0 };
const avancer = () => {
etat.horloge += PAS_HORLOGE;
return etat.horloge;
};
const maintenant = () => etat.horloge;
// Ce que désigne un chemin relatif à une racine : son chemin absolu en
// parties, la clé dans le disque de chacun de ses préfixes, sa clé et celle
// de son parent, son nom, et le dossier que nomme une ECRITURE — celui qui le
// porte, ou la racine elle-même pour ''. CHEMIN_REFUSE pour un chemin refusé
// ou une racine inconnue.
function localiser(racine, chemin) {
const segments = exigerCheminRelatif(chemin);
const base = racines.get(racine?.id);
if (base === undefined) throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
const parties = [...base.split(separateur), ...segments];
const absolu = (n) => parties.slice(0, n).join(separateur);
const cleDe = (n) => (insensibleCasse ? replier(absolu(n)) : absolu(n));
const n = parties.length;
return {
racine: racine.id,
chemin,
parties,
cleDe,
cle: cleDe(n),
cleParent: n > 1 ? cleDe(n - 1) : null,
nom: parties[n - 1],
dossier: absolu(segments.length > 0 ? n - 1 : n),
};
}
const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
function exigerInscriptible(loc) {
const cause = refusEcriture.get(loc.racine);
if (cause !== undefined) throw refus(loc, cause);
}
function exigerDossierParent(loc) {
if (disque.get(loc.cleParent)?.type !== 'dossier') throw refus(loc, 'ENOENT');
}
function exigerPasDossier(loc) {
if (disque.get(loc.cle)?.type === 'dossier') throw refus(loc, 'EISDIR');
}
// Crée, dans l'ordre, les dossiers des n premières parties du chemin absolu
// qui manquent, datés par horloge(). Un fichier en travers lève ECRITURE :
// EEXIST quand il occupe le chemin même, ENOTDIR un dossier parent.
function creerDossiers(loc, n, horloge) {
for (let k = 1; k <= n; k += 1) {
const noeud = disque.get(loc.cleDe(k));
if (noeud === undefined) {
disque.set(loc.cleDe(k), {
nom: loc.parties[k - 1],
type: 'dossier',
texte: null,
modifie: horloge(),
parent: k > 1 ? loc.cleDe(k - 1) : null,
});
} else if (noeud.type !== 'dossier') {
throw refus(loc, k === loc.parties.length ? 'EEXIST' : 'ENOTDIR');
}
}
}
// Écrit le texte d'un fichier : un fichier présent garde son nom, un fichier
// neuf prend celui du chemin.
function poserFichier(loc, texte, modifie) {
const present = disque.get(loc.cle);
disque.set(loc.cle, { nom: present?.nom ?? loc.nom, type: 'fichier', texte, modifie, parent: loc.cleParent });
}
// Porte le fichier de la source à la cible, sous le nom de la cible : un
// renommage par-dessus donne au fichier la casse du nouveau nom.
function renommer(source, cible) {
const noeud = disque.get(source.cle);
disque.delete(source.cle);
disque.set(cible.cle, { ...noeud, nom: cible.nom, parent: cible.cleParent });
}
// Cause du refus du prochain renommage de la racine, ou null ; chaque refus
// en consomme un.
function renommageRefuse(racineId) {
const panne = refusRenommage.get(racineId);
if (panne === undefined || panne.restants === 0) return null;
panne.restants -= 1;
return panne.cause;
}
// Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa
// prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le
// disque porte un processus de cet hôte sous ce pid, et que ce processus
// n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et
// vivant à null.
function verrouPresent(texte) {
let contenu;
try {
contenu = JSON.parse(texte);
} catch {
contenu = null;
}
if (!estContenuVerrou(contenu)) return { pris: false, seance: null, depuis: null, vivant: null };
let vivant = null;
if (contenu.hote === hote) {
const processus = etat.processus.get(contenu.pid);
vivant = processus !== undefined && processus.hote === hote && !processus.estMort();
}
return { pris: false, seance: contenu.seance, depuis: contenu.depuis, vivant };
}
// Une primitive : le processus coupé lève PanneSimulee avant tout ; l'appel
// compte selon son genre — 'ecritures', 'lectures' ou null ; une primitive
// d'écriture rendue sans lever rapproche la coupure.
function primitive(genre, travail) {
return async (...parametres) => {
if (estMort()) throw new PanneSimulee();
if (genre !== null) compteurs[genre] += 1;
const resultat = travail(...parametres);
if (genre === 'ecritures' && restantes !== null) restantes -= 1;
return resultat;
};
}
return {
nature,
renommageAtomique,
verrouDisponible,
pid,
hote,
emplacements: primitive(null, () => ({
executable,
donneesApplicatives: [...donneesApplicatives],
insensibleCasse,
separateur,
})),
racines: primitive(null, () => ({
portable: racines.has('portable') ? { id: 'portable', chemin: racines.get('portable') } : null,
documents: { id: 'documents', chemin: documents },
})),
// Les dialogues natifs rendent null, comme quand l'opérateur annule.
choisirDossier: primitive(null, () => null),
// Le témoin s'écrit par-dessus celui qu'une séance précédente a pu
// laisser, puis s'efface ; sa relecture ne peut différer de ce qui vient
// de s'écrire en mémoire. La sonde échoue sur la cause qui refuse
// l'écriture de la racine, ou sur un fichier qui occupe son chemin.
sonder: primitive('ecritures', (racine) => {
const loc = localiser(racine, '');
const cause = refusEcriture.get(loc.racine);
if (cause !== undefined) return { inscriptible: false, cause };
try {
creerDossiers(loc, loc.parties.length, avancer);
} catch (erreur) {
return { inscriptible: false, cause: erreur.details.cause };
}
const temoin = localiser(racine, TEMOIN);
poserFichier(temoin, TEMOIN, avancer());
disque.delete(temoin.cle);
return { inscriptible: true, cause: null };
}),
typeSupport: primitive(null, (racine) => {
const { racine: id } = localiser(racine, '');
return Object.hasOwn(support, id) ? support[id] : 'fixe';
}),
lireTexte: primitive('lectures', (racine, chemin) => {
const noeud = disque.get(localiser(racine, chemin).cle);
if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin });
return noeud.texte;
}),
ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => {
exigerChaine(texte, 'le texte');
const cible = localiser(racine, chemin);
exigerInscriptible(cible);
exigerDossierParent(cible);
exigerPasDossier(cible);
if (!renommageAtomique) {
poserFichier(cible, texte, avancer());
return;
}
const ecriture = localiser(racine, chemin + SUFFIXE_ECRITURE);
poserFichier(ecriture, texte, avancer());
let cause = null;
for (let essai = 0; essai <= REESSAIS_RENOMMAGE; essai += 1) {
cause = renommageRefuse(cible.racine);
if (cause === null) {
renommer(ecriture, cible);
return;
}
}
disque.delete(ecriture.cle);
throw refus(cible, cause);
}),
ajouterLigne: primitive('ecritures', (racine, chemin, ligne) => {
exigerChaine(ligne, 'la ligne');
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
exigerDossierParent(loc);
exigerPasDossier(loc);
const avant = disque.get(loc.cle)?.texte ?? '';
poserFichier(loc, `${avant}${ligne}\n`, avancer());
}),
lister: primitive('lectures', (racine, dossier) => {
const loc = localiser(racine, dossier);
if (disque.get(loc.cle)?.type !== 'dossier') throw new ErreurStockage('ABSENT', { chemin: dossier });
const entrees = [];
for (const noeud of disque.values()) {
if (noeud.parent !== loc.cle) continue;
const taille = noeud.type === 'fichier' ? UTF8.encode(noeud.texte).length : 0;
entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie });
}
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
}),
creerDossier: primitive('ecritures', (racine, chemin) => {
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
creerDossiers(loc, loc.parties.length, avancer);
}),
// Un dossier absent sous la cible nomme la cible ; une panne nomme la
// source, que le renommage n'a pas pu porter.
deplacer: primitive('ecritures', (racine, de, vers) => {
const source = localiser(racine, de);
const cible = localiser(racine, vers);
exigerInscriptible(source);
const noeud = disque.get(source.cle);
if (noeud === undefined) throw new ErreurStockage('ABSENT', { chemin: de });
if (noeud.type === 'dossier') throw refus(source, 'EISDIR');
if (disque.has(cible.cle)) throw new ErreurStockage('EXISTE', { chemin: vers });
exigerDossierParent(cible);
const cause = renommageRefuse(source.racine);
if (cause !== null) throw refus(source, cause);
renommer(source, cible);
}),
supprimer: primitive('ecritures', (racine, chemin) => {
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
exigerPasDossier(loc);
disque.delete(loc.cle);
}),
verrouiller: primitive('ecritures', (racine, chemin, seance) => {
exigerSeance(seance);
const loc = localiser(racine, chemin);
if (!verrouDisponible) return { pris: true };
exigerInscriptible(loc);
exigerDossierParent(loc);
exigerPasDossier(loc);
const present = disque.get(loc.cle);
if (present !== undefined) return verrouPresent(present.texte);
const prise = avancer();
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
return { pris: true };
}),
deverrouiller: primitive('ecritures', (racine, chemin, seance) => {
exigerSeance(seance);
const loc = localiser(racine, chemin);
if (!verrouDisponible) return;
exigerInscriptible(loc);
const present = disque.get(loc.cle);
if (present?.type === 'fichier' && verrouPresent(present.texte).seance === seance) disque.delete(loc.cle);
}),
ouvrirDansExplorateur: primitive(null, (racine) => {
localiser(racine, '');
}),
choisirFichierAImporter: primitive(null, () => null),
enregistrerSous: primitive(null, () => null),
pannes: {
renommage(racineId, n = Infinity, cause = 'EBUSY') {
if (n !== Infinity && !(Number.isInteger(n) && n >= 0)) {
throw new TypeError('pannes.renommage : n est un entier positif ou nul, ou Infinity');
}
refusRenommage.set(localiser({ id: racineId }, '').racine, { restants: n, cause });
},
ecriture(racineId, cause = 'EROFS') {
refusEcriture.set(localiser({ id: racineId }, '').racine, cause);
},
couperApres(n) {
if (!(Number.isInteger(n) && n >= 0)) throw new TypeError('pannes.couperApres : n est un entier positif ou nul');
restantes = n;
},
},
compteurs,
deposer(racineId, chemin, texte, modifie = maintenant()) {
exigerChaine(texte, 'le texte');
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length - 1, maintenant);
exigerPasDossier(loc);
poserFichier(loc, texte, modifie);
},
deposerDossier(racineId, chemin = '') {
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length, maintenant);
},
contenu(racineId, chemin) {
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
return noeud?.type === 'fichier' ? noeud.texte : null;
},
};
}

View file

@ -0,0 +1,589 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du système de fichiers d'épreuve (§ 13.4, § 14.10). La suite de
// contrat s'y joue sur trois réglages : Windows, Unix, et celui de la
// plateforme web, sans renommage atomique ni verrou. Suit ce qui n'appartient
// qu'à lui : ses réglages, qui en ressortent tels qu'il les reçoit, ses
// racines et emplacements, la casse des noms, les pannes qu'il simule —
// renommage refusé, support qui refuse l'écriture, processus coupé puis
// rouvert sur le même disque —, le verrou entre processus, les compteurs, et
// ce que l'épreuve pose ou lit sans compter. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { eprouverContrat } from './contrat_fichiers.js';
import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.js';
import { describe, test } from './lanceur.js';
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
let raison;
await assert.rejects(
promesse,
(erreur) => {
raison = erreur;
return true;
},
message,
);
return raison;
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details) {
const erreur = await rejette(promesse, code);
assert.ok(erreur instanceof ErreurStockage, String(erreur));
assert.equal(erreur.code, code);
assert.deepEqual(erreur.details, details);
}
// Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
assert.fail('aucune erreur levée');
}
const noms = (entrees) => entrees.map((entree) => entree.nom);
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
const DOC = { id: 'documents', chemin: DOCUMENTS };
const DATA = 'E:\\soirees\\data';
const PORTABLE = { id: 'portable', chemin: DATA };
// Un système d'épreuve dont la racine documents existe, posée sans compter.
function preparer(options = {}) {
const fs = creerFichiersSimules(options);
fs.deposerDossier('documents');
return fs;
}
// Les trois réglages sur lesquels la suite de contrat se joue.
const REGLAGES = [
['épreuve, sous Windows', {}],
[
'épreuve, sous Unix',
{
separateur: '/',
insensibleCasse: false,
documents: '/home/exemple/Documents/Gestion table tournante Libre',
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
},
],
[
'épreuve, comme la plateforme web',
{
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
separateur: '/',
insensibleCasse: false,
documents: '/gestion_table_tournante_libre',
},
],
];
for (const [nom, options] of REGLAGES) {
eprouverContrat(
nom,
async () => {
const fs = preparer(options);
return { fs, racine: (await fs.racines()).documents };
},
{ describe, test, egal: assert.deepStrictEqual, vrai: assert.ok, rejette },
);
}
describe('fichiers simulés : racines et emplacements', () => {
test('par défaut : Windows, sans égard à la casse, sans exécutable publié, la seule racine documents', async () => {
const fs = creerFichiersSimules();
assert.equal(fs.nature, 'epreuve');
assert.equal(fs.renommageAtomique, true);
assert.equal(fs.verrouDisponible, true);
assert.deepEqual(await fs.emplacements(), {
executable: null,
donneesApplicatives: ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'],
insensibleCasse: true,
separateur: '\\',
});
assert.deepEqual(await fs.racines(), { portable: null, documents: DOC });
await echoue(fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'CHEMIN_REFUSE', { chemin: 'soiree.gtt.json' });
});
test('les réglages donnés en ressortent tels quels : nature, garanties, hôte, emplacements, racine documents', async () => {
// Chaque réglage diffère de sa valeur par défaut : une valeur que le
// double figerait au lieu de la reprendre fait échouer l'épreuve.
// donneesApplicatives est la seule voie par laquelle il simule un
// dossier de travail sous un emplacement de données applicatives
// (§ 8.6, point 2).
const documents = '/home/exemple/Documents/Gestion table tournante Libre';
const fs = creerFichiersSimules({
documents,
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
separateur: '/',
insensibleCasse: false,
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
hote: 'autre-poste',
});
assert.equal(fs.nature, 'web');
assert.equal(fs.renommageAtomique, false);
assert.equal(fs.verrouDisponible, false);
assert.equal(fs.hote, 'autre-poste');
assert.deepEqual(await fs.emplacements(), {
executable: null,
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
insensibleCasse: false,
separateur: '/',
});
assert.deepEqual(await fs.racines(), { portable: null, documents: { id: 'documents', chemin: documents } });
});
test("un exécutable publié ouvre la racine portable, data/ à côté de lui, au séparateur du système", async () => {
const cas = [
[{ executable: 'E:\\soirees' }, 'E:\\soirees\\data'],
[{ executable: 'E:\\' }, 'E:\\data'],
[{ executable: '/media/cle/soirees', separateur: '/', insensibleCasse: false }, '/media/cle/soirees/data'],
];
for (const [options, chemin] of cas) {
const fs = creerFichiersSimules(options);
assert.deepEqual((await fs.racines()).portable, { id: 'portable', chemin });
assert.equal((await fs.emplacements()).executable, options.executable);
}
});
test('typeSupport rend le support déclaré pour une racine, fixe par défaut', async () => {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', support: { portable: 'amovible' } });
assert.equal(await fs.typeSupport(PORTABLE), 'amovible');
assert.equal(await fs.typeSupport(DOC), 'fixe');
});
test("une racine n'existe qu'une fois créée : avant, lister lève ABSENT et écrire ECRITURE (ENOENT) ; sonder la crée", async () => {
const fs = creerFichiersSimules();
const absent = (chemin) => ({ chemin, dossier: DOCUMENTS, cause: 'ENOENT' });
await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' });
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json'));
await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl'));
await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', absent('soiree.gtt.verrou'));
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('deplacer vers un dossier absent nomme la cible et son dossier, et laisse la source', async () => {
const fs = preparer();
fs.deposer('documents', 'soiree.gtt.json', 'état');
await echoue(fs.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'), 'ECRITURE', {
chemin: 'corbeille/soiree.gtt.json',
dossier: `${DOCUMENTS}\\corbeille`,
cause: 'ENOENT',
});
assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'état');
});
test('une sonde déclarée en échec rend sa cause, et sa racine refuse toute écriture', async () => {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } });
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EACCES' });
await echoue(fs.creerDossier(PORTABLE, ''), 'ECRITURE', { chemin: '', dossier: DATA, cause: 'EACCES' });
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
});
test("un fichier qui occupe le chemin de la racine fait échouer la sonde, sans lever (EEXIST)", async () => {
// L'exécutable est publié dans E:\soirees, que la racine documents
// désigne aussi : un fichier data y occupe le chemin de la racine portable.
const fs = creerFichiersSimules({ executable: 'E:\\soirees', documents: 'E:\\soirees' });
fs.deposer('documents', 'data', 'un fichier, pas un dossier');
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EEXIST' });
assert.equal(fs.contenu('documents', 'data'), 'un fichier, pas un dossier');
});
test("les dialogues natifs rendent null, comme quand l'opérateur annule ; ouvrir une racine inconnue est refusé", async () => {
const fs = preparer();
assert.equal(await fs.choisirDossier(), null);
assert.equal(await fs.choisirFichierAImporter(), null);
assert.equal(await fs.enregistrerSous('liste.csv', new Uint8Array([0x61])), null);
assert.equal(await fs.ouvrirDansExplorateur(DOC), undefined);
await echoue(fs.ouvrirDansExplorateur(PORTABLE), 'CHEMIN_REFUSE', { chemin: '' });
});
});
describe('fichiers simulés : casse des noms', () => {
test('sans égard à la casse, une autre casse désigne le même fichier, et lister rend la casse de sa création', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'état');
assert.equal(await fs.lireTexte(DOC, 'SOIR\u{C9}E.GTT.JSON'), 'état');
await fs.ajouterLigne(DOC, 'Soir\u{E9}e.gtt-journal.jsonl', 'a');
await fs.ajouterLigne(DOC, 'soir\u{E9}e.GTT-JOURNAL.jsonl', 'b');
assert.deepEqual(noms(await fs.lister(DOC, '')), [
'Soir\u{E9}e.gtt-journal.jsonl',
'Soir\u{E9}e.gtt.json',
]);
assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt-journal.jsonl'), 'a\nb\n');
});
test('la casse se replie caractère par caractère, comme Windows : un sigma final rejoint sa majuscule, un eszett ne devient pas SS', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, '\u{3A3}\u{391}\u{3A3}.txt', 'majuscules');
assert.equal(await fs.lireTexte(DOC, '\u{3C3}\u{3B1}\u{3C2}.txt'), 'majuscules');
await fs.ecrireAtomique(DOC, 'stra\u{DF}e.txt', 'eszett');
await fs.ecrireAtomique(DOC, 'STRASSE.txt', 'deux s');
assert.equal(await fs.lireTexte(DOC, 'stra\u{DF}e.txt'), 'eszett');
assert.equal(await fs.lireTexte(DOC, 'STRASSE.txt'), 'deux s');
});
test('renommer par-dessus donne au fichier la casse du nouveau nom ; deplacer vers une autre casse du même nom lève EXISTE', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'un');
await fs.ecrireAtomique(DOC, 'soir\u{E9}e.gtt.json', 'deux');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soir\u{E9}e.gtt.json']);
assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt.json'), 'deux');
await echoue(fs.deplacer(DOC, 'soir\u{E9}e.gtt.json', 'SOIR\u{C9}E.gtt.json'), 'EXISTE', {
chemin: 'SOIR\u{C9}E.gtt.json',
});
});
test('sensible à la casse, deux casses font deux fichiers', async () => {
const racine = '/home/exemple/Documents/Gestion table tournante Libre';
const fs = preparer({ separateur: '/', insensibleCasse: false, documents: racine });
const doc = { id: 'documents', chemin: racine };
await fs.ecrireAtomique(doc, 'soir\u{E9}e.gtt.json', 'minuscule');
await fs.ecrireAtomique(doc, 'Soir\u{E9}e.gtt.json', 'majuscule');
assert.deepEqual(noms(await fs.lister(doc, '')), ['Soir\u{E9}e.gtt.json', 'soir\u{E9}e.gtt.json']);
await echoue(fs.lireTexte(doc, 'SOIR\u{C9}E.GTT.JSON'), 'ABSENT', { chemin: 'SOIR\u{C9}E.GTT.JSON' });
});
test('un disque partagé garde la casse et le séparateur de sa première instance', () => {
const disque = new Map();
creerFichiersSimules({ disque });
assert.throws(() => creerFichiersSimules({ disque, insensibleCasse: false }));
assert.throws(() => creerFichiersSimules({ disque, separateur: '/' }));
creerFichiersSimules({ disque });
});
});
describe('fichiers simulés : renommage refusé (§ 8.8, § 14.10)', () => {
test("un renommage refusé laisse la cible intacte, retire le fichier d'écriture et lève ECRITURE en nommant fichier et dossier", async () => {
const fs = preparer();
fs.deposer('documents', 'soiree.gtt.json', 'avant');
fs.pannes.renommage('documents');
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'après'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DOCUMENTS,
cause: 'EBUSY',
});
assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'avant');
assert.equal(fs.contenu('documents', 'soiree.gtt.json.ecriture'), null);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.json']);
});
test("l'écriture réessaie le renommage : dix refus et elle passe, onze et elle échoue", async () => {
const fs = preparer();
fs.pannes.renommage('documents', 10, 'EPERM');
await fs.ecrireAtomique(DOC, 'etat.json', 'après dix refus');
assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus');
fs.pannes.renommage('documents', 11, 'EPERM');
await echoue(fs.ecrireAtomique(DOC, 'etat.json', 'après onze refus'), 'ECRITURE', {
chemin: 'etat.json',
dossier: DOCUMENTS,
cause: 'EPERM',
});
assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus');
});
test('la panne de renommage ne touche que sa racine, et refuse aussi deplacer', async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposer('portable', 'soiree.gtt.json', 'état');
fs.pannes.renommage('portable');
await fs.ecrireAtomique(DOC, 'ailleurs.json', 'passe');
assert.equal(fs.contenu('documents', 'ailleurs.json'), 'passe');
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'soiree-2.gtt.json'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DATA,
cause: 'EBUSY',
});
assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'état');
assert.equal(fs.contenu('portable', 'soiree-2.gtt.json'), null);
});
test("sans renommage atomique, ecrireAtomique écrit droit sur la cible : une panne de renommage ne l'arrête pas", async () => {
const fs = preparer({ renommageAtomique: false });
fs.deposer('documents', 'etat.json', 'avant');
fs.pannes.renommage('documents');
await fs.ecrireAtomique(DOC, 'etat.json', 'après');
assert.equal(fs.contenu('documents', 'etat.json'), 'après');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['etat.json']);
});
});
describe("fichiers simulés : support qui refuse l'écriture", () => {
test("après pannes.ecriture, chaque primitive d'écriture de la racine lève ECRITURE avec la cause, la sonde échoue, et rien ne change", async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposer('portable', 'soiree.gtt.json', 'état');
fs.deposer('portable', 'soiree.gtt-journal.jsonl', 'ligne\n');
fs.deposer('portable', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
fs.pannes.ecriture('portable');
const refus = (chemin) => ({ chemin, dossier: DATA, cause: 'EROFS' });
await echoue(fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'nouveau'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(
fs.ajouterLigne(PORTABLE, 'soiree.gtt-journal.jsonl', 'suite'),
'ECRITURE',
refus('soiree.gtt-journal.jsonl'),
);
await echoue(fs.creerDossier(PORTABLE, 'corbeille'), 'ECRITURE', refus('corbeille'));
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b'), 'ECRITURE', refus('neuf.gtt.verrou'));
await echoue(fs.deverrouiller(PORTABLE, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', refus('soiree.gtt.verrou'));
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EROFS' });
assert.deepEqual(noms(await fs.lister(PORTABLE, '')), [
'soiree.gtt-journal.jsonl',
'soiree.gtt.json',
'soiree.gtt.verrou',
]);
assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'état');
assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt-journal.jsonl'), 'ligne\n');
});
test("une cause donnée remplace EROFS, et l'autre racine écrit encore", async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposerDossier('portable');
fs.pannes.ecriture('documents', 'ENOSPC');
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DOCUMENTS,
cause: 'ENOSPC',
});
await fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'écrit ailleurs');
assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'écrit ailleurs');
});
});
describe('fichiers simulés : processus coupé (couperApres)', () => {
test('après n écritures réussies, toute primitive lève PanneSimulee ; une nouvelle instance sur le même disque relit les n écritures et écrit à son tour', async () => {
const disque = new Map();
const premiere = creerFichiersSimules({ disque });
premiere.deposerDossier('documents');
premiere.deposer('documents', 'soiree.gtt.json', 'révision 1');
premiere.pannes.couperApres(2);
await premiere.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":2}');
await premiere.ecrireAtomique(DOC, 'soiree.gtt.json.precedent', 'révision 1');
const panne = await rejette(premiere.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2'));
assert.ok(panne instanceof PanneSimulee, String(panne));
assert.ok(!(panne instanceof ErreurStockage));
await assert.rejects(premiere.lireTexte(DOC, 'soiree.gtt.json'), PanneSimulee);
await assert.rejects(premiere.lister(DOC, ''), PanneSimulee);
await assert.rejects(premiere.emplacements(), PanneSimulee);
await assert.rejects(premiere.sonder(DOC), PanneSimulee);
const reouverte = creerFichiersSimules({ disque });
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt-journal.jsonl'), '{"revision":2}\n');
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json.precedent'), 'révision 1');
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json'), 'révision 1');
await reouverte.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2');
assert.equal(premiere.contenu('documents', 'soiree.gtt.json'), 'révision 2');
});
test('une écriture refusée ne compte pas parmi les n', async () => {
const fs = preparer();
fs.pannes.renommage('documents');
fs.pannes.couperApres(1);
const refus = await rejette(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'));
assert.equal(refus.code, 'ECRITURE');
await fs.creerDossier(DOC, 'corbeille');
await assert.rejects(fs.lister(DOC, ''), PanneSimulee);
});
test('couperApres(0) arrête le processus sur-le-champ', async () => {
const fs = preparer();
fs.pannes.couperApres(0);
await assert.rejects(fs.lister(DOC, ''), PanneSimulee);
});
test("les pannes refusent un compte qui n'est pas un entier positif ou nul", () => {
const fs = preparer();
assert.throws(() => fs.pannes.renommage('documents', -1), TypeError);
assert.throws(() => fs.pannes.renommage('documents', 1.5), TypeError);
assert.throws(() => fs.pannes.couperApres(-1), TypeError);
assert.throws(() => fs.pannes.couperApres(Infinity), TypeError);
});
});
describe('fichiers simulés : verrou', () => {
test("le fichier de verrou porte la séance, le pid et l'hôte du processus, et l'instant de sa prise", async () => {
const fs = preparer();
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const texte = fs.contenu('documents', 'soiree.gtt.verrou');
assert.match(
texte,
/^\{"seance":"seance-a","pid":\d+,"hote":"poste-epreuve","depuis":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00"\}\n$/,
);
const { pid, depuis } = JSON.parse(texte);
assert.equal(pid, fs.pid);
assert.equal(fs.hote, 'poste-epreuve');
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-a',
depuis,
vivant: true,
});
});
test('la création est exclusive, même pour la séance qui tient le verrou', async () => {
const fs = preparer();
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
const second = await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]);
});
test('vivant : vrai pour un processus en vie sur la même machine, faux pour un processus coupé, null pour une autre machine', async () => {
const disque = new Map();
const a = creerFichiersSimules({ disque });
const b = creerFichiersSimules({ disque });
const ailleurs = creerFichiersSimules({ disque, hote: 'autre-poste' });
a.deposerDossier('documents');
assert.notEqual(a.pid, b.pid);
await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a');
const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]);
await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c');
const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x');
assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]);
// Le pid d'un processus de l'autre machine ne désigne aucun processus de
// celle-ci.
const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe));
assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x')).vivant, false);
a.pannes.couperApres(0);
const apresCoupure = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
assert.deepEqual([apresCoupure.seance, apresCoupure.vivant], ['seance-a', false]);
});
test("un verrou illisible ne se lit ni ne s'efface ; un pid absent de la machine est mort", async () => {
const fs = preparer();
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":');
assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'), illisible);
await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a');
assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":');
fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}');
assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b'), illisible);
const ancien = { seance: 'ancienne', pid: 4242, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
fs.deposer('documents', 'mort.gtt.verrou', JSON.stringify(ancien));
assert.deepEqual(await fs.verrouiller(DOC, 'mort.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'ancienne',
depuis: '2026-01-01T08:00:00+00:00',
vivant: false,
});
});
test("sans verrou disponible, verrouiller rend pris sans rien écrire et deverrouiller n'efface rien", async () => {
const fs = preparer({ verrouDisponible: false });
fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a'), { pris: true });
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b'), { pris: true });
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.verrou']);
});
});
describe('fichiers simulés : compteurs, préparation, horloge', () => {
test("chaque appel d'une primitive de lecture compte une lecture et chaque appel d'une primitive d'écriture une écriture, refusé compris ; les autres ne comptent pas", async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'a.txt', 'a');
await fs.ajouterLigne(DOC, 'journal.jsonl', 'ligne');
await fs.creerDossier(DOC, 'corbeille');
await fs.deplacer(DOC, 'a.txt', 'corbeille/a.txt');
await fs.supprimer(DOC, 'corbeille/a.txt');
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fs.sonder(DOC);
await assert.rejects(fs.ecrireAtomique(DOC, '../a.txt', 'a'));
await fs.lireTexte(DOC, 'journal.jsonl');
await fs.lister(DOC, '');
await assert.rejects(fs.lireTexte(DOC, 'absent.txt'));
await fs.emplacements();
await fs.racines();
await fs.typeSupport(DOC);
await fs.choisirDossier();
await fs.ouvrirDansExplorateur(DOC);
await fs.choisirFichierAImporter();
await fs.enregistrerSous('liste.csv', new Uint8Array([0x61]));
fs.contenu('documents', 'journal.jsonl');
fs.deposer('documents', 'pose.txt', 'posé');
assert.deepEqual(fs.compteurs, { ecritures: 9, lectures: 3 });
});
test('deposer et deposerDossier posent sans compter, dossiers parents compris ; contenu lit sans compter et rend null hors d\u{2019}un fichier', async () => {
const fs = creerFichiersSimules();
fs.deposerDossier('documents', 'corbeille/2026-01-02_03-04-05');
fs.deposer('documents', 'archives/2025/soiree.gtt.json', 'état', 5);
assert.equal(fs.contenu('documents', 'archives/2025/soiree.gtt.json'), 'état');
assert.equal(fs.contenu('documents', 'ARCHIVES/2025/SOIREE.GTT.JSON'), 'état');
assert.equal(fs.contenu('documents', 'archives/2025'), null);
assert.equal(fs.contenu('documents', 'absent.txt'), null);
assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 0 });
assert.deepEqual(await fs.lister(DOC, 'archives/2025'), [
{ nom: 'soiree.gtt.json', type: 'fichier', taille: 5, modifie: 5 },
]);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['archives', 'corbeille']);
assert.equal(erreurDe(() => fs.deposer('inconnue', 'x.txt', 'x')).code, 'CHEMIN_REFUSE');
assert.equal(erreurDe(() => fs.pannes.ecriture('inconnue')).code, 'CHEMIN_REFUSE');
});
test("modifie suit l'horloge du disque : chaque écriture l'avance ; deposer pose l'instant courant, ou celui qu'on lui donne", async () => {
const fs = preparer();
fs.deposer('documents', 'a.txt', 'a');
fs.deposer('documents', 'b.txt', 'b');
fs.deposer('documents', 'c.txt', 'c', 7);
await fs.ecrireAtomique(DOC, 'd.txt', 'd');
await fs.ecrireAtomique(DOC, 'e.txt', 'e');
const [a, b, c, d, e] = await fs.lister(DOC, '');
assert.equal(a.modifie, b.modifie);
assert.equal(c.modifie, 7);
assert.ok(d.modifie > a.modifie, `${d.modifie} > ${a.modifie}`);
assert.ok(e.modifie > d.modifie, `${e.modifie} > ${d.modifie}`);
});
});
describe('fichiers simulés : préconditions et dossiers', () => {
test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => {
const fs = preparer();
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\nb'), TypeError);
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\r'), TypeError);
await assert.rejects(fs.ecrireAtomique(DOC, 'etat.json', new Uint8Array([0x61])), TypeError);
await assert.rejects(fs.verrouiller(DOC, 'soiree.gtt.verrou', ''), TypeError);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('un dossier ne se lit, ne s\u{2019}écrit, ne se déplace ni ne se supprime comme un fichier, et un fichier ne se liste pas', async () => {
const fs = preparer();
fs.deposerDossier('documents', 'corbeille');
fs.deposer('documents', 'soiree.gtt.json', 'état');
const refus = (chemin, cause, dossier = DOCUMENTS) => ({ chemin, dossier, cause });
await echoue(fs.lireTexte(DOC, 'corbeille'), 'ABSENT', { chemin: 'corbeille' });
await echoue(fs.lister(DOC, 'soiree.gtt.json'), 'ABSENT', { chemin: 'soiree.gtt.json' });
await echoue(fs.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST'));
await echoue(
fs.creerDossier(DOC, 'soiree.gtt.json/sous'),
'ECRITURE',
refus('soiree.gtt.json/sous', 'ENOTDIR', `${DOCUMENTS}\\soiree.gtt.json`),
);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']);
});
});

5
test/fixtures/csv/champ_cite.csv vendored Normal file
View file

@ -0,0 +1,5 @@
nom;prenom;appartenance;notes
Ombrelle;Iris;"Club ""Les Merles"" ; section nord";"dit ""bonjour"" ; puis part"
Pervenche;Théo;Chorale du Givre;"première ligne
deuxième ligne"
Sarcelle;Ondine;;fin
1 nom prenom appartenance notes
2 Ombrelle Iris Club "Les Merles" ; section nord dit "bonjour" ; puis part
3 Pervenche Théo Chorale du Givre première ligne deuxième ligne
4 Sarcelle Ondine fin

View file

@ -0,0 +1,3 @@
nom;prenom;appartenance
Ombrelle;Iris;Club des Merles
Bruyère;Anouk;Chorale du Givre
1 nom prenom appartenance
2 Ombrelle Iris Club des Merles
3 Bruyère Anouk Chorale du Givre

3
test/fixtures/csv/entete_vide.csv vendored Normal file
View file

@ -0,0 +1,3 @@
nom;prenom;;appartenance
Ombrelle;Iris;note sans en-tête;Club des Merles
Pervenche;Théo;;
1 nom prenom appartenance
2 Ombrelle Iris note sans en-tête Club des Merles
3 Pervenche Théo

3
test/fixtures/csv/espaces_autour.csv vendored Normal file
View file

@ -0,0 +1,3 @@
 nom ; prenom ; appartenance ; courriel ; exclu
Ombrelle ; Iris ; Club des Merles ; iris@exemple.test ; Oui
Pervenche ; Théo ; ; ;
1 nom prenom appartenance courriel exclu
2 Ombrelle Iris Club des Merles iris@exemple.test Oui
3 Pervenche  Théo

View file

@ -0,0 +1,6 @@
nom;prenom;appartenance
Ombrelle;Iris;Club des Merles
Pervenche;Théo;
;;
Sarcelle;Ondine;Chorale du Givre
1 nom prenom appartenance
2 Ombrelle Iris Club des Merles
3 Pervenche Théo
4
5 Sarcelle Ondine Chorale du Givre

4
test/fixtures/csv/marque_crlf.csv vendored Normal file
View file

@ -0,0 +1,4 @@
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes
Ombrelle;Iris;Club des Merles;iris.ombrelle@exemple.test;animation;non;
Pervenche;Théo;;theo.pervenche@exemple.test;;;arrive tard
Sarcelle;Ondine;Chorale du Givre;;;oui;
1 nom prenom appartenance courriel titre_pressenti exclu notes
2 Ombrelle Iris Club des Merles iris.ombrelle@exemple.test animation non
3 Pervenche Théo theo.pervenche@exemple.test arrive tard
4 Sarcelle Ondine Chorale du Givre oui

BIN
test/fixtures/csv/utf16le_tabulation.csv vendored Normal file

Binary file not shown.
1 nom prenom appartenance notes
2 Ombrelle Iris Chœur de l’Anse café à 8 h
3 Pervenche Théo Club des Merles dit "oui" puis part

4
test/fixtures/csv/windows1252.csv vendored Normal file
View file

@ -0,0 +1,4 @@
nom;prenom;appartenance;notes
Ombrelle;Iris;Chœur de l’Anse;café à 8 h
Pervenche;Théo;Club des Merles;"dit ""oui""
puis part"
1 nom prenom appartenance notes
2 Ombrelle Iris Chœur de l’Anse café à 8 h
3 Pervenche Théo Club des Merles dit "oui" puis part