Compare commits
10 commits
b9090508ff
...
201259331d
| Author | SHA1 | Date | |
|---|---|---|---|
| 201259331d | |||
| 3b00566560 | |||
| 4003b1f833 | |||
| 35cc5f5f75 | |||
| fdaa79ed32 | |||
| f24896c4a1 | |||
| d0f4081961 | |||
| e1ea16d244 | |||
| 1d15ebaf68 | |||
| dd088f2dd9 |
47 changed files with 16747 additions and 1 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -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
314
src/csv/apercu.js
Normal 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
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
87
src/csv/colonnes.js
Normal 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
180
src/csv/colonnes.test.js
Normal 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
130
src/csv/encodage.js
Normal 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
353
src/csv/encodage.test.js
Normal 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
21
src/csv/erreurs.js
Normal 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
111
src/csv/export.js
Normal 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
193
src/csv/export.test.js
Normal 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
342
src/csv/import.js
Normal 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
858
src/csv/import.test.js
Normal 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
197
src/csv/lecture.js
Normal 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
564
src/csv/lecture.test.js
Normal 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
45
src/csv/normalisation.js
Normal 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, '');
|
||||||
|
}
|
||||||
110
src/csv/normalisation.test.js
Normal file
110
src/csv/normalisation.test.js
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
187
src/stockage/canonique.js
Normal 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`;
|
||||||
|
}
|
||||||
703
src/stockage/canonique.test.js
Normal file
703
src/stockage/canonique.test.js
Normal 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
336
src/stockage/correctifs.js
Normal 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;
|
||||||
|
}
|
||||||
1379
src/stockage/correctifs.test.js
Normal file
1379
src/stockage/correctifs.test.js
Normal file
File diff suppressed because it is too large
Load diff
641
src/stockage/document.js
Normal file
641
src/stockage/document.js
Normal 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;
|
||||||
|
}
|
||||||
953
src/stockage/document.test.js
Normal file
953
src/stockage/document.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
226
src/stockage/dossier_travail.js
Normal file
226
src/stockage/dossier_travail.js
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La règle du dossier de travail (§ 8.6), que l'application applique une
|
||||||
|
// fois par séance, au démarrage, sur le système de fichiers qu'elle reçoit
|
||||||
|
// (§ 13.4). Sous la plateforme web, « à côté de l'exécutable » ne désigne
|
||||||
|
// rien : le dossier de travail est la racine documents, et PLATEFORME_WEB
|
||||||
|
// annonce les garanties que la plateforme n'offre pas (§ 8.8). Ailleurs, la
|
||||||
|
// règle tient en cinq points :
|
||||||
|
//
|
||||||
|
// 1. le dossier de l'exécutable est celui que publie le lanceur portable,
|
||||||
|
// Emplacements.executable, jamais celui du processus, qu'une archive
|
||||||
|
// auto-extractible fait tourner dans un dossier temporaire ; non publié,
|
||||||
|
// la règle passe au point 5 (NON_PUBLIE) ;
|
||||||
|
// 2. sous un emplacement de données applicatives, elle passe au point 5
|
||||||
|
// sans sonder data/ (DONNEES_APPLICATIVES) : la sonde y réussirait, et
|
||||||
|
// data/ naîtrait là où le § 8.6 l'interdit ;
|
||||||
|
// 3. sinon, la sonde éprouve l'écriture dans data/, qu'elle crée ;
|
||||||
|
// 4. réussie, data/ est le dossier de travail : le mode portable ;
|
||||||
|
// 5. échouée (SONDE_ECHOUEE, avec sa cause) ou interrompue, le dossier de
|
||||||
|
// travail est la racine documents.
|
||||||
|
//
|
||||||
|
// Le dossier retenu est toujours sondé, et sa sonde le crée : au point 5 et
|
||||||
|
// sous web, la racine documents naît là, avant la réponse quand la règle
|
||||||
|
// demande, et aucune écriture de fichier ne la crée ensuite, puisqu'un
|
||||||
|
// dossier de travail disparu ne se recrée pas en silence. Hors des témoins
|
||||||
|
// des sondes, la règle ne fait que lire : en mode portable, rien ne s'écrit
|
||||||
|
// hors de data/ ; sous un emplacement de données applicatives, data/ n'est
|
||||||
|
// ni sondé ni créé.
|
||||||
|
//
|
||||||
|
// Ce qui arrive ensuite (§ 8.6) se lit dans le résultat :
|
||||||
|
//
|
||||||
|
// question le logiciel demande avant d'ouvrir la liste.
|
||||||
|
// PORTABLE_NON_INSCRIPTIBLE : la sonde de data/ échoue,
|
||||||
|
// et data/ porte des événements, ou ne se lit pas — leur
|
||||||
|
// compte est alors inconnu, null ; passer en silence aux
|
||||||
|
// Documents ouvrirait sur une liste où ils manquent.
|
||||||
|
// DOCUMENTS_NON_INSCRIPTIBLE : la sonde du dossier retenu
|
||||||
|
// échoue, et aucun geste ne s'y écrirait. La première
|
||||||
|
// l'emporte quand les deux tombent : elle seule nomme des
|
||||||
|
// événements qui existent, ou peuvent exister, ailleurs, et
|
||||||
|
// l'échec du dossier retenu se nomme encore à la première
|
||||||
|
// écriture refusée.
|
||||||
|
// autre le dossier retenu est neuf et vide — il ne porte aucune
|
||||||
|
// entrée — quand l'autre emplacement porte des événements :
|
||||||
|
// le logiciel propose de les importer, par copie (§ 8.7).
|
||||||
|
// Un dossier qui a servi, fût-ce pour une corbeille ou des
|
||||||
|
// réglages locaux, ne le propose plus à chaque séance ; un
|
||||||
|
// emplacement qui ne se lit pas ne propose rien.
|
||||||
|
// avertissements PLATEFORME_WEB, puis SUPPORT_AMOVIBLE quand le dossier
|
||||||
|
// retenu est sur un support amovible, où le renommage
|
||||||
|
// par-dessus ne vaut pas celui du volume système (§ 8.8).
|
||||||
|
//
|
||||||
|
// Un événement se compte par ses deux fichiers (§ 8.6) : chaque base qui
|
||||||
|
// porte un état ou un journal à la racine du dossier, un journal sans son
|
||||||
|
// état compris, dont l'événement se relève (§ 8.8). Le compte lit les noms
|
||||||
|
// sans ouvrir les fichiers ; l'appariement par identifiant interne reste
|
||||||
|
// celui de la liste des événements.
|
||||||
|
//
|
||||||
|
// Une lecture qui échoue autrement que sur un dossier absent ne vaut pas un
|
||||||
|
// dossier vide. Sur le dossier retenu, l'échec remonte : la séance n'y
|
||||||
|
// lirait pas sa liste. Sur l'autre emplacement, qui ne sert qu'à proposer un
|
||||||
|
// import ou à nommer ce qu'une bascule cacherait, il laisse un compte
|
||||||
|
// inconnu, et le dossier retenu reste. Un chemin refusé, et ce qui n'est pas
|
||||||
|
// une ErreurStockage, remontent d'où qu'ils viennent : une faute du code ou
|
||||||
|
// un processus coupé ne disent rien du disque.
|
||||||
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
import { SUFFIXES } from './noms.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./systeme_fichiers.js').Racine} Racine
|
||||||
|
* @typedef {import('./systeme_fichiers.js').SystemeFichiers} SystemeFichiers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le dossier de travail d'une séance, et ce que le logiciel doit en dire.
|
||||||
|
*
|
||||||
|
* @typedef {Object} DossierTravail
|
||||||
|
* @property {Racine} racine
|
||||||
|
* @property {'portable'|'documents'|'web'} mode
|
||||||
|
* @property {null|'NON_PUBLIE'|'DONNEES_APPLICATIVES'|'SONDE_ECHOUEE'} raison
|
||||||
|
* pourquoi le mode n'est pas portable ; null en mode portable et
|
||||||
|
* sous web
|
||||||
|
* @property {string|null} cause cause de l'échec de la sonde de data/
|
||||||
|
* (SONDE_ECHOUEE) ; null sinon
|
||||||
|
* @property {null|{code: 'PORTABLE_NON_INSCRIPTIBLE'|'DOCUMENTS_NON_INSCRIPTIBLE',
|
||||||
|
* racine: Racine, evenements: number|null, cause: string|null}} question
|
||||||
|
* le dossier qui refuse l'écriture, ses événements — null quand
|
||||||
|
* data/ ne se lit pas —, la cause de sa sonde : le logiciel
|
||||||
|
* demande avant d'ouvrir la liste
|
||||||
|
* @property {null|{racine: Racine, evenements: number}} autre
|
||||||
|
* le dossier retenu est neuf et vide, l'autre emplacement porte
|
||||||
|
* ces événements : le logiciel propose de les importer, par copie
|
||||||
|
* @property {Array<'PLATEFORME_WEB'|'SUPPORT_AMOVIBLE'>} avertissements
|
||||||
|
* dans cet ordre
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Les deux fichiers d'un événement (§ 8.6).
|
||||||
|
const FICHIERS_EVENEMENT = [SUFFIXES.etat, SUFFIXES.journal];
|
||||||
|
|
||||||
|
// Un caractère vers sa majuscule quand elle tient en un seul caractère, sans
|
||||||
|
// regarder le contexte, comme Windows compare deux noms : ß, dont la
|
||||||
|
// majuscule s'écrit SS, reste lui-même.
|
||||||
|
function majusculeSimple(caractere) {
|
||||||
|
const majuscule = caractere.toUpperCase();
|
||||||
|
return Array.from(majuscule).length === 1 ? majuscule : caractere;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un chemin tel que le système le compare : chaque caractère vers sa
|
||||||
|
// majuscule simple là où la casse ne compte pas, puis sans ses séparateurs
|
||||||
|
// finaux.
|
||||||
|
function comparable(chemin, insensibleCasse, separateur) {
|
||||||
|
let texte = insensibleCasse ? Array.from(chemin, majusculeSimple).join('') : chemin;
|
||||||
|
while (texte.endsWith(separateur)) texte = texte.slice(0, -1);
|
||||||
|
return texte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai quand chemin est parent ou se trouve sous lui, sur une frontière de
|
||||||
|
* segment : C:\Users\Exemple\AppDataBis n'est pas sous
|
||||||
|
* C:\Users\Exemple\AppData. Les deux chemins sont absolus, au séparateur du
|
||||||
|
* système, et comparés comme il compare les noms. Un séparateur final ne
|
||||||
|
* compte pas, si bien que la racine d'un volume, C:\ ou /, porte tout le
|
||||||
|
* volume.
|
||||||
|
*
|
||||||
|
* @param {string} chemin
|
||||||
|
* @param {string} parent
|
||||||
|
* @param {{insensibleCasse: boolean, separateur: '\\'|'/'}} systeme
|
||||||
|
* ceux des Emplacements
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function estSous(chemin, parent, { insensibleCasse, separateur }) {
|
||||||
|
const enfant = comparable(chemin, insensibleCasse, separateur);
|
||||||
|
const dossier = comparable(parent, insensibleCasse, separateur);
|
||||||
|
return enfant === dossier || enfant.startsWith(dossier + separateur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrées directes de la racine d'un dossier ; aucune pour un dossier
|
||||||
|
// absent. Tout autre échec de la lecture remonte : une panne n'est pas un
|
||||||
|
// dossier vide.
|
||||||
|
async function entrees(fs, racine) {
|
||||||
|
try {
|
||||||
|
return await fs.lister(racine, '');
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurStockage && erreur.code === 'ABSENT') return [];
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événements d'une liste d'entrées : les bases distinctes de ses fichiers
|
||||||
|
// d'état et de journal. Un dossier ne compte pas, fût-il nommé comme eux.
|
||||||
|
function compterEvenements(liste) {
|
||||||
|
const bases = new Set();
|
||||||
|
for (const { nom, type } of liste) {
|
||||||
|
const suffixe = FICHIERS_EVENEMENT.find((fin) => nom.endsWith(fin));
|
||||||
|
if (type === 'fichier' && suffixe !== undefined) bases.add(nom.slice(0, -suffixe.length));
|
||||||
|
}
|
||||||
|
return bases.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événements de l'autre emplacement ; null, compte inconnu, quand sa lecture
|
||||||
|
// échoue sur une ErreurStockage autre qu'un dossier absent ou un chemin
|
||||||
|
// refusé.
|
||||||
|
async function compterAilleurs(fs, ailleurs) {
|
||||||
|
try {
|
||||||
|
return compterEvenements(await entrees(fs, ailleurs));
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurStockage && erreur.code !== 'CHEMIN_REFUSE') return null;
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les points 1 à 5 : le mode, le dossier retenu, l'autre emplacement — null
|
||||||
|
// quand il n'y en a pas —, pourquoi data/ n'est pas retenu, et la sonde du
|
||||||
|
// dossier retenu quand elle a déjà eu lieu, null sinon.
|
||||||
|
async function appliquerRegle(fs, { portable, documents }) {
|
||||||
|
const repli = (raison, cause) => ({ mode: 'documents', racine: documents, ailleurs: portable, raison, cause, sonde: null });
|
||||||
|
if (fs.nature === 'web') {
|
||||||
|
return { mode: 'web', racine: documents, ailleurs: null, raison: null, cause: null, sonde: null };
|
||||||
|
}
|
||||||
|
const emplacements = await fs.emplacements();
|
||||||
|
if (emplacements.executable === null) return repli('NON_PUBLIE', null);
|
||||||
|
if (emplacements.donneesApplicatives.some((dossier) => estSous(emplacements.executable, dossier, emplacements))) {
|
||||||
|
return repli('DONNEES_APPLICATIVES', null);
|
||||||
|
}
|
||||||
|
const sonde = await fs.sonder(portable);
|
||||||
|
if (!sonde.inscriptible) return repli('SONDE_ECHOUEE', sonde.cause);
|
||||||
|
return { mode: 'portable', racine: portable, ailleurs: documents, raison: null, cause: null, sonde };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique la règle du dossier de travail (§ 8.6), une fois par séance ; la
|
||||||
|
* perte d'inscriptibilité en cours de séance se traite à l'écriture qui
|
||||||
|
* échoue, non ici. N'écrit que les témoins des sondes : celle de data/, aux
|
||||||
|
* points 3 et 4, et celle du dossier retenu, qui le crée.
|
||||||
|
*
|
||||||
|
* @param {SystemeFichiers} fs
|
||||||
|
* @returns {Promise<DossierTravail>}
|
||||||
|
* @throws ce que lève une primitive, hors d'un dossier absent à la lecture
|
||||||
|
* et, à celle de l'autre emplacement, d'une ErreurStockage autre que
|
||||||
|
* CHEMIN_REFUSE
|
||||||
|
*/
|
||||||
|
export async function determinerDossierTravail(fs) {
|
||||||
|
const issue = await appliquerRegle(fs, await fs.racines());
|
||||||
|
const { mode, racine, ailleurs, raison, cause } = issue;
|
||||||
|
const sonde = issue.sonde ?? (await fs.sonder(racine));
|
||||||
|
const ici = await entrees(fs, racine);
|
||||||
|
const evenementsAilleurs = ailleurs === null ? 0 : await compterAilleurs(fs, ailleurs);
|
||||||
|
|
||||||
|
// SONDE_ECHOUEE est la seule raison née d'une sonde de data/, qui est
|
||||||
|
// alors l'autre emplacement. Un compte inconnu demande comme un compte non
|
||||||
|
// nul : data/ peut porter ce qu'une bascule cacherait.
|
||||||
|
let question = null;
|
||||||
|
if (raison === 'SONDE_ECHOUEE' && evenementsAilleurs !== 0) {
|
||||||
|
question = { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: ailleurs, evenements: evenementsAilleurs, cause };
|
||||||
|
} else if (!sonde.inscriptible) {
|
||||||
|
question = { code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine, evenements: compterEvenements(ici), cause: sonde.cause };
|
||||||
|
}
|
||||||
|
// Un compte inconnu, null, ne propose rien : null n'est pas supérieur à 0.
|
||||||
|
const autre = ici.length === 0 && evenementsAilleurs > 0 ? { racine: ailleurs, evenements: evenementsAilleurs } : null;
|
||||||
|
|
||||||
|
const avertissements = [];
|
||||||
|
if (mode === 'web') avertissements.push('PLATEFORME_WEB');
|
||||||
|
if ((await fs.typeSupport(racine)) === 'amovible') avertissements.push('SUPPORT_AMOVIBLE');
|
||||||
|
return { racine, mode, raison, cause, question, autre, avertissements };
|
||||||
|
}
|
||||||
459
src/stockage/dossier_travail.test.js
Normal file
459
src/stockage/dossier_travail.test.js
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la règle du dossier de travail (§ 8.6, § 13.4, § 14.10), sur
|
||||||
|
// le système de fichiers d'épreuve : estSous, sur une frontière de segment et
|
||||||
|
// selon la casse du système ; les cinq issues simulées — chemin publié par le
|
||||||
|
// lanceur portable, exécutable sous un emplacement de données applicatives,
|
||||||
|
// sonde qui échoue, exécutable non publié, support amovible — et la
|
||||||
|
// plateforme web ; puis ce qui arrive ensuite : la question quand un dossier
|
||||||
|
// qui porte des événements refuse l'écriture, la proposition d'import quand
|
||||||
|
// le dossier retenu est neuf et vide, le compte des événements, et la
|
||||||
|
// lecture qui échoue : celle du dossier retenu fait rejeter, celle de l'autre
|
||||||
|
// emplacement laisse un compte inconnu. La perte d'inscriptibilité en cours
|
||||||
|
// de séance n'est pas de cette règle, qui ne s'applique qu'une fois.
|
||||||
|
//
|
||||||
|
// Le relevé des écritures de la règle — primitive et racine, dans l'ordre des
|
||||||
|
// appels — montre qu'elle n'en fait pas d'autre que ses sondes. Les noms
|
||||||
|
// d'épreuve sont inventés ; dans les données d'épreuve, un caractère hors de
|
||||||
|
// l'ASCII s'écrit en échappement.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { determinerDossierTravail, estSous } from './dossier_travail.js';
|
||||||
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
|
||||||
|
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
|
||||||
|
const DOC = { id: 'documents', chemin: DOCUMENTS };
|
||||||
|
const PORTABLE = { id: 'portable', chemin: 'E:\\soirees\\data' };
|
||||||
|
|
||||||
|
// Un exécutable publié sous un emplacement de données applicatives, comme
|
||||||
|
// l'y rangerait un installateur, et son data/.
|
||||||
|
const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programs\\gtt';
|
||||||
|
const DATA_SOUS_APPDATA = { id: 'portable', chemin: `${SOUS_APPDATA}\\data` };
|
||||||
|
|
||||||
|
const WINDOWS = { insensibleCasse: true, separateur: '\\' };
|
||||||
|
const UNIX = { insensibleCasse: false, separateur: '/' };
|
||||||
|
|
||||||
|
// Réglages du système d'épreuve sous Unix, sensible à la casse.
|
||||||
|
const SYSTEME_UNIX = {
|
||||||
|
separateur: '/',
|
||||||
|
insensibleCasse: false,
|
||||||
|
documents: '/home/exemple/Documents/Gestion table tournante Libre',
|
||||||
|
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Les primitives d'écriture de l'interface (systeme_fichiers.js).
|
||||||
|
const PRIMITIVES_ECRITURE = [
|
||||||
|
'ecrireAtomique',
|
||||||
|
'ajouterLigne',
|
||||||
|
'creerDossier',
|
||||||
|
'deplacer',
|
||||||
|
'supprimer',
|
||||||
|
'verrouiller',
|
||||||
|
'deverrouiller',
|
||||||
|
'sonder',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Un système d'épreuve, et le relevé de ses écritures : [primitive, racine],
|
||||||
|
// dans l'ordre des appels. Le reste — lectures, pannes, mains de l'épreuve —
|
||||||
|
// est celui du système simulé.
|
||||||
|
function preparer(options = {}) {
|
||||||
|
const simule = creerFichiersSimules(options);
|
||||||
|
const ecritures = [];
|
||||||
|
const fs = { ...simule };
|
||||||
|
for (const nom of PRIMITIVES_ECRITURE) {
|
||||||
|
fs[nom] = (racine, ...reste) => {
|
||||||
|
ecritures.push([nom, racine?.id]);
|
||||||
|
return simule[nom](racine, ...reste);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { fs, ecritures };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le résultat d'une détermination : les champs donnés, les autres à leur
|
||||||
|
// valeur quand rien n'est à demander, à proposer ni à annoncer.
|
||||||
|
const dossier = (champs) => ({ raison: null, cause: null, question: null, autre: null, avertissements: [], ...champs });
|
||||||
|
|
||||||
|
// Pose un événement sous une base : son état et son journal. La règle compte
|
||||||
|
// les fichiers sans les lire : leur contenu n'importe pas.
|
||||||
|
function poserEvenement(fs, racineId, base) {
|
||||||
|
fs.deposer(racineId, `${base}.gtt.json`, '{}\n');
|
||||||
|
fs.deposer(racineId, `${base}.gtt-journal.jsonl`, '{}\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejet de la liste d'un dossier qui n'existe pas.
|
||||||
|
const absent = (erreur) => erreur instanceof ErreurStockage && erreur.code === 'ABSENT';
|
||||||
|
|
||||||
|
// Une lecture que la plateforme refuse. Le contrat ne nomme d'échec de
|
||||||
|
// lecture que le dossier absent : une ErreurStockage d'un autre code, qui
|
||||||
|
// porte la cause de la plateforme, en tient lieu.
|
||||||
|
const lectureRefusee = (racine, cause) => new ErreurStockage('ECRITURE', { chemin: '', dossier: racine.chemin, cause });
|
||||||
|
|
||||||
|
// Le système d'épreuve dont lister lève erreur sur une racine, et lit les
|
||||||
|
// autres comme lui.
|
||||||
|
function listerEnPanne(fs, racineId, erreur) {
|
||||||
|
return {
|
||||||
|
...fs,
|
||||||
|
async lister(racine, dossier) {
|
||||||
|
if (racine.id === racineId) throw erreur;
|
||||||
|
return fs.lister(racine, dossier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('estSous', () => {
|
||||||
|
test("vrai pour le parent et sous lui, sur une frontière de segment : AppDataBis n'est pas sous AppData", () => {
|
||||||
|
const appData = 'C:\\Users\\Exemple\\AppData';
|
||||||
|
const cas = [
|
||||||
|
['C:\\Users\\Exemple\\AppData\\Local\\Temp\\x', 'C:\\Users\\Exemple\\AppData\\Local', WINDOWS, true],
|
||||||
|
[appData, appData, WINDOWS, true],
|
||||||
|
['C:\\Users\\Exemple\\AppDataBis', appData, WINDOWS, false],
|
||||||
|
['C:\\Users\\Exemple\\AppDataBis\\soirees', appData, WINDOWS, false],
|
||||||
|
['C:\\Users\\Exemple', appData, WINDOWS, false],
|
||||||
|
['E:\\soirees', appData, WINDOWS, false],
|
||||||
|
['/home/exemple/.config/outil', '/home/exemple/.config', UNIX, true],
|
||||||
|
['/home/exemple/.configBis', '/home/exemple/.config', UNIX, false],
|
||||||
|
// Sous Unix, la barre oblique inverse est un caractère du nom.
|
||||||
|
['/tmp\\x', '/tmp', UNIX, false],
|
||||||
|
];
|
||||||
|
for (const [chemin, parent, systeme, attendu] of cas) {
|
||||||
|
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un séparateur final ne compte pas, et la racine d'un volume porte tout le volume", () => {
|
||||||
|
const local = 'C:\\Users\\Exemple\\AppData\\Local';
|
||||||
|
const cas = [
|
||||||
|
[`${local}\\`, local, WINDOWS, true],
|
||||||
|
[`${local}\\x`, `${local}\\`, WINDOWS, true],
|
||||||
|
[`${local}x`, `${local}\\`, WINDOWS, false],
|
||||||
|
['C:\\soirees', 'C:\\', WINDOWS, true],
|
||||||
|
['C:\\', 'C:\\', WINDOWS, true],
|
||||||
|
['D:\\soirees', 'C:\\', WINDOWS, false],
|
||||||
|
['/tmp/x', '/', UNIX, true],
|
||||||
|
['/tmp', '/tmp/', UNIX, true],
|
||||||
|
['/tmpx', '/tmp/', UNIX, false],
|
||||||
|
];
|
||||||
|
for (const [chemin, parent, systeme, attendu] of cas) {
|
||||||
|
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans égard à la casse là où le système l'ignore, caractère par caractère comme Windows ; sensible ailleurs", () => {
|
||||||
|
const cas = [
|
||||||
|
['c:\\users\\exemple\\appdata\\local\\temp\\x', 'C:\\Users\\Exemple\\AppData\\Local', WINDOWS, true],
|
||||||
|
['C:\\USERS\\EXEMPLE\\APPDATA\\ROAMING', 'c:\\users\\exemple\\appdata\\roaming', WINDOWS, true],
|
||||||
|
// Au-delà de l'ASCII : É et é se confondent.
|
||||||
|
['C:\\Users\\\u{C9}T\u{C9}\\AppData\\Local\\x', 'C:\\Users\\\u{E9}t\u{E9}\\AppData\\Local', WINDOWS, true],
|
||||||
|
// ß n'a pas de majuscule d'un seul caractère : Windows ne le confond
|
||||||
|
// pas avec SS, deux noms, deux dossiers.
|
||||||
|
['C:\\Users\\STRASSE\\x', 'C:\\Users\\stra\u{DF}e', WINDOWS, false],
|
||||||
|
['/home/exemple/.config/outil', '/home/exemple/.CONFIG', UNIX, false],
|
||||||
|
['/TMP/x', '/tmp', UNIX, false],
|
||||||
|
];
|
||||||
|
for (const [chemin, parent, systeme, attendu] of cas) {
|
||||||
|
assert.equal(estSous(chemin, parent, systeme), attendu, `${chemin} sous ${parent}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('determinerDossierTravail : les cinq issues (§ 8.6, § 13.4)', () => {
|
||||||
|
test("publié sur E:\\soirees, sonde réussie : data/ est le dossier de travail, créé par la sonde, et rien ne s'écrit hors de lui", async () => {
|
||||||
|
const { fs, ecritures } = preparer({ executable: 'E:\\soirees' });
|
||||||
|
assert.deepEqual(await determinerDossierTravail(fs), dossier({ racine: PORTABLE, mode: 'portable' }));
|
||||||
|
assert.deepEqual(ecritures, [['sonder', 'portable']]);
|
||||||
|
assert.deepEqual(await fs.lister(PORTABLE, ''), []);
|
||||||
|
await assert.rejects(fs.lister(DOC, ''), absent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publié sous AppData\\Local\\Temp : documents, DONNEES_APPLICATIVES, et data/ ni sondé ni créé', async () => {
|
||||||
|
const executable = 'C:\\Users\\Exemple\\AppData\\Local\\Temp\\x';
|
||||||
|
const { fs, ecritures } = preparer({ executable });
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(fs),
|
||||||
|
dossier({ racine: DOC, mode: 'documents', raison: 'DONNEES_APPLICATIVES' }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(ecritures, [['sonder', 'documents']]);
|
||||||
|
await assert.rejects(fs.lister({ id: 'portable', chemin: `${executable}\\data` }, ''), absent);
|
||||||
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'exécutable se compare aux données applicatives selon la casse et le séparateur du système", async () => {
|
||||||
|
// AppData lui-même, et non ses seuls Roaming et Local : tout ce qu'il
|
||||||
|
// contient est sous lui, et rien de ce qui prolonge son nom.
|
||||||
|
const appData = ['C:\\Users\\Exemple\\AppData'];
|
||||||
|
const cas = [
|
||||||
|
[{ executable: 'c:\\users\\exemple\\appdata\\roaming\\outil' }, ['documents', 'DONNEES_APPLICATIVES']],
|
||||||
|
[{ donneesApplicatives: appData, executable: 'C:\\Users\\Exemple\\AppData\\LocalLow\\soirees' }, ['documents', 'DONNEES_APPLICATIVES']],
|
||||||
|
[{ donneesApplicatives: appData, executable: 'C:\\Users\\Exemple\\AppDataBis\\soirees' }, ['portable', null]],
|
||||||
|
[{ ...SYSTEME_UNIX, executable: '/tmp/montage-gtt' }, ['documents', 'DONNEES_APPLICATIVES']],
|
||||||
|
[{ ...SYSTEME_UNIX, executable: '/TMP/soirees' }, ['portable', null]],
|
||||||
|
[{ ...SYSTEME_UNIX, executable: '/media/cle/soirees' }, ['portable', null]],
|
||||||
|
];
|
||||||
|
for (const [options, attendu] of cas) {
|
||||||
|
const { fs } = preparer(options);
|
||||||
|
const { mode, raison } = await determinerDossierTravail(fs);
|
||||||
|
assert.deepEqual([mode, raison], attendu, options.executable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la sonde de data/ échoue (EACCES) : documents, SONDE_ECHOUEE et sa cause ; data/ ne porte rien, rien à demander', async () => {
|
||||||
|
const { fs, ecritures } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } });
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(fs),
|
||||||
|
dossier({ racine: DOC, mode: 'documents', raison: 'SONDE_ECHOUEE', cause: 'EACCES' }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(ecritures, [
|
||||||
|
['sonder', 'portable'],
|
||||||
|
['sonder', 'documents'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non publié : documents, NON_PUBLIE, et le dossier des Documents créé par sa sonde', async () => {
|
||||||
|
const { fs, ecritures } = preparer();
|
||||||
|
assert.deepEqual(await determinerDossierTravail(fs), dossier({ racine: DOC, mode: 'documents', raison: 'NON_PUBLIE' }));
|
||||||
|
assert.deepEqual(ecritures, [['sonder', 'documents']]);
|
||||||
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support amovible : SUPPORT_AMOVIBLE, pour le seul dossier de travail retenu', async () => {
|
||||||
|
const cas = [
|
||||||
|
[{ executable: 'E:\\soirees', support: { portable: 'amovible' } }, ['portable', ['SUPPORT_AMOVIBLE']]],
|
||||||
|
// data/ sur la clé refuse l'écriture : le dossier retenu est fixe.
|
||||||
|
[{ executable: 'E:\\soirees', support: { portable: 'amovible' }, sonde: { portable: 'EROFS' } }, ['documents', []]],
|
||||||
|
[{ support: { documents: 'amovible' } }, ['documents', ['SUPPORT_AMOVIBLE']]],
|
||||||
|
// Un support inconnu n'est pas annoncé amovible.
|
||||||
|
[{ executable: 'E:\\soirees', support: { portable: 'inconnu' } }, ['portable', []]],
|
||||||
|
// Les deux avertissements, dans l'ordre que le résultat promet.
|
||||||
|
[{ ...SYSTEME_UNIX, nature: 'web', support: { documents: 'amovible' } }, ['web', ['PLATEFORME_WEB', 'SUPPORT_AMOVIBLE']]],
|
||||||
|
];
|
||||||
|
for (const [options, attendu] of cas) {
|
||||||
|
const { fs } = preparer(options);
|
||||||
|
const { mode, avertissements } = await determinerDossierTravail(fs);
|
||||||
|
assert.deepEqual([mode, avertissements], attendu, JSON.stringify(options));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web : la racine documents, mode web, PLATEFORME_WEB ; un dossier publié n'y désigne rien", async () => {
|
||||||
|
const racine = { id: 'documents', chemin: '/gestion_table_tournante_libre' };
|
||||||
|
const { fs, ecritures } = preparer({
|
||||||
|
nature: 'web',
|
||||||
|
renommageAtomique: false,
|
||||||
|
verrouDisponible: false,
|
||||||
|
separateur: '/',
|
||||||
|
insensibleCasse: false,
|
||||||
|
documents: racine.chemin,
|
||||||
|
executable: '/media/cle/soirees',
|
||||||
|
});
|
||||||
|
poserEvenement(fs, 'portable', 'gala-printemps');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(fs),
|
||||||
|
dossier({ racine, mode: 'web', avertissements: ['PLATEFORME_WEB'] }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(ecritures, [['sonder', 'documents']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('determinerDossierTravail : ce qui arrive ensuite (§ 8.6)', () => {
|
||||||
|
test("data/ porte deux événements et refuse l'écriture : question avec le compte, et rien ne bascule en silence", async () => {
|
||||||
|
const { fs, ecritures } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
|
||||||
|
poserEvenement(fs, 'portable', 'gala-printemps');
|
||||||
|
poserEvenement(fs, 'portable', 'souper-benefice');
|
||||||
|
const avant = await fs.lister(PORTABLE, '');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(fs),
|
||||||
|
dossier({
|
||||||
|
racine: DOC,
|
||||||
|
mode: 'documents',
|
||||||
|
raison: 'SONDE_ECHOUEE',
|
||||||
|
cause: 'EROFS',
|
||||||
|
question: { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 2, cause: 'EROFS' },
|
||||||
|
autre: { racine: PORTABLE, evenements: 2 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Seules les sondes écrivent : data/ reste tel quel, et rien n'en est
|
||||||
|
// copié dans les Documents avant que l'opérateur ait répondu.
|
||||||
|
assert.deepEqual(ecritures, [
|
||||||
|
['sonder', 'portable'],
|
||||||
|
['sonder', 'documents'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(await fs.lister(PORTABLE, ''), avant);
|
||||||
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un événement compte par ses deux fichiers, à la racine du dossier : un état, un journal, ou les deux sous la même base", async () => {
|
||||||
|
const { fs } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
|
||||||
|
// Trois événements : état et journal, état seul, journal seul.
|
||||||
|
poserEvenement(fs, 'portable', 'gala');
|
||||||
|
fs.deposer('portable', 'souper.gtt.json', '{}\n');
|
||||||
|
fs.deposer('portable', 'tournoi.gtt-journal.jsonl', '{}\n');
|
||||||
|
// Aucun des suivants n'en est un.
|
||||||
|
fs.deposer('portable', 'kermesse.gtt.json.precedent', '{}\n');
|
||||||
|
fs.deposer('portable', 'kermesse.gtt.verrou', '{}\n');
|
||||||
|
fs.deposer('portable', 'kermesse.gtt.json.ecriture', '{}\n');
|
||||||
|
fs.deposer('portable', 'reglages_locaux.json', '{}\n');
|
||||||
|
fs.deposer('portable', 'corbeille/2026-01-02_03-04-05/bazar.gtt.json', '{}\n');
|
||||||
|
fs.deposerDossier('portable', 'archives.gtt.json');
|
||||||
|
const { question } = await determinerDossierTravail(fs);
|
||||||
|
assert.deepEqual(question, { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 3, cause: 'EROFS' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data/ qui ne porte que des restes d'événements ne fait rien demander", async () => {
|
||||||
|
const { fs } = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EROFS' } });
|
||||||
|
fs.deposer('portable', 'kermesse.gtt.json.precedent', '{}\n');
|
||||||
|
fs.deposer('portable', 'corbeille/2026-01-02_03-04-05/bazar.gtt.json', '{}\n');
|
||||||
|
const { question, raison } = await determinerDossierTravail(fs);
|
||||||
|
assert.deepEqual([question, raison], [null, 'SONDE_ECHOUEE']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le dossier retenu neuf et vide, l'autre emplacement porte des événements : autre, dans les deux sens", async () => {
|
||||||
|
const versDocuments = preparer({ executable: 'E:\\soirees' });
|
||||||
|
for (const base of ['gala', 'souper', 'tournoi']) poserEvenement(versDocuments.fs, 'documents', base);
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(versDocuments.fs),
|
||||||
|
dossier({ racine: PORTABLE, mode: 'portable', autre: { racine: DOC, evenements: 3 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// data/ sous AppData se lit sans se sonder.
|
||||||
|
const versData = preparer({ executable: SOUS_APPDATA });
|
||||||
|
poserEvenement(versData.fs, 'portable', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(versData.fs),
|
||||||
|
dossier({
|
||||||
|
racine: DOC,
|
||||||
|
mode: 'documents',
|
||||||
|
raison: 'DONNEES_APPLICATIVES',
|
||||||
|
autre: { racine: DATA_SOUS_APPDATA, evenements: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(versData.ecritures, [['sonder', 'documents']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un dossier retenu qui porte la moindre entrée n'est pas neuf, et un emplacement sans événement ne propose rien", async () => {
|
||||||
|
// data/ a servi : sa corbeille le dit.
|
||||||
|
const corbeille = preparer({ executable: 'E:\\soirees' });
|
||||||
|
corbeille.fs.deposerDossier('portable', 'corbeille/2026-01-02_03-04-05');
|
||||||
|
poserEvenement(corbeille.fs, 'documents', 'gala');
|
||||||
|
// Les Documents ont servi : leurs réglages locaux le disent.
|
||||||
|
const reglages = preparer({ executable: SOUS_APPDATA });
|
||||||
|
reglages.fs.deposer('documents', 'reglages_locaux.json', '{}\n');
|
||||||
|
poserEvenement(reglages.fs, 'portable', 'gala');
|
||||||
|
// Les Documents n'ont que des restes d'événements.
|
||||||
|
const restes = preparer({ executable: 'E:\\soirees' });
|
||||||
|
restes.fs.deposer('documents', 'gala.gtt.json.precedent', '{}\n');
|
||||||
|
restes.fs.deposerDossier('documents', 'corbeille');
|
||||||
|
for (const [nom, { fs }] of [['corbeille', corbeille], ['reglages', reglages], ['restes', restes]]) {
|
||||||
|
assert.equal((await determinerDossierTravail(fs)).autre, null, nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un témoin resté d'une séance précédente ne compte pas : la sonde l'efface avant que la règle lise le dossier", async () => {
|
||||||
|
const portable = preparer({ executable: 'E:\\soirees' });
|
||||||
|
portable.fs.deposer('portable', '.gtt-temoin', '.gtt-temoin');
|
||||||
|
poserEvenement(portable.fs, 'documents', 'gala');
|
||||||
|
assert.deepEqual((await determinerDossierTravail(portable.fs)).autre, { racine: DOC, evenements: 1 });
|
||||||
|
assert.equal(portable.fs.contenu('portable', '.gtt-temoin'), null);
|
||||||
|
|
||||||
|
const documents = preparer({ executable: SOUS_APPDATA });
|
||||||
|
documents.fs.deposer('documents', '.gtt-temoin', '.gtt-temoin');
|
||||||
|
poserEvenement(documents.fs, 'portable', 'gala');
|
||||||
|
assert.deepEqual((await determinerDossierTravail(documents.fs)).autre, { racine: DATA_SOUS_APPDATA, evenements: 1 });
|
||||||
|
assert.equal(documents.fs.contenu('documents', '.gtt-temoin'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la sonde du dossier retenu échoue : DOCUMENTS_NON_INSCRIPTIBLE ; data/ qui porte des événements garde la priorité", async () => {
|
||||||
|
const nonPublie = preparer({ sonde: { documents: 'EPERM' } });
|
||||||
|
poserEvenement(nonPublie.fs, 'documents', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(nonPublie.fs),
|
||||||
|
dossier({
|
||||||
|
racine: DOC,
|
||||||
|
mode: 'documents',
|
||||||
|
raison: 'NON_PUBLIE',
|
||||||
|
question: { code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: DOC, evenements: 1, cause: 'EPERM' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const racineWeb = { id: 'documents', chemin: '/gestion_table_tournante_libre' };
|
||||||
|
const web = preparer({ ...SYSTEME_UNIX, nature: 'web', documents: racineWeb.chemin, sonde: { documents: 'QuotaExceededError' } });
|
||||||
|
assert.deepEqual(
|
||||||
|
(await determinerDossierTravail(web.fs)).question,
|
||||||
|
{ code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: racineWeb, evenements: 0, cause: 'QuotaExceededError' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const lesDeux = { executable: 'E:\\soirees', sonde: { portable: 'EROFS', documents: 'EPERM' } };
|
||||||
|
const avecEvenements = preparer(lesDeux);
|
||||||
|
poserEvenement(avecEvenements.fs, 'portable', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
(await determinerDossierTravail(avecEvenements.fs)).question,
|
||||||
|
{ code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 1, cause: 'EROFS' },
|
||||||
|
);
|
||||||
|
const sansEvenement = preparer(lesDeux);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await determinerDossierTravail(sansEvenement.fs)).question,
|
||||||
|
{ code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: DOC, evenements: 0, cause: 'EPERM' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la lecture du dossier retenu qui échoue autrement que sur un dossier absent remonte : une panne n'est pas un dossier vide", async () => {
|
||||||
|
// Le processus meurt après la sonde de data/, sa seule écriture.
|
||||||
|
const coupe = preparer({ executable: 'E:\\soirees' });
|
||||||
|
coupe.fs.pannes.couperApres(1);
|
||||||
|
await assert.rejects(determinerDossierTravail(coupe.fs), PanneSimulee);
|
||||||
|
|
||||||
|
// data/ retenu, puis les Documents retenus, refusent leur lecture.
|
||||||
|
const cas = [
|
||||||
|
[{ executable: 'E:\\soirees' }, PORTABLE],
|
||||||
|
[{ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } }, DOC],
|
||||||
|
];
|
||||||
|
for (const [options, racine] of cas) {
|
||||||
|
const refus = lectureRefusee(racine, 'EIO');
|
||||||
|
const { fs } = preparer(options);
|
||||||
|
await assert.rejects(determinerDossierTravail(listerEnPanne(fs, racine.id, refus)), (erreur) => erreur === refus, racine.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la lecture de l'autre emplacement qui échoue laisse le dossier retenu : rien ne se propose, et data/ qui refuse l'écriture fait demander, compte inconnu", async () => {
|
||||||
|
// Les Documents, sur un partage injoignable, portent peut-être des
|
||||||
|
// événements : data/ reste le dossier de travail, sans proposition.
|
||||||
|
const portable = preparer({ executable: 'E:\\soirees' });
|
||||||
|
poserEvenement(portable.fs, 'documents', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(listerEnPanne(portable.fs, 'documents', lectureRefusee(DOC, 'ETIMEDOUT'))),
|
||||||
|
dossier({ racine: PORTABLE, mode: 'portable' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// data/ sous AppData ne se lit pas : rien à proposer, rien à demander.
|
||||||
|
const sousAppData = preparer({ executable: SOUS_APPDATA });
|
||||||
|
poserEvenement(sousAppData.fs, 'portable', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(listerEnPanne(sousAppData.fs, 'portable', lectureRefusee(DATA_SOUS_APPDATA, 'EPERM'))),
|
||||||
|
dossier({ racine: DOC, mode: 'documents', raison: 'DONNEES_APPLICATIVES' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// data/ refuse l'écriture et la lecture, sous des droits posés par un
|
||||||
|
// autre poste : ce qu'il porte est inconnu, et la règle demande plutôt
|
||||||
|
// que de basculer en silence.
|
||||||
|
const etranger = preparer({ executable: 'E:\\soirees', sonde: { portable: 'EPERM' } });
|
||||||
|
poserEvenement(etranger.fs, 'portable', 'gala');
|
||||||
|
assert.deepEqual(
|
||||||
|
await determinerDossierTravail(listerEnPanne(etranger.fs, 'portable', lectureRefusee(PORTABLE, 'EPERM'))),
|
||||||
|
dossier({
|
||||||
|
racine: DOC,
|
||||||
|
mode: 'documents',
|
||||||
|
raison: 'SONDE_ECHOUEE',
|
||||||
|
cause: 'EPERM',
|
||||||
|
question: { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: null, cause: 'EPERM' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un chemin refusé, ou une erreur hors du stockage, remonte même de l'autre emplacement : une faute du code n'est pas un état du disque", async () => {
|
||||||
|
for (const erreur of [new ErreurStockage('CHEMIN_REFUSE', { chemin: '' }), new Error('canal rompu')]) {
|
||||||
|
const { fs } = preparer({ executable: 'E:\\soirees' });
|
||||||
|
await assert.rejects(determinerDossierTravail(listerEnPanne(fs, 'documents', erreur)), (recue) => recue === erreur, erreur.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/stockage/erreurs.js
Normal file
22
src/stockage/erreurs.js
Normal 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
459
src/stockage/journal.js
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
392
src/stockage/journal.long.test.js
Normal file
392
src/stockage/journal.long.test.js
Normal 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
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
287
src/stockage/noms.js
Normal 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
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
545
src/stockage/placements.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
1282
src/stockage/placements.test.js
Normal file
1282
src/stockage/placements.test.js
Normal file
File diff suppressed because it is too large
Load diff
161
src/stockage/systeme_fichiers.js
Normal file
161
src/stockage/systeme_fichiers.js
Normal 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;
|
||||||
|
}
|
||||||
84
src/stockage/systeme_fichiers.test.js
Normal file
84
src/stockage/systeme_fichiers.test.js
Normal 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
425
src/stockage/types.js
Normal 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
314
test/contrat_fichiers.js
Normal 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
517
test/fichiers_simules.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
589
test/fichiers_simules.test.js
Normal file
589
test/fichiers_simules.test.js
Normal 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
5
test/fixtures/csv/champ_cite.csv
vendored
Normal 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
|
||||||
|
3
test/fixtures/csv/derniere_ligne_sans_fin.csv
vendored
Normal file
3
test/fixtures/csv/derniere_ligne_sans_fin.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
nom;prenom;appartenance
|
||||||
|
Ombrelle;Iris;Club des Merles
|
||||||
|
Bruyère;Anouk;Chorale du Givre
|
||||||
|
3
test/fixtures/csv/entete_vide.csv
vendored
Normal file
3
test/fixtures/csv/entete_vide.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
nom;prenom;;appartenance
|
||||||
|
Ombrelle;Iris;note sans en-tête;Club des Merles
|
||||||
|
Pervenche;Théo;;
|
||||||
|
3
test/fixtures/csv/espaces_autour.csv
vendored
Normal file
3
test/fixtures/csv/espaces_autour.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
nom ; prenom ; appartenance ; courriel ; exclu
|
||||||
|
Ombrelle ; Iris ; Club des Merles ; iris@exemple.test ; Oui
|
||||||
|
Pervenche ; Théo ; ; ;
|
||||||
|
6
test/fixtures/csv/ligne_vide_au_milieu.csv
vendored
Normal file
6
test/fixtures/csv/ligne_vide_au_milieu.csv
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
nom;prenom;appartenance
|
||||||
|
Ombrelle;Iris;Club des Merles
|
||||||
|
|
||||||
|
Pervenche;Théo;
|
||||||
|
;;
|
||||||
|
Sarcelle;Ondine;Chorale du Givre
|
||||||
|
4
test/fixtures/csv/marque_crlf.csv
vendored
Normal file
4
test/fixtures/csv/marque_crlf.csv
vendored
Normal 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;
|
||||||
|
BIN
test/fixtures/csv/utf16le_tabulation.csv
vendored
Normal file
BIN
test/fixtures/csv/utf16le_tabulation.csv
vendored
Normal file
Binary file not shown.
|
4
test/fixtures/csv/windows1252.csv
vendored
Normal file
4
test/fixtures/csv/windows1252.csv
vendored
Normal 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"
|
||||||
|
Loading…
Reference in a new issue