Compare commits
4 commits
fa2fc0eb2b
...
481f6a6bae
| Author | SHA1 | Date | |
|---|---|---|---|
| 481f6a6bae | |||
| 9530eca507 | |||
| a3d7badf6d | |||
| 220814a673 |
24 changed files with 7709 additions and 87 deletions
|
|
@ -331,16 +331,18 @@ export function creerFichiers({
|
|||
if (portable !== null) bases.set('portable', portable);
|
||||
let choisis = 0;
|
||||
|
||||
// Ce que désigne un chemin relatif à une racine : sa racine et sa cible en
|
||||
// absolu, et le dossier que nomme un échec — celui qui porte la cible, la
|
||||
// racine elle-même pour ''. Seul le texte se lit : CHEMIN_REFUSE pour un
|
||||
// chemin que la règle des segments refuse ou une racine inconnue.
|
||||
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
||||
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
|
||||
// la cible, la racine elle-même pour ''. Seul le texte se lit :
|
||||
// CHEMIN_REFUSE pour un chemin que la règle des segments refuse ou une
|
||||
// racine inconnue.
|
||||
function localiser(racine, chemin) {
|
||||
const segments = segmentsAdmis(chemin);
|
||||
const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined;
|
||||
if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin });
|
||||
return {
|
||||
chemin,
|
||||
segments,
|
||||
racine: chemins.join(base),
|
||||
absolu: chemins.join(base, ...segments),
|
||||
dossier: chemins.join(base, ...segments.slice(0, -1)),
|
||||
|
|
@ -650,11 +652,27 @@ export function creerFichiers({
|
|||
}
|
||||
},
|
||||
|
||||
// '' crée la racine, parents compris. Un autre chemin se crée dossier par
|
||||
// dossier sous la racine, sans recursive : mkdir lève ENOENT quand son
|
||||
// parent manque, si bien qu'une racine qui disparaît, même entre deux
|
||||
// appels, ne renaît pas (§ 8.6). Un dossier présent passe ; un fichier en
|
||||
// travers lève EEXIST sur le chemin même, ENOTDIR au-dessus.
|
||||
async creerDossier(racine, chemin) {
|
||||
const loc = localiser(racine, chemin);
|
||||
const { segments } = loc;
|
||||
try {
|
||||
await confiner(loc);
|
||||
await fs.mkdir(loc.absolu, { recursive: true });
|
||||
if (segments.length === 0) await fs.mkdir(loc.absolu, { recursive: true });
|
||||
for (let rang = 1; rang <= segments.length; rang += 1) {
|
||||
const dossier = chemins.join(loc.racine, ...segments.slice(0, rang));
|
||||
try {
|
||||
await fs.mkdir(dossier);
|
||||
} catch (erreur) {
|
||||
if (erreur?.code !== 'EEXIST') throw erreur;
|
||||
const cause = rang === segments.length ? 'EEXIST' : 'ENOTDIR';
|
||||
if (!(await etatSuivi(dossier))?.isDirectory()) throw refus(loc, cause);
|
||||
}
|
||||
}
|
||||
} catch (erreur) {
|
||||
throw echecEcriture(loc, erreur);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,5 +33,8 @@
|
|||
"svelte": "^5.57.1",
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron-winstaller": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
461
src/application/commandes.js
Normal file
461
src/application/commandes.js
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les commandes nommées (§ 8.2) : toute modification d'un événement passe par
|
||||
// l'une d'elles. Une commande est une fonction pure (charge, arguments) →
|
||||
// { charge, libelle, avertissements } : la charge rendue est neuve, celle
|
||||
// reçue ne change jamais ; le libellé est le texte français que le journal
|
||||
// fige (libelles.js) ; les avertissements, { code, details }, disent ce que le
|
||||
// geste emporte de plus que son effet, sans rien empêcher. Un refus lève
|
||||
// ErreurCommande(code, details), où details.remede nomme le geste qui le lève,
|
||||
// null quand il n'y en a pas ; une faute du code — un argument de forme
|
||||
// fausse, un état ou un champ inconnus — lève TypeError. Aucune commande
|
||||
// n'écrit, ne lit l'horloge ni ne tire un aléa.
|
||||
//
|
||||
// La saisie entre en NFC, ses blancs de bord retirés, une chaîne vide valant
|
||||
// null (§ 10.2) ; la règle d'un champ est celle du schéma du fichier d'état,
|
||||
// que document.js tient seul. Les identifiants viennent de prochainsIds, qui
|
||||
// ne recule jamais : un identifiant retiré ne revient pas (§ 4, § 5.7). Un
|
||||
// plan bloqué refuse toute commande, débloquer excepté (§ 9).
|
||||
//
|
||||
// Les états du plan (§ 9) : brouillon, proposé et retenu se posent par
|
||||
// changerEtat sans rien détruire, proposé quand une proposition existe,
|
||||
// retenu quand un placement est retenu ; bloqué se pose de chacun d'eux, et
|
||||
// seul debloquer le lève, vers l'état que le contenu impose (etatDeduit). Une
|
||||
// génération fait passer un brouillon à proposé ; retenir pose retenu ;
|
||||
// effacer les propositions ramène un plan proposé sans retenu à brouillon.
|
||||
import { ErreurCsv } from '../csv/erreurs.js';
|
||||
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
|
||||
import { serialiserCharge } from '../stockage/canonique.js';
|
||||
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||
import { versFichier } from '../stockage/placements.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
/**
|
||||
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
|
||||
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
|
||||
* le texte affiché vient de la table des libellés (§ 14.6).
|
||||
*/
|
||||
export class ErreurCommande extends Error {
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {Object} [details]
|
||||
*/
|
||||
constructor(code, details = {}) {
|
||||
super(`${code} ${JSON.stringify(details)}`);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
ErreurCommande.prototype.name = 'ErreurCommande';
|
||||
|
||||
// Le refus de code donné : ses détails, puis le remède, null sans lui.
|
||||
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
|
||||
|
||||
// Un avertissement : il n'empêche rien.
|
||||
const avertissement = (code, details) => ({ code, details });
|
||||
|
||||
// Règles du schéma : celle d'un champ d'objet, puis celles de l'événement et
|
||||
// d'un participant.
|
||||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||||
const EVENEMENT = regleDuChamp(CHARGE, 'evenement');
|
||||
const PARTICIPANT = regleDuChamp(CHARGE, 'participants').element;
|
||||
// Le plus grand identifiant qu'admet la règle : le compteur, qui la suit,
|
||||
// n'aurait plus de valeur au-delà de lui.
|
||||
const IDENTIFIANT_MAX = regleDuChamp(PARTICIPANT, 'id').max;
|
||||
|
||||
const ETATS = ['brouillon', 'propose', 'retenu', 'bloque'];
|
||||
|
||||
// Les champs de texte d'un participant que la saisie pose, dans l'ordre du
|
||||
// fichier.
|
||||
const CHAMPS_DE_SAISIE = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
|
||||
|
||||
// Une saisie de texte : en NFC, ses blancs de bord retirés, null quand il ne
|
||||
// reste rien ; null et l'absence valent null.
|
||||
function saisie(valeur, champ) {
|
||||
if (valeur === undefined || valeur === null) return null;
|
||||
if (typeof valeur !== 'string') {
|
||||
throw new TypeError(`${champ} : chaîne ou null attendue, reçu ${JSON.stringify(valeur)}`);
|
||||
}
|
||||
const texte = valeur.normalize('NFC').trim();
|
||||
return texte === '' ? null : texte;
|
||||
}
|
||||
|
||||
// Les champs d'une saisie de participant : chaque champ de texte présent, lu
|
||||
// par saisie, et exclu quand admisExclu, un booléen. Un champ inconnu, ou
|
||||
// d'une autre sorte, est une faute du code.
|
||||
function lireChamps(champs, { admisExclu }) {
|
||||
if (champs === null || typeof champs !== 'object' || Array.isArray(champs)) {
|
||||
throw new TypeError(`champs : objet attendu, reçu ${JSON.stringify(champs)}`);
|
||||
}
|
||||
const permis = admisExclu ? [...CHAMPS_DE_SAISIE, 'exclu'] : CHAMPS_DE_SAISIE;
|
||||
const inconnu = clesRangees(champs).find((cle) => !permis.includes(cle));
|
||||
if (inconnu !== undefined) throw new TypeError(`champs : ${inconnu} n'est pas un champ de cette saisie`);
|
||||
const lus = {};
|
||||
for (const champ of CHAMPS_DE_SAISIE) if (Object.hasOwn(champs, champ)) lus[champ] = saisie(champs[champ], champ);
|
||||
if (Object.hasOwn(champs, 'exclu')) {
|
||||
if (typeof champs.exclu !== 'boolean') {
|
||||
throw new TypeError(`exclu : booléen attendu, reçu ${JSON.stringify(champs.exclu)}`);
|
||||
}
|
||||
lus.exclu = champs.exclu;
|
||||
}
|
||||
return lus;
|
||||
}
|
||||
|
||||
// Un participant tel que la saisie le pose : nom requis (NOM_REQUIS) ; toute
|
||||
// autre valeur hors de sa règle est une faute du code.
|
||||
function exigerParticipant(participant) {
|
||||
if (premiereFaute(participant.nom, regleDuChamp(PARTICIPANT, 'nom')) !== null) throw refus('NOM_REQUIS');
|
||||
const faute = premiereFaute(participant, PARTICIPANT);
|
||||
if (faute !== null) throw new TypeError(`participant : ${faute} hors de sa règle`);
|
||||
}
|
||||
|
||||
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
|
||||
function participantDe(charge, id) {
|
||||
if (!Number.isSafeInteger(id)) throw new TypeError(`id : entier attendu, reçu ${JSON.stringify(id)}`);
|
||||
const participant = charge.participants.find((candidat) => candidat.id === id);
|
||||
if (participant === undefined) throw refus('PARTICIPANT_INCONNU', { id });
|
||||
return participant;
|
||||
}
|
||||
|
||||
// Le nom d'une personne tel que les libellés l'écrivent : prénom, puis nom.
|
||||
const personneDe = ({ nom, prenom }) => (prenom === null ? nom : `${prenom} ${nom}`);
|
||||
|
||||
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
||||
function exigerModifiable(charge) {
|
||||
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
||||
}
|
||||
|
||||
// Titres de place pourvus (§ 4.1, § 4.4) : le compte que bilanRemplacement
|
||||
// tient pour l'import, seule implémentation de la règle (§ 13.2).
|
||||
const titresPourvus = (charge) => bilanRemplacement(charge).titresPourvus;
|
||||
|
||||
// La charge reçue, copiée, son participant id remplacé par ce que rend
|
||||
// modifier de sa copie.
|
||||
function avecParticipant(charge, id, modifier) {
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.participants = suivante.participants.map((participant) =>
|
||||
participant.id === id ? modifier(participant) : participant,
|
||||
);
|
||||
return suivante;
|
||||
}
|
||||
|
||||
// La charge reçue, copiée, à l'état donné.
|
||||
function avecEtat(charge, etat) {
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.evenement.etat = etat;
|
||||
return suivante;
|
||||
}
|
||||
|
||||
/** ajouterParticipant {champs} : seul le nom est requis ; exclu vaut faux par défaut. */
|
||||
function ajouterParticipant(charge, { champs }) {
|
||||
exigerModifiable(charge);
|
||||
const lus = lireChamps(champs, { admisExclu: true });
|
||||
const id = charge.prochainsIds.participant;
|
||||
const participant = {
|
||||
id,
|
||||
nom: lus.nom ?? null,
|
||||
prenom: lus.prenom ?? null,
|
||||
appartenance: lus.appartenance ?? null,
|
||||
courriel: lus.courriel ?? null,
|
||||
titrePressenti: lus.titrePressenti ?? null,
|
||||
notes: lus.notes ?? null,
|
||||
exclu: lus.exclu ?? false,
|
||||
};
|
||||
exigerParticipant(participant);
|
||||
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'participant' });
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.participants.push(participant);
|
||||
suivante.prochainsIds.participant = id + 1;
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }),
|
||||
avertissements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* modifierParticipant {id, champs} : les champs de texte donnés ; l'exclusion
|
||||
* a ses deux commandes. Le libellé nomme la personne d'avant et chaque champ
|
||||
* changé, puis le nouveau nom quand il change.
|
||||
*/
|
||||
function modifierParticipant(charge, { id, champs }) {
|
||||
exigerModifiable(charge);
|
||||
const avant = participantDe(charge, id);
|
||||
const apres = { ...avant, ...lireChamps(champs, { admisExclu: false }) };
|
||||
exigerParticipant(apres);
|
||||
const changes = CHAMPS_DE_SAISIE.filter((champ) => apres[champ] !== avant[champ]);
|
||||
if (changes.length === 0) throw refus('SANS_EFFET', { commande: 'modifierParticipant' });
|
||||
const renomme = apres.nom !== avant.nom || apres.prenom !== avant.prenom;
|
||||
return {
|
||||
charge: avecParticipant(charge, id, () => apres),
|
||||
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
|
||||
personne: personneDe(avant),
|
||||
champs: changes,
|
||||
devenu: renomme ? personneDe(apres) : null,
|
||||
}),
|
||||
avertissements: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Les comptes d'une exclusion ou d'une réintégration (§ 4.4) : les
|
||||
// réservations de la personne, que l'exclusion suspend sans les retirer, et
|
||||
// les titres que le geste cesse de pourvoir, ou pourvoit de nouveau.
|
||||
function bilanExclusion(avant, apres, participant) {
|
||||
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
|
||||
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
|
||||
return { participant: participant.id, personne: personneDe(participant), reservations, titres };
|
||||
}
|
||||
|
||||
/**
|
||||
* exclureParticipant {id} (§ 4.4) : ses réservations restent, suspendues ; ses
|
||||
* places reviennent aux autres et ses titres ne sont plus pourvus, ce que
|
||||
* RESERVATIONS_SUSPENDUES dit quand il en porte.
|
||||
*/
|
||||
function exclureParticipant(charge, { id }) {
|
||||
exigerModifiable(charge);
|
||||
const participant = participantDe(charge, id);
|
||||
if (participant.exclu) throw refus('SANS_EFFET', { commande: 'exclureParticipant' });
|
||||
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: true }));
|
||||
const bilan = bilanExclusion(charge, suivante, participant);
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_EXCLURE_PARTICIPANT', { personne: bilan.personne }),
|
||||
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_SUSPENDUES', bilan)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres reviennent, RESERVATIONS_RETABLIES. */
|
||||
function reintegrerParticipant(charge, { id }) {
|
||||
exigerModifiable(charge);
|
||||
const participant = participantDe(charge, id);
|
||||
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
|
||||
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
|
||||
const bilan = bilanExclusion(charge, suivante, participant);
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_REINTEGRER_PARTICIPANT', { personne: bilan.personne }),
|
||||
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_RETABLIES', bilan)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* supprimerParticipant {id} : la personne et ses réservations partent dans la
|
||||
* même entrée ; les titres restent à leurs places, les propositions et le
|
||||
* retenu restent, en dérive (§ 9) ; le compteur ne recule pas.
|
||||
*/
|
||||
function supprimerParticipant(charge, { id }) {
|
||||
exigerModifiable(charge);
|
||||
const participant = participantDe(charge, id);
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.participants = suivante.participants.filter((candidat) => candidat.id !== id);
|
||||
suivante.reservations = suivante.reservations.filter((reservation) => reservation.participant !== id);
|
||||
const reservations = charge.reservations.length - suivante.reservations.length;
|
||||
const details = {
|
||||
participant: id,
|
||||
personne: personneDe(participant),
|
||||
reservations,
|
||||
titres: titresPourvus(charge) - titresPourvus(suivante),
|
||||
};
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_SUPPRIMER_PARTICIPANT', { personne: details.personne }),
|
||||
avertissements: reservations > 0 ? [avertissement('RESERVATIONS_RETIREES', details)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
|
||||
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
|
||||
* refusé ou ambigu — devient le refus de la commande, sous le même code. Le
|
||||
* résumé suit le rendu, pour le rapport d'import et le CSV des refus. Selon
|
||||
* l'état : sans avertissement en brouillon et proposé ; en retenu,
|
||||
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
|
||||
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
|
||||
*/
|
||||
function importerParticipants(charge, { apercu, mode }) {
|
||||
exigerModifiable(charge);
|
||||
let resultat;
|
||||
try {
|
||||
resultat = appliquerImport(charge, apercu, mode);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
|
||||
throw erreur;
|
||||
}
|
||||
const { ajoutes, misAJour, retires, derive } = resultat.resume;
|
||||
return {
|
||||
charge: resultat.charge,
|
||||
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
|
||||
avertissements: derive ? [avertissement('IMPORT_EN_RETENU', {})] : [],
|
||||
resume: resultat.resume,
|
||||
};
|
||||
}
|
||||
|
||||
/** changerEtat {etat} (§ 9) : un état du plan, sans rien détruire ; bloque se pose de chacun. */
|
||||
function changerEtat(charge, { etat }) {
|
||||
if (!ETATS.includes(etat)) throw new TypeError(`etat : ${ETATS.join(', ')} attendu, reçu ${JSON.stringify(etat)}`);
|
||||
exigerModifiable(charge);
|
||||
if (etat === charge.evenement.etat) throw refus('SANS_EFFET', { commande: 'changerEtat' });
|
||||
if (etat === 'propose' && charge.propositions.length === 0) {
|
||||
throw refus('AUCUNE_PROPOSITION', {}, { geste: 'ouvrirSection', section: 'generation' });
|
||||
}
|
||||
if (etat === 'retenu' && charge.retenu === null) {
|
||||
throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
|
||||
}
|
||||
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_CHANGER_ETAT', { etat }), avertissements: [] };
|
||||
}
|
||||
|
||||
/** debloquer {} (§ 8.5, § 9) : l'état revient à celui que le contenu impose. */
|
||||
function debloquer(charge) {
|
||||
if (charge.evenement.etat !== 'bloque') throw refus('PLAN_NON_BLOQUE');
|
||||
const etat = etatDeduit(charge);
|
||||
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
|
||||
* § 8.9) : les propositions du moteur, mises en forme de fichier
|
||||
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
|
||||
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
|
||||
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
|
||||
* dernière. Un brouillon passe à proposé.
|
||||
*/
|
||||
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
|
||||
exigerModifiable(charge);
|
||||
if (!Array.isArray(propositions)) throw new TypeError('propositions : liste attendue');
|
||||
if (propositions.length === 0) throw refus('SANS_EFFET', { commande: 'enregistrerGeneration' });
|
||||
const rangs = propositions.map(({ id }) => id);
|
||||
if (!rangs.every((id) => Number.isSafeInteger(id) && id >= 1) || new Set(rangs).size !== rangs.length) {
|
||||
throw new TypeError('propositions : identifiants du moteur entiers, ≥ 1 et distincts attendus');
|
||||
}
|
||||
const decalage = charge.prochainsIds.proposition - 1;
|
||||
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
|
||||
let fichiers;
|
||||
try {
|
||||
fichiers = propositions.map((proposition) => versFichier(proposition, configuration, options));
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
|
||||
throw refus('COMPTEUR_SATURE', erreur.details);
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
const ids = fichiers.map(({ id }) => id);
|
||||
const premier = Math.min(...ids);
|
||||
const dernier = Math.max(...ids);
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.propositions.push(...fichiers);
|
||||
suivante.prochainsIds.proposition = dernier + 1;
|
||||
if (suivante.evenement.etat === 'brouillon') suivante.evenement.etat = 'propose';
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_ENREGISTRER_GENERATION', { n: fichiers.length, premier, dernier }),
|
||||
avertissements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* retenirProposition {id} (§ 5.7, § 9) : le placement de la proposition,
|
||||
* copié, devient le retenu, que les retouches modifieront sans toucher la
|
||||
* proposition ; le plan passe à retenu.
|
||||
*/
|
||||
function retenirProposition(charge, { id }) {
|
||||
exigerModifiable(charge);
|
||||
const proposition = charge.propositions.find((candidate) => candidate.id === id);
|
||||
if (proposition === undefined) throw refus('PROPOSITION_INCONNUE', { id });
|
||||
const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition);
|
||||
const suivante = avecEtat(charge, 'retenu');
|
||||
suivante.retenu = { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
|
||||
return { charge: suivante, libelle: libelle('ENTREE_RETENIR_PROPOSITION', { id }), avertissements: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* effacerPropositions {} (§ 5.7) : la liste se vide, la proposition d'où vient
|
||||
* le retenu exceptée ; le compteur ne recule pas. Un plan proposé sans retenu
|
||||
* revient à brouillon.
|
||||
*/
|
||||
function effacerPropositions(charge) {
|
||||
exigerModifiable(charge);
|
||||
const origine = charge.retenu?.proposition ?? null;
|
||||
const gardees = charge.propositions.filter(({ id }) => id === origine);
|
||||
const n = charge.propositions.length - gardees.length;
|
||||
if (n === 0) throw refus('SANS_EFFET', { commande: 'effacerPropositions' });
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.propositions = suivante.propositions.filter(({ id }) => id === origine);
|
||||
if (suivante.retenu === null && suivante.evenement.etat === 'propose') suivante.evenement.etat = 'brouillon';
|
||||
return {
|
||||
charge: suivante,
|
||||
libelle: libelle('ENTREE_EFFACER_PROPOSITIONS', { n, gardee: gardees.length > 0 ? origine : null }),
|
||||
avertissements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le registre des commandes, par nom : chacune (charge, arguments) →
|
||||
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
|
||||
* résumé de l'import.
|
||||
*
|
||||
* @typedef {{code: string, details: Object}} Avertissement
|
||||
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
||||
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
|
||||
*/
|
||||
export const COMMANDES = new Map([
|
||||
['ajouterParticipant', ajouterParticipant],
|
||||
['modifierParticipant', modifierParticipant],
|
||||
['exclureParticipant', exclureParticipant],
|
||||
['reintegrerParticipant', reintegrerParticipant],
|
||||
['supprimerParticipant', supprimerParticipant],
|
||||
['importerParticipants', importerParticipants],
|
||||
['changerEtat', changerEtat],
|
||||
['debloquer', debloquer],
|
||||
['enregistrerGeneration', enregistrerGeneration],
|
||||
['retenirProposition', retenirProposition],
|
||||
['effacerPropositions', effacerPropositions],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
||||
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
|
||||
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
|
||||
*
|
||||
* @param {string} nom
|
||||
* @param {Object} charge
|
||||
* @param {Object} [args]
|
||||
* @returns {{charge: Object, libelle: string, avertissements: Array<{code: string, details: Object}>}}
|
||||
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
|
||||
* @throws {TypeError} une commande inconnue, un argument de forme fausse
|
||||
*/
|
||||
export function appliquerCommande(nom, charge, args = {}) {
|
||||
const commande = COMMANDES.get(nom);
|
||||
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
|
||||
const rendu = commande(charge, args ?? {});
|
||||
if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
|
||||
return { ...rendu, avertissements: rendu.avertissements ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
|
||||
* champ se contrôle selon sa règle avant que l'identifiant ne se tire :
|
||||
* NOM_REQUIS, DATE_INVALIDE {date}, SIEGES_INVALIDES {sieges},
|
||||
* TOURS_INVALIDES {tours}. Le nom entre en NFC, ses blancs de bord retirés ;
|
||||
* une date vide vaut null.
|
||||
*
|
||||
* @param {{nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
|
||||
* @param {() => string} identifiant tiré une fois, après les contrôles
|
||||
* @returns {{charge: Object, libelle: string}}
|
||||
* @throws {ErreurCommande}
|
||||
*/
|
||||
export function chargeNeuve({ nom, date = null, siegesParDefaut, tours }, identifiant) {
|
||||
const regle = (cle) => regleDuChamp(EVENEMENT, cle);
|
||||
const nomLu = saisie(nom, 'nom');
|
||||
const dateLue = saisie(date, 'date');
|
||||
if (premiereFaute(nomLu, regle('nom')) !== null) throw refus('NOM_REQUIS');
|
||||
if (premiereFaute(dateLue, regle('date')) !== null) throw refus('DATE_INVALIDE', { date });
|
||||
if (premiereFaute(siegesParDefaut, regle('siegesParDefaut')) !== null) {
|
||||
throw refus('SIEGES_INVALIDES', { sieges: siegesParDefaut });
|
||||
}
|
||||
if (premiereFaute(tours, regle('tours')) !== null) throw refus('TOURS_INVALIDES', { tours });
|
||||
const charge = creerCharge({ id: identifiant(), nom: nomLu, date: dateLue, siegesParDefaut, tours });
|
||||
return { charge, libelle: libelle('ENTREE_CREATION', { nom: nomLu }) };
|
||||
}
|
||||
504
src/application/commandes.test.js
Normal file
504
src/application/commandes.test.js
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des commandes nommées (§ 4, § 4.4, § 5.7, § 8.2, § 9, § 10.1) :
|
||||
// chacune rend une charge neuve et un libellé figé, ne touche pas la charge
|
||||
// reçue, tire ses identifiants de prochainsIds sans jamais en réutiliser un,
|
||||
// et refuse par un code. Les noms d'épreuve sont inventés ; la version vient
|
||||
// du module engendré.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { apercevoir } from '../csv/apercu.js';
|
||||
import { bilanRemplacement } from '../csv/import.js';
|
||||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
||||
import { derive, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve } from './commandes.js';
|
||||
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
const personne = (id, nom, prenom = null, appartenance = null) => ({
|
||||
id,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
});
|
||||
|
||||
const table = (id, x) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x, y: 0 } });
|
||||
|
||||
// Quatre personnes, deux tables de deux places, deux tours. Iris Ombrelle
|
||||
// anime la table 1 : une réservation « tous » au siège 1, et le titre de ce
|
||||
// siège.
|
||||
function chargeEssai() {
|
||||
const charge = creerCharge({ id: 'evt-lucioles', nom: 'Soirée des Lucioles', siegesParDefaut: 2, tours: 2 });
|
||||
charge.prochainsIds = { participant: 5, table: 3, proposition: 1 };
|
||||
charge.participants = [
|
||||
personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
|
||||
personne(2, 'Grisaille', null, 'Club des Merles'),
|
||||
personne(3, 'Pervenche', 'Théo'),
|
||||
personne(4, 'Lacasse', 'Ondine', 'Société Alpha'),
|
||||
];
|
||||
charge.tables = [table(1, 0), table(2, 250)];
|
||||
charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
|
||||
charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Les arguments d'enregistrerGeneration : une génération courte du moteur
|
||||
// sur la configuration de la charge.
|
||||
function generer(charge, nombre = 3, graine = 7) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
return { propositions: rechercher(configuration, { graine, arret: 40, nombre }), configuration, produitVersion: V1 };
|
||||
}
|
||||
|
||||
// La charge d'essai, une cinquième personne exclue, deux propositions et la
|
||||
// première retenue.
|
||||
function chargeRiche() {
|
||||
const charge = chargeEssai();
|
||||
charge.participants.push({ ...personne(5, 'Ancolie', 'Amandine'), exclu: true });
|
||||
charge.prochainsIds.participant = 6;
|
||||
const generee = COMMANDES.get('enregistrerGeneration')(charge, generer(charge, 2)).charge;
|
||||
return COMMANDES.get('retenirProposition')(generee, { id: 1 }).charge;
|
||||
}
|
||||
|
||||
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
|
||||
function figer(valeur) {
|
||||
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
|
||||
Object.freeze(valeur);
|
||||
for (const element of Object.values(valeur)) figer(element);
|
||||
}
|
||||
return valeur;
|
||||
}
|
||||
|
||||
// Le refus attendu : une ErreurCommande de ce code, et de ces détails quand
|
||||
// ils sont donnés.
|
||||
function refuse(appel, code, details) {
|
||||
assert.throws(appel, (erreur) => {
|
||||
assert.ok(erreur instanceof ErreurCommande, `ErreurCommande attendue, reçu ${erreur}`);
|
||||
assert.equal(erreur.code, code);
|
||||
if (details !== undefined) assert.deepEqual(erreur.details, details);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||
|
||||
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
|
||||
|
||||
describe('commandes : le registre (§ 8.2)', () => {
|
||||
test('le registre porte les onze commandes du plan, chacune une fonction', () => {
|
||||
assert.deepEqual([...COMMANDES.keys()].sort(), [
|
||||
'ajouterParticipant',
|
||||
'changerEtat',
|
||||
'debloquer',
|
||||
'effacerPropositions',
|
||||
'enregistrerGeneration',
|
||||
'exclureParticipant',
|
||||
'importerParticipants',
|
||||
'modifierParticipant',
|
||||
'reintegrerParticipant',
|
||||
'retenirProposition',
|
||||
'supprimerParticipant',
|
||||
]);
|
||||
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
|
||||
});
|
||||
|
||||
test('appliquerCommande rend charge, libellé et avertissements ; un nom inconnu est une faute du code', () => {
|
||||
const rendu = appliquerCommande('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère' } });
|
||||
assert.equal(rendu.libelle, 'Ajouté Bruyère');
|
||||
assert.deepEqual(rendu.avertissements, []);
|
||||
assert.equal(rendu.charge.participants.length, 5);
|
||||
assert.throws(() => appliquerCommande('commandeInventee', chargeEssai(), {}), TypeError);
|
||||
});
|
||||
|
||||
test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => {
|
||||
const charge = chargeEssai();
|
||||
const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants });
|
||||
refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', {
|
||||
commande: 'importerParticipants',
|
||||
remede: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandes : les participants (§ 4, § 4.4, § 10.2)', () => {
|
||||
test('ajouter : seul le nom est requis ; les autres champs valent null, exclu faux ; le compteur avance', () => {
|
||||
const { charge, libelle, avertissements } = executer('ajouterParticipant', chargeEssai(), { champs: { nom: ' Pervenche ' } });
|
||||
assert.deepEqual(charge.participants.at(-1), personne(5, 'Pervenche'));
|
||||
assert.equal(charge.prochainsIds.participant, 6);
|
||||
assert.equal(libelle, 'Ajouté Pervenche');
|
||||
assert.deepEqual(avertissements, []);
|
||||
const complet = executer('ajouterParticipant', chargeEssai(), {
|
||||
champs: { nom: 'Parasol', prenom: 'Iris', appartenance: 'Club des Merles', courriel: '', notes: ' arrive tard ', exclu: true },
|
||||
});
|
||||
assert.deepEqual(complet.charge.participants.at(-1), {
|
||||
...personne(5, 'Parasol', 'Iris', 'Club des Merles'),
|
||||
notes: 'arrive tard',
|
||||
exclu: true,
|
||||
});
|
||||
assert.equal(complet.libelle, 'Ajouté Iris Parasol');
|
||||
});
|
||||
|
||||
test('ajouter : un nom saisi en NFD est enregistré en NFC', () => {
|
||||
const { charge, libelle } = executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Pervenche', prenom: 'The\u{301}o' } });
|
||||
assert.equal(charge.participants.at(-1).prenom, 'Th\u{E9}o');
|
||||
assert.equal(libelle, 'Ajouté Th\u{E9}o Pervenche');
|
||||
});
|
||||
|
||||
test('ajouter : un nom vide ou blanc est refusé NOM_REQUIS ; un champ inconnu ou de forme fausse est une faute du code', () => {
|
||||
refuse(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: ' ' } }), 'NOM_REQUIS', { remede: null });
|
||||
refuse(() => executer('ajouterParticipant', chargeEssai(), { champs: { prenom: 'Iris' } }), 'NOM_REQUIS');
|
||||
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère', age: 3 } }), TypeError);
|
||||
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère', exclu: 'oui' } }), TypeError);
|
||||
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 42 } }), TypeError);
|
||||
});
|
||||
|
||||
test("un identifiant ne se réutilise jamais : supprimer ne fait pas reculer le compteur (§ 4)", () => {
|
||||
const ajoutee = executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère' } }).charge;
|
||||
const supprimee = executer('supprimerParticipant', ajoutee, { id: 5 }).charge;
|
||||
assert.equal(supprimee.prochainsIds.participant, 6);
|
||||
const rajoutee = executer('ajouterParticipant', supprimee, { champs: { nom: 'Bruyère' } }).charge;
|
||||
assert.deepEqual(rajoutee.participants.map(({ id }) => id), [1, 2, 3, 4, 6]);
|
||||
});
|
||||
|
||||
test('modifier : le libellé nomme la personne et chaque champ changé', () => {
|
||||
const { charge, libelle } = executer('modifierParticipant', chargeEssai(), { id: 4, champs: { courriel: ' o@exemple.test ' } });
|
||||
assert.equal(charge.participants[3].courriel, 'o@exemple.test');
|
||||
assert.equal(libelle, 'Modifié Ondine Lacasse : courriel');
|
||||
const deux = executer('modifierParticipant', chargeEssai(), { id: 3, champs: { notes: 'à jeun', titrePressenti: 'hôte', courriel: null } });
|
||||
assert.equal(deux.libelle, 'Modifié Théo Pervenche : titre pressenti, notes');
|
||||
});
|
||||
|
||||
test("modifier : renommer garde l'ancien nom au libellé et nomme le nouveau", () => {
|
||||
const { charge, libelle } = executer('modifierParticipant', chargeEssai(), { id: 1, champs: { nom: 'Parasol' } });
|
||||
assert.equal(charge.participants[0].nom, 'Parasol');
|
||||
assert.equal(libelle, 'Modifié Iris Ombrelle : nom (désormais Iris Parasol)');
|
||||
});
|
||||
|
||||
test('modifier : sans changement SANS_EFFET ; personne inconnue ; nom vidé ; exclu passe par sa commande', () => {
|
||||
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { nom: 'Lacasse', courriel: '' } }), 'SANS_EFFET');
|
||||
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 9, champs: { nom: 'Lacasse' } }), 'PARTICIPANT_INCONNU', {
|
||||
id: 9,
|
||||
remede: null,
|
||||
});
|
||||
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { nom: ' ' } }), 'NOM_REQUIS');
|
||||
assert.throws(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { exclu: true } }), TypeError);
|
||||
});
|
||||
|
||||
test("exclure : les réservations restent, suspendues ; le titre n'est plus pourvu ; l'avertissement le dit (§ 4.4)", () => {
|
||||
const avant = chargeEssai();
|
||||
assert.equal(bilanRemplacement(avant).titresPourvus, 1);
|
||||
const { charge, libelle, avertissements } = executer('exclureParticipant', avant, { id: 1 });
|
||||
assert.equal(charge.participants[0].exclu, true);
|
||||
assert.deepEqual(charge.reservations, avant.reservations);
|
||||
assert.deepEqual(charge.titres, avant.titres);
|
||||
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
|
||||
assert.equal(libelle, 'Exclu Iris Ombrelle');
|
||||
assert.deepEqual(avertissements, [
|
||||
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('réintégrer rend les réservations et le titre, et le dit', () => {
|
||||
const exclue = executer('exclureParticipant', chargeEssai(), { id: 1 }).charge;
|
||||
const { charge, libelle, avertissements } = executer('reintegrerParticipant', exclue, { id: 1 });
|
||||
assert.equal(charge.participants[0].exclu, false);
|
||||
assert.equal(bilanRemplacement(charge).titresPourvus, 1);
|
||||
assert.equal(libelle, 'Réintégré Iris Ombrelle');
|
||||
assert.deepEqual(avertissements, [
|
||||
{ code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("exclure sans réservation n'avertit pas ; exclure deux fois, réintégrer une présente : SANS_EFFET", () => {
|
||||
assert.deepEqual(executer('exclureParticipant', chargeEssai(), { id: 3 }).avertissements, []);
|
||||
const exclue = executer('exclureParticipant', chargeEssai(), { id: 3 }).charge;
|
||||
refuse(() => executer('exclureParticipant', exclue, { id: 3 }), 'SANS_EFFET');
|
||||
refuse(() => executer('reintegrerParticipant', chargeEssai(), { id: 3 }), 'SANS_EFFET');
|
||||
refuse(() => executer('exclureParticipant', chargeEssai(), { id: 9 }), 'PARTICIPANT_INCONNU');
|
||||
});
|
||||
|
||||
test('supprimer : ses réservations partent, les titres et les propositions restent, signalées en dérive', () => {
|
||||
const avant = chargeRiche();
|
||||
const { charge, libelle, avertissements } = executer('supprimerParticipant', avant, { id: 1 });
|
||||
assert.deepEqual(charge.participants.map(({ id }) => id), [2, 3, 4, 5]);
|
||||
assert.deepEqual(charge.reservations, []);
|
||||
assert.deepEqual(charge.titres, avant.titres);
|
||||
assert.deepEqual(charge.prochainsIds, avant.prochainsIds);
|
||||
assert.deepEqual(charge.propositions, avant.propositions);
|
||||
assert.ok(derive(charge.propositions[0], charge).some(({ code, participant }) => code === 'PARTICIPANT_SUPPRIME' && participant === 1));
|
||||
assert.equal(libelle, 'Supprimé Iris Ombrelle');
|
||||
assert.deepEqual(avertissements, [
|
||||
{ code: 'RESERVATIONS_RETIREES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
|
||||
]);
|
||||
refuse(() => executer('supprimerParticipant', chargeEssai(), { id: 9 }), 'PARTICIPANT_INCONNU');
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandes : l'import (§ 10.1)", () => {
|
||||
test('ajouter : chaque ligne valide devient une personne, sous le compteur, en une seule commande', () => {
|
||||
const avant = chargeEssai();
|
||||
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
|
||||
const { charge, libelle, resume } = executer('importerParticipants', avant, { apercu, mode: 'ajouter' });
|
||||
assert.deepEqual(charge.participants.slice(4).map(({ id, nom, prenom, appartenance }) => [id, nom, prenom, appartenance]), [
|
||||
[5, 'Ancolie', 'Bastien', 'Club des Merles'],
|
||||
[6, 'Bruyère', 'Capucine', null],
|
||||
]);
|
||||
assert.equal(charge.prochainsIds.participant, 7);
|
||||
assert.equal(libelle, 'Importé 2 participants');
|
||||
assert.equal(resume.ajoutes, 2);
|
||||
});
|
||||
|
||||
test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => {
|
||||
const avant = chargeEssai();
|
||||
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
|
||||
const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' });
|
||||
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
|
||||
assert.deepEqual(remplace.charge.reservations, []);
|
||||
// Deux ajoutés, un modifié : des comptes distincts, qu'un libellé qui les
|
||||
// intervertirait ne rendrait pas.
|
||||
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
|
||||
const misAJour = executer('importerParticipants', avant, {
|
||||
apercu: apercevoir(csv, { participants: avant.participants }),
|
||||
mode: 'mettreAJour',
|
||||
});
|
||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]);
|
||||
assert.equal(misAJour.libelle, 'Mis à jour la liste par import : 2 ajoutés, 1 modifié');
|
||||
});
|
||||
|
||||
test("importer selon l'état : rien en brouillon et proposé, l'avertissement de dérive en retenu, refusé en bloqué", () => {
|
||||
const enRetenu = [{ code: 'IMPORT_EN_RETENU', details: {} }];
|
||||
const brouillon = chargeEssai();
|
||||
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
|
||||
const retenue = chargeRiche();
|
||||
assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']);
|
||||
const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants }), mode });
|
||||
assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []);
|
||||
assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []);
|
||||
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
|
||||
assert.deepEqual(executer('importerParticipants', retenue, ajout(retenue, mode)).avertissements, enRetenu, mode);
|
||||
}
|
||||
// Une mise à jour sans personne nouvelle avertit aussi : le placement
|
||||
// retenu date de la liste d'avant.
|
||||
const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', { participants: retenue.participants });
|
||||
const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' });
|
||||
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
|
||||
assert.deepEqual(misAJour.avertissements, enRetenu);
|
||||
const bloquee = { ...retenue, evenement: { ...retenue.evenement, etat: 'bloque' } };
|
||||
refuse(() => executer('importerParticipants', bloquee, ajout(bloquee)), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
|
||||
});
|
||||
|
||||
test("un aperçu refusé ou ambigu est refusé sous son code ; un plan bloqué nomme le remède « Débloquer »", () => {
|
||||
const avant = chargeEssai();
|
||||
const sansNom = apercevoir('prenom;courriel\nIris;i@exemple.test\n', { participants: avant.participants });
|
||||
refuse(() => executer('importerParticipants', avant, { apercu: sansNom, mode: 'ajouter' }), 'NOM_NON_ASSOCIE', {
|
||||
ligne: null,
|
||||
remede: null,
|
||||
});
|
||||
const ambigu = apercevoir('nom;organisation;entreprise\nAncolie;A;B\n', { participants: avant.participants });
|
||||
refuse(() => executer('importerParticipants', avant, { apercu: ambigu, mode: 'ajouter' }), 'AMBIGUITE', {
|
||||
champs: ['appartenance'],
|
||||
remede: null,
|
||||
});
|
||||
const bloquee = { ...avant, evenement: { ...avant.evenement, etat: 'bloque' } };
|
||||
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
|
||||
refuse(() => executer('importerParticipants', bloquee, { apercu, mode: 'ajouter' }), 'PLAN_BLOQUE', {
|
||||
remede: { geste: 'debloquer' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandes : les états du plan (§ 9)', () => {
|
||||
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
|
||||
function chargeA(etat, contenu) {
|
||||
const charge = contenu === 'vide' ? chargeEssai() : chargeRiche();
|
||||
if (contenu === 'propositions') charge.retenu = null;
|
||||
charge.evenement.etat = etat;
|
||||
return charge;
|
||||
}
|
||||
|
||||
test('les transitions admises et refusées, chacune sans rien détruire', () => {
|
||||
// [de, contenu, vers, refus attendu ou null]
|
||||
const cas = [
|
||||
['brouillon', 'vide', 'propose', 'AUCUNE_PROPOSITION'],
|
||||
['brouillon', 'vide', 'retenu', 'AUCUN_RETENU'],
|
||||
['brouillon', 'vide', 'bloque', null],
|
||||
['brouillon', 'propositions', 'propose', null],
|
||||
['brouillon', 'propositions', 'retenu', 'AUCUN_RETENU'],
|
||||
['propose', 'propositions', 'brouillon', null],
|
||||
['propose', 'retenu', 'retenu', null],
|
||||
['retenu', 'retenu', 'propose', null],
|
||||
['retenu', 'retenu', 'brouillon', null],
|
||||
['retenu', 'retenu', 'bloque', null],
|
||||
['retenu', 'retenu', 'retenu', 'SANS_EFFET'],
|
||||
['bloque', 'retenu', 'retenu', 'PLAN_BLOQUE'],
|
||||
['bloque', 'retenu', 'brouillon', 'PLAN_BLOQUE'],
|
||||
];
|
||||
for (const [de, contenu, vers, refus] of cas) {
|
||||
const avant = chargeA(de, contenu);
|
||||
if (refus !== null) {
|
||||
refuse(() => executer('changerEtat', avant, { etat: vers }), refus);
|
||||
continue;
|
||||
}
|
||||
const { charge, libelle } = executer('changerEtat', avant, { etat: vers });
|
||||
assert.equal(charge.evenement.etat, vers, `${de} → ${vers}`);
|
||||
assert.deepEqual({ ...charge, evenement: { ...charge.evenement, etat: de } }, avant, `${de} → ${vers} : rien d'autre ne change`);
|
||||
assert.match(libelle, /^Plan passé à l'état « /);
|
||||
}
|
||||
});
|
||||
|
||||
test('un refus de transition nomme le remède ; un état inconnu est une faute du code', () => {
|
||||
refuse(() => executer('changerEtat', chargeA('brouillon', 'vide'), { etat: 'propose' }), 'AUCUNE_PROPOSITION', {
|
||||
remede: { geste: 'ouvrirSection', section: 'generation' },
|
||||
});
|
||||
refuse(() => executer('changerEtat', chargeA('propose', 'propositions'), { etat: 'retenu' }), 'AUCUN_RETENU', {
|
||||
remede: { geste: 'ouvrirSection', section: 'propositions' },
|
||||
});
|
||||
assert.throws(() => executer('changerEtat', chargeA('brouillon', 'vide'), { etat: 'archive' }), TypeError);
|
||||
});
|
||||
|
||||
test("débloquer rend l'état que le contenu impose ; un plan qui n'est pas bloqué est refusé", () => {
|
||||
for (const contenu of ['vide', 'propositions', 'retenu']) {
|
||||
const avant = chargeA('bloque', contenu);
|
||||
const { charge, libelle } = executer('debloquer', avant, {});
|
||||
assert.equal(charge.evenement.etat, etatDeduit(avant));
|
||||
assert.match(libelle, /^Plan débloqué/);
|
||||
}
|
||||
refuse(() => executer('debloquer', chargeA('retenu', 'retenu'), {}), 'PLAN_NON_BLOQUE', { remede: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandes : générer, retenir, effacer (§ 5.7, § 8.9)', () => {
|
||||
test('les propositions prennent leurs identifiants à partir du compteur, et le relèvent ; brouillon passe à proposé', () => {
|
||||
const avant = chargeEssai();
|
||||
avant.prochainsIds.proposition = 4;
|
||||
const args = generer(avant, 3);
|
||||
const { charge, libelle } = executer('enregistrerGeneration', avant, args);
|
||||
assert.deepEqual(charge.propositions.map(({ id }) => id), [4, 5, 6]);
|
||||
assert.equal(charge.prochainsIds.proposition, 7);
|
||||
assert.equal(charge.evenement.etat, 'propose');
|
||||
assert.equal(libelle, 'Généré 3 propositions (n° 4 à 6)');
|
||||
charge.propositions.forEach((p, rang) => {
|
||||
const source = args.propositions[rang];
|
||||
assert.deepEqual([p.graine, p.arret, p.historique, p.produitVersion], [source.graine, source.arret, source.historique, V1]);
|
||||
assert.deepEqual(planDepuisFichier(p), source.plan);
|
||||
assert.equal(p.siegesAttribues, avant.reglages.attribuerSieges);
|
||||
});
|
||||
});
|
||||
|
||||
test("une seconde génération continue la numérotation ; après un effacement, aucun identifiant ne revient", () => {
|
||||
const premiere = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 3)).charge;
|
||||
const seconde = executer('enregistrerGeneration', premiere, generer(premiere, 2, 11)).charge;
|
||||
assert.deepEqual(seconde.propositions.map(({ id }) => id), [1, 2, 3, 4, 5]);
|
||||
assert.equal(seconde.evenement.etat, 'propose');
|
||||
const effacee = executer('effacerPropositions', seconde, {}).charge;
|
||||
const troisieme = executer('enregistrerGeneration', effacee, generer(effacee, 1, 13)).charge;
|
||||
assert.deepEqual(troisieme.propositions.map(({ id }) => id), [6]);
|
||||
});
|
||||
|
||||
test("une liste vide est sans effet ; des identifiants du moteur en double sont une faute du code", () => {
|
||||
refuse(() => executer('enregistrerGeneration', chargeEssai(), { ...generer(chargeEssai(), 1), propositions: [] }), 'SANS_EFFET');
|
||||
const args = generer(chargeEssai(), 2);
|
||||
args.propositions[1] = { ...args.propositions[1], id: 1 };
|
||||
assert.throws(() => executer('enregistrerGeneration', chargeEssai(), args), TypeError);
|
||||
});
|
||||
|
||||
test("retenir pose le placement en vigueur, copié de la proposition, et l'état Retenu", () => {
|
||||
const generee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
|
||||
const { charge, libelle } = executer('retenirProposition', generee, { id: 2 });
|
||||
const { id, graine, arret, historique, produitVersion, ...plan } = generee.propositions[1];
|
||||
assert.deepEqual(charge.retenu, { proposition: 2, ...plan });
|
||||
assert.equal(charge.evenement.etat, 'retenu');
|
||||
assert.deepEqual(charge.propositions, generee.propositions);
|
||||
assert.equal(libelle, 'Retenu la proposition n° 2');
|
||||
assert.deepEqual([id, typeof graine, typeof arret, typeof historique, produitVersion], [2, 'number', 'number', 'number', V1]);
|
||||
refuse(() => executer('retenirProposition', generee, { id: 9 }), 'PROPOSITION_INCONNUE', { id: 9, remede: null });
|
||||
});
|
||||
|
||||
test("effacer épargne la proposition retenue ; sans retenu, le plan revient en brouillon ; rien à effacer : SANS_EFFET", () => {
|
||||
const riche = chargeRiche();
|
||||
const { charge, libelle } = executer('effacerPropositions', riche, {});
|
||||
assert.deepEqual(charge.propositions.map(({ id }) => id), [1]);
|
||||
assert.equal(charge.prochainsIds.proposition, riche.prochainsIds.proposition);
|
||||
assert.equal(charge.evenement.etat, 'retenu');
|
||||
assert.equal(libelle, 'Effacé 1 proposition, gardé la retenue (n° 1)');
|
||||
refuse(() => executer('effacerPropositions', charge, {}), 'SANS_EFFET');
|
||||
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
|
||||
const videe = executer('effacerPropositions', proposee, {});
|
||||
assert.deepEqual(videe.charge.propositions, []);
|
||||
assert.equal(videe.charge.evenement.etat, 'brouillon');
|
||||
assert.equal(videe.libelle, 'Effacé 2 propositions');
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandes : la charge d'un événement neuf (§ 8.1)", () => {
|
||||
test("chargeNeuve contrôle chaque champ avant de tirer l'identifiant, et convertit la saisie en NFC", () => {
|
||||
let tirages = 0;
|
||||
const identifiant = () => {
|
||||
tirages += 1;
|
||||
return `evt-${tirages}`;
|
||||
};
|
||||
const base = { nom: 'Soirée des Lucioles', date: null, siegesParDefaut: 8, tours: 3 };
|
||||
refuse(() => chargeNeuve({ ...base, nom: ' ' }, identifiant), 'NOM_REQUIS');
|
||||
refuse(() => chargeNeuve({ ...base, date: '2031-02-30' }, identifiant), 'DATE_INVALIDE', { date: '2031-02-30', remede: null });
|
||||
refuse(() => chargeNeuve({ ...base, siegesParDefaut: 1 }, identifiant), 'SIEGES_INVALIDES', { sieges: 1, remede: null });
|
||||
refuse(() => chargeNeuve({ ...base, tours: 0 }, identifiant), 'TOURS_INVALIDES', { tours: 0, remede: null });
|
||||
assert.equal(tirages, 0);
|
||||
const { charge, libelle } = chargeNeuve({ ...base, nom: ' Soire\u{301}e des Lucioles ', date: '2031-05-17' }, identifiant);
|
||||
assert.equal(tirages, 1);
|
||||
assert.deepEqual(charge, {
|
||||
...creerCharge({ id: 'evt-1', nom: 'Soir\u{E9}e des Lucioles', date: '2031-05-17', siegesParDefaut: 8, tours: 3 }),
|
||||
});
|
||||
assert.equal(libelle, "Création de l'événement « Soir\u{E9}e des Lucioles »");
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
||||
// Arguments d'exemple de chaque commande sur la charge riche ; debloquer
|
||||
// reçoit la même charge, bloquée.
|
||||
function exemples(charge) {
|
||||
return new Map([
|
||||
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
|
||||
['modifierParticipant', { id: 4, champs: { courriel: 'o@exemple.test' } }],
|
||||
['exclureParticipant', { id: 1 }],
|
||||
['reintegrerParticipant', { id: 5 }],
|
||||
['supprimerParticipant', { id: 2 }],
|
||||
['importerParticipants', { apercu: apercevoir(CSV_DEUX, { participants: charge.participants }), mode: 'ajouter' }],
|
||||
['changerEtat', { etat: 'bloque' }],
|
||||
['debloquer', {}],
|
||||
['enregistrerGeneration', generer(charge, 1, 5)],
|
||||
['retenirProposition', { id: 2 }],
|
||||
['effacerPropositions', {}],
|
||||
]);
|
||||
}
|
||||
|
||||
test("aucune commande ne modifie la charge qu'elle reçoit", () => {
|
||||
const charge = chargeRiche();
|
||||
const args = exemples(charge);
|
||||
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
|
||||
for (const [nom, commande] of COMMANDES) {
|
||||
const recue = nom === 'debloquer' ? { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } } : charge;
|
||||
const copie = structuredClone(recue);
|
||||
const rendu = commande(figer(recue), args.get(nom));
|
||||
assert.deepEqual(recue, copie, nom);
|
||||
assert.notEqual(rendu.charge, recue, nom);
|
||||
assert.equal(typeof rendu.libelle, 'string', nom);
|
||||
assert.ok(Array.isArray(rendu.avertissements), nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('sur un plan bloqué, chaque commande sauf debloquer est refusée PLAN_BLOQUE, remède « Débloquer »', () => {
|
||||
const charge = chargeRiche();
|
||||
const args = exemples(charge);
|
||||
const bloquee = figer({ ...charge, evenement: { ...charge.evenement, etat: 'bloque' } });
|
||||
for (const nom of COMMANDES.keys()) {
|
||||
if (nom === 'debloquer') continue;
|
||||
refuse(() => executer(nom, bloquee, args.get(nom)), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
587
src/application/libelles.js
Normal file
587
src/application/libelles.js
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La table des textes de l'application (§ 14.6) : chaque code que lèvent ou
|
||||
// rendent src/stockage, src/csv et src/application — refus, avertissements,
|
||||
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
|
||||
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
|
||||
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
|
||||
// un code brut. Une valeur codée d'un détail — raison, état, champ, mode,
|
||||
// échec — que la table ne connaît pas lève aussi, au lieu de se taire. Une
|
||||
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
|
||||
// un code de l'application : les textes la citent telle quelle, entre
|
||||
// parenthèses ; une cause que la table porte s'y lit par son texte.
|
||||
//
|
||||
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
|
||||
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
|
||||
// change aucun libellé déjà écrit.
|
||||
//
|
||||
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage
|
||||
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
|
||||
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
const NOMS_DES_ETATS = new Map([
|
||||
['brouillon', 'Brouillon'],
|
||||
['propose', 'Proposé'],
|
||||
['retenu', 'Retenu'],
|
||||
['bloque', 'Bloqué'],
|
||||
]);
|
||||
|
||||
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
|
||||
const NOMS_DES_CHAMPS = new Map([
|
||||
['nom', 'nom'],
|
||||
['prenom', 'prénom'],
|
||||
['appartenance', 'appartenance'],
|
||||
['courriel', 'courriel'],
|
||||
['titrePressenti', 'titre pressenti'],
|
||||
['notes', 'notes'],
|
||||
]);
|
||||
|
||||
const MOIS = [
|
||||
'janvier',
|
||||
'février',
|
||||
'mars',
|
||||
'avril',
|
||||
'mai',
|
||||
'juin',
|
||||
'juillet',
|
||||
'août',
|
||||
'septembre',
|
||||
'octobre',
|
||||
'novembre',
|
||||
'décembre',
|
||||
];
|
||||
|
||||
// Valeur d'une table de noms, ou RangeError qui la nomme.
|
||||
function nomDans(table, valeur, quoi) {
|
||||
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
|
||||
return table.get(valeur);
|
||||
}
|
||||
const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
|
||||
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
|
||||
|
||||
// Un texte cité entre guillemets français.
|
||||
const cite = (texte) => `« ${texte} »`;
|
||||
// Une cause entre parenthèses, rien quand elle manque : son texte quand la
|
||||
// table en porte un, sinon la cause telle que le système la nomme.
|
||||
function causeEntreParentheses(cause) {
|
||||
if (cause === null || cause === undefined || cause === '') return '';
|
||||
return ` (${TEXTES.has(cause) ? texteDe(cause) : cause})`;
|
||||
}
|
||||
// Le chemin de l'élément fautif, rien quand il manque ou désigne la racine.
|
||||
const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
|
||||
// Un compte et son nom, au singulier jusqu'à un, comme le veut le français.
|
||||
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
||||
// Une énumération : « a », « a et b », « a, b et c ».
|
||||
function enumeration(termes) {
|
||||
if (termes.length <= 1) return termes.join('');
|
||||
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
|
||||
}
|
||||
// Des numéros de proposition, un illisible nommé comme tel.
|
||||
const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
|
||||
// La première lettre en capitale.
|
||||
const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
|
||||
|
||||
// Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à 8 h 05 »,
|
||||
// à l'heure qu'il porte ; une autre forme se cite telle quelle.
|
||||
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
|
||||
function moment(horodatage) {
|
||||
const morceaux = HORODATAGE.exec(String(horodatage));
|
||||
if (morceaux === null) return String(horodatage);
|
||||
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
|
||||
if (mois < 1 || mois > 12) return String(horodatage);
|
||||
const quantieme = jour === 1 ? '1er' : String(jour);
|
||||
return `le ${quantieme} ${MOIS[mois - 1]} ${annee} à ${heure} h ${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
const depuisQuand = (depuis) => (depuis === null || depuis === undefined ? '' : ` depuis ${moment(depuis)}`);
|
||||
|
||||
// Le chemin affichable d'une racine, reçue entière ou par son seul chemin.
|
||||
const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
|
||||
|
||||
// Le remède commun aux refus d'encodage (§ 10.1).
|
||||
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
|
||||
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
|
||||
|
||||
// La raison d'un fichier d'état illisible, en une phrase.
|
||||
function raisonIllisible({ raison, ...details }) {
|
||||
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
|
||||
return texteDe(raison, details);
|
||||
}
|
||||
|
||||
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
|
||||
function secoursDe(secours) {
|
||||
if (secours === undefined || secours === null) return '';
|
||||
const offerts = [];
|
||||
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
|
||||
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
|
||||
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
|
||||
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
|
||||
}
|
||||
|
||||
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
|
||||
function touchees({ ids, retenu }) {
|
||||
const parties = [];
|
||||
if (retenu) parties.push('le placement retenu');
|
||||
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
|
||||
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
|
||||
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
|
||||
}
|
||||
|
||||
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
|
||||
const titresDe = (n, singulier, pluriel) =>
|
||||
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
|
||||
|
||||
// Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause.
|
||||
const ouEcrire = ({ chemin, dossier, cause }) =>
|
||||
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}`;
|
||||
|
||||
const TEXTES = new Map([
|
||||
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ---------
|
||||
[
|
||||
'ABSENT',
|
||||
({ chemin }) =>
|
||||
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
|
||||
],
|
||||
['ECRITURE', (details) => `L'écriture de ${ouEcrire(details)} a échoué ; le fichier d'avant reste intact.`],
|
||||
['LECTURE', (details) => `La lecture de ${ouEcrire(details)} est refusée.`],
|
||||
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
|
||||
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
|
||||
[
|
||||
'ETAT_ILLISIBLE',
|
||||
(details) => {
|
||||
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
|
||||
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details)}${secoursDe(details.secours)}`;
|
||||
},
|
||||
],
|
||||
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
|
||||
[
|
||||
'CHEMIN_TROP_LONG',
|
||||
({ racine }) =>
|
||||
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
|
||||
'sous la longueur de chemin que Windows accepte.',
|
||||
],
|
||||
[
|
||||
'IDENTIFIANT_PRESENT',
|
||||
({ base }) =>
|
||||
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
|
||||
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
|
||||
],
|
||||
[
|
||||
'VERROU_PRIS',
|
||||
({ depuis, vivant }) => {
|
||||
if (vivant === true) {
|
||||
return `Une autre séance modifie cet événement${depuisQuand(depuis)} : il reste en lecture ici.`;
|
||||
}
|
||||
if (vivant === false) {
|
||||
return (
|
||||
`Une séance interrompue a laissé son verrou${depuisQuand(depuis)}, et il n'a pas pu se reprendre : ` +
|
||||
"l'événement reste en lecture."
|
||||
);
|
||||
}
|
||||
return (
|
||||
`Un verrou d'un autre poste, ou illisible, tient cet événement${depuisQuand(depuis)} : ` +
|
||||
'il reste en lecture ici, et rien ne le reprend seul.'
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'LECTURE_SEULE',
|
||||
({ raison }) => {
|
||||
if (raison === 'BLOQUE') {
|
||||
return `${texteDe('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
|
||||
}
|
||||
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
|
||||
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
|
||||
}
|
||||
throw new RangeError(`libelle : raison de lecture seule inconnue ${JSON.stringify(raison)}`);
|
||||
},
|
||||
],
|
||||
[
|
||||
'ETAT_NON_ECRIT',
|
||||
(details) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details)} n'a pas pu s'écrire.`,
|
||||
],
|
||||
[
|
||||
'CORBEILLE_SATUREE',
|
||||
({ dossier }) =>
|
||||
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
|
||||
'la suppression se refait une seconde plus tard.',
|
||||
],
|
||||
[
|
||||
'COMPTEUR_SATURE',
|
||||
({ compteur }) =>
|
||||
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
|
||||
'rien de neuf ne se numérote plus dans cet événement.',
|
||||
],
|
||||
['NON_DISPONIBLE', ({ cause }) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause)}.`],
|
||||
|
||||
// --- Les raisons d'un fichier illisible ----------------------------------
|
||||
['VIDE', () => 'Le fichier est vide.'],
|
||||
['JSON', () => "Le texte n'est pas du JSON valide."],
|
||||
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
|
||||
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
|
||||
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
|
||||
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
|
||||
[
|
||||
'FORMAT_PLUS_RECENT',
|
||||
({ format }) => {
|
||||
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
|
||||
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
|
||||
},
|
||||
],
|
||||
|
||||
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
|
||||
[
|
||||
'LONGUEUR',
|
||||
({ tour, table, declare, ecrit }) => {
|
||||
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
|
||||
if (table === null) {
|
||||
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
|
||||
}
|
||||
return (
|
||||
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
|
||||
`pour ${compte(declare, 'place', 'places')}.`
|
||||
);
|
||||
},
|
||||
],
|
||||
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
|
||||
[
|
||||
'INCONNU',
|
||||
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
|
||||
],
|
||||
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
|
||||
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
|
||||
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
|
||||
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
|
||||
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
|
||||
[
|
||||
'PARTICIPANT_NON_PLACE',
|
||||
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
|
||||
],
|
||||
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
|
||||
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
|
||||
[
|
||||
'CAPACITE_CHANGEE',
|
||||
({ table, avant, maintenant }) =>
|
||||
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||
],
|
||||
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
|
||||
|
||||
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) ---------------
|
||||
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
|
||||
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
|
||||
[
|
||||
'JOURNAL_EN_AVANCE_DE',
|
||||
({ n }) =>
|
||||
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
|
||||
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
|
||||
],
|
||||
[
|
||||
'JOURNAL_INCOMPLET',
|
||||
({ etat, journal }) =>
|
||||
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
|
||||
'le prochain geste inscrit une reprise.',
|
||||
],
|
||||
[
|
||||
'JOURNAL_ECARTEES',
|
||||
({ n }) =>
|
||||
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
|
||||
'à partir de la première ligne illisible.',
|
||||
],
|
||||
[
|
||||
'JOURNAL_FORMAT_PLUS_RECENT',
|
||||
({ format }) =>
|
||||
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
|
||||
"il ne se lit pas, et l'événement reste en lecture seule.",
|
||||
],
|
||||
[
|
||||
'JOURNAL_ETRANGER',
|
||||
({ evenement }) =>
|
||||
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
|
||||
],
|
||||
[
|
||||
'PROPOSITIONS_FAUTIVES',
|
||||
({ ids }) =>
|
||||
ids.length <= 1
|
||||
? `Une proposition incohérente est écartée (${numeros(ids)}).`
|
||||
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
|
||||
],
|
||||
[
|
||||
'DERIVE',
|
||||
(details) => {
|
||||
const { texte, pluriel } = touchees(details);
|
||||
return pluriel
|
||||
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
|
||||
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
|
||||
},
|
||||
],
|
||||
|
||||
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ----
|
||||
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
|
||||
[
|
||||
'DONNEES_APPLICATIVES',
|
||||
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
|
||||
],
|
||||
[
|
||||
'SONDE_ECHOUEE',
|
||||
({ cause }) =>
|
||||
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause)} : ` +
|
||||
'les événements se rangent dans les Documents.',
|
||||
],
|
||||
// Une cause de la sonde, que les textes citent entre parenthèses.
|
||||
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
|
||||
[
|
||||
'PORTABLE_NON_INSCRIPTIBLE',
|
||||
({ racine, evenements, cause }) => {
|
||||
const porte =
|
||||
evenements === null
|
||||
? 'porte peut-être des événements'
|
||||
: `porte ${compte(evenements, 'événement', 'événements')}`;
|
||||
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause)}.`;
|
||||
},
|
||||
],
|
||||
[
|
||||
'DOCUMENTS_NON_INSCRIPTIBLE',
|
||||
({ racine, cause }) =>
|
||||
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause)} : ` +
|
||||
"aucun geste ne s'y enregistrerait.",
|
||||
],
|
||||
[
|
||||
'PLATEFORME_WEB',
|
||||
() =>
|
||||
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
|
||||
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
|
||||
],
|
||||
[
|
||||
'SUPPORT_AMOVIBLE',
|
||||
() =>
|
||||
'Le dossier de travail est sur un support amovible : ' +
|
||||
'retirer le support pendant une écriture peut la laisser incomplète.',
|
||||
],
|
||||
[
|
||||
'REGLAGES_ILLISIBLES',
|
||||
({ chemin, raison }) => {
|
||||
const pourquoi =
|
||||
raison === 'JSON' || raison === 'FORME' ? texteDe(raison) : `La lecture est refusée (${raison}).`;
|
||||
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
|
||||
},
|
||||
],
|
||||
|
||||
// --- Le CSV (§ 10.1) -------------------------------------------------------
|
||||
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
|
||||
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
|
||||
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
|
||||
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
|
||||
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
|
||||
[
|
||||
'SEPARATEUR_INTROUVABLE',
|
||||
({ ligne, cause }) => {
|
||||
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
|
||||
if (ligne === null || ligne === undefined) return constat;
|
||||
const ecart = texteDe(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
|
||||
return `${constat} ${ecart}`;
|
||||
},
|
||||
],
|
||||
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
|
||||
[
|
||||
'GUILLEMET_OUVERT',
|
||||
({ ligne }) =>
|
||||
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
|
||||
'tout ce qui le suit tiendrait dans un seul champ.',
|
||||
],
|
||||
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
|
||||
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
|
||||
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
|
||||
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
|
||||
['NOM_ABSENT', () => 'Le nom manque.'],
|
||||
[
|
||||
'EXCLU_INCONNU',
|
||||
({ valeur }) =>
|
||||
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
|
||||
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
|
||||
],
|
||||
[
|
||||
'PLUSIEURS_CORRESPONDENT',
|
||||
({ participants }) =>
|
||||
'Plusieurs personnes de la liste correspondent à cette ligne ' +
|
||||
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
|
||||
],
|
||||
[
|
||||
'DEJA_DESIGNE',
|
||||
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
|
||||
],
|
||||
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
|
||||
[
|
||||
'AMBIGUITE',
|
||||
({ champs }) =>
|
||||
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
|
||||
],
|
||||
|
||||
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
|
||||
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
|
||||
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
|
||||
['FORMAT', (details) => texteDe('FORMAT_PLUS_RECENT', details)],
|
||||
['BLOQUE', () => 'Le plan est bloqué.'],
|
||||
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
|
||||
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
|
||||
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
|
||||
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
|
||||
[
|
||||
'SUPPORT_ROMPU',
|
||||
({ echec, ...details }) => {
|
||||
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details)} n'a pas pu s'écrire`;
|
||||
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
|
||||
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
|
||||
const perte =
|
||||
echec === 'ETAT_NON_ECRIT'
|
||||
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
|
||||
: "le dernier geste n'est pas enregistré";
|
||||
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
|
||||
}
|
||||
if (echec === 'JALON_NON_ECRIT') {
|
||||
return (
|
||||
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
|
||||
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
|
||||
);
|
||||
}
|
||||
if (echec === 'VERROU_NON_ECRIT') {
|
||||
return (
|
||||
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
|
||||
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
|
||||
);
|
||||
}
|
||||
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
|
||||
},
|
||||
],
|
||||
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
|
||||
[
|
||||
'VERROU_REPRIS',
|
||||
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
|
||||
],
|
||||
[
|
||||
'VERROU_NON_RENDU',
|
||||
({ cause }) =>
|
||||
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause)} : ` +
|
||||
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
|
||||
],
|
||||
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
|
||||
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
|
||||
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
|
||||
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
|
||||
[
|
||||
'JALON_NON_ECRIT',
|
||||
({ dossier, cause }) =>
|
||||
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
|
||||
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}.`,
|
||||
],
|
||||
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
|
||||
|
||||
// --- Les refus et les avertissements des commandes (§ 4.4, § 9, § 10.2) ---
|
||||
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
|
||||
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
|
||||
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
|
||||
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
|
||||
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
|
||||
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
|
||||
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
|
||||
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
|
||||
[
|
||||
'RESERVATIONS_SUSPENDUES',
|
||||
({ personne, reservations, titres }) =>
|
||||
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
|
||||
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
||||
],
|
||||
[
|
||||
'RESERVATIONS_RETABLIES',
|
||||
({ personne, reservations, titres }) =>
|
||||
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
|
||||
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
|
||||
],
|
||||
[
|
||||
'RESERVATIONS_RETIREES',
|
||||
({ personne, reservations, titres }) =>
|
||||
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
|
||||
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
||||
],
|
||||
[
|
||||
'IMPORT_EN_RETENU',
|
||||
() =>
|
||||
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
|
||||
"et rien n'est effacé ; à vous de juger s'il convient encore.",
|
||||
],
|
||||
|
||||
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
|
||||
['JALON_CREATION', () => 'Création'],
|
||||
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
|
||||
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
|
||||
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
|
||||
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
|
||||
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
|
||||
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
|
||||
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
|
||||
[
|
||||
'ENTREE_MODIFIER_PARTICIPANT',
|
||||
({ personne, champs, devenu }) => {
|
||||
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
|
||||
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
|
||||
},
|
||||
],
|
||||
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
|
||||
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
|
||||
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
|
||||
[
|
||||
'ENTREE_IMPORTER_PARTICIPANTS',
|
||||
({ mode, ajoutes, misAJour, retires }) => {
|
||||
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
|
||||
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
|
||||
if (mode === 'mettreAJour') {
|
||||
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
|
||||
}
|
||||
if (mode === 'remplacer') {
|
||||
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
|
||||
}
|
||||
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
|
||||
},
|
||||
],
|
||||
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
|
||||
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
|
||||
[
|
||||
'ENTREE_ENREGISTRER_GENERATION',
|
||||
({ n, premier, dernier }) =>
|
||||
`Généré ${compte(n, 'proposition', 'propositions')} ` +
|
||||
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
|
||||
],
|
||||
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
|
||||
[
|
||||
'ENTREE_EFFACER_PROPOSITIONS',
|
||||
({ n, gardee }) =>
|
||||
`Effacé ${compte(n, 'proposition', 'propositions')}` +
|
||||
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
|
||||
],
|
||||
]);
|
||||
|
||||
// Le texte d'un code de la table, ou RangeError qui le nomme.
|
||||
function texteDe(code, details = {}) {
|
||||
const texte = TEXTES.get(code);
|
||||
if (texte === undefined) throw new RangeError(`libelle : code inconnu ${JSON.stringify(code)}`);
|
||||
return texte(details ?? {});
|
||||
}
|
||||
|
||||
/** Les codes qui ont un texte, triés par unités UTF-16 croissantes. */
|
||||
export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
|
||||
|
||||
/**
|
||||
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un
|
||||
* avertissement, une raison, ou le libellé figé d'une entrée ou d'un jalon.
|
||||
*
|
||||
* @param {string} code
|
||||
* @param {Object} [details]
|
||||
* @returns {string}
|
||||
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
|
||||
* — raison, état, champ, mode, échec — que la table ne connaît pas
|
||||
*/
|
||||
export function libelle(code, details = {}) {
|
||||
return texteDe(code, details);
|
||||
}
|
||||
466
src/application/libelles.test.js
Normal file
466
src/application/libelles.test.js
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la table des libellés (§ 14.6, § 14.2). Le balayage relève, dans
|
||||
// les sources de src/stockage, src/csv et src/application — épreuves et table
|
||||
// exclues —, chaque code qu'elles émettent : le premier argument littéral d'un
|
||||
// appel, constructeur d'erreur ou fabrique d'avertissement ; la valeur
|
||||
// littérale d'une clé code ; celle d'une clé raison, cause ou remede, qui
|
||||
// nomme un code de détail ; la valeur littérale d'une constante, qui nomme le
|
||||
// code que le module émet par elle ; et les tables de codes des contrats,
|
||||
// celle de src/stockage/types.js et celle de src/csv/erreurs.js quand il en
|
||||
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
|
||||
// un balayage vide échoue. Chaque texte se compose ensuite de détails
|
||||
// d'exemple. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CODES, libelle } from './libelles.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||
|
||||
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
||||
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
|
||||
// La table des textes elle-même n'émet rien : ses clés sont ce qu'on éprouve.
|
||||
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
|
||||
|
||||
// --- Le balayage ----------------------------------------------------------
|
||||
|
||||
// Commentaires et littéraux de chaîne, dans l'ordre où ils s'ouvrent : un
|
||||
// commentaire se blanchit, sauf ses fins de ligne, et une chaîne reste, si
|
||||
// bien qu'un « // » dans une chaîne n'ouvre rien.
|
||||
const JETONS = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\[\s\S]|[^`\\])*`/g;
|
||||
const sansCommentaires = (texte) =>
|
||||
texte.replace(JETONS, (jeton) => (jeton.startsWith('/') ? jeton.replace(/[^\n]/g, ' ') : jeton));
|
||||
|
||||
// Un code : des capitales, des chiffres, des soulignés entre deux mots.
|
||||
const CODE = String.raw`'([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)'`;
|
||||
const MOTIFS_EMISSION = [
|
||||
// Premier argument d'un appel : new ErreurStockage('ABSENT', …),
|
||||
// refuser('UTF8_INVALIDE'), ajouter('DERIVE', …), libelle('ENTREE_…', …).
|
||||
// normalize reçoit le nom d'une forme Unicode, non un code.
|
||||
new RegExp(String.raw`\b(?!normalize\b)[A-Za-z_$][\w$]*\s*\(\s*${CODE}`, 'g'),
|
||||
// Un objet qui porte un code : { code: 'NOM_ABSENT', … }.
|
||||
new RegExp(String.raw`\bcode\s*:\s*${CODE}`, 'g'),
|
||||
// Un code de détail : { raison: 'BLOQUE' }, { cause: 'NOMBRE_DE_CHAMPS' },
|
||||
// { remede: 'ENREGISTRER_EN_UTF8' }.
|
||||
new RegExp(String.raw`\b(?:raison|cause|remede)\s*:\s*${CODE}`, 'g'),
|
||||
// Une constante qui nomme un code, que le module émet ensuite par elle :
|
||||
// const RELU = 'RELECTURE_DIFFERENTE', puis { cause: RELU }.
|
||||
new RegExp(String.raw`\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*${CODE}`, 'g'),
|
||||
];
|
||||
|
||||
/** Codes qu'un texte source émet, hors commentaires, dans l'ordre du texte. */
|
||||
function codesEmis(texte) {
|
||||
const net = sansCommentaires(texte);
|
||||
return MOTIFS_EMISSION.flatMap((motif) => [...net.matchAll(motif)].map((trouve) => ({ indice: trouve.index, code: trouve[1] })))
|
||||
.sort((a, b) => a.indice - b.indice)
|
||||
.map(({ code }) => code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes d'une table de contrat : dans un commentaire de documentation qui
|
||||
* s'ouvre sur « Codes d… », chaque ligne qui porte, trois espaces après
|
||||
* l'astérisque, un code en tête.
|
||||
*/
|
||||
function codesDesTables(texte) {
|
||||
const codes = [];
|
||||
for (const [bloc] of texte.matchAll(/\/\*\*[\s\S]*?\*\//g)) {
|
||||
if (!/^\s*\*\s*Codes d/m.test(bloc)) continue;
|
||||
for (const [, code] of bloc.matchAll(/^[ \t]*\*[ \t]{3}([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)(?=\s|$)/gm)) codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
// Modules d'un arbre, épreuves et table des textes exclues, triés.
|
||||
function modulesDe(arbre) {
|
||||
return readdirSync(join(RACINE, arbre), { recursive: true })
|
||||
.map((nom) => join(arbre, nom))
|
||||
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && chemin !== TABLE_DES_TEXTES)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Relevé de l'arbre réel : par arbre, les codes émis et le fichier qui émet
|
||||
// chacun en premier ; puis ceux des tables de contrat.
|
||||
function releverArbre() {
|
||||
const parArbre = new Map();
|
||||
const emetteurs = new Map();
|
||||
for (const arbre of ARBRES) {
|
||||
const codes = new Set();
|
||||
for (const chemin of modulesDe(arbre)) {
|
||||
for (const code of codesEmis(readFileSync(join(RACINE, chemin), 'utf8'))) {
|
||||
codes.add(code);
|
||||
if (!emetteurs.has(code)) emetteurs.set(code, relative('.', chemin));
|
||||
}
|
||||
}
|
||||
parArbre.set(arbre, codes);
|
||||
}
|
||||
const tables = new Set(TABLES.flatMap((chemin) => codesDesTables(readFileSync(join(RACINE, chemin), 'utf8'))));
|
||||
for (const code of tables) if (!emetteurs.has(code)) emetteurs.set(code, 'table de contrat');
|
||||
return { parArbre, tables, emetteurs };
|
||||
}
|
||||
|
||||
describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
||||
test("le relevé lit le premier argument d'un appel, une clé code, un code de détail, et rien d'un commentaire", () => {
|
||||
const source = [
|
||||
"// throw new ErreurStockage('DANS_UN_COMMENTAIRE');",
|
||||
"/** refuser('DANS_UN_BLOC') */",
|
||||
"throw new ErreurStockage('ABSENT', { chemin });",
|
||||
"const refus = refuser(\n 'UTF8_INVALIDE');",
|
||||
"avertissements.push({ code: 'DERIVE', details });",
|
||||
"throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });",
|
||||
"const texte = brut.normalize('NFC');",
|
||||
"const url = 'http://exemple.test'; ajouter('APRES_UNE_ADRESSE');",
|
||||
"if (erreur.code === 'ECARTE') return 'PAS_UN_APPEL';",
|
||||
"const minuscule = verifier('minuscule');",
|
||||
"const RELU = 'CONSTANTE_CODE'; const motif = raisonLue ?? 'PAS_UNE_CONSTANTE';",
|
||||
].join('\n');
|
||||
assert.deepEqual(codesEmis(source), [
|
||||
'ABSENT',
|
||||
'UTF8_INVALIDE',
|
||||
'DERIVE',
|
||||
'LECTURE_SEULE',
|
||||
'BLOQUE',
|
||||
'APRES_UNE_ADRESSE',
|
||||
'CONSTANTE_CODE',
|
||||
]);
|
||||
});
|
||||
|
||||
test("le relevé des tables lit chaque ligne de code d'un bloc « Codes d… », et ignore les autres blocs", () => {
|
||||
const source = [
|
||||
'/**',
|
||||
" * Codes d'une erreur d'essai :",
|
||||
' *',
|
||||
' * PREMIER {chemin} un premier code',
|
||||
' * qui continue ici',
|
||||
' * SECOND_CODE',
|
||||
' * {} un second',
|
||||
' */',
|
||||
'/**',
|
||||
' * Autre chose :',
|
||||
' * IGNORE {}',
|
||||
' */',
|
||||
].join('\n');
|
||||
assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']);
|
||||
});
|
||||
|
||||
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
||||
const { parArbre, tables } = releverArbre();
|
||||
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
||||
assert.ok(tables.size > 0, 'tables de contrat : balayage vide');
|
||||
assert.ok(tables.has('VERROU_PRIS') && tables.has('IDENTIFIANT_HORS_COMPTEUR'));
|
||||
});
|
||||
|
||||
test('chaque code que src/stockage, src/csv et src/application émettent a son texte', () => {
|
||||
const { parArbre, tables, emetteurs } = releverArbre();
|
||||
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
|
||||
assert.ok(releves.size > 0, 'balayage vide');
|
||||
const connus = new Set(CODES);
|
||||
const sansTexte = [...releves].filter((code) => !connus.has(code)).sort();
|
||||
assert.deepEqual(
|
||||
sansTexte.map((code) => `${code} (${emetteurs.get(code)})`),
|
||||
[],
|
||||
'codes émis sans texte dans src/application/libelles.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('chaque texte de la table a un émetteur : aucune entrée morte', () => {
|
||||
const { parArbre, tables } = releverArbre();
|
||||
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
|
||||
assert.ok(CODES.length > 0, 'table vide');
|
||||
assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les textes -----------------------------------------------------------
|
||||
|
||||
// Détails d'exemple de chaque code de la table : chaque texte s'y compose une
|
||||
// fois. Un code ajouté à la table sans exemple fait échouer l'épreuve.
|
||||
const EXEMPLES = new Map([
|
||||
// Système de fichiers et dépôt
|
||||
['ABSENT', { chemin: 'Soirée des Lucioles.gtt.json' }],
|
||||
['ECRITURE', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||
['LECTURE', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EACCES' }],
|
||||
['EXISTE', { chemin: 'Soirée des Lucioles.gtt.json' }],
|
||||
['CHEMIN_REFUSE', { chemin: '../ailleurs.gtt.json' }],
|
||||
['ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'JSON', chemin: null, secours: { precedent: 4, journal: 5 } }],
|
||||
['CORRECTIF', { rang: 2 }],
|
||||
['CHEMIN_TROP_LONG', { racine: 'E:\\un\\dossier\\profond' }],
|
||||
['IDENTIFIANT_PRESENT', { id: 'evt-lucioles', base: 'Soirée des Lucioles' }],
|
||||
['VERROU_PRIS', { seance: 'seance-autre', depuis: '2031-05-17T08:05:00-04:00', vivant: true }],
|
||||
['LECTURE_SEULE', { raison: 'BLOQUE' }],
|
||||
['ETAT_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EIO' }],
|
||||
['CORBEILLE_SATUREE', { dossier: 'corbeille/2031-05-17_08-05-00_99' }],
|
||||
['COMPTEUR_SATURE', { compteur: 'proposition' }],
|
||||
['NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' }],
|
||||
// Raisons d'un état illisible
|
||||
['VIDE', {}],
|
||||
['JSON', {}],
|
||||
['FORME', { chemin: 'charge.participants[2].nom' }],
|
||||
['COMPTES', { chemin: 'entete.comptes.tables' }],
|
||||
['REFERENCE', { chemin: 'charge.reservations[0].table' }],
|
||||
['FORMAT_INCONNU', { chemin: 'entete.format' }],
|
||||
['FORMAT_PLUS_RECENT', { format: 2 }],
|
||||
// Fautes d'un placement
|
||||
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
|
||||
['DOUBLON', { tour: 1, participant: 3 }],
|
||||
['INCONNU', { tour: 1, participant: 12 }],
|
||||
['MANQUANT', { tour: 2, participant: 7 }],
|
||||
['IDENTIFIANT_REPETE', {}],
|
||||
['IDENTIFIANT_HORS_COMPTEUR', {}],
|
||||
// Raisons de dérive
|
||||
['PARTICIPANT_EXCLU', { participant: 3 }],
|
||||
['PARTICIPANT_SUPPRIME', { participant: 4 }],
|
||||
['PARTICIPANT_NON_PLACE', { participant: 9 }],
|
||||
['TABLE_SUPPRIMEE', { table: 2 }],
|
||||
['TABLE_AJOUTEE', { table: 5 }],
|
||||
['CAPACITE_CHANGEE', { table: 1, avant: 8, maintenant: 6 }],
|
||||
['TOURS_CHANGES', { avant: 4, maintenant: 3 }],
|
||||
// Avertissements de l'ouverture
|
||||
['RETENU_FAUTIF', {}],
|
||||
['JOURNAL_REPRIS', {}],
|
||||
['JOURNAL_EN_AVANCE_DE', { n: 3 }],
|
||||
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
|
||||
['JOURNAL_ECARTEES', { n: 64 }],
|
||||
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
|
||||
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
|
||||
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
|
||||
['DERIVE', { ids: [1, 2], retenu: true }],
|
||||
// Dossier de travail et réglages locaux
|
||||
['NON_PUBLIE', {}],
|
||||
['DONNEES_APPLICATIVES', {}],
|
||||
['SONDE_ECHOUEE', { cause: 'EROFS' }],
|
||||
['RELECTURE_DIFFERENTE', {}],
|
||||
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
|
||||
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
|
||||
['PLATEFORME_WEB', {}],
|
||||
['SUPPORT_AMOVIBLE', {}],
|
||||
['REGLAGES_ILLISIBLES', { chemin: 'reglages_locaux.json', raison: 'FORME' }],
|
||||
// CSV
|
||||
['UTF16_SANS_MARQUE', { remede: 'ENREGISTRER_EN_UTF8' }],
|
||||
['UTF16_INVALIDE', { remede: 'ENREGISTRER_EN_UTF8' }],
|
||||
['UTF8_INVALIDE', { remede: 'ENREGISTRER_EN_UTF8' }],
|
||||
['CARACTERE_NUL', { remede: 'ENREGISTRER_EN_UTF8' }],
|
||||
['ENREGISTRER_EN_UTF8', {}],
|
||||
['SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' }],
|
||||
['NOMBRE_DE_CHAMPS', { ligne: 3 }],
|
||||
['GUILLEMET_OUVERT', { ligne: 2 }],
|
||||
['AUCUN_ENTETE', {}],
|
||||
['NOM_NON_ASSOCIE', {}],
|
||||
['AUCUNE_LIGNE_VALIDE', {}],
|
||||
['CHAMPS_EN_TROP', { ligne: 4, valeur: null }],
|
||||
['NOM_ABSENT', { ligne: 5, valeur: null }],
|
||||
['EXCLU_INCONNU', { ligne: 6, valeur: 'peut-être' }],
|
||||
['PLUSIEURS_CORRESPONDENT', { ligne: 7, valeur: null, participants: [3, 8] }],
|
||||
['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }],
|
||||
['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }],
|
||||
['AMBIGUITE', { champs: ['appartenance', 'courriel'] }],
|
||||
// Séance
|
||||
['AUCUN_EVENEMENT', {}],
|
||||
['MODE_LECTURE', {}],
|
||||
['FORMAT', {}],
|
||||
['BLOQUE', {}],
|
||||
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||
['SUPPORT_INTACT', {}],
|
||||
['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }],
|
||||
['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||
['RIEN_A_DEFAIRE', {}],
|
||||
['RIEN_A_REFAIRE', {}],
|
||||
['INSTANT_NON_RESTITUABLE', { revision: 3 }],
|
||||
['PLAN_NON_BLOQUE', { remede: null }],
|
||||
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||
['SANS_EFFET', { commande: 'modifierParticipant', remede: null }],
|
||||
// Refus et avertissements des commandes
|
||||
['NOM_REQUIS', { remede: null }],
|
||||
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
|
||||
['SIEGES_INVALIDES', { sieges: 1, remede: null }],
|
||||
['TOURS_INVALIDES', { tours: 0, remede: null }],
|
||||
['PARTICIPANT_INCONNU', { id: 42, remede: null }],
|
||||
['PROPOSITION_INCONNUE', { id: 17, remede: null }],
|
||||
['AUCUNE_PROPOSITION', { remede: { geste: 'ouvrirSection', section: 'generation' } }],
|
||||
['AUCUN_RETENU', { remede: { geste: 'ouvrirSection', section: 'propositions' } }],
|
||||
['RESERVATIONS_SUSPENDUES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
|
||||
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
|
||||
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
|
||||
['IMPORT_EN_RETENU', {}],
|
||||
// Libellés figés du journal
|
||||
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
|
||||
['JALON_CREATION', {}],
|
||||
['JALON_ETAT', { etat: 'retenu' }],
|
||||
['JALON_AVANT_GENERATION', {}],
|
||||
['ENTREE_REPRISE', {}],
|
||||
['ENTREE_DEFAIRE', { libelle: 'Ajouté Théo Pervenche' }],
|
||||
['ENTREE_REFAIRE', { libelle: 'Ajouté Théo Pervenche' }],
|
||||
['ENTREE_REVENIR', { libelle: 'Ajouté Iris Ombrelle' }],
|
||||
['ENTREE_AJOUTER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
|
||||
['ENTREE_MODIFIER_PARTICIPANT', { personne: 'Iris Ombrelle', champs: ['courriel'], devenu: null }],
|
||||
['ENTREE_EXCLURE_PARTICIPANT', { personne: 'Iris Ombrelle' }],
|
||||
['ENTREE_REINTEGRER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
|
||||
['ENTREE_SUPPRIMER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
|
||||
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
|
||||
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
|
||||
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
|
||||
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
|
||||
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
|
||||
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
|
||||
]);
|
||||
|
||||
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
|
||||
// objet : chaque trace est un mot entier, « nulle part » n'en est pas une.
|
||||
const TRACES = [/\bundefined\b/, /\bnull\b/, /\bNaN\b/, /\b(?:true|false)\b/, /\[object /];
|
||||
|
||||
describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
||||
test('un code inconnu lève, et le message nomme le code', () => {
|
||||
assert.throws(() => libelle('CODE_QUI_N_EXISTE_PAS', {}), (erreur) => {
|
||||
assert.ok(erreur instanceof RangeError);
|
||||
assert.match(erreur.message, /CODE_QUI_N_EXISTE_PAS/);
|
||||
return true;
|
||||
});
|
||||
assert.throws(() => libelle(undefined), RangeError);
|
||||
});
|
||||
|
||||
test("chaque texte de la table se compose de ses détails d'exemple", () => {
|
||||
assert.ok(CODES.length > 0, 'table vide');
|
||||
assert.deepEqual(CODES.filter((code) => !EXEMPLES.has(code)), [], "codes de la table sans détails d'exemple");
|
||||
assert.deepEqual([...EXEMPLES.keys()].filter((code) => !CODES.includes(code)), [], 'exemples sans code dans la table');
|
||||
for (const code of CODES) {
|
||||
const texte = libelle(code, EXEMPLES.get(code));
|
||||
assert.equal(typeof texte, 'string', code);
|
||||
assert.ok(texte.trim().length > 0, `${code} : texte vide`);
|
||||
assert.notEqual(texte, code, `${code} : le code brut tient lieu de texte`);
|
||||
for (const trace of TRACES) assert.doesNotMatch(texte, trace, `${code} : « ${texte} »`);
|
||||
}
|
||||
});
|
||||
|
||||
test('la table est triée, sans doublon, et figée', () => {
|
||||
assert.deepEqual([...CODES], [...new Set(CODES)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
assert.ok(Object.isFrozen(CODES));
|
||||
});
|
||||
|
||||
test('les libellés figés des exemples du contrat, détails interpolés', () => {
|
||||
assert.equal(libelle('ENTREE_CREATION', { nom: "Soirée d'essai" }), "Création de l'événement « Soirée d'essai »");
|
||||
assert.equal(libelle('JALON_CREATION'), 'Création');
|
||||
assert.equal(libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: 'Iris Ombrelle' }), 'Ajouté Iris Ombrelle');
|
||||
assert.equal(
|
||||
libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'Ondine Lacasse', champs: ['courriel'], devenu: null }),
|
||||
'Modifié Ondine Lacasse : courriel',
|
||||
);
|
||||
assert.equal(
|
||||
libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'Iris Ombrelle', champs: ['nom', 'titrePressenti'], devenu: 'Iris Parasol' }),
|
||||
'Modifié Iris Ombrelle : nom, titre pressenti (désormais Iris Parasol)',
|
||||
);
|
||||
assert.equal(libelle('ENTREE_REVENIR', { libelle: 'Ajouté Iris Ombrelle' }), 'Retour à « Ajouté Iris Ombrelle »');
|
||||
assert.equal(libelle('ENTREE_DEFAIRE', { libelle: 'Ajouté Théo Pervenche' }), 'Défait : « Ajouté Théo Pervenche »');
|
||||
assert.equal(libelle('ENTREE_REFAIRE', { libelle: 'Ajouté Théo Pervenche' }), 'Refait : « Ajouté Théo Pervenche »');
|
||||
assert.equal(libelle('ENTREE_REPRISE'), "Reprise de l'état trouvé sur le disque");
|
||||
assert.equal(libelle('JALON_ETAT', { etat: 'propose' }), 'État « Proposé »');
|
||||
assert.equal(libelle('ENTREE_CHANGER_ETAT', { etat: 'bloque' }), "Plan passé à l'état « Bloqué »");
|
||||
assert.equal(libelle('ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }), 'Généré 5 propositions (n° 3 à 7)');
|
||||
assert.equal(libelle('ENTREE_ENREGISTRER_GENERATION', { n: 1, premier: 4, dernier: 4 }), 'Généré 1 proposition (n° 4)');
|
||||
assert.equal(libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 1, misAJour: 0, retires: 0 }), 'Importé 1 participant');
|
||||
assert.equal(
|
||||
libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'remplacer', ajoutes: 250, misAJour: 0, retires: 260 }),
|
||||
'Remplacé la liste par import : 260 retirés, 250 ajoutés',
|
||||
);
|
||||
});
|
||||
|
||||
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
|
||||
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
|
||||
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
|
||||
assert.match(libelle('SEPARATEUR_INTROUVABLE', { ligne: 3 }), /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
|
||||
assert.match(libelle('SEPARATEUR_INTROUVABLE', { separateur: ',', ligne: 2, cause: 'GUILLEMET_OUVERT' }), /ligne 2/);
|
||||
assert.match(libelle('GUILLEMET_OUVERT', { ligne: 2 }), /ligne 2/);
|
||||
assert.doesNotMatch(libelle('SEPARATEUR_INTROUVABLE', {}), /ligne/);
|
||||
});
|
||||
|
||||
test("les quatre refus d'encodage nomment le même remède : réenregistrer en UTF-8", () => {
|
||||
const remede = libelle('ENREGISTRER_EN_UTF8');
|
||||
assert.match(remede, /UTF-8/);
|
||||
for (const code of ['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']) {
|
||||
const texte = libelle(code, { remede: 'ENREGISTRER_EN_UTF8' });
|
||||
assert.ok(texte.endsWith(remede), `${code} : « ${texte} »`);
|
||||
}
|
||||
});
|
||||
|
||||
test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => {
|
||||
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
|
||||
assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/);
|
||||
assert.match(libelle('MODE_LECTURE'), /« Modifier »/);
|
||||
assert.doesNotMatch(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), /Débloquer/);
|
||||
for (const trace of TRACES) assert.doesNotMatch(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), trace);
|
||||
assert.equal(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), libelle('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }));
|
||||
});
|
||||
|
||||
test("un état illisible nomme sa raison et ses secours ; chaque raison du contrat a son texte", () => {
|
||||
const texte = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'VIDE', chemin: null, secours: { precedent: 4, journal: null } });
|
||||
assert.match(texte, /« Soirée des Lucioles »/);
|
||||
assert.match(texte, new RegExp(libelle('VIDE')));
|
||||
assert.match(texte, /révision 4/);
|
||||
const sansSecours = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'JSON', chemin: null, secours: { precedent: null, journal: null } });
|
||||
assert.match(sansSecours, /Aucune version de secours/);
|
||||
for (const raison of ['ABSENT', 'VIDE', 'JSON', 'FORME', 'COMPTES', 'REFERENCE', 'FORMAT_INCONNU', 'FORMAT_PLUS_RECENT']) {
|
||||
const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } };
|
||||
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
|
||||
}
|
||||
assert.throws(() => libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null }), RangeError);
|
||||
assert.throws(() => libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' }), RangeError);
|
||||
});
|
||||
|
||||
test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => {
|
||||
const avecCause = CODES.filter((code) => Object.hasOwn(EXEMPLES.get(code) ?? {}, 'cause'));
|
||||
assert.ok(avecCause.length > 0, 'aucun texte ne reçoit de cause');
|
||||
for (const code of avecCause) {
|
||||
const texte = libelle(code, { ...EXEMPLES.get(code), cause: 'RELECTURE_DIFFERENTE' });
|
||||
assert.doesNotMatch(texte, /RELECTURE_DIFFERENTE/, `${code} : « ${texte} »`);
|
||||
}
|
||||
const relue = libelle('RELECTURE_DIFFERENTE');
|
||||
for (const code of ['ECRITURE', 'SONDE_ECHOUEE', 'PORTABLE_NON_INSCRIPTIBLE', 'DOCUMENTS_NON_INSCRIPTIBLE']) {
|
||||
const details = EXEMPLES.get(code);
|
||||
assert.ok(libelle(code, { ...details, cause: 'RELECTURE_DIFFERENTE' }).includes(`(${relue})`), code);
|
||||
assert.ok(libelle(code, { ...details, cause: 'TypeMismatchError' }).includes('(TypeMismatchError)'), code);
|
||||
}
|
||||
});
|
||||
|
||||
test("le support rompu dit ce que l'écriture refusée laisse sur le disque ; un échec inconnu lève", () => {
|
||||
const refus = { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' };
|
||||
const textes = ['ECRITURE', 'ETAT_NON_ECRIT', 'JALON_NON_ECRIT', 'VERROU_NON_ECRIT'].map((echec) =>
|
||||
libelle('SUPPORT_ROMPU', { echec, ...refus }),
|
||||
);
|
||||
for (const texte of textes) {
|
||||
assert.ok(texte.includes('« Soirée des Lucioles.gtt-journal.jsonl » dans le dossier « E:\\data » (EROFS)'), texte);
|
||||
assert.match(texte, /« Écrire ailleurs »/);
|
||||
}
|
||||
assert.equal(new Set(textes).size, 4);
|
||||
const [ecriture, etat, jalon, verrou] = textes;
|
||||
assert.match(ecriture, /le dernier geste n'est pas enregistré/);
|
||||
assert.match(etat, /le dernier geste est inscrit à l'historique, mais pas dans l'état/);
|
||||
assert.match(jalon, /le dernier geste est enregistré, mais pas le jalon qui le nomme/i);
|
||||
for (const texte of [jalon, verrou]) assert.doesNotMatch(texte, /n'est pas enregistré|ne se correspondent plus/);
|
||||
assert.throws(() => libelle('SUPPORT_ROMPU', { echec: 'ECHEC_INVENTE', ...refus }), RangeError);
|
||||
});
|
||||
|
||||
test('un verrou pris dit depuis quand, et distingue la séance vivante, inconnue ou morte', () => {
|
||||
const vivant = libelle('VERROU_PRIS', { seance: 's', depuis: '2031-05-01T21:40:00-04:00', vivant: true });
|
||||
assert.match(vivant, /1er mai 2031 à 21 h 40/);
|
||||
const inconnu = libelle('VERROU_PRIS', { seance: null, depuis: null, vivant: null });
|
||||
assert.notEqual(inconnu, vivant);
|
||||
assert.notEqual(libelle('VERROU_PRIS', { seance: 's', depuis: '2031-05-01T21:40:00-04:00', vivant: false }), vivant);
|
||||
});
|
||||
|
||||
test('un état, un champ, un mode inconnus lèvent au lieu de se taire', () => {
|
||||
assert.throws(() => libelle('JALON_ETAT', { etat: 'archive' }), RangeError);
|
||||
assert.throws(() => libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'x', champs: ['age'], devenu: null }), RangeError);
|
||||
assert.throws(() => libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'fusionner', ajoutes: 1, misAJour: 0, retires: 0 }), RangeError);
|
||||
});
|
||||
|
||||
test('les comptes accordent le singulier et le pluriel', () => {
|
||||
assert.match(libelle('JOURNAL_ECARTEES', { n: 1 }), /^1 entrée de l'historique est écartée/);
|
||||
assert.match(libelle('JOURNAL_ECARTEES', { n: 64 }), /^64 entrées de l'historique sont écartées/);
|
||||
assert.equal(libelle('DERIVE', { ids: [], retenu: true }).startsWith('Le placement retenu ne décrit plus'), true);
|
||||
assert.match(libelle('DERIVE', { ids: [1, 2], retenu: true }), /^Le placement retenu et les propositions n° 1 et 2 ne décrivent plus/);
|
||||
assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/);
|
||||
});
|
||||
});
|
||||
683
src/application/seance.js
Normal file
683
src/application/seance.js
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La séance (§ 8.1, § 8.3, § 8.4, § 8.5, § 8.6, § 8.8) : elle tient un seul
|
||||
// événement ouvert, ferme le précédent sans question, et l'ouvre toujours en
|
||||
// lecture ; ouvrir n'écrit rien. Le mode — lecture ou écriture — est une
|
||||
// posture de la séance, jamais écrite dans le fichier. Passer en écriture
|
||||
// prend le verrou de l'événement ; revenir en lecture, par la commande ou au
|
||||
// terme de l'inactivité, le rend.
|
||||
//
|
||||
// Un geste est une commande nommée (commandes.js) : la charge qu'elle rend
|
||||
// s'enregistre par le dépôt, le journal d'abord, l'état ensuite (§ 8.2) ; un
|
||||
// geste refusé n'écrit rien. Défaire, refaire et revenir restituent un
|
||||
// instant du journal et l'écrivent comme une entrée de retour, sans jamais
|
||||
// tronquer le journal (§ 8.3). Les jalons automatiques nomment la création,
|
||||
// chaque changement d'état, et l'instant qu'une génération quitte.
|
||||
//
|
||||
// Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire :
|
||||
// un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un
|
||||
// plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture,
|
||||
// MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Sur un plan bloqué, débloquer
|
||||
// est le seul geste admis : il prend lui-même le verrou, et laisse la séance
|
||||
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
|
||||
// lecture et rend le verrou.
|
||||
//
|
||||
// Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle
|
||||
// d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt
|
||||
// l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte
|
||||
// encore — celui d'avant le geste, ou celui que le refus a avancé, son geste
|
||||
// au journal —, refuse tout geste jusqu'à ce qu'ecrireAilleurs écrive
|
||||
// l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé
|
||||
// ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà
|
||||
// l'événement, celui de la séance compris : deux copies d'un même
|
||||
// identifiant ne se côtoient jamais dans un dossier (§ 8.6).
|
||||
//
|
||||
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
|
||||
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
|
||||
// laissé. L'horloge et les minuteries entrent en paramètre : la séance ne lit
|
||||
// ni l'heure ni un aléa, et n'arme aucune minuterie elle-même.
|
||||
import { serialiserCharge } from '../stockage/canonique.js';
|
||||
import {
|
||||
creer as creerSurDisque,
|
||||
deverrouiller,
|
||||
enregistrer,
|
||||
enregistrerAilleurs,
|
||||
lister,
|
||||
nommer as nommerSurDisque,
|
||||
ouvrir as ouvrirSurDisque,
|
||||
ouvrirSecours as ouvrirSecoursSurDisque,
|
||||
verrouiller,
|
||||
} from '../stockage/depot.js';
|
||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||
import { fil, reconstruire } from '../stockage/journal.js';
|
||||
import { appliquerCommande, chargeNeuve } from './commandes.js';
|
||||
import { libelle } from './libelles.js';
|
||||
|
||||
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
|
||||
export const INACTIVITE_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Le refus d'un geste par la séance : son code et ses détails. VERROU_PRIS
|
||||
* et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
|
||||
* ErreurStockage. Le texte affiché vient de la table des libellés (§ 14.6).
|
||||
*/
|
||||
export class ErreurSeance extends Error {
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {Object} [details]
|
||||
*/
|
||||
constructor(code, details = {}) {
|
||||
super(`${code} ${JSON.stringify(details)}`);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
ErreurSeance.prototype.name = 'ErreurSeance';
|
||||
|
||||
// Un avertissement : il n'empêche rien.
|
||||
const avertissement = (code, details) => ({ code, details });
|
||||
|
||||
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
|
||||
|
||||
// Le libellé de l'entrée de révision donnée.
|
||||
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
|
||||
|
||||
// Le libellé figé d'une entrée de retour, qui nomme l'instant : celui que
|
||||
// défaire quitte, celui que refaire ou revenir restituent.
|
||||
function libelleDuRetour(sens, instant) {
|
||||
if (sens === 'defaire') return libelle('ENTREE_DEFAIRE', { libelle: instant });
|
||||
if (sens === 'refaire') return libelle('ENTREE_REFAIRE', { libelle: instant });
|
||||
return libelle('ENTREE_REVENIR', { libelle: instant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Une séance sur le dossier de travail racine.
|
||||
*
|
||||
* @param {Object} parametres
|
||||
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
||||
* @param {import('../stockage/systeme_fichiers.js').Racine} parametres.racine
|
||||
* le dossier de travail que la règle du § 8.6 a déterminé
|
||||
* @param {{horodatage: () => string}} parametres.horloge
|
||||
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||
* @param {() => string} parametres.identifiant tiré une fois pour le verrou
|
||||
* de la séance, puis une fois par événement qu'elle crée
|
||||
* @param {string} parametres.produitVersion la construction qui écrit
|
||||
* @param {(fn: () => unknown, ms: number) => unknown} parametres.planifier
|
||||
* arme une minuterie ; rend ce qu'annuler reçoit
|
||||
* @param {(minuterie: unknown) => void} parametres.annuler
|
||||
* @param {number} [parametres.inactiviteMs]
|
||||
*/
|
||||
export function creerSeance({
|
||||
fs,
|
||||
racine,
|
||||
horloge,
|
||||
identifiant,
|
||||
produitVersion,
|
||||
planifier,
|
||||
annuler,
|
||||
inactiviteMs = INACTIVITE_MS,
|
||||
}) {
|
||||
const id = identifiant();
|
||||
const libelleReprise = libelle('ENTREE_REPRISE');
|
||||
|
||||
// L'état de la séance. ouvert : l'Ouvert courant, null sans événement ;
|
||||
// verrouTenu : la séance tient le verrou de cet événement ; refusSupport :
|
||||
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
|
||||
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
|
||||
// passage en écriture ; gestes, calculs : ceux qui sont ouverts, qui
|
||||
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée.
|
||||
const etat = {
|
||||
racine,
|
||||
ouvert: null,
|
||||
mode: 'lecture',
|
||||
verrouTenu: false,
|
||||
supportRompu: false,
|
||||
refusSupport: null,
|
||||
verrouAutre: null,
|
||||
avertissementsOuverture: [],
|
||||
avertissementsGeste: [],
|
||||
gestes: 0,
|
||||
calculs: 0,
|
||||
minuterie: null,
|
||||
};
|
||||
|
||||
// La file des appels qui lisent ou écrivent l'événement : chacun part
|
||||
// quand le précédent a fini, réussi ou non.
|
||||
let file = Promise.resolve();
|
||||
function enFile(travail) {
|
||||
const suite = file.then(travail);
|
||||
file = suite.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return suite;
|
||||
}
|
||||
|
||||
// --- L'inactivité (§ 8.4) -----------------------------------------------
|
||||
|
||||
function suspendre() {
|
||||
if (etat.minuterie === null) return;
|
||||
annuler(etat.minuterie);
|
||||
etat.minuterie = null;
|
||||
}
|
||||
|
||||
// Relance le compte d'inactivité : en écriture, sans geste ni calcul
|
||||
// ouvert ; suspendu sinon.
|
||||
function armer() {
|
||||
suspendre();
|
||||
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
|
||||
const minuterie = planifier(() => enFile(() => expirer(minuterie)), inactiviteMs);
|
||||
etat.minuterie = minuterie;
|
||||
}
|
||||
|
||||
// Le terme d'une minuterie, à son tour dans la file : celle qui est
|
||||
// toujours armée ramène la séance en lecture, tant que rien n'est ouvert.
|
||||
async function expirer(minuterie) {
|
||||
if (etat.minuterie !== minuterie) return;
|
||||
etat.minuterie = null;
|
||||
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
|
||||
await quitterEcriture();
|
||||
}
|
||||
|
||||
// --- Le verrou (§ 8.8) -----------------------------------------------------
|
||||
|
||||
// Prend le verrou de l'événement de l'Ouvert. Le verrou de cette séance,
|
||||
// resté d'un rendu refusé, est le sien. Celui d'une séance morte de ce
|
||||
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS.
|
||||
// Celui d'une séance vivante, d'un autre poste ou illisible ne se reprend
|
||||
// pas : VERROU_PRIS, que verrouAutre garde. Un système sans verrou le rend
|
||||
// toujours pris. Un support qui refuse d'écrire le verrou, ou d'effacer
|
||||
// celui du mort, rompt le support sous l'échec VERROU_NON_ECRIT, et son
|
||||
// ECRITURE remonte (§ 8.6). Rend les avertissements.
|
||||
async function prendreVerrou(ouvert) {
|
||||
let verrou;
|
||||
const avertissements = [];
|
||||
try {
|
||||
verrou = await verrouiller(ouvert, id);
|
||||
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
|
||||
if (!verrou.pris && verrou.vivant === false) {
|
||||
const mort = verrou;
|
||||
await deverrouiller(ouvert, mort.seance);
|
||||
verrou = await verrouiller(ouvert, id);
|
||||
if (verrou.pris) {
|
||||
avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis }));
|
||||
}
|
||||
}
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
|
||||
throw erreur;
|
||||
}
|
||||
if (!verrou.pris) {
|
||||
const { seance, depuis, vivant } = verrou;
|
||||
etat.verrouAutre = { seance, depuis, vivant };
|
||||
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant });
|
||||
}
|
||||
etat.verrouAutre = null;
|
||||
etat.verrouTenu = true;
|
||||
return avertissements;
|
||||
}
|
||||
|
||||
// Rend le verrou que la séance tient sur l'événement de l'Ouvert. Un refus
|
||||
// du système de fichiers ne retient pas la séance : VERROU_NON_RENDU le
|
||||
// dit, et le verrou tombe avec ce processus.
|
||||
async function rendreVerrou(ouvert) {
|
||||
if (!etat.verrouTenu) return [];
|
||||
etat.verrouTenu = false;
|
||||
try {
|
||||
await deverrouiller(ouvert, id);
|
||||
return [];
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
return [avertissement('VERROU_NON_RENDU', erreur.details)];
|
||||
}
|
||||
}
|
||||
|
||||
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
|
||||
// retour explicite annule en plus le geste ouvert. Les avertissements du
|
||||
// rendu s'ajoutent à ceux du dernier geste.
|
||||
async function quitterEcriture({ explicite = false } = {}) {
|
||||
suspendre();
|
||||
if (explicite) etat.gestes = 0;
|
||||
etat.mode = 'lecture';
|
||||
const avertissements = await rendreVerrou(etat.ouvert);
|
||||
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
|
||||
return avertissements;
|
||||
}
|
||||
|
||||
// --- Les refus --------------------------------------------------------------
|
||||
|
||||
function exigerOuvert() {
|
||||
if (etat.ouvert === null) throw new ErreurSeance('AUCUN_EVENEMENT');
|
||||
return etat.ouvert;
|
||||
}
|
||||
|
||||
function exigerSupport() {
|
||||
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
|
||||
}
|
||||
|
||||
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
|
||||
// l'ordre de l'en-tête du module.
|
||||
function exigerEcriture() {
|
||||
const ouvert = exigerOuvert();
|
||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
||||
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE');
|
||||
exigerSupport();
|
||||
return ouvert;
|
||||
}
|
||||
|
||||
// --- L'écriture d'un geste (§ 8.2, § 8.3, § 8.6) -------------------------------
|
||||
|
||||
// Le support rompu par erreur, ECRITURE ou ETAT_NON_ECRIT : la séance garde
|
||||
// ouvert, l'Ouvert que le disque porte, et le refus, dont echec nomme ce
|
||||
// que le support n'a pas reçu : par défaut le code de l'erreur, la ligne
|
||||
// ou l'état d'un geste ; JALON_NON_ECRIT, le jalon d'un geste enregistré ;
|
||||
// VERROU_NON_ECRIT, le verrou d'un passage en écriture.
|
||||
function rompre(erreur, ouvert, echec = erreur.code) {
|
||||
const { chemin, dossier, cause } = erreur.details;
|
||||
etat.ouvert = ouvert;
|
||||
etat.supportRompu = true;
|
||||
etat.refusSupport = { echec, chemin, dossier, cause };
|
||||
}
|
||||
|
||||
// Une écriture du dépôt depuis l'Ouvert reçu ; ce qu'elle rend devient
|
||||
// l'Ouvert de la séance. ECRITURE rompt le support en gardant l'Ouvert
|
||||
// reçu, que rien n'a changé ; ETAT_NON_ECRIT, en gardant l'Ouvert avancé de
|
||||
// ses détails, son geste au journal. Le refus remonte.
|
||||
async function ecrire(ouvert, ecriture) {
|
||||
try {
|
||||
etat.ouvert = await ecriture(ouvert);
|
||||
return etat.ouvert;
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert);
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Écrit un geste depuis l'Ouvert depart : le jalon jalonAvant sur l'instant
|
||||
// courant quand il est donné, puis l'entrée et l'état, puis, quand l'état
|
||||
// du plan change, le jalon qui le nomme (§ 8.3). Un horodatage pour tout
|
||||
// le geste. Le jalon d'état qu'un refus empêche laisse le geste
|
||||
// enregistré : JALON_NON_ECRIT s'ajoute aux avertissements, et le support
|
||||
// est rompu sous ce même échec. Rend le libellé ; les avertissements
|
||||
// deviennent ceux du dernier geste.
|
||||
async function ecrireGeste(depart, { charge, libelle: texte, avertissements }, options = {}) {
|
||||
const { sens = null, retour = null, jalonAvant = null } = options;
|
||||
const horodatage = horloge.horodatage();
|
||||
const commun = { horodatage, libelleReprise };
|
||||
let ouvert = depart;
|
||||
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
|
||||
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
|
||||
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
|
||||
const suite = [...avertissements];
|
||||
const etatDuPlan = ouvert.charge.evenement.etat;
|
||||
if (etatDuPlan !== depart.charge.evenement.etat) {
|
||||
const nom = libelle('JALON_ETAT', { etat: etatDuPlan });
|
||||
try {
|
||||
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
|
||||
rompre(erreur, ouvert, 'JALON_NON_ECRIT');
|
||||
const { chemin, dossier, cause } = erreur.details;
|
||||
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
|
||||
}
|
||||
}
|
||||
etat.avertissementsGeste = suite;
|
||||
return texte;
|
||||
}
|
||||
|
||||
// Après un geste, achevé ou refusé : un plan bloqué ne reste pas en
|
||||
// écriture, la séance revient en lecture et rend le verrou ; sinon le
|
||||
// compte d'inactivité repart.
|
||||
async function apresGeste() {
|
||||
if (etat.mode === 'ecriture' && etat.ouvert !== null && estBloque(etat.ouvert)) {
|
||||
await quitterEcriture();
|
||||
return;
|
||||
}
|
||||
armer();
|
||||
}
|
||||
|
||||
// Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La
|
||||
// commande d'abord, qui ne touche rien ; puis le verrou, que la séance
|
||||
// prend en lecture ; puis l'entrée et le jalon de l'état rendu. La séance
|
||||
// reste en écriture.
|
||||
async function debloquerPlan(ouvert, args) {
|
||||
exigerSupport();
|
||||
const resultat = appliquerCommande('debloquer', ouvert.charge, args);
|
||||
const avertissements = etat.mode === 'ecriture' ? [] : await prendreVerrou(ouvert);
|
||||
etat.mode = 'ecriture';
|
||||
return ecrireGeste(ouvert, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
|
||||
}
|
||||
|
||||
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
|
||||
// commande ajoute.
|
||||
async function geste(nom, args) {
|
||||
const ouvert = exigerOuvert();
|
||||
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
|
||||
return { libelle: await debloquerPlan(ouvert, args) };
|
||||
}
|
||||
exigerEcriture();
|
||||
const resultat = appliquerCommande(nom, ouvert.charge, args);
|
||||
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
|
||||
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
|
||||
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
|
||||
}
|
||||
|
||||
// Un geste et son suivi (apresGeste), qu'il aboutisse ou non : ce qu'il
|
||||
// rend, et les avertissements du dernier geste, rendu du verrou compris.
|
||||
async function suivi(travail) {
|
||||
let rendu;
|
||||
try {
|
||||
rendu = await travail();
|
||||
} finally {
|
||||
await apresGeste();
|
||||
}
|
||||
return { ...rendu, avertissements: [...etat.avertissementsGeste] };
|
||||
}
|
||||
|
||||
// --- Les retours (§ 8.3) -----------------------------------------------------
|
||||
|
||||
// Le fil de l'Ouvert : les cibles de défaire et de refaire, null quand
|
||||
// elles n'existent pas, et le libellé de l'instant courant. Quand le
|
||||
// journal finit sur une autre révision que la charge ouverte, ou manque, la
|
||||
// prochaine écriture pose d'abord l'entrée de reprise, une entrée ordinaire
|
||||
// qui restitue la charge ouverte : l'instant courant est le sien, défaire
|
||||
// vise celui où finit le journal, et rien ne se refait.
|
||||
function filDe(ouvert) {
|
||||
const { journal } = ouvert;
|
||||
if (journal === null || journal.entrees.length === 0) {
|
||||
return { cibleDefaire: null, cibleRefaire: null, courant: libelleReprise };
|
||||
}
|
||||
const lu = fil(journal);
|
||||
if (journal.entrees.at(-1).revision !== ouvert.revision) {
|
||||
return { cibleDefaire: lu.position, cibleRefaire: null, courant: libelleReprise };
|
||||
}
|
||||
const courant = libelleDeRevision(journal, lu.position);
|
||||
return { cibleDefaire: lu.cibleDefaire, cibleRefaire: lu.cibleRefaire, courant };
|
||||
}
|
||||
|
||||
// La charge de l'instant revision du journal de l'Ouvert, ou
|
||||
// INSTANT_NON_RESTITUABLE : un instant que l'élagage a retiré, ou qu'un
|
||||
// correctif qui ne s'applique pas a perdu.
|
||||
function restituer(ouvert, revision) {
|
||||
if (!Number.isSafeInteger(revision)) {
|
||||
throw new TypeError(`revision : entier attendu, reçu ${JSON.stringify(revision)}`);
|
||||
}
|
||||
if (ouvert.journal === null) throw new ErreurSeance('INSTANT_NON_RESTITUABLE', { revision });
|
||||
try {
|
||||
return reconstruire(ouvert.journal, revision);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof RangeError || (erreur instanceof ErreurStockage && erreur.code === 'CORRECTIF')) {
|
||||
throw new ErreurSeance('INSTANT_NON_RESTITUABLE', { revision });
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
|
||||
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
|
||||
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
|
||||
async function retour(sens, visee) {
|
||||
const ouvert = exigerEcriture();
|
||||
const { cibleDefaire, cibleRefaire, courant } = filDe(ouvert);
|
||||
let cible = visee;
|
||||
if (sens === 'defaire') {
|
||||
if (cibleDefaire === null) throw new ErreurSeance('RIEN_A_DEFAIRE');
|
||||
cible = cibleDefaire;
|
||||
}
|
||||
if (sens === 'refaire') {
|
||||
if (cibleRefaire === null) throw new ErreurSeance('RIEN_A_REFAIRE');
|
||||
cible = cibleRefaire;
|
||||
}
|
||||
const charge = restituer(ouvert, cible);
|
||||
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
|
||||
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
|
||||
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
|
||||
const texte = await ecrireGeste(ouvert, entree, { sens, retour: cible });
|
||||
return { libelle: texte };
|
||||
}
|
||||
|
||||
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
|
||||
|
||||
// Ferme l'événement ouvert : le compte s'arrête, le verrou se rend, et la
|
||||
// séance oublie l'événement. Rend les avertissements du rendu.
|
||||
async function fermerEvenement() {
|
||||
suspendre();
|
||||
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
|
||||
Object.assign(etat, {
|
||||
ouvert: null,
|
||||
mode: 'lecture',
|
||||
verrouTenu: false,
|
||||
supportRompu: false,
|
||||
refusSupport: null,
|
||||
verrouAutre: null,
|
||||
avertissementsOuverture: [],
|
||||
avertissementsGeste: [],
|
||||
gestes: 0,
|
||||
calculs: 0,
|
||||
});
|
||||
return avertissements;
|
||||
}
|
||||
|
||||
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
|
||||
// fermé le précédent.
|
||||
async function ouvrirAvec(lire) {
|
||||
await fermerEvenement();
|
||||
const ouvert = await lire();
|
||||
etat.ouvert = ouvert;
|
||||
etat.avertissementsOuverture = ouvert.avertissements;
|
||||
return { base: ouvert.base, avertissements: ouvert.avertissements };
|
||||
}
|
||||
|
||||
// Crée l'événement de ces champs (§ 2.1, étape 1) : la charge neuve
|
||||
// (chargeNeuve), dont un champ refusé lève avant que rien ne change —
|
||||
// l'événement ouvert reste, son mode et son verrou compris ; puis le
|
||||
// précédent se ferme, et s'écrivent l'entrée de création et le jalon
|
||||
// « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état
|
||||
// que le support refuse d'écrire, le journal écrit, rompt le support.
|
||||
async function creerEvenement(champs) {
|
||||
const { charge, libelle: texte } = chargeNeuve(champs, identifiant);
|
||||
await fermerEvenement();
|
||||
const options = {
|
||||
libelle: texte,
|
||||
nomJalon: libelle('JALON_CREATION'),
|
||||
horodatage: horloge.horodatage(),
|
||||
produitVersion,
|
||||
};
|
||||
try {
|
||||
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
||||
throw erreur;
|
||||
}
|
||||
etat.avertissementsOuverture = etat.ouvert.avertissements;
|
||||
return { base: etat.ouvert.base };
|
||||
}
|
||||
|
||||
// --- Les modes (§ 8.4, § 8.8) -------------------------------------------------------
|
||||
|
||||
async function passerEnEcriture() {
|
||||
const ouvert = exigerOuvert();
|
||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||
exigerSupport();
|
||||
const avertissements = await prendreVerrou(ouvert);
|
||||
etat.mode = 'ecriture';
|
||||
etat.avertissementsGeste = avertissements;
|
||||
armer();
|
||||
return { avertissements };
|
||||
}
|
||||
|
||||
async function passerEnLecture() {
|
||||
etat.gestes = 0;
|
||||
suspendre();
|
||||
if (etat.mode === 'lecture') return { avertissements: [] };
|
||||
etat.avertissementsGeste = [];
|
||||
return { avertissements: await quitterEcriture({ explicite: true }) };
|
||||
}
|
||||
|
||||
// Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en
|
||||
// NFC, ses blancs de bord retirés.
|
||||
async function nommer(nom) {
|
||||
try {
|
||||
const ouvert = exigerEcriture();
|
||||
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
|
||||
const texte = nom.normalize('NFC').trim();
|
||||
if (texte === '') throw new ErreurSeance('NOM_REQUIS');
|
||||
await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise }));
|
||||
} finally {
|
||||
await apresGeste();
|
||||
}
|
||||
return { revision: etat.ouvert.revision };
|
||||
}
|
||||
|
||||
// Le résumé (lister) de l'événement d'identifiant idEvenement quand le
|
||||
// dossier de racine le porte déjà : par un état lisible, par un état
|
||||
// illisible que son .precedent ou son journal apparie à cet identifiant,
|
||||
// ou par un journal sans état ; null sinon, et pour un dossier qui
|
||||
// n'existe pas encore. Rien ne s'écrit.
|
||||
async function dejaPresent(racine, idEvenement) {
|
||||
let resumes;
|
||||
try {
|
||||
resumes = await lister(fs, racine);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ABSENT') return null;
|
||||
throw erreur;
|
||||
}
|
||||
return resumes.find((resume) => resume.id === idEvenement) ?? null;
|
||||
}
|
||||
|
||||
// Écrit l'événement dans la racine nouvelle, la réponse au support rompu
|
||||
// (§ 8.6) : la paire alignée de l'Ouvert gardé (enregistrerAilleurs), puis
|
||||
// le verrou rendu là où il était, puis, en écriture, pris là-bas. La séance
|
||||
// s'y poursuit, son support rétabli. Un dossier qui porte déjà
|
||||
// l'événement (dejaPresent), celui de la séance compris, se refuse
|
||||
// IDENTIFIANT_PRESENT, sans rien écrire : la paire s'y poserait sous une
|
||||
// seconde base. Un état que le support refuse là-bas, le journal écrit, y
|
||||
// transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en
|
||||
// lecture, le support rompu par ce refus, le verrou rendu là où il était ;
|
||||
// un nouvel essai y trouve ce journal, et se refuse. Tout autre échec
|
||||
// laisse tout en l'état, le support toujours rompu.
|
||||
async function ecrireAilleurs(nouvelle) {
|
||||
const ouvert = exigerOuvert();
|
||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
||||
if (!etat.supportRompu) throw new ErreurSeance('SUPPORT_INTACT');
|
||||
const present = await dejaPresent(nouvelle, ouvert.charge.evenement.id);
|
||||
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
|
||||
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
|
||||
let ailleurs;
|
||||
try {
|
||||
ailleurs = await enregistrerAilleurs(ouvert, nouvelle, options);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') {
|
||||
suspendre();
|
||||
etat.mode = 'lecture';
|
||||
etat.racine = nouvelle;
|
||||
rompre(erreur, erreur.details.ouvert);
|
||||
etat.avertissementsGeste = await rendreVerrou(ouvert);
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
const avertissements = await rendreVerrou(ouvert);
|
||||
Object.assign(etat, { ouvert: ailleurs, racine: nouvelle, supportRompu: false, refusSupport: null });
|
||||
if (etat.mode === 'ecriture') {
|
||||
try {
|
||||
avertissements.push(...(await prendreVerrou(ailleurs)));
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
etat.mode = 'lecture';
|
||||
avertissements.push(avertissement(erreur.code, erreur.details));
|
||||
}
|
||||
}
|
||||
etat.avertissementsGeste = avertissements;
|
||||
armer();
|
||||
return { base: ailleurs.base, avertissements };
|
||||
}
|
||||
|
||||
return {
|
||||
/** L'identifiant de la séance, qui signe son verrou. */
|
||||
get id() {
|
||||
return id;
|
||||
},
|
||||
/** Le dossier de travail, que l'écriture ailleurs remplace. */
|
||||
get racine() {
|
||||
return etat.racine;
|
||||
},
|
||||
/** 'lecture' ou 'ecriture' (§ 8.4). */
|
||||
get mode() {
|
||||
return etat.mode;
|
||||
},
|
||||
/** L'Ouvert de l'événement (depot.js), null sans événement. */
|
||||
get ouvert() {
|
||||
return etat.ouvert;
|
||||
},
|
||||
get charge() {
|
||||
return etat.ouvert?.charge ?? null;
|
||||
},
|
||||
/** Vrai quand une écriture refusée a rompu l'équivalence du § 8.2. */
|
||||
get supportRompu() {
|
||||
return etat.supportRompu;
|
||||
},
|
||||
/** {echec, chemin, dossier, cause} de l'écriture qui a rompu le support, ou null. */
|
||||
get refusSupport() {
|
||||
return etat.refusSupport;
|
||||
},
|
||||
/** {seance, depuis, vivant} du verrou qui a refusé le dernier passage en écriture, ou null. */
|
||||
get verrouAutre() {
|
||||
return etat.verrouAutre;
|
||||
},
|
||||
/** Ceux de l'ouverture, puis ceux du dernier geste. */
|
||||
get avertissements() {
|
||||
return [...etat.avertissementsOuverture, ...etat.avertissementsGeste];
|
||||
},
|
||||
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
|
||||
get retours() {
|
||||
if (etat.ouvert === null) return { defaire: false, refaire: false };
|
||||
const { cibleDefaire, cibleRefaire } = filDe(etat.ouvert);
|
||||
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
|
||||
},
|
||||
get gesteEnCours() {
|
||||
return etat.gestes > 0;
|
||||
},
|
||||
get calculEnCours() {
|
||||
return etat.calculs > 0;
|
||||
},
|
||||
|
||||
ouvrir: (base) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base))),
|
||||
ouvrirSecours: (base, source) =>
|
||||
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source))),
|
||||
creer: (champs) => enFile(() => creerEvenement(champs)),
|
||||
fermer: () => enFile(async () => ({ avertissements: await fermerEvenement() })),
|
||||
passerEnEcriture: () => enFile(passerEnEcriture),
|
||||
passerEnLecture: () => enFile(passerEnLecture),
|
||||
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
||||
debloquer: () => enFile(() => suivi(() => geste('debloquer', {}))),
|
||||
defaire: () => enFile(() => suivi(() => retour('defaire', null))),
|
||||
refaire: () => enFile(() => suivi(() => retour('refaire', null))),
|
||||
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
|
||||
nommer: (nom) => enFile(() => nommer(nom)),
|
||||
ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)),
|
||||
|
||||
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
|
||||
// ils s'emboîtent, et un fini de trop ne compte pas.
|
||||
debuterGeste() {
|
||||
etat.gestes += 1;
|
||||
suspendre();
|
||||
},
|
||||
finirGeste() {
|
||||
etat.gestes = Math.max(0, etat.gestes - 1);
|
||||
armer();
|
||||
},
|
||||
debuterCalcul() {
|
||||
etat.calculs += 1;
|
||||
suspendre();
|
||||
},
|
||||
finirCalcul() {
|
||||
etat.calculs = Math.max(0, etat.calculs - 1);
|
||||
armer();
|
||||
},
|
||||
};
|
||||
}
|
||||
1200
src/application/seance.test.js
Normal file
1200
src/application/seance.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -49,7 +49,13 @@
|
|||
// geste : le journal en mémoire, en entier — suivi de l'entrée de reprise
|
||||
// quand il finit sur une autre révision que la charge ouverte —, puis l'état
|
||||
// que restitue sa dernière entrée, à sa révision. Rouvert là-bas,
|
||||
// l'événement rend la charge écrite sans rien rattraper.
|
||||
// l'événement rend la charge écrite sans rien rattraper. Avec un geste, la
|
||||
// paire porte l'entrée de ce geste à la suite du journal, et sa charge : le
|
||||
// renommage (§ 8.7) écrit ainsi la paire complète sous le nouveau nom, dans
|
||||
// le dossier même, sans toucher l'ancienne ni créer le dossier. Son
|
||||
// .precedent, l'état d'avant le geste, s'écrit avant le journal : la
|
||||
// nouvelle base appartient à l'événement dès que son journal existe, et
|
||||
// l'ancienne paire ne s'apparie jamais ce journal.
|
||||
//
|
||||
// Un état sur le disque, ou un journal de la base, d'un format plus récent
|
||||
// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours
|
||||
|
|
@ -133,8 +139,11 @@ import { examiner } from './placements.js';
|
|||
* Un événement du dossier, tel que lister le résume.
|
||||
*
|
||||
* Les champs qui viennent de l'état lu — nom, date, etat, participants —
|
||||
* valent null quand il ne se lit pas ou qu'il manque ; id aussi, sauf pour un
|
||||
* journal sans état, dont il est l'événement que nomme sa première ligne.
|
||||
* valent null quand il ne se lit pas ou qu'il manque. id est celui de l'état
|
||||
* lu ; pour un état illisible, celui par lequel il s'apparie
|
||||
* (identifiantSansEtat), null quand il n'en a aucun ; pour un journal sans
|
||||
* état, l'événement que nomme sa première ligne. Un événement du dossier se
|
||||
* reconnaît ainsi à son id, que son état se lise ou non.
|
||||
*
|
||||
* @typedef {Object} Resume
|
||||
* @property {string} base
|
||||
|
|
@ -243,12 +252,27 @@ function formatPlusRecentDe(texte) {
|
|||
return Number.isSafeInteger(format) && format > FORMAT ? format : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bases que portent les fichiers d'événement — état, .precedent, journal,
|
||||
* verrou — à la racine de racine, dans l'ordre de sa liste, sans rien
|
||||
* écrire : une base que l'un d'eux porte n'est pas libre (deriverBase). Une
|
||||
* base revient une fois par fichier qui la porte.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @returns {Promise<string[]>}
|
||||
* @throws {ErreurStockage} ABSENT quand la racine n'existe pas
|
||||
*/
|
||||
export async function basesPrises(fs, racine) {
|
||||
return basesDe(await fs.lister(racine, ''));
|
||||
}
|
||||
|
||||
// Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des
|
||||
// bases d'autres, ni de celles que portent les fichiers du dossier, à la casse
|
||||
// près.
|
||||
async function deriver(fs, racine, nom, autres) {
|
||||
const { separateur } = await fs.emplacements();
|
||||
const existantes = [...autres, ...basesDe(await fs.lister(racine, ''))];
|
||||
const existantes = [...autres, ...(await basesPrises(fs, racine))];
|
||||
return deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||||
}
|
||||
|
||||
|
|
@ -521,9 +545,10 @@ async function refuser(vue, base, details) {
|
|||
* et qu'aucun état lisible ne porte son identifiant, illisible ABSENT ; il
|
||||
* ne se montre pas sinon : une copie d'un journal apparié, ou le journal
|
||||
* qu'un état illisible s'apparie par son .precedent. Un identifiant que
|
||||
* portent deux états lisibles les signale doublon. Résumés triés par nom,
|
||||
* les noms inconnus après les autres, puis par base, unités UTF-16
|
||||
* comparées.
|
||||
* portent deux états lisibles les signale doublon ; le résumé d'un état
|
||||
* illisible porte en id celui qui l'apparie, sans le compter en doublon.
|
||||
* Résumés triés par nom, les noms inconnus après les autres, puis par base,
|
||||
* unités UTF-16 comparées.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
|
|
@ -557,7 +582,7 @@ export async function lister(fs, racine) {
|
|||
else if (id !== null) journal = (await apparier(vue, base, id))?.base ?? null;
|
||||
if (journal !== null) apparies.add(journal);
|
||||
if (lecture === null) {
|
||||
resumes.push(sansLecture(base, null, modifie, journal, details.raison));
|
||||
resumes.push(sansLecture(base, id, modifie, journal, details.raison));
|
||||
continue;
|
||||
}
|
||||
const { evenement, participants } = lecture.charge;
|
||||
|
|
@ -905,6 +930,29 @@ function apresEcriture(ouvert, { charge, revision, journal }) {
|
|||
};
|
||||
}
|
||||
|
||||
// La ligne de l'entrée d'un geste à la suite du journal que preparer a
|
||||
// préparé, et sa révision, celle qui suit la dernière entrée de ce journal :
|
||||
// un correctif depuis la charge que restitue cette entrée, ou un instantané à
|
||||
// la cadence du journal ; produitVersion n'y paraît que quand la construction
|
||||
// change (versionNotee). Une ligne que la lecture refuserait lève TypeError
|
||||
// (ligneEntree), avant toute écriture.
|
||||
function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion, sens, retour }) {
|
||||
const derniere = preparation.journal.entrees.at(-1).revision;
|
||||
const revision = derniere + 1;
|
||||
const ligne = ligneEntree({
|
||||
revision,
|
||||
libelle,
|
||||
horodatage,
|
||||
sens,
|
||||
retour,
|
||||
produitVersion: versionNotee(produitVersion, preparation.journal, derniere),
|
||||
avant: preparation.charge,
|
||||
apres,
|
||||
instantane: estInstantane(revision, preparation.journal.entrees[0].revision),
|
||||
});
|
||||
return { ligne, revision };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de
|
||||
* son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit
|
||||
|
|
@ -990,22 +1038,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
|||
* refuserait, avant toute écriture
|
||||
*/
|
||||
export async function enregistrer(ouvert, apres, options) {
|
||||
const { libelle, horodatage, produitVersion, sens, retour, libelleReprise } = options;
|
||||
const { horodatage, produitVersion, libelleReprise } = options;
|
||||
exigerEcriture(ouvert);
|
||||
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||
const derniere = preparation.journal.entrees.at(-1).revision;
|
||||
const revision = derniere + 1;
|
||||
const ligne = ligneEntree({
|
||||
revision,
|
||||
libelle,
|
||||
horodatage,
|
||||
sens,
|
||||
retour,
|
||||
produitVersion: versionNotee(produitVersion, preparation.journal, derniere),
|
||||
avant: preparation.charge,
|
||||
apres,
|
||||
instantane: estInstantane(revision, preparation.journal.entrees[0].revision),
|
||||
});
|
||||
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
||||
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
||||
const aligne = etatAligne(ouvert, preparation);
|
||||
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
|
||||
|
|
@ -1058,43 +1094,72 @@ export async function deverrouiller({ fs, racine, base }, seance) {
|
|||
await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance);
|
||||
}
|
||||
|
||||
// L'état d'avant un geste, que le .precedent garde (§ 8.8) : le texte de
|
||||
// l'état sur le disque quand il se lit ; sinon, pour un Ouvert de secours, la
|
||||
// charge ouverte sous son en-tête, celle que l'entrée de reprise restitue.
|
||||
// Une charge que l'analyse refuserait lève TypeError, avant toute écriture.
|
||||
function etatDAvant({ texteEtat, charge, revision, produitVersion }) {
|
||||
return texteEtat ?? relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
||||
}
|
||||
|
||||
// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans
|
||||
// journal, l'état seul : la charge ouverte, à sa révision et à sa version.
|
||||
// Sinon le journal préparé (preparer), suivi de l'entrée de reprise quand il
|
||||
// finit sur une autre révision que la charge ouverte, en entier et élagué
|
||||
// au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que restitue sa
|
||||
// dernière entrée, sous l'en-tête de celle-ci (enteteDeLaDerniere), comme
|
||||
// l'état qu'aligne un geste : la dernière entrée du journal écrit restitue
|
||||
// l'état écrit (§ 8.2), et la réouverture n'a rien à rattraper. Une reprise
|
||||
// sans libelleReprise lève TypeError (preparer).
|
||||
function paireAilleurs(ouvert, options) {
|
||||
if (ouvert.journal === null) {
|
||||
// journal ni geste, l'état seul : la charge ouverte, à sa révision et à sa
|
||||
// version. Sinon le journal préparé (preparer), suivi de l'entrée de reprise
|
||||
// quand il finit sur une autre révision que la charge ouverte, ou qu'il
|
||||
// manque, puis de l'entrée du geste quand il y en a un (ligneDuGeste), en
|
||||
// entier et élagué au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que
|
||||
// restitue sa dernière entrée : la charge du geste, à sa révision et sous sa
|
||||
// version, comme l'écrit enregistrer ; sans geste, la charge préparée sous
|
||||
// l'en-tête de cette entrée (enteteDeLaDerniere), comme l'état qu'aligne un
|
||||
// geste. La dernière entrée du journal écrit restitue l'état écrit (§ 8.2),
|
||||
// et la réouverture n'a rien à rattraper. Une reprise sans libelleReprise, ou
|
||||
// un geste dont la ligne ne se relirait pas, lève TypeError.
|
||||
function paireAilleurs(ouvert, { libelleReprise, horodatage, geste }) {
|
||||
if (ouvert.journal === null && geste === null) {
|
||||
const { charge, revision, produitVersion } = ouvert;
|
||||
return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion };
|
||||
}
|
||||
const preparation = preparer(ouvert, options);
|
||||
const { texte, journal } = textePlein(preparation.journal);
|
||||
const { charge } = preparation;
|
||||
return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
|
||||
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||
if (geste === null) {
|
||||
const { texte, journal } = textePlein(preparation.journal);
|
||||
const { charge } = preparation;
|
||||
return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
|
||||
}
|
||||
const { apres, produitVersion } = geste;
|
||||
const { ligne, revision } = ligneDuGeste(preparation, apres, { ...geste, horodatage });
|
||||
const { texte, journal } = textePlein(avec(preparation.journal, ligne));
|
||||
return { journal, texteJournal: texte, charge: apres, chargeJournal: canoniser(apres), revision, produitVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit l'événement ouvert dans une autre racine (§ 8.6) : la réponse à une
|
||||
* écriture qui échoue, l'Ouvert reçu étant celui d'avant le geste refusé
|
||||
* (ECRITURE) ou l'Ouvert avancé d'ETAT_NON_ECRIT. L'opérateur a choisi cette
|
||||
* racine, que rien n'a sondée — documents en mode portable — : elle se crée
|
||||
* d'abord, parents compris, un dossier existant restant tel quel. Sa base
|
||||
* dérive du nom là-bas (deriverBase), libre parmi existantes et les bases du
|
||||
* Écrit l'événement ouvert sous une base libre d'une racine : dans une autre
|
||||
* racine (§ 8.6), la réponse à une écriture qui échoue, l'Ouvert reçu étant
|
||||
* celui d'avant le geste refusé (ECRITURE) ou l'Ouvert avancé
|
||||
* d'ETAT_NON_ECRIT ; ou, avec un geste, sous le nom que ce geste donne, dans
|
||||
* le dossier même, ce qu'écrit le renommage (§ 8.7). Une racine que
|
||||
* l'opérateur a choisie, que rien n'a sondée — documents en mode portable —,
|
||||
* se crée d'abord, parents compris, un dossier existant restant tel quel ;
|
||||
* creerRacine faux ne la crée pas : le dossier de travail qui disparaît en
|
||||
* cours de séance ne renaît pas vide (§ 8.6). La base dérive du nom que porte
|
||||
* la charge écrite (deriverBase), libre parmi existantes et les bases du
|
||||
* dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le
|
||||
* journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé
|
||||
* libelleReprise, quand il finit sur une autre révision que la charge
|
||||
* ouverte, et élagué au-delà de PLAFOND_ENTREES —, puis l'état que restitue
|
||||
* sa dernière entrée, à la révision et à la version de celle-ci. Rouvert
|
||||
* là-bas, l'événement rend cette charge sans rien rattraper. Un événement
|
||||
* sans journal n'écrit que son état : la charge ouverte, à sa révision. Le
|
||||
* .precedent ne se recopie pas. Les lignes et le texte de l'état se
|
||||
* calculent, et se relisent, avant la première écriture, celle de la racine
|
||||
* comprise.
|
||||
* ouverte, puis de l'entrée du geste, et élagué au-delà de PLAFOND_ENTREES —,
|
||||
* puis l'état que restitue sa dernière entrée, à la révision et à la version
|
||||
* de celle-ci. Rouvert là-bas, l'événement rend cette charge sans rien
|
||||
* rattraper. Un événement sans journal ni geste n'écrit que son état : la
|
||||
* charge ouverte, à sa révision ; avec un geste, l'entrée de reprise commence
|
||||
* son journal. Le .precedent de l'Ouvert ne se recopie pas. Avec un geste, la
|
||||
* nouvelle base reçoit d'abord le sien, l'état d'avant le geste (etatDAvant),
|
||||
* comme enregistrer le pose, mais avant le journal : la base appartient alors
|
||||
* à l'événement (proprietaire), si bien qu'aucun autre état de son
|
||||
* identifiant — l'ancienne paire d'un renommage interrompu, dont le journal
|
||||
* de la base n'est pas le sien — ne s'apparie le journal neuf (apparier). La
|
||||
* paire va sous une base qu'aucun fichier du dossier ne porte : elle n'y
|
||||
* remplace rien, pas même la paire de l'Ouvert quand ce dossier est le sien.
|
||||
* Les lignes et les textes d'état se calculent, et se relisent, avant la
|
||||
* première écriture, celle de la racine comprise.
|
||||
*
|
||||
* @param {Ouvert} ouvert
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
|
|
@ -1104,25 +1169,41 @@ function paireAilleurs(ouvert, options) {
|
|||
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
||||
* requis quand elle a lieu
|
||||
* @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de
|
||||
* l'entrée de reprise, requis avec elle
|
||||
* l'entrée de reprise et de celle du geste, requis avec elles
|
||||
* @param {{apres: import('./types.js').Charge, libelle: string, produitVersion: string}|null} [options.geste]
|
||||
* un geste à la suite de la charge ouverte, comme enregistrer le
|
||||
* reçoit : apres devient la charge écrite, à la révision qui suit la
|
||||
* dernière entrée du journal, l'entrée au libellé libelle, l'état
|
||||
* sous la version produitVersion ; null par défaut
|
||||
* @param {boolean} [options.creerRacine] faux : la racine n'est pas créée,
|
||||
* et doit exister ; vrai par défaut
|
||||
* @returns {Promise<Ouvert>} l'Ouvert dans racine : le journal et la
|
||||
* révision écrits, chargeJournal la charge de l'état écrit, et
|
||||
* charge celle qu'en rend examiner
|
||||
* @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se
|
||||
* crée pas, rien d'autre n'étant écrit, ou que le journal n'a pas pu
|
||||
* s'écrire ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire, ouvert
|
||||
* étant l'Ouvert dans racine ; CHEMIN_TROP_LONG
|
||||
* @throws {TypeError} une reprise sans libelleReprise ou horodatage, un état
|
||||
* que la lecture refuserait, avant toute écriture
|
||||
* crée pas, ou que le .precedent d'un geste n'a pas pu s'écrire, rien
|
||||
* d'autre n'étant écrit, ou que le journal n'a pas pu s'écrire, le
|
||||
* .precedent d'un geste restant écrit avant lui ; ABSENT {chemin: ''}
|
||||
* quand creerRacine est faux et que la racine n'existe pas, rien
|
||||
* n'étant écrit ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire,
|
||||
* ouvert étant l'Ouvert dans racine ; CHEMIN_TROP_LONG
|
||||
* @throws {TypeError} une reprise sans libelleReprise ou horodatage, un geste
|
||||
* ou un état que la lecture refuserait, avant toute écriture
|
||||
*/
|
||||
export async function enregistrerAilleurs(ouvert, racine, { existantes, libelleReprise, horodatage }) {
|
||||
export async function enregistrerAilleurs(
|
||||
ouvert,
|
||||
racine,
|
||||
{ existantes, libelleReprise, horodatage, geste = null, creerRacine = true },
|
||||
) {
|
||||
exigerEcriture(ouvert);
|
||||
const { fs } = ouvert;
|
||||
const paire = paireAilleurs(ouvert, { libelleReprise, horodatage });
|
||||
const paire = paireAilleurs(ouvert, { libelleReprise, horodatage, geste });
|
||||
const { journal, charge, revision, produitVersion } = paire;
|
||||
const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
||||
await fs.creerDossier(racine, '');
|
||||
const precedent = geste === null ? null : etatDAvant(ouvert);
|
||||
if (creerRacine) await fs.creerDossier(racine, '');
|
||||
const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]);
|
||||
if (precedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, precedent);
|
||||
if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal);
|
||||
const ailleurs = {
|
||||
...ouvert,
|
||||
|
|
|
|||
548
src/stockage/depot.long.test.js
Normal file
548
src/stockage/depot.long.test.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le coût d'un enregistrement complet (§ 8.2, § 8.9, § 14.4) : la durée d'un
|
||||
// geste qu'enregistrer (depot.js) écrit sur un vrai système de fichiers — le
|
||||
// journal, le .precedent, l'état — pour la grande démonstration livrée
|
||||
// portant vingt propositions, dont une retenue. Deux gestes se mesurent,
|
||||
// chacun sous son seuil : le geste ordinaire, dont la ligne s'ajoute en fin
|
||||
// de journal, et le geste dont l'entrée fait passer le journal au-delà de
|
||||
// PLAFOND_ENTREES, qui le réécrit en entier, élagué. Le système de fichiers
|
||||
// est celui de la plateforme electron : la page (fichiers_electron.js) devant
|
||||
// les primitives de la coquille (electron/fichiers.js), sous un dossier
|
||||
// temporaire neuf, retiré à la fin de l'épreuve ; seul l'IPC d'Electron y est
|
||||
// remplacé, par une copie des arguments et des réponses.
|
||||
//
|
||||
// La charge mesurée se construit une fois pour le fichier (chargeMesuree) :
|
||||
// le moteur produit ses vingt plans, et une épreuve affirme que l'analyse
|
||||
// l'admet et que la forme canonique la réécrit telle quelle. Chaque geste
|
||||
// mesuré retouche le placement retenu — deux personnes changent de table à
|
||||
// un tour — ; son entrée porte un correctif qui touche deux listes de table,
|
||||
// ou l'instantané de la charge à la cadence du journal. Après chaque geste,
|
||||
// hors de la mesure, l'état et le .precedent sur le disque sont, octet pour
|
||||
// octet, ceux que le geste écrit ; à la fin, le journal relu porte une
|
||||
// entrée par révision et restitue la charge de révisions choisies : la durée
|
||||
// retenue vient toujours d'un geste qui a écrit tout ce qu'écrit un
|
||||
// enregistrement. Un geste qui n'écrirait plus les
|
||||
// propositions, plus le .precedent ou plus le journal élagué mesurerait
|
||||
// autre chose, et passerait sous le seuil (§ 14.2).
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PREFIXE_CANAL, PRIMITIVES, creerFichiers, decrireSysteme, servir } from '../../electron/fichiers.js';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { regenerer } from '../moteur/recherche.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { canoniser, serialiser } from './canonique.js';
|
||||
import { creer, enregistrer, ouvrir } from './depot.js';
|
||||
import { analyser, configurationDepuisCharge } from './document.js';
|
||||
import { creerFichiersElectron } from './fichiers_electron.js';
|
||||
import {
|
||||
INTERVALLE_INSTANTANE,
|
||||
PLAFOND_ENTREES,
|
||||
PLANCHER_ENTREES,
|
||||
estInstantane,
|
||||
ligneEntree,
|
||||
lireJournal,
|
||||
reconstruire,
|
||||
} from './journal.js';
|
||||
import { SUFFIXES } from './noms.js';
|
||||
import { examiner, versFichier } from './placements.js';
|
||||
|
||||
const V = VERSION.affichee;
|
||||
const NOM_PRODUIT = 'Gestion table tournante Libre';
|
||||
|
||||
// Les propositions de la charge mesurée, la vingtaine du § 8.9 sur la grande
|
||||
// démonstration, aux graines 1 à PROPOSITIONS.
|
||||
const PROPOSITIONS = 20;
|
||||
|
||||
// Le compte d'arrêt de chaque proposition, réduit à côté de celui des
|
||||
// réglages : la taille d'un placement ne dépend que de sa forme, non de sa
|
||||
// qualité, et la construction de la charge reste brève.
|
||||
const ARRET_REDUIT = 2_000;
|
||||
|
||||
// L'échantillonnage, contre une charge passagère de la machine. Après un
|
||||
// geste de chauffe, chaque épreuve mesure son nombre de base de gestes, et
|
||||
// son seuil porte sur le plus court : le minimum écarte ce qu'une machine
|
||||
// occupée ajoute à un geste, pourvu qu'un geste au moins s'exécute quand la
|
||||
// charge se relâche. Tant que le plus court dépasse le seuil, l'épreuve
|
||||
// mesure d'autres gestes, jusqu'à son plafond de gestes ou jusqu'à ce que
|
||||
// FENETRE_MESURE_MS se soient écoulées depuis le premier geste mesuré, ce
|
||||
// qui vient d'abord. Une charge plus courte que la fenêtre laisse ainsi
|
||||
// passer un geste sous le seuil, et l'épreuve passe ; une régression, qui
|
||||
// allonge chaque geste, le laisse au-delà à chaque geste, et l'épreuve
|
||||
// échoue au plafond. Sous la charge que couvre MARGE, le nombre de base
|
||||
// suffit : l'épreuve ne s'allonge qu'au-delà.
|
||||
//
|
||||
// GESTES_MESURES, le nombre de base des gestes ordinaires : vingt tiennent
|
||||
// sous la charge que couvre MARGE, là où cinq n'y tiennent pas.
|
||||
// GESTES_AU_PLUS, cinq fois autant, borne les gestes ordinaires mesurés.
|
||||
const GESTES_MESURES = 20;
|
||||
const GESTES_AU_PLUS = 100;
|
||||
|
||||
// ESSAIS_ELAGAGE, le nombre de base des essais du geste qui élague : cinq
|
||||
// tiennent sous la charge que couvre MARGE. Chaque essai pose un journal
|
||||
// plein, d'environ 2 Mo, et relit celui qu'il écrit. ESSAIS_ELAGAGE_AU_PLUS,
|
||||
// cinq fois autant, borne les essais mesurés.
|
||||
const ESSAIS_ELAGAGE = 5;
|
||||
const ESSAIS_ELAGAGE_AU_PLUS = 25;
|
||||
|
||||
// La fenêtre de mesure, en millisecondes, au mur, vérifications hors mesure
|
||||
// comprises : la charge passagère que l'échantillonnage écarte dure moins
|
||||
// qu'elle. Elle borne aussi la durée d'une épreuve qui échoue : une mesure
|
||||
// s'arrête au premier geste qui finit au-delà, et la fenêtre vaut un sixième
|
||||
// du plafond de la série node-long (test/rapporteur.js).
|
||||
const FENETRE_MESURE_MS = 10_000;
|
||||
|
||||
// Les seuils du coût d'un enregistrement complet (§ 8.2, § 14.1), en
|
||||
// millisecondes, chacun au minimum de ses gestes mesurés, qui garde le coût
|
||||
// du geste. Le § 8.2 réécrit l'état entier à chaque geste : c'est ce coût
|
||||
// qui gouverne la forme du fichier (§ 8.9).
|
||||
//
|
||||
// SEUIL_MS borne le geste ordinaire : correctif depuis l'entrée qui précède,
|
||||
// état sérialisé puis relu, ligne du journal ajoutée et vidée sur le disque,
|
||||
// .precedent puis état réécrits par écriture atomique. L'entrée qui porte un
|
||||
// instantané, une sur INTERVALLE_INSTANTANE, coûte autant : canoniser la
|
||||
// charge y remplace le calcul du correctif, et sa ligne s'ajoute de même.
|
||||
// SEUIL_ELAGAGE_MS borne le geste dont l'entrée fait passer le journal
|
||||
// au-delà de PLAFOND_ENTREES : le journal se réécrit en entier — son texte
|
||||
// recomposé, lu par l'élagage, puis relu une fois élagué — par une écriture
|
||||
// atomique de la taille d'une dizaine d'instantanés de la charge, avant le
|
||||
// .precedent et l'état. Le plafond atteint, ce geste revient environ une
|
||||
// fois par centaine de gestes : l'élagage garde un peu plus de
|
||||
// PLANCHER_ENTREES entrées. Les autres réécritures entières du journal —
|
||||
// une entrée de reprise, des lignes écartées à la lecture, un journal qui
|
||||
// manque (depot.js) — réparent un événement sorti du cours ordinaire des
|
||||
// gestes, et ces épreuves ne les mesurent pas.
|
||||
//
|
||||
// REFERENCE_MS et REFERENCE_ELAGAGE_MS sont les minimums que relèvent ces
|
||||
// épreuves, le fichier lancé seul, médiane de plusieurs lancements arrondie
|
||||
// à la milliseconde supérieure, sur un processeur x86-64 de bureau de
|
||||
// génération Zen 3, seize cœurs virtuels sous KVM, le dossier temporaire en
|
||||
// mémoire (tmpfs), sous Linux et Node 26.
|
||||
// MARGE, le facteur qui tire chaque seuil de sa référence, couvre, sur la
|
||||
// machine des références, quatre séries node-long lancées ensemble —
|
||||
// autant que de conteneurs simultanés par défaut sous make
|
||||
// verifier_systemes (GTT_PARALLELES) —, chacune son dossier temporaire sur
|
||||
// disque, où vider sur le disque s'ajoute au calcul. Sous cette charge, le
|
||||
// plus court des GESTES_MESURES gestes ordinaires monte jusqu'à trois fois
|
||||
// REFERENCE_MS, et le plus court des ESSAIS_ELAGAGE essais jusqu'à trois
|
||||
// fois REFERENCE_ELAGAGE_MS : il reste un tiers de marge. MARGE ne couvre
|
||||
// pas ce qu'un autre conteneur de make verifier_systemes peut exécuter en
|
||||
// même temps — make test, npm ci, Chromium —, ni davantage de conteneurs à
|
||||
// la fois, ni une machine plus lente d'un tiers sous la même charge : contre
|
||||
// une telle charge, quand elle passe, l'échantillonnage mesure d'autres
|
||||
// gestes jusqu'à son plafond. Elle reste assez basse pour qu'un état dont
|
||||
// les placements pèsent dix fois plus, comme sous leur forme nommée (§ 8.9),
|
||||
// dépasse chacun des deux seuils.
|
||||
//
|
||||
// Propriétaire (§ 14.1) : la série node-long, où ces épreuves relèvent la
|
||||
// mesure à chaque exécution et la nomment quand elle dépasse son seuil. Le
|
||||
// relevé sur la plateforme livrée — l'exécutable Windows, sous un antivirus
|
||||
// qui inspecte chaque fichier temporaire — appartient au niveau manuel,
|
||||
// avant livraison : tant qu'il n'a pas eu lieu, ces seuils sont des défauts
|
||||
// de travail relevés sous Linux.
|
||||
const REFERENCE_MS = 20;
|
||||
const REFERENCE_ELAGAGE_MS = 87;
|
||||
const MARGE = 4;
|
||||
const SEUIL_MS = REFERENCE_MS * MARGE;
|
||||
const SEUIL_ELAGAGE_MS = REFERENCE_ELAGAGE_MS * MARGE;
|
||||
|
||||
// Durée affichée, une décimale, virgule décimale.
|
||||
const ms = (duree) => `${duree.toFixed(1).replace('.', ',')} ms`;
|
||||
|
||||
const croissant = (a, b) => a - b;
|
||||
|
||||
// Un nombre sur deux chiffres.
|
||||
const deux = (n) => String(n).padStart(2, '0');
|
||||
|
||||
// Horodatage d'affichage de l'instant n, une minute par instant depuis 8 h ;
|
||||
// il n'entre dans aucune mesure.
|
||||
const heure = (n) => `2031-11-08T${deux(8 + Math.floor(n / 60))}:${deux(n % 60)}:00-05:00`;
|
||||
|
||||
const CREATION = { libelle: "Création de l'événement", nomJalon: 'Création', horodatage: heure(0), produitVersion: V };
|
||||
const geste = (n) => ({ libelle: `Retouche ${n} du placement retenu`, horodatage: heure(n), produitVersion: V });
|
||||
|
||||
// Le retenu repris d'une proposition, dont l'identifiant devient l'origine ;
|
||||
// son placement est une copie, qu'une retouche modifie sans toucher la
|
||||
// proposition.
|
||||
function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) {
|
||||
return {
|
||||
proposition: id,
|
||||
siegesAttribues,
|
||||
tables: [...tables],
|
||||
capacites: [...capacites],
|
||||
tours,
|
||||
participants: [...participants],
|
||||
placement: structuredClone(placement),
|
||||
};
|
||||
}
|
||||
|
||||
// La grande démonstration livrée, portant PROPOSITIONS propositions que le
|
||||
// moteur produit aux graines 1 à PROPOSITIONS sous ARRET_REDUIT, numérotées
|
||||
// depuis le compteur de la charge, qui passe au-delà de la dernière ; la
|
||||
// première est retenue, et l'événement dans l'état retenu.
|
||||
function construire() {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const { historique } = charge.reglages.generation;
|
||||
const decalage = charge.prochainsIds.proposition - 1;
|
||||
charge.propositions = Array.from({ length: PROPOSITIONS }, (_, rang) => {
|
||||
const graine = rang + 1;
|
||||
const plan = regenerer(configuration, { graine, arret: ARRET_REDUIT, historique });
|
||||
return versFichier({ id: graine, graine, arret: ARRET_REDUIT, historique, plan }, configuration, {
|
||||
produitVersion: V,
|
||||
attribuerSieges: charge.reglages.attribuerSieges,
|
||||
decalage,
|
||||
});
|
||||
});
|
||||
charge.prochainsIds.proposition = decalage + PROPOSITIONS + 1;
|
||||
charge.retenu = retenuDe(charge.propositions[0]);
|
||||
charge.evenement.etat = 'retenu';
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge mesurée, construite au premier appel et partagée par les
|
||||
// épreuves du fichier, qui ne la modifient pas.
|
||||
let construite = null;
|
||||
const chargeMesuree = () => (construite ??= construire());
|
||||
|
||||
// La charge reçue, son placement retenu retouché au tour d'index tour : la
|
||||
// première personne sans réservation de la première table et celle de la
|
||||
// deuxième changent de table, comme une retouche à la main (§ 5.8). Une
|
||||
// personne réservée ne s'échange pas, ce que le geste refuserait (§ 5.9).
|
||||
// Les sièges n'étant pas attribués, chaque liste reste triée par
|
||||
// identifiant (§ 8.9).
|
||||
function echanger(charge, tour) {
|
||||
const apres = structuredClone(charge);
|
||||
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
|
||||
const [premiere, deuxieme] = apres.retenu.placement[tour].sieges;
|
||||
const a = premiere.find((id) => !reservees.has(id));
|
||||
const b = deuxieme.find((id) => !reservees.has(id));
|
||||
assert.ok(a !== undefined && b !== undefined, `tour ${tour + 1} : aucune personne à échanger`);
|
||||
premiere.splice(premiere.indexOf(a), 1, b);
|
||||
deuxieme.splice(deuxieme.indexOf(b), 1, a);
|
||||
premiere.sort(croissant);
|
||||
deuxieme.sort(croissant);
|
||||
return apres;
|
||||
}
|
||||
|
||||
// Les textes d'un événement dont le journal est plein, à PLAFOND_ENTREES
|
||||
// entrées : le journal que creer a écrit pour la charge a — ouverture,
|
||||
// entrée 1 en instantané, jalon de la création —, suivi des entrées 2 à
|
||||
// PLAFOND_ENTREES, écrites ligne à ligne plutôt que par autant de gestes
|
||||
// enregistrés. Les charges alternent : a aux révisions impaires, b, son
|
||||
// retenu retouché au premier tour, aux paires. Chaque ligne a la forme que
|
||||
// ligneEntree donne à son passage — instantané à la cadence du journal
|
||||
// (estInstantane), correctif de a vers b ou de b vers a sinon —, calculée
|
||||
// une fois par sorte de passage, sous le libellé et l'horodatage de sa
|
||||
// révision : les clés remplacées gardent leur place, et chaque ligne est
|
||||
// celle qu'écrirait ligneEntree. Rend le journal, l'état à la dernière
|
||||
// révision, le .precedent à celle d'avant, et chargeDe, la charge d'une
|
||||
// révision.
|
||||
function evenementPlein(journalCree, a) {
|
||||
const b = echanger(a, 0);
|
||||
const chargeDe = (revision) => (revision % 2 === 1 ? a : b);
|
||||
const formes = new Map();
|
||||
const lignes = [];
|
||||
for (let revision = 2; revision <= PLAFOND_ENTREES; revision += 1) {
|
||||
const { libelle, horodatage } = geste(revision);
|
||||
const instantane = estInstantane(revision, 1);
|
||||
const sorte = `${instantane} ${revision % 2}`;
|
||||
if (!formes.has(sorte)) {
|
||||
const ligne = ligneEntree({
|
||||
revision,
|
||||
libelle,
|
||||
horodatage,
|
||||
avant: chargeDe(revision - 1),
|
||||
apres: chargeDe(revision),
|
||||
instantane,
|
||||
});
|
||||
formes.set(sorte, JSON.parse(ligne));
|
||||
}
|
||||
lignes.push(JSON.stringify({ ...formes.get(sorte), revision, libelle, horodatage }));
|
||||
}
|
||||
const etat = (revision) => serialiser(chargeDe(revision), { revision, produitVersion: V });
|
||||
return {
|
||||
journal: `${journalCree}${lignes.join('\n')}\n`,
|
||||
etat: etat(PLAFOND_ENTREES),
|
||||
precedent: etat(PLAFOND_ENTREES - 1),
|
||||
chargeDe,
|
||||
};
|
||||
}
|
||||
|
||||
// Les dialogues et l'explorateur ne servent pas à un enregistrement : un
|
||||
// appel lèverait TypeError.
|
||||
const SANS_DIALOGUE = Object.freeze({});
|
||||
|
||||
// Le système de fichiers de la plateforme electron sur dossier : la page
|
||||
// (creerFichiersElectron) devant les primitives de la coquille
|
||||
// (creerFichiers), que servir offre canal par canal, comme le processus
|
||||
// principal. L'IPC d'Electron y est remplacé par une copie structurée des
|
||||
// arguments et de la réponse, comme il les copie. Les Documents et l'AppData
|
||||
// du poste sont des dossiers de dossier ; rend le système et la racine
|
||||
// documents, créée.
|
||||
async function systemeSur(dossier) {
|
||||
const systeme = decrireSysteme({
|
||||
env: {},
|
||||
plateforme: process.platform,
|
||||
cheminSysteme: (nom) => (nom === 'temp' ? tmpdir() : join(dossier, nom)),
|
||||
nomProduit: NOM_PRODUIT,
|
||||
});
|
||||
const gestionnaires = new Map();
|
||||
servir(creerFichiers({ ...systeme, dialog: SANS_DIALOGUE, shell: SANS_DIALOGUE }), (canal, gestionnaire) =>
|
||||
gestionnaires.set(canal, gestionnaire),
|
||||
);
|
||||
const pont = Object.fromEntries(
|
||||
PRIMITIVES.map((primitive) => {
|
||||
const gestionnaire = gestionnaires.get(`${PREFIXE_CANAL}${primitive}`);
|
||||
const appeler = async (...parametres) =>
|
||||
structuredClone(await gestionnaire({ sender: null }, ...structuredClone(parametres)));
|
||||
return [primitive, appeler];
|
||||
}),
|
||||
);
|
||||
const fs = creerFichiersElectron(pont);
|
||||
const { documents } = await fs.racines();
|
||||
mkdirSync(documents.chemin, { recursive: true });
|
||||
return { fs, racine: documents };
|
||||
}
|
||||
|
||||
// Chemin, sur le disque, du fichier de suffixe de l'événement base sous
|
||||
// racine.
|
||||
const cheminDe = (racine, base, suffixe) => join(racine.chemin, `${base}${suffixe}`);
|
||||
|
||||
// Texte du fichier chemin, ou null quand il manque.
|
||||
const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : null);
|
||||
|
||||
// Révision de l'en-tête d'un texte d'état, ou null sans texte.
|
||||
const revisionDe = (texte) => (texte === null ? null : analyser(texte).entete.revision);
|
||||
|
||||
// Affirme que le disque porte ce qu'écrit le geste de la révision revision :
|
||||
// l'état au texte etat, le .precedent au texte precedent, celui de l'état
|
||||
// d'avant le geste. Les révisions des en-têtes se comparent d'abord, null
|
||||
// pour un fichier qui manque : un fichier que le geste n'a pas écrit se
|
||||
// nomme par la révision qu'il garde, avant toute différence de deux textes.
|
||||
function affirmerEcrits(racine, base, revision, { etat, precedent }) {
|
||||
const ecrits = {
|
||||
etat: lireSiPresent(cheminDe(racine, base, SUFFIXES.etat)),
|
||||
precedent: lireSiPresent(cheminDe(racine, base, SUFFIXES.precedent)),
|
||||
};
|
||||
assert.deepEqual(
|
||||
{ etat: revisionDe(ecrits.etat), precedent: revisionDe(ecrits.precedent) },
|
||||
{ etat: revision, precedent: revision - 1 },
|
||||
`révisions de l'état et du .precedent sur le disque après le geste de la révision ${revision}`,
|
||||
);
|
||||
assert.ok(ecrits.etat === etat, `l'état sur le disque n'est pas celui qu'écrit le geste de la révision ${revision}`);
|
||||
assert.ok(
|
||||
ecrits.precedent === precedent,
|
||||
`le .precedent sur le disque n'est pas l'état d'avant le geste de la révision ${revision}`,
|
||||
);
|
||||
}
|
||||
|
||||
// La mesure échantillonnée d'un geste (GESTES_MESURES) : faire exécute un
|
||||
// geste et ses vérifications, et rend la durée du seul geste. Un premier
|
||||
// appel chauffe, sans compter ; suivent base gestes mesurés, puis d'autres
|
||||
// tant que le plus court dépasse seuil, jusqu'à auPlus gestes mesurés ou la
|
||||
// fin de FENETRE_MESURE_MS, comptée depuis le premier. Rend les durées, dans
|
||||
// l'ordre des gestes, et arret, ce qui a clos une mesure restée au-delà du
|
||||
// seuil.
|
||||
async function mesurer(faire, { base, auPlus, seuil }) {
|
||||
await faire();
|
||||
const durees = [];
|
||||
const debut = performance.now();
|
||||
const ouverte = () => performance.now() - debut < FENETRE_MESURE_MS;
|
||||
while (durees.length < base || (Math.min(...durees) > seuil && durees.length < auPlus && ouverte())) {
|
||||
durees.push(await faire());
|
||||
}
|
||||
const arret =
|
||||
durees.length >= auPlus
|
||||
? `plafond de ${auPlus} mesures atteint`
|
||||
: `fenêtre de ${FENETRE_MESURE_MS / 1000} s écoulée`;
|
||||
return { durees, arret };
|
||||
}
|
||||
|
||||
// Le message d'un seuil dépassé : le minimum, le nombre de mesures, leur
|
||||
// médiane et la plus longue, ce qui a clos la mesure, le seuil, et la
|
||||
// référence dont MARGE le tire.
|
||||
function depasse(quoi, { durees, arret }, seuil, reference) {
|
||||
const triees = [...durees].sort(croissant);
|
||||
return (
|
||||
`${quoi} s'enregistre en ${ms(triees[0])} au minimum de ${durees.length} mesures ` +
|
||||
`(médiane ${ms(triees[Math.floor((triees.length - 1) / 2)])}, la plus longue ${ms(triees.at(-1))} ; ` +
|
||||
`${arret}), au-delà du seuil de ${ms(seuil)} (§ 8.2) : ${reference}, MARGE ${MARGE}`
|
||||
);
|
||||
}
|
||||
|
||||
describe("dépôt : le coût d'un enregistrement complet, sur un vrai système de fichiers (§ 8.2)", () => {
|
||||
test("la charge mesurée est la grande démonstration portant vingt propositions du moteur, la première retenue : l'analyse l'admet, la forme canonique la réécrit octet pour octet, et aucune proposition n'est fautive ni périmée", () => {
|
||||
const texte = serialiser(chargeMesuree(), { revision: 1, produitVersion: V });
|
||||
const lu = analyser(texte);
|
||||
const { participants, propositions, retenu } = lu.entete.comptes;
|
||||
assert.deepEqual(
|
||||
{ participants, propositions, retenu },
|
||||
{ participants: 260, propositions: PROPOSITIONS, retenu: 1 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
lu.charge.propositions.map(({ graine }) => graine),
|
||||
Array.from({ length: PROPOSITIONS }, (_, rang) => rang + 1),
|
||||
);
|
||||
assert.equal(serialiser(lu.charge, lu.entete), texte);
|
||||
const { fautives, derives, retenu: examenRetenu } = examiner(lu.charge);
|
||||
assert.deepEqual(
|
||||
{ fautives, derives, retenu: examenRetenu },
|
||||
{ fautives: [], derives: [], retenu: { fautes: [], raisons: [] } },
|
||||
);
|
||||
});
|
||||
|
||||
test(`un geste qui retouche le placement retenu s'enregistre — journal, .precedent, état — en au plus ${ms(SEUIL_MS)}, au minimum de ${GESTES_MESURES} à ${GESTES_AU_PLUS} mesures après un geste de chauffe ; chaque geste laisse sur le disque l'état et le .precedent qu'il écrit, et l'événement rouvert est celui du dernier geste, dont le journal porte chaque révision`, async () => {
|
||||
const dossier = mkdtempSync(join(tmpdir(), 'gtt-cout-'));
|
||||
try {
|
||||
const { fs, racine } = await systemeSur(dossier);
|
||||
let ouvert = await creer(fs, racine, chargeMesuree(), CREATION);
|
||||
// La charge de chaque révision, depuis la création, et le texte de
|
||||
// l'état à la dernière. Chaque geste retouche le tour qui suit celui du
|
||||
// geste d'avant.
|
||||
const charges = [chargeMesuree()];
|
||||
let texteEtat = serialiser(chargeMesuree(), { revision: 1, produitVersion: V });
|
||||
const faire = async () => {
|
||||
const revision = charges.length + 1;
|
||||
const avant = charges.at(-1);
|
||||
const apres = echanger(avant, (revision - 2) % avant.evenement.tours);
|
||||
const debut = performance.now();
|
||||
ouvert = await enregistrer(ouvert, apres, geste(revision));
|
||||
const duree = performance.now() - debut;
|
||||
const texteApres = serialiser(apres, { revision, produitVersion: V });
|
||||
assert.equal(ouvert.revision, revision);
|
||||
affirmerEcrits(racine, ouvert.base, revision, { etat: texteApres, precedent: texteEtat });
|
||||
charges.push(apres);
|
||||
texteEtat = texteApres;
|
||||
return duree;
|
||||
};
|
||||
const mesure = await mesurer(faire, { base: GESTES_MESURES, auPlus: GESTES_AU_PLUS, seuil: SEUIL_MS });
|
||||
|
||||
// L'événement se rouvre sans rien rattraper ni signaler, à la charge
|
||||
// du dernier geste ; l'état sur le disque porte les vingt propositions
|
||||
// et le retenu ; le journal porte une entrée par révision.
|
||||
const revision = charges.length;
|
||||
const rouvert = await ouvrir(fs, racine, ouvert.base);
|
||||
assert.deepEqual(rouvert.avertissements, []);
|
||||
assert.equal(rouvert.revision, revision);
|
||||
assert.deepEqual(rouvert.charge, canoniser(charges.at(-1)));
|
||||
const etat = analyser(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.etat), 'utf8'));
|
||||
const { propositions, retenu } = etat.entete.comptes;
|
||||
assert.deepEqual({ propositions, retenu }, { propositions: PROPOSITIONS, retenu: 1 });
|
||||
const journal = lireJournal(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.journal), 'utf8'));
|
||||
assert.deepEqual(
|
||||
journal.entrees.map((entree) => entree.revision),
|
||||
Array.from({ length: revision }, (_, rang) => rang + 1),
|
||||
);
|
||||
// Le journal restitue la charge de chaque révision jusqu'au dernier des
|
||||
// GESTES_MESURES gestes de base, puis celle de la dernière.
|
||||
// Reconstruire une révision rejoue chaque correctif depuis l'instantané
|
||||
// qui la précède : le coût de relire chaque révision croît comme le
|
||||
// carré du nombre de gestes entre deux instantanés. Les gestes de base,
|
||||
// en nombre fixe, se relisent donc tous, et les gestes de plus de
|
||||
// l'échantillonnage par le dernier. Un correctif faux se voit à sa
|
||||
// révision : un geste ultérieur qui récrit les mêmes listes de table
|
||||
// l'efface des suivantes.
|
||||
const relues = Array.from({ length: GESTES_MESURES + 2 }, (_, rang) => rang + 1);
|
||||
if (revision > relues.length) relues.push(revision);
|
||||
for (const relue of relues) {
|
||||
assert.deepEqual(reconstruire(journal, relue), canoniser(charges[relue - 1]), `révision ${relue}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
Math.min(...mesure.durees) <= SEUIL_MS,
|
||||
depasse('un geste sur la grande démonstration', mesure, SEUIL_MS, `REFERENCE_MS ${REFERENCE_MS} ms`),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dossier, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`le geste dont l'entrée fait passer le journal au-delà de ${PLAFOND_ENTREES} entrées le réécrit élagué — journal, .precedent, état — en au plus ${ms(SEUIL_ELAGAGE_MS)}, au minimum de ${ESSAIS_ELAGAGE} à ${ESSAIS_ELAGAGE_AU_PLUS} essais après un essai de chauffe, chacun sur une copie neuve du journal plein ; le journal écrit part de l'instantané de la coupe et restitue le geste`, async () => {
|
||||
const dossier = mkdtempSync(join(tmpdir(), 'gtt-elagage-'));
|
||||
try {
|
||||
const { fs, racine } = await systemeSur(dossier);
|
||||
const { base } = await creer(fs, racine, chargeMesuree(), CREATION);
|
||||
const chemin = (suffixe) => cheminDe(racine, base, suffixe);
|
||||
const plein = evenementPlein(readFileSync(chemin(SUFFIXES.journal), 'utf8'), chargeMesuree());
|
||||
const poser = () => {
|
||||
writeFileSync(chemin(SUFFIXES.journal), plein.journal);
|
||||
writeFileSync(chemin(SUFFIXES.precedent), plein.precedent);
|
||||
writeFileSync(chemin(SUFFIXES.etat), plein.etat);
|
||||
};
|
||||
|
||||
// L'événement plein s'ouvre sans rien signaler, son journal à
|
||||
// PLAFOND_ENTREES entrées. enregistrer ne modifie pas l'Ouvert reçu :
|
||||
// chaque essai part du même, sur le journal plein reposé, la chauffe
|
||||
// comprise.
|
||||
poser();
|
||||
const ouvert = await ouvrir(fs, racine, base);
|
||||
assert.deepEqual(
|
||||
{ avertissements: ouvert.avertissements, revision: ouvert.revision, entrees: ouvert.journal.entrees.length },
|
||||
{ avertissements: [], revision: PLAFOND_ENTREES, entrees: PLAFOND_ENTREES },
|
||||
);
|
||||
|
||||
// La coupe de l'élagage : l'instantané le plus récent — révision 1,
|
||||
// puis toutes les INTERVALLE_INSTANTANE — d'où le journal garde au
|
||||
// moins PLANCHER_ENTREES entrées jusqu'au geste.
|
||||
const revision = PLAFOND_ENTREES + 1;
|
||||
const coupe = 1 + Math.floor((revision - PLANCHER_ENTREES) / INTERVALLE_INSTANTANE) * INTERVALLE_INSTANTANE;
|
||||
const apres = echanger(plein.chargeDe(PLAFOND_ENTREES), 1);
|
||||
const texteApres = serialiser(apres, { revision, produitVersion: V });
|
||||
let essai = 0;
|
||||
let journal = null;
|
||||
const faire = async () => {
|
||||
poser();
|
||||
const debut = performance.now();
|
||||
await enregistrer(ouvert, apres, geste(revision));
|
||||
const duree = performance.now() - debut;
|
||||
affirmerEcrits(racine, base, revision, { etat: texteApres, precedent: plein.etat });
|
||||
journal = lireJournal(readFileSync(chemin(SUFFIXES.journal), 'utf8'));
|
||||
assert.deepEqual(
|
||||
{
|
||||
premiere: journal.entrees[0].revision,
|
||||
entrees: journal.entrees.length,
|
||||
jalons: journal.jalons.length,
|
||||
ecartees: journal.ecartees,
|
||||
},
|
||||
{ premiere: coupe, entrees: revision - coupe + 1, jalons: 0, ecartees: 0 },
|
||||
`journal écrit par l'essai ${essai}`,
|
||||
);
|
||||
assert.deepEqual(reconstruire(journal, revision), canoniser(apres));
|
||||
essai += 1;
|
||||
return duree;
|
||||
};
|
||||
const mesure = await mesurer(faire, {
|
||||
base: ESSAIS_ELAGAGE,
|
||||
auPlus: ESSAIS_ELAGAGE_AU_PLUS,
|
||||
seuil: SEUIL_ELAGAGE_MS,
|
||||
});
|
||||
|
||||
// Le journal élagué restitue encore l'instant d'avant le geste, et
|
||||
// l'événement se rouvre sur le geste sans rien rattraper ni signaler.
|
||||
assert.deepEqual(reconstruire(journal, PLAFOND_ENTREES), canoniser(plein.chargeDe(PLAFOND_ENTREES)));
|
||||
const rouvert = await ouvrir(fs, racine, base);
|
||||
assert.deepEqual(
|
||||
{ avertissements: rouvert.avertissements, revision: rouvert.revision },
|
||||
{ avertissements: [], revision },
|
||||
);
|
||||
assert.deepEqual(rouvert.charge, canoniser(apres));
|
||||
|
||||
assert.ok(
|
||||
Math.min(...mesure.durees) <= SEUIL_ELAGAGE_MS,
|
||||
depasse(
|
||||
'le geste qui élague le journal plein',
|
||||
mesure,
|
||||
SEUIL_ELAGAGE_MS,
|
||||
`REFERENCE_ELAGAGE_MS ${REFERENCE_ELAGAGE_MS} ms`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dossier, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,7 @@ import { versionVoisine } from '../../test/version_voisine.js';
|
|||
import { VERSION } from '../version.genere.js';
|
||||
import { serialiser, serialiserCharge } from './canonique.js';
|
||||
import {
|
||||
basesPrises,
|
||||
creer,
|
||||
deverrouiller,
|
||||
enregistrer,
|
||||
|
|
@ -450,7 +451,7 @@ describe('dépôt : créer, ouvrir, lister', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible, journal sans état, doublon, journal d'un autre", async () => {
|
||||
test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible et l'identifiant qui l'apparie, journal sans état, doublon, journal d'un autre", async () => {
|
||||
const ouverture = (evenement) => `${ligneOuverture(evenement)}\n`;
|
||||
const etat = (id, nom) => etatTexte(chargeEssai({ id, nom }));
|
||||
const { fs } = disqueAvec([
|
||||
|
|
@ -477,6 +478,9 @@ describe('dépôt : créer, ouvrir, lister', () => {
|
|||
['Hortensia.gtt.json', etat('evt-h', 'Hortensia'), 8000],
|
||||
// état illisible, ni .precedent ni journal
|
||||
['Jacinthe.gtt.json', '{"entete":', 8500],
|
||||
// état illisible, son .precedent lisible, sans journal
|
||||
['Kalmia.gtt.json', '{', 8600],
|
||||
['Kalmia.gtt.json.precedent', etat('evt-k', 'Kalmia'), 8601],
|
||||
// ni état ni journal
|
||||
['notes.txt', 'à lire', 9000],
|
||||
]);
|
||||
|
|
@ -511,11 +515,12 @@ describe('dépôt : créer, ouvrir, lister', () => {
|
|||
lisible('Dahlia bis', 'evt-d', 'Dahlia', 4001, null, true),
|
||||
lisible('Esquisse', 'evt-e', 'Esquisse', 5000, null),
|
||||
lisible('Hortensia', 'evt-h', 'Hortensia', 8000, 'Glycine'),
|
||||
illisible('Bleuet', null, 2000, 'Bleuet', 'VIDE'),
|
||||
illisible('Bleuet', 'evt-b', 2000, 'Bleuet', 'VIDE'),
|
||||
illisible('Capucine', 'evt-c', 3000, 'Capucine', 'ABSENT'),
|
||||
illisible('Fougere', null, 6000, 'Fougere', 'ABSENT'),
|
||||
illisible('Glycine', null, 7000, null, 'JSON'),
|
||||
illisible('Jacinthe', null, 8500, null, 'JSON'),
|
||||
illisible('Kalmia', 'evt-k', 8600, null, 'JSON'),
|
||||
]);
|
||||
assert.equal(fs.compteurs.ecritures, 0);
|
||||
});
|
||||
|
|
@ -2303,6 +2308,153 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
|||
assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4));
|
||||
});
|
||||
|
||||
test("enregistrerAilleurs avec un geste, sans créer la racine : sous la base que dérive le nom du geste, l'état d'avant le geste au .precedent — le texte sur le disque, sinon la charge ouverte —, puis le journal recopié — reprise comprise quand il le faut — suivi de l'entrée du geste, puis l'état du geste ; la paire de l'Ouvert ne s'écrit pas et ne s'apparie pas le journal neuf, et une racine absente ne se crée pas", async () => {
|
||||
const { disque, fs } = nouveauDisque();
|
||||
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
|
||||
const avant = await fichiersDe(disque);
|
||||
const nouvelle = 'Gala des Saules';
|
||||
const renommee = (charge) => ({ ...charge, evenement: { ...charge.evenement, nom: nouvelle } });
|
||||
const options = {
|
||||
existantes: [],
|
||||
horodatage: heure(3),
|
||||
geste: { apres: renommee(ouvert.charge), libelle: 'Renommé', produitVersion: V2 },
|
||||
creerRacine: false,
|
||||
};
|
||||
const ecritsSous = (base) => [
|
||||
['ecrireAtomique', `${base}${SUFFIXES.precedent}`],
|
||||
['ecrireAtomique', `${base}${SUFFIXES.journal}`],
|
||||
['ecrireAtomique', `${base}${SUFFIXES.etat}`],
|
||||
];
|
||||
const trace = tracer(fs);
|
||||
const ailleurs = await enregistrerAilleurs(ouvert, DOC, options);
|
||||
assert.deepEqual(trace, ecritsSous(nouvelle));
|
||||
assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), texteDe(avant, ETAT));
|
||||
assert.deepEqual(
|
||||
(await fichiersDe(disque)).filter(([nom]) => !nom.startsWith(nouvelle)),
|
||||
avant,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[ailleurs.racine, ailleurs.base, ailleurs.baseJournal, ailleurs.revision, ailleurs.produitVersion],
|
||||
[DOC, nouvelle, nouvelle, 3, V2],
|
||||
);
|
||||
assert.equal(texteDeCharge(ailleurs.charge), texteDeCharge(renommee(modifier(chargeEssai(), 2))));
|
||||
const journal = exigerEquivalence(fs, nouvelle);
|
||||
assert.deepEqual(revisionsEtLibelles(journal), [
|
||||
[1, CREATION.libelle],
|
||||
[2, 'Geste 2'],
|
||||
[3, 'Renommé'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
journal.entrees.map(({ produitVersion }) => produitVersion),
|
||||
[V1, null, V2],
|
||||
);
|
||||
assert.equal(analyser(fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).entete.produitVersion, V2);
|
||||
const rouvert = await rouvrir(disque, nouvelle);
|
||||
assert.deepEqual([rouvert.revision, rouvert.charge.evenement.nom, rouvert.avertissements], [3, nouvelle, []]);
|
||||
const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 4), geste(4, { produitVersion: V2 }));
|
||||
assert.deepEqual([suite.base, suite.revision], [nouvelle, 4]);
|
||||
exigerEquivalence(fs, nouvelle);
|
||||
|
||||
// Un état sans journal, à la mise en page d'un autre outil : l'entrée de
|
||||
// reprise, au libellé reçu, précède celle du geste ; sans ce libellé,
|
||||
// TypeError avant toute écriture. Le .precedent neuf porte ses octets.
|
||||
const enQuatre = JSON.stringify(JSON.parse(etatTexte(chargeEssai(), 4)), null, 4);
|
||||
const importe = `${enQuatre.replaceAll('\n', '\r\n')}\r\n`;
|
||||
const seul = disqueAvec([[ETAT, importe]]);
|
||||
const sansJournal = await rouvrir(seul.disque, BASE);
|
||||
const gesteSeul = { ...options, geste: { ...options.geste, apres: renommee(sansJournal.charge), produitVersion: V1 } };
|
||||
await assert.rejects(enregistrerAilleurs(sansJournal, DOC, gesteSeul), TypeError);
|
||||
assert.equal(sansJournal.fs.compteurs.ecritures, 0);
|
||||
const repris = await enregistrerAilleurs(sansJournal, DOC, { ...gesteSeul, libelleReprise: REPRISE });
|
||||
assert.deepEqual([repris.base, repris.revision], [nouvelle, 5]);
|
||||
assert.deepEqual(revisionsEtLibelles(exigerEquivalence(sansJournal.fs, nouvelle)), [
|
||||
[4, REPRISE],
|
||||
[5, 'Renommé'],
|
||||
]);
|
||||
assert.equal(sansJournal.fs.contenu('documents', ETAT), importe);
|
||||
assert.equal(sansJournal.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), importe);
|
||||
|
||||
// Coupée juste après le journal neuf, l'écriture laisse la nouvelle base
|
||||
// à l'événement par son .precedent : l'état de l'Ouvert, qu'aucun journal
|
||||
// de sa base ne suit, ne se l'apparie pas, et s'ouvre tel qu'il était ;
|
||||
// la liste ne montre que lui.
|
||||
const coupe = disqueAvec([[ETAT, etatTexte(chargeEssai(), 4)]]);
|
||||
const avantCoupure = await rouvrir(coupe.disque, BASE);
|
||||
const ecrire = avantCoupure.fs.ecrireAtomique;
|
||||
avantCoupure.fs.ecrireAtomique = async (racine, chemin, texte) => {
|
||||
await ecrire(racine, chemin, texte);
|
||||
if (chemin === `${nouvelle}${SUFFIXES.journal}`) avantCoupure.fs.pannes.couperApres(0);
|
||||
};
|
||||
await assert.rejects(
|
||||
enregistrerAilleurs(avantCoupure, DOC, { ...gesteSeul, libelleReprise: REPRISE }),
|
||||
PanneSimulee,
|
||||
);
|
||||
const apresCoupure = await rouvrir(coupe.disque, BASE);
|
||||
assert.deepEqual(
|
||||
[apresCoupure.revision, apresCoupure.charge.evenement.nom, apresCoupure.journal, apresCoupure.avertissements],
|
||||
[4, BASE, null, []],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await lister(creerFichiersSimules({ disque: coupe.disque }), DOC)).map(({ base, journal }) => [base, journal]),
|
||||
[[BASE, null]],
|
||||
);
|
||||
|
||||
// Un Ouvert de secours, l'état sur le disque illisible : le .precedent
|
||||
// de la nouvelle base reçoit la charge ouverte, à sa révision.
|
||||
const secours = disqueAvec([
|
||||
[ETAT, '{'],
|
||||
[PRECEDENT, etatTexte(chargeEssai(), 4)],
|
||||
]);
|
||||
const depuisPrecedent = await ouvrirSecours(secours.fs, DOC, BASE, 'precedent');
|
||||
assert.equal(depuisPrecedent.texteEtat, null);
|
||||
const traceSecours = tracer(secours.fs);
|
||||
const gesteSecours = { ...gesteSeul.geste, apres: renommee(depuisPrecedent.charge) };
|
||||
const renommeSecours = await enregistrerAilleurs(depuisPrecedent, DOC, {
|
||||
...gesteSeul,
|
||||
geste: gesteSecours,
|
||||
libelleReprise: REPRISE,
|
||||
});
|
||||
assert.deepEqual(traceSecours, ecritsSous(nouvelle));
|
||||
assert.equal(secours.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), etatTexte(chargeEssai(), 4));
|
||||
assert.deepEqual([renommeSecours.base, renommeSecours.revision], [nouvelle, 5]);
|
||||
assert.deepEqual(
|
||||
[ETAT, PRECEDENT].map((nom) => secours.fs.contenu('documents', nom)),
|
||||
['{', etatTexte(chargeEssai(), 4)],
|
||||
);
|
||||
|
||||
// Sur un disque où documents n'existe pas : creerRacine faux ne le crée
|
||||
// pas, et sa liste lève ABSENT avant toute écriture.
|
||||
const neuf = new Map();
|
||||
const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' });
|
||||
sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4));
|
||||
const { portable, documents } = await sansDocuments.racines();
|
||||
const portableSeul = await ouvrir(sansDocuments, portable, BASE);
|
||||
const traceRacine = tracer(sansDocuments);
|
||||
const absente = await echec(
|
||||
enregistrerAilleurs(portableSeul, documents, { existantes: [], creerRacine: false }),
|
||||
'ABSENT',
|
||||
);
|
||||
assert.deepEqual(absente.details, { chemin: '' });
|
||||
assert.deepEqual(traceRacine, []);
|
||||
await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT');
|
||||
});
|
||||
|
||||
test("basesPrises rend, sans rien écrire, la base de chaque fichier d'événement du dossier — état, .precedent, journal, verrou —, dans l'ordre de sa liste ; ABSENT quand la racine manque", async () => {
|
||||
const { fs } = disqueAvec([
|
||||
[ETAT, '{'],
|
||||
[PRECEDENT, '{'],
|
||||
['Archives des Lucioles.gtt-journal.jsonl', '{'],
|
||||
['GALA DES SAULES.gtt.verrou', '{}\n'],
|
||||
['notes.txt', 'à garder'],
|
||||
['reglages_locaux.json', '{}\n'],
|
||||
]);
|
||||
fs.deposerDossier('documents', 'corbeille');
|
||||
assert.deepEqual(await basesPrises(fs, DOC), ['Archives des Lucioles', 'GALA DES SAULES', BASE, BASE]);
|
||||
assert.equal(fs.compteurs.ecritures, 0);
|
||||
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
|
||||
});
|
||||
|
||||
test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => {
|
||||
const { fs } = nouveauDisque();
|
||||
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
|
||||
|
|
|
|||
|
|
@ -135,12 +135,15 @@ async function ecrire(loc, travail) {
|
|||
}
|
||||
|
||||
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
||||
// creer crée ce qui manque, dossier du produit compris. Lève l'exception de
|
||||
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
|
||||
// crée que pour des segments vides, qui le désignent : un chemin sous un
|
||||
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
|
||||
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
||||
// fichier à sa place.
|
||||
async function dossierDe(segments, creer = false) {
|
||||
const options = { create: creer };
|
||||
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options);
|
||||
const produit = { create: creer && segments.length === 0 };
|
||||
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
|
||||
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
|
||||
return dossier;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,19 +152,26 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
|
|||
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => {
|
||||
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien, creerDossier d'un chemin sous lui aussi ; creerDossier de '' le crée", async () => {
|
||||
const fs = creerFichiersWeb();
|
||||
const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' });
|
||||
const refus = (chemin, dossier = CHEMIN_DOCUMENTS) => ({ chemin, dossier, cause: 'NotFoundError' });
|
||||
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json'));
|
||||
await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl'));
|
||||
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' });
|
||||
await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
|
||||
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' });
|
||||
await fs.supprimer(DOCUMENTS, 'etat.json');
|
||||
await echoue(
|
||||
fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'),
|
||||
'ECRITURE',
|
||||
refus('corbeille/2026-01-02_03-04-05', `${CHEMIN_DOCUMENTS}/corbeille`),
|
||||
);
|
||||
expect(await nomsOpfs()).toStrictEqual([]);
|
||||
|
||||
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
|
||||
await fs.creerDossier(DOCUMENTS, '');
|
||||
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
|
||||
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
||||
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
|
||||
expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -182,7 +182,10 @@ function tenir(brut, limite) {
|
|||
// 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();
|
||||
// Exportée : supprimer et le rang d'une démonstration (operations.js)
|
||||
// comparent les noms de fichier par cette même clé.
|
||||
export const cleSansCasse = (base) => base.normalize('NFC').toUpperCase();
|
||||
const cle = cleSansCasse;
|
||||
|
||||
/**
|
||||
* Base de nom de fichier d'un nom d'événement dans un dossier de travail
|
||||
|
|
|
|||
524
src/stockage/operations.js
Normal file
524
src/stockage/operations.js
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les opérations sur les événements d'un dossier de travail (§ 8.6, § 8.7,
|
||||
// § 15) : copier, renommer, importer un fichier d'état venu d'ailleurs ou les
|
||||
// paires de l'autre emplacement, supprimer vers la corbeille, la lister, et
|
||||
// charger une démonstration livrée. Chaque fonction reçoit le système de
|
||||
// fichiers (§ 13.4) ; aucune ne lit l'horloge ni un aléa, ni ne porte de
|
||||
// texte : identifiant, horodatage, version et libellés entrent en paramètre
|
||||
// (§ 14.6).
|
||||
//
|
||||
// Ce qui s'écrit d'un événement passe par le dépôt, qui seul apparie un état
|
||||
// à son journal et les aligne l'un sur l'autre : copier et charger créent
|
||||
// par creer ; renommer écrit par enregistrer, en place, ou par
|
||||
// enregistrerAilleurs, sous le nouveau nom ; le journal d'un événement est
|
||||
// celui qu'ouvrir lui apparie, ou, faute d'état lisible, celui que lister
|
||||
// lui apparie. Ce module ne fait par lui-même que recopier les octets d'un
|
||||
// fichier, le déplacer ou le supprimer en entier.
|
||||
//
|
||||
// Un fichier d'un format plus récent que FORMAT ne se réécrit sous aucun nom
|
||||
// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît
|
||||
// pas. Copier, renommer et charger le refusent avant toute écriture ;
|
||||
// importer en recopie les octets, et l'événement importé s'ouvre en lecture
|
||||
// seule.
|
||||
//
|
||||
// Une base se dérive du nom de l'événement (deriverBase), libre parmi les
|
||||
// bases que portent les fichiers d'événement à la racine du dossier
|
||||
// (basesPrises), comme le dépôt la dérive pour creer.
|
||||
import { canoniser } from './canonique.js';
|
||||
import { basesPrises, creer, enregistrer, enregistrerAilleurs, lister, ouvrir } from './depot.js';
|
||||
import { analyser, etatDeduit } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
||||
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
|
||||
// Celui des noms qui désigne le même fichier que nom : nom lui-même quand il
|
||||
// y est ; sinon, quand le système ignore la casse, le premier dont la clé
|
||||
// (cleSansCasse) est la sienne ; null sinon. Sur un système sensible à la
|
||||
// casse, seul le nom exact désigne le fichier.
|
||||
function nomSurDisque(noms, nom, insensibleCasse) {
|
||||
if (noms.includes(nom)) return nom;
|
||||
if (!insensibleCasse) return null;
|
||||
const cle = cleSansCasse(nom);
|
||||
return noms.find((autre) => cleSansCasse(autre) === cle) ?? null;
|
||||
}
|
||||
|
||||
// Entrées du dossier de racine, triées par nom ; aucune quand il manque.
|
||||
async function entreesDe(fs, racine, dossier) {
|
||||
try {
|
||||
return [...(await fs.lister(racine, dossier))].sort((a, b) => comparerTextes(a.nom, b.nom));
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ABSENT') return [];
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Texte du fichier chemin, ou null quand il manque.
|
||||
async function texteSiPresent(fs, racine, chemin) {
|
||||
try {
|
||||
return await fs.lireTexte(racine, chemin);
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ABSENT') return null;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Un fichier d'un format plus récent ne se réécrit sous aucun nom (§ 8.8).
|
||||
function exigerFormatCourant(formatPlusRecent) {
|
||||
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
||||
}
|
||||
|
||||
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
||||
// révision au journal ; sans elle — un état sans journal, ou que le journal
|
||||
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
||||
// l'entrée de reprise de cet état. TypeError quand il le faut et qu'il manque.
|
||||
function libelleDeLInstant({ journal, revision }, libelleReprise) {
|
||||
const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise;
|
||||
if (typeof libelle !== 'string' || libelle === '') {
|
||||
throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`);
|
||||
}
|
||||
return libelle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie l'événement ouvert dans son dossier (§ 8.7) : un événement neuf,
|
||||
* d'identifiant id et de nom nom, que le logiciel demande avant de créer.
|
||||
* Sa charge est celle de l'Ouvert ; son état se déduit de son contenu
|
||||
* (etatDeduit), jamais bloqué ; sa filiation inscrit la source, son
|
||||
* identifiant et son nom, et l'instant copié, la révision de l'Ouvert et le
|
||||
* libellé de son entrée au journal. L'historique ne se copie pas : creer
|
||||
* écrit un journal d'une entrée, en instantané, au libellé libelle, et son
|
||||
* jalon nomJalon, puis l'état ; sa base se dérive du nom. L'original n'est
|
||||
* ni lu ni écrit : la copie part de l'Ouvert, en lecture comme en écriture.
|
||||
*
|
||||
* @param {import('./depot.js').Ouvert} ouvert
|
||||
* @param {Object} options
|
||||
* @param {string} options.id identifiant de la copie
|
||||
* @param {string} options.nom nom de la copie
|
||||
* @param {string} options.libelle texte figé de son entrée
|
||||
* @param {string} options.nomJalon nom du jalon de sa création
|
||||
* @param {string} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||
* @param {string} options.produitVersion la construction qui écrit
|
||||
* @param {string} [options.libelleReprise] libellé de l'instant copié quand
|
||||
* sa révision n'a pas d'entrée au journal ; requis alors
|
||||
* @returns {Promise<import('./depot.js').Ouvert>} l'Ouvert de la copie
|
||||
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'}, sans
|
||||
* rien écrire ; ce que lève creer
|
||||
* @throws {TypeError} une valeur que la lecture refuserait, libelleReprise
|
||||
* manquant quand il est requis, avant toute écriture
|
||||
*/
|
||||
export async function copier(ouvert, { id, nom, libelle, nomJalon, horodatage, produitVersion, libelleReprise }) {
|
||||
exigerFormatCourant(ouvert.formatPlusRecent);
|
||||
const source = ouvert.charge.evenement;
|
||||
const instant = { revision: ouvert.revision, libelle: libelleDeLInstant(ouvert, libelleReprise) };
|
||||
const charge = canoniser(ouvert.charge);
|
||||
charge.evenement = {
|
||||
...charge.evenement,
|
||||
id,
|
||||
nom,
|
||||
etat: etatDeduit(charge),
|
||||
filiation: { source: { id: source.id, nom: source.nom }, instant },
|
||||
};
|
||||
return creer(ouvert.fs, ouvert.racine, charge, { libelle, nomJalon, horodatage, produitVersion });
|
||||
}
|
||||
|
||||
// Vrai quand la base que deriver tire du nom, parmi les autres bases, tombe
|
||||
// sur base à la casse près : rendre base aux bases prises fait alors passer
|
||||
// la dérivation au rang suivant, ou lever CHEMIN_TROP_LONG quand ce rang ne
|
||||
// tient pas sous la borne. Sinon les mêmes rangs restent pris, et la
|
||||
// dérivation ne change pas. Seule deriverBase compare ainsi deux bases.
|
||||
function tombeSur(deriver, autres, base) {
|
||||
const libre = deriver(autres);
|
||||
try {
|
||||
return deriver([...autres, base]) !== libre;
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'CHEMIN_TROP_LONG') return true;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Supprime le fichier chemin de racine. Rend vrai quand il n'y est plus, et
|
||||
// faux quand le système de fichiers refuse de l'effacer, par une
|
||||
// ErreurStockage, qui ne lève pas. Toute autre erreur remonte.
|
||||
async function effacerSiPossible(fs, racine, chemin) {
|
||||
try {
|
||||
await fs.supprimer(racine, chemin);
|
||||
return true;
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efface ce que la recopie a écrit sous base avant un refus : le journal
|
||||
// quand journal le dit, puis le .precedent, et lui seulement une fois le
|
||||
// journal parti. Un journal resté garde ainsi son .precedent, qui le tient à
|
||||
// la nouvelle base : sans lui, l'ancien état pourrait s'apparier ce journal
|
||||
// (apparier, dans le dépôt).
|
||||
async function effacerRecopie(fs, racine, base, { journal }) {
|
||||
if (journal && !(await effacerSiPossible(fs, racine, base + SUFFIXES.journal))) return;
|
||||
await effacerSiPossible(fs, racine, base + SUFFIXES.precedent);
|
||||
}
|
||||
|
||||
// La nouvelle paire de l'Ouvert, que le dépôt écrit dans son dossier sous la
|
||||
// base que dérive le nom de la charge écrite (enregistrerAilleurs, avec le
|
||||
// geste d'options) : l'état d'avant au .precedent, puis le journal entier
|
||||
// suivi de l'entrée du geste, puis l'état. Le dossier ne se crée pas : un
|
||||
// dossier de travail disparu ne renaît pas (§ 8.6). Le .precedent, écrit
|
||||
// avant le journal, fait de la nouvelle base celle de l'événement : le
|
||||
// journal neuf ne s'apparie qu'à elle, jamais à l'ancien état, que le journal
|
||||
// de l'ancienne base soit le sien ou non, et lister le tait tant que l'ancien
|
||||
// état se lit.
|
||||
//
|
||||
// Un refus avant l'état laisse l'ancienne paire seule à porter l'événement,
|
||||
// et ce que la recopie a écrit avant lui s'efface (effacerRecopie) : sur un
|
||||
// journal refusé, que nomme le chemin de l'ECRITURE, le .precedent ; sur un
|
||||
// état refusé, le journal, puis le .precedent. L'ECRITURE d'un journal
|
||||
// remonte telle quelle ; le refus de l'état remonte en ECRITURE {chemin,
|
||||
// dossier, cause}, sans Ouvert : celui de l'appelant décrit encore le disque.
|
||||
// Toute autre erreur remonte telle quelle.
|
||||
async function ecrireNouvellePaire(ouvert, options) {
|
||||
const { fs, racine } = ouvert;
|
||||
try {
|
||||
return await enregistrerAilleurs(ouvert, racine, { ...options, existantes: [], creerRacine: false });
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
if (erreur.code === 'ETAT_NON_ECRIT') {
|
||||
const { chemin, dossier, cause, ouvert: neuve } = erreur.details;
|
||||
await effacerRecopie(fs, racine, neuve.base, { journal: true });
|
||||
throw new ErreurStockage('ECRITURE', { chemin, dossier, cause });
|
||||
}
|
||||
const { chemin } = erreur.details;
|
||||
if (erreur.code === 'ECRITURE' && chemin.endsWith(SUFFIXES.journal)) {
|
||||
await effacerRecopie(fs, racine, chemin.slice(0, -SUFFIXES.journal.length), { journal: false });
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Supprime l'ancienne paire de l'Ouvert, la nouvelle étant complète : son
|
||||
// état d'abord, sans lequel lister ne la montre plus, la nouvelle portant
|
||||
// son identifiant ; puis son .precedent et le journal de l'événement
|
||||
// (baseJournal), jamais celui d'un autre ; un fichier absent n'est pas une
|
||||
// faute. Une ErreurStockage reçoit dans ses détails ouvert, l'Ouvert de la
|
||||
// nouvelle paire, d'où continuer, puis remonte : celui que tient l'appelant
|
||||
// décrit l'ancienne, et un geste parti de lui ferait diverger deux paires du
|
||||
// même événement.
|
||||
async function supprimerAncienne(ouvert, nouvelle) {
|
||||
const { fs, racine, base, baseJournal } = ouvert;
|
||||
const chemins = [base + SUFFIXES.etat, base + SUFFIXES.precedent];
|
||||
if (baseJournal !== null) chemins.push(baseJournal + SUFFIXES.journal);
|
||||
try {
|
||||
for (const chemin of chemins) await fs.supprimer(racine, chemin);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage) erreur.details.ouvert = nouvelle;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renomme l'événement ouvert (§ 8.7) : nom devient le nom de sa charge, par
|
||||
* un geste au libellé libelle. La base du nouveau nom se dérive, libre dans
|
||||
* le dossier. Quand elle tomberait sur la base de l'événement, ses fichiers
|
||||
* mis à part — un nom qui ne diffère que par un caractère que Windows
|
||||
* refuse, ou par la casse —, le renommage est ce seul geste, en place
|
||||
* (enregistrer). Sinon, le logiciel écrit d'abord la paire complète sous la
|
||||
* nouvelle base — l'état d'avant au .precedent, le journal recopié suivi de
|
||||
* l'entrée du renommage, puis l'état —, puis supprime l'ancienne : son état,
|
||||
* son .precedent et le journal de l'événement, jamais l'inverse. Rien de
|
||||
* l'ancienne paire ne s'écrit, et le .precedent neuf, posé avant le journal
|
||||
* neuf, tient ce journal à la nouvelle base : quel que soit le journal de
|
||||
* l'ancienne base — le sien, aucun pour un état importé, un illisible, celui
|
||||
* d'un autre événement, le sien rangé sous une autre base —, l'ancienne
|
||||
* paire, tant qu'elle existe, s'ouvre sous l'ancien nom, à sa révision, ses
|
||||
* octets intacts. Une coupure laisse l'ancienne paire, les deux, ou la
|
||||
* nouvelle, jamais aucune. Le journal d'un autre événement, que porterait
|
||||
* l'ancienne base, ne se touche pas ; le verrou non plus, que la séance
|
||||
* déplace.
|
||||
*
|
||||
* Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase
|
||||
* les retire aussi —, ne touche à rien : aucune entrée, aucune recopie,
|
||||
* aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait
|
||||
* aujourd'hui une autre base que la sienne. Une entrée est exactement ce qui
|
||||
* change un fichier d'événement (§ 8.2), et garder son nom n'est pas un geste.
|
||||
*
|
||||
* Un refus avant que la nouvelle paire soit complète laisse l'ancienne
|
||||
* intacte, et l'Ouvert reçu d'où continuer ; ce que la recopie a écrit
|
||||
* s'efface quand le support le permet (ecrireNouvellePaire). Un refus
|
||||
* pendant la suppression de l'ancienne porte dans ses détails ouvert,
|
||||
* l'Ouvert de la nouvelle paire, d'où continuer : l'Ouvert reçu ne décrit
|
||||
* plus que des fichiers en partance.
|
||||
*
|
||||
* @param {import('./depot.js').Ouvert} ouvert en écriture
|
||||
* @param {Object} options
|
||||
* @param {string} options.nom
|
||||
* @param {string} options.libelle texte figé de l'entrée
|
||||
* @param {string} options.horodatage
|
||||
* @param {string} options.produitVersion
|
||||
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
||||
* requis quand le journal ne finit pas sur la charge ouverte
|
||||
* @returns {Promise<import('./depot.js').Ouvert>} l'Ouvert renommé, sous la
|
||||
* base que le nom dérive ; l'Ouvert reçu quand le nom ne change pas
|
||||
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
|
||||
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
|
||||
* le dossier n'existe plus ; en place, ce que lève enregistrer ;
|
||||
* ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de
|
||||
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
|
||||
* dossier, cause, ouvert} de la suppression de l'ancienne
|
||||
* @throws {TypeError} une valeur que la lecture refuserait, avant toute
|
||||
* écriture
|
||||
*/
|
||||
export async function renommer(ouvert, { nom, libelle, horodatage, produitVersion, libelleReprise }) {
|
||||
exigerFormatCourant(ouvert.formatPlusRecent);
|
||||
if (nom.trim() === ouvert.charge.evenement.nom.trim()) return ouvert;
|
||||
const { fs, racine, base } = ouvert;
|
||||
const autres = (await basesPrises(fs, racine)).filter((autre) => autre !== base);
|
||||
const { separateur } = await fs.emplacements();
|
||||
const deriver = (existantes) => deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||||
const apres = { ...ouvert.charge, evenement: { ...ouvert.charge.evenement, nom } };
|
||||
if (tombeSur(deriver, autres, base)) {
|
||||
return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise });
|
||||
}
|
||||
const geste = { apres, libelle, produitVersion };
|
||||
const nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage });
|
||||
await supprimerAncienne(ouvert, nouvelle);
|
||||
return nouvelle;
|
||||
}
|
||||
|
||||
// Décodage strict : une suite d'octets qui n'est pas de l'UTF-8 lève, et la
|
||||
// marque d'ordre d'octets reste dans le texte. Le texte décodé se réencode
|
||||
// ainsi en UTF-8 sur exactement les octets reçus.
|
||||
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||
|
||||
// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8
|
||||
// ne sont pas un texte JSON : ETAT_ILLISIBLE, raison JSON, comme l'analyse le
|
||||
// lèverait d'un texte qui ne se lit pas.
|
||||
function decoder(octets) {
|
||||
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
|
||||
try {
|
||||
return UTF8_STRICT.decode(octets);
|
||||
} catch {
|
||||
throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'JSON', chemin: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Écrit dans racine l'événement id, nom : texteJournal, quand il est donné,
|
||||
// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette
|
||||
// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id.
|
||||
async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
|
||||
const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && resume.id === id);
|
||||
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
|
||||
const { separateur } = await fs.emplacements();
|
||||
const existantes = await basesPrises(fs, racine);
|
||||
const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||||
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
||||
await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe un fichier d'état venu d'ailleurs (§ 8.7) : ses octets, lus en
|
||||
* UTF-8, se copient tels quels — marque d'ordre d'octets, fins de ligne et
|
||||
* mise en page comprises — sous une base libre que dérive le nom qu'il
|
||||
* porte. Aucun journal ne s'écrit, et rien ne se convertit : un fichier d'un
|
||||
* format plus récent, que l'analyse lit réduit à ce qu'elle connaît, s'ouvre
|
||||
* ensuite en lecture seule.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @param {Uint8Array} octets
|
||||
* @returns {Promise<{base: string}>}
|
||||
* @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de
|
||||
* l'analyse, raison JSON pour des octets hors UTF-8 ; ni base ni
|
||||
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
|
||||
* base} quand un état lisible du dossier porte son identifiant ;
|
||||
* CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de
|
||||
* l'état
|
||||
* @throws {TypeError} des octets qui ne sont pas un Uint8Array
|
||||
*/
|
||||
export async function importer(fs, racine, octets) {
|
||||
const texte = decoder(octets);
|
||||
const { id, nom } = analyser(texte).charge.evenement;
|
||||
return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
|
||||
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
|
||||
* se copient tels quels dans vers, le journal d'abord, sous une base libre
|
||||
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Les
|
||||
* originaux restent où ils sont, sans qu'aucune écriture ne touche depuis.
|
||||
* Une base qui ne s'importe pas se refuse par son code — ETAT_ILLISIBLE,
|
||||
* ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ;
|
||||
* l'écriture refusée de son état laisse dans vers le journal déjà écrit.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} depuis
|
||||
* @param {import('./systeme_fichiers.js').Racine} vers
|
||||
* @param {Iterable<string>} bases bases de depuis
|
||||
* @returns {Promise<{importes: Array<{base: string, nouvelle: string}>, refuses: Array<{base: string, code: string}>}>}
|
||||
* importes : chaque base importée et sa base dans vers ; refuses :
|
||||
* chaque base refusée et le code de son refus ; l'un et l'autre
|
||||
* dans l'ordre des bases reçues. Une erreur qui n'est pas une
|
||||
* ErreurStockage remonte telle quelle
|
||||
*/
|
||||
export async function importerDepuis(fs, depuis, vers, bases) {
|
||||
const importes = [];
|
||||
const refuses = [];
|
||||
for (const base of bases) {
|
||||
try {
|
||||
const { texteEtat, baseJournal } = await ouvrir(fs, depuis, base);
|
||||
const { id, nom } = analyser(texteEtat).charge.evenement;
|
||||
const texteJournal =
|
||||
baseJournal === null ? null : await texteSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
||||
importes.push({ base, nouvelle: await deposer(fs, vers, { id, nom, texteEtat, texteJournal }) });
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
refuses.push({ base, code: erreur.code });
|
||||
}
|
||||
}
|
||||
return { importes, refuses };
|
||||
}
|
||||
|
||||
// Base du journal de l'événement de base : celle qu'ouvrir lui apparie quand
|
||||
// son état se lit ; sinon celle que lister lui apparie par l'identifiant de
|
||||
// son .precedent, ou du journal sans état, le résumé de base se trouvant
|
||||
// comme un nom de fichier (nomSurDisque) ; null quand aucun ne lui revient.
|
||||
// Le journal d'un autre événement n'est jamais le sien. Sur un système qui
|
||||
// ignore la casse, la base rendue peut différer par la casse du nom du
|
||||
// journal sur le disque.
|
||||
async function baseDuJournal(fs, racine, base, insensibleCasse) {
|
||||
try {
|
||||
return (await ouvrir(fs, racine, base)).baseJournal;
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ABSENT') return null;
|
||||
if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur;
|
||||
}
|
||||
const resumes = await lister(fs, racine);
|
||||
const trouvee = nomSurDisque(resumes.map((resume) => resume.base), base, insensibleCasse);
|
||||
return resumes.find((resume) => resume.base === trouvee)?.journal ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime l'événement de base (§ 8.7) : ses fichiers passent dans un
|
||||
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille),
|
||||
* créé d'abord — _2, _3… quand la seconde a déjà le sien. Partent l'état, le
|
||||
* .precedent, le journal de l'événement (baseDuJournal), jamais celui d'un
|
||||
* autre, le verrou, et chacun de leurs fichiers d'écriture atomique restés ;
|
||||
* ceux-ci d'abord, puis le verrou, le journal, le .precedent, et l'état en
|
||||
* dernier. Sur un système de fichiers qui ignore la casse, un fichier se
|
||||
* reconnaît à la clé de son nom, la majuscule de sa NFC, comme deriverBase
|
||||
* compare les bases, et part sous son nom sur le disque : un journal, un
|
||||
* .precedent ou un verrou d'une autre casse est le même fichier. Sur un
|
||||
* système sensible à la casse, seul le nom exact compte. Chaque déplacement
|
||||
* porte un fichier entier : une coupure laisse
|
||||
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
|
||||
* liste encore tant que son état n'est pas parti. Rien ne vide la corbeille.
|
||||
*
|
||||
* Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un
|
||||
* dossier de travail qui disparaît pendant la suppression ne renaît pas
|
||||
* (§ 8.6). Disparu avant la création du dossier daté, il fait lever
|
||||
* ECRITURE, cause ENOENT ; après, le déplacement du premier fichier qui n'est
|
||||
* plus là lève ABSENT.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @param {string} base
|
||||
* @param {{horodatage: string}} options AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||
* @returns {Promise<{dossier: string}>} chemin relatif du dossier daté
|
||||
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
|
||||
* fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ;
|
||||
* ECRITURE de la création du dossier daté ou d'un déplacement
|
||||
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
|
||||
* pendant les déplacements
|
||||
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire
|
||||
*/
|
||||
export async function supprimer(fs, racine, base, { horodatage }) {
|
||||
const { insensibleCasse } = await fs.emplacements();
|
||||
const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
|
||||
const fichiers = [
|
||||
base + SUFFIXES.verrou,
|
||||
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
||||
base + SUFFIXES.precedent,
|
||||
base + SUFFIXES.etat,
|
||||
];
|
||||
const presents = (await fs.lister(racine, ''))
|
||||
.filter(({ type }) => type === 'fichier')
|
||||
.map(({ nom }) => nom)
|
||||
.sort(comparerTextes);
|
||||
const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
|
||||
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
|
||||
.filter((nom) => nom !== null);
|
||||
if (partants.length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
||||
const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
||||
const dossier = dossierCorbeille(horodatage, existants);
|
||||
await fs.creerDossier(racine, dossier);
|
||||
for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
|
||||
return { dossier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le contenu de la corbeille de racine (§ 8.7), sans rien écrire : un
|
||||
* élément par dossier daté, son chemin relatif et les noms de ses fichiers,
|
||||
* les dossiers et les fichiers triés par nom. Une corbeille absente est
|
||||
* vide ; ce qui n'est pas un dossier daté ne s'y liste pas.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @returns {Promise<Array<{dossier: string, fichiers: string[]}>>}
|
||||
*/
|
||||
export async function listerCorbeille(fs, racine) {
|
||||
const contenu = [];
|
||||
for (const { nom, type } of await entreesDe(fs, racine, DOSSIER_CORBEILLE)) {
|
||||
if (type !== 'dossier') continue;
|
||||
const dossier = `${DOSSIER_CORBEILLE}/${nom}`;
|
||||
const entrees = await entreesDe(fs, racine, dossier);
|
||||
const fichiers = entrees.filter((entree) => entree.type === 'fichier').map((entree) => entree.nom);
|
||||
contenu.push({ dossier, fichiers });
|
||||
}
|
||||
return contenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge une démonstration livrée (§ 15) : un événement neuf, d'identifiant
|
||||
* id, nommé d'après la démonstration suivie d'un rang, « ‹nom› N », N le
|
||||
* premier rang libre à partir de 1 parmi les noms des événements lisibles du
|
||||
* dossier. Les noms se comparent en NFC et, quand le système de fichiers
|
||||
* ignore la casse, par leur majuscule, comme deriverBase compare les bases.
|
||||
* Son état se déduit de son contenu ; creer l'écrit, journal d'une entrée au
|
||||
* libellé libelle et son jalon nomJalon, sous une base que le nom dérive. Le
|
||||
* texte livré ne fait que se lire ; ni lui ni l'événement courant ne
|
||||
* changent. Le mode de l'événement chargé, la lecture, appartient à la
|
||||
* séance.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @param {string} texteLivre le fichier d'état livré
|
||||
* @param {{id: string, libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options
|
||||
* @returns {Promise<import('./depot.js').Ouvert>}
|
||||
* @throws {ErreurStockage} ETAT_ILLISIBLE de l'analyse ; LECTURE_SEULE
|
||||
* {raison: 'FORMAT_PLUS_RECENT'} ; l'un et l'autre sans rien écrire ;
|
||||
* ce que lève creer
|
||||
*/
|
||||
export async function chargerDemonstration(
|
||||
fs,
|
||||
racine,
|
||||
texteLivre,
|
||||
{ id, libelle, nomJalon, horodatage, produitVersion },
|
||||
) {
|
||||
const { charge, formatPlusRecent } = analyser(texteLivre);
|
||||
exigerFormatCourant(formatPlusRecent);
|
||||
const { insensibleCasse } = await fs.emplacements();
|
||||
const cle = (nom) => (insensibleCasse ? cleSansCasse(nom) : nom.normalize('NFC'));
|
||||
const pris = new Set(
|
||||
(await lister(fs, racine)).filter((resume) => resume.nom !== null).map((resume) => cle(resume.nom)),
|
||||
);
|
||||
let rang = 1;
|
||||
while (pris.has(cle(`${charge.evenement.nom} ${rang}`))) rang += 1;
|
||||
const evenement = { ...charge.evenement, id, nom: `${charge.evenement.nom} ${rang}`, etat: etatDeduit(charge) };
|
||||
return creer(fs, racine, { ...charge, evenement }, { libelle, nomJalon, horodatage, produitVersion });
|
||||
}
|
||||
1554
src/stockage/operations.test.js
Normal file
1554
src/stockage/operations.test.js
Normal file
File diff suppressed because it is too large
Load diff
321
src/stockage/reglages_locaux.js
Normal file
321
src/stockage/reglages_locaux.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les réglages locaux (§ 8.5) : le fichier reglages_locaux.json, à la racine
|
||||
// du dossier de travail, à côté des fichiers d'événement et hors d'eux. Il
|
||||
// porte deux choses, et deux seulement : le cadrage de chaque événement,
|
||||
// indexé par son identifiant — le zoom k en pixels par centimètre, la
|
||||
// translation tx, ty en pixels —, et le format de papier des planches,
|
||||
// global à la machine (§ 11.2), null tant que l'opérateur ne l'a pas choisi.
|
||||
// Ni l'un ni l'autre ne voyage avec un événement ni ne revient par un retour
|
||||
// arrière.
|
||||
//
|
||||
// La lecture ne bloque jamais : un fichier absent donne les défauts ; un
|
||||
// fichier qui ne se lit pas, ou dont une valeur connue sort de sa règle,
|
||||
// donne les défauts et l'avertissement REGLAGES_ILLISIBLES. Un fichier
|
||||
// lisible garde ses clés inconnues, à tout niveau, et son format, un entier
|
||||
// d'au moins 1 : le réglage qu'une construction plus récente y ajoute
|
||||
// survit à la réécriture par une plus ancienne.
|
||||
//
|
||||
// Le texte est canonique : JSON sur une ligne, les clés de chaque objet
|
||||
// rangées par unités UTF-16, les cadrages par identifiant d'événement, une
|
||||
// fin de ligne finale. L'écriture en est différée (§ 8.2, § 8.5) : un
|
||||
// écrivain regroupe les demandes d'un délai borné, si bien qu'un zoom
|
||||
// continu écrit une fois par délai, jamais à chaque image.
|
||||
import { clesRangees } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
/** Le fichier des réglages locaux, à la racine du dossier de travail (§ 8.5). */
|
||||
export const FICHIER_REGLAGES = 'reglages_locaux.json';
|
||||
|
||||
// Format d'un fichier que ce code crée.
|
||||
const FORMAT_REGLAGES = 1;
|
||||
|
||||
// Les papiers d'une planche (§ 11.2).
|
||||
const PAPIERS = ['A4', 'Lettre'];
|
||||
|
||||
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
||||
|
||||
/**
|
||||
* Les réglages locaux, tels que reglages_locaux.json les porte.
|
||||
*
|
||||
* @typedef {Object} ReglagesLocaux
|
||||
* @property {number} format entier ≥ 1, gardé tel que lu
|
||||
* @property {null|'A4'|'Lettre'} papier
|
||||
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
||||
* un par événement, triés par identifiant ; evenement non vide, k
|
||||
* nombre fini > 0, tx et ty nombres finis. Toute autre clé, à tout
|
||||
* niveau, se garde telle quelle
|
||||
*/
|
||||
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const parEvenement = (a, b) => comparerTextes(a.evenement, b.evenement);
|
||||
|
||||
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
||||
// Les réglages d'un dossier qui n'en a pas, neufs à chaque appel.
|
||||
const defauts = () => ({ format: FORMAT_REGLAGES, papier: null, cadrages: [] });
|
||||
|
||||
// Chemin de la première valeur connue d'un cadrage qui sort de sa règle, ou
|
||||
// null ; chemin désigne le cadrage lui-même.
|
||||
function fauteDeCadrage(cadrage, chemin) {
|
||||
if (!estObjet(cadrage)) return chemin;
|
||||
const { evenement, k, tx, ty } = cadrage;
|
||||
if (typeof evenement !== 'string' || evenement === '') return `${chemin}.evenement`;
|
||||
if (!Number.isFinite(k) || k <= 0) return `${chemin}.k`;
|
||||
if (!Number.isFinite(tx)) return `${chemin}.tx`;
|
||||
if (!Number.isFinite(ty)) return `${chemin}.ty`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Chemin de la première valeur connue des réglages qui sort de sa règle, ou
|
||||
// null ; '' quand les réglages ne sont pas un objet. Le second cadrage d'un
|
||||
// même événement est fautif.
|
||||
function fauteDeReglages(reglages) {
|
||||
if (!estObjet(reglages)) return '';
|
||||
const { format, papier, cadrages } = reglages;
|
||||
if (!Number.isSafeInteger(format) || format < 1) return 'format';
|
||||
if (papier !== null && !PAPIERS.includes(papier)) return 'papier';
|
||||
if (!Array.isArray(cadrages)) return 'cadrages';
|
||||
const vus = new Set();
|
||||
for (let rang = 0; rang < cadrages.length; rang += 1) {
|
||||
const chemin = `cadrages[${rang}]`;
|
||||
const faute = fauteDeCadrage(cadrages[rang], chemin);
|
||||
if (faute !== null) return faute;
|
||||
if (vus.has(cadrages[rang].evenement)) return `${chemin}.evenement`;
|
||||
vus.add(cadrages[rang].evenement);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copie de valeur dont chaque objet reçoit ses clés rangées (clesRangees),
|
||||
// chaque liste gardant son ordre. 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 rangee(valeur) {
|
||||
if (Array.isArray(valeur)) return valeur.map(rangee);
|
||||
if (!estObjet(valeur)) return valeur;
|
||||
return Object.fromEntries(clesRangees(valeur).map((cle) => [cle, rangee(valeur[cle])]));
|
||||
}
|
||||
|
||||
// Ce que rend lireReglages d'un fichier illisible : les défauts, et
|
||||
// l'avertissement qui nomme le fichier et la raison.
|
||||
const illisibles = (raison) => ({
|
||||
reglages: defauts(),
|
||||
avertissement: { code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lit les réglages locaux de racine (§ 8.5), sans rien écrire ni jamais
|
||||
* bloquer. Un fichier absent rend les défauts, sans avertissement. Un
|
||||
* fichier illisible rend les défauts et l'avertissement REGLAGES_ILLISIBLES
|
||||
* {chemin, raison} : raison JSON pour un texte qui ne se lit pas, vide
|
||||
* compris ; FORME pour une valeur connue hors de sa règle — un format qui
|
||||
* n'est pas un entier ≥ 1, un papier hors de null, A4 et Lettre, un cadrage
|
||||
* fautif ou en double ; le code d'une lecture que le système de fichiers
|
||||
* refuse autrement que par ABSENT. Une marque d'ordre d'octets en tête est
|
||||
* ignorée. Les réglages lus gardent leurs clés inconnues et leur format ;
|
||||
* leurs cadrages sont triés par identifiant d'événement.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @returns {Promise<{reglages: ReglagesLocaux, avertissement: {code: string, details: Object}|null}>}
|
||||
* une erreur qui n'est pas une ErreurStockage remonte telle quelle
|
||||
*/
|
||||
export async function lireReglages(fs, racine) {
|
||||
let texte;
|
||||
try {
|
||||
texte = await fs.lireTexte(racine, FICHIER_REGLAGES);
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
if (erreur.code === 'ABSENT') return { reglages: defauts(), avertissement: null };
|
||||
return illisibles(erreur.code);
|
||||
}
|
||||
let lu;
|
||||
try {
|
||||
lu = JSON.parse(texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte);
|
||||
} catch {
|
||||
return illisibles('JSON');
|
||||
}
|
||||
if (fauteDeReglages(lu) !== null) return illisibles('FORME');
|
||||
return { reglages: { ...lu, cadrages: [...lu.cadrages].sort(parEvenement) }, avertissement: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cadrage de l'événement d'identifiant evenement, ou null quand les
|
||||
* réglages n'en portent pas.
|
||||
*
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @param {string} evenement
|
||||
* @returns {{k: number, tx: number, ty: number}|null}
|
||||
*/
|
||||
export function cadrageDe({ cadrages }, evenement) {
|
||||
const cadrage = cadrages.find((candidat) => candidat.evenement === evenement);
|
||||
return cadrage === undefined ? null : { k: cadrage.k, tx: cadrage.tx, ty: cadrage.ty };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les réglages reçus, le cadrage de l'événement remplacé, ou ajouté à son
|
||||
* rang ; ceux reçus ne changent pas.
|
||||
*
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @param {string} evenement identifiant de l'événement, non vide
|
||||
* @param {{k: number, tx: number, ty: number}} cadrage k fini > 0, tx et
|
||||
* ty finis
|
||||
* @returns {ReglagesLocaux}
|
||||
* @throws {TypeError} une valeur hors de sa règle
|
||||
*/
|
||||
export function avecCadrage(reglages, evenement, { k, tx, ty }) {
|
||||
const cadrage = { evenement, k, tx, ty };
|
||||
const faute = fauteDeCadrage(cadrage, 'cadrage');
|
||||
if (faute !== null) throw new TypeError(`avecCadrage : ${faute} hors de sa règle`);
|
||||
const autres = reglages.cadrages.filter((autre) => autre.evenement !== evenement);
|
||||
return { ...reglages, cadrages: [...autres, cadrage].sort(parEvenement) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les réglages reçus, le papier remplacé ; ceux reçus ne changent pas.
|
||||
*
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @param {null|'A4'|'Lettre'} papier
|
||||
* @returns {ReglagesLocaux}
|
||||
* @throws {TypeError} un autre papier
|
||||
*/
|
||||
export function avecPapier(reglages, papier) {
|
||||
if (papier !== null && !PAPIERS.includes(papier)) {
|
||||
throw new TypeError(`avecPapier : papier null, A4 ou Lettre attendu, reçu ${JSON.stringify(papier)}`);
|
||||
}
|
||||
return { ...reglages, papier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte canonique des réglages : JSON sur une ligne, les clés de chaque
|
||||
* objet rangées par unités UTF-16, clés inconnues comprises, les cadrages
|
||||
* triés par identifiant d'événement, une fin de ligne finale. Deux réglages
|
||||
* égaux s'écrivent pareil, quel que soit l'ordre de leurs clés ou de leurs
|
||||
* cadrages.
|
||||
*
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @returns {string}
|
||||
* @throws {TypeError} une valeur connue hors de sa règle, que la lecture
|
||||
* refuserait
|
||||
*/
|
||||
export function serialiserReglages(reglages) {
|
||||
const faute = fauteDeReglages(reglages);
|
||||
if (faute !== null) throw new TypeError(`serialiserReglages : ${faute || 'les réglages'} hors de sa règle`);
|
||||
return `${JSON.stringify(rangee({ ...reglages, cadrages: [...reglages.cadrages].sort(parEvenement) }))}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit les réglages dans racine : leur texte canonique, par une écriture
|
||||
* atomique de FICHIER_REGLAGES à sa racine. Le dossier de travail doit
|
||||
* exister : aucune écriture ne le recrée (§ 8.6).
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @returns {Promise<void>}
|
||||
* @throws {TypeError} des réglages hors de leur règle, avant toute écriture
|
||||
* @throws {ErreurStockage} ECRITURE, le fichier intact
|
||||
*/
|
||||
export async function ecrireReglages(fs, racine, reglages) {
|
||||
await fs.ecrireAtomique(racine, FICHIER_REGLAGES, serialiserReglages(reglages));
|
||||
}
|
||||
|
||||
const rien = () => {};
|
||||
|
||||
/**
|
||||
* Écrivain différé des réglages (§ 8.5). La première demande arme un
|
||||
* minuteur de delaiMs ; celles qui suivent avant son terme remplacent les
|
||||
* réglages en attente sans le réarmer : le délai est borné, et un zoom
|
||||
* continu écrit une fois par délai. À son terme, ecrire reçoit les derniers
|
||||
* réglages demandés. Les écritures s'enchaînent sans jamais se chevaucher :
|
||||
* chacune attend la fin de la précédente.
|
||||
*
|
||||
* La dernière demande l'emporte sur tous les chemins. Chaque demande avance
|
||||
* un compteur, que chaque écriture relève au moment où elle prend les
|
||||
* réglages en attente. Une écriture refusée ne remet ses réglages en attente
|
||||
* que quand rien n'attend et que le compteur n'a pas bougé depuis ce relevé ;
|
||||
* la demande suivante, ou vider, les écrit alors. Une demande venue
|
||||
* entre-temps, qu'elle attende encore ou que son écriture soit déjà lancée,
|
||||
* est plus récente que les réglages refusés, qui ne reviennent pas.
|
||||
*
|
||||
* Le refus d'une écriture qu'a lancée le minuteur ne devient jamais un rejet
|
||||
* que rien ne traite : il va à signaler quand il est donné, et se tait sinon.
|
||||
* vider annule le minuteur, écrit tout de suite les réglages en attente et
|
||||
* rend la promesse de cette écriture, qui rejette sur son refus ; sans
|
||||
* réglages en attente, il n'écrit rien et rend une promesse que la fin
|
||||
* de l'écriture en cours résout.
|
||||
*
|
||||
* @param {Object} parametres
|
||||
* @param {(reglages: ReglagesLocaux) => Promise<void>} parametres.ecrire
|
||||
* @param {(fonction: () => void, delaiMs: number) => *} parametres.planifier
|
||||
* arme un minuteur et rend de quoi l'annuler
|
||||
* @param {(minuteur: *) => void} parametres.annuler
|
||||
* @param {number} parametres.delaiMs nombre fini ≥ 0
|
||||
* @param {((erreur: *) => void)|null} [parametres.signaler]
|
||||
* @returns {{demander: (reglages: ReglagesLocaux) => void, vider: () => Promise<void>}}
|
||||
* @throws {TypeError} un paramètre hors de sa forme
|
||||
*/
|
||||
export function creerEcrivainDiffere({ ecrire, planifier, annuler, delaiMs, signaler = null }) {
|
||||
for (const [nom, fonction] of [
|
||||
['ecrire', ecrire],
|
||||
['planifier', planifier],
|
||||
['annuler', annuler],
|
||||
]) {
|
||||
if (typeof fonction !== 'function') throw new TypeError(`creerEcrivainDiffere : ${nom} n'est pas une fonction`);
|
||||
}
|
||||
if (signaler !== null && typeof signaler !== 'function') {
|
||||
throw new TypeError("creerEcrivainDiffere : signaler n'est pas une fonction");
|
||||
}
|
||||
if (!Number.isFinite(delaiMs) || delaiMs < 0) {
|
||||
throw new TypeError(`creerEcrivainDiffere : délai fini ≥ 0 attendu, reçu ${JSON.stringify(delaiMs)}`);
|
||||
}
|
||||
// Les réglages à écrire, ou null ; le nombre de demandes reçues ; le
|
||||
// minuteur armé, ou null ; la fin de la dernière écriture lancée, qui ne
|
||||
// rejette jamais.
|
||||
let attente = null;
|
||||
let demandes = 0;
|
||||
let minuteur = null;
|
||||
let file = Promise.resolve();
|
||||
|
||||
// Lance l'écriture des réglages en attente à la suite de la précédente, et
|
||||
// relève le nombre de demandes au moment où elle les prend. Rend sa
|
||||
// promesse, qui rejette sur son refus après avoir remis ces réglages en
|
||||
// attente quand rien n'attend et qu'aucune demande n'a suivi ce relevé.
|
||||
function ecrireEnAttente() {
|
||||
const reglages = attente;
|
||||
const releve = demandes;
|
||||
attente = null;
|
||||
const ecriture = file.then(() => ecrire(reglages));
|
||||
file = ecriture.then(rien, rien);
|
||||
return ecriture.then(rien, (erreur) => {
|
||||
if (attente === null && demandes === releve) attente = reglages;
|
||||
throw erreur;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
demander(reglages) {
|
||||
demandes += 1;
|
||||
attente = reglages;
|
||||
if (minuteur !== null) return;
|
||||
minuteur = planifier(() => {
|
||||
minuteur = null;
|
||||
ecrireEnAttente().catch((erreur) => {
|
||||
if (signaler !== null) signaler(erreur);
|
||||
});
|
||||
}, delaiMs);
|
||||
},
|
||||
vider() {
|
||||
if (minuteur !== null) {
|
||||
annuler(minuteur);
|
||||
minuteur = null;
|
||||
}
|
||||
return attente === null ? file : ecrireEnAttente();
|
||||
},
|
||||
};
|
||||
}
|
||||
437
src/stockage/reglages_locaux.test.js
Normal file
437
src/stockage/reglages_locaux.test.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des réglages locaux (§ 8.5) : la lecture, jamais bloquante, qui
|
||||
// rend les défauts et un avertissement pour un fichier illisible ; la forme
|
||||
// canonique, qui garde les clés inconnues d'une construction plus récente ;
|
||||
// l'écriture atomique à la racine ; l'écrivain différé, à délai borné, sous
|
||||
// un planificateur factice. Les réglages se lisent et s'écrivent sur le
|
||||
// système de fichiers d'épreuve.
|
||||
import assert from 'node:assert/strict';
|
||||
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import {
|
||||
FICHIER_REGLAGES,
|
||||
avecCadrage,
|
||||
avecPapier,
|
||||
cadrageDe,
|
||||
creerEcrivainDiffere,
|
||||
ecrireReglages,
|
||||
lireReglages,
|
||||
serialiserReglages,
|
||||
} from './reglages_locaux.js';
|
||||
|
||||
const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' };
|
||||
|
||||
// Les réglages d'un dossier qui n'en a pas : ni papier choisi, ni cadrage.
|
||||
const DEFAUTS = Object.freeze({ format: 1, papier: null, cadrages: Object.freeze([]) });
|
||||
|
||||
// Un disque neuf dont la racine documents existe, et son premier processus ;
|
||||
// texte, quand il est donné, est le fichier des réglages.
|
||||
function disque(texte = null) {
|
||||
const fs = creerFichiersSimules({ disque: new Map() });
|
||||
fs.deposerDossier('documents');
|
||||
if (texte !== null) fs.deposer('documents', FICHIER_REGLAGES, texte);
|
||||
return fs;
|
||||
}
|
||||
|
||||
// L'avertissement d'un fichier illisible, de raison donnée.
|
||||
const illisibles = (raison) => ({ code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } });
|
||||
|
||||
// Trois réglages successifs : le papier, puis un cadrage, puis ce cadrage
|
||||
// zoomé et déplacé.
|
||||
const R1 = { format: 1, papier: 'A4', cadrages: [] };
|
||||
const R2 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 1, tx: 0, ty: 0 }] };
|
||||
const R3 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 2, tx: -40, ty: 15 }] };
|
||||
|
||||
// Un planificateur factice : planifier inscrit un minuteur et rend son
|
||||
// numéro, annuler le retire, echoir déclenche ceux qui restent, dans l'ordre
|
||||
// de leur armement ; armements et annulations comptent les appels.
|
||||
function planificateur() {
|
||||
const minuteurs = new Map();
|
||||
const compte = { armements: 0, annulations: 0 };
|
||||
return {
|
||||
compte,
|
||||
planifier: (fonction, delai) => {
|
||||
compte.armements += 1;
|
||||
minuteurs.set(compte.armements, { fonction, delai });
|
||||
return compte.armements;
|
||||
},
|
||||
annuler: (numero) => {
|
||||
compte.annulations += 1;
|
||||
minuteurs.delete(numero);
|
||||
},
|
||||
echoir: () => {
|
||||
const echus = [...minuteurs.values()];
|
||||
minuteurs.clear();
|
||||
for (const { fonction } of echus) fonction();
|
||||
},
|
||||
delais: () => [...minuteurs.values()].map(({ delai }) => delai),
|
||||
};
|
||||
}
|
||||
|
||||
// Laisse s'écouler les promesses en attente et les rejets non traités.
|
||||
const attendre = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Exécute travail en relevant chaque rejet de promesse que rien ne traite ;
|
||||
// rend ce relevé.
|
||||
async function rejetsNonTraites(travail) {
|
||||
const rejets = [];
|
||||
const ecouteur = (raison) => rejets.push(raison);
|
||||
process.on('unhandledRejection', ecouteur);
|
||||
try {
|
||||
await travail();
|
||||
await attendre();
|
||||
await attendre();
|
||||
} finally {
|
||||
process.off('unhandledRejection', ecouteur);
|
||||
}
|
||||
return rejets;
|
||||
}
|
||||
|
||||
describe('réglages locaux : lire, jamais bloquant (§ 8.5)', () => {
|
||||
test('un fichier absent rend les défauts, sans avertissement ni écriture', async () => {
|
||||
const fs = disque();
|
||||
assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: null });
|
||||
assert.equal(fs.compteurs.ecritures, 0);
|
||||
});
|
||||
|
||||
test('un fichier illisible rend les défauts et REGLAGES_ILLISIBLES, sa raison nommée, sans lever ni écrire', async () => {
|
||||
const cadrage = (plus) => JSON.stringify({ evenement: 'evt-a', k: 1, tx: 0, ty: 0, ...plus });
|
||||
const avecCadrages = (...cadrages) => `{"cadrages":[${cadrages.join(',')}],"format":1,"papier":null}\n`;
|
||||
// Par cas : le texte du fichier, et la raison de l'avertissement.
|
||||
const cas = [
|
||||
['un texte vide', '', 'JSON'],
|
||||
['du JSON invalide', '{"papier":', 'JSON'],
|
||||
['une liste', '[]\n', 'FORME'],
|
||||
['un format absent', '{"cadrages":[],"papier":null}\n', 'FORME'],
|
||||
['un format non entier', '{"cadrages":[],"format":1.5,"papier":null}\n', 'FORME'],
|
||||
['un format nul', '{"cadrages":[],"format":0,"papier":null}\n', 'FORME'],
|
||||
['un papier absent', '{"cadrages":[],"format":1}\n', 'FORME'],
|
||||
['un papier inconnu', '{"cadrages":[],"format":1,"papier":"A3"}\n', 'FORME'],
|
||||
['des cadrages absents', '{"format":1,"papier":null}\n', 'FORME'],
|
||||
['un cadrage qui n’est pas un objet', avecCadrages('[]'), 'FORME'],
|
||||
['un événement vide', avecCadrages(cadrage({ evenement: '' })), 'FORME'],
|
||||
['un zoom nul', avecCadrages(cadrage({ k: 0 })), 'FORME'],
|
||||
['un zoom négatif', avecCadrages(cadrage({ k: -1 })), 'FORME'],
|
||||
['une translation en texte', avecCadrages(cadrage({ tx: '10' })), 'FORME'],
|
||||
['une translation nulle', avecCadrages(cadrage({ ty: null })), 'FORME'],
|
||||
['un cadrage en double', avecCadrages(cadrage({}), cadrage({ k: 2 })), 'FORME'],
|
||||
];
|
||||
for (const [nom, texte, raison] of cas) {
|
||||
const fs = disque(texte);
|
||||
assert.deepEqual(
|
||||
await lireReglages(fs, DOC),
|
||||
{ reglages: DEFAUTS, avertissement: illisibles(raison) },
|
||||
nom,
|
||||
);
|
||||
assert.equal(fs.compteurs.ecritures, 0, nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('une lecture refusée par le système de fichiers donne les défauts et son code pour raison ; une panne du processus remonte', async () => {
|
||||
const fs = disque('{"cadrages":[],"format":1,"papier":"A4"}\n');
|
||||
fs.lireTexte = async (racine, chemin) => {
|
||||
throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EACCES' });
|
||||
};
|
||||
assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: illisibles('LECTURE') });
|
||||
|
||||
const coupe = disque('{"cadrages":[],"format":1,"papier":"A4"}\n');
|
||||
coupe.pannes.couperApres(0);
|
||||
await assert.rejects(lireReglages(coupe, DOC), PanneSimulee);
|
||||
});
|
||||
|
||||
test('un fichier lisible rend son papier et ses cadrages triés par événement, ses clés inconnues et son format gardés ; une marque d’ordre d’octets en tête est admise', async () => {
|
||||
const texte =
|
||||
'\u{FEFF}{"format":2,"papier":"Lettre","theme":"sombre","cadrages":[' +
|
||||
'{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20,"rotation":90},' +
|
||||
'{"evenement":"evt-a","k":1.25,"tx":0,"ty":0}]}\n';
|
||||
const fs = disque(texte);
|
||||
const { reglages, avertissement } = await lireReglages(fs, DOC);
|
||||
assert.equal(avertissement, null);
|
||||
assert.deepEqual(reglages, {
|
||||
format: 2,
|
||||
papier: 'Lettre',
|
||||
theme: 'sombre',
|
||||
cadrages: [
|
||||
{ evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 },
|
||||
{ evenement: 'evt-b', k: 0.5, tx: 10, ty: -20, rotation: 90 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(cadrageDe(reglages, 'evt-b'), { k: 0.5, tx: 10, ty: -20 });
|
||||
assert.equal(cadrageDe(reglages, 'evt-c'), null);
|
||||
assert.equal(fs.compteurs.ecritures, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('réglages locaux : la forme canonique et l’écriture', () => {
|
||||
test('serialiserReglages range les clés à chaque niveau et les cadrages par identifiant d’événement, et finit sur une fin de ligne : deux constructions dans deux ordres donnent le même texte', () => {
|
||||
const attendu =
|
||||
'{"cadrages":[{"evenement":"evt-a","k":1.25,"tx":0,"ty":0},' +
|
||||
'{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20}],"format":1,"papier":"A4"}\n';
|
||||
const a = avecCadrage(
|
||||
avecCadrage(avecPapier(DEFAUTS, 'A4'), 'evt-b', { k: 0.5, tx: 10, ty: -20 }),
|
||||
'evt-a',
|
||||
{ k: 1.25, tx: 0, ty: 0 },
|
||||
);
|
||||
const b = avecPapier(
|
||||
avecCadrage(avecCadrage(DEFAUTS, 'evt-a', { k: 1.25, tx: 0, ty: 0 }), 'evt-b', { k: 0.5, tx: 10, ty: -20 }),
|
||||
'A4',
|
||||
);
|
||||
const aLaMain = {
|
||||
papier: 'A4',
|
||||
format: 1,
|
||||
cadrages: [
|
||||
{ ty: -20, tx: 10, k: 0.5, evenement: 'evt-b' },
|
||||
{ evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 },
|
||||
],
|
||||
};
|
||||
for (const reglages of [a, b, aLaMain]) assert.equal(serialiserReglages(reglages), attendu);
|
||||
assert.equal(
|
||||
serialiserReglages({ zeta: { b: 1, a: [2, { d: 3, c: 4 }] }, ...DEFAUTS }),
|
||||
'{"cadrages":[],"format":1,"papier":null,"zeta":{"a":[2,{"c":4,"d":3}],"b":1}}\n',
|
||||
);
|
||||
assert.throws(() => serialiserReglages({ ...DEFAUTS, papier: 'A3' }), TypeError);
|
||||
const zoomIndefini = { evenement: 'evt-a', k: Number.NaN, tx: 0, ty: 0 };
|
||||
assert.throws(() => serialiserReglages({ ...DEFAUTS, cadrages: [zoomIndefini] }), TypeError);
|
||||
assert.throws(() => serialiserReglages(null), TypeError);
|
||||
});
|
||||
|
||||
test("avecCadrage remplace le cadrage d'un événement ou l'ajoute à son rang, avecPapier pose le papier, sans modifier les réglages reçus ; une valeur hors de sa règle lève TypeError", () => {
|
||||
const depart = avecCadrage(DEFAUTS, 'evt-b', { k: 0.5, tx: 10, ty: -20 });
|
||||
const copie = structuredClone(depart);
|
||||
const ajoute = avecCadrage(depart, 'evt-a', { k: 1, tx: 1, ty: 1 });
|
||||
const remplace = avecCadrage(ajoute, 'evt-b', { k: 2, tx: 0, ty: 0 });
|
||||
assert.deepEqual(depart, copie);
|
||||
assert.deepEqual(DEFAUTS, { format: 1, papier: null, cadrages: [] });
|
||||
assert.deepEqual(remplace.cadrages, [
|
||||
{ evenement: 'evt-a', k: 1, tx: 1, ty: 1 },
|
||||
{ evenement: 'evt-b', k: 2, tx: 0, ty: 0 },
|
||||
]);
|
||||
const lettre = avecPapier(remplace, 'Lettre');
|
||||
assert.deepEqual([remplace.papier, lettre.papier, avecPapier(lettre, null).papier], [null, 'Lettre', null]);
|
||||
assert.deepEqual(lettre.cadrages, remplace.cadrages);
|
||||
const refus = [
|
||||
['un zoom nul', () => avecCadrage(DEFAUTS, 'evt-a', { k: 0, tx: 0, ty: 0 })],
|
||||
['un zoom négatif', () => avecCadrage(DEFAUTS, 'evt-a', { k: -1, tx: 0, ty: 0 })],
|
||||
['une translation infinie', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: Number.POSITIVE_INFINITY, ty: 0 })],
|
||||
['une translation absente', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: 0 })],
|
||||
['un événement vide', () => avecCadrage(DEFAUTS, '', { k: 1, tx: 0, ty: 0 })],
|
||||
['un papier inconnu', () => avecPapier(DEFAUTS, 'A3')],
|
||||
];
|
||||
for (const [nom, appel] of refus) assert.throws(appel, TypeError, nom);
|
||||
});
|
||||
|
||||
test("ecrireReglages écrit le texte canonique par une seule écriture atomique à la racine, sans créer un dossier de travail qui manque", async () => {
|
||||
const fs = disque();
|
||||
const ecritures = [];
|
||||
const ecrire = fs.ecrireAtomique;
|
||||
fs.ecrireAtomique = (racine, chemin, texte) => {
|
||||
ecritures.push(chemin);
|
||||
return ecrire(racine, chemin, texte);
|
||||
};
|
||||
await ecrireReglages(fs, DOC, R3);
|
||||
assert.deepEqual(ecritures, [FICHIER_REGLAGES]);
|
||||
assert.equal(fs.compteurs.ecritures, 1);
|
||||
assert.equal(fs.contenu('documents', FICHIER_REGLAGES), serialiserReglages(R3));
|
||||
assert.deepEqual(await lireReglages(fs, DOC), { reglages: R3, avertissement: null });
|
||||
|
||||
const sansDossier = creerFichiersSimules({ disque: new Map() });
|
||||
await assert.rejects(ecrireReglages(sansDossier, DOC, R1), (erreur) => erreur.code === 'ECRITURE');
|
||||
});
|
||||
|
||||
test("une clé inconnue d'un fichier lisible survit à la réécriture, à tout niveau, son format compris : le réglage d'une construction plus récente reste", async () => {
|
||||
const texte =
|
||||
'{"cadrages":[{"evenement":"evt-a","k":1,"rotation":90,"tx":0,"ty":0}],' +
|
||||
'"format":2,"papier":null,"theme":"sombre"}\n';
|
||||
const fs = disque(texte);
|
||||
const { reglages } = await lireReglages(fs, DOC);
|
||||
await ecrireReglages(fs, DOC, avecPapier(reglages, 'A4'));
|
||||
assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte.replace('"papier":null', '"papier":"A4"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('réglages locaux : l’écriture différée (§ 8.2, § 8.5)', () => {
|
||||
test('trois demandes rapprochées arment un seul minuteur, jamais réarmé, et donnent une écriture, la dernière ; une demande après elle en arme un autre', async () => {
|
||||
const ecrits = [];
|
||||
const minuterie = planificateur();
|
||||
const ecrivain = creerEcrivainDiffere({
|
||||
ecrire: async (reglages) => {
|
||||
ecrits.push(reglages);
|
||||
},
|
||||
planifier: minuterie.planifier,
|
||||
annuler: minuterie.annuler,
|
||||
delaiMs: 500,
|
||||
});
|
||||
ecrivain.demander(R1);
|
||||
ecrivain.demander(R2);
|
||||
ecrivain.demander(R3);
|
||||
assert.deepEqual(minuterie.compte, { armements: 1, annulations: 0 });
|
||||
assert.deepEqual(minuterie.delais(), [500]);
|
||||
assert.deepEqual(ecrits, []);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.deepEqual(ecrits, [R3]);
|
||||
|
||||
ecrivain.demander(R1);
|
||||
assert.deepEqual(minuterie.compte, { armements: 2, annulations: 0 });
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.deepEqual(ecrits, [R3, R1]);
|
||||
});
|
||||
|
||||
test("vider annule le minuteur et écrit tout de suite la demande en attente, et rend la promesse de l'écriture ; sans demande en attente, il n'écrit rien", async () => {
|
||||
const ecrits = [];
|
||||
const minuterie = planificateur();
|
||||
const ecrivain = creerEcrivainDiffere({
|
||||
ecrire: async (reglages) => {
|
||||
ecrits.push(reglages);
|
||||
},
|
||||
planifier: minuterie.planifier,
|
||||
annuler: minuterie.annuler,
|
||||
delaiMs: 500,
|
||||
});
|
||||
await ecrivain.vider();
|
||||
assert.deepEqual([ecrits, minuterie.compte], [[], { armements: 0, annulations: 0 }]);
|
||||
ecrivain.demander(R1);
|
||||
ecrivain.demander(R2);
|
||||
const promesse = ecrivain.vider();
|
||||
assert.deepEqual([minuterie.delais(), minuterie.compte.annulations], [[], 1]);
|
||||
await promesse;
|
||||
assert.deepEqual(ecrits, [R2]);
|
||||
minuterie.echoir();
|
||||
await ecrivain.vider();
|
||||
assert.deepEqual(ecrits, [R2]);
|
||||
});
|
||||
|
||||
test("une écriture refusée au terme du minuteur va au signaleur, jamais en rejet non traité ; les réglages restent en attente pour la demande suivante ou pour vider, qui rejette à son tour", async () => {
|
||||
let refuser = true;
|
||||
const ecrits = [];
|
||||
const signales = [];
|
||||
const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EROFS' });
|
||||
const ecrire = async (reglages) => {
|
||||
if (refuser) throw refus;
|
||||
ecrits.push(reglages);
|
||||
};
|
||||
const minuterie = planificateur();
|
||||
const commun = { ecrire, planifier: minuterie.planifier, annuler: minuterie.annuler, delaiMs: 500 };
|
||||
const rejets = await rejetsNonTraites(async () => {
|
||||
const ecrivain = creerEcrivainDiffere({ ...commun, signaler: (erreur) => signales.push(erreur) });
|
||||
ecrivain.demander(R1);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.deepEqual([signales, ecrits], [[refus], []]);
|
||||
await assert.rejects(ecrivain.vider(), (erreur) => erreur === refus);
|
||||
refuser = false;
|
||||
await ecrivain.vider();
|
||||
assert.deepEqual(ecrits, [R1]);
|
||||
|
||||
// Sans signaleur, le refus se tait ; la demande suivante remplace
|
||||
// celle qui attend, et l'écrit.
|
||||
const muet = creerEcrivainDiffere(commun);
|
||||
refuser = true;
|
||||
muet.demander(R2);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
refuser = false;
|
||||
muet.demander(R3);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.deepEqual(ecrits, [R1, R3]);
|
||||
});
|
||||
assert.deepEqual(rejets, []);
|
||||
assert.equal(signales.length, 1);
|
||||
});
|
||||
|
||||
test("les écritures ne se chevauchent jamais : celle d'une demande suivante attend la fin de la précédente, et vider attend celle en cours", async () => {
|
||||
const appels = [];
|
||||
const liberations = [];
|
||||
const minuterie = planificateur();
|
||||
const ecrivain = creerEcrivainDiffere({
|
||||
ecrire: (reglages) => {
|
||||
appels.push(reglages);
|
||||
return new Promise((resolve) => liberations.push(resolve));
|
||||
},
|
||||
planifier: minuterie.planifier,
|
||||
annuler: minuterie.annuler,
|
||||
delaiMs: 500,
|
||||
});
|
||||
ecrivain.demander(R1);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
ecrivain.demander(R2);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.deepEqual(appels, [R1]);
|
||||
liberations[0]();
|
||||
await attendre();
|
||||
assert.deepEqual(appels, [R1, R2]);
|
||||
let fini = false;
|
||||
const fin = ecrivain.vider().then(() => {
|
||||
fini = true;
|
||||
});
|
||||
await attendre();
|
||||
assert.equal(fini, false);
|
||||
liberations[1]();
|
||||
await fin;
|
||||
assert.equal(fini, true);
|
||||
});
|
||||
|
||||
test("la dernière demande l'emporte : une écriture refusée pendant que la suivante attend la fin ne revient pas en attente, et vider n'écrit ensuite rien de plus", async () => {
|
||||
// Les deux premières écritures se tiennent jusqu'à ce que l'épreuve les
|
||||
// libère ou les refuse ; les suivantes réussissent tout de suite. ecrits
|
||||
// reçoit les réglages de chaque écriture réussie.
|
||||
const ecrits = [];
|
||||
const tenues = [];
|
||||
const signales = [];
|
||||
const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EBUSY' });
|
||||
const minuterie = planificateur();
|
||||
const rejets = await rejetsNonTraites(async () => {
|
||||
const ecrivain = creerEcrivainDiffere({
|
||||
ecrire: (reglages) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const liberer = () => {
|
||||
ecrits.push(reglages);
|
||||
resolve();
|
||||
};
|
||||
if (tenues.length < 2) tenues.push({ liberer, refuser: reject });
|
||||
else liberer();
|
||||
}),
|
||||
planifier: minuterie.planifier,
|
||||
annuler: minuterie.annuler,
|
||||
delaiMs: 500,
|
||||
signaler: (erreur) => signales.push(erreur),
|
||||
});
|
||||
ecrivain.demander(R1);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
ecrivain.demander(R2);
|
||||
minuterie.echoir();
|
||||
await attendre();
|
||||
assert.equal(tenues.length, 1);
|
||||
tenues[0].refuser(refus);
|
||||
await attendre();
|
||||
assert.equal(tenues.length, 2);
|
||||
tenues[1].liberer();
|
||||
await ecrivain.vider();
|
||||
assert.deepEqual(ecrits, [R2]);
|
||||
assert.deepEqual(minuterie.delais(), []);
|
||||
});
|
||||
assert.deepEqual([rejets, signales], [[], [refus]]);
|
||||
});
|
||||
|
||||
test("creerEcrivainDiffere refuse des paramètres hors de leur forme, TypeError", () => {
|
||||
const valides = { ecrire: async () => {}, planifier: () => 1, annuler: () => {}, delaiMs: 500 };
|
||||
const refus = [
|
||||
['ecrire absent', { ...valides, ecrire: undefined }],
|
||||
['planifier absent', { ...valides, planifier: null }],
|
||||
['annuler absent', { ...valides, annuler: 'non' }],
|
||||
['un délai négatif', { ...valides, delaiMs: -1 }],
|
||||
['un délai qui n’est pas un nombre', { ...valides, delaiMs: '500' }],
|
||||
['un signaleur qui n’est pas une fonction', { ...valides, signaler: 1 }],
|
||||
];
|
||||
for (const [nom, parametres] of refus) assert.throws(() => creerEcrivainDiffere(parametres), TypeError, nom);
|
||||
assert.equal(typeof creerEcrivainDiffere({ ...valides, signaler: null }).demander, 'function');
|
||||
});
|
||||
});
|
||||
|
|
@ -109,7 +109,11 @@ import { ErreurStockage } from './erreurs.js';
|
|||
* 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
|
||||
* '' crée la racine elle-même, parents compris ; un autre chemin
|
||||
* crée ce qui lui manque sous la racine, qui doit exister : une
|
||||
* racine absente lève ECRITURE, sans rien créer, car un dossier de
|
||||
* travail disparu ne renaît pas en silence (§ 8.6). 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
|
||||
|
|
|
|||
|
|
@ -312,11 +312,21 @@
|
|||
* @property {string} hote
|
||||
* @property {string} depuis
|
||||
*
|
||||
* @typedef {Object} ReglagesLocaux reglages_locaux.json
|
||||
* @property {number} format
|
||||
* @typedef {Object} ReglagesLocaux reglages_locaux.json (reglages_locaux.js),
|
||||
* JSON sur une ligne, les clés de chaque objet rangées par unités
|
||||
* UTF-16 ; une clé inconnue, à tout niveau, se garde à la réécriture
|
||||
* @property {number} format entier ≥ 1, gardé tel que lu
|
||||
* @property {null|'A4'|'Lettre'} papier
|
||||
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
||||
* triés par identifiant d'événement
|
||||
* triés par identifiant d'événement, un par événement ; k fini
|
||||
* > 0, tx et ty finis
|
||||
*
|
||||
* Avertissement de la lecture des réglages locaux, qui ne lève jamais :
|
||||
*
|
||||
* REGLAGES_ILLISIBLES {chemin, raison} le fichier ne se lit pas, et
|
||||
* les défauts s'appliquent ;
|
||||
* raison JSON, FORME, ou le code
|
||||
* du refus de lecture
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -180,6 +180,22 @@ describe('coquille sur un vrai disque', () => {
|
|||
assert.deepEqual(readdirSync(portable), ['garde.txt']);
|
||||
});
|
||||
|
||||
testNettoye("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||
const dossier = dossierTemporaire();
|
||||
const { fs, portable } = systemeSur(dossier, { publie: join('cle', 'soirees') });
|
||||
const racine = (await fs.racines()).portable;
|
||||
await echoue(fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
|
||||
chemin: 'corbeille/2026-01-02_03-04-05',
|
||||
dossier: join(portable, 'corbeille'),
|
||||
cause: 'ENOENT',
|
||||
});
|
||||
assert.deepEqual(readdirSync(dossier), []);
|
||||
await fs.creerDossier(racine, '');
|
||||
assert.deepEqual(readdirSync(portable), []);
|
||||
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
|
||||
assert.deepEqual(readdirSync(join(portable, 'corbeille')), ['2026-01-02_03-04-05']);
|
||||
});
|
||||
|
||||
testNettoye(
|
||||
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
|
||||
{ skip: SOUS_ROOT || SOUS_WINDOWS },
|
||||
|
|
|
|||
|
|
@ -81,9 +81,10 @@ function erreurSysteme(code, syscall, chemin) {
|
|||
* cible absolue. fs porte les fonctions de node:fs/promises dont la coquille
|
||||
* se sert et lève les erreurs du système dans les mêmes cas — ENOENT,
|
||||
* ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier
|
||||
* segment de lstat, unlink, rename et d'une ouverture exclusive (wx). Les
|
||||
* noms que rend readdir sortent dans l'ordre inverse de leur création : un
|
||||
* tri omis se voit.
|
||||
* segment de lstat, unlink, rename, mkdir sans recursive et d'une ouverture
|
||||
* exclusive (wx). mkdir sans recursive crée un seul dossier, son parent
|
||||
* présent ; avec, tous ceux qui manquent. Les noms que rend readdir sortent
|
||||
* dans l'ordre inverse de leur création : un tri omis se voit.
|
||||
*
|
||||
* appels chaque appel, « fonction chemin [drapeaux | cible] », les
|
||||
* écritures d'une poignée comprises (write, sync, close)
|
||||
|
|
@ -229,7 +230,13 @@ function creerDisque() {
|
|||
},
|
||||
async mkdir(chemin, options) {
|
||||
appel('mkdir', chemin);
|
||||
assert.deepEqual(options, { recursive: true }, 'mkdir sans recursive');
|
||||
if (options === undefined) {
|
||||
const { chemin: resolu, noeud } = resoudre(chemin, false, 'mkdir');
|
||||
if (noeud !== undefined) throw erreurSysteme('EEXIST', 'mkdir', chemin);
|
||||
creer(resolu, 'dossier');
|
||||
return;
|
||||
}
|
||||
assert.deepEqual(options, { recursive: true }, 'mkdir aux options inconnues');
|
||||
const segments = chemin.split('/').filter((segment) => segment !== '');
|
||||
let courant = '/';
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
|
|
@ -746,6 +753,20 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
|||
await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR'));
|
||||
});
|
||||
|
||||
test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||
const { disque, fichiers } = preparer();
|
||||
await refuse(
|
||||
fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'),
|
||||
'ECRITURE',
|
||||
ecriture('corbeille/2026-01-02_03-04-05', `${DATA}/corbeille`, 'ENOENT'),
|
||||
);
|
||||
assert.equal(disque.existe(PUBLIE), false);
|
||||
await fichiers.creerDossier(PORTABLE, '');
|
||||
assert.deepEqual(disque.noms(DATA), []);
|
||||
await fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05');
|
||||
assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']);
|
||||
});
|
||||
|
||||
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@
|
|||
// 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.
|
||||
// Une racine n'existe qu'une fois créée — par sonder, creerDossier de '' ou,
|
||||
// sans compter, deposerDossier —, et aucune écriture ne crée le dossier qui
|
||||
// porte sa cible, ni creerDossier d'un autre chemin la racine : 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';
|
||||
|
||||
|
|
@ -202,10 +203,10 @@ export function creerFichiersSimules({
|
|||
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.
|
||||
// parties, la clé dans le disque de chacun de ses préfixes, sa clé, celle
|
||||
// de son parent et celle de la racine, 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);
|
||||
|
|
@ -221,6 +222,7 @@ export function creerFichiersSimules({
|
|||
cleDe,
|
||||
cle: cleDe(n),
|
||||
cleParent: n > 1 ? cleDe(n - 1) : null,
|
||||
cleRacine: cleDe(n - segments.length),
|
||||
nom: parties[n - 1],
|
||||
dossier: absolu(segments.length > 0 ? n - 1 : n),
|
||||
};
|
||||
|
|
@ -418,9 +420,12 @@ export function creerFichiersSimules({
|
|||
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
|
||||
}),
|
||||
|
||||
// '' crée la racine, parents compris ; un autre chemin ne crée que ce qui
|
||||
// manque sous elle, et une racine absente lève ECRITURE, ENOENT.
|
||||
creerDossier: primitive('ecritures', (racine, chemin) => {
|
||||
const loc = localiser(racine, chemin);
|
||||
exigerInscriptible(loc);
|
||||
if (chemin !== '' && !disque.has(loc.cleRacine)) throw refus(loc, 'ENOENT');
|
||||
creerDossiers(loc, loc.parties.length, avancer);
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -586,4 +586,18 @@ describe('fichiers simulés : préconditions et dossiers', () => {
|
|||
);
|
||||
assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']);
|
||||
});
|
||||
|
||||
test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
||||
await echoue(fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
|
||||
chemin: 'corbeille/2026-01-02_03-04-05',
|
||||
dossier: `${DATA}\\corbeille`,
|
||||
cause: 'ENOENT',
|
||||
});
|
||||
await echoue(fs.lister(PORTABLE, ''), 'ABSENT', { chemin: '' });
|
||||
await fs.creerDossier(PORTABLE, '');
|
||||
assert.deepEqual(await fs.lister(PORTABLE, ''), []);
|
||||
await fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05');
|
||||
assert.deepEqual(noms(await fs.lister(PORTABLE, 'corbeille')), ['2026-01-02_03-04-05']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue