[FIX] core: bounded geometry, dated lock, empty seat, neighbour lists
Non-finite scale ratios, view translations and handle counts now raise instead of returning 0, NaN or looping forever. The lock carries the instant from the single clock, a lock taken back in this session's name is its own, and quitting releases held locks. An empty chair is null only, at its exact path. A table grown over a neighbour warns LISTE_SUR_VOISINE once per pair; successive remedies never aim at the same free cell. Checked: every new test red first; 2983 node and 617 browser tests green from the index alone. --- FR --- [FIX] cœur : géométrie bornée, verrou daté, chaise vide, listes voisines Un rapport d'échelle, une translation de vue ou un compte de poignée non finis lèvent au lieu de rendre 0, NaN ou de boucler sans fin. Le verrou porte l'instant de l'horloge unique, un verrou repris au nom de la séance est le sien, et quitter rend les verrous tenus. Une chaise vide est null seulement, à son chemin exact. Une table agrandie sur sa voisine avertit LISTE_SUR_VOISINE une fois par paire ; les remèdes successifs ne visent jamais la même case libre. Vérifié : chaque épreuve rouge d'abord ; 2983 node et 617 navigateur verts depuis l'index seul. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
94ea937ae4
commit
d5a1560915
54 changed files with 1659 additions and 548 deletions
|
|
@ -34,7 +34,7 @@
|
||||||
// alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE
|
// alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE
|
||||||
// {cause}, l'explorateur du système qui ne s'ouvre pas.
|
// {cause}, l'explorateur du système qui ne s'ouvre pas.
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { realpath as realpathRappel } from 'node:fs';
|
import { readFileSync, realpath as realpathRappel, unlinkSync } from 'node:fs';
|
||||||
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
||||||
import { hostname } from 'node:os';
|
import { hostname } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
@ -205,7 +205,9 @@ export function decrireSysteme({ env, plateforme, cheminSysteme, nomProduit }) {
|
||||||
// Les fonctions de node:fs/promises dont les primitives se servent. realpath
|
// Les fonctions de node:fs/promises dont les primitives se servent. realpath
|
||||||
// est celle de node:fs, qui résout les liens segment par segment ; celle de
|
// est celle de node:fs, qui résout les liens segment par segment ; celle de
|
||||||
// node:fs/promises demande au système le chemin final d'un fichier ouvert,
|
// node:fs/promises demande au système le chemin final d'un fichier ouvert,
|
||||||
// ce que des lecteurs de Windows refusent.
|
// ce que des lecteurs de Windows refusent. readFileSync et unlinkSync, de
|
||||||
|
// node:fs, servent à rendreVerrousTenus : un écouteur de sortie d'Electron
|
||||||
|
// n'attend aucune promesse.
|
||||||
const FS_NODE = Object.freeze({
|
const FS_NODE = Object.freeze({
|
||||||
lstat,
|
lstat,
|
||||||
stat,
|
stat,
|
||||||
|
|
@ -216,6 +218,8 @@ const FS_NODE = Object.freeze({
|
||||||
unlink,
|
unlink,
|
||||||
mkdir,
|
mkdir,
|
||||||
readdir,
|
readdir,
|
||||||
|
readFileSync,
|
||||||
|
unlinkSync,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lance une commande sans shell, ces variables ajoutées à l'environnement du
|
// Lance une commande sans shell, ces variables ajoutées à l'environnement du
|
||||||
|
|
@ -276,19 +280,6 @@ function analyserVerrou(texte) {
|
||||||
return valide ? { ...contenu, demarrage: contenu.demarrage ?? null } : null;
|
return valide ? { ...contenu, demarrage: contenu.demarrage ?? null } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un instant sous la forme de l'horloge de l'application,
|
|
||||||
// AAAA-MM-JJTHH:MM:SS±HH:MM, à l'heure locale du poste et avec son décalage.
|
|
||||||
function horodatage(instant) {
|
|
||||||
const deux = (n) => String(n).padStart(2, '0');
|
|
||||||
const decalage = -instant.getTimezoneOffset();
|
|
||||||
const ecart = Math.abs(decalage);
|
|
||||||
return (
|
|
||||||
`${instant.getFullYear()}-${deux(instant.getMonth() + 1)}-${deux(instant.getDate())}` +
|
|
||||||
`T${deux(instant.getHours())}:${deux(instant.getMinutes())}:${deux(instant.getSeconds())}` +
|
|
||||||
`${decalage < 0 ? '-' : '+'}${deux(Math.floor(ecart / 60))}:${deux(ecart % 60)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -324,7 +315,8 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
|
||||||
* @param {() => Object|null} [reglages.fenetre] la fenêtre parente des dialogues
|
* @param {() => Object|null} [reglages.fenetre] la fenêtre parente des dialogues
|
||||||
* Le reste remplace le système, pour l'épreuve :
|
* Le reste remplace le système, pour l'épreuve :
|
||||||
* @param {Object} [reglages.fs] lstat, stat, realpath, readFile, open,
|
* @param {Object} [reglages.fs] lstat, stat, realpath, readFile, open,
|
||||||
* rename, unlink, mkdir, readdir, comme ceux de node:fs/promises
|
* rename, unlink, mkdir, readdir, comme ceux de node:fs/promises, et
|
||||||
|
* readFileSync, unlinkSync, comme ceux de node:fs
|
||||||
* @param {Object} [reglages.chemins] node:path, ou l'une de ses variantes
|
* @param {Object} [reglages.chemins] node:path, ou l'une de ses variantes
|
||||||
* @param {string} [reglages.plateforme] process.platform
|
* @param {string} [reglages.plateforme] process.platform
|
||||||
* @param {Function} [reglages.lancer] (commande, arguments, {variables,
|
* @param {Function} [reglages.lancer] (commande, arguments, {variables,
|
||||||
|
|
@ -336,9 +328,8 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
|
||||||
* par défaut, celui que lit la plateforme
|
* par défaut, celui que lit la plateforme
|
||||||
* @param {number} [reglages.pid]
|
* @param {number} [reglages.pid]
|
||||||
* @param {string} [reglages.hote]
|
* @param {string} [reglages.hote]
|
||||||
* @param {() => Date} [reglages.maintenant]
|
|
||||||
* @returns {Object<string, Function>} les primitives, par nom, et
|
* @returns {Object<string, Function>} les primitives, par nom, et
|
||||||
* verrousTenus(), qui n'en est pas une
|
* rendreVerrousTenus(), qui n'en est pas une
|
||||||
*/
|
*/
|
||||||
export function creerFichiers({
|
export function creerFichiers({
|
||||||
emplacements,
|
emplacements,
|
||||||
|
|
@ -355,7 +346,6 @@ export function creerFichiers({
|
||||||
demarrage,
|
demarrage,
|
||||||
pid = process.pid,
|
pid = process.pid,
|
||||||
hote = hostname(),
|
hote = hostname(),
|
||||||
maintenant = () => new Date(),
|
|
||||||
}) {
|
}) {
|
||||||
// Chemin absolu de chaque racine connue, par identifiant. Une Map : un
|
// Chemin absolu de chaque racine connue, par identifiant. Une Map : un
|
||||||
// identifiant reçu de la page ne rencontre aucune propriété héritée.
|
// identifiant reçu de la page ne rencontre aucune propriété héritée.
|
||||||
|
|
@ -366,7 +356,7 @@ export function creerFichiers({
|
||||||
// Le démarrage du processus courant, lu une fois qu'il est connu.
|
// Le démarrage du processus courant, lu une fois qu'il est connu.
|
||||||
let demarrageCourant = null;
|
let demarrageCourant = null;
|
||||||
// Les verrous que verrouiller a pris et que deverrouiller n'a pas rendus :
|
// Les verrous que verrouiller a pris et que deverrouiller n'a pas rendus :
|
||||||
// chemin absolu → séance, que verrousTenus rend au processus principal.
|
// chemin absolu → séance, que rendreVerrousTenus efface à la sortie.
|
||||||
const tenus = new Map();
|
const tenus = new Map();
|
||||||
|
|
||||||
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
||||||
|
|
@ -831,18 +821,22 @@ export function creerFichiers({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Création exclusive (wx), qui ne suit aucun lien, d'un verrou qui
|
// Création exclusive (wx), qui ne suit aucun lien, d'un verrou qui
|
||||||
// inscrit la séance, le pid, l'hôte, l'instant de la prise et le
|
// inscrit la séance, le pid, l'hôte, l'instant de la prise reçu de la
|
||||||
// démarrage du processus, lus avant la création : la création et
|
// page, tel quel, et le démarrage du processus, lu avant la création :
|
||||||
// l'écriture se suivent sans attente. Un verrou présent
|
// la création et l'écriture se suivent sans attente. Un verrou présent
|
||||||
// se lit ; s'il disparaît entre la création refusée et sa lecture, la
|
// se lit ; s'il disparaît entre la création refusée et sa lecture, la
|
||||||
// prise se tente une seconde fois. Un verrou écrit à moitié s'efface.
|
// prise se tente une seconde fois. Un verrou écrit à moitié s'efface.
|
||||||
async verrouiller(racine, chemin, seance) {
|
// L'instant n'est contrôlé que pour son type : sa forme se juge dans la
|
||||||
|
// page (src/stockage/fichiers_electron.js), et le processus principal
|
||||||
|
// n'importe rien de src/.
|
||||||
|
async verrouiller(racine, chemin, seance, depuis) {
|
||||||
exigerSeance(seance);
|
exigerSeance(seance);
|
||||||
|
exigerTexte(depuis, "l'instant de prise");
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
try {
|
try {
|
||||||
await confiner(loc);
|
await confiner(loc);
|
||||||
const demarrageInscrit = await monDemarrage();
|
const demarrageInscrit = await monDemarrage();
|
||||||
const inscrit = { seance, pid, hote, depuis: horodatage(maintenant()), demarrage: demarrageInscrit };
|
const inscrit = { seance, pid, hote, depuis, demarrage: demarrageInscrit };
|
||||||
const contenu = `${JSON.stringify(inscrit)}\n`;
|
const contenu = `${JSON.stringify(inscrit)}\n`;
|
||||||
for (let essai = 0; ; essai += 1) {
|
for (let essai = 0; ; essai += 1) {
|
||||||
let poignee;
|
let poignee;
|
||||||
|
|
@ -895,11 +889,23 @@ export function creerFichiers({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hors des primitives, que servir ne publie pas : les verrous pris et
|
// Hors des primitives, que servir ne publie pas : rend les verrous pris
|
||||||
// pas rendus, [{ chemin, seance }], chemin absolu, dans l'ordre de leur
|
// et pas rendus, dans l'ordre de leur prise, à la sortie du processus
|
||||||
// prise. Le processus principal les efface à sa sortie.
|
// principal (§ 8.8). Synchrone, comme le retrait du profil : Electron
|
||||||
verrousTenus() {
|
// n'attend aucune promesse d'un écouteur de sortie. Chacun se relit et
|
||||||
return [...tenus].map(([chemin, seance]) => ({ chemin, seance }));
|
// ne s'efface que s'il porte encore la séance qui l'a pris, lu par
|
||||||
|
// analyserVerrou, le seul lecteur de leur format : un verrou repris par
|
||||||
|
// une autre séance, illisible ou absent reste tel. Un échec de lecture
|
||||||
|
// ou d'effacement laisse le verrou et passe au suivant ; rien ne lève.
|
||||||
|
rendreVerrousTenus() {
|
||||||
|
for (const [absolu, seance] of tenus) {
|
||||||
|
try {
|
||||||
|
if (analyserVerrou(fs.readFileSync(absolu).toString('utf8'))?.seance === seance) fs.unlinkSync(absolu);
|
||||||
|
} catch {
|
||||||
|
// Absent, illisible ou inaccessible : rien à rendre.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tenus.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
|
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
|
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
|
||||||
// non un accès au disque.
|
// non un accès au disque.
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { readdir, rm } from 'node:fs/promises';
|
import { readdir, rm } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
@ -85,19 +85,10 @@ ipcMain.handle('gtt:copier', (_evenement, texte) => {
|
||||||
|
|
||||||
// Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) :
|
// Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) :
|
||||||
// la page ne survit pas au processus principal, et une fermeture de la fenêtre
|
// la page ne survit pas au processus principal, et une fermeture de la fenêtre
|
||||||
// ne lui laisse pas le temps de les rendre elle-même. Chacun se relit, de
|
// ne lui laisse pas le temps de les rendre elle-même. rendreVerrousTenus les
|
||||||
// façon synchrone comme le retrait du profil — Electron n'attend aucune
|
// relit et les efface de façon synchrone, comme le retrait du profil, chacun
|
||||||
// promesse d'un écouteur de sortie —, et ne s'efface que s'il porte encore la
|
// seulement s'il porte encore la séance qui l'a pris.
|
||||||
// séance qui l'a pris : un verrou repris par une autre séance reste.
|
app.on('quit', () => fichiers.rendreVerrousTenus());
|
||||||
app.on('quit', () => {
|
|
||||||
for (const { chemin, seance } of fichiers.verrousTenus()) {
|
|
||||||
try {
|
|
||||||
if (JSON.parse(readFileSync(chemin, 'utf8'))?.seance === seance) rmSync(chemin, { force: true });
|
|
||||||
} catch {
|
|
||||||
// Absent, illisible ou inaccessible : rien à rendre.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function creerFenetre() {
|
function creerFenetre() {
|
||||||
fenetre = new BrowserWindow({
|
fenetre = new BrowserWindow({
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
|
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
|
||||||
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
||||||
// table et changer les tours laissent propositions et retenu en dérive.
|
// table et changer les tours laissent propositions et retenu en dérive.
|
||||||
import { caseLibre } from '../geometrie/tables.js';
|
import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js';
|
||||||
import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
|
import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite, cibleRedondance } from '../stockage/document.js';
|
||||||
import { fautes } from '../stockage/placements.js';
|
import { fautes } from '../stockage/placements.js';
|
||||||
import { bilanChangementDefaut } from './bilans.js';
|
import { bilanChangementDefaut } from './bilans.js';
|
||||||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||||||
|
|
@ -81,6 +81,67 @@ function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefa
|
||||||
return suivante;
|
return suivante;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Les recouvrements qu'un agrandissement fait naître : pour chaque
|
||||||
|
// table dont la capacité croît d'avant à apres, chaque autre table que sa
|
||||||
|
// liste couvre désormais, ou dont la liste couvre désormais son emprise
|
||||||
|
// agrandie — listeSurTable, d'un côté ou de l'autre —, quand ce
|
||||||
|
// recouvrement n'existait pas avant le geste. LISTE_SUR_VOISINE {table,
|
||||||
|
// voisine, numero, numeroVoisine}, remède le déplacement de la voisine vers
|
||||||
|
// caseLibre des autres tables, chacune à sa capacité d'apres : un geste ne
|
||||||
|
// bouge que ce qu'il nomme, et l'opérateur choisit.
|
||||||
|
//
|
||||||
|
// Une paire de tables se dit une fois, quel que soit le nombre de ses
|
||||||
|
// tables agrandies : deux remèdes, l'un déplaçant chaque table, se
|
||||||
|
// contrediraient. table est toujours une table agrandie, que le texte dit
|
||||||
|
// telle. Quand les deux le sont, table est celle dont la liste couvre
|
||||||
|
// l'autre, et le remède déplace la couverte ; si chacune couvre l'autre, la
|
||||||
|
// première de la charge. Dans l'ordre des tables agrandies, puis des
|
||||||
|
// voisines, celui de la charge.
|
||||||
|
function listesSurVoisines(avant, apres) {
|
||||||
|
const geometrieDe = (charge, table) => geometrieTable(table, capacite(charge, table));
|
||||||
|
const listeSur = (charge, a, b) => listeSurTable(geometrieDe(charge, a), capacite(charge, a), geometrieDe(charge, b));
|
||||||
|
const couvre = (charge, a, b) => listeSur(charge, a, b) || listeSur(charge, b, a);
|
||||||
|
const anterieure = (table) => avant.tables.find(({ id }) => id === table.id);
|
||||||
|
const agrandie = (table) => capacite(apres, table) > capacite(avant, anterieure(table));
|
||||||
|
const dites = new Set();
|
||||||
|
// Les remèdes se suivent : une voisine déjà envoyée ailleurs compte à sa
|
||||||
|
// destination, pour que deux remèdes ne visent jamais la même case.
|
||||||
|
const destinations = new Map();
|
||||||
|
const avertissements = [];
|
||||||
|
for (const grande of apres.tables) {
|
||||||
|
if (!agrandie(grande)) continue;
|
||||||
|
for (const autre of apres.tables) {
|
||||||
|
if (autre.id === grande.id || !couvre(apres, grande, autre)) continue;
|
||||||
|
const paire = `${Math.min(grande.id, autre.id)}:${Math.max(grande.id, autre.id)}`;
|
||||||
|
if (dites.has(paire) || couvre(avant, anterieure(grande), anterieure(autre))) continue;
|
||||||
|
dites.add(paire);
|
||||||
|
const inverse = !listeSur(apres, grande, autre) && agrandie(autre) && listeSur(apres, autre, grande);
|
||||||
|
const [table, voisine] = inverse ? [autre, grande] : [grande, autre];
|
||||||
|
const autres = apres.tables
|
||||||
|
.filter(({ id }) => id !== voisine.id)
|
||||||
|
.map((place) => ({
|
||||||
|
position: destinations.get(place.id) ?? place.position,
|
||||||
|
forme: place.forme,
|
||||||
|
places: capacite(apres, place),
|
||||||
|
}));
|
||||||
|
const position = destinations.get(voisine.id)
|
||||||
|
?? caseLibre(autres, { forme: voisine.forme, places: capacite(apres, voisine) });
|
||||||
|
destinations.set(voisine.id, position);
|
||||||
|
avertissements.push({
|
||||||
|
code: 'LISTE_SUR_VOISINE',
|
||||||
|
details: {
|
||||||
|
table: table.id,
|
||||||
|
voisine: voisine.id,
|
||||||
|
numero: table.numero,
|
||||||
|
numeroVoisine: voisine.numero,
|
||||||
|
remede: { commande: 'deplacerTable', arguments: { id: voisine.id, position } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return avertissements;
|
||||||
|
}
|
||||||
|
|
||||||
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
|
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
|
||||||
// refus levé.
|
// refus levé.
|
||||||
function exigerCapacite(charge, id, sieges, origine) {
|
function exigerCapacite(charge, id, sieges, origine) {
|
||||||
|
|
@ -153,7 +214,9 @@ function deplacerTable(charge, { id, position } = {}) {
|
||||||
/**
|
/**
|
||||||
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
|
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
|
||||||
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
|
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
|
||||||
* par controlerCapacite selon leur origine ; le retenu suit.
|
* par controlerCapacite selon leur origine ; le retenu suit. Un
|
||||||
|
* agrandissement qui porte une liste sur une voisine le dit, après les
|
||||||
|
* avertissements de la capacité : LISTE_SUR_VOISINE (listesSurVoisines).
|
||||||
*/
|
*/
|
||||||
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
||||||
exigerModifiable(charge);
|
exigerModifiable(charge);
|
||||||
|
|
@ -161,10 +224,11 @@ function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
||||||
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
||||||
const sieges = entierSaisi(saisie);
|
const sieges = entierSaisi(saisie);
|
||||||
const avertissements = exigerCapacite(charge, id, sieges, origine);
|
const avertissements = exigerCapacite(charge, id, sieges, origine);
|
||||||
|
const suivante = avecSieges(charge, new Map([[id, sieges]]));
|
||||||
return {
|
return {
|
||||||
charge: avecSieges(charge, new Map([[id, sieges]])),
|
charge: suivante,
|
||||||
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
|
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
|
||||||
avertissements,
|
avertissements: [...avertissements, ...listesSurVoisines(charge, suivante)],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,10 +242,11 @@ function realignerTable(charge, { id } = {}) {
|
||||||
const table = tableDe(charge, id);
|
const table = tableDe(charge, id);
|
||||||
const sieges = charge.evenement.siegesParDefaut;
|
const sieges = charge.evenement.siegesParDefaut;
|
||||||
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
|
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
|
||||||
|
const suivante = avecSieges(charge, new Map([[id, null]]));
|
||||||
return {
|
return {
|
||||||
charge: avecSieges(charge, new Map([[id, null]])),
|
charge: suivante,
|
||||||
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
|
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
|
||||||
avertissements,
|
avertissements: [...avertissements, ...listesSurVoisines(charge, suivante)],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,8 +271,10 @@ function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
|
||||||
avertissements.push({ code, details });
|
avertissements.push({ code, details });
|
||||||
}
|
}
|
||||||
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
|
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
|
||||||
|
const suivante = avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges);
|
||||||
|
avertissements.push(...listesSurVoisines(charge, suivante));
|
||||||
return {
|
return {
|
||||||
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
|
charge: suivante,
|
||||||
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
|
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
|
||||||
avertissements,
|
avertissements,
|
||||||
};
|
};
|
||||||
|
|
@ -264,12 +331,15 @@ function modifierReglage(charge, { cle, valeur } = {}) {
|
||||||
* Σ r(p) du retenu ; une saisie lue par lireEntier, un entier ≥ 0, NON_ENTIER
|
* Σ r(p) du retenu ; une saisie lue par lireEntier, un entier ≥ 0, NON_ENTIER
|
||||||
* sinon — un signe moins compris —, remède null. La valeur par défaut,
|
* sinon — un signe moins compris —, remède null. La valeur par défaut,
|
||||||
* CIBLE_REDONDANCE_PAR_DEFAUT, retire le champ des réglages : la charge
|
* CIBLE_REDONDANCE_PAR_DEFAUT, retire le champ des réglages : la charge
|
||||||
* s'écrit alors comme une charge qui ne l'a jamais porté, et la même valeur
|
* s'écrit alors comme une charge qui ne l'a jamais porté. La même valeur que
|
||||||
* que la cible courante est sans effet.
|
* la cible courante, lue par cibleRedondance, est SANS_EFFET, champ écrit ou
|
||||||
|
* non : une cible de 1000 écrite au fichier ne se retire pas par une saisie
|
||||||
|
* qui ne change rien.
|
||||||
*/
|
*/
|
||||||
function changerCibleRedondance(charge, { cible: saisie } = {}) {
|
function changerCibleRedondance(charge, { cible: saisie } = {}) {
|
||||||
exigerModifiable(charge);
|
exigerModifiable(charge);
|
||||||
const cible = entierSaisi(saisie);
|
const cible = entierSaisi(saisie);
|
||||||
|
if (cible === cibleRedondance(charge)) throw refus('SANS_EFFET', { commande: 'changerCibleRedondance' });
|
||||||
const suivante = structuredClone(charge);
|
const suivante = structuredClone(charge);
|
||||||
if (cible === CIBLE_REDONDANCE_PAR_DEFAUT) delete suivante.reglages.cibleRedondance;
|
if (cible === CIBLE_REDONDANCE_PAR_DEFAUT) delete suivante.reglages.cibleRedondance;
|
||||||
else suivante.reglages.cibleRedondance = cible;
|
else suivante.reglages.cibleRedondance = cible;
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@
|
||||||
// passent par seance.executer sur le système de fichiers d'épreuve. Les noms
|
// passent par seance.executer sur le système de fichiers d'épreuve. Les noms
|
||||||
// d'épreuve sont inventés ; la version vient du module engendré.
|
// d'épreuve sont inventés ; la version vient du module engendré.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { fautesBandes as fautesBandesPosees } from '../../test/bandes.js';
|
||||||
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
|
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { texteLivre } from '../demo/livrees.js';
|
import { texteLivre } from '../demo/livrees.js';
|
||||||
import { constante } from '../geometrie/constantes.js';
|
import { caseLibre } from '../geometrie/tables.js';
|
||||||
import { caseLibre, geometrieTable } from '../geometrie/tables.js';
|
|
||||||
import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js';
|
|
||||||
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||||
import { serialiser } from '../stockage/canonique.js';
|
import { serialiser } from '../stockage/canonique.js';
|
||||||
|
|
@ -129,44 +128,14 @@ function seNormalise(charge) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
|
// Les fautes « une liste passe sur une autre table » des tables d'une
|
||||||
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le
|
// charge, chacune à sa capacité (test/bandes.js) ; avec, l'identifiant de la
|
||||||
// dessin la mesure au navigateur.
|
// table dont seules les paires s'examinent.
|
||||||
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
|
const fautesBandes = (charge, options) =>
|
||||||
|
fautesBandesPosees(
|
||||||
// Les fautes « une liste passe sur une autre table » d'une charge : pour
|
charge.tables.map((table) => ({ id: table.id, position: table.position, forme: table.forme, places: capacite(charge, table) })),
|
||||||
// chaque paire ordonnée de tables, la bande du bloc de noms de la première —
|
options,
|
||||||
// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de
|
);
|
||||||
// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la
|
|
||||||
// seconde, bords exclus. Avec avec, seules les paires où figure la table
|
|
||||||
// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires
|
|
||||||
// examinées.
|
|
||||||
function fautesBandes(charge, { avec = null } = {}) {
|
|
||||||
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
|
||||||
const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) }));
|
|
||||||
const fautes = [];
|
|
||||||
let paires = 0;
|
|
||||||
for (const { g, places } of geometries) {
|
|
||||||
const bande = {
|
|
||||||
gauche: g.origineListe.x,
|
|
||||||
droite: g.origineListe.x + largeur,
|
|
||||||
haut: g.origineListe.y,
|
|
||||||
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
|
|
||||||
};
|
|
||||||
for (const { g: autre } of geometries) {
|
|
||||||
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
|
|
||||||
paires += 1;
|
|
||||||
const e = autre.rayonEmprise;
|
|
||||||
const recoupe =
|
|
||||||
autre.centre.x + e > bande.gauche &&
|
|
||||||
autre.centre.x - e < bande.droite &&
|
|
||||||
autre.centre.y + e > bande.haut &&
|
|
||||||
autre.centre.y - e < bande.bas;
|
|
||||||
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { fautes, paires };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
|
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
|
||||||
// se relit, et se normalise quand la charge reçue se normalisait.
|
// se relit, et se normalise quand la charge reçue se normalisait.
|
||||||
|
|
@ -298,8 +267,11 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
||||||
// à moins d'un pas d'une table posée, sur les deux axes, est prise, la
|
// à moins d'un pas d'une table posée, sur les deux axes, est prise, la
|
||||||
// suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
|
// suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
|
||||||
// table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
|
// table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
|
||||||
// la liste de sa voisine, ce que la création ne change pas.
|
// la liste de sa voisine, la table 2 : l'agrandissement le dit
|
||||||
charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge;
|
// (LISTE_SUR_VOISINE), et la création ne le change pas.
|
||||||
|
const agrandie = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' });
|
||||||
|
assert.deepEqual(agrandie.avertissements.map(({ code, details }) => [code, details.table, details.voisine]), [['LISTE_SUR_VOISINE', 5, 2]]);
|
||||||
|
charge = agrandie.charge;
|
||||||
charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
|
charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
|
||||||
assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
|
assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
|
||||||
assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
|
assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
|
||||||
|
|
@ -317,13 +289,141 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
||||||
let charge = analyser(texteLivre('grande')).charge;
|
let charge = analyser(texteLivre('grande')).charge;
|
||||||
const premiere = charge.tables.find(({ numero }) => numero === 1);
|
const premiere = charge.tables.find(({ numero }) => numero === 1);
|
||||||
assert.deepEqual(premiere.position, { x: 0, y: 0 });
|
assert.deepEqual(premiere.position, { x: 0, y: 0 });
|
||||||
charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge;
|
const agrandie = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' });
|
||||||
|
// Chaque voisine que la liste agrandie couvre est nommée, et ce sont
|
||||||
|
// celles que l'épreuve des bandes voit.
|
||||||
|
const couvertes = agrandie.avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE').map(({ details }) => details.voisine);
|
||||||
|
assert.ok(couvertes.length > 0);
|
||||||
|
const vues = new Set(
|
||||||
|
agrandie.charge.tables
|
||||||
|
.filter(({ id }) => id !== premiere.id && fautesBandes(agrandie.charge, { avec: id }).fautes.some((faute) => faute.includes(`table ${premiere.id} `)))
|
||||||
|
.map(({ id }) => id),
|
||||||
|
);
|
||||||
|
assert.deepEqual(couvertes, [...vues]);
|
||||||
|
// Les remèdes se suivent : chacun envoie sa voisine sur une case que ni
|
||||||
|
// une table posée ni un remède précédent n'occupe, et les appliquer tous
|
||||||
|
// dans l'ordre réussit sans qu'une table en recouvre une autre.
|
||||||
|
const remedes = agrandie.avertissements
|
||||||
|
.filter(({ code }) => code === 'LISTE_SUR_VOISINE')
|
||||||
|
.map(({ details }) => details.remede.arguments);
|
||||||
|
const cases = remedes.map(({ position }) => `${position.x},${position.y}`);
|
||||||
|
assert.equal(new Set(cases).size, remedes.length);
|
||||||
|
let suivie = agrandie.charge;
|
||||||
|
for (const argumentsRemede of remedes) suivie = accepter('deplacerTable', suivie, argumentsRemede).charge;
|
||||||
|
const posees = new Set(suivie.tables.map(({ position }) => `${position.x},${position.y}`));
|
||||||
|
assert.equal(posees.size, suivie.tables.length);
|
||||||
|
charge = agrandie.charge;
|
||||||
charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
|
assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
|
||||||
const creee = charge.tables.at(-1).id;
|
const creee = charge.tables.at(-1).id;
|
||||||
assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
|
assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('agrandie, une table dont la liste couvre la voisine : LISTE_SUR_VOISINE {table, voisine}, remède le déplacement de la voisine vers une case libre, qui lève la faute', () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (let i = 0; i < 2; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
|
assert.deepEqual(charge.tables.map(({ position }) => [position.x, position.y]), [[0, 0], [813, 0]]);
|
||||||
|
// Ronde de 16 en (0, 0) : sa liste, de 232,8 à 732,8, couvre l'emprise
|
||||||
|
// de la voisine, de 692,6 à 933,4.
|
||||||
|
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 16, origine: 'champ' });
|
||||||
|
const autres = apres.tables
|
||||||
|
.filter(({ id }) => id !== 2)
|
||||||
|
.map((table) => ({ position: table.position, forme: table.forme, places: capacite(apres, table) }));
|
||||||
|
const position = caseLibre(autres, { forme: 'ronde', places: 8 });
|
||||||
|
assert.deepEqual(avertissements, [
|
||||||
|
{
|
||||||
|
code: 'LISTE_SUR_VOISINE',
|
||||||
|
details: {
|
||||||
|
table: 1,
|
||||||
|
voisine: 2,
|
||||||
|
numero: 1,
|
||||||
|
numeroVoisine: 2,
|
||||||
|
remede: { commande: 'deplacerTable', arguments: { id: 2, position } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(fautesBandes(apres).fautes.length, 1);
|
||||||
|
const { commande, arguments: argumentsDuRemede } = avertissements[0].details.remede;
|
||||||
|
const deplacee = accepter(commande, apres, argumentsDuRemede).charge;
|
||||||
|
assert.deepEqual(fautesBandes(deplacee), { fautes: [], paires: 2 });
|
||||||
|
const texte = libelle('LISTE_SUR_VOISINE', avertissements[0].details);
|
||||||
|
assert.match(texte, /table 1\b/);
|
||||||
|
assert.match(texte, /table 2\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agrandie sans rien couvrir, ou réduite : aucun LISTE_SUR_VOISINE', () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (let i = 0; i < 2; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
|
// La table de droite, à 10 places : sa liste part vers la droite, où il
|
||||||
|
// n'y a rien, et son emprise reste hors de la liste de la première.
|
||||||
|
const droite = accepter('changerPlacesTable', charge, { id: 2, sieges: 10, origine: 'champ' });
|
||||||
|
assert.deepEqual(droite.avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE'), []);
|
||||||
|
assert.deepEqual(fautesBandes(droite.charge).fautes, []);
|
||||||
|
const reduite = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
|
||||||
|
assert.deepEqual(reduite.avertissements, []);
|
||||||
|
// Un recouvrement qui existait avant le geste n'est pas le sien.
|
||||||
|
const serree = accepter('deplacerTable', charge, { id: 2, position: { x: 600, y: 0 } }).charge;
|
||||||
|
assert.equal(fautesBandes(serree).fautes.length, 1);
|
||||||
|
const encore = accepter('changerPlacesTable', serree, { id: 1, sieges: 9, origine: 'champ' });
|
||||||
|
assert.deepEqual(encore.avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("réalignée sous un défaut plus grand, une table surchargée dont la liste couvre la voisine : LISTE_SUR_VOISINE", () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (let i = 0; i < 2; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
|
// Les deux tables surchargées, à 6 et 8 places : le défaut passé à 16 ne
|
||||||
|
// touche aucune d'elles, et ne dit rien.
|
||||||
|
charge = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' }).charge;
|
||||||
|
charge = accepter('changerPlacesTable', charge, { id: 2, sieges: 8, origine: 'champ' }).charge;
|
||||||
|
const defaut = accepter('changerDefaut', charge, { sieges: 16, realigner: false });
|
||||||
|
assert.deepEqual(defaut.avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE'), []);
|
||||||
|
// Réalignée, la table 1 passe à 16 places : sa liste couvre la table 2.
|
||||||
|
const { charge: apres, avertissements } = accepter('realignerTable', defaut.charge, { id: 1 });
|
||||||
|
const autres = apres.tables
|
||||||
|
.filter(({ id }) => id !== 2)
|
||||||
|
.map((table) => ({ position: table.position, forme: table.forme, places: capacite(apres, table) }));
|
||||||
|
const position = caseLibre(autres, { forme: 'ronde', places: 8 });
|
||||||
|
assert.deepEqual(
|
||||||
|
avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE').map(({ details }) => [details.table, details.voisine, details.remede]),
|
||||||
|
[[1, 2, { commande: 'deplacerTable', arguments: { id: 2, position } }]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le défaut agrandit deux voisines dans le même geste : leur recouvrement n'est dit qu'une fois, sous la table dont la liste couvre l'autre, remède le déplacement de la couverte", () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (let i = 0; i < 2; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
|
const { charge: apres, avertissements } = accepter('changerDefaut', charge, { sieges: 16, realigner: false });
|
||||||
|
const autres = apres.tables
|
||||||
|
.filter(({ id }) => id !== 2)
|
||||||
|
.map((table) => ({ position: table.position, forme: table.forme, places: capacite(apres, table) }));
|
||||||
|
const position = caseLibre(autres, { forme: 'ronde', places: 16 });
|
||||||
|
assert.deepEqual(
|
||||||
|
avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE').map(({ details }) => [details.table, details.voisine, details.remede]),
|
||||||
|
[[1, 2, { commande: 'deplacerTable', arguments: { id: 2, position } }]],
|
||||||
|
);
|
||||||
|
// Le remède suivi lève la faute.
|
||||||
|
const { commande, arguments: argumentsDuRemede } = avertissements.find(({ code }) => code === 'LISTE_SUR_VOISINE').details.remede;
|
||||||
|
assert.deepEqual(fautesBandes(accepter(commande, apres, argumentsDuRemede).charge).fautes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le défaut agrandit deux voisines dont la seconde de la charge porte sa liste sur la première : l'avertissement se dit sous la seconde, remède le déplacement de la première", () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (let i = 0; i < 2; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
|
||||||
|
// La table 1 passe à droite de la table 2 : la liste de la 2, tournée
|
||||||
|
// vers la droite, va vers elle.
|
||||||
|
charge = accepter('deplacerTable', charge, { id: 1, position: { x: 1626, y: 0 } }).charge;
|
||||||
|
assert.deepEqual(fautesBandes(charge).fautes, []);
|
||||||
|
const { charge: apres, avertissements } = accepter('changerDefaut', charge, { sieges: 16, realigner: false });
|
||||||
|
const autres = apres.tables
|
||||||
|
.filter(({ id }) => id !== 1)
|
||||||
|
.map((table) => ({ position: table.position, forme: table.forme, places: capacite(apres, table) }));
|
||||||
|
const position = caseLibre(autres, { forme: 'ronde', places: 16 });
|
||||||
|
assert.deepEqual(
|
||||||
|
avertissements.filter(({ code }) => code === 'LISTE_SUR_VOISINE').map(({ details }) => [details.table, details.voisine, details.remede]),
|
||||||
|
[[2, 1, { commande: 'deplacerTable', arguments: { id: 1, position } }]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
|
test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
|
||||||
// 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
|
// 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
|
||||||
// trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
|
// trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
|
||||||
|
|
@ -370,9 +470,18 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
|
||||||
|
|
||||||
test('une forme inconnue, une table inconnue ou une position non finie sont des fautes du code', () => {
|
test('une forme inconnue, une table inconnue ou une position non finie sont des fautes du code', () => {
|
||||||
const charge = chargeDe({ tables: [{ id: 1 }] });
|
const charge = chargeDe({ tables: [{ id: 1 }] });
|
||||||
assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), TypeError);
|
// Le message nomme la faute : une TypeError levée par une lecture de
|
||||||
assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), TypeError);
|
// propriété sur undefined ne passerait pas pour elle.
|
||||||
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), TypeError);
|
assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), { name: 'TypeError', message: /^forme : / });
|
||||||
|
assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), { name: 'TypeError', message: 'table inconnue : 7' });
|
||||||
|
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 7, position: { x: 0, y: 0 } }), {
|
||||||
|
name: 'TypeError',
|
||||||
|
message: 'table inconnue : 7',
|
||||||
|
});
|
||||||
|
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), {
|
||||||
|
name: 'TypeError',
|
||||||
|
message: /^position : /,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('une table créée suit le défaut : sieges null, 8 places sous un défaut de 8, 10 sous un défaut de 10', () => {
|
test('une table créée suit le défaut : sieges null, 8 places sous un défaut de 8, 10 sous un défaut de 10', () => {
|
||||||
|
|
@ -674,13 +783,18 @@ describe("mobilier : les places d'une table, par le champ et par la poignée (§
|
||||||
assert.deepEqual(apres.retenu.placement[0].reserve, [4, 7]);
|
assert.deepEqual(apres.retenu.placement[0].reserve, [4, 7]);
|
||||||
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [7] } }]);
|
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [7] } }]);
|
||||||
|
|
||||||
// Réaligner une table surchargée à 10, la personne 10 réservée sans siège.
|
// Réaligner une table surchargée à 10, la personne 10 réservée sans
|
||||||
const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges: true, reservations: [resa(10, 1, null)] });
|
// siège, sous des sièges attribués comme sans attribution : la même liste.
|
||||||
|
for (const attribuerSieges of [true, false]) {
|
||||||
|
const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges, reservations: [resa(10, 1, null)] });
|
||||||
placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true });
|
placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true });
|
||||||
const realignee = accepter('realignerTable', dix, { id: 1 });
|
const realignee = accepter('realignerTable', dix, { id: 1 });
|
||||||
assert.equal(invariantViole(realignee.charge), null);
|
const cas = `realignerTable, attribuerSieges ${attribuerSieges}`;
|
||||||
assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10]);
|
assert.equal(invariantViole(realignee.charge), null, cas);
|
||||||
assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }]);
|
assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10], cas);
|
||||||
|
assert.deepEqual(realignee.charge.retenu.placement[0].reserve, [8, 9], cas);
|
||||||
|
assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }], cas);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sièges attribués, le retour des réservés : siège voulu, chaise libre ou d\'un absent, non-réservé du plus haut rang, ordre des rangs, tour couvert', () => {
|
test('sièges attribués, le retour des réservés : siège voulu, chaise libre ou d\'un absent, non-réservé du plus haut rang, ordre des rangs, tour couvert', () => {
|
||||||
|
|
@ -1065,4 +1179,15 @@ describe('mobilier : la cible de la somme de redondance (§ 5.4, § 8.1, § 8.9)
|
||||||
refuse(() => appliquerCommande('changerCibleRedondance', posee, { cible: '640' }), 'SANS_EFFET');
|
refuse(() => appliquerCommande('changerCibleRedondance', posee, { cible: '640' }), 'SANS_EFFET');
|
||||||
assert.throws(() => COMMANDES.get('changerCibleRedondance')(charge, { cible: null }), TypeError);
|
assert.throws(() => COMMANDES.get('changerCibleRedondance')(charge, { cible: null }), TypeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('une cible de 1000 écrite au fichier, saisie de nouveau à 1000 : SANS_EFFET, sans retirer le champ ni faire d’entrée', () => {
|
||||||
|
const explicite = chargeDe({ tables: [{ id: 1 }], n: 2 });
|
||||||
|
explicite.reglages.cibleRedondance = 1000;
|
||||||
|
analyser(serialiser(explicite, { revision: 1, produitVersion: V1 }));
|
||||||
|
refuse(() => appliquerCommande('changerCibleRedondance', explicite, { cible: '1000' }), 'SANS_EFFET', {
|
||||||
|
commande: 'changerCibleRedondance',
|
||||||
|
});
|
||||||
|
refuse(() => COMMANDES.get('changerCibleRedondance')(explicite, { cible: 1000 }), 'SANS_EFFET');
|
||||||
|
assert.equal(explicite.reglages.cibleRedondance, 1000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ function enReserve(retenu, tour, id) {
|
||||||
|
|
||||||
// Le rang de la table idTable dans le retenu, la table ajoutée vide à chaque
|
// Le rang de la table idTable dans le retenu, la table ajoutée vide à chaque
|
||||||
// tour quand il ne la déclare pas encore — une table créée après la
|
// tour quand il ne la déclare pas encore — une table créée après la
|
||||||
// retenue (A4 l'y ajoute aussi).
|
// retenue, que creerTable y ajoute aussi.
|
||||||
function rangDeTable(retenu, idTable, places) {
|
function rangDeTable(retenu, idTable, places) {
|
||||||
const rang = retenu.tables.indexOf(idTable);
|
const rang = retenu.tables.indexOf(idTable);
|
||||||
if (rang >= 0) return rang;
|
if (rang >= 0) return rang;
|
||||||
|
|
@ -452,16 +452,19 @@ function retouche(charge, suivante, remedeDe = () => null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le remède d'un retrait du tour que la réservation de la personne à sa
|
// Le remède d'un retrait du tour que la réservation de la personne à sa
|
||||||
// table interdit : le retrait de cette réservation, la première dans l'ordre
|
// table interdit : le retrait de cette réservation, celle qui tient la
|
||||||
// canonique qui tient la personne à la table de la violation au tour, « tous
|
// personne à la table de la violation au tour, la plus étroite d'abord —
|
||||||
// les tours » avant « ce tour ». null quand aucune violation RESERVATION ne
|
// « ce tour » avant « tous les tours », puis l'ordre canonique. Quand les
|
||||||
// nomme la personne.
|
// deux portées coexistent, retirer « ce tour » lève le refus et garde les
|
||||||
|
// autres tours, que retirer « tous les tours » perdrait. null quand aucune
|
||||||
|
// violation RESERVATION ne nomme la personne.
|
||||||
function remedeDuRetrait(charge, participant, tour, violations) {
|
function remedeDuRetrait(charge, participant, tour, violations) {
|
||||||
const violation = violations.find((v) => v.code === 'RESERVATION' && v.participant === participant);
|
const violation = violations.find((v) => v.code === 'RESERVATION' && v.participant === participant);
|
||||||
if (violation === undefined) return null;
|
if (violation === undefined) return null;
|
||||||
|
const largeur = ({ portee }) => (portee === 'tous' ? 1 : 0);
|
||||||
const [tenue] = charge.reservations
|
const [tenue] = charge.reservations
|
||||||
.filter((r) => r.participant === participant && r.table === violation.table && (r.portee === 'tous' || r.tour === tour))
|
.filter((r) => r.participant === participant && r.table === violation.table && (r.portee === 'tous' || r.tour === tour))
|
||||||
.sort(ordreDesReservations);
|
.sort((a, b) => largeur(a) - largeur(b) || ordreDesReservations(a, b));
|
||||||
if (tenue === undefined) return null;
|
if (tenue === undefined) return null;
|
||||||
return {
|
return {
|
||||||
commande: 'retirerReservation',
|
commande: 'retirerReservation',
|
||||||
|
|
|
||||||
|
|
@ -753,6 +753,26 @@ describe('commandes des places : le vérificateur d’invariants du retenu (§ 1
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deux réservations de la personne à sa table, « tous les tours » et « ce tour » : le remède retire la plus étroite d’abord, puis l’autre, et le retrait passe', () => {
|
||||||
|
// Chacune tient à elle seule la personne à la table au tour 1 : aucun
|
||||||
|
// retrait unique ne lève le refus. Le premier remède garde « tous les
|
||||||
|
// tours », qui réserve encore les tours 2 et 3 ; le refus qui suit
|
||||||
|
// nomme alors celle-ci.
|
||||||
|
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null), resa(4, 5, null, 1)] }));
|
||||||
|
const remede = (portee, tour) => ({ commande: 'retirerReservation', arguments: { participant: 4, table: 5, portee, tour } });
|
||||||
|
const premier = refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||||
|
remede: remede('tour', 1),
|
||||||
|
});
|
||||||
|
const { charge: sansTour } = executer(premier.details.remede.commande, charge, premier.details.remede.arguments);
|
||||||
|
assert.deepEqual(sansTour.reservations, [resa(4, 5, null)]);
|
||||||
|
const second = refuse(() => executer('retirerDuTour', sansTour, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||||
|
remede: remede('tous', null),
|
||||||
|
});
|
||||||
|
const { charge: sansReservation } = executer(second.details.remede.commande, sansTour, second.details.remede.arguments);
|
||||||
|
const { charge: retire } = executer('retirerDuTour', sansReservation, { tour: 1, participant: 4 });
|
||||||
|
assert.deepEqual(reserveDe(retire, 1), [4]);
|
||||||
|
});
|
||||||
|
|
||||||
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
|
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
|
||||||
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
|
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
|
||||||
charge.participants.push(personne(13));
|
charge.participants.push(personne(13));
|
||||||
|
|
@ -941,7 +961,8 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
|
||||||
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
|
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
|
||||||
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
|
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
|
||||||
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
|
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
|
||||||
// Le refus du dépôt sur le sol (src/interface/plan/depot.js), dont le texte vit ici.
|
// Le refus du dépôt sur le sol (src/interface/plan/depot.js) et de
|
||||||
|
// « Retirer du tour », dont le texte vit ici.
|
||||||
['RESERVATION_TOUS_LES_TOURS', [{ participant: 1, table: 3 }, 'tous les tours']],
|
['RESERVATION_TOUS_LES_TOURS', [{ participant: 1, table: 3 }, 'tous les tours']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -955,11 +976,12 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
|
||||||
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
|
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
|
||||||
}
|
}
|
||||||
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
|
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
|
||||||
// Le refus du dépôt sur le sol dit ce que le sol ne fait pas, sans rien
|
// Le refus du dépôt sur le sol, et de « Retirer du tour », dit ce que le
|
||||||
// promettre du glissement vers une autre chaise : deplacerReservation y
|
// retrait d'un seul tour ne fait pas, sans rien promettre du glissement
|
||||||
// garde la portée « tous », et la déplace à chaque tour.
|
// vers une autre chaise : deplacerReservation y garde la portée « tous »,
|
||||||
|
// et la déplace à chaque tour.
|
||||||
const tous = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
|
const tous = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
|
||||||
assert.ok(tous.includes('hors des tables'), tous);
|
assert.ok(tous.includes('pour un seul tour'), tous);
|
||||||
assert.ok(!tous.includes('ne change que'), tous);
|
assert.ok(!tous.includes('ne change que'), tous);
|
||||||
assert.equal(libelle('places.titreParDefaut'), 'animateur');
|
assert.equal(libelle('places.titreParDefaut'), 'animateur');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,19 +44,30 @@ import { etatPlaces } from './places.js';
|
||||||
*
|
*
|
||||||
* @param {import('../stockage/types.js').Charge} charge
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
* @param {{proposition?: number|null}} demande la proposition demandée au plan
|
* @param {{proposition?: number|null}} demande la proposition demandée au plan
|
||||||
|
* @param {{etatPlaces?: typeof etatPlaces}} [lecture] la lecture d'un tour,
|
||||||
|
* etatPlaces par défaut ; une épreuve y compte les lectures
|
||||||
* @returns {ConflitPlacement[]|null}
|
* @returns {ConflitPlacement[]|null}
|
||||||
*/
|
*/
|
||||||
export function conflitsDuPlacement(charge, { proposition = null } = {}) {
|
export function conflitsDuPlacement(charge, { proposition = null } = {}, { etatPlaces: lire = etatPlaces } = {}) {
|
||||||
const conflits = [];
|
const conflits = [];
|
||||||
// Chaque tour déjà lu : sa source, sa proposition, et la table de chaque
|
// Chaque tour déjà lu : sa source, sa proposition, et la table de chaque
|
||||||
// personne assise, {id, numero}.
|
// personne assise, {id, numero}.
|
||||||
const lus = [];
|
const lus = [];
|
||||||
|
// Les tours relus dans une autre source que la leur, par proposition de
|
||||||
|
// la source (null pour le retenu) puis par rang : chacun ne se relit
|
||||||
|
// qu'une fois.
|
||||||
|
const relus = new Map();
|
||||||
let lu = false;
|
let lu = false;
|
||||||
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
|
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
|
||||||
const etat = etatPlaces(charge, { tour, proposition });
|
const etat = lire(charge, { tour, proposition });
|
||||||
if (etat.source !== 'reservations' && etat.evalue) {
|
if (etat.source !== 'reservations' && etat.evalue) {
|
||||||
lu = true;
|
lu = true;
|
||||||
const tablesParTour = historique(charge, etat, lus);
|
const tablesParTour = historique(etat, lus, (rang) => {
|
||||||
|
if (!relus.has(etat.proposition)) relus.set(etat.proposition, new Map());
|
||||||
|
const deLaSource = relus.get(etat.proposition);
|
||||||
|
if (!deLaSource.has(rang)) deLaSource.set(rang, tablesDe(lire(charge, { tour: rang + 1, proposition: etat.proposition })));
|
||||||
|
return deLaSource.get(rang);
|
||||||
|
});
|
||||||
for (const { id: table, numero, conflits: deTable } of etat.tables) {
|
for (const { id: table, numero, conflits: deTable } of etat.tables) {
|
||||||
for (const regle of deTable?.regles ?? []) {
|
for (const regle of deTable?.regles ?? []) {
|
||||||
for (const personnes of regle.paires ?? regle.personnes.map((p) => [p])) {
|
for (const personnes of regle.paires ?? regle.personnes.map((p) => [p])) {
|
||||||
|
|
@ -87,15 +98,13 @@ function tablesDe(etat) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// La table de chaque personne aux tours qui précèdent celui d'etat, lus dans
|
// La table de chaque personne aux tours qui précèdent celui d'etat, lus dans
|
||||||
// la source d'etat : un tour lu dans une autre source se relit par
|
// la source d'etat : un tour lu dans une autre source se relit par relire(rang),
|
||||||
// etatPlaces avec la proposition d'etat, null pour le retenu. La source
|
// la lecture du tour avec la proposition d'etat, null pour le retenu, que
|
||||||
// d'etat porte son tour, donc tous ceux qui le précèdent, et la relecture
|
// l'appelant garde. La source d'etat porte son tour, donc tous ceux qui le
|
||||||
// rend la même source.
|
// précèdent, et la relecture rend la même source.
|
||||||
function historique(charge, etat, lus) {
|
function historique(etat, lus, relire) {
|
||||||
return lus.map(({ source, proposition, tableDe }, rang) =>
|
return lus.map(({ source, proposition, tableDe }, rang) =>
|
||||||
source === etat.source && proposition === etat.proposition
|
source === etat.source && proposition === etat.proposition ? tableDe : relire(rang),
|
||||||
? tableDe
|
|
||||||
: tablesDe(etatPlaces(charge, { tour: rang + 1, proposition: etat.proposition })),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,24 @@ describe('conflitsDuPlacement : la proposition montrée, sinon le retenu (§ 7.5
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('une proposition plus courte que R : chaque tour se lit une fois, et ses tours une fois de plus dans le retenu, non à chaque tour du retenu', () => {
|
||||||
|
// La proposition 1 porte deux tours sur cinq : les tours 3 à 5 se lisent
|
||||||
|
// dans le retenu, et l'histoire de leurs conflits relit les tours 1 et 2
|
||||||
|
// dans le retenu. Borne : R lectures, plus une par tour relu.
|
||||||
|
const suite = [[[2, 3, 4], [1, 5, 6]], [[1, 3, 5], [2, 4, 6]], [[1, 4, 6], [2, 3, 5]]];
|
||||||
|
const c = charge({ plans: [AUTRE, [...PLAN, ...suite]], retenu: 1, tours: 5 });
|
||||||
|
const lus = [];
|
||||||
|
const espion = (charge_, demande) => {
|
||||||
|
lus.push(demande);
|
||||||
|
return etatPlaces(charge_, demande);
|
||||||
|
};
|
||||||
|
const conflits = conflitsDuPlacement(c, { proposition: 1 }, { etatPlaces: espion });
|
||||||
|
assert.deepEqual(conflits, conflitsDuPlacement(c, { proposition: 1 }));
|
||||||
|
assert.ok(conflits.some(({ tour }) => tour === 5));
|
||||||
|
assert.ok(lus.length <= 5 + 2, JSON.stringify(lus));
|
||||||
|
assert.equal(lus.length, 7);
|
||||||
|
});
|
||||||
|
|
||||||
test('un retenu en dérive — une personne ajoutée qu’il ne place pas — ne s’évalue pas : null, comme les pastilles absentes du plan', () => {
|
test('un retenu en dérive — une personne ajoutée qu’il ne place pas — ne s’évalue pas : null, comme les pastilles absentes du plan', () => {
|
||||||
const c = executer('ajouterParticipant', charge({ retenu: 0 }), { champs: { nom: 'Sarcelle', prenom: 'Capucine' } }).charge;
|
const c = executer('ajouterParticipant', charge({ retenu: 0 }), { champs: { nom: 'Sarcelle', prenom: 'Capucine' } }).charge;
|
||||||
assert.ok(etatPlaces(c, { tour: 1 }).evalue === false);
|
assert.ok(etatPlaces(c, { tour: 1 }).evalue === false);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
|
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
|
||||||
// et questions sont des codes et leurs détails.
|
// et questions sont des codes et leurs détails.
|
||||||
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
|
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
|
||||||
import { analyser } from '../stockage/document.js';
|
|
||||||
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
|
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { decoderJournal, lireJournal } from '../stockage/journal.js';
|
import { decoderJournal, lireJournal } from '../stockage/journal.js';
|
||||||
|
|
@ -51,7 +50,6 @@ import {
|
||||||
renommer as renommerSurDisque,
|
renommer as renommerSurDisque,
|
||||||
supprimer as supprimerSurDisque,
|
supprimer as supprimerSurDisque,
|
||||||
} from '../stockage/operations.js';
|
} from '../stockage/operations.js';
|
||||||
import { examiner } from '../stockage/placements.js';
|
|
||||||
import { lireReglages } from '../stockage/reglages_locaux.js';
|
import { lireReglages } from '../stockage/reglages_locaux.js';
|
||||||
import { ErreurCommande, refus } from './erreurs.js';
|
import { ErreurCommande, refus } from './erreurs.js';
|
||||||
import { resumeDuMeilleur, resumeDuRetenu } from './evaluation.js';
|
import { resumeDuMeilleur, resumeDuRetenu } from './evaluation.js';
|
||||||
|
|
@ -144,17 +142,17 @@ async function derniereModification(fs, racine, base) {
|
||||||
// liste montre pour un événement dont le calcul a fauté.
|
// liste montre pour un événement dont le calcul a fauté.
|
||||||
const AUCUN_RESUME = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null });
|
const AUCUN_RESUME = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null });
|
||||||
|
|
||||||
// La charge examinée de l'état base, lue sans rien écrire (examiner, § 8.9,
|
// La charge de l'événement base telle que l'ouverture la lit, sans rien
|
||||||
// point 3) : celle que l'ouverture garde, où une proposition fautive, dont
|
// écrire (ouvrir du dépôt, § 8.8) : un journal en avance d'une entrée la
|
||||||
// l'identifiant se répète ou atteint le compteur, n'entre pas au classement —
|
// rattrape en mémoire, par la règle de rattraper, et examiner (§ 8.9, point
|
||||||
// qui lève sur un identifiant répété —, si bien que la liste nomme la
|
// 3) en écarte une proposition fautive, dont l'identifiant se répète ou
|
||||||
// proposition au rang 1 que le bloc de génération de l'événement ouvert
|
// atteint le compteur — le classement lève sur un identifiant répété. La
|
||||||
// nomme. null pour un état absent ou qui ne se lit pas.
|
// liste nomme ainsi la proposition au rang 1, et son nombre, que le bloc de
|
||||||
|
// génération de l'événement ouvert nomme (§ 8.1). null pour un état absent
|
||||||
|
// ou qui ne s'ouvre pas.
|
||||||
async function chargeExaminee(fs, racine, base) {
|
async function chargeExaminee(fs, racine, base) {
|
||||||
try {
|
try {
|
||||||
const texte = await fs.lireTexte(racine, base + SUFFIXES.etat);
|
return (await ouvrirDepot(fs, racine, base)).charge;
|
||||||
if (texte === null) return null;
|
|
||||||
return examiner(analyser(texte).charge).charge;
|
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur instanceof ErreurStockage) return null;
|
if (erreur instanceof ErreurStockage) return null;
|
||||||
throw erreur;
|
throw erreur;
|
||||||
|
|
@ -199,7 +197,7 @@ async function verrouDe(fs, racine, base) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La façade des événements, branchée par App sur la séance (I1).
|
* La façade des événements, branchée par App sur la séance.
|
||||||
*
|
*
|
||||||
* @param {Object} parametres
|
* @param {Object} parametres
|
||||||
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
||||||
|
|
@ -362,16 +360,17 @@ export function creerFacade({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le verrou de l'événement cible pris sous la séance de la façade. Le sien,
|
// Le verrou de l'événement cible pris sous la séance de la façade, daté de
|
||||||
// resté d'un rendu refusé, est déjà pris ; celui d'une séance morte
|
// l'horloge lue une fois. Le sien, resté d'un rendu refusé, est déjà pris ;
|
||||||
// (vivant faux) s'efface en son nom, puis se prend ; tout autre lève
|
// celui d'une séance morte (vivant faux) s'efface en son nom, puis se
|
||||||
// VERROU_PRIS, sans rien changer.
|
// prend ; tout autre lève VERROU_PRIS, sans rien changer.
|
||||||
async function prendreVerrou(cible) {
|
async function prendreVerrou(cible) {
|
||||||
let verrou = await verrouiller(cible, seance);
|
const depuis = horloge.horodatage();
|
||||||
|
let verrou = await verrouiller(cible, seance, depuis);
|
||||||
if (!verrou.pris && verrou.seance === seance) return;
|
if (!verrou.pris && verrou.seance === seance) return;
|
||||||
if (!verrou.pris && verrou.vivant === false) {
|
if (!verrou.pris && verrou.vivant === false) {
|
||||||
await deverrouiller(cible, verrou.seance);
|
await deverrouiller(cible, verrou.seance);
|
||||||
verrou = await verrouiller(cible, seance);
|
verrou = await verrouiller(cible, seance, depuis);
|
||||||
}
|
}
|
||||||
if (!verrou.pris) {
|
if (!verrou.pris) {
|
||||||
const { seance: tenue, depuis, vivant } = verrou;
|
const { seance: tenue, depuis, vivant } = verrou;
|
||||||
|
|
@ -406,13 +405,17 @@ export function creerFacade({
|
||||||
return { base: charge.base };
|
return { base: charge.base };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Une source d'un format plus récent ne se copie pas : la LECTURE_SEULE du
|
||||||
|
// dépôt se relaie avec remede null, comme les refus propres de la façade.
|
||||||
async function copier(base, { nom }) {
|
async function copier(base, { nom }) {
|
||||||
const texte = nomSaisi(nom);
|
const texte = nomSaisi(nom);
|
||||||
const courante = await exigerDossier();
|
const courante = await exigerDossier();
|
||||||
const source = await ouvrirDepot(fs, courante, base);
|
const source = await ouvrirDepot(fs, courante, base);
|
||||||
// L'instant copié, par la règle même qui l'inscrit à la filiation.
|
// L'instant copié, par la règle même qui l'inscrit à la filiation.
|
||||||
const instant = libelleDeLInstant(source, libelleReprise);
|
const instant = libelleDeLInstant(source, libelleReprise);
|
||||||
const copie = await copierSurDisque(source, {
|
let copie;
|
||||||
|
try {
|
||||||
|
copie = await copierSurDisque(source, {
|
||||||
id: identifiant(),
|
id: identifiant(),
|
||||||
nom: texte,
|
nom: texte,
|
||||||
libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }),
|
libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }),
|
||||||
|
|
@ -421,6 +424,12 @@ export function creerFacade({
|
||||||
produitVersion,
|
produitVersion,
|
||||||
libelleReprise,
|
libelleReprise,
|
||||||
});
|
});
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurStockage && erreur.code === 'LECTURE_SEULE') {
|
||||||
|
throw new ErreurStockage('LECTURE_SEULE', { ...erreur.details, remede: null });
|
||||||
|
}
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
await ouvrir(copie.base);
|
await ouvrir(copie.base);
|
||||||
return { base: copie.base };
|
return { base: copie.base };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import { CATALOGUE } from '../demo/catalogue.js';
|
||||||
import { LIVREES, texteLivre } from '../demo/livrees.js';
|
import { LIVREES, texteLivre } from '../demo/livrees.js';
|
||||||
import { rechercher } from '../moteur/recherche.js';
|
import { rechercher } from '../moteur/recherche.js';
|
||||||
import { serialiser } from '../stockage/canonique.js';
|
import { serialiser } from '../stockage/canonique.js';
|
||||||
import { creer as creerDansLeDepot } from '../stockage/depot.js';
|
import { creer as creerDansLeDepot, enregistrer as enregistrerDansLeDepot } from '../stockage/depot.js';
|
||||||
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
import { FORMAT, analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { lireJournal } from '../stockage/journal.js';
|
import { lireJournal } from '../stockage/journal.js';
|
||||||
import { SUFFIXES } from '../stockage/noms.js';
|
import { SUFFIXES } from '../stockage/noms.js';
|
||||||
|
|
@ -181,7 +181,7 @@ describe('demarrer : une détermination par séance (§ 8.6)', () => {
|
||||||
assert.deepEqual(appels, [['changerRacine', DOC]]);
|
assert.deepEqual(appels, [['changerRacine', DOC]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('les cinq issues de S8 rendent leurs avis, dans l’ordre d’Avis', async () => {
|
test('les cinq issues du dossier de travail rendent leurs avis, dans l’ordre d’Avis', async () => {
|
||||||
const cas = [
|
const cas = [
|
||||||
[{}, [{ code: 'NON_PUBLIE', details: {} }]],
|
[{}, [{ code: 'NON_PUBLIE', details: {} }]],
|
||||||
[{ executable: SOUS_APPDATA }, [{ code: 'DONNEES_APPLICATIVES', details: {} }]],
|
[{ executable: SOUS_APPDATA }, [{ code: 'DONNEES_APPLICATIVES', details: {} }]],
|
||||||
|
|
@ -213,7 +213,7 @@ describe('demarrer : une détermination par séance (§ 8.6)', () => {
|
||||||
assert.deepEqual(facade.explorateur(), { disponible: true, raison: null });
|
assert.deepEqual(facade.explorateur(), { disponible: true, raison: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('des réglages locaux corrompus ajoutent REGLAGES_ILLISIBLES après les avis de S8', async () => {
|
test('des réglages locaux corrompus ajoutent REGLAGES_ILLISIBLES après les avis du dossier de travail', async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
fs.deposer('documents', 'reglages_locaux.json', '{ pas du JSON');
|
fs.deposer('documents', 'reglages_locaux.json', '{ pas du JSON');
|
||||||
const { facade } = monter({ fs });
|
const { facade } = monter({ fs });
|
||||||
|
|
@ -247,6 +247,28 @@ describe('demarrer : une détermination par séance (§ 8.6)', () => {
|
||||||
|
|
||||||
// --- Les questions (§ 8.6) ---------------------------------------------------
|
// --- Les questions (§ 8.6) ---------------------------------------------------
|
||||||
|
|
||||||
|
describe('une opération lancée pendant demarrer', () => {
|
||||||
|
test('attend la détermination en cours, et lit le dossier retenu ; une seule détermination', async () => {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
const sondes = [];
|
||||||
|
const sonder = fs.sonder;
|
||||||
|
fs.sonder = async (racine) => {
|
||||||
|
sondes.push(racine.id);
|
||||||
|
return sonder(racine);
|
||||||
|
};
|
||||||
|
const { facade } = monter({ fs });
|
||||||
|
fs.deposerDossier('documents');
|
||||||
|
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
|
const demarrage = facade.demarrer();
|
||||||
|
const [lignes, corbeille] = await Promise.all([facade.lister(), facade.corbeille()]);
|
||||||
|
assert.deepEqual(lignes.map(({ base }) => base), [LUCIOLES]);
|
||||||
|
assert.deepEqual(corbeille, []);
|
||||||
|
assert.equal((await demarrage).dossier.racine.id, 'documents');
|
||||||
|
assert.equal(await facade.demarrer(), await demarrage);
|
||||||
|
assert.deepEqual(sondes, ['documents']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('les questions du dossier de travail (§ 8.6)', () => {
|
describe('les questions du dossier de travail (§ 8.6)', () => {
|
||||||
// data/ porte deux événements, posés par un premier processus ; le second,
|
// data/ porte deux événements, posés par un premier processus ; le second,
|
||||||
// monté sur le même disque, voit data/ refuser l'écriture.
|
// monté sur le même disque, voit data/ refuser l'écriture.
|
||||||
|
|
@ -284,6 +306,33 @@ describe('les questions du dossier de travail (§ 8.6)', () => {
|
||||||
assert.equal(typeof libelle(refus.code, refus.details), 'string');
|
assert.equal(typeof libelle(refus.code, refus.details), 'string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tant que la question attend, chaque opération qui lit ou écrit le dossier se refuse QUESTION_EN_ATTENTE, sans rien écrire', async () => {
|
||||||
|
const { fs } = await dataQuiRefuse();
|
||||||
|
const { facade, appels } = monter({ fs });
|
||||||
|
await facade.demarrer();
|
||||||
|
const avant = ecritures(fs);
|
||||||
|
const operations = [
|
||||||
|
['lister', () => facade.lister()],
|
||||||
|
['creer', () => facade.creer({ nom: 'Soirée des Merles' })],
|
||||||
|
['chargerDemonstration', () => facade.chargerDemonstration(DEMONSTRATIONS[0].cle)],
|
||||||
|
['copier', () => facade.copier(LUCIOLES, { nom: 'Variante' })],
|
||||||
|
['renommer', () => facade.renommer(LUCIOLES, { nom: 'Variante' })],
|
||||||
|
['supprimer', () => facade.supprimer(LUCIOLES)],
|
||||||
|
['corbeille', () => facade.corbeille()],
|
||||||
|
['importerFichier', () => facade.importerFichier()],
|
||||||
|
['importerDepuisAutre', () => facade.importerDepuisAutre()],
|
||||||
|
['ouvrirExplorateur', () => facade.ouvrirExplorateur()],
|
||||||
|
];
|
||||||
|
assert.equal(operations.length, 10);
|
||||||
|
for (const [nom, operation] of operations) {
|
||||||
|
const refus = await refusDe(operation());
|
||||||
|
assert.equal(refus.code, 'QUESTION_EN_ATTENTE', nom);
|
||||||
|
assert.deepEqual(refus.details, { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }, nom);
|
||||||
|
}
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
assert.deepEqual(appels, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('« reessayer » refait la détermination : deux sondes de plus, la même question', async () => {
|
test('« reessayer » refait la détermination : deux sondes de plus, la même question', async () => {
|
||||||
const { fs, sondes } = await dataQuiRefuse();
|
const { fs, sondes } = await dataQuiRefuse();
|
||||||
const { facade, appels } = monter({ fs });
|
const { facade, appels } = monter({ fs });
|
||||||
|
|
@ -507,6 +556,39 @@ describe('lister (§ 8.1, § 19.4)', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un .gtt.verrou dont la lecture échoue autrement qu'ABSENT se dit illisible, sans écrire ; un absent n'est pas un verrou", async () => {
|
||||||
|
const { fs, facade } = await deuxEvenements();
|
||||||
|
fs.deposer('portable', `soiree-a${SUFFIXES.verrou}`, '{}');
|
||||||
|
const lireTexte = fs.lireTexte;
|
||||||
|
fs.lireTexte = async (racine, chemin) => {
|
||||||
|
if (chemin === `soiree-a${SUFFIXES.verrou}`) throw new ErreurStockage('LECTURE', { chemin, dossier: PORTABLE.chemin, cause: 'EACCES' });
|
||||||
|
return lireTexte(racine, chemin);
|
||||||
|
};
|
||||||
|
const avant = ecritures(fs);
|
||||||
|
const lignes = await facade.lister();
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
assert.deepEqual(
|
||||||
|
lignes.map(({ base, verrou }) => [base, verrou]),
|
||||||
|
[
|
||||||
|
['soiree-a', { seance: null, depuis: null }],
|
||||||
|
['petite', null],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un journal dont une ligne n'est pas de l'UTF-8 : la dernière modification est celle de la dernière entrée lue avant elle", async () => {
|
||||||
|
const fs = creerFichiersSimules();
|
||||||
|
const { facade } = monter({ fs });
|
||||||
|
await facade.demarrer();
|
||||||
|
const base = await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
|
const journal = UTF8.encode(fs.contenu('documents', base + SUFFIXES.journal));
|
||||||
|
fs.deposerOctets('documents', base + SUFFIXES.journal, new Uint8Array([...journal, 0xff, 0xfe, 0x0a]));
|
||||||
|
const avant = ecritures(fs);
|
||||||
|
const [ligne] = await facade.lister();
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
assert.equal(ligne.derniereModification, '2031-05-17T08:00:00-04:00');
|
||||||
|
});
|
||||||
|
|
||||||
test('signalements : état vide, journal seul, doublons ; rien pour les autres fichiers', async () => {
|
test('signalements : état vide, journal seul, doublons ; rien pour les autres fichiers', async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
const { facade } = monter({ fs });
|
const { facade } = monter({ fs });
|
||||||
|
|
@ -642,6 +724,36 @@ describe('lister (§ 8.1, § 19.4)', () => {
|
||||||
assert.equal(par.petite.meilleureDuClassement, null);
|
assert.equal(par.petite.meilleureDuClassement, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un journal en avance d'une entrée : la ligne porte la proposition et le nombre que montre le bloc après ouverture, sans rien écrire", async () => {
|
||||||
|
const montage = await deuxEvenements();
|
||||||
|
const { fs, facade } = montage;
|
||||||
|
// L'état d'une soirée sans proposition ; le journal porte en plus la
|
||||||
|
// génération dont l'état ne s'est pas écrit.
|
||||||
|
const charge = analyser(texteLivre('petite-conflit')).charge;
|
||||||
|
charge.evenement = { ...charge.evenement, id: 'evt-avance', nom: 'Soirée en avance' };
|
||||||
|
const ouvert = await creerDansLeDepot(fs, PORTABLE, charge, {
|
||||||
|
libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }),
|
||||||
|
nomJalon: libelle('JALON_CREATION'),
|
||||||
|
horodatage: DEPART,
|
||||||
|
produitVersion: V1,
|
||||||
|
});
|
||||||
|
const configuration = configurationDepuisCharge(charge);
|
||||||
|
const propositions = rechercher(configuration, { graine: 4, nombre: 2, arret: 300 });
|
||||||
|
const apres = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||||
|
fs.pannes.renommage('portable', 11);
|
||||||
|
const refus = await refusDe(enregistrerDansLeDepot(ouvert, apres, { libelle: 'Génération', horodatage: DEPART, produitVersion: V1 }));
|
||||||
|
assert.equal(refus.code, 'ETAT_NON_ECRIT');
|
||||||
|
assert.equal(analyser(fs.contenu('portable', ouvert.base + SUFFIXES.etat)).charge.propositions.length, 0);
|
||||||
|
const avant = ecritures(fs);
|
||||||
|
const ligne = (await listerResume(facade)).find(({ base }) => base === ouvert.base);
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
await montage.seance.ouvrir(ouvert.base);
|
||||||
|
const bloc = resumeDuMeilleur(montage.seance.charge);
|
||||||
|
assert.notEqual(bloc, null);
|
||||||
|
assert.deepEqual(ligne.meilleureDuClassement, bloc);
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
});
|
||||||
|
|
||||||
test('lister lit chaque état une fois, celle du dépôt ; resumer le relit une fois de plus pour ses deux résumés', async () => {
|
test('lister lit chaque état une fois, celle du dépôt ; resumer le relit une fois de plus pour ses deux résumés', async () => {
|
||||||
const { fs, facade } = await deuxEvenements();
|
const { fs, facade } = await deuxEvenements();
|
||||||
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
|
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
|
||||||
|
|
@ -1068,6 +1180,18 @@ describe('copier (§ 8.7)', () => {
|
||||||
assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null);
|
assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("d'un format plus récent : LECTURE_SEULE relayée avec remede null, rien d'écrit", async () => {
|
||||||
|
const { fs, facade } = monter();
|
||||||
|
await facade.demarrer();
|
||||||
|
const texte = texteEtat({ id: 'evt-lucioles', nom: LUCIOLES }).replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
|
||||||
|
fs.deposer('documents', LUCIOLES + SUFFIXES.etat, texte);
|
||||||
|
const avant = ecritures(fs);
|
||||||
|
const refus = await refusDe(facade.copier(LUCIOLES, { nom: 'Variante des Lucioles' }));
|
||||||
|
assert.equal(refus.code, 'LECTURE_SEULE');
|
||||||
|
assert.deepEqual(refus.details, { raison: 'FORMAT_PLUS_RECENT', remede: null });
|
||||||
|
assert.equal(ecritures(fs), avant);
|
||||||
|
});
|
||||||
|
|
||||||
test('des Documents trop profonds pour le nom : CHEMIN_TROP_LONG, rien d’écrit', async () => {
|
test('des Documents trop profonds pour le nom : CHEMIN_TROP_LONG, rien d’écrit', async () => {
|
||||||
const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) });
|
const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) });
|
||||||
await facade.demarrer();
|
await facade.demarrer();
|
||||||
|
|
@ -1218,6 +1342,39 @@ describe('renommer (§ 8.7, § 8.8)', () => {
|
||||||
assert.equal(montage.fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null);
|
assert.equal(montage.fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("l'événement ouvert, renommé d'un nom que les Documents ne portent pas : refusé, il se rouvre en lecture sous sa base", async () => {
|
||||||
|
const montage = monter({ fs: creerFichiersSimules({ documents: PROFOND }) });
|
||||||
|
const { fs, facade, appels } = montage;
|
||||||
|
await facade.demarrer();
|
||||||
|
fs.deposer('documents', `Court${SUFFIXES.etat}`, texteEtat({ id: 'evt-court', nom: 'Court' }));
|
||||||
|
await montage.seance.ouvrir('Court');
|
||||||
|
appels.length = 0;
|
||||||
|
const refus = await refusDe(facade.renommer('Court', { nom: 'x'.repeat(120) }));
|
||||||
|
assert.equal(refus.code, 'CHEMIN_TROP_LONG');
|
||||||
|
assert.deepEqual(appels, [['fermer'], ['ouvrir', 'Court']]);
|
||||||
|
assert.deepEqual([montage.seance.ouvert.base, montage.seance.mode], ['Court', 'lecture']);
|
||||||
|
assert.equal(fs.contenu('documents', `Court${SUFFIXES.verrou}`), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bloqué par une autre séance entre le contrôle et le verrou : la relecture sous le verrou le refuse, et le verrou se rend', async () => {
|
||||||
|
const { fs, facade } = monter();
|
||||||
|
await facade.demarrer();
|
||||||
|
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
|
const bloque = texteEtat({ id: 'evt-lucioles', nom: LUCIOLES }).replace('"etat":"brouillon"', '"etat":"bloque"');
|
||||||
|
assert.notEqual(bloque, texteEtat({ id: 'evt-lucioles', nom: LUCIOLES }));
|
||||||
|
const verrouiller = fs.verrouiller;
|
||||||
|
fs.verrouiller = async (...parametres) => {
|
||||||
|
fs.deposer('documents', LUCIOLES + SUFFIXES.etat, bloque);
|
||||||
|
return verrouiller(...parametres);
|
||||||
|
};
|
||||||
|
const refus = await refusDe(facade.renommer(LUCIOLES, { nom: CERFS }));
|
||||||
|
assert.equal(refus.code, 'LECTURE_SEULE');
|
||||||
|
assert.deepEqual(refus.details, { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
|
||||||
|
assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null);
|
||||||
|
assert.equal(fs.contenu('documents', CERFS + SUFFIXES.etat), null);
|
||||||
|
assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.etat), bloque);
|
||||||
|
});
|
||||||
|
|
||||||
test('le verrou d’une séance vivante : VERROU_PRIS, rien ne bouge ; celui d’une séance morte s’efface', async () => {
|
test('le verrou d’une séance vivante : VERROU_PRIS, rien ne bouge ; celui d’une séance morte s’efface', async () => {
|
||||||
const disque = new Map();
|
const disque = new Map();
|
||||||
const fs = creerFichiersSimules({ disque });
|
const fs = creerFichiersSimules({ disque });
|
||||||
|
|
@ -1225,7 +1382,7 @@ describe('renommer (§ 8.7, § 8.8)', () => {
|
||||||
await facade.demarrer();
|
await facade.demarrer();
|
||||||
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
const voisin = creerFichiersSimules({ disque });
|
const voisin = creerFichiersSimules({ disque });
|
||||||
await voisin.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-voisine');
|
await voisin.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-voisine', '2031-05-17T09:30:00-04:00');
|
||||||
const avant = await photographier(fs, DOC);
|
const avant = await photographier(fs, DOC);
|
||||||
assert.equal(Object.keys(avant).length, 3);
|
assert.equal(Object.keys(avant).length, 3);
|
||||||
const ecrituresAvant = ecritures(fs);
|
const ecrituresAvant = ecritures(fs);
|
||||||
|
|
@ -1286,7 +1443,7 @@ describe('supprimer (§ 8.7, § 8.8)', () => {
|
||||||
await facade.demarrer();
|
await facade.demarrer();
|
||||||
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
const mort = creerFichiersSimules({ disque });
|
const mort = creerFichiersSimules({ disque });
|
||||||
await mort.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-morte');
|
await mort.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-morte', '2031-05-17T09:30:00-04:00');
|
||||||
mort.pannes.couperApres(0);
|
mort.pannes.couperApres(0);
|
||||||
const avant = ecritures(fs);
|
const avant = ecritures(fs);
|
||||||
await facade.supprimer(LUCIOLES);
|
await facade.supprimer(LUCIOLES);
|
||||||
|
|
@ -1329,13 +1486,14 @@ describe('supprimer (§ 8.7, § 8.8)', () => {
|
||||||
await facade.demarrer();
|
await facade.demarrer();
|
||||||
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES });
|
||||||
const autre = creerFichiersSimules({ disque, ...reglages });
|
const autre = creerFichiersSimules({ disque, ...reglages });
|
||||||
await autre.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-autre');
|
await autre.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-autre', '2031-05-17T09:30:00-04:00');
|
||||||
const avant = await photographier(fs, DOC);
|
const avant = await photographier(fs, DOC);
|
||||||
assert.equal(Object.keys(avant).length, 3);
|
assert.equal(Object.keys(avant).length, 3);
|
||||||
const ecrituresAvant = ecritures(fs);
|
const ecrituresAvant = ecritures(fs);
|
||||||
const refus = await refusDe(facade.supprimer(LUCIOLES));
|
const refus = await refusDe(facade.supprimer(LUCIOLES));
|
||||||
assert.equal(refus.code, 'VERROU_PRIS');
|
assert.equal(refus.code, 'VERROU_PRIS');
|
||||||
assert.equal(refus.details.vivant, vivant);
|
assert.equal(refus.details.vivant, vivant);
|
||||||
|
assert.equal(refus.details.remede, null);
|
||||||
// La seule sonde, refusée.
|
// La seule sonde, refusée.
|
||||||
assert.equal(ecritures(fs), ecrituresAvant + 1);
|
assert.equal(ecritures(fs), ecrituresAvant + 1);
|
||||||
assert.deepEqual(await photographier(fs, DOC), avant);
|
assert.deepEqual(await photographier(fs, DOC), avant);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
|
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
|
||||||
// aucune lettre, n'y passe pas.
|
// aucune lettre, n'y passe pas.
|
||||||
|
|
||||||
|
import { estHorodatage } from '../stockage/journal.js';
|
||||||
|
|
||||||
const ESPACE_FINE = '\u{202F}';
|
const ESPACE_FINE = '\u{202F}';
|
||||||
const MOINS = '\u{2212}';
|
const MOINS = '\u{2212}';
|
||||||
|
|
||||||
|
|
@ -30,7 +32,6 @@ const PAS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/;
|
|
||||||
|
|
||||||
// Le nombre de jours d'un mois du calendrier grégorien.
|
// Le nombre de jours d'un mois du calendrier grégorien.
|
||||||
function joursDuMois(annee, mois) {
|
function joursDuMois(annee, mois) {
|
||||||
|
|
@ -47,6 +48,31 @@ function lireDate(annee, mois, jour, texte) {
|
||||||
return lue;
|
return lue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La seule lecture d'un horodatage sous src/application : sa forme est celle
|
||||||
|
* d'estHorodatage (src/stockage/journal.js), dont les champs, de largeur
|
||||||
|
* fixe, se lisent par leur position ; la date doit être un jour du
|
||||||
|
* calendrier, l'heure une heure du jour, le décalage au plus ±23:59.
|
||||||
|
*
|
||||||
|
* @param {unknown} texte AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||||
|
* @returns {{annee: number, mois: number, jour: number, heure: number, minute: number,
|
||||||
|
* seconde: number, decalage: number}} decalage en minutes à l'est de UTC
|
||||||
|
* @throws {RangeError} une autre forme, un jour hors du calendrier, une heure
|
||||||
|
* ou un décalage hors de leurs bornes
|
||||||
|
*/
|
||||||
|
export function lireHorodatage(texte) {
|
||||||
|
if (!estHorodatage(texte)) {
|
||||||
|
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
|
||||||
|
}
|
||||||
|
const champ = (debut, fin) => Number(texte.slice(debut, fin));
|
||||||
|
const [heure, minute, seconde, decalageH, decalageM] = [champ(11, 13), champ(14, 16), champ(17, 19), champ(20, 22), champ(23, 25)];
|
||||||
|
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
|
||||||
|
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
|
||||||
|
}
|
||||||
|
const decalage = (texte[19] === '-' ? -1 : 1) * (decalageH * 60 + decalageM);
|
||||||
|
return { ...lireDate(texte.slice(0, 4), texte.slice(5, 7), texte.slice(8, 10), texte), heure, minute, seconde, decalage };
|
||||||
|
}
|
||||||
|
|
||||||
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
|
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
|
||||||
function exigerFini(valeur, quoi) {
|
function exigerFini(valeur, quoi) {
|
||||||
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
|
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
|
||||||
|
|
@ -134,15 +160,8 @@ export function creerFormat(t) {
|
||||||
* qu'il porte : la date, puis l'heure et la minute.
|
* qu'il porte : la date, puis l'heure et la minute.
|
||||||
*/
|
*/
|
||||||
function horodatage(texte) {
|
function horodatage(texte) {
|
||||||
const morceaux = HORODATAGE.exec(String(texte));
|
const { annee, mois, jour, heure, minute } = lireHorodatage(texte);
|
||||||
if (morceaux === null) {
|
return t('format.horodatage', { annee, mois, jour, heure, minute });
|
||||||
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
|
|
||||||
}
|
|
||||||
const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number);
|
|
||||||
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
|
|
||||||
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
|
|
||||||
}
|
|
||||||
return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nombre, duree, ordreDeGrandeur, date, horodatage };
|
return { nombre, duree, ordreDeGrandeur, date, horodatage };
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@
|
||||||
// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ;
|
// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ;
|
||||||
// en langue témoin, un texte qui porte une lettre est encadré de la marque.
|
// en langue témoin, un texte qui porte une lettre est encadré de la marque.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { creerFormat } from './format.js';
|
import { creerFormat, lireHorodatage } from './format.js';
|
||||||
|
import { dater } from './libelles/aides.js';
|
||||||
import { TABLES_APPLICATION } from './libelles.js';
|
import { TABLES_APPLICATION } from './libelles.js';
|
||||||
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
|
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
|
||||||
|
|
||||||
|
|
@ -159,6 +163,45 @@ describe('format : les dates et les horodatages (§ 18.4)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('un décalage hors de ses bornes, +24:00 ou −04:60, lève ; +23:59 et −12:00 se lisent', () => {
|
||||||
|
for (const horodatage of ['2026-10-06T19:02:11+24:00', '2026-10-06T19:02:11-04:60', '2026-10-06T19:02:61-04:00']) {
|
||||||
|
assert.throws(() => format.horodatage(horodatage), RangeError, horodatage);
|
||||||
|
assert.throws(() => lireHorodatage(horodatage), RangeError, horodatage);
|
||||||
|
}
|
||||||
|
assert.equal(format.horodatage('2026-10-06T19:02:11+23:59'), '6 octobre 2026, 19 h 02');
|
||||||
|
assert.deepEqual(lireHorodatage('2026-10-06T19:02:11-12:00'), {
|
||||||
|
annee: 2026,
|
||||||
|
mois: 10,
|
||||||
|
jour: 6,
|
||||||
|
heure: 19,
|
||||||
|
minute: 2,
|
||||||
|
seconde: 11,
|
||||||
|
decalage: -720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dater lit l'horodatage par lireHorodatage : ce qu'elle refuse se cite tel quel", () => {
|
||||||
|
const texte = francais.t;
|
||||||
|
assert.equal(dater('2031-05-17T08:05:00-04:00', texte), `le ${texte('format.date', { annee: 2031, mois: 5, jour: 17 })} à ${texte('format.heure', { heure: 8, minute: 5 })}`);
|
||||||
|
for (const fautif of ['2031-05-17T08:05:00+24:00', '2031-02-30T08:05:00-04:00', '2031-05-17T25:05:00-04:00', '2031-05-17 08:05', null]) {
|
||||||
|
assert.throws(() => lireHorodatage(fautif), RangeError, String(fautif));
|
||||||
|
assert.equal(dater(fautif, texte), String(fautif), String(fautif));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lireHorodatage est la seule lecture d'un horodatage sous src/application : aucun autre module n'en porte l'expression", () => {
|
||||||
|
const racine = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const sources = readdirSync(racine, { recursive: true })
|
||||||
|
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js'))
|
||||||
|
.sort();
|
||||||
|
assert.ok(sources.length > 0, 'balayage vide');
|
||||||
|
// Une expression d'horodatage : deux chiffres, deux-points, deux
|
||||||
|
// chiffres, écrits en classes de chiffres.
|
||||||
|
const EXPRESSION = /\\d\{2\}\)?:\(?\\d\{2\}/;
|
||||||
|
const porteurs = sources.filter((nom) => EXPRESSION.test(readFileSync(join(racine, nom), 'utf8')));
|
||||||
|
assert.deepEqual(porteurs, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => {
|
test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => {
|
||||||
assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`);
|
assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`);
|
||||||
assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`);
|
assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
* @property {(id: unknown) => void} annuler
|
* @property {(id: unknown) => void} annuler
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { lireHorodatage } from './format.js';
|
||||||
|
|
||||||
const deux = (n) => String(n).padStart(2, '0');
|
const deux = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
// Un décalage en minutes à l'est de UTC, écrit ±HH:MM.
|
// Un décalage en minutes à l'est de UTC, écrit ±HH:MM.
|
||||||
|
|
@ -74,7 +76,6 @@ export function creerHorlogeSysteme() {
|
||||||
// l'horloge du système. Les jours se comptent depuis cette origine.
|
// l'horloge du système. Les jours se comptent depuis cette origine.
|
||||||
|
|
||||||
const MS_PAR_JOUR = 86_400_000;
|
const MS_PAR_JOUR = 86_400_000;
|
||||||
const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
|
|
||||||
|
|
||||||
// Le nombre de jours depuis 1970-01-01 d'une date civile.
|
// Le nombre de jours depuis 1970-01-01 d'une date civile.
|
||||||
function joursDepuisOrigine(annee, mois, jour) {
|
function joursDepuisOrigine(annee, mois, jour) {
|
||||||
|
|
@ -101,19 +102,12 @@ function dateCivile(jours) {
|
||||||
return { annee: anneeDEre + ere * 400 + (mois <= 2 ? 1 : 0), mois, jour };
|
return { annee: anneeDEre + ere * 400 + (mois <= 2 ? 1 : 0), mois, jour };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un horodatage de départ lu : l'instant UTC en millisecondes et le décalage
|
// Un horodatage de départ lu par lireHorodatage (format.js) : l'instant UTC
|
||||||
// en minutes à l'est de UTC. Une date hors du calendrier lève.
|
// en millisecondes et le décalage en minutes à l'est de UTC. Une autre
|
||||||
|
// forme, ou une date hors du calendrier, lève RangeError.
|
||||||
function lireDepart(depart) {
|
function lireDepart(depart) {
|
||||||
const morceaux = FORME.exec(String(depart));
|
const { annee, mois, jour, heure, minute, seconde, decalage } = lireHorodatage(depart);
|
||||||
if (morceaux === null) throw new RangeError(`horloge : départ AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${depart}`);
|
const local = joursDepuisOrigine(annee, mois, jour) * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000;
|
||||||
const [annee, mois, jour, heure, minute, seconde] = morceaux.slice(1, 7).map(Number);
|
|
||||||
const decalage = (morceaux[7] === '-' ? -1 : 1) * (Number(morceaux[8]) * 60 + Number(morceaux[9]));
|
|
||||||
const jours = joursDepuisOrigine(annee, mois, jour);
|
|
||||||
const relue = dateCivile(jours);
|
|
||||||
if (mois < 1 || mois > 12 || relue.mois !== mois || relue.jour !== jour || heure > 23 || minute > 59 || seconde > 59) {
|
|
||||||
throw new RangeError(`horloge : ${depart} n'est pas un instant du calendrier`);
|
|
||||||
}
|
|
||||||
const local = jours * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000;
|
|
||||||
return { utc: local - decalage * 60_000, decalage };
|
return { utc: local - decalage * 60_000, decalage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +119,8 @@ function lireDepart(depart) {
|
||||||
* qui échoit dans la fenêtre part dans le même avancer ; à son retour,
|
* qui échoit dans la fenêtre part dans le même avancer ; à son retour,
|
||||||
* l'horloge a avancé de ms exactement. Il n'attend aucune promesse : l'épreuve
|
* l'horloge a avancé de ms exactement. Il n'attend aucune promesse : l'épreuve
|
||||||
* attend elle-même ce que les rappels lancent. Un rappel qui lève arrête
|
* attend elle-même ce que les rappels lancent. Un rappel qui lève arrête
|
||||||
* avancer à son échéance, et l'erreur remonte.
|
* avancer à son échéance, et l'erreur remonte. Un rappel qui appelle avancer
|
||||||
|
* lève : instant() ne recule jamais.
|
||||||
*
|
*
|
||||||
* horodatage() garde le décalage du départ ; instant() part de 0.
|
* horodatage() garde le décalage du départ ; instant() part de 0.
|
||||||
*
|
*
|
||||||
|
|
@ -174,10 +169,17 @@ export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = {
|
||||||
return choisie;
|
return choisie;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vrai pendant qu'avancer déclenche ses minuteries : un rappel qui
|
||||||
|
// avancerait à son tour ferait reculer instant() au retour.
|
||||||
|
let enCours = false;
|
||||||
|
|
||||||
function avancer(ms) {
|
function avancer(ms) {
|
||||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
||||||
throw new RangeError(`horloge : avancer attend un délai fini et positif, reçu ${String(ms)}`);
|
throw new RangeError(`horloge : avancer attend un délai fini et positif, reçu ${String(ms)}`);
|
||||||
}
|
}
|
||||||
|
if (enCours) throw new Error('horloge : avancer ré-entrant, appelé depuis un rappel');
|
||||||
|
enCours = true;
|
||||||
|
try {
|
||||||
const fin = ecoule + ms;
|
const fin = ecoule + ms;
|
||||||
for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) {
|
for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) {
|
||||||
const { fn } = enAttente.get(suivante.id);
|
const { fn } = enAttente.get(suivante.id);
|
||||||
|
|
@ -186,6 +188,9 @@ export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
ecoule = fin;
|
ecoule = fin;
|
||||||
|
} finally {
|
||||||
|
enCours = false;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
// aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures
|
// aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures
|
||||||
// de chaque horloge répondent détachées de leur objet.
|
// de chaque horloge répondent détachées de leur objet.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { creerHorlogePilotee, creerHorlogeSysteme } from './horloge.js';
|
import { creerHorlogePilotee, creerHorlogeSysteme } from './horloge.js';
|
||||||
|
|
||||||
|
|
@ -125,6 +126,20 @@ describe('horloge pilotée : les minuteries (§ 19.4)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("avancer ré-entrant, depuis un rappel, lève : instant() ne recule jamais, et l'horloge reste utilisable", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const lus = [];
|
||||||
|
horloge.planifier(() => {
|
||||||
|
lus.push(horloge.instant());
|
||||||
|
horloge.avancer(10 * MINUTE);
|
||||||
|
}, MINUTE);
|
||||||
|
assert.throws(() => horloge.avancer(2 * MINUTE), { name: 'Error', message: /ré-entrant/ });
|
||||||
|
assert.deepEqual(lus, [MINUTE]);
|
||||||
|
assert.equal(horloge.instant(), MINUTE);
|
||||||
|
horloge.avancer(MINUTE);
|
||||||
|
assert.equal(horloge.instant(), 2 * MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
test('planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas', () => {
|
test('planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas', () => {
|
||||||
const horloge = creerHorlogePilotee();
|
const horloge = creerHorlogePilotee();
|
||||||
assert.throws(() => horloge.planifier(() => {}, -5), RangeError);
|
assert.throws(() => horloge.planifier(() => {}, -5), RangeError);
|
||||||
|
|
@ -160,6 +175,56 @@ describe('horloge système (§ 19.4, § 14.14)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('horloge système : ses minuteries passent par setTimeout et clearTimeout', () => {
|
||||||
|
// Remplace un temporisateur global par un espion qui le consigne et lui
|
||||||
|
// délègue, le temps de corps ; le rend ensuite, quoi qu'il arrive.
|
||||||
|
function espionner(nom, corps) {
|
||||||
|
const original = globalThis[nom];
|
||||||
|
const appels = [];
|
||||||
|
globalThis[nom] = (...parametres) => {
|
||||||
|
appels.push(parametres);
|
||||||
|
return original(...parametres);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
corps(appels);
|
||||||
|
} finally {
|
||||||
|
globalThis[nom] = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("annuler rend l'identifiant à clearTimeout, une fois", () => {
|
||||||
|
const horloge = creerHorlogeSysteme();
|
||||||
|
const id = horloge.planifier(() => {
|
||||||
|
throw new Error('une minuterie annulée est partie');
|
||||||
|
}, 60 * MINUTE);
|
||||||
|
espionner('clearTimeout', (appels) => {
|
||||||
|
horloge.annuler(id);
|
||||||
|
assert.deepEqual(appels, [[id]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas, sans armer setTimeout", () => {
|
||||||
|
const horloge = creerHorlogeSysteme();
|
||||||
|
espionner('setTimeout', (appels) => {
|
||||||
|
assert.throws(() => horloge.planifier(() => {}, -1), RangeError);
|
||||||
|
assert.throws(() => horloge.planifier(() => {}, Number.POSITIVE_INFINITY), RangeError);
|
||||||
|
assert.throws(() => horloge.planifier('rappel', 5), TypeError);
|
||||||
|
assert.deepEqual(appels, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le décalage local se lit du fuseau du processus : Etc/GMT+4 donne −04:00, UTC +00:00", () => {
|
||||||
|
const module = new URL('./horloge.js', import.meta.url).href;
|
||||||
|
const code = `const { creerHorlogeSysteme } = await import(${JSON.stringify(module)}); process.stdout.write(creerHorlogeSysteme().horodatage());`;
|
||||||
|
for (const [fuseau, fin] of [['Etc/GMT+4', '-04:00'], ['UTC', '+00:00'], ['Etc/GMT-5', '+05:00']]) {
|
||||||
|
const lu = spawnSync(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, TZ: fuseau }, encoding: 'utf8' });
|
||||||
|
assert.equal(lu.status, 0, lu.stderr);
|
||||||
|
assert.match(lu.stdout, FORME, fuseau);
|
||||||
|
assert.ok(lu.stdout.endsWith(fin), `${fuseau} : ${lu.stdout}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('horloges : quatre fermetures appelables détachées', () => {
|
describe('horloges : quatre fermetures appelables détachées', () => {
|
||||||
test('la pilotée répond détachée comme sur son objet ; une minuterie annulée détachée ne part pas', () => {
|
test('la pilotée répond détachée comme sur son objet ; une minuterie annulée détachée ne part pas', () => {
|
||||||
const horloge = creerHorlogePilotee();
|
const horloge = creerHorlogePilotee();
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,7 @@ const EXEMPLES = new Map([
|
||||||
['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }],
|
['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }],
|
||||||
['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }],
|
['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }],
|
||||||
['AU_DELA_LISIBLE', { seuil: 16 }],
|
['AU_DELA_LISIBLE', { seuil: 16 }],
|
||||||
|
['LISTE_SUR_VOISINE', { table: 4, voisine: 7, numero: 3, numeroVoisine: 5, remede: { commande: 'deplacerTable', arguments: { id: 7, position: { x: 1626, y: 0 } } } }],
|
||||||
['ENTREE_CREER_TABLE', { numero: 3 }],
|
['ENTREE_CREER_TABLE', { numero: 3 }],
|
||||||
['ENTREE_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }],
|
['ENTREE_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }],
|
||||||
['ENTREE_DEPLACER_TABLE', { numero: 3 }],
|
['ENTREE_DEPLACER_TABLE', { numero: 3 }],
|
||||||
|
|
@ -568,6 +569,18 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("RESERVATION_TOUS_LES_TOURS ne nomme aucun geste : le même refus suit un dépôt sur le sol et « Retirer du tour », où rien ne se dépose", () => {
|
||||||
|
const texte = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
|
||||||
|
assert.doesNotMatch(texte, /d[ée]pos/i);
|
||||||
|
assert.match(texte, /tous les tours/);
|
||||||
|
assert.match(texte, /« Retirer la réservation »/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CONVERSION_IMPOSSIBLE nomme chacune de ses raisons : des tours non couverts, une réservation « tous les tours » déjà là, une personne exclue', () => {
|
||||||
|
const texte = libelle('CONVERSION_IMPOSSIBLE', { participant: 1, table: 3, remede: null });
|
||||||
|
for (const motif of [/chaque tour/, /« tous les tours »/, /exclue/]) assert.match(texte, motif);
|
||||||
|
});
|
||||||
|
|
||||||
test('la table est triée, sans doublon, et figée', () => {
|
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.deepEqual([...CODES], [...new Set(CODES)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||||
assert.ok(Object.isFrozen(CODES));
|
assert.ok(Object.isFrozen(CODES));
|
||||||
|
|
@ -831,7 +844,7 @@ describe('libellés : les tables par domaine (§ 14.6)', () => {
|
||||||
assert.ok(comptees >= CODES.length, `${comptees} clés`);
|
assert.ok(comptees >= CODES.length, `${comptees} clés`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chaque texte d'A1 est dans la table du domaine qui l'émet", async () => {
|
test("chaque texte est dans la table du domaine qui l'émet", async () => {
|
||||||
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
|
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
|
||||||
const ATTENDUS = [
|
const ATTENDUS = [
|
||||||
['stockage', ['ABSENT', 'VERROU_PRIS', 'LECTURE_SEULE', 'DERIVE', 'RELECTURE_DIFFERENTE', 'COMPTEUR_SATURE']],
|
['stockage', ['ABSENT', 'VERROU_PRIS', 'LECTURE_SEULE', 'DERIVE', 'RELECTURE_DIFFERENTE', 'COMPTEUR_SATURE']],
|
||||||
|
|
@ -856,7 +869,7 @@ describe('libellés : les tables par domaine (§ 14.6)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Le moteur et la génération (A6) -------------------------------------------
|
// --- Le moteur et la génération ----------------------------------------------
|
||||||
|
|
||||||
describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7)', () => {
|
describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7)', () => {
|
||||||
test('le relevé du moteur porte des codes, dans ses quatre modules à codes, et rien des valeurs de domine', () => {
|
test('le relevé du moteur porte des codes, dans ses quatre modules à codes, et rien des valeurs de domine', () => {
|
||||||
|
|
@ -876,9 +889,10 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
|
||||||
const ailleurs = new Set([...releve.tables, ...ARBRES.flatMap((arbre) => [...releve.parArbre.get(arbre)])]);
|
const ailleurs = new Set([...releve.tables, ...ARBRES.flatMap((arbre) => [...releve.parArbre.get(arbre)])]);
|
||||||
const { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.js');
|
const { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.js');
|
||||||
const { TABLE: STOCKAGE_TABLE } = await import('./libelles/stockage.js');
|
const { TABLE: STOCKAGE_TABLE } = await import('./libelles/stockage.js');
|
||||||
// INCONNU est déjà la clé de la table d'A1, une faute de placement du
|
// INCONNU est déjà une clé de la table du stockage, une faute de
|
||||||
// stockage aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION sont
|
// placement aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION,
|
||||||
// dans libelles/moteur.js depuis la séance (A2), et ne s'y redéfinissent pas.
|
// que la séance émet aussi, sont dans libelles/moteur.js, et ne s'y
|
||||||
|
// redéfinissent pas.
|
||||||
const EXCEPTIONS = new Map([['INCONNU', STOCKAGE_TABLE]]);
|
const EXCEPTIONS = new Map([['INCONNU', STOCKAGE_TABLE]]);
|
||||||
let comptes = 0;
|
let comptes = 0;
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//
|
//
|
||||||
// Les aides ne lisent ni l'horloge ni la langue du système : un horodatage se
|
// Les aides 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.
|
// met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
|
||||||
|
import { lireHorodatage } from '../format.js';
|
||||||
|
|
||||||
const NOMS_DES_ETATS = new Map([
|
const NOMS_DES_ETATS = new Map([
|
||||||
['brouillon', 'Brouillon'],
|
['brouillon', 'Brouillon'],
|
||||||
|
|
@ -80,20 +81,21 @@ export function phraseDeRaison(raison, details, texte) {
|
||||||
/** Des numéros de proposition, un illisible nommé comme tel. */
|
/** Des numéros de proposition, un illisible nommé comme tel. */
|
||||||
export const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
|
export const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
|
||||||
|
|
||||||
// La forme d'un horodatage : date, heure et minute se lisent, les secondes et
|
|
||||||
// le décalage se taisent.
|
|
||||||
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à
|
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à
|
||||||
* 8 h 05 », à l'heure qu'il porte, par les textes de la mise en forme
|
* 8 h 05 », à l'heure qu'il porte, par les textes de la mise en forme
|
||||||
* (format.date, format.heure) ; une autre forme se cite telle quelle.
|
* (format.date, format.heure) : date, heure et minute se lisent, les
|
||||||
|
* secondes et le décalage se taisent. Il se lit par lireHorodatage
|
||||||
|
* (format.js) ; ce qu'elle refuse se cite tel quel.
|
||||||
*/
|
*/
|
||||||
export function dater(horodatage, texte) {
|
export function dater(horodatage, texte) {
|
||||||
const morceaux = HORODATAGE.exec(String(horodatage));
|
let lu;
|
||||||
if (morceaux === null) return String(horodatage);
|
try {
|
||||||
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
|
lu = lireHorodatage(horodatage);
|
||||||
if (mois < 1 || mois > 12) return String(horodatage);
|
} catch {
|
||||||
|
return String(horodatage);
|
||||||
|
}
|
||||||
|
const { annee, mois, jour, heure, minute } = lu;
|
||||||
return `le ${texte('format.date', { annee, mois, jour })} à ${texte('format.heure', { heure, minute })}`;
|
return `le ${texte('format.date', { annee, mois, jour })} à ${texte('format.heure', { heure, minute })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,12 @@ export const TABLE = new Map([
|
||||||
'AU_DELA_LISIBLE',
|
'AU_DELA_LISIBLE',
|
||||||
({ seuil }) => `Au-delà de ${seuil} places, la liste des noms de la table cesse d'être lisible sur le plan.`,
|
({ seuil }) => `Au-delà de ${seuil} places, la liste des noms de la table cesse d'être lisible sur le plan.`,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'LISTE_SUR_VOISINE',
|
||||||
|
({ numero, numeroVoisine }) =>
|
||||||
|
`Agrandie, la table ${numero} et la table ${numeroVoisine} se chevauchent sur le plan : ` +
|
||||||
|
`une liste de noms passe sur l'autre table. Déplacer la table ${numeroVoisine} la pose sur une case libre.`,
|
||||||
|
],
|
||||||
|
|
||||||
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
|
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
|
||||||
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
|
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ export const TABLE = new Map([
|
||||||
['RESERVATION_ABSENTE', () => "Cette réservation n'existe pas."],
|
['RESERVATION_ABSENTE', () => "Cette réservation n'existe pas."],
|
||||||
[
|
[
|
||||||
'CONVERSION_IMPOSSIBLE',
|
'CONVERSION_IMPOSSIBLE',
|
||||||
() => "Ces réservations ne couvrent pas chaque tour à cette table : il n'y a rien à convertir.",
|
() =>
|
||||||
|
"Il n'y a rien à convertir : ces réservations ne couvrent pas chaque tour à cette table, " +
|
||||||
|
"une réservation « tous les tours » l'y tient déjà, ou la personne est exclue.",
|
||||||
],
|
],
|
||||||
|
|
||||||
// --- Le retenu (§ 5.8, § 7.1) ----------------------------------------------
|
// --- Le retenu (§ 5.8, § 7.1) ----------------------------------------------
|
||||||
|
|
@ -77,7 +79,7 @@ export const TABLE = new Map([
|
||||||
[
|
[
|
||||||
'RESERVATION_TOUS_LES_TOURS',
|
'RESERVATION_TOUS_LES_TOURS',
|
||||||
() =>
|
() =>
|
||||||
'Cette personne est réservée ici pour tous les tours : la déposer hors des tables ne retire pas sa réservation. ' +
|
'Cette personne est réservée ici pour tous les tours : la retirer de cette table pour un seul tour ne retire pas sa réservation. ' +
|
||||||
'« Retirer la réservation » la retire de tous les tours.',
|
'« Retirer la réservation » la retire de tous les tours.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@ function planEvalue(charge, instance, placement) {
|
||||||
* sans siège, ou dont le siège est pris : elle se lit comme une réservation
|
* sans siège, ou dont le siège est pris : elle se lit comme une réservation
|
||||||
* sans siège.
|
* sans siège.
|
||||||
*
|
*
|
||||||
* @param {Object[]} couvrantes les réservations actives qui couvrent le tour à cette table, ordre canonique
|
* @param {Object[]} couvrantes les réservations actives qui couvrent le tour à
|
||||||
|
* cette table, ordre canonique
|
||||||
* @param {number} places la capacité courante de la table
|
* @param {number} places la capacité courante de la table
|
||||||
* @returns {{tenu: Map<number, Object>, siegeDe: Map<number, number>, sansSiege: Map<number, Object>}}
|
* @returns {{tenu: Map<number, Object>, siegeDe: Map<number, number>, sansSiege: Map<number, Object>}}
|
||||||
*/
|
*/
|
||||||
|
|
@ -172,10 +173,12 @@ export function placesTenues(couvrantes, places) {
|
||||||
|
|
||||||
// Le remplissage et les marques d'une chaise (§ 7.6) : une réservation
|
// Le remplissage et les marques d'une chaise (§ 7.6) : une réservation
|
||||||
// porte le cadenas fermé, l'anneau en plus pour la portée « tous les
|
// porte le cadenas fermé, l'anneau en plus pour la portée « tous les
|
||||||
// tours » ; la chaise est pleine quand le placement la tient, hachurée — à
|
// tours » ; la chaise est pleine quand le placement la tient, hachurée quand
|
||||||
// pourvoir — quand il la laisse vide ou y assied une autre personne. Le
|
// il la laisse vide ou y assied une autre personne — la réservation n'y est
|
||||||
// cadenas ouvert reste au titre seul, hachuré quand rien ne le pourvoit à ce
|
// pas tenue. Cette hachure ne dit rien du titre de la chaise, que la chaise
|
||||||
// tour : la forme distingue toujours une réservation d'un titre.
|
// porte à part, pourvu ou non selon pourvoyeursDesTitres (§ 4.1). Le cadenas
|
||||||
|
// ouvert reste au titre seul, hachuré quand rien ne le pourvoit à ce tour :
|
||||||
|
// la forme distingue toujours une réservation d'un titre.
|
||||||
function aspect(reservation, occupant, titre) {
|
function aspect(reservation, occupant, titre) {
|
||||||
if (reservation !== null) {
|
if (reservation !== null) {
|
||||||
const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
|
const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
|
||||||
|
|
@ -191,19 +194,21 @@ function aspect(reservation, occupant, titre) {
|
||||||
* — la proposition demandée, le retenu, ou les seules réservations —, ce
|
* — la proposition demandée, le retenu, ou les seules réservations —, ce
|
||||||
* qu'un geste de personne y modifie, chaque table de la charge dans son
|
* qu'un geste de personne y modifie, chaque table de la charge dans son
|
||||||
* ordre avec ses chaises, et la réserve. Sièges attribués, la chaise s porte
|
* ordre avec ses chaises, et la réserve. Sièges attribués, la chaise s porte
|
||||||
* le rang s de la liste de la source ; sinon, et sous les seules
|
* le rang s de la liste de la source, et la source se lit telle quelle : la
|
||||||
* réservations, repartirSieges répartit les occupants de la table, chacun à
|
* réservation qui tient un siège que la liste donne à une autre personne,
|
||||||
* son siège réservé, une personne réservée sans siège qui pourvoit un titre
|
* ou laisse vide, se lit sur la chaise sans la remplir — hachure et cadenas
|
||||||
* au siège de ce titre ; quand la liste dépasse la capacité, les personnes
|
* fermé —, et sa personne est là où la liste l'assied. Sans attribution,
|
||||||
* réservées à la table passent avant les autres. Sièges attribués, la
|
* repartirSieges répartit les occupants de la liste, chacun à son siège
|
||||||
* source se lit telle quelle : la réservation qui tient un siège que la
|
* réservé, une personne réservée sans siège qui pourvoit un titre au siège
|
||||||
* liste donne à une autre personne, ou laisse vide, se lit sur la chaise
|
* de ce titre ; quand la liste dépasse la capacité, les personnes réservées
|
||||||
* sans la remplir — hachure et cadenas fermé —, et sa personne est là où
|
* à la table passent avant les autres. Sous les seules réservations, les
|
||||||
* la liste l'assied. Un exclu, une personne que la charge n'a plus,
|
* personnes que les réservations du tour tiennent à la table s'y répartissent
|
||||||
* n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne
|
* de même. Un exclu, une personne que la charge n'a plus, n'occupe rien et
|
||||||
* reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au
|
* n'attend pas ; une personne déjà assise à une table précédente de la
|
||||||
* tour, null quand la source est en dérive ou que la configuration ne se
|
* charge ne s'assied pas une seconde fois ; une personne présente qu'aucune
|
||||||
* normalise pas.
|
* chaise ne reçoit attend en réserve. Les conflits sont ceux de
|
||||||
|
* conflitsParTable au tour, null quand la source est en dérive ou que la
|
||||||
|
* configuration ne se normalise pas ; evalue le dit.
|
||||||
*
|
*
|
||||||
* Ne lève sur aucune charge que l'analyse admet, pour tout tour de 1 à R.
|
* Ne lève sur aucune charge que l'analyse admet, pour tout tour de 1 à R.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,7 @@ describe('etatPlaces : la réservation au siège que le placement ne tient pas (
|
||||||
return charge;
|
return charge;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("la chaise réservée à 2, qu'occupe 3 : la réservation se lit, mais ne la tient pas — hachure et cadenas fermé, l'anneau de sa portée ; la chaise de 2 reste sans marque", () => {
|
test("la chaise réservée à 2, qu'occupe 3 : la réservation se lit, mais ne la tient pas — hachure et cadenas fermé, l'anneau de sa portée, le titre pourvu ; la chaise de 2 reste sans marque", () => {
|
||||||
const charge = auxSieges();
|
const charge = auxSieges();
|
||||||
charge.reservations.push(resa(2, 1, 3));
|
charge.reservations.push(resa(2, 1, 3));
|
||||||
charge.titres.push({ table: 1, siege: 3, libelle: 'Animation' });
|
charge.titres.push({ table: 1, siege: 3, libelle: 'Animation' });
|
||||||
|
|
@ -483,6 +483,9 @@ describe('etatPlaces : la réservation au siège que le placement ne tient pas (
|
||||||
assert.equal(trois.occupant, 3);
|
assert.equal(trois.occupant, 3);
|
||||||
assert.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null });
|
assert.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null });
|
||||||
assert.deepEqual([trois.remplissage, trois.marques], ['hachure', ['cadenasFerme', 'anneau']]);
|
assert.deepEqual([trois.remplissage, trois.marques], ['hachure', ['cadenasFerme', 'anneau']]);
|
||||||
|
// La hachure dit la réservation non tenue, non le titre : 3, assis au
|
||||||
|
// siège titré, le pourvoit (§ 4.1).
|
||||||
|
assert.deepEqual(trois.titre, { libelle: 'Animation', pourvu: true });
|
||||||
assert.deepEqual([deux.occupant, deux.reservation, deux.remplissage, deux.marques], [2, null, 'normal', []]);
|
assert.deepEqual([deux.occupant, deux.reservation, deux.remplissage, deux.marques], [2, null, 'normal', []]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ export function creerSeance({
|
||||||
cadrage: null,
|
cadrage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- L'observation (A2) -----------------------------------------------------
|
// --- L'observation ------------------------------------------------------------
|
||||||
|
|
||||||
// Les abonnés de subscribe, ceux de surAnnulation, et l'instantané rendu
|
// Les abonnés de subscribe, ceux de surAnnulation, et l'instantané rendu
|
||||||
// depuis le dernier changement, null quand il est à refaire.
|
// depuis le dernier changement, null quand il est à refaire.
|
||||||
|
|
@ -361,7 +361,7 @@ export function creerSeance({
|
||||||
|
|
||||||
// Le cadrage mémorisé de l'événement ouvert, relu dans les réglages de la
|
// Le cadrage mémorisé de l'événement ouvert, relu dans les réglages de la
|
||||||
// racine ; null quand ils n'en portent pas, et pour des réglages que la
|
// racine ; null quand ils n'en portent pas, et pour des réglages que la
|
||||||
// lecture ne prend pas — la façade annonce REGLAGES_ILLISIBLES (A3).
|
// lecture ne prend pas — la façade annonce REGLAGES_ILLISIBLES.
|
||||||
async function cadrageMemorise(ouvert) {
|
async function cadrageMemorise(ouvert) {
|
||||||
try {
|
try {
|
||||||
const { reglages } = await lireReglages(fs, ouvert.racine);
|
const { reglages } = await lireReglages(fs, ouvert.racine);
|
||||||
|
|
@ -445,9 +445,16 @@ export function creerSeance({
|
||||||
async function prendreVerrou(ouvert, reprise = null) {
|
async function prendreVerrou(ouvert, reprise = null) {
|
||||||
let verrou;
|
let verrou;
|
||||||
const avertissements = [];
|
const avertissements = [];
|
||||||
|
const depuis = horloge.horodatage();
|
||||||
|
// Un verrou que le fichier porte au nom de cette séance est le sien, à
|
||||||
|
// chaque prise : celle d'après l'effacement le trouve ainsi quand la
|
||||||
|
// relecture d'effacerVerrou l'a posé.
|
||||||
|
const prendre = async () => {
|
||||||
|
const rendu = await verrouiller(ouvert, id, depuis);
|
||||||
|
return !rendu.pris && rendu.seance === id ? { pris: true } : rendu;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
verrou = await verrouiller(ouvert, id);
|
verrou = await prendre();
|
||||||
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
|
|
||||||
const confirme =
|
const confirme =
|
||||||
!verrou.pris &&
|
!verrou.pris &&
|
||||||
reprise !== null &&
|
reprise !== null &&
|
||||||
|
|
@ -456,8 +463,8 @@ export function creerSeance({
|
||||||
verrou.depuis === reprise.depuis;
|
verrou.depuis === reprise.depuis;
|
||||||
if (!verrou.pris && (verrou.vivant === false || confirme)) {
|
if (!verrou.pris && (verrou.vivant === false || confirme)) {
|
||||||
const ancien = verrou;
|
const ancien = verrou;
|
||||||
await effacerVerrou(ouvert, ancien.seance);
|
await effacerVerrou(ouvert, ancien.seance, depuis);
|
||||||
verrou = await verrouiller(ouvert, id);
|
verrou = await prendre();
|
||||||
if (verrou.pris) {
|
if (verrou.pris) {
|
||||||
avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis }));
|
avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis }));
|
||||||
}
|
}
|
||||||
|
|
@ -477,14 +484,21 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Efface le verrou d'une autre séance, que prendreVerrou reprend : celui
|
// Efface le verrou d'une autre séance, que prendreVerrou reprend : celui
|
||||||
// de la séance nommée, par deverrouiller, qui n'efface que lui ; un verrou
|
// de la séance nommée, par deverrouiller, qui n'efface que lui. Un verrou
|
||||||
// illisible (seance null) n'a pas de séance qui le rende, et son fichier
|
// illisible (seance null) n'a pas de séance qui le rende : il se relit
|
||||||
// s'efface.
|
// juste avant l'effacement, par verrouiller, et son fichier ne s'efface que
|
||||||
async function effacerVerrou(ouvert, seance) {
|
// s'il est toujours illisible. Un verrou valide posé depuis par une autre
|
||||||
|
// séance reste, et prendreVerrou le refuse ; un fichier disparu depuis
|
||||||
|
// laisse la prise à cette séance, que la prise suivante reconnaît comme la
|
||||||
|
// sienne. Entre la relecture et l'effacement reste une fenêtre de la durée
|
||||||
|
// d'un appel au système de fichiers.
|
||||||
|
async function effacerVerrou(ouvert, seance, depuis) {
|
||||||
if (seance !== null) {
|
if (seance !== null) {
|
||||||
await deverrouiller(ouvert, seance);
|
await deverrouiller(ouvert, seance);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const relu = await verrouiller(ouvert, id, depuis);
|
||||||
|
if (relu.pris || relu.seance !== null) return;
|
||||||
await ouvert.fs.supprimer(ouvert.racine, ouvert.base + SUFFIXES.verrou);
|
await ouvert.fs.supprimer(ouvert.racine, ouvert.base + SUFFIXES.verrou);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -945,19 +959,19 @@ export function creerSeance({
|
||||||
// Reprendre le verrou (§ 8.8) : le passage en écriture qui reprend le
|
// Reprendre le verrou (§ 8.8) : le passage en écriture qui reprend le
|
||||||
// verrou refusé que verrouAutre garde, une fois que l'opérateur l'a
|
// verrou refusé que verrouAutre garde, une fois que l'opérateur l'a
|
||||||
// confirmé en voyant sa séance et depuis quand (prendreVerrou). Sans
|
// confirmé en voyant sa séance et depuis quand (prendreVerrou). Sans
|
||||||
// verrou refusé, c'est un passage en écriture. Le refus d'un débloquer
|
// verrou refusé, c'est passerEnEcriture, ses refus compris : un plan
|
||||||
// admet la reprise : sur un plan bloqué, ou relu bloqué, le verrou repris
|
// bloqué lève LECTURE_SEULE BLOQUE. Le refus d'un débloquer admet la
|
||||||
// se rend aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit
|
// reprise : sur un plan bloqué, ou relu bloqué, le verrou repris se rend
|
||||||
// parmi les avertissements du dernier geste ; débloquer passe ensuite.
|
// aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit parmi les
|
||||||
|
// avertissements du dernier geste ; débloquer passe ensuite.
|
||||||
async function reprendreVerrou() {
|
async function reprendreVerrou() {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
|
if (etat.verrouAutre === null) return passerEnEcriture();
|
||||||
if (ouvert.formatPlusRecent) throw refusFormat();
|
if (ouvert.formatPlusRecent) throw refusFormat();
|
||||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||||
exigerSupport();
|
exigerSupport();
|
||||||
const reprise = etat.verrouAutre;
|
const { seance, depuis } = etat.verrouAutre;
|
||||||
return entrerSousVerrou(ouvert, reprise === null ? null : { seance: reprise.seance, depuis: reprise.depuis }, {
|
return entrerSousVerrou(ouvert, { seance, depuis }, { bloqueAdmis: true });
|
||||||
bloqueAdmis: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prend le verrou — en reprenant celui que reprise nomme —, relit
|
// Prend le verrou — en reprenant celui que reprise nomme —, relit
|
||||||
|
|
@ -1040,7 +1054,8 @@ export function creerSeance({
|
||||||
// laisse tout en l'état, le support toujours rompu. Réussi, il demande le
|
// laisse tout en l'état, le support toujours rompu. Réussi, il demande le
|
||||||
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
|
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
|
||||||
// annonce la garantie affaiblie d'un support amovible, comme la règle du
|
// annonce la garantie affaiblie d'un support amovible, comme la règle du
|
||||||
// dossier de travail au démarrage (§ 8.8).
|
// dossier de travail au démarrage (§ 8.8). Le cadrage de l'événement se
|
||||||
|
// demande alors pour les réglages de la racine nouvelle (§ 8.5).
|
||||||
//
|
//
|
||||||
// Sans racine, le dossier se demande à l'opérateur par fs.choisirDossier ;
|
// Sans racine, le dossier se demande à l'opérateur par fs.choisirDossier ;
|
||||||
// son annulation ne change rien, et rend null.
|
// son annulation ne change rien, et rend null.
|
||||||
|
|
@ -1076,6 +1091,12 @@ export function creerSeance({
|
||||||
ailleurs: null,
|
ailleurs: null,
|
||||||
formatLu: null,
|
formatLu: null,
|
||||||
});
|
});
|
||||||
|
// Le cadrage de l'événement suit la séance : demandé de nouveau pour la
|
||||||
|
// racine nouvelle, il y remplace celui qui attendait l'ancienne, que son
|
||||||
|
// support refuse.
|
||||||
|
if (etat.cadrage !== null) {
|
||||||
|
ecrivain.demander({ racine: nouvelle, evenement: ailleurs.charge.evenement.id, cadrage: etat.cadrage });
|
||||||
|
}
|
||||||
if (etat.mode === 'ecriture') {
|
if (etat.mode === 'ecriture') {
|
||||||
try {
|
try {
|
||||||
avertissements.push(...(await prendreVerrou(ailleurs)));
|
avertissements.push(...(await prendreVerrou(ailleurs)));
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ async function refus(promesse, code, details) {
|
||||||
return erreur;
|
return erreur;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les refus de lecture seule de la séance, remède compris (A2).
|
// Les refus de lecture seule de la séance, remède compris.
|
||||||
const FORMAT_SEUL = { raison: 'FORMAT', remede: null };
|
const FORMAT_SEUL = { raison: 'FORMAT', remede: null };
|
||||||
const BLOQUE_SEUL = { raison: 'BLOQUE', remede: { geste: 'debloquer' } };
|
const BLOQUE_SEUL = { raison: 'BLOQUE', remede: { geste: 'debloquer' } };
|
||||||
const ENCODAGE_SEUL = { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } };
|
const ENCODAGE_SEUL = { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } };
|
||||||
|
|
@ -371,6 +371,17 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => {
|
||||||
await refus(second.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE');
|
await refus(second.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("le verrou porte l'instant de l'horloge de la séance : VERROU_PRIS le rend à une seconde séance", async () => {
|
||||||
|
const disque = new Map();
|
||||||
|
const pilote = '2031-05-17T19:42:00-04:00';
|
||||||
|
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere', horloge: { horodatage: () => pilote } });
|
||||||
|
await enEcriture(premier);
|
||||||
|
assert.equal(verrouDe(premier.fs).depuis, pilote);
|
||||||
|
const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' });
|
||||||
|
await ouvrirSansEcrire(second);
|
||||||
|
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis: pilote, vivant: true, remede: null });
|
||||||
|
});
|
||||||
|
|
||||||
test("le verrou d'une séance morte du même poste se reprend, et l'avertissement le dit", async () => {
|
test("le verrou d'une séance morte du même poste se reprend, et l'avertissement le dit", async () => {
|
||||||
const disque = new Map();
|
const disque = new Map();
|
||||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||||
|
|
@ -470,6 +481,56 @@ describe('séance : reprendre le verrou que la séance ne reprend pas seule (§
|
||||||
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
|
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un verrou illisible remplacé par un valide entre sa relecture et son effacement : le valide reste, et VERROU_PRIS le nomme", async () => {
|
||||||
|
const disque = new Map();
|
||||||
|
const second = await secondeSeance(disque, 'poste-epreuve');
|
||||||
|
second.fs.deposer('documents', VERROU, 'pas un verrou\n');
|
||||||
|
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null });
|
||||||
|
// Le système de la séance pose un verrou valide juste après avoir rendu
|
||||||
|
// l'illisible à la reprise, comme une autre séance qui le prendrait
|
||||||
|
// entre deux appels.
|
||||||
|
const pose = '2031-05-17T09:15:00-04:00';
|
||||||
|
const valide = verrouLointain('lointaine-6', pose);
|
||||||
|
const verrouiller = second.fs.verrouiller;
|
||||||
|
let substituer = true;
|
||||||
|
second.fs.verrouiller = async (...parametres) => {
|
||||||
|
const rendu = await verrouiller(...parametres);
|
||||||
|
if (substituer && rendu.pris === false && rendu.seance === null) {
|
||||||
|
substituer = false;
|
||||||
|
second.fs.deposer('documents', VERROU, valide);
|
||||||
|
}
|
||||||
|
return rendu;
|
||||||
|
};
|
||||||
|
const details = { seance: 'lointaine-6', depuis: pose, vivant: null };
|
||||||
|
await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null });
|
||||||
|
assert.equal(second.fs.contenu('documents', VERROU), valide);
|
||||||
|
assert.equal(second.seance.mode, 'lecture');
|
||||||
|
assert.deepEqual(second.seance.etat().verrouAutre, details);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un verrou illisible disparu entre le refus et sa relecture : la relecture prend le verrou, la séance le reconnaît comme le sien, VERROU_REPRIS puis écriture', async () => {
|
||||||
|
const disque = new Map();
|
||||||
|
const second = await secondeSeance(disque, 'poste-epreuve');
|
||||||
|
second.fs.deposer('documents', VERROU, 'pas un verrou\n');
|
||||||
|
await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null });
|
||||||
|
// Le fichier disparaît juste avant le deuxième appel de la reprise, la
|
||||||
|
// relecture d'effacerVerrou, qui pose alors le verrou de la séance.
|
||||||
|
const verrouiller = second.fs.verrouiller;
|
||||||
|
let appels = 0;
|
||||||
|
second.fs.verrouiller = async (racine, chemin, ...reste) => {
|
||||||
|
appels += 1;
|
||||||
|
if (appels === 2) await second.fs.supprimer(racine, chemin);
|
||||||
|
return verrouiller(racine, chemin, ...reste);
|
||||||
|
};
|
||||||
|
const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }];
|
||||||
|
assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris });
|
||||||
|
assert.equal(second.seance.mode, 'ecriture');
|
||||||
|
assert.equal(verrouDe(second.fs).seance, 'reprise-1');
|
||||||
|
assert.equal(second.seance.etat().verrouAutre, null);
|
||||||
|
await second.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
|
||||||
|
assert.deepEqual(noms(etatDe(second.fs).charge), ['Ombrelle']);
|
||||||
|
});
|
||||||
|
|
||||||
test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => {
|
test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => {
|
||||||
const disque = new Map();
|
const disque = new Map();
|
||||||
const second = await secondeSeance(disque);
|
const second = await secondeSeance(disque);
|
||||||
|
|
@ -539,6 +600,19 @@ describe('séance : reprendre le verrou que la séance ne reprend pas seule (§
|
||||||
assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']);
|
assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sans verrou refusé, reprendre sur un plan bloqué lève LECTURE_SEULE BLOQUE comme passer en écriture, sans rien écrire', async () => {
|
||||||
|
const montage = monter();
|
||||||
|
const { fs, seance } = montage;
|
||||||
|
await enEcriture(montage);
|
||||||
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
||||||
|
await seance.passerEnLecture();
|
||||||
|
assert.equal(seance.etat().verrouAutre, null);
|
||||||
|
const avant = fs.compteurs.ecritures;
|
||||||
|
await refus(seance.reprendreVerrou(), 'LECTURE_SEULE', BLOQUE_SEUL);
|
||||||
|
assert.equal(fs.compteurs.ecritures, avant);
|
||||||
|
assert.deepEqual([seance.mode, seance.charge.evenement.etat, verrouDe(fs)], ['lecture', 'bloque', null]);
|
||||||
|
});
|
||||||
|
|
||||||
test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => {
|
test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => {
|
||||||
const montage = monter({ prefixe: 'bloqueuse' });
|
const montage = monter({ prefixe: 'bloqueuse' });
|
||||||
const { fs, seance } = montage;
|
const { fs, seance } = montage;
|
||||||
|
|
@ -1629,6 +1703,19 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
||||||
assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']);
|
assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("le cadrage en attente quand le support rompt s'écrit dans les réglages de la racine nouvelle, après écrire ailleurs", async () => {
|
||||||
|
const { fs, seance, planificateur } = await surPortable();
|
||||||
|
fs.pannes.ecriture('portable');
|
||||||
|
seance.demanderCadrage({ k: 1.25, tx: 30, ty: -12 });
|
||||||
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
||||||
|
await seance.ecrireAilleurs(DOC);
|
||||||
|
await planificateur.avancer(DELAI_REGLAGES_MS);
|
||||||
|
await laisser();
|
||||||
|
const evenement = seance.charge.evenement.id;
|
||||||
|
assert.deepEqual(JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages, [{ evenement, k: 1.25, tx: 30, ty: -12 }]);
|
||||||
|
assert.equal(fs.contenu('portable', FICHIER_REGLAGES), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("un état qui ne s'écrit pas : le geste est au journal, la séance en montre la charge, et écrire ailleurs la garde", async () => {
|
test("un état qui ne s'écrit pas : le geste est au journal, la séance en montre la charge, et écrire ailleurs la garde", async () => {
|
||||||
const { fs, seance } = await surPortable();
|
const { fs, seance } = await surPortable();
|
||||||
fs.pannes.renommage('portable');
|
fs.pannes.renommage('portable');
|
||||||
|
|
@ -1740,9 +1827,9 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
||||||
// Les Documents prennent la paire, mais refusent le verrou.
|
// Les Documents prennent la paire, mais refusent le verrou.
|
||||||
const details = { chemin: VERROU, dossier: DOCUMENTS, cause: 'EACCES' };
|
const details = { chemin: VERROU, dossier: DOCUMENTS, cause: 'EACCES' };
|
||||||
const verrouiller = fs.verrouiller;
|
const verrouiller = fs.verrouiller;
|
||||||
fs.verrouiller = async (racine, chemin, jeton) => {
|
fs.verrouiller = async (racine, chemin, jeton, depuis) => {
|
||||||
if (racine.id === 'documents') throw new ErreurStockage('ECRITURE', details);
|
if (racine.id === 'documents') throw new ErreurStockage('ECRITURE', details);
|
||||||
return verrouiller(racine, chemin, jeton);
|
return verrouiller(racine, chemin, jeton, depuis);
|
||||||
};
|
};
|
||||||
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
||||||
assert.equal(base, BASE);
|
assert.equal(base, BASE);
|
||||||
|
|
@ -1977,7 +2064,7 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- La séance observable (A2) -----------------------------------------------
|
// --- La séance observable ----------------------------------------------------
|
||||||
|
|
||||||
// Laisse passer les tâches en attente : l'écrivain différé des réglages
|
// Laisse passer les tâches en attente : l'écrivain différé des réglages
|
||||||
// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse.
|
// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse.
|
||||||
|
|
@ -2049,7 +2136,7 @@ function deposerAuFormat(fs, format, charge = chargeEssai()) {
|
||||||
const MODIFIER = { geste: 'modifier' };
|
const MODIFIER = { geste: 'modifier' };
|
||||||
const DEBLOQUER = { geste: 'debloquer' };
|
const DEBLOQUER = { geste: 'debloquer' };
|
||||||
|
|
||||||
describe('séance observable : etat() et subscribe (§ 8.4, A2)', () => {
|
describe('séance observable : etat() et subscribe (§ 8.4)', () => {
|
||||||
test('subscribe appelle aussitôt, puis après ouvrir, passer en écriture, un geste et passer en lecture ; désabonné, plus rien', async () => {
|
test('subscribe appelle aussitôt, puis après ouvrir, passer en écriture, un geste et passer en lecture ; désabonné, plus rien', async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
await deposerEssai(fs);
|
await deposerEssai(fs);
|
||||||
|
|
@ -2116,7 +2203,7 @@ describe('séance observable : etat() et subscribe (§ 8.4, A2)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : la racine (§ 8.6, A2)', () => {
|
describe('séance observable : la racine (§ 8.6)', () => {
|
||||||
test("une séance créée sans racine n'en a pas ; changerRacine la pose sans rien écrire", async () => {
|
test("une séance créée sans racine n'en a pas ; changerRacine la pose sans rien écrire", async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
fs.deposerDossier('documents');
|
fs.deposerDossier('documents');
|
||||||
|
|
@ -2145,7 +2232,7 @@ describe('séance observable : la racine (§ 8.6, A2)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : la version de format (§ 8.8, § 18.6, A2)', () => {
|
describe('séance observable : la version de format (§ 8.8, § 18.6)', () => {
|
||||||
test("un fichier d'un format plus récent s'annonce 'plusRecent' ; passer en écriture et un geste sont refusés LECTURE_SEULE FORMAT, remède null, jamais MODE_LECTURE", async () => {
|
test("un fichier d'un format plus récent s'annonce 'plusRecent' ; passer en écriture et un geste sont refusés LECTURE_SEULE FORMAT, remède null, jamais MODE_LECTURE", async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
deposerAuFormat(fs, FORMAT + 1);
|
deposerAuFormat(fs, FORMAT + 1);
|
||||||
|
|
@ -2181,7 +2268,7 @@ describe('séance observable : la version de format (§ 8.8, § 18.6, A2)', () =
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : bloquer et débloquer (§ 8.5, § 9, A2)', () => {
|
describe('séance observable : bloquer et débloquer (§ 8.5, § 9)', () => {
|
||||||
test("sur un plan bloqué, passer en écriture, changer l'état et défaire sont refusés BLOQUE, remède debloquer, jamais MODE_LECTURE, sans écriture", async () => {
|
test("sur un plan bloqué, passer en écriture, changer l'état et défaire sont refusés BLOQUE, remède debloquer, jamais MODE_LECTURE, sans écriture", async () => {
|
||||||
const montage = await ouvertSur(chargeBloquee());
|
const montage = await ouvertSur(chargeBloquee());
|
||||||
const { fs, seance } = montage;
|
const { fs, seance } = montage;
|
||||||
|
|
@ -2231,7 +2318,7 @@ describe('séance observable : bloquer et débloquer (§ 8.5, § 9, A2)', () =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("séance observable : l'annulation et le retour automatique (§ 8.4, A2)", () => {
|
describe("séance observable : l'annulation et le retour automatique (§ 8.4)", () => {
|
||||||
test('surAnnulation est appelé au passage en lecture explicite pendant un geste, au retour automatique et à la fermeture ; désabonné, plus rien', async () => {
|
test('surAnnulation est appelé au passage en lecture explicite pendant un geste, au retour automatique et à la fermeture ; désabonné, plus rien', async () => {
|
||||||
const montage = monter();
|
const montage = monter();
|
||||||
const { seance, planificateur } = montage;
|
const { seance, planificateur } = montage;
|
||||||
|
|
@ -2282,7 +2369,7 @@ describe("séance observable : l'annulation et le retour automatique (§ 8.4, A2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : le cadrage (§ 8.5, A2)', () => {
|
describe('séance observable : le cadrage (§ 8.5)', () => {
|
||||||
const REGLAGES = `{"cadrages":[{"evenement":"evt-lucioles","k":2,"tx":10,"ty":-5}],"format":1,"papier":null}\n`;
|
const REGLAGES = `{"cadrages":[{"evenement":"evt-lucioles","k":2,"tx":10,"ty":-5}],"format":1,"papier":null}\n`;
|
||||||
const lireCadrages = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages;
|
const lireCadrages = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages;
|
||||||
|
|
||||||
|
|
@ -2421,7 +2508,7 @@ describe('séance observable : le cadrage (§ 8.5, A2)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)", () => {
|
describe("séance observable : les avertissements d'un geste (§ 5.9, § 9)", () => {
|
||||||
// Deux tables de huit et seize convives : la salle est pleine.
|
// Deux tables de huit et seize convives : la salle est pleine.
|
||||||
function sallePleine() {
|
function sallePleine() {
|
||||||
const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 8, tours: 2 });
|
const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 8, tours: 2 });
|
||||||
|
|
@ -2484,7 +2571,7 @@ describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () => {
|
describe("séance observable : le verrou d'une autre séance (§ 8.8)", () => {
|
||||||
test('la seconde séance ouvre en lecture sans refus ; son passage en écriture est refusé VERROU_PRIS, remède null, et verrouAutre porte la séance et depuis', async () => {
|
test('la seconde séance ouvre en lecture sans refus ; son passage en écriture est refusé VERROU_PRIS, remède null, et verrouAutre porte la séance et depuis', async () => {
|
||||||
const disque = new Map();
|
const disque = new Map();
|
||||||
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
||||||
|
|
@ -2556,7 +2643,7 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () =
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : le support rompu (§ 8.6, § 8.2, A2)', () => {
|
describe('séance observable : le support rompu (§ 8.6, § 8.2)', () => {
|
||||||
// Un événement du disque portable, en écriture, après un premier geste.
|
// Un événement du disque portable, en écriture, après un premier geste.
|
||||||
async function surPortable() {
|
async function surPortable() {
|
||||||
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
||||||
|
|
@ -2654,7 +2741,7 @@ describe('séance observable : le support rompu (§ 8.6, § 8.2, A2)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : le refus courant (§ 2.1, garantie 3, A2)', () => {
|
describe('séance observable : le refus courant (§ 2.1, garantie 3)', () => {
|
||||||
test("null sans événement et en écriture ; sinon, en profondeur, le code et les détails qu'executer lève, remède compris", async () => {
|
test("null sans événement et en écriture ; sinon, en profondeur, le code et les détails qu'executer lève, remède compris", async () => {
|
||||||
assert.equal(monter().seance.etat().refus, null);
|
assert.equal(monter().seance.etat().refus, null);
|
||||||
const enEcritureMontage = await ouvertSur();
|
const enEcritureMontage = await ouvertSur();
|
||||||
|
|
@ -2698,7 +2785,7 @@ describe('séance observable : le refus courant (§ 2.1, garantie 3, A2)', () =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : fautives et examen (§ 8.9, A2)', () => {
|
describe('séance observable : fautives et examen (§ 8.9)', () => {
|
||||||
test("les propositions que l'ouverture écarte restent dans fautives après un geste, et partent à la fermeture ; l'examen d'après un geste n'en porte aucune", async () => {
|
test("les propositions que l'ouverture écarte restent dans fautives après un geste, et partent à la fermeture ; l'examen d'après un geste n'en porte aucune", async () => {
|
||||||
const charge = chargeEssai();
|
const charge = chargeEssai();
|
||||||
charge.prochainsIds.proposition = 2;
|
charge.prochainsIds.proposition = 2;
|
||||||
|
|
@ -2760,7 +2847,7 @@ describe('séance : attendre la file (§ 5.10, § 8.4)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : la dérive nommée (§ 9, A2)', () => {
|
describe('séance observable : la dérive nommée (§ 9)', () => {
|
||||||
test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => {
|
test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => {
|
||||||
const charge = avecRetenu(chargeEssai());
|
const charge = avecRetenu(chargeEssai());
|
||||||
charge.participants[2].exclu = true;
|
charge.participants[2].exclu = true;
|
||||||
|
|
@ -2779,7 +2866,7 @@ describe('séance observable : la dérive nommée (§ 9, A2)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => {
|
describe('séance observable : les invariants du retenu (§ 14.12, § 14.15)', () => {
|
||||||
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => {
|
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => {
|
||||||
const fs = creerFichiersSimules();
|
const fs = creerFichiersSimules();
|
||||||
const charge = reserverGrisaille(avecRetenu(chargeEssai()));
|
const charge = reserverGrisaille(avecRetenu(chargeEssai()));
|
||||||
|
|
@ -2818,27 +2905,11 @@ describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)", () => {
|
// Les textes des codes que la séance ajoute se composent, sans trace de
|
||||||
const TRACES_A2 = [/\bundefined\b/, /\bnull\b/, /\bNaN\b/, /\b(?:true|false)\b/, /\[object /];
|
// valeur absente, sous les détails d'exemple de la table que
|
||||||
const EXEMPLES_A2 = [
|
// libelles.test.js partage pour chaque code ; les épreuves qui suivent
|
||||||
['RETOUR_AUTOMATIQUE', {}],
|
// lisent ce que chaque texte nomme.
|
||||||
['INVARIANT_VIOLE', { proposition: 3, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }, { code: 'DOUBLE_PLACE', participant: 4, tour: 2 }] }],
|
describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6)", () => {
|
||||||
['DERIVE_NOUVELLE', { touchees: [{ id: 2, raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 3 }] }, { id: 'retenu', raisons: [{ code: 'TABLE_AJOUTEE', table: 5 }] }] }],
|
|
||||||
['RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 }],
|
|
||||||
['CONFIGURATION_INCOHERENTE', { code: 'RESERVATION_CONFLIT', details: { participant: 1, tour: 1, tables: [1, 2] } }],
|
|
||||||
['PLACES_MANQUANTES', { placesManquantes: 4 }],
|
|
||||||
['SURRESERVATION', { table: 3, tour: 2, reservees: 9, capacite: 8 }],
|
|
||||||
];
|
|
||||||
|
|
||||||
test('chaque code se compose de ses détails, sans trace de valeur absente', () => {
|
|
||||||
assert.equal(EXEMPLES_A2.length, 7);
|
|
||||||
for (const [code, details] of EXEMPLES_A2) {
|
|
||||||
const texte = libelle(code, details);
|
|
||||||
assert.ok(texte.trim().length > 0 && texte !== code, code);
|
|
||||||
for (const trace of TRACES_A2) assert.doesNotMatch(texte, trace, `${code} : « ${texte} »`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('le manque dit son nombre ; la surréservation nomme la table, le tour, les réservées et la capacité', () => {
|
test('le manque dit son nombre ; la surréservation nomme la table, le tour, les réservées et la capacité', () => {
|
||||||
assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 1 }), /\b1 place\b/);
|
assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 1 }), /\b1 place\b/);
|
||||||
assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 4 }), /\b4 places\b/);
|
assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 4 }), /\b4 places\b/);
|
||||||
|
|
@ -2847,11 +2918,22 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => {
|
test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => {
|
||||||
const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]);
|
const derive = libelle('DERIVE_NOUVELLE', {
|
||||||
|
touchees: [
|
||||||
|
{ id: 2, raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 3 }] },
|
||||||
|
{ id: 'retenu', raisons: [{ code: 'TABLE_AJOUTEE', table: 5 }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive);
|
assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive);
|
||||||
assert.match(derive, /n° 2/);
|
assert.match(derive, /n° 2/);
|
||||||
assert.match(derive, /retenu/);
|
assert.match(derive, /retenu/);
|
||||||
const viole = libelle('INVARIANT_VIOLE', EXEMPLES_A2[1][1]);
|
const viole = libelle('INVARIANT_VIOLE', {
|
||||||
|
proposition: 3,
|
||||||
|
violations: [
|
||||||
|
{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 },
|
||||||
|
{ code: 'DOUBLE_PLACE', participant: 4, tour: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole);
|
assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole);
|
||||||
assert.match(viole, /n° 3/);
|
assert.match(viole, /n° 3/);
|
||||||
for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) {
|
for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) {
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,22 @@ describe('tables qui se recouvrent : la dernière reçue, peinte au-dessus', ()
|
||||||
assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 });
|
assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('régime sièges : le plateau d’une carrée cache la chaise de dessous jusque dans ses coins, hors de son disque inscrit', () => {
|
||||||
|
// Ronde de 8 en (0, 0) : son siège 4 est à 98,394 cm du centre, à 45°,
|
||||||
|
// en (69,574 ; 69,574). Carrée de 8 en (15, 15), côté 120 : le siège est
|
||||||
|
// à (54,574 ; 54,574) de son centre, dans le plateau, à 77,2 cm du centre,
|
||||||
|
// hors du disque inscrit de rayon 60, et à plus de 36 cm de chacune de ses
|
||||||
|
// chaises, au-delà de leur cible de rayon 30.
|
||||||
|
const ronde = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||||
|
const carree = geometrieTable(table(2, 'carree', 15, 15), 8);
|
||||||
|
const coin = ronde.chaises[3];
|
||||||
|
assert.equal(carree.demiTaille, 60);
|
||||||
|
assert.ok(Math.hypot(coin.x - 15, coin.y - 15) > carree.demiTaille);
|
||||||
|
assert.ok(carree.chaises.every((c) => Math.hypot(coin.x - c.x, coin.y - c.y) > carree.rayonCible));
|
||||||
|
assert.equal(designer(coin, [ronde, carree], 1), null);
|
||||||
|
assert.deepEqual(designer(coin, [carree, ronde], 1), { table: 1, siege: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
test('régimes différents : chaque table se juge sur son propre pas', () => {
|
test('régimes différents : chaque table se juge sur son propre pas', () => {
|
||||||
// À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3
|
// À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3
|
||||||
// (pas 70) en régime sièges.
|
// (pas 70) en régime sièges.
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,21 @@ const MANTISSES = [5, 2, 1];
|
||||||
// par 10ⁿ, qu'elle arrondit.
|
// par 10ⁿ, qu'elle arrondit.
|
||||||
const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n);
|
const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n);
|
||||||
|
|
||||||
|
// Le plus petit nombre flottant normal, 2⁻¹⁰²² : sous lui, 10⁻ⁿ déborde et
|
||||||
|
// ronde rend 0.
|
||||||
|
const PLUS_PETIT_NORMAL = 2 ** -1022;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe
|
* La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe
|
||||||
* quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la
|
* quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la
|
||||||
* largeur de cette barre, longueurCm × k. La recherche part de la décade
|
* largeur de cette barre, longueurCm × k. La recherche part de la décade
|
||||||
* au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la
|
* au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la
|
||||||
* première longueur qui tient : l'arrondi du logarithme ne fait rien manquer.
|
* première longueur qui tient : l'arrondi du logarithme ne fait rien manquer.
|
||||||
* Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0.
|
* Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0, et
|
||||||
|
* quand le rapport largeurMaxPx / k n'est pas un nombre fini au moins égal au
|
||||||
|
* plus petit nombre normal : au-delà, aucune décade ne s'atteint et la
|
||||||
|
* recherche ne finirait pas ; en deçà, la longueur ronde s'arrondit à 0, une
|
||||||
|
* barre vide. Toute longueur rendue est finie et > 0.
|
||||||
* @param {number} k px/cm
|
* @param {number} k px/cm
|
||||||
* @param {number} largeurMaxPx
|
* @param {number} largeurMaxPx
|
||||||
* @returns {{longueurCm: number, largeurPx: number}}
|
* @returns {{longueurCm: number, largeurPx: number}}
|
||||||
|
|
@ -31,7 +39,11 @@ export function echelleGraphique(k, largeurMaxPx) {
|
||||||
throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`);
|
throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let n = Math.floor(Math.log10(largeurMaxPx / k)) + 1; ; n -= 1) {
|
const rapport = largeurMaxPx / k;
|
||||||
|
if (!Number.isFinite(rapport) || rapport < PLUS_PETIT_NORMAL) {
|
||||||
|
throw new RangeError(`largeur / k hors des nombres représentables : ${String(largeurMaxPx)} / ${String(k)}`);
|
||||||
|
}
|
||||||
|
for (let n = Math.floor(Math.log10(rapport)) + 1; ; n -= 1) {
|
||||||
for (const m of MANTISSES) {
|
for (const m of MANTISSES) {
|
||||||
const longueurCm = ronde(m, n);
|
const longueurCm = ronde(m, n);
|
||||||
if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k };
|
if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k };
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,21 @@ describe("échelle : l'échelle graphique (§ 7.7)", () => {
|
||||||
assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`);
|
assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("un rapport largeur / k qui sort des nombres flottants finis et non nuls est refusé, sans boucle", () => {
|
||||||
|
// 1e-300 / 1e300 vaut 0, et 1e-300 / 1e10 tombe sous le plus petit
|
||||||
|
// nombre normal : la plus grande longueur ronde y serait 0, une barre
|
||||||
|
// vide. 1e300 / 1e-300 vaut Infinity, et aucune décade ne s'y atteint.
|
||||||
|
const horsDomaine = [[1e300, 1e-300], [1e10, 1e-300], [1e-300, 1e300], [Number.MIN_VALUE, Number.MAX_VALUE]];
|
||||||
|
for (const [k, largeur] of horsDomaine) {
|
||||||
|
assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aux bords du domaine admis, la longueur reste ronde, finie et non nulle', () => {
|
||||||
|
for (const [k, largeur] of [[1e-150, 1e150], [1e150, 1e-150], [1, Number.MAX_VALUE], [1, 2 ** -1022]]) {
|
||||||
|
const { longueurCm, largeurPx } = echelleGraphique(k, largeur);
|
||||||
|
assert.ok(Number.isFinite(longueurCm) && longueurCm > 0 && largeurPx <= largeur, `${k}, ${largeur} : ${longueurCm}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
// porte aucun angle. La grille où se posent les tables des démonstrations et
|
// porte aucun angle. La grille où se posent les tables des démonstrations et
|
||||||
// celles que l'opérateur crée a un seul pas, ecartsGrille.
|
// celles que l'opérateur crée a un seul pas, ecartsGrille.
|
||||||
import { constante } from './constantes.js';
|
import { constante } from './constantes.js';
|
||||||
|
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} GeometrieTable en cm
|
* @typedef {Object} GeometrieTable en cm
|
||||||
|
|
@ -39,14 +40,20 @@ export const RAYON_CHAISE = 22;
|
||||||
export const RAYON_POIGNEE = 15;
|
export const RAYON_POIGNEE = 15;
|
||||||
/** Distance entre l'emprise et le bord de la poignée, en cm. */
|
/** Distance entre l'emprise et le bord de la poignée, en cm. */
|
||||||
export const ECART_POIGNEE = 8;
|
export const ECART_POIGNEE = 8;
|
||||||
/** Distance entre l'emprise et le bloc de noms, en cm : le carré du focus
|
/** Distance entre l'emprise et le bloc de noms, en cm. Le carré du focus
|
||||||
* que l'interface pose autour de l'emprise, ou d'une chaise, s'étend à
|
* que l'interface pose autour de l'emprise, ou d'une chaise, s'en écarte
|
||||||
* 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
|
* de ECART_FOCUS (src/interface/plan/modele.js), plus la moitié de son
|
||||||
* aucune marque ne couvre la liste. */
|
* trait, en deçà de cette distance : le bloc commence au-delà, et aucune
|
||||||
|
* marque ne couvre la liste. */
|
||||||
export const ECART_LISTE = 36;
|
export const ECART_LISTE = 36;
|
||||||
/** Écart minimal entre les centres de deux rangées de la grille des tables,
|
/** Écart minimal entre les centres de deux rangées de la grille des tables,
|
||||||
* en cm. */
|
* en cm. */
|
||||||
export const ECART_RANGEES = 300;
|
export const ECART_RANGEES = 300;
|
||||||
|
/** Hauteur d'une ligne de liste que la géométrie suppose, en cm : 1,25
|
||||||
|
* corps, au-dessus de la hauteur d'encre du gabarit sur les polices
|
||||||
|
* courantes. Le dessin mesure la sienne au navigateur (HAUTEUR_LIGNE, du
|
||||||
|
* registre) ; un calcul pur qui juge où descend une liste lit celle-ci. */
|
||||||
|
export const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
|
||||||
|
|
||||||
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
|
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
|
||||||
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
|
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
|
||||||
|
|
@ -255,10 +262,13 @@ export function ecartsGrille(tables) {
|
||||||
* s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
|
* s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
|
||||||
* la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
|
* la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
|
||||||
* emprise des deux. Une table à un pas de rangée ou plus a son emprise à
|
* emprise des deux. Une table à un pas de rangée ou plus a son emprise à
|
||||||
* ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors
|
* ECART_LISTE au moins de l'emprise nouvelle. Ainsi, à travers une colonne,
|
||||||
* de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée,
|
* une table déplacée hors de la grille, ou une grille dont le pas a crû
|
||||||
* ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne
|
* depuis qu'elle a été posée, ne reçoit pas la liste de la table nouvelle et
|
||||||
* à travers une colonne.
|
* ne lui porte pas la sienne. Le pas de rangée ne sépare que les emprises :
|
||||||
|
* une liste plus haute que lui, celle d'une table de beaucoup de places,
|
||||||
|
* descend sur une table posée hors de la grille dessous, dans la bande de
|
||||||
|
* son bloc ; listeSurTable juge ce recouvrement.
|
||||||
*
|
*
|
||||||
* La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
|
* La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
|
||||||
* au plus deux multiples du pas, et une table prend donc au plus quatre
|
* au plus deux multiples du pas, et une table prend donc au plus quatre
|
||||||
|
|
@ -281,15 +291,45 @@ export function caseLibre(tables, nouvelle) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai quand le bloc de noms de la table geometrie, à places places, recoupe
|
||||||
|
* l'emprise de la table autre, bords exclus : la bande du bloc, large de
|
||||||
|
* LARGEUR_BLOC_NOMS_CM depuis origineListe, haute de places + 1 lignes de
|
||||||
|
* HAUTEUR_LIGNE_SUPPOSEE — l'en-tête d'une table gelée compris —, contre le
|
||||||
|
* carré de demi-côté rayonEmprise centré sur autre, qui circonscrit le
|
||||||
|
* disque d'une ronde. La seule mesure de ce recouvrement : le mobilier en
|
||||||
|
* tire LISTE_SUR_VOISINE.
|
||||||
|
*
|
||||||
|
* @param {GeometrieTable} geometrie la table dont la liste se juge
|
||||||
|
* @param {number} places sa capacité courante
|
||||||
|
* @param {GeometrieTable} autre
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function listeSurTable(geometrie, places, autre) {
|
||||||
|
const gauche = geometrie.origineListe.x;
|
||||||
|
const haut = geometrie.origineListe.y;
|
||||||
|
const droite = gauche + constante('LARGEUR_BLOC_NOMS_CM');
|
||||||
|
const bas = haut + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE;
|
||||||
|
const { centre, rayonEmprise: e } = autre;
|
||||||
|
return centre.x + e > gauche && centre.x - e < droite && centre.y + e > haut && centre.y - e < bas;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
|
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
|
||||||
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
|
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
|
||||||
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round).
|
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round). Lève
|
||||||
|
* RangeError sur des places initiales qui ne sont pas un entier ≥ 1, ou sur
|
||||||
|
* un écart non fini : le résultat serait NaN, ou un nombre non entier.
|
||||||
*
|
*
|
||||||
* @param {number} placesInitiales les places au début du geste
|
* @param {number} placesInitiales les places au début du geste
|
||||||
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif en s'éloignant du centre
|
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif
|
||||||
|
* en s'éloignant du centre
|
||||||
* @returns {number} entier ≥ 2
|
* @returns {number} entier ≥ 2
|
||||||
*/
|
*/
|
||||||
export function placesSousPoignee(placesInitiales, ecartRadial) {
|
export function placesSousPoignee(placesInitiales, ecartRadial) {
|
||||||
|
exigerPlaces(placesInitiales);
|
||||||
|
if (typeof ecartRadial !== 'number' || !Number.isFinite(ecartRadial)) {
|
||||||
|
throw new RangeError(`écart radial : nombre fini attendu, reçu ${String(ecartRadial)}`);
|
||||||
|
}
|
||||||
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
|
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
// d'une table créée et nombre de places sous la poignée. Les valeurs
|
// d'une table créée et nombre de places sous la poignée. Les valeurs
|
||||||
// attendues se calculent à la main dans le commentaire qui les précède.
|
// attendues se calculent à la main dans le commentaire qui les précède.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { HAUTEUR_LIGNE_SUPPOSEE, fautesBandes } from '../../test/bandes.js';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { constante } from './constantes.js';
|
import { constante } from './constantes.js';
|
||||||
import { surLeSol } from './designation.js';
|
import { surLeSol } from './designation.js';
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
ecartsGrille,
|
ecartsGrille,
|
||||||
englobant,
|
englobant,
|
||||||
geometrieTable,
|
geometrieTable,
|
||||||
|
listeSurTable,
|
||||||
placesSousPoignee,
|
placesSousPoignee,
|
||||||
} from './tables.js';
|
} from './tables.js';
|
||||||
|
|
||||||
|
|
@ -46,48 +48,6 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
// Une table du fichier d'état : identifiant, numéro, forme et centre.
|
// Une table du fichier d'état : identifiant, numéro, forme et centre.
|
||||||
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
||||||
|
|
||||||
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
|
|
||||||
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le
|
|
||||||
// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas.
|
|
||||||
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
|
|
||||||
|
|
||||||
// Les fautes « une liste passe sur une autre table » d'un ensemble de tables
|
|
||||||
// posées {position, forme, places} : pour chaque paire ordonnée, la bande du
|
|
||||||
// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis
|
|
||||||
// origineListe, places + 1 lignes de haut, en-tête d'une table gelée
|
|
||||||
// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec
|
|
||||||
// avec, seules les paires où figure la table de ce rang, compté depuis 1,
|
|
||||||
// sont examinées. Rend les fautes et le nombre de paires examinées.
|
|
||||||
function fautesBandes(posees, { avec = null } = {}) {
|
|
||||||
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
|
||||||
const geometries = posees.map(({ position, forme, places }, rang) => ({
|
|
||||||
g: geometrieTable(table(rang + 1, forme, position.x, position.y), places),
|
|
||||||
places,
|
|
||||||
}));
|
|
||||||
const fautes = [];
|
|
||||||
let paires = 0;
|
|
||||||
for (const { g, places } of geometries) {
|
|
||||||
const bande = {
|
|
||||||
gauche: g.origineListe.x,
|
|
||||||
droite: g.origineListe.x + largeur,
|
|
||||||
haut: g.origineListe.y,
|
|
||||||
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
|
|
||||||
};
|
|
||||||
for (const { g: autre } of geometries) {
|
|
||||||
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
|
|
||||||
paires += 1;
|
|
||||||
const e = autre.rayonEmprise;
|
|
||||||
const recoupe =
|
|
||||||
autre.centre.x + e > bande.gauche &&
|
|
||||||
autre.centre.x - e < bande.droite &&
|
|
||||||
autre.centre.y + e > bande.haut &&
|
|
||||||
autre.centre.y - e < bande.bas;
|
|
||||||
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { fautes, paires };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('constantes', () => {
|
describe('constantes', () => {
|
||||||
test('les valeurs du contrat', () => {
|
test('les valeurs du contrat', () => {
|
||||||
assert.equal(PLANCHER_DIAMETRE, 70);
|
assert.equal(PLANCHER_DIAMETRE, 70);
|
||||||
|
|
@ -97,6 +57,7 @@ describe('constantes', () => {
|
||||||
assert.equal(ECART_POIGNEE, 8);
|
assert.equal(ECART_POIGNEE, 8);
|
||||||
assert.equal(ECART_LISTE, 36);
|
assert.equal(ECART_LISTE, 36);
|
||||||
assert.equal(ECART_RANGEES, 300);
|
assert.equal(ECART_RANGEES, 300);
|
||||||
|
assert.equal(HAUTEUR_LIGNE_SUPPOSEE, 1.25 * TAILLE_TEXTE_LISTE_CM);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -489,6 +450,55 @@ describe('caseLibre : la place d\u{2019}une table créée, sur la grille des dé
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listeSurTable : la liste d\u{2019}une table passe sur une autre', () => {
|
||||||
|
const ronde = (id, x, y, places = 8) => ({ id, forme: 'ronde', position: { x, y }, places });
|
||||||
|
const geometrie = ({ id, forme, position, places }) => geometrieTable(table(id, forme, position.x, position.y), places);
|
||||||
|
|
||||||
|
test('une ronde de 8 en (330, 0) porte sa liste sur une autre en (813, 0), non l\u{2019}inverse ; au pas de 813, rien ne se couvre', () => {
|
||||||
|
// La liste de la première, de 486,4 à 986,4, couvre l'emprise de la
|
||||||
|
// seconde, de 692,6 à 933,4 ; celle de la seconde part vers la droite.
|
||||||
|
const [a, b] = [ronde(1, 330, 0), ronde(2, 813, 0)];
|
||||||
|
assert.equal(listeSurTable(geometrie(a), 8, geometrie(b)), true);
|
||||||
|
assert.equal(listeSurTable(geometrie(b), 8, geometrie(a)), false);
|
||||||
|
const [c, d] = [ronde(1, 0, 0), ronde(2, 813, 0)];
|
||||||
|
assert.equal(listeSurTable(geometrie(c), 8, geometrie(d)), false);
|
||||||
|
assert.equal(listeSurTable(geometrie(d), 8, geometrie(c)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bords exclus : une emprise qui touche la bande ne la recoupe pas ; un centimètre de plus, si', () => {
|
||||||
|
// La bande d'une ronde de 8 en (0, 0) va de 156,4 à 656,4 : une table
|
||||||
|
// dont l'emprise commence exactement à 656,4 ne la recoupe pas.
|
||||||
|
const a = geometrie(ronde(1, 0, 0));
|
||||||
|
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
||||||
|
const bord = a.origineListe.x + largeur;
|
||||||
|
const juste = geometrie(ronde(2, bord + a.rayonEmprise, 0));
|
||||||
|
assert.equal(listeSurTable(a, 8, juste), false);
|
||||||
|
const dedans = geometrie(ronde(2, bord + a.rayonEmprise - 1, 0));
|
||||||
|
assert.equal(listeSurTable(a, 8, dedans), true);
|
||||||
|
// En bas, la bande compte places + 1 lignes de HAUTEUR_LIGNE_SUPPOSEE.
|
||||||
|
const bas = a.origineListe.y + 9 * HAUTEUR_LIGNE_SUPPOSEE;
|
||||||
|
assert.equal(listeSurTable(a, 8, geometrie(ronde(2, 400, bas + a.rayonEmprise))), false);
|
||||||
|
assert.equal(listeSurTable(a, 8, geometrie(ronde(2, 400, bas + a.rayonEmprise - 1))), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'épreuve des bandes et listeSurTable disent la même chose sur 300 paires tirées", () => {
|
||||||
|
let graine = 7;
|
||||||
|
const tirer = (n) => {
|
||||||
|
graine = (graine * 1103515245 + 12345) % 2147483648;
|
||||||
|
return graine % n;
|
||||||
|
};
|
||||||
|
let couvertes = 0;
|
||||||
|
for (let i = 0; i < 300; i += 1) {
|
||||||
|
const a = { id: 1, forme: tirer(2) === 0 ? 'ronde' : 'carree', position: { x: 0, y: 0 }, places: 2 + tirer(20) };
|
||||||
|
const b = { id: 2, forme: tirer(2) === 0 ? 'ronde' : 'carree', position: { x: tirer(1600) - 200, y: tirer(1200) - 600 }, places: 2 + tirer(20) };
|
||||||
|
const attendu = fautesBandes([a, b]).fautes.some((faute) => faute.startsWith('bloc de la table 1 '));
|
||||||
|
assert.equal(listeSurTable(geometrie(a), a.places, geometrie(b)), attendu, JSON.stringify([a, b]));
|
||||||
|
if (attendu) couvertes += 1;
|
||||||
|
}
|
||||||
|
assert.ok(couvertes > 20 && couvertes < 280, `${couvertes} paires couvertes`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('placesSousPoignee', () => {
|
describe('placesSousPoignee', () => {
|
||||||
const PAS_RAYON = 60 / (2 * Math.PI);
|
const PAS_RAYON = 60 / (2 * Math.PI);
|
||||||
|
|
||||||
|
|
@ -504,4 +514,10 @@ describe('placesSousPoignee', () => {
|
||||||
assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9);
|
assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9);
|
||||||
assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7);
|
assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('un nombre de places qui n’est pas un entier ≥ 1, ou un écart non fini, lève RangeError', () => {
|
||||||
|
for (const [places, ecart] of [[8, Number.NaN], [2.5, 0], [8, Number.POSITIVE_INFINITY], [8, Number.NEGATIVE_INFINITY], [Number.NaN, 0], [0, 0], ['8', 0]]) {
|
||||||
|
assert.throws(() => placesSousPoignee(places, ecart), RangeError, `${String(places)}, ${String(ecart)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -176,15 +176,23 @@ export function deplacerVue(vueInitiale, pointeurInitial, pointeur) {
|
||||||
* nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode
|
* nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode
|
||||||
* (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève
|
* (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève
|
||||||
* RangeError sur un autre deltaMode.
|
* RangeError sur un autre deltaMode.
|
||||||
|
*
|
||||||
|
* Le facteur rendu est fini et > 0, ce que zoomerSous exige : un deltaY
|
||||||
|
* démesuré le porterait à 0 ou à Infinity. Sous des bornes, il se borne à
|
||||||
|
* [kMin / kMax, kMax / kMin], le plus grand pas qu'un geste fasse d'une borne
|
||||||
|
* à l'autre ; sans elles, à [1 / Number.MAX_VALUE, Number.MAX_VALUE]. Dans
|
||||||
|
* les deux cas, le k borné que zoomerSous en tire est celui du facteur exact.
|
||||||
* @param {number} deltaY @param {number} deltaMode
|
* @param {number} deltaY @param {number} deltaMode
|
||||||
|
* @param {{kMin: number, kMax: number}|null} [bornes]
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function facteurMolette(deltaY, deltaMode) {
|
export function facteurMolette(deltaY, deltaMode, bornes = null) {
|
||||||
const cran = CRAN_PAR_MODE[deltaMode];
|
const cran = CRAN_PAR_MODE[deltaMode];
|
||||||
if (!Number.isInteger(deltaMode) || cran === undefined) {
|
if (!Number.isInteger(deltaMode) || cran === undefined) {
|
||||||
throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`);
|
throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`);
|
||||||
}
|
}
|
||||||
return FACTEUR_CRAN ** (-deltaY / cran);
|
const plafond = bornes === null ? Number.MAX_VALUE : bornes.kMax / bornes.kMin;
|
||||||
|
return Math.min(plafond, Math.max(1 / plafond, FACTEUR_CRAN ** (-deltaY / cran)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -204,15 +212,17 @@ const FORME_TRANSFORM = new RegExp(`^translate\\((${NOMBRE}),(${NOMBRE})\\) scal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La vue que décrit un attribut transform de la forme qu'écrit transformDe.
|
* La vue que décrit un attribut transform de la forme qu'écrit transformDe.
|
||||||
* Lève SyntaxError sur toute autre forme, et sur un k qui n'est pas > 0.
|
* Lève SyntaxError sur toute autre forme, sur un k qui n'est pas un nombre
|
||||||
|
* fini > 0, et sur un tx ou un ty non fini : « 1e999 » a la forme d'un nombre
|
||||||
|
* et se lit Infinity.
|
||||||
* @param {string} texte
|
* @param {string} texte
|
||||||
* @returns {Vue}
|
* @returns {Vue}
|
||||||
*/
|
*/
|
||||||
export function vueDepuisTransform(texte) {
|
export function vueDepuisTransform(texte) {
|
||||||
const lu = FORME_TRANSFORM.exec(texte);
|
const lu = FORME_TRANSFORM.exec(texte);
|
||||||
const k = lu === null ? Number.NaN : Number(lu[3]);
|
const [tx, ty, k] = lu === null ? [] : [Number(lu[1]), Number(lu[2]), Number(lu[3])];
|
||||||
if (lu === null || !(k > 0) || !Number.isFinite(k)) {
|
if (lu === null || !(k > 0) || ![k, tx, ty].every(Number.isFinite)) {
|
||||||
throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`);
|
throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`);
|
||||||
}
|
}
|
||||||
return { k, tx: Number(lu[1]), ty: Number(lu[2]) };
|
return { k, tx, ty };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,18 +185,21 @@ describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)"
|
||||||
test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => {
|
test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => {
|
||||||
const GRAINE = 20260;
|
const GRAINE = 20260;
|
||||||
const alea = creerPcg32(GRAINE, FLUX.DEMO);
|
const alea = creerPcg32(GRAINE, FLUX.DEMO);
|
||||||
let nuls = 0;
|
// Les natures tirées : 0, un cadre plus étroit que ses marges ; 1, un
|
||||||
let rendus = 0;
|
// cadre de hauteur nulle ; 2, un englobant plat ; 3 et 4, un englobant
|
||||||
|
// quelconque ; 5, un plan vide. Chaque nature compte ses tirages nuls et
|
||||||
|
// rendus.
|
||||||
|
const comptes = Array.from({ length: 6 }, () => ({ nuls: 0, rendus: 0 }));
|
||||||
for (let i = 0; i < 200; i += 1) {
|
for (let i = 0; i < 200; i += 1) {
|
||||||
const nature = i % 5;
|
const nature = i % 6;
|
||||||
const cadre = {
|
const cadre = {
|
||||||
gauche: alea.borne(2000) - 1000,
|
gauche: alea.borne(2000) - 1000,
|
||||||
haut: alea.borne(2000) - 1000,
|
haut: alea.borne(2000) - 1000,
|
||||||
largeur: nature === 0 ? alea.borne(33) : alea.borne(3000),
|
largeur: nature === 0 ? alea.borne(33) : 33 + alea.borne(3000),
|
||||||
hauteur: nature === 1 ? 0 : alea.borne(3000),
|
hauteur: nature === 1 ? 0 : 33 + alea.borne(3000),
|
||||||
};
|
};
|
||||||
let englobant = null;
|
let englobant = null;
|
||||||
if (nature >= 2) {
|
if (nature >= 2 && nature <= 4) {
|
||||||
const minX = alea.borne(20000) - 10000;
|
const minX = alea.borne(20000) - 10000;
|
||||||
const minY = alea.borne(20000) - 10000;
|
const minY = alea.borne(20000) - 10000;
|
||||||
const plat = nature === 2;
|
const plat = nature === 2;
|
||||||
|
|
@ -211,16 +214,21 @@ describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)"
|
||||||
const vue = ajuster(englobant, cadre);
|
const vue = ajuster(englobant, cadre);
|
||||||
if (k === null) {
|
if (k === null) {
|
||||||
assert.equal(vue, null, `tirage ${i}`);
|
assert.equal(vue, null, `tirage ${i}`);
|
||||||
nuls += 1;
|
comptes[nature].nuls += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
rendus += 1;
|
comptes[nature].rendus += 1;
|
||||||
assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`);
|
assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`);
|
||||||
assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`);
|
assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`);
|
||||||
const bornes = bornesZoom(k);
|
const bornes = bornesZoom(k);
|
||||||
assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`);
|
assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`);
|
||||||
}
|
}
|
||||||
assert.ok(nuls > 0 && rendus > 0, `${nuls} nuls, ${rendus} rendus`);
|
// Un cadre trop petit ne s'ajuste jamais ; chaque autre nature rend au
|
||||||
|
// moins un tirage, le plan vide sur un cadre valide compris.
|
||||||
|
const resume = JSON.stringify(comptes);
|
||||||
|
for (const nature of [0, 1]) assert.equal(comptes[nature].rendus, 0, resume);
|
||||||
|
for (const nature of [0, 1, 2, 3, 4, 5]) assert.ok(comptes[nature].nuls + comptes[nature].rendus > 0, resume);
|
||||||
|
for (const nature of [2, 3, 4, 5]) assert.ok(comptes[nature].rendus > 0, resume);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => {
|
test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => {
|
||||||
|
|
@ -324,6 +332,25 @@ describe("vue : la molette et l'attribut transform", () => {
|
||||||
assert.throws(() => facteurMolette(-100, 3), RangeError);
|
assert.throws(() => facteurMolette(-100, 3), RangeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('une molette démesurée rend un facteur fini > 0 : zoomerSous ne lève pas et atteint la borne', () => {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
|
||||||
|
const bornes = { kMin: 0.2, kMax: 10 };
|
||||||
|
const vue = { k: 1, tx: 0, ty: 0 };
|
||||||
|
for (const [deltaY, deltaMode, kAttendu] of [[1e6, 0, 0.2], [-1e6, 0, 10], [1e6, 2, 0.2], [-1e6, 2, 10], [-Number.MAX_VALUE, 1, 10]]) {
|
||||||
|
for (const facteur of [facteurMolette(deltaY, deltaMode), facteurMolette(deltaY, deltaMode, bornes)]) {
|
||||||
|
assert.ok(Number.isFinite(facteur) && facteur > 0, `${deltaY}, ${deltaMode} : ${facteur}`);
|
||||||
|
assert.equal(zoomerSous(vue, cadre, 400, 300, facteur, bornes).k, kAttendu, `${deltaY}, ${deltaMode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sous des bornes données, le facteur se borne à leur rapport : un geste ne va pas plus loin que de kMin à kMax', () => {
|
||||||
|
const bornes = { kMin: 0.2, kMax: 10 };
|
||||||
|
assert.equal(facteurMolette(-1e6, 0, bornes), 50);
|
||||||
|
assert.equal(facteurMolette(1e6, 0, bornes), 1 / 50);
|
||||||
|
assert.ok(proches(facteurMolette(-100, 0, bornes), 1.1));
|
||||||
|
});
|
||||||
|
|
||||||
test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => {
|
test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => {
|
||||||
const vues = [
|
const vues = [
|
||||||
{ k: 2, tx: 10, ty: 20 },
|
{ k: 2, tx: 10, ty: 20 },
|
||||||
|
|
@ -341,6 +368,9 @@ describe("vue : la molette et l'attribut transform", () => {
|
||||||
'translate(10,20) scale(-2)',
|
'translate(10,20) scale(-2)',
|
||||||
'translate(a,20) scale(2)',
|
'translate(a,20) scale(2)',
|
||||||
'translate(10,20) scale(2) rotate(3)',
|
'translate(10,20) scale(2) rotate(3)',
|
||||||
|
'translate(1e999,0) scale(2)',
|
||||||
|
'translate(0,-1e999) scale(2)',
|
||||||
|
'translate(10,20) scale(1e999)',
|
||||||
]) {
|
]) {
|
||||||
assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte);
|
assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,12 @@ function pairesMinimales(poses, libres) {
|
||||||
// la somme des minimums les collisions cumulées. Un groupe de s membres
|
// la somme des minimums les collisions cumulées. Un groupe de s membres
|
||||||
// n'offre que C(s, 2) paires distinctes : l'excédent, cumulées − distinctes,
|
// n'offre que C(s, 2) paires distinctes : l'excédent, cumulées − distinctes,
|
||||||
// ne descend pas sous plancherCumulees − C(s, 2), écrit quand c'est positif.
|
// ne descend pas sous plancherCumulees − C(s, 2), écrit quand c'est positif.
|
||||||
function planchersCollisions({ N, T, R, groupe, groupes, fixe }) {
|
// Exporté pour qu'une épreuve en compte les appels (diagnostiquer).
|
||||||
|
//
|
||||||
|
// avecLibres faux : les membres libres ne comptent pas. Le plancher est
|
||||||
|
// alors celui des seules places réservées, la limite du plancher quand les
|
||||||
|
// tables ajoutées sont assez nombreuses pour asseoir chaque libre seul.
|
||||||
|
export function planchersCollisions({ N, T, R, groupe, groupes, fixe }, { avecLibres = true } = {}) {
|
||||||
const membres = groupes.map(() => []);
|
const membres = groupes.map(() => []);
|
||||||
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p);
|
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p);
|
||||||
const planchers = [];
|
const planchers = [];
|
||||||
|
|
@ -121,8 +126,8 @@ function planchersCollisions({ N, T, R, groupe, groupes, fixe }) {
|
||||||
let libres = 0;
|
let libres = 0;
|
||||||
for (const p of membres[g]) {
|
for (const p of membres[g]) {
|
||||||
const t = fixe[p * R + r];
|
const t = fixe[p * R + r];
|
||||||
if (t === LIBRE) libres += 1;
|
if (t !== LIBRE) poses[t] += 1;
|
||||||
else poses[t] += 1;
|
else if (avecLibres) libres += 1;
|
||||||
}
|
}
|
||||||
const minimum = pairesMinimales(poses, libres);
|
const minimum = pairesMinimales(poses, libres);
|
||||||
cumulees += minimum;
|
cumulees += minimum;
|
||||||
|
|
@ -213,26 +218,29 @@ function repartition(aPriori, statut) {
|
||||||
|
|
||||||
// Chaque plancher de collisions, complété de tablesManquantes. La sonde à m
|
// Chaque plancher de collisions, complété de tablesManquantes. La sonde à m
|
||||||
// tables ajoute m tables de la plus grande capacité présente, d'identifiants
|
// tables ajoute m tables de la plus grande capacité présente, d'identifiants
|
||||||
// au-delà du plus grand, et rechiffre les planchers par planchersCollisions :
|
// au-delà du plus grand, et rechiffre les planchers par chiffrer : le
|
||||||
// le premier m où le groupe n'y figure plus est son remède. Un groupe de s
|
// premier m où le groupe n'y figure plus est son remède. Un groupe de s
|
||||||
// membres se sépare dès que s tables de plus s'offrent à ses membres libres,
|
// membres se sépare dès que s tables de plus s'offrent à ses membres libres,
|
||||||
// sauf quand des réservations en assoient deux à une même table d'un tour :
|
// sauf quand des réservations en assoient deux à une même table d'un tour :
|
||||||
// au-delà de s, il garde null. Les capacités, relâchées dans le plancher, ne
|
// le plancher des seules places réservées (avecLibres faux), qu'aucune table
|
||||||
// changent pas le compte ; elles disent quelles tables ajouter. Un plancher
|
// ajoutée n'abaisse, le dit d'un seul chiffrage, et le groupe garde null
|
||||||
// suppose une table au moins.
|
// sans être sondé. Les capacités, relâchées dans le plancher, ne changent
|
||||||
function avecTablesManquantes(configuration, instance, planchers) {
|
// pas le compte ; elles disent quelles tables ajouter. Un plancher suppose
|
||||||
|
// une table au moins.
|
||||||
|
function avecTablesManquantes(configuration, instance, planchers, chiffrer) {
|
||||||
const lignes = planchers.map((plancher) => ({ ...plancher, tablesManquantes: null }));
|
const lignes = planchers.map((plancher) => ({ ...plancher, tablesManquantes: null }));
|
||||||
if (lignes.length === 0) return lignes;
|
if (lignes.length === 0) return lignes;
|
||||||
const enAttente = (m) => lignes.some((l) => l.tablesManquantes === null && m <= l.effectif);
|
const sansRemede = new Set(chiffrer(instance, { avecLibres: false }).map(({ groupe }) => groupe));
|
||||||
|
const enAttente = (m) => lignes.some((l) => l.tablesManquantes === null && !sansRemede.has(l.groupe) && m <= l.effectif);
|
||||||
const capacite = Math.max(...instance.capacite);
|
const capacite = Math.max(...instance.capacite);
|
||||||
const premierId = Math.max(...instance.idsTables) + 1;
|
const premierId = Math.max(...instance.idsTables) + 1;
|
||||||
const ajoutees = [];
|
const ajoutees = [];
|
||||||
for (let m = 1; enAttente(m); m += 1) {
|
for (let m = 1; enAttente(m); m += 1) {
|
||||||
ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite });
|
ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite });
|
||||||
const sonde = normaliser({ ...configuration, tables: [...configuration.tables, ...ajoutees] });
|
const sonde = normaliser({ ...configuration, tables: [...configuration.tables, ...ajoutees] });
|
||||||
const restants = new Set(planchersCollisions(sonde).map(({ groupe }) => groupe));
|
const restants = new Set(chiffrer(sonde).map(({ groupe }) => groupe));
|
||||||
for (const ligne of lignes) {
|
for (const ligne of lignes) {
|
||||||
if (ligne.tablesManquantes === null && m <= ligne.effectif && !restants.has(ligne.groupe)) {
|
if (ligne.tablesManquantes === null && !sansRemede.has(ligne.groupe) && m <= ligne.effectif && !restants.has(ligne.groupe)) {
|
||||||
ligne.tablesManquantes = m;
|
ligne.tablesManquantes = m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -388,9 +396,12 @@ function etendue(valeurs) {
|
||||||
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
|
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').Configuration} configuration
|
* @param {import('./types.js').Configuration} configuration
|
||||||
|
* @param {{planchersCollisions?: typeof planchersCollisions}} [calcul]
|
||||||
|
* le chiffrage des planchers de collisions, planchersCollisions par
|
||||||
|
* défaut ; une épreuve y compte les appels
|
||||||
* @returns {Diagnostic}
|
* @returns {Diagnostic}
|
||||||
*/
|
*/
|
||||||
export function diagnostiquer(configuration) {
|
export function diagnostiquer(configuration, { planchersCollisions: chiffrer = planchersCollisions } = {}) {
|
||||||
const instance = normaliser(configuration);
|
const instance = normaliser(configuration);
|
||||||
const aPriori = plafondsAPriori(instance);
|
const aPriori = plafondsAPriori(instance);
|
||||||
const memeAppartenance = animateursMemeAppartenance(instance);
|
const memeAppartenance = animateursMemeAppartenance(instance);
|
||||||
|
|
@ -404,7 +415,7 @@ export function diagnostiquer(configuration) {
|
||||||
parPersonne: aPriori,
|
parPersonne: aPriori,
|
||||||
repartition: repartition(aPriori, instance.statut),
|
repartition: repartition(aPriori, instance.statut),
|
||||||
},
|
},
|
||||||
collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)),
|
collisions: avecTablesManquantes(configuration, instance, chiffrer(instance), chiffrer),
|
||||||
redondanceMinimale: minimale,
|
redondanceMinimale: minimale,
|
||||||
remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) },
|
remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) },
|
||||||
animateursMemeAppartenance: memeAppartenance,
|
animateursMemeAppartenance: memeAppartenance,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { describe, test } from '../../test/lanceur.js';
|
||||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||||
import { conflitsParTable } from './conflits.js';
|
import { conflitsParTable } from './conflits.js';
|
||||||
import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
|
import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
|
||||||
import { diagnostiquer } from './diagnostic.js';
|
import { diagnostiquer, planchersCollisions } from './diagnostic.js';
|
||||||
import { ErreurConfiguration } from './erreurs.js';
|
import { ErreurConfiguration } from './erreurs.js';
|
||||||
import { mesurer } from './indicateurs.js';
|
import { mesurer } from './indicateurs.js';
|
||||||
import { plafondItineraire, plafondsAPriori, plafondsRealises } from './plafond.js';
|
import { plafondItineraire, plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||||
|
|
@ -592,6 +592,28 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trente membres de X, dont deux ancrés à la même table : la paire qu'ils
|
||||||
|
// forment ne se défait par aucune table ajoutée. Le remède ne se sonde pas
|
||||||
|
// jusqu'à l'effectif : un chiffrage de plus, celui des seules places
|
||||||
|
// réservées, suffit à le dire sans remède.
|
||||||
|
test('un groupe que ses réservations réunissent ne se sonde pas table après table : deux chiffrages au plus', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 20], [20, 20]],
|
||||||
|
appartenances: fois(30, 'X'),
|
||||||
|
tours: 2,
|
||||||
|
reservations: [ancrer(1, 10), ancrer(2, 10)],
|
||||||
|
});
|
||||||
|
let chiffrages = 0;
|
||||||
|
const espion = (...parametres) => {
|
||||||
|
chiffrages += 1;
|
||||||
|
return planchersCollisions(...parametres);
|
||||||
|
};
|
||||||
|
const { collisions } = diagnostiquer(configuration, { planchersCollisions: espion });
|
||||||
|
assert.deepEqual(collisions, diagnostiquer(configuration).collisions);
|
||||||
|
assert.deepEqual(collisions.map(({ groupe, effectif, tablesManquantes }) => [groupe, effectif, tablesManquantes]), [['X', 30, null]]);
|
||||||
|
assert.ok(chiffrages <= 2, `${chiffrages} chiffrages`);
|
||||||
|
});
|
||||||
|
|
||||||
// X compte quatre membres dont un exclu : trois sur deux tables, une paire
|
// X compte quatre membres dont un exclu : trois sur deux tables, une paire
|
||||||
// par tour, deux tours.
|
// par tour, deux tours.
|
||||||
test('un membre exclu sort de l’effectif et du plancher', () => {
|
test('un membre exclu sort de l’effectif et du plancher', () => {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
// Une opération pose une valeur au bout d'un chemin, ou retire
|
// Une opération pose une valeur au bout d'un chemin, ou retire
|
||||||
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
|
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
|
||||||
// { op: 'retirer', chemin }. Un champ facultatif du schéma (optionnel) se
|
// { op: 'retirer', chemin }. Un champ facultatif du schéma (optionnel) se
|
||||||
// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Un chemin est une liste d'étapes depuis la
|
// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Un
|
||||||
// charge : une clé d'objet, chaîne ; un rang de liste, entier ≥ 0 ; ou
|
// chemin est une liste d'étapes depuis la charge : une clé d'objet, chaîne ;
|
||||||
|
// un rang de liste, entier ≥ 0 ; ou
|
||||||
// { id }, qui désigne dans une liste d'enregistrements à identifiant —
|
// { id }, qui désigne dans une liste d'enregistrements à identifiant —
|
||||||
// participants, tables, propositions — l'enregistrement de cet identifiant,
|
// participants, tables, propositions — l'enregistrement de cet identifiant,
|
||||||
// et non une place.
|
// et non une place.
|
||||||
|
|
@ -135,24 +136,26 @@ function comparer(a, b, regle, chemin, operations) {
|
||||||
} else if (a.length !== b.length) {
|
} else if (a.length !== b.length) {
|
||||||
operations.push({ op: 'poser', chemin, valeur: b });
|
operations.push({ op: 'poser', chemin, valeur: b });
|
||||||
} else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) {
|
} else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) {
|
||||||
comparerIdentifiants(a, b, regle.element, chemin, operations);
|
comparerIdentifiants(a, b, chemin, operations);
|
||||||
} else {
|
} else {
|
||||||
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
|
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de
|
// Ajoute à operations ce qui mène de a à b, deux listes de même longueur
|
||||||
// même longueur, d'élément de règle regleElement, au bout de chemin : une
|
// d'identifiants ou d'occupants, des scalaires, au bout de chemin : une pose
|
||||||
// pose par rang qui change, ou b entière quand ces poses pèsent plus que la
|
// par rang qui change, ou b entière quand ces poses pèsent plus que la
|
||||||
// sienne. Une liste triée par identifiant — une liste de table sans
|
// sienne. Une liste triée par identifiant — une liste de table sans
|
||||||
// attribution, une réserve, les participants d'un plan — décale d'un rang
|
// attribution, une réserve, les participants d'un plan — décale d'un rang
|
||||||
// chaque entrée qu'un identifiant remplacé franchit, et ses poses rang par
|
// chaque entrée qu'un identifiant remplacé franchit, et ses poses rang par
|
||||||
// rang se multiplient ; ce qui s'en écrit ne pèse jamais plus que la liste
|
// rang se multiplient ; ce qui s'en écrit ne pèse jamais plus que la liste
|
||||||
// posée entière. Sous des sièges attribués, une chaise libérée ou reprise
|
// posée entière. Sous des sièges attribués, une chaise libérée ou reprise
|
||||||
// ne change que son rang : une pose, de valeur null ou de l'occupant.
|
// ne change que son rang : une pose, de valeur null ou de l'occupant.
|
||||||
function comparerIdentifiants(a, b, regleElement, chemin, operations) {
|
function comparerIdentifiants(a, b, chemin, operations) {
|
||||||
const parRang = [];
|
const parRang = [];
|
||||||
a.forEach((id, rang) => comparer(id, b[rang], regleElement, [...chemin, rang], parRang));
|
a.forEach((id, rang) => {
|
||||||
|
if (id !== b[rang]) parRang.push({ op: 'poser', chemin: [...chemin, rang], valeur: b[rang] });
|
||||||
|
});
|
||||||
const entiere = { op: 'poser', chemin, valeur: b };
|
const entiere = { op: 'poser', chemin, valeur: b };
|
||||||
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
|
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
|
||||||
}
|
}
|
||||||
|
|
@ -383,10 +386,11 @@ function executer(charge, operation) {
|
||||||
* qu'un objet ou null ; sous un retenu que sa règle refuse, quoi que ce
|
* qu'un objet ou null ; sous un retenu que sa règle refuse, quoi que ce
|
||||||
* soit ; retirer autre chose qu'un enregistrement par { id } ou un champ
|
* soit ; retirer autre chose qu'un enregistrement par { id } ou un champ
|
||||||
* facultatif présent. Une charge rendue garde ainsi chaque proposition dans
|
* facultatif présent. Une charge rendue garde ainsi chaque proposition dans
|
||||||
* sa règle, et canoniser, difference et serialiser la lisent sans lever. Le contrôle porte sur la forme : une opération ne
|
* sa règle, et canoniser, difference et serialiser la lisent sans lever. Le
|
||||||
* porte pas la valeur qu'elle remplace, et un correctif dont chaque chemin
|
* contrôle porte sur la forme : une opération ne porte pas la valeur qu'elle
|
||||||
* existe aussi dans une autre charge s'y applique sans lever. Un correctif
|
* remplace, et un correctif dont chaque chemin existe aussi dans une autre
|
||||||
* qui n'est pas une liste lève TypeError.
|
* charge s'y applique sans lever. Un correctif qui n'est pas une liste lève
|
||||||
|
* TypeError.
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').Charge} a
|
* @param {import('./types.js').Charge} a
|
||||||
* @param {Operation[]} correctif
|
* @param {Operation[]} correctif
|
||||||
|
|
|
||||||
|
|
@ -562,21 +562,38 @@ function avertir({ examen, journal, revision, rattrape, ecartees, recree, format
|
||||||
return avertissements;
|
return avertissements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La règle du rattrapage d'un journal en avance (§ 8.8), pure : le dernier
|
||||||
|
* instant restituable du journal apparié, quand sa révision suit d'une seule
|
||||||
|
* celle de l'état, donne la charge, canonisée, la révision et la version de
|
||||||
|
* l'état rattrapé en mémoire, et ce que l'en-tête de l'état annonce sans que
|
||||||
|
* sa charge le porte ne compte plus. Sinon l'état reste tel quel. Rien ne
|
||||||
|
* s'écrit : l'ouverture la suit, et le premier geste réécrit l'état.
|
||||||
|
*
|
||||||
|
* @param {{charge: Object, revision: number, produitVersion: string, manquants: Object}} etat
|
||||||
|
* @param {{charge: Object, revision: number, journal: Object}|null} instant
|
||||||
|
* le dernier instant restituable du journal, null sans journal
|
||||||
|
* @returns {{charge: Object, revision: number, produitVersion: string, manquants: Object, rattrape: boolean}}
|
||||||
|
*/
|
||||||
|
export function rattraper(etat, instant) {
|
||||||
|
const { charge, revision, produitVersion, manquants } = etat;
|
||||||
|
if (instant === null || instant.revision !== revision + 1) return { charge, revision, produitVersion, manquants, rattrape: false };
|
||||||
|
return {
|
||||||
|
charge: canoniser(instant.charge),
|
||||||
|
revision: instant.revision,
|
||||||
|
produitVersion: versionDe(instant.journal, instant.revision),
|
||||||
|
manquants: AUCUN_MANQUANT,
|
||||||
|
rattrape: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
|
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
|
||||||
// journal trouvé pour elle. rattraper : un journal en avance d'une seule
|
// journal trouvé pour elle. avecRattrapage : la source suit la règle de
|
||||||
// entrée sur la source donne sa charge, sa révision et sa version, et ce que
|
// rattraper. La source d'un format plus récent, ou un journal d'un format
|
||||||
// l'en-tête de la source annonce sans que sa charge le porte ne compte plus.
|
// plus récent, gardent l'Ouvert en lecture seule.
|
||||||
// La source d'un format plus récent, ou un journal d'un format plus récent,
|
function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }) {
|
||||||
// gardent l'Ouvert en lecture seule.
|
const lu = avecRattrapage ? rattraper(source, instant) : { ...source, rattrape: false };
|
||||||
function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
const { charge, revision, produitVersion, manquants, rattrape } = lu;
|
||||||
let { charge, revision, produitVersion, manquants } = source;
|
|
||||||
const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
|
|
||||||
if (rattrape) {
|
|
||||||
charge = canoniser(instant.charge);
|
|
||||||
revision = instant.revision;
|
|
||||||
produitVersion = versionDe(instant.journal, revision);
|
|
||||||
manquants = AUCUN_MANQUANT;
|
|
||||||
}
|
|
||||||
const journal = instant?.journal ?? null;
|
const journal = instant?.journal ?? null;
|
||||||
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
||||||
const examen = examiner(charge);
|
const examen = examiner(charge);
|
||||||
|
|
@ -746,7 +763,7 @@ export async function ouvrir(fs, racine, base) {
|
||||||
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
|
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
|
||||||
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
||||||
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
||||||
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true });
|
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), avecRattrapage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -799,7 +816,7 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
||||||
const { lecture } = precedent;
|
const { lecture } = precedent;
|
||||||
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
||||||
const depuis = depuisLecture(lecture, disque);
|
const depuis = depuisLecture(lecture, disque);
|
||||||
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false });
|
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), avecRattrapage: false });
|
||||||
}
|
}
|
||||||
const trouve = await (etat.lecture === null
|
const trouve = await (etat.lecture === null
|
||||||
? journalSansEtat(vue, base)
|
? journalSansEtat(vue, base)
|
||||||
|
|
@ -823,7 +840,7 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
||||||
manquants: AUCUN_MANQUANT,
|
manquants: AUCUN_MANQUANT,
|
||||||
...disque,
|
...disque,
|
||||||
};
|
};
|
||||||
return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false });
|
return assembler({ fs, racine, base, source: depuis, trouve, instant, avecRattrapage: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un Ouvert en lecture seule — un fichier qu'il a lu est d'un format plus
|
// Un Ouvert en lecture seule — un fichier qu'il a lu est d'un format plus
|
||||||
|
|
@ -1265,15 +1282,18 @@ export async function nommer(ouvert, { nom, horodatage, libelleReprise }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prend le verrou de la séance sur l'événement (§ 8.8) : le Verrou que rend
|
* Prend le verrou de la séance sur l'événement (§ 8.8), daté de depuis, que
|
||||||
* le système de fichiers, tel quel.
|
* la séance lit à son horloge : le Verrou que rend le système de fichiers,
|
||||||
|
* tel quel.
|
||||||
*
|
*
|
||||||
* @param {Ouvert} ouvert
|
* @param {Ouvert} ouvert
|
||||||
* @param {string} seance
|
* @param {string} seance
|
||||||
|
* @param {string} depuis AAAA-MM-JJTHH:MM:SS±HH:MM ; une autre forme lève
|
||||||
|
* TypeError sans rien créer
|
||||||
* @returns {Promise<import('./systeme_fichiers.js').Verrou>}
|
* @returns {Promise<import('./systeme_fichiers.js').Verrou>}
|
||||||
*/
|
*/
|
||||||
export async function verrouiller({ fs, racine, base }, seance) {
|
export async function verrouiller({ fs, racine, base }, seance, depuis) {
|
||||||
return fs.verrouiller(racine, base + SUFFIXES.verrou, seance);
|
return fs.verrouiller(racine, base + SUFFIXES.verrou, seance, depuis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2425,18 +2425,20 @@ describe('dépôt : format plus récent, élagage', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
||||||
test("le verrou de la séance se prend, se refuse à une autre séance avec ce que le système en dit, et se rend", async () => {
|
test("le verrou de la séance se prend à l'instant qu'elle donne, se refuse à une autre séance avec ce que le système en dit, et se rend", async () => {
|
||||||
const { disque, fs } = nouveauDisque();
|
const { disque, fs } = nouveauDisque();
|
||||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true });
|
const depuis = '2031-05-17T08:30:00-04:00';
|
||||||
|
assert.deepEqual(await verrouiller(ouvert, 'seance-a', depuis), { pris: true });
|
||||||
const texte = fs.contenu('documents', VERROU);
|
const texte = fs.contenu('documents', VERROU);
|
||||||
const { depuis } = JSON.parse(texte);
|
assert.equal(JSON.parse(texte).depuis, depuis);
|
||||||
|
|
||||||
const voisin = await rouvrir(disque, BASE);
|
const voisin = await rouvrir(disque, BASE);
|
||||||
assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: false, seance: 'seance-a', depuis, vivant: true });
|
const plusTard = '2031-05-17T08:31:00-04:00';
|
||||||
|
assert.deepEqual(await verrouiller(voisin, 'seance-b', plusTard), { pris: false, seance: 'seance-a', depuis, vivant: true });
|
||||||
const lointain = await ouvrir(creerFichiersSimules({ disque, hote: 'autre-poste' }), DOC, BASE);
|
const lointain = await ouvrir(creerFichiersSimules({ disque, hote: 'autre-poste' }), DOC, BASE);
|
||||||
assert.equal(lointain.fs.compteurs.ecritures, 0, 'ouvrir écrit');
|
assert.equal(lointain.fs.compteurs.ecritures, 0, 'ouvrir écrit');
|
||||||
assert.deepEqual(await verrouiller(lointain, 'seance-c'), {
|
assert.deepEqual(await verrouiller(lointain, 'seance-c', plusTard), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'seance-a',
|
seance: 'seance-a',
|
||||||
depuis,
|
depuis,
|
||||||
|
|
@ -2448,9 +2450,9 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
||||||
await deverrouiller(ouvert, 'seance-a');
|
await deverrouiller(ouvert, 'seance-a');
|
||||||
assert.equal(fs.contenu('documents', VERROU), null);
|
assert.equal(fs.contenu('documents', VERROU), null);
|
||||||
|
|
||||||
assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: true });
|
assert.deepEqual(await verrouiller(voisin, 'seance-b', plusTard), { pris: true });
|
||||||
voisin.fs.pannes.couperApres(0);
|
voisin.fs.pannes.couperApres(0);
|
||||||
const vuDApres = await verrouiller(ouvert, 'seance-a');
|
const vuDApres = await verrouiller(ouvert, 'seance-a', plusTard);
|
||||||
assert.deepEqual([vuDApres.pris, vuDApres.seance, vuDApres.vivant], [false, 'seance-b', false]);
|
assert.deepEqual([vuDApres.pris, vuDApres.seance, vuDApres.vivant], [false, 'seance-b', false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,11 @@ const NOMBRE = regle('nombre');
|
||||||
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
|
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
|
||||||
// L'occupant d'un siège dans une liste de table d'un placement :
|
// L'occupant d'un siège dans une liste de table d'un placement :
|
||||||
// l'identifiant d'une personne, ou null, le marqueur d'une chaise vide
|
// l'identifiant d'une personne, ou null, le marqueur d'une chaise vide
|
||||||
// (§ 8.9). La règle admet null à toute place d'une telle liste ; le
|
// (§ 8.9). Ses bornes sont celles d'IDENTIFIANT, dont la règle se recopie,
|
||||||
|
// null admis. La règle admet null à toute place d'une telle liste ; le
|
||||||
// contrôle des placements (placements.js) ne l'admet que sous des sièges
|
// contrôle des placements (placements.js) ne l'admet que sous des sièges
|
||||||
// attribués, et jamais en fin de liste.
|
// attribués, et jamais en fin de liste.
|
||||||
const OCCUPANT = entier(1, 2 ** 31 - 1, { nul: true });
|
const OCCUPANT = regle('entier', { ...IDENTIFIANT, nul: true });
|
||||||
const DATE_OU_NUL = regle('date', { nul: true });
|
const DATE_OU_NUL = regle('date', { nul: true });
|
||||||
|
|
||||||
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
|
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
|
||||||
|
|
|
||||||
|
|
@ -1103,6 +1103,22 @@ describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("à la place d'un occupant, 0, 1,5, −1 et 2^31 sont fautifs, comme tout identifiant ; 2^31 − 1 se lit", () => {
|
||||||
|
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||||
|
const PROPOSITION = regleDe(regleDe(SCHEMA, 'charge'), 'propositions').element;
|
||||||
|
for (const occupant of [0, 1.5, 2 ** 31, -1]) {
|
||||||
|
const abimee = propositionAChaiseVide();
|
||||||
|
abimee.placement[1].sieges[1][1] = occupant;
|
||||||
|
assert.equal(premiereFaute(abimee, PROPOSITION), 'placement[1].sieges[1][1]', String(occupant));
|
||||||
|
}
|
||||||
|
const limite = propositionAChaiseVide();
|
||||||
|
limite.placement[1].sieges[1][1] = 2 ** 31 - 1;
|
||||||
|
assert.equal(premiereFaute(limite, PROPOSITION), null);
|
||||||
|
const occupant = regleDe(regleDe(PROPOSITION, 'placement').element, 'sieges').element.element;
|
||||||
|
const identifiant = regleDe(PROPOSITION, 'participants').element;
|
||||||
|
assert.deepEqual([occupant.min, occupant.max, occupant.nul], [identifiant.min, identifiant.max, true]);
|
||||||
|
});
|
||||||
|
|
||||||
test("l'analyse lit un fichier dont une proposition porte une chaise vide, et sa relecture rend la liste telle qu'écrite", () => {
|
test("l'analyse lit un fichier dont une proposition porte une chaise vide, et sa relecture rend la liste telle qu'écrite", () => {
|
||||||
const documentLu = documentValide();
|
const documentLu = documentValide();
|
||||||
documentLu.charge.propositions = [propositionAChaiseVide()];
|
documentLu.charge.propositions = [propositionAChaiseVide()];
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille.
|
// L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille.
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
import { exigerDepuis } from './systeme_fichiers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le système de fichiers de la plateforme electron.
|
* Le système de fichiers de la plateforme electron.
|
||||||
|
|
@ -50,7 +51,13 @@ export function creerFichiersElectron(pont = window.gtt.fichiers) {
|
||||||
creerDossier: (racine, chemin) => appeler('creerDossier', racine, chemin),
|
creerDossier: (racine, chemin) => appeler('creerDossier', racine, chemin),
|
||||||
deplacer: (racine, de, vers) => appeler('deplacer', racine, de, vers),
|
deplacer: (racine, de, vers) => appeler('deplacer', racine, de, vers),
|
||||||
supprimer: (racine, chemin) => appeler('supprimer', racine, chemin),
|
supprimer: (racine, chemin) => appeler('supprimer', racine, chemin),
|
||||||
verrouiller: (racine, chemin, seance) => appeler('verrouiller', racine, chemin, seance),
|
// La forme de l'instant de prise se juge ici, avant le pont : le
|
||||||
|
// processus principal, qui n'importe rien de src/, n'en contrôle que le
|
||||||
|
// type.
|
||||||
|
verrouiller: async (racine, chemin, seance, depuis) => {
|
||||||
|
exigerDepuis(depuis);
|
||||||
|
return appeler('verrouiller', racine, chemin, seance, depuis);
|
||||||
|
},
|
||||||
deverrouiller: (racine, chemin, seance) => appeler('deverrouiller', racine, chemin, seance),
|
deverrouiller: (racine, chemin, seance) => appeler('deverrouiller', racine, chemin, seance),
|
||||||
ouvrirDansExplorateur: (racine) => appeler('ouvrirDansExplorateur', racine),
|
ouvrirDansExplorateur: (racine) => appeler('ouvrirDansExplorateur', racine),
|
||||||
choisirFichierAImporter: () => appeler('choisirFichierAImporter'),
|
choisirFichierAImporter: () => appeler('choisirFichierAImporter'),
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend
|
// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend
|
||||||
// le nom proposé.
|
// le nom proposé.
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
import { exigerCheminRelatif } from './systeme_fichiers.js';
|
import { exigerCheminRelatif, exigerDepuis } from './systeme_fichiers.js';
|
||||||
|
|
||||||
// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne.
|
// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne.
|
||||||
const DOSSIER_PRODUIT = 'gestion_table_tournante_libre';
|
const DOSSIER_PRODUIT = 'gestion_table_tournante_libre';
|
||||||
|
|
@ -475,8 +475,9 @@ export function creerFichiersWeb() {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
verrouiller: async (racine, chemin, seance) => {
|
verrouiller: async (racine, chemin, seance, depuis) => {
|
||||||
exigerSeance(seance);
|
exigerSeance(seance);
|
||||||
|
exigerDepuis(depuis);
|
||||||
localiser(racine, chemin);
|
localiser(racine, chemin);
|
||||||
return { pris: true };
|
return { pris: true };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -482,11 +482,15 @@ describe("fichiers web : ce que l'OPFS refuse", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// L'instant de prise qu'un verrou reçoit, de la forme qu'écrit l'horloge de
|
||||||
|
// l'application ; la plateforme ne l'écrit nulle part.
|
||||||
|
const PRISE = '2031-05-17T19:42:07-04:00';
|
||||||
|
|
||||||
describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)', () => {
|
describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)', () => {
|
||||||
test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => {
|
test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => {
|
||||||
const fs = await preparer();
|
const fs = await preparer();
|
||||||
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
|
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a', PRISE)).toStrictEqual({ pris: true });
|
||||||
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
|
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a', PRISE)).toStrictEqual({ pris: true });
|
||||||
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
||||||
|
|
||||||
// Un verrou venu d'un dossier de la coquille reste où il est.
|
// Un verrou venu d'un dossier de la coquille reste où il est.
|
||||||
|
|
@ -494,7 +498,7 @@ describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)',
|
||||||
await fs.ecrireAtomique(DOCUMENTS, 'soiree.gtt.verrou', verrou);
|
await fs.ecrireAtomique(DOCUMENTS, 'soiree.gtt.verrou', verrou);
|
||||||
await fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b');
|
await fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b');
|
||||||
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
|
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
|
||||||
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-c')).toStrictEqual({ pris: true });
|
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-c', PRISE)).toStrictEqual({ pris: true });
|
||||||
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
|
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -609,7 +613,8 @@ describe('fichiers web : fautes de programmation', () => {
|
||||||
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42),
|
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42),
|
||||||
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'),
|
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'),
|
||||||
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'retour\rchariot'),
|
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'retour\rchariot'),
|
||||||
() => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', ''),
|
() => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', '', PRISE),
|
||||||
|
() => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a', '2031-05-17T19:42:07Z'),
|
||||||
() => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null),
|
() => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null),
|
||||||
() => fs.enregistrerSous('', BRUTS),
|
() => fs.enregistrerSous('', BRUTS),
|
||||||
() => fs.enregistrerSous('liste.csv', 'nom;prenom'),
|
() => fs.enregistrerSous('liste.csv', 'nom;prenom'),
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
// texte entier d'un journal, en décode les octets ligne par ligne, restitue
|
// texte entier d'un journal, en décode les octets ligne par ligne, restitue
|
||||||
// la charge d'un instant, suit le fil courant et élague. Une table par sorte
|
// la charge d'un instant, suit le fil courant et élague. Une table par sorte
|
||||||
// de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à
|
// de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à
|
||||||
// l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
|
// l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par
|
||||||
// refuserait la forme. La suite des révisions et la cadence des instantanés,
|
// TypeError la ligne dont la lecture refuserait la forme. La suite des
|
||||||
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
|
// révisions et la cadence des instantanés, que la lecture contrôle aussi,
|
||||||
// appartiennent à l'appelant.
|
// dépendent des lignes voisines : elles appartiennent à l'appelant.
|
||||||
//
|
//
|
||||||
// Une entrée porte soit l'instantané de la charge qui en résulte, soit le
|
// Une entrée porte soit l'instantané de la charge qui en résulte, soit le
|
||||||
// correctif qui y mène depuis la charge de l'entrée qui la précède dans
|
// correctif qui y mène depuis la charge de l'entrée qui la précède dans
|
||||||
|
|
@ -76,7 +76,17 @@ const SENS = [null, 'defaire', 'refaire', 'revenir'];
|
||||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||||
const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1;
|
const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1;
|
||||||
const estTexte = (valeur) => typeof valeur === 'string' && valeur !== '';
|
const estTexte = (valeur) => typeof valeur === 'string' && valeur !== '';
|
||||||
const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur);
|
|
||||||
|
/**
|
||||||
|
* Vrai pour une chaîne de la forme AAAA-MM-JJTHH:MM:SS±HH:MM, celle que rend
|
||||||
|
* l'horloge de l'application : la seule définition de la forme d'un
|
||||||
|
* horodatage, que le journal, le verrou et le placement daté partagent. Ni
|
||||||
|
* le calendrier ni les bornes des heures ne se contrôlent ici.
|
||||||
|
*
|
||||||
|
* @param {unknown} valeur
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur);
|
||||||
|
|
||||||
// Chemin de la première valeur de charge que l'analyse du fichier d'état
|
// Chemin de la première valeur de charge que l'analyse du fichier d'état
|
||||||
// refuserait, ou null : la forme de la charge, propositions et retenu lus
|
// refuserait, ou null : la forme de la charge, propositions et retenu lus
|
||||||
|
|
|
||||||
|
|
@ -582,7 +582,7 @@ export async function supprimer(fs, racine, base, { horodatage, seance }) {
|
||||||
const dossier = dossierCorbeille(horodatage, existants);
|
const dossier = dossierCorbeille(horodatage, existants);
|
||||||
// La sonde vise le verrou sous son nom sur le disque, celui qui part.
|
// La sonde vise le verrou sous son nom sur le disque, celui qui part.
|
||||||
const nomDuVerrou = nomSurDisque(presents, verrou, insensibleCasse) ?? verrou;
|
const nomDuVerrou = nomSurDisque(presents, verrou, insensibleCasse) ?? verrou;
|
||||||
const sonde = await fs.verrouiller(racine, nomDuVerrou, seance);
|
const sonde = await fs.verrouiller(racine, nomDuVerrou, seance, horodatage);
|
||||||
if (!sonde.pris && sonde.seance !== seance && sonde.vivant !== false) {
|
if (!sonde.pris && sonde.seance !== seance && sonde.vivant !== false) {
|
||||||
throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant });
|
throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -481,7 +481,7 @@ describe('opérations : copier (§ 8.7)', () => {
|
||||||
describe('opérations : renommer (§ 8.7)', () => {
|
describe('opérations : renommer (§ 8.7)', () => {
|
||||||
test("renommer écrit d'abord la paire complète sous la nouvelle base — l'état d'avant au .precedent, le journal recopié avec l'entrée du renommage, puis l'état —, sans écrire l'ancienne ni créer le dossier, puis supprime l'ancienne : état, .precedent, journal ; le verrou reste à la séance, et le geste suivant continue sous la nouvelle base", async () => {
|
test("renommer écrit d'abord la paire complète sous la nouvelle base — l'état d'avant au .precedent, le journal recopié avec l'entrée du renommage, puis l'état —, sans écrire l'ancienne ni créer le dossier, puis supprime l'ancienne : état, .precedent, journal ; le verrou reste à la séance, et le geste suivant continue sous la nouvelle base", async () => {
|
||||||
const { disque, fs, ouvert } = await deuxRevisions();
|
const { disque, fs, ouvert } = await deuxRevisions();
|
||||||
assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true });
|
assert.deepEqual(await verrouiller(ouvert, 'seance-a', HEURE_SUPPRESSION), { pris: true });
|
||||||
const verrou = fs.contenu('documents', VERROU);
|
const verrou = fs.contenu('documents', VERROU);
|
||||||
const etatAvant = fs.contenu('documents', ETAT);
|
const etatAvant = fs.contenu('documents', ETAT);
|
||||||
const nouvelle = 'Gala des Saules';
|
const nouvelle = 'Gala des Saules';
|
||||||
|
|
@ -1255,7 +1255,7 @@ describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
|
||||||
describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
test("supprimer déplace l'état, le .precedent, le journal apparié, le verrou et les écritures restées dans un seul dossier daté, créé d'abord, l'état en dernier ; listerCorbeille le montre ; le reste du dossier ne bouge pas", async () => {
|
test("supprimer déplace l'état, le .precedent, le journal apparié, le verrou et les écritures restées dans un seul dossier daté, créé d'abord, l'état en dernier ; listerCorbeille le montre ; le reste du dossier ne bouge pas", async () => {
|
||||||
const { disque, fs, ouvert } = await deuxRevisions();
|
const { disque, fs, ouvert } = await deuxRevisions();
|
||||||
await verrouiller(ouvert, 'seance-a');
|
await verrouiller(ouvert, 'seance-a', HEURE_SUPPRESSION);
|
||||||
fs.deposer('documents', `${ETAT}.ecriture`, 'partiel');
|
fs.deposer('documents', `${ETAT}.ecriture`, 'partiel');
|
||||||
fs.deposer('documents', `${JOURNAL}.ecriture`, 'partiel aussi');
|
fs.deposer('documents', `${JOURNAL}.ecriture`, 'partiel aussi');
|
||||||
await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION);
|
await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION);
|
||||||
|
|
@ -1516,7 +1516,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
test('une coupure à chaque écriture de la suppression laisse chaque fichier de l’événement à sa place ou dans la corbeille, son texte intact, jamais perdu', async () => {
|
test('une coupure à chaque écriture de la suppression laisse chaque fichier de l’événement à sa place ou dans la corbeille, son texte intact, jamais perdu', async () => {
|
||||||
for (let n = 0; ; n += 1) {
|
for (let n = 0; ; n += 1) {
|
||||||
const { disque, fs, ouvert } = await deuxRevisions();
|
const { disque, fs, ouvert } = await deuxRevisions();
|
||||||
await verrouiller(ouvert, 'seance-a');
|
await verrouiller(ouvert, 'seance-a', HEURE_SUPPRESSION);
|
||||||
fs.deposer('documents', `${ETAT}.ecriture`, 'partiel');
|
fs.deposer('documents', `${ETAT}.ecriture`, 'partiel');
|
||||||
const avant = await arbre(disque);
|
const avant = await arbre(disque);
|
||||||
fs.pannes.couperApres(n);
|
fs.pannes.couperApres(n);
|
||||||
|
|
@ -1603,10 +1603,10 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
|
|
||||||
test("supprimer sonde le verrou sous la séance qui supprime : une autre séance vivante, d'un autre poste ou au verrou illisible lève VERROU_PRIS {seance, depuis, vivant} sans rien déplacer ; un verrou mort, le sien, ou celui que la sonde prend partent avec le reste", async () => {
|
test("supprimer sonde le verrou sous la séance qui supprime : une autre séance vivante, d'un autre poste ou au verrou illisible lève VERROU_PRIS {seance, depuis, vivant} sans rien déplacer ; un verrou mort, le sien, ou celui que la sonde prend partent avec le reste", async () => {
|
||||||
const refuses = [
|
const refuses = [
|
||||||
['une autre séance vivante', (fs, ouvert) => verrouiller(ouvert, 'seance-b'), { seance: 'seance-b', vivant: true }],
|
['une autre séance vivante', (fs, ouvert) => verrouiller(ouvert, 'seance-b', HEURE_SUPPRESSION), { seance: 'seance-b', vivant: true }],
|
||||||
[
|
[
|
||||||
"une séance d'un autre poste",
|
"une séance d'un autre poste",
|
||||||
(fs, ouvert, disque) => verrouiller({ ...ouvert, fs: creerFichiersSimules({ disque, hote: 'poste-voisin' }) }, 'seance-c'),
|
(fs, ouvert, disque) => verrouiller({ ...ouvert, fs: creerFichiersSimules({ disque, hote: 'poste-voisin' }) }, 'seance-c', HEURE_SUPPRESSION),
|
||||||
{ seance: 'seance-c', vivant: null },
|
{ seance: 'seance-c', vivant: null },
|
||||||
],
|
],
|
||||||
['un verrou illisible', (fs) => fs.deposer('documents', VERROU, '{'), { seance: null, vivant: null }],
|
['un verrou illisible', (fs) => fs.deposer('documents', VERROU, '{'), { seance: null, vivant: null }],
|
||||||
|
|
@ -1627,7 +1627,7 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
// Deux processus sur un même disque : le premier tient l'événement en
|
// Deux processus sur un même disque : le premier tient l'événement en
|
||||||
// écriture et continue ses gestes ; le second ne le supprime pas.
|
// écriture et continue ses gestes ; le second ne le supprime pas.
|
||||||
const partage = await deuxRevisions();
|
const partage = await deuxRevisions();
|
||||||
await verrouiller(partage.ouvert, SEANCE);
|
await verrouiller(partage.ouvert, SEANCE, HEURE_SUPPRESSION);
|
||||||
const second = creerFichiersSimules({ disque: partage.disque });
|
const second = creerFichiersSimules({ disque: partage.disque });
|
||||||
const refus = await echec(supprimer(second, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: 'seance-b' }), 'VERROU_PRIS');
|
const refus = await echec(supprimer(second, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: 'seance-b' }), 'VERROU_PRIS');
|
||||||
assert.deepEqual([refus.details.seance, refus.details.vivant], [SEANCE, true]);
|
assert.deepEqual([refus.details.seance, refus.details.vivant], [SEANCE, true]);
|
||||||
|
|
@ -1643,12 +1643,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
"une séance coupée",
|
"une séance coupée",
|
||||||
async (disque) => {
|
async (disque) => {
|
||||||
const coupee = creerFichiersSimules({ disque });
|
const coupee = creerFichiersSimules({ disque });
|
||||||
await coupee.verrouiller(DOC, VERROU, 'seance-morte');
|
await coupee.verrouiller(DOC, VERROU, 'seance-morte', HEURE_SUPPRESSION);
|
||||||
coupee.pannes.couperApres(0);
|
coupee.pannes.couperApres(0);
|
||||||
return 'seance-morte';
|
return 'seance-morte';
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
['la séance qui supprime', async (disque, fs, ouvert) => (await verrouiller(ouvert, SEANCE), SEANCE)],
|
['la séance qui supprime', async (disque, fs, ouvert) => (await verrouiller(ouvert, SEANCE, HEURE_SUPPRESSION), SEANCE)],
|
||||||
['aucun verrou', async () => SEANCE],
|
['aucun verrou', async () => SEANCE],
|
||||||
];
|
];
|
||||||
for (const [nom, poser] of acceptes) {
|
for (const [nom, poser] of acceptes) {
|
||||||
|
|
@ -1656,7 +1656,8 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => {
|
||||||
const tenant = await poser(disque, fs, ouvert);
|
const tenant = await poser(disque, fs, ouvert);
|
||||||
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE }, nom);
|
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE }, nom);
|
||||||
assert.deepEqual(await listerCorbeille(fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT, VERROU] }], nom);
|
assert.deepEqual(await listerCorbeille(fs, DOC), [{ dossier: DATE, fichiers: [JOURNAL, ETAT, PRECEDENT, VERROU] }], nom);
|
||||||
assert.equal(JSON.parse(fs.contenu('documents', `${DATE}/${VERROU}`)).seance, tenant, nom);
|
const parti = JSON.parse(fs.contenu('documents', `${DATE}/${VERROU}`));
|
||||||
|
assert.deepEqual([parti.seance, parti.depuis], [tenant, HEURE_SUPPRESSION], nom);
|
||||||
assert.deepEqual(await lister(fs, DOC), [], nom);
|
assert.deepEqual(await lister(fs, DOC), [], nom);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -340,8 +340,8 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
|
||||||
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
|
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
|
||||||
* tables dans l'ordre déclaré, chaque liste de table sans ses chaises
|
* tables dans l'ordre déclaré, chaque liste de table sans ses chaises
|
||||||
* vides, et chaque réserve, par identifiant croissant, comme le moteur les
|
* vides, et chaque réserve, par identifiant croissant, comme le moteur les
|
||||||
* rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
|
* rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec
|
||||||
* n'est pas cohérent (fautes).
|
* p. Lève TypeError quand p n'est pas cohérent (fautes).
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
||||||
* @returns {import('../moteur/types.js').Plan}
|
* @returns {import('../moteur/types.js').Plan}
|
||||||
|
|
|
||||||
|
|
@ -1569,6 +1569,10 @@ describe('le marqueur de chaise vide : null avant le dernier occupant, sièges a
|
||||||
p.siegesAttribues = false;
|
p.siegesAttribues = false;
|
||||||
p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] };
|
p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] };
|
||||||
}, 'placement[0].sieges[0][0]'],
|
}, 'placement[0].sieges[0][0]'],
|
||||||
|
['sièges non attribués, deux chaises vides : la première est nommée', (p) => {
|
||||||
|
p.siegesAttribues = false;
|
||||||
|
p.placement[0] = { sieges: [[null, null, 7], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2, 12] };
|
||||||
|
}, 'placement[0].sieges[0][0]'],
|
||||||
['null dans la réserve', (p) => { p.placement[0].reserve = [2, null]; }, 'placement[0].reserve[1]'],
|
['null dans la réserve', (p) => { p.placement[0].reserve = [2, null]; }, 'placement[0].reserve[1]'],
|
||||||
['null parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'],
|
['null parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'],
|
||||||
['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'],
|
['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'],
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
//
|
//
|
||||||
// types.js donne la table entière des codes du stockage.
|
// types.js donne la table entière des codes du stockage.
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
import { estHorodatage } from './journal.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Une racine : un dossier que l'implémentation connaît par son identifiant
|
* Une racine : un dossier que l'implémentation connaît par son identifiant
|
||||||
|
|
@ -134,9 +135,13 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* source absente ; le dossier de la cible doit exister
|
* source absente ; le dossier de la cible doit exister
|
||||||
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
|
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
|
||||||
* un fichier ; un fichier absent n'est pas une faute
|
* un fichier ; un fichier absent n'est pas une faute
|
||||||
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
|
* @property {(r: Racine, chemin: string, seance: string, depuis: string) => Promise<Verrou>} verrouiller
|
||||||
* création exclusive de {seance, pid, hote, depuis, demarrage} :
|
* création exclusive de {seance, pid, hote, depuis, demarrage} :
|
||||||
* un verrou présent n'est jamais repris, fût-il de la même séance
|
* un verrou présent n'est jamais repris, fût-il de la même séance.
|
||||||
|
* depuis, l'instant de la prise que donne l'horloge de
|
||||||
|
* l'application, s'inscrit tel quel ; un depuis qui n'a pas la
|
||||||
|
* forme d'un horodatage lève TypeError sans rien créer, là où
|
||||||
|
* verrouDisponible est faux comme ailleurs (exigerDepuis)
|
||||||
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
|
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
|
||||||
* n'efface que le verrou de cette séance ; un verrou absent n'est
|
* n'efface que le verrou de cette séance ; un verrou absent n'est
|
||||||
* pas une faute
|
* pas une faute
|
||||||
|
|
@ -176,3 +181,15 @@ export function exigerCheminRelatif(chemin) {
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lève TypeError quand depuis, l'instant de prise d'un verrou, n'a pas la
|
||||||
|
* forme d'un horodatage (estHorodatage) : chaque implémentation de
|
||||||
|
* verrouiller l'appelle avant de rien lire ou créer.
|
||||||
|
*
|
||||||
|
* @param {unknown} depuis
|
||||||
|
* @throws {TypeError}
|
||||||
|
*/
|
||||||
|
export function exigerDepuis(depuis) {
|
||||||
|
if (!estHorodatage(depuis)) throw new TypeError(`instant de prise : horodatage attendu, reçu ${JSON.stringify(depuis)}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,14 +162,16 @@
|
||||||
* correspondent rang à rang ; placement porte un tour par élément :
|
* correspondent rang à rang ; placement porte un tour par élément :
|
||||||
* sieges[i] liste les occupants de tables[i] dans l'ordre des sièges —
|
* sieges[i] liste les occupants de tables[i] dans l'ordre des sièges —
|
||||||
* triés par identifiant quand siegesAttribues est faux —, reserve ceux qui
|
* triés par identifiant quand siegesAttribues est faux —, reserve ceux qui
|
||||||
* ne sont assis nulle part à ce tour. siegesAttribues appartient à la
|
* ne sont assis nulle part à ce tour. Sous des sièges attribués, null marque
|
||||||
|
* une chaise vide, avant le dernier occupant, siège 1 compris : il compte
|
||||||
|
* dans la longueur de la liste. siegesAttribues appartient à la
|
||||||
* proposition, ou au retenu, et non au réglage courant : changer le réglage
|
* proposition, ou au retenu, et non au réglage courant : changer le réglage
|
||||||
* ne détruit pas l'ordre des sièges d'une proposition déjà produite.
|
* ne détruit pas l'ordre des sièges d'une proposition déjà produite.
|
||||||
* participants et chaque reserve sont des ensembles, triés par identifiant
|
* participants et chaque reserve sont des ensembles, triés par identifiant
|
||||||
* croissant quel que soit siegesAttribues.
|
* croissant quel que soit siegesAttribues.
|
||||||
*
|
*
|
||||||
* @typedef {Object} TourDePlacement
|
* @typedef {Object} TourDePlacement
|
||||||
* @property {number[][]} sieges
|
* @property {(number|null)[][]} sieges null : une chaise vide
|
||||||
* @property {number[]} reserve triée par identifiant croissant
|
* @property {number[]} reserve triée par identifiant croissant
|
||||||
*
|
*
|
||||||
* @typedef {Object} PropositionFichier
|
* @typedef {Object} PropositionFichier
|
||||||
|
|
@ -452,9 +454,12 @@
|
||||||
* signalé et gardé (§ 8.9, point 3). Les raisons de dérive d'un placement
|
* signalé et gardé (§ 8.9, point 3). Les raisons de dérive d'un placement
|
||||||
* cohérent sont décrites avec derive (placements.js, Raison).
|
* cohérent sont décrites avec derive (placements.js, Raison).
|
||||||
*
|
*
|
||||||
* FORME {chemin} une valeur sort de sa règle, ou la
|
* FORME {chemin} une valeur sort de sa règle, la
|
||||||
* déclaration se contredit ; chemin
|
* déclaration se contredit, ou une
|
||||||
* dans le placement, '' pour lui-même
|
* chaise vide est mal placée — sans
|
||||||
|
* attribution des sièges, ou en fin
|
||||||
|
* de liste ; chemin dans le
|
||||||
|
* placement, '' pour lui-même
|
||||||
* LONGUEUR {tour, table, une longueur écrite contredit celle
|
* LONGUEUR {tour, table, une longueur écrite contredit celle
|
||||||
* declare, ecrit} que le placement déclare : nombre de
|
* declare, ecrit} que le placement déclare : nombre de
|
||||||
* tours (tour et table null), de listes
|
* tours (tour et table null), de listes
|
||||||
|
|
|
||||||
56
test/bandes.js
Normal file
56
test/bandes.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// L'épreuve des bandes, partagée par les épreuves de la géométrie des tables
|
||||||
|
// et du mobilier : une liste de noms passe-t-elle sur une autre table ? Le
|
||||||
|
// calcul est écrit ici à part de listeSurTable (src/geometrie/tables.js),
|
||||||
|
// qu'il éprouve : seule la hauteur de ligne supposée vient du module, une
|
||||||
|
// valeur et non un calcul.
|
||||||
|
import { constante } from '../src/geometrie/constantes.js';
|
||||||
|
import { HAUTEUR_LIGNE_SUPPOSEE, geometrieTable } from '../src/geometrie/tables.js';
|
||||||
|
|
||||||
|
export { HAUTEUR_LIGNE_SUPPOSEE };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les fautes « une liste passe sur une autre table » d'un ensemble de tables
|
||||||
|
* posées : pour chaque paire ordonnée, la bande du bloc de noms de la
|
||||||
|
* première — LARGEUR_BLOC_NOMS_CM de large depuis origineListe, places + 1
|
||||||
|
* lignes de HAUTEUR_LIGNE_SUPPOSEE, en-tête d'une table gelée compris —
|
||||||
|
* recoupe le carré d'emprise de la seconde, bords exclus.
|
||||||
|
*
|
||||||
|
* @param {Array<{id?: number, position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} posees
|
||||||
|
* id, l'identifiant de la table ; son rang compté depuis 1 quand il manque
|
||||||
|
* @param {{avec?: number|null}} [options] avec : seules les paires où figure
|
||||||
|
* la table de cet identifiant sont examinées
|
||||||
|
* @returns {{fautes: string[], paires: number}} les fautes, et le nombre de
|
||||||
|
* paires examinées
|
||||||
|
*/
|
||||||
|
export function fautesBandes(posees, { avec = null } = {}) {
|
||||||
|
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
|
||||||
|
const geometries = posees.map(({ id, position, forme, places }, rang) => ({
|
||||||
|
g: geometrieTable({ id: id ?? rang + 1, numero: rang + 1, forme, position }, places),
|
||||||
|
places,
|
||||||
|
}));
|
||||||
|
const fautes = [];
|
||||||
|
let paires = 0;
|
||||||
|
for (const { g, places } of geometries) {
|
||||||
|
const bande = {
|
||||||
|
gauche: g.origineListe.x,
|
||||||
|
droite: g.origineListe.x + largeur,
|
||||||
|
haut: g.origineListe.y,
|
||||||
|
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
|
||||||
|
};
|
||||||
|
for (const { g: autre } of geometries) {
|
||||||
|
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
|
||||||
|
paires += 1;
|
||||||
|
const e = autre.rayonEmprise;
|
||||||
|
const recoupe =
|
||||||
|
autre.centre.x + e > bande.gauche &&
|
||||||
|
autre.centre.x - e < bande.droite &&
|
||||||
|
autre.centre.y + e > bande.haut &&
|
||||||
|
autre.centre.y - e < bande.bas;
|
||||||
|
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fautes, paires };
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
// ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté
|
// ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté
|
||||||
// d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la
|
// d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la
|
||||||
// suite exige du verrou ce que la plateforme promet à la place : verrouiller
|
// suite exige du verrou ce que la plateforme promet à la place : verrouiller
|
||||||
// rend { pris: true } à chaque appel, et deverrouiller ne lève pas.
|
// rend { pris: true } à chaque appel, et deverrouiller ne lève pas. Partout,
|
||||||
|
// l'instant de prise que verrouiller reçoit a la forme d'un horodatage.
|
||||||
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
||||||
|
|
||||||
// Les primitives de l'interface, dans l'ordre où elle les déclare.
|
// Les primitives de l'interface, dans l'ordre où elle les déclare.
|
||||||
|
|
@ -44,6 +45,15 @@ const PRIMITIVES = [
|
||||||
'enregistrerSous',
|
'enregistrerSous',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Instants de prise d'un verrou, de la forme qu'écrit l'horloge de
|
||||||
|
// l'application : le verrou les inscrit tels quels, décalage compris.
|
||||||
|
const DEPUIS = '2031-05-17T19:42:07-04:00';
|
||||||
|
const DEPUIS_LOINTAIN = '2031-05-18T06:12:00+05:30';
|
||||||
|
|
||||||
|
// Des valeurs qui n'ont pas la forme d'un horodatage : absente, vide, sans
|
||||||
|
// décalage, en temps universel, un instant en millisecondes, une date seule.
|
||||||
|
const DEPUIS_FAUTIFS = [undefined, null, '', '2031-05-17 19:42:07', '2031-05-17T19:42:07Z', 1_950_000_000_000, '2031-05-17'];
|
||||||
|
|
||||||
// Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix,
|
// Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix,
|
||||||
// absolu sous Windows, remontant après un détour.
|
// absolu sous Windows, remontant après un détour.
|
||||||
const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x'];
|
const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x'];
|
||||||
|
|
@ -61,7 +71,7 @@ const APPELS_A_CHEMIN = [
|
||||||
['deplacer, source', (fs, racine, chemin) => fs.deplacer(racine, chemin, 'ailleurs.txt')],
|
['deplacer, source', (fs, racine, chemin) => fs.deplacer(racine, chemin, 'ailleurs.txt')],
|
||||||
['deplacer, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)],
|
['deplacer, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)],
|
||||||
['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)],
|
['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)],
|
||||||
['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a')],
|
['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a', DEPUIS)],
|
||||||
['deverrouiller', (fs, racine, chemin) => fs.deverrouiller(racine, chemin, 'seance-a')],
|
['deverrouiller', (fs, racine, chemin) => fs.deverrouiller(racine, chemin, 'seance-a')],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -275,31 +285,40 @@ export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rej
|
||||||
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
|
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verrouiller est exclusif : un second appel rend la séance du premier, vivante', async () => {
|
test("verrouiller est exclusif : un second appel rend la séance du premier, vivante, et l'instant qu'il a reçu, tel quel", async () => {
|
||||||
const { fs, racine } = await fabrique();
|
const { fs, racine } = await fabrique();
|
||||||
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', DEPUIS_LOINTAIN), { pris: true });
|
||||||
const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
|
const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b', DEPUIS);
|
||||||
if (!fs.verrouDisponible) {
|
if (!fs.verrouDisponible) {
|
||||||
egal(second, { pris: true });
|
egal(second, { pris: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vrai(typeof second.depuis === 'string' && second.depuis !== '', `depuis ${second.depuis}`);
|
egal(second, { pris: false, seance: 'seance-a', depuis: DEPUIS_LOINTAIN, vivant: true });
|
||||||
egal(second, { pris: false, seance: 'seance-a', depuis: second.depuis, vivant: true });
|
});
|
||||||
|
|
||||||
|
test("verrouiller refuse un instant qui n'a pas la forme d'un horodatage : TypeError, et rien ne se crée", async () => {
|
||||||
|
const { fs, racine } = await fabrique();
|
||||||
|
for (const depuis of DEPUIS_FAUTIFS) {
|
||||||
|
const raison = await rejette(fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', depuis), String(depuis));
|
||||||
|
vrai(raison instanceof TypeError, `${String(depuis)} : ${raison}`);
|
||||||
|
}
|
||||||
|
egal(noms(await fs.lister(racine, '')), []);
|
||||||
|
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', DEPUIS), { pris: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deverrouiller n'efface que le verrou de sa séance, et un verrou absent n'est pas une faute", async () => {
|
test("deverrouiller n'efface que le verrou de sa séance, et un verrou absent n'est pas une faute", async () => {
|
||||||
const { fs, racine } = await fabrique();
|
const { fs, racine } = await fabrique();
|
||||||
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
||||||
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', DEPUIS);
|
||||||
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
|
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
|
||||||
if (!fs.verrouDisponible) {
|
if (!fs.verrouDisponible) {
|
||||||
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
|
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c', DEPUIS), { pris: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c');
|
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c', DEPUIS_LOINTAIN);
|
||||||
egal([tenu.pris, tenu.seance], [false, 'seance-a']);
|
egal([tenu.pris, tenu.seance, tenu.depuis], [false, 'seance-a', DEPUIS]);
|
||||||
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
||||||
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
|
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c', DEPUIS), { pris: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sonder rend inscriptible, efface le témoin resté et laisse les autres fichiers', async () => {
|
test('sonder rend inscriptible, efface le témoin resté et laisse les autres fichiers', async () => {
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,12 @@ describe('coquille : processus principal', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("à la sortie, la coquille confie les verrous tenus à rendreVerrousTenus, seul lecteur de leur format : elle n'en analyse aucun", () => {
|
||||||
|
const texte = readFileSync(PRINCIPAL, 'utf8');
|
||||||
|
assert.match(texte, /app\.on\('quit', \(\) => fichiers\.rendreVerrousTenus\(\)\)/);
|
||||||
|
assert.doesNotMatch(texte, /JSON\.parse|\.verrousTenus\(/);
|
||||||
|
});
|
||||||
|
|
||||||
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
|
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
|
||||||
const { restes } = coquille();
|
const { restes } = coquille();
|
||||||
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
|
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ const VERROU_REPRIS = 'reprise.gtt.verrou';
|
||||||
const SEANCE_PAGE = 'seance-page';
|
const SEANCE_PAGE = 'seance-page';
|
||||||
const SEANCE_REPRISE = 'seance-reprise';
|
const SEANCE_REPRISE = 'seance-reprise';
|
||||||
const VERROU_AUTRE = 'autre.gtt.verrou';
|
const VERROU_AUTRE = 'autre.gtt.verrou';
|
||||||
|
// L'instant de prise que la page passe avec chaque verrou, celui de son
|
||||||
|
// horloge.
|
||||||
|
const DEPUIS_PAGE = '2026-10-06T19:00:00-04:00';
|
||||||
const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName;
|
const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName;
|
||||||
|
|
||||||
// Ce que rend app.getPath, par nom.
|
// Ce que rend app.getPath, par nom.
|
||||||
|
|
@ -90,7 +93,7 @@ const ARGUMENTS_DE_LA_PAGE = {
|
||||||
creerDossier: () => [INCONNUE, 'corbeille'],
|
creerDossier: () => [INCONNUE, 'corbeille'],
|
||||||
deplacer: () => [INCONNUE, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'],
|
deplacer: () => [INCONNUE, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'],
|
||||||
supprimer: () => [INCONNUE, 'soiree.gtt.json'],
|
supprimer: () => [INCONNUE, 'soiree.gtt.json'],
|
||||||
verrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'],
|
verrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice', DEPUIS_PAGE],
|
||||||
deverrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'],
|
deverrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'],
|
||||||
ouvrirDansExplorateur: (racines) => [racines?.documents],
|
ouvrirDansExplorateur: (racines) => [racines?.documents],
|
||||||
enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])],
|
enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])],
|
||||||
|
|
@ -339,10 +342,10 @@ async function jouerLePrechargement() {
|
||||||
const documents = racines?.documents ?? null;
|
const documents = racines?.documents ?? null;
|
||||||
const verrou = documents === null || fichiers === undefined
|
const verrou = documents === null || fichiers === undefined
|
||||||
? null
|
? null
|
||||||
: await appelerUne(fichiers.verrouiller, [documents, VERROU_PAGE, SEANCE_PAGE]);
|
: await appelerUne(fichiers.verrouiller, [documents, VERROU_PAGE, SEANCE_PAGE, DEPUIS_PAGE]);
|
||||||
const repris = documents === null || fichiers === undefined
|
const repris = documents === null || fichiers === undefined
|
||||||
? null
|
? null
|
||||||
: await appelerUne(fichiers.verrouiller, [documents, VERROU_REPRIS, SEANCE_PAGE]);
|
: await appelerUne(fichiers.verrouiller, [documents, VERROU_REPRIS, SEANCE_PAGE, DEPUIS_PAGE]);
|
||||||
if (repris?.reponse?.ok === true) {
|
if (repris?.reponse?.ok === true) {
|
||||||
const chemin = join(documents.chemin, VERROU_REPRIS);
|
const chemin = join(documents.chemin, VERROU_REPRIS);
|
||||||
const contenu = JSON.parse(readFileSync(chemin, 'utf8'));
|
const contenu = JSON.parse(readFileSync(chemin, 'utf8'));
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ const NOM_PRODUIT = 'Gestion table tournante Libre';
|
||||||
// Sous Windows, un dossier ne passe pas en lecture seule par ses droits, et
|
// Sous Windows, un dossier ne passe pas en lecture seule par ses droits, et
|
||||||
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
|
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
|
||||||
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
|
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
|
||||||
|
// L'instant de prise qu'un verrou reçoit, de la forme qu'écrit l'horloge de
|
||||||
|
// l'application.
|
||||||
|
const PRISE = '2026-10-06T19:00:00-04:00';
|
||||||
|
|
||||||
const SOUS_WINDOWS = process.platform === 'win32';
|
const SOUS_WINDOWS = process.platform === 'win32';
|
||||||
const SOUS_LINUX = process.platform === 'linux';
|
const SOUS_LINUX = process.platform === 'linux';
|
||||||
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
|
|
@ -244,7 +248,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
await echoue(fs.ajouterLigne(racine, 'lien/secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
|
await echoue(fs.ajouterLigne(racine, 'lien/secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
|
||||||
await echoue(fs.lister(racine, 'lien'), 'CHEMIN_REFUSE', { chemin: 'lien' });
|
await echoue(fs.lister(racine, 'lien'), 'CHEMIN_REFUSE', { chemin: 'lien' });
|
||||||
await echoue(fs.deplacer(racine, 'ici.txt', 'lien/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/vole.txt' });
|
await echoue(fs.deplacer(racine, 'ici.txt', 'lien/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/vole.txt' });
|
||||||
await echoue(fs.verrouiller(racine, 'lien/neuf.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.gtt.verrou' });
|
await echoue(fs.verrouiller(racine, 'lien/neuf.gtt.verrou', 'seance-a', PRISE), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.gtt.verrou' });
|
||||||
await echoue(fs.deverrouiller(racine, 'lien/soiree.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/soiree.gtt.verrou' });
|
await echoue(fs.deverrouiller(racine, 'lien/soiree.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/soiree.gtt.verrou' });
|
||||||
if (!SOUS_WINDOWS) {
|
if (!SOUS_WINDOWS) {
|
||||||
symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt'));
|
symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt'));
|
||||||
|
|
@ -291,10 +295,10 @@ describe('coquille sur un vrai disque', () => {
|
||||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||||
mkdirSync(documents, { recursive: true });
|
mkdirSync(documents, { recursive: true });
|
||||||
const racine = (await fs.racines()).documents;
|
const racine = (await fs.racines()).documents;
|
||||||
assert.deepEqual(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
assert.deepEqual(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', PRISE), { pris: true });
|
||||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||||
assert.deepEqual([pose.pid, pose.hote], [process.pid, hostname()]);
|
assert.deepEqual([pose.pid, pose.hote], [process.pid, hostname()]);
|
||||||
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
|
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b', PRISE);
|
||||||
assert.deepEqual(tenu, { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true });
|
assert.deepEqual(tenu, { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true });
|
||||||
|
|
||||||
const termine = spawnSync(process.execPath, ['-e', '']);
|
const termine = spawnSync(process.execPath, ['-e', '']);
|
||||||
|
|
@ -304,7 +308,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
join(documents, 'ancienne.gtt.verrou'),
|
join(documents, 'ancienne.gtt.verrou'),
|
||||||
`${JSON.stringify({ seance: 'seance-terminee', pid: termine.pid, hote: hostname(), depuis })}\n`,
|
`${JSON.stringify({ seance: 'seance-terminee', pid: termine.pid, hote: hostname(), depuis })}\n`,
|
||||||
);
|
);
|
||||||
assert.deepEqual(await fs.verrouiller(racine, 'ancienne.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fs.verrouiller(racine, 'ancienne.gtt.verrou', 'seance-b', PRISE), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'seance-terminee',
|
seance: 'seance-terminee',
|
||||||
depuis,
|
depuis,
|
||||||
|
|
@ -324,7 +328,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||||
mkdirSync(documents, { recursive: true });
|
mkdirSync(documents, { recursive: true });
|
||||||
const racine = (await fs.racines()).documents;
|
const racine = (await fs.racines()).documents;
|
||||||
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||||
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
||||||
assert.equal(pose.demarrage, demarrageDe(process.pid));
|
assert.equal(pose.demarrage, demarrageDe(process.pid));
|
||||||
|
|
@ -344,7 +348,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
|
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b'),
|
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b', PRISE),
|
||||||
{ pris: false, seance: 'seance-p', depuis, vivant },
|
{ pris: false, seance: 'seance-p', depuis, vivant },
|
||||||
nom,
|
nom,
|
||||||
);
|
);
|
||||||
|
|
@ -372,7 +376,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
shell: SANS_APPEL,
|
shell: SANS_APPEL,
|
||||||
plateforme: 'darwin',
|
plateforme: 'darwin',
|
||||||
});
|
});
|
||||||
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a', PRISE), { pris: true });
|
||||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||||
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
|
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
|
||||||
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
||||||
|
|
@ -387,7 +391,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
const lecteur = [
|
const lecteur = [
|
||||||
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
|
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
|
||||||
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
|
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
|
||||||
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b");',
|
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b", "2026-10-06T19:05:00-04:00");',
|
||||||
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
|
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
|
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,9 @@ const DEV_DISQUE = 0x801n;
|
||||||
const HOTE = 'poste-essai';
|
const HOTE = 'poste-essai';
|
||||||
const PID = 4242;
|
const PID = 4242;
|
||||||
|
|
||||||
// Instant que rend l'horloge injectée.
|
// L'instant de prise qu'un verrou reçoit, de la forme qu'écrit l'horloge de
|
||||||
const INSTANT = new Date(Date.UTC(2026, 9, 6, 23, 2, 11));
|
// l'application.
|
||||||
|
const PRISE = '2026-10-06T19:02:11-04:00';
|
||||||
|
|
||||||
// Les racines de l'épreuve, et ce que les échecs y nomment.
|
// Les racines de l'épreuve, et ce que les échecs y nomment.
|
||||||
const DOCUMENTS = '/poste/exemple/Documents/Gestion table tournante Libre';
|
const DOCUMENTS = '/poste/exemple/Documents/Gestion table tournante Libre';
|
||||||
|
|
@ -201,6 +202,21 @@ function creerDisque() {
|
||||||
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin);
|
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin);
|
||||||
return Buffer.from(noeud.octets);
|
return Buffer.from(noeud.octets);
|
||||||
},
|
},
|
||||||
|
// Les deux appels synchrones de la sortie de la coquille.
|
||||||
|
readFileSync(chemin) {
|
||||||
|
appel('readFileSync', chemin);
|
||||||
|
const { noeud } = resoudre(chemin, true, 'open');
|
||||||
|
if (noeud === undefined) throw erreurSysteme('ENOENT', 'open', chemin);
|
||||||
|
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin);
|
||||||
|
return Buffer.from(noeud.octets);
|
||||||
|
},
|
||||||
|
unlinkSync(chemin) {
|
||||||
|
appel('unlinkSync', chemin);
|
||||||
|
const { chemin: resolu, noeud } = resoudre(chemin, false, 'unlink');
|
||||||
|
if (noeud === undefined) throw erreurSysteme('ENOENT', 'unlink', chemin);
|
||||||
|
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'unlink', chemin);
|
||||||
|
noeuds.delete(resolu);
|
||||||
|
},
|
||||||
async open(chemin, drapeaux) {
|
async open(chemin, drapeaux) {
|
||||||
appel('open', chemin, drapeaux);
|
appel('open', chemin, drapeaux);
|
||||||
const exclusif = drapeaux === 'wx';
|
const exclusif = drapeaux === 'wx';
|
||||||
|
|
@ -385,7 +401,6 @@ function preparer({
|
||||||
demarrage,
|
demarrage,
|
||||||
pid: PID,
|
pid: PID,
|
||||||
hote,
|
hote,
|
||||||
maintenant: () => INSTANT,
|
|
||||||
});
|
});
|
||||||
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
|
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
|
||||||
}
|
}
|
||||||
|
|
@ -451,7 +466,7 @@ const APPELS_A_CHEMIN = [
|
||||||
['deplacer, source', (fichiers, racine, chemin) => fichiers.deplacer(racine, chemin, 'libre.txt')],
|
['deplacer, source', (fichiers, racine, chemin) => fichiers.deplacer(racine, chemin, 'libre.txt')],
|
||||||
['deplacer, cible', (fichiers, racine, chemin) => fichiers.deplacer(racine, 'ici.txt', chemin)],
|
['deplacer, cible', (fichiers, racine, chemin) => fichiers.deplacer(racine, 'ici.txt', chemin)],
|
||||||
['supprimer', (fichiers, racine, chemin) => fichiers.supprimer(racine, chemin)],
|
['supprimer', (fichiers, racine, chemin) => fichiers.supprimer(racine, chemin)],
|
||||||
['verrouiller', (fichiers, racine, chemin) => fichiers.verrouiller(racine, chemin, 'seance-a')],
|
['verrouiller', (fichiers, racine, chemin) => fichiers.verrouiller(racine, chemin, 'seance-a', PRISE)],
|
||||||
['deverrouiller', (fichiers, racine, chemin) => fichiers.deverrouiller(racine, chemin, 'seance-a')],
|
['deverrouiller', (fichiers, racine, chemin) => fichiers.deverrouiller(racine, chemin, 'seance-a')],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -986,19 +1001,69 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
|
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
|
||||||
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
|
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
|
||||||
|
|
||||||
test("se crée en exclusif, porte {seance, pid, hote, depuis, demarrage} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
|
test("se crée en exclusif, porte {seance, pid, hote, depuis, demarrage} et une fin de ligne ; depuis est l'instant reçu, tel quel", async () => {
|
||||||
const processus = tableDeProcessus([[PID, '918273']]);
|
const processus = tableDeProcessus([[PID, '918273']]);
|
||||||
const { disque, fichiers } = preparer(processus);
|
const { disque, fichiers } = preparer(processus);
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
const recu = '2026-10-07T04:32:11+05:30';
|
||||||
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', recu), { pris: true });
|
||||||
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
|
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
|
||||||
const texte = disque.contenu(VERROU);
|
const texte = disque.contenu(VERROU);
|
||||||
assert.ok(texte.endsWith('}\n'), texte);
|
assert.ok(texte.endsWith('}\n'), texte);
|
||||||
const contenu = JSON.parse(texte);
|
const contenu = JSON.parse(texte);
|
||||||
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
||||||
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null, demarrage: '918273' });
|
assert.deepEqual(contenu, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: recu, demarrage: '918273' });
|
||||||
assert.deepEqual(processus.lus, [PID]);
|
assert.deepEqual(processus.lus, [PID]);
|
||||||
assert.match(contenu.depuis, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
|
});
|
||||||
assert.equal(Date.parse(contenu.depuis), INSTANT.getTime());
|
|
||||||
|
test("un instant de prise qui n'est pas une chaîne lève TypeError, sans rien lire ni créer", async () => {
|
||||||
|
const { disque, fichiers } = preparer();
|
||||||
|
for (const depuis of [undefined, null, 1_950_000_000_000, new Date(0)]) {
|
||||||
|
await assert.rejects(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', depuis), TypeError, String(depuis));
|
||||||
|
}
|
||||||
|
assert.deepEqual(disque.appels, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rendreVerrousTenus efface, d'un trait et sans attendre, chaque verrou pris et pas rendu qui porte encore sa séance ; un verrou repris, illisible ou absent reste tel", async () => {
|
||||||
|
const { disque, fichiers } = preparer();
|
||||||
|
const chemin = (nom) => `${DOCUMENTS}/${nom}`;
|
||||||
|
for (const nom of ['garde.gtt.verrou', 'repris.gtt.verrou', 'illisible.gtt.verrou', 'parti.gtt.verrou', 'rendu.gtt.verrou']) {
|
||||||
|
assert.deepEqual(await fichiers.verrouiller(DOC, nom, 'seance-a', PRISE), { pris: true }, nom);
|
||||||
|
}
|
||||||
|
await fichiers.deverrouiller(DOC, 'rendu.gtt.verrou', 'seance-a');
|
||||||
|
const reprise = `${JSON.stringify({ seance: 'seance-b', pid: 777, hote: HOTE, depuis: PRISE, demarrage: null })}\n`;
|
||||||
|
disque.deposer(chemin('repris.gtt.verrou'), reprise);
|
||||||
|
disque.deposer(chemin('illisible.gtt.verrou'), '{"seance":');
|
||||||
|
await fichiers.supprimer(DOC, 'parti.gtt.verrou');
|
||||||
|
disque.deposer(chemin('rendu.gtt.verrou'), reprise.replace('seance-b', 'seance-a'));
|
||||||
|
const avant = disque.appels.length;
|
||||||
|
assert.equal(fichiers.rendreVerrousTenus(), undefined);
|
||||||
|
assert.deepEqual(disque.appels.slice(avant), [
|
||||||
|
`readFileSync ${chemin('garde.gtt.verrou')}`,
|
||||||
|
`unlinkSync ${chemin('garde.gtt.verrou')}`,
|
||||||
|
`readFileSync ${chemin('repris.gtt.verrou')}`,
|
||||||
|
`readFileSync ${chemin('illisible.gtt.verrou')}`,
|
||||||
|
`readFileSync ${chemin('parti.gtt.verrou')}`,
|
||||||
|
]);
|
||||||
|
assert.equal(disque.existe(chemin('garde.gtt.verrou')), false);
|
||||||
|
assert.equal(disque.contenu(chemin('repris.gtt.verrou')), reprise);
|
||||||
|
assert.equal(disque.contenu(chemin('illisible.gtt.verrou')), '{"seance":');
|
||||||
|
// Le verrou rendu par deverrouiller n'est plus tenu : un verrou de même
|
||||||
|
// séance posé depuis à sa place reste.
|
||||||
|
assert.notEqual(disque.contenu(chemin('rendu.gtt.verrou')), null);
|
||||||
|
// Un second appel n'a plus rien à rendre.
|
||||||
|
const apres = disque.appels.length;
|
||||||
|
fichiers.rendreVerrousTenus();
|
||||||
|
assert.deepEqual(disque.appels.slice(apres), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rendreVerrousTenus ne lève pas : une lecture ou un effacement refusés laissent le verrou, et les suivants se rendent", async () => {
|
||||||
|
const { disque, fichiers } = preparer();
|
||||||
|
const chemin = (nom) => `${DOCUMENTS}/${nom}`;
|
||||||
|
for (const nom of ['a.gtt.verrou', 'b.gtt.verrou', 'c.gtt.verrou']) await fichiers.verrouiller(DOC, nom, 'seance-a', PRISE);
|
||||||
|
disque.panne('readFileSync', 'EACCES', { chemin: chemin('a.gtt.verrou') });
|
||||||
|
disque.panne('unlinkSync', 'EBUSY', { chemin: chemin('b.gtt.verrou') });
|
||||||
|
fichiers.rendreVerrousTenus();
|
||||||
|
assert.deepEqual(['a', 'b', 'c'].map((nom) => disque.existe(chemin(`${nom}.gtt.verrou`))), [true, true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("un verrou présent rend sa séance : morte quand son pid ne répond pas ; sans démarrage inscrit, un pid qui répond, même sans droit de le signaler, laisse vivant inconnu", async () => {
|
test("un verrou présent rend sa séance : morte quand son pid ne répond pas ; sans démarrage inscrit, un pid qui répond, même sans droit de le signaler, laisse vivant inconnu", async () => {
|
||||||
|
|
@ -1010,7 +1075,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const { disque, fichiers, signaux } = preparer({ signaler });
|
const { disque, fichiers, signaux } = preparer({ signaler });
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: '2026-10-06T19:02:11-04:00' });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: '2026-10-06T19:02:11-04:00' });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'),
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE),
|
||||||
{ pris: false, seance: 'seance-a', depuis: '2026-10-06T19:02:11-04:00', vivant },
|
{ pris: false, seance: 'seance-a', depuis: '2026-10-06T19:02:11-04:00', vivant },
|
||||||
nom,
|
nom,
|
||||||
);
|
);
|
||||||
|
|
@ -1028,13 +1093,13 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
return reponses.length > 1 ? reponses.shift() : reponses[0];
|
return reponses.length > 1 ? reponses.shift() : reponses[0];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, null);
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, null);
|
||||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
||||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
||||||
assert.deepEqual(lus, [PID, PID]);
|
assert.deepEqual(lus, [PID, PID]);
|
||||||
});
|
});
|
||||||
|
|
@ -1056,7 +1121,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const processus = tableDeProcessus([[PID, '1'], [777, demarrageLu]]);
|
const processus = tableDeProcessus([[PID, '1'], [777, demarrageLu]]);
|
||||||
const { disque, fichiers } = preparer(processus);
|
const { disque, fichiers } = preparer(processus);
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, ...inscrit });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, ...inscrit });
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(vivant), nom);
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), tenu(vivant), nom);
|
||||||
// Le démarrage du processus courant se lit avant la prise, celui du
|
// Le démarrage du processus courant se lit avant la prise, celui du
|
||||||
// pid inscrit une fois le verrou trouvé.
|
// pid inscrit une fois le verrou trouvé.
|
||||||
assert.deepEqual(processus.lus, lus, nom);
|
assert.deepEqual(processus.lus, lus, nom);
|
||||||
|
|
@ -1066,8 +1131,8 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const processus = tableDeProcessus([[PID, '1']]);
|
const processus = tableDeProcessus([[PID, '1']]);
|
||||||
const { disque, fichiers } = preparer(processus);
|
const { disque, fichiers } = preparer(processus);
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, demarrage: '5000' });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, demarrage: '5000' });
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), tenu(false));
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), tenu(false));
|
||||||
assert.deepEqual(processus.lus, [PID]);
|
assert.deepEqual(processus.lus, [PID]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1076,7 +1141,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
for (const inscrit of [{ demarrage: 5000 }, { demarrage: true }, { demarrage: {} }]) {
|
for (const inscrit of [{ demarrage: 5000 }, { demarrage: true }, { demarrage: {} }]) {
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: 'x', ...inscrit });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: 'x', ...inscrit });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'),
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE),
|
||||||
{ pris: false, seance: null, depuis: null, vivant: null },
|
{ pris: false, seance: null, depuis: null, vivant: null },
|
||||||
JSON.stringify(inscrit),
|
JSON.stringify(inscrit),
|
||||||
);
|
);
|
||||||
|
|
@ -1097,11 +1162,11 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
// ligne en porte assez.
|
// ligne en porte assez.
|
||||||
disque.deposer('/proc/782/stat', stat(782, 5000).replace(/[()]/g, ''));
|
disque.deposer('/proc/782/stat', stat(782, 5000).replace(/[()]/g, ''));
|
||||||
const { fichiers } = preparer({ disque, signaler: () => {} });
|
const { fichiers } = preparer({ disque, signaler: () => {} });
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '31337');
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '31337');
|
||||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null], [781, null], [782, null]]) {
|
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null], [781, null], [782, null]]) {
|
||||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '5000' });
|
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '5000' });
|
||||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE)).vivant, vivant, String(pid));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1115,7 +1180,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
return reponses.get(variables.GTT_PID);
|
return reponses.get(variables.GTT_PID);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '133725999999999999');
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '133725999999999999');
|
||||||
assert.deepEqual(commandes.map(({ commande }) => commande), ['powershell.exe']);
|
assert.deepEqual(commandes.map(({ commande }) => commande), ['powershell.exe']);
|
||||||
const [{ arguments_, options }] = commandes;
|
const [{ arguments_, options }] = commandes;
|
||||||
|
|
@ -1125,7 +1190,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
assert.deepEqual(options, { variables: { GTT_PID: String(PID) }, delai: DELAI_SUPPORT_MS });
|
assert.deepEqual(options, { variables: { GTT_PID: String(PID) }, delai: DELAI_SUPPORT_MS });
|
||||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null]]) {
|
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null]]) {
|
||||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '133725000000000000' });
|
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '133725000000000000' });
|
||||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE)).vivant, vivant, String(pid));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1142,7 +1207,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 4312, hote: HOTE, depuis: DEPUIS, demarrage: '133725000000000000' });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 4312, hote: HOTE, depuis: DEPUIS, demarrage: '133725000000000000' });
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'seance-a',
|
seance: 'seance-a',
|
||||||
depuis: DEPUIS,
|
depuis: DEPUIS,
|
||||||
|
|
@ -1157,7 +1222,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
plateforme: 'darwin',
|
plateforme: 'darwin',
|
||||||
lancer: async (_commande, arguments_) => sorties.get(arguments_[3]) ?? 'Wed Oct 7 08:00:00 2026\n',
|
lancer: async (_commande, arguments_) => sorties.get(arguments_[3]) ?? 'Wed Oct 7 08:00:00 2026\n',
|
||||||
});
|
});
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, 'Wed Oct 7 08:00:00 2026');
|
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, 'Wed Oct 7 08:00:00 2026');
|
||||||
assert.deepEqual(commandes[0], {
|
assert.deepEqual(commandes[0], {
|
||||||
commande: 'ps',
|
commande: 'ps',
|
||||||
|
|
@ -1166,11 +1231,11 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
});
|
});
|
||||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null]]) {
|
for (const [pid, vivant] of [[777, true], [778, false], [779, null]]) {
|
||||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: 'Tue Oct 6 19:02:11 2026' });
|
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: 'Tue Oct 6 19:02:11 2026' });
|
||||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE)).vivant, vivant, String(pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ailleurs = preparer({ plateforme: 'freebsd' });
|
const ailleurs = preparer({ plateforme: 'freebsd' });
|
||||||
await ailleurs.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await ailleurs.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
assert.equal(JSON.parse(ailleurs.disque.contenu(VERROU)).demarrage, null);
|
assert.equal(JSON.parse(ailleurs.disque.contenu(VERROU)).demarrage, null);
|
||||||
assert.deepEqual(ailleurs.commandes, []);
|
assert.deepEqual(ailleurs.commandes, []);
|
||||||
});
|
});
|
||||||
|
|
@ -1186,17 +1251,17 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
let fuseauDuPoste = 'America/Montreal';
|
let fuseauDuPoste = 'America/Montreal';
|
||||||
const lancer = async (_commande, _arguments, { variables }) => `${ecrit.get(variables.TZ ?? fuseauDuPoste)}\n`;
|
const lancer = async (_commande, _arguments, { variables }) => `${ecrit.get(variables.TZ ?? fuseauDuPoste)}\n`;
|
||||||
const premiere = preparer({ plateforme: 'darwin', lancer });
|
const premiere = preparer({ plateforme: 'darwin', lancer });
|
||||||
assert.deepEqual(await premiere.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
assert.deepEqual(await premiere.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE), { pris: true });
|
||||||
fuseauDuPoste = 'Europe/Paris';
|
fuseauDuPoste = 'Europe/Paris';
|
||||||
const seconde = preparer({ disque: premiere.disque, plateforme: 'darwin', lancer });
|
const seconde = preparer({ disque: premiere.disque, plateforme: 'darwin', lancer });
|
||||||
const tenu = await seconde.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
|
const tenu = await seconde.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE);
|
||||||
assert.deepEqual({ seance: tenu.seance, vivant: tenu.vivant }, { seance: 'seance-a', vivant: true });
|
assert.deepEqual({ seance: tenu.seance, vivant: tenu.vivant }, { seance: 'seance-a', vivant: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => {
|
test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => {
|
||||||
const { disque, fichiers, signaux } = preparer();
|
const { disque, fichiers, signaux } = preparer();
|
||||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
|
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'seance-a',
|
seance: 'seance-a',
|
||||||
depuis: '2026-10-06T19:02:11-04:00',
|
depuis: '2026-10-06T19:02:11-04:00',
|
||||||
|
|
@ -1206,7 +1271,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
|
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
|
||||||
for (const texte of ['{"seance":"seance-a"', '{"seance":"seance-a","pid":0,"hote":"poste-essai","depuis":"x"}', '[]']) {
|
for (const texte of ['{"seance":"seance-a"', '{"seance":"seance-a","pid":0,"hote":"poste-essai","depuis":"x"}', '[]']) {
|
||||||
disque.deposer(VERROU, texte);
|
disque.deposer(VERROU, texte);
|
||||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), illisible, texte);
|
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', PRISE), illisible, texte);
|
||||||
}
|
}
|
||||||
assert.deepEqual(signaux, []);
|
assert.deepEqual(signaux, []);
|
||||||
});
|
});
|
||||||
|
|
@ -1214,7 +1279,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
test("deverrouiller n'efface que le verrou de sa séance ; ni un absent ni un illisible ne sont des fautes", async () => {
|
test("deverrouiller n'efface que le verrou de sa séance ; ni un absent ni un illisible ne sont des fautes", async () => {
|
||||||
const { disque, fichiers } = preparer();
|
const { disque, fichiers } = preparer();
|
||||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE);
|
||||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
|
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
|
||||||
assert.equal(disque.existe(VERROU), true);
|
assert.equal(disque.existe(VERROU), true);
|
||||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
|
|
@ -1227,10 +1292,10 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
test("un verrou qui ne s'écrit pas jusqu'au bout est retiré, et lève ECRITURE ; un dossier à sa place lève ECRITURE", async () => {
|
test("un verrou qui ne s'écrit pas jusqu'au bout est retiré, et lève ECRITURE ; un dossier à sa place lève ECRITURE", async () => {
|
||||||
const { disque, fichiers } = preparer();
|
const { disque, fichiers } = preparer();
|
||||||
disque.panne('write', 'ENOSPC', { chemin: VERROU });
|
disque.panne('write', 'ENOSPC', { chemin: VERROU });
|
||||||
await refuse(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('soiree.gtt.verrou', DOCUMENTS, 'ENOSPC'));
|
await refuse(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', PRISE), 'ECRITURE', ecriture('soiree.gtt.verrou', DOCUMENTS, 'ENOSPC'));
|
||||||
assert.equal(disque.existe(VERROU), false);
|
assert.equal(disque.existe(VERROU), false);
|
||||||
disque.deposerDossier(`${DOCUMENTS}/dossier.gtt.verrou`);
|
disque.deposerDossier(`${DOCUMENTS}/dossier.gtt.verrou`);
|
||||||
await refuse(fichiers.verrouiller(DOC, 'dossier.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('dossier.gtt.verrou', DOCUMENTS, 'EISDIR'));
|
await refuse(fichiers.verrouiller(DOC, 'dossier.gtt.verrou', 'seance-a', PRISE), 'ECRITURE', ecriture('dossier.gtt.verrou', DOCUMENTS, 'EISDIR'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processusVivant : un processus qui répond au signal 0 vit, comme celui qu'on n'a pas le droit de signaler ; un absent non", () => {
|
test("processusVivant : un processus qui répond au signal 0 vit, comme celui qu'on n'a pas le droit de signaler ; un absent non", () => {
|
||||||
|
|
@ -1249,7 +1314,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||||
test('la séance est une chaîne non vide', async () => {
|
test('la séance est une chaîne non vide', async () => {
|
||||||
const { disque, fichiers } = preparer();
|
const { disque, fichiers } = preparer();
|
||||||
for (const seance of ['', null, 7]) {
|
for (const seance of ['', null, 7]) {
|
||||||
await assert.rejects(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance));
|
await assert.rejects(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', seance, PRISE), TypeError, String(seance));
|
||||||
await assert.rejects(fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance));
|
await assert.rejects(fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance));
|
||||||
}
|
}
|
||||||
assert.deepEqual(disque.appels, []);
|
assert.deepEqual(disque.appels, []);
|
||||||
|
|
@ -1712,7 +1777,7 @@ describe('page : src/stockage/fichiers_electron.js', () => {
|
||||||
['creerDossier', [DOC, 'corbeille']],
|
['creerDossier', [DOC, 'corbeille']],
|
||||||
['deplacer', [DOC, 'a.txt', 'b.txt']],
|
['deplacer', [DOC, 'a.txt', 'b.txt']],
|
||||||
['supprimer', [DOC, 'a.txt']],
|
['supprimer', [DOC, 'a.txt']],
|
||||||
['verrouiller', [DOC, 'a.gtt.verrou', 'seance-a']],
|
['verrouiller', [DOC, 'a.gtt.verrou', 'seance-a', PRISE]],
|
||||||
['deverrouiller', [DOC, 'a.gtt.verrou', 'seance-a']],
|
['deverrouiller', [DOC, 'a.gtt.verrou', 'seance-a']],
|
||||||
['ouvrirDansExplorateur', [DOC]],
|
['ouvrirDansExplorateur', [DOC]],
|
||||||
['choisirFichierAImporter', []],
|
['choisirFichierAImporter', []],
|
||||||
|
|
@ -1726,6 +1791,16 @@ describe('page : src/stockage/fichiers_electron.js', () => {
|
||||||
assert.deepEqual(recus, appels.map(([primitive, parametres]) => [primitive, ...parametres]));
|
assert.deepEqual(recus, appels.map(([primitive, parametres]) => [primitive, ...parametres]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("verrouiller refuse, avant le pont, un instant de prise qui n'a pas la forme d'un horodatage : TypeError", async () => {
|
||||||
|
const { pont, appels } = pontFactice([]);
|
||||||
|
const fs = creerFichiersElectron(pont);
|
||||||
|
for (const depuis of [undefined, '', '2026-10-06T19:02:11Z', '2026-10-06 19:02:11-04:00']) {
|
||||||
|
const erreur = await rejette(fs.verrouiller(DOC, 'a.gtt.verrou', 'seance-a', depuis));
|
||||||
|
assert.ok(erreur instanceof TypeError, `${String(depuis)} : ${erreur}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(appels, []);
|
||||||
|
});
|
||||||
|
|
||||||
test("une réponse { ok: false } devient l'ErreurStockage de ce code et de ces détails ; une réponse d'une autre forme, une TypeError", async () => {
|
test("une réponse { ok: false } devient l'ErreurStockage de ce code et de ces détails ; une réponse d'une autre forme, une TypeError", async () => {
|
||||||
const { pont } = pontFactice([
|
const { pont } = pontFactice([
|
||||||
{ ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } },
|
{ ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } },
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
// ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme
|
// ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme
|
||||||
// le verrait la coquille.
|
// le verrait la coquille.
|
||||||
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
||||||
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
|
import { exigerCheminRelatif, exigerDepuis } from '../src/stockage/systeme_fichiers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Levée par chaque primitive d'une instance dont le processus est coupé
|
* Levée par chaque primitive d'une instance dont le processus est coupé
|
||||||
|
|
@ -48,8 +48,9 @@ const REESSAIS_RENOMMAGE = 10;
|
||||||
|
|
||||||
// Horloge d'un disque neuf, en millisecondes depuis l'époque Unix : le
|
// Horloge d'un disque neuf, en millisecondes depuis l'époque Unix : le
|
||||||
// 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une
|
// 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une
|
||||||
// primitive l'avance d'une seconde, si bien que modifie et la prise d'un
|
// primitive l'avance d'une seconde, si bien que modifie ne dépend que de la
|
||||||
// verrou ne dépendent que de la suite des écritures.
|
// suite des écritures. L'instant de prise d'un verrou est celui que
|
||||||
|
// verrouiller reçoit, et non celui de cette horloge.
|
||||||
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
|
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
|
||||||
const PAS_HORLOGE = 1000;
|
const PAS_HORLOGE = 1000;
|
||||||
|
|
||||||
|
|
@ -101,10 +102,6 @@ function replier(texte) {
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un instant de l'horloge sous la forme de celle de l'application,
|
|
||||||
// AAAA-MM-JJTHH:MM:SS±HH:MM.
|
|
||||||
const horodatage = (millisecondes) => `${new Date(millisecondes).toISOString().slice(0, 19)}+00:00`;
|
|
||||||
|
|
||||||
function exigerChaine(valeur, quoi) {
|
function exigerChaine(valeur, quoi) {
|
||||||
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
|
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +110,10 @@ function exigerSeance(seance) {
|
||||||
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
|
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un chemin absolu, comme le rend un dialogue de dossier : un lecteur suivi
|
||||||
|
// d'un séparateur, ou un séparateur en tête.
|
||||||
|
const CHEMIN_ABSOLU = /^(?:[A-Za-z]:[\\/]|[\\/])/;
|
||||||
|
|
||||||
// Vrai pour le contenu lu d'un verrou : {seance, pid, hote, depuis}.
|
// Vrai pour le contenu lu d'un verrou : {seance, pid, hote, depuis}.
|
||||||
const estContenuVerrou = (contenu) =>
|
const estContenuVerrou = (contenu) =>
|
||||||
contenu !== null &&
|
contenu !== null &&
|
||||||
|
|
@ -150,7 +151,8 @@ const estContenuVerrou = (contenu) =>
|
||||||
* @param {string} [reglages.hote] machine du processus ; un verrou d'un autre
|
* @param {string} [reglages.hote] machine du processus ; un verrou d'un autre
|
||||||
* hôte rend vivant à null
|
* hôte rend vivant à null
|
||||||
* @param {string[]} [reglages.dossiersChoisis] chemins absolus que rend
|
* @param {string[]} [reglages.dossiersChoisis] chemins absolus que rend
|
||||||
* choisirDossier, un par appel, dans l'ordre, puis null comme quand
|
* choisirDossier — un autre chemin lève TypeError à la création —, un
|
||||||
|
* par appel, dans l'ordre, puis null comme quand
|
||||||
* l'opérateur annule : chacun sous l'identifiant choisi-<n>, n compté
|
* l'opérateur annule : chacun sous l'identifiant choisi-<n>, n compté
|
||||||
* depuis 1, et inscrit parmi les racines du système, comme le fait la
|
* depuis 1, et inscrit parmi les racines du système, comme le fait la
|
||||||
* coquille (electron/fichiers.js). La racine choisie n'existe qu'une
|
* coquille (electron/fichiers.js). La racine choisie n'existe qu'une
|
||||||
|
|
@ -204,8 +206,8 @@ export function creerFichiersSimules({
|
||||||
hote = 'poste-epreuve',
|
hote = 'poste-epreuve',
|
||||||
dossiersChoisis = [],
|
dossiersChoisis = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || chemin === '')) {
|
if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || !CHEMIN_ABSOLU.test(chemin))) {
|
||||||
throw new TypeError('dossiersChoisis : un tableau de chemins, chaînes non vides');
|
throw new TypeError('dossiersChoisis : un tableau de chemins absolus');
|
||||||
}
|
}
|
||||||
const etat = etatDuDisque(disque, insensibleCasse, separateur);
|
const etat = etatDuDisque(disque, insensibleCasse, separateur);
|
||||||
const pid = etat.prochainPid;
|
const pid = etat.prochainPid;
|
||||||
|
|
@ -511,8 +513,9 @@ export function creerFichiersSimules({
|
||||||
disque.delete(loc.cle);
|
disque.delete(loc.cle);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
verrouiller: primitive('ecritures', (racine, chemin, seance) => {
|
verrouiller: primitive('ecritures', (racine, chemin, seance, depuis) => {
|
||||||
exigerSeance(seance);
|
exigerSeance(seance);
|
||||||
|
exigerDepuis(depuis);
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
if (!verrouDisponible) return { pris: true };
|
if (!verrouDisponible) return { pris: true };
|
||||||
exigerInscriptible(loc);
|
exigerInscriptible(loc);
|
||||||
|
|
@ -520,8 +523,7 @@ export function creerFichiersSimules({
|
||||||
exigerPasDossier(loc);
|
exigerPasDossier(loc);
|
||||||
const present = disque.get(loc.cle);
|
const present = disque.get(loc.cle);
|
||||||
if (present !== undefined) return verrouPresent(texteDuNoeud(present));
|
if (present !== undefined) return verrouPresent(texteDuNoeud(present));
|
||||||
const prise = avancer();
|
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis })}\n`, avancer());
|
||||||
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
|
|
||||||
return { pris: true };
|
return { pris: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import { eprouverContrat } from './contrat_fichiers.js';
|
||||||
import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.js';
|
import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.js';
|
||||||
import { describe, test } from './lanceur.js';
|
import { describe, test } from './lanceur.js';
|
||||||
|
|
||||||
|
// L'instant de prise d'un verrou que reçoit verrouiller, de la forme
|
||||||
|
// qu'écrit l'horloge de l'application.
|
||||||
|
const DEPUIS = '2031-05-17T19:42:07-04:00';
|
||||||
|
|
||||||
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
|
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
|
||||||
async function rejette(promesse, message) {
|
async function rejette(promesse, message) {
|
||||||
let raison;
|
let raison;
|
||||||
|
|
@ -167,7 +171,7 @@ describe('fichiers simulés : racines et emplacements', () => {
|
||||||
await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' });
|
await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' });
|
||||||
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json'));
|
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json'));
|
||||||
await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl'));
|
await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl'));
|
||||||
await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', absent('soiree.gtt.verrou'));
|
await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS), 'ECRITURE', absent('soiree.gtt.verrou'));
|
||||||
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
|
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
|
||||||
assert.deepEqual(await fs.lister(DOC, ''), []);
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
});
|
});
|
||||||
|
|
@ -237,8 +241,8 @@ describe('fichiers simulés : racines et emplacements', () => {
|
||||||
await echoue(fs.lister({ id: 'choisi-3', chemin: 'G:\\' }, ''), 'CHEMIN_REFUSE', { chemin: '' });
|
await echoue(fs.lister({ id: 'choisi-3', chemin: 'G:\\' }, ''), 'CHEMIN_REFUSE', { chemin: '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide lève TypeError à la création', () => {
|
test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide, ou pas un chemin absolu, lève TypeError à la création', () => {
|
||||||
for (const fautif of [[''], [null], 'D:\\Secours']) {
|
for (const fautif of [[''], [null], 'D:\\Secours', ['Secours'], ['secours\\soirees'], ['D:\\Secours', './Repli']]) {
|
||||||
assert.ok(erreurDe(() => creerFichiersSimules({ dossiersChoisis: fautif })) instanceof TypeError, JSON.stringify(fautif));
|
assert.ok(erreurDe(() => creerFichiersSimules({ dossiersChoisis: fautif })) instanceof TypeError, JSON.stringify(fautif));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -369,7 +373,7 @@ describe("fichiers simulés : support qui refuse l'écriture", () => {
|
||||||
await echoue(fs.creerDossier(PORTABLE, 'corbeille'), 'ECRITURE', refus('corbeille'));
|
await echoue(fs.creerDossier(PORTABLE, 'corbeille'), 'ECRITURE', refus('corbeille'));
|
||||||
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
|
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
|
||||||
await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
|
await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
|
||||||
await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b'), 'ECRITURE', refus('neuf.gtt.verrou'));
|
await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b', DEPUIS), 'ECRITURE', refus('neuf.gtt.verrou'));
|
||||||
await echoue(fs.deverrouiller(PORTABLE, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', refus('soiree.gtt.verrou'));
|
await echoue(fs.deverrouiller(PORTABLE, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', refus('soiree.gtt.verrou'));
|
||||||
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EROFS' });
|
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EROFS' });
|
||||||
|
|
||||||
|
|
@ -448,29 +452,26 @@ describe('fichiers simulés : processus coupé (couperApres)', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fichiers simulés : verrou', () => {
|
describe('fichiers simulés : verrou', () => {
|
||||||
test("le fichier de verrou porte la séance, le pid et l'hôte du processus, et l'instant de sa prise", async () => {
|
test("le fichier de verrou porte la séance, le pid et l'hôte du processus, et l'instant de sa prise qu'il a reçu, tel quel", async () => {
|
||||||
const fs = preparer();
|
const fs = preparer();
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
const avant = fs.compteurs.ecritures;
|
||||||
|
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS), { pris: true });
|
||||||
const texte = fs.contenu('documents', 'soiree.gtt.verrou');
|
const texte = fs.contenu('documents', 'soiree.gtt.verrou');
|
||||||
assert.match(
|
assert.equal(texte, `${JSON.stringify({ seance: 'seance-a', pid: fs.pid, hote: 'poste-epreuve', depuis: DEPUIS })}\n`);
|
||||||
texte,
|
|
||||||
/^\{"seance":"seance-a","pid":\d+,"hote":"poste-epreuve","depuis":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00"\}\n$/,
|
|
||||||
);
|
|
||||||
const { pid, depuis } = JSON.parse(texte);
|
|
||||||
assert.equal(pid, fs.pid);
|
|
||||||
assert.equal(fs.hote, 'poste-epreuve');
|
assert.equal(fs.hote, 'poste-epreuve');
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', '2031-05-17T20:00:00-04:00'), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'seance-a',
|
seance: 'seance-a',
|
||||||
depuis,
|
depuis: DEPUIS,
|
||||||
vivant: true,
|
vivant: true,
|
||||||
});
|
});
|
||||||
|
assert.equal(fs.compteurs.ecritures, avant + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('la création est exclusive, même pour la séance qui tient le verrou', async () => {
|
test('la création est exclusive, même pour la séance qui tient le verrou', async () => {
|
||||||
const fs = preparer();
|
const fs = preparer();
|
||||||
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS);
|
||||||
const second = await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
const second = await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS);
|
||||||
assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]);
|
assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -482,22 +483,22 @@ describe('fichiers simulés : verrou', () => {
|
||||||
a.deposerDossier('documents');
|
a.deposerDossier('documents');
|
||||||
assert.notEqual(a.pid, b.pid);
|
assert.notEqual(a.pid, b.pid);
|
||||||
|
|
||||||
await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a');
|
await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a', DEPUIS);
|
||||||
const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
|
const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b', DEPUIS);
|
||||||
assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]);
|
assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]);
|
||||||
|
|
||||||
await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c');
|
await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c', DEPUIS);
|
||||||
const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x');
|
const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x', DEPUIS);
|
||||||
assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]);
|
assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]);
|
||||||
|
|
||||||
// Le pid d'un processus de l'autre machine ne désigne aucun processus de
|
// Le pid d'un processus de l'autre machine ne désigne aucun processus de
|
||||||
// celle-ci.
|
// celle-ci.
|
||||||
const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
|
const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
|
||||||
a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe));
|
a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe));
|
||||||
assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x')).vivant, false);
|
assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x', DEPUIS)).vivant, false);
|
||||||
|
|
||||||
a.pannes.couperApres(0);
|
a.pannes.couperApres(0);
|
||||||
const apresCoupure = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
|
const apresCoupure = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b', DEPUIS);
|
||||||
assert.deepEqual([apresCoupure.seance, apresCoupure.vivant], ['seance-a', false]);
|
assert.deepEqual([apresCoupure.seance, apresCoupure.vivant], ['seance-a', false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -505,16 +506,16 @@ describe('fichiers simulés : verrou', () => {
|
||||||
const fs = preparer();
|
const fs = preparer();
|
||||||
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
|
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
|
||||||
fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":');
|
fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":');
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'), illisible);
|
assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a', DEPUIS), illisible);
|
||||||
await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a');
|
await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a');
|
||||||
assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":');
|
assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":');
|
||||||
|
|
||||||
fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}');
|
fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}');
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b'), illisible);
|
assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b', DEPUIS), illisible);
|
||||||
|
|
||||||
const ancien = { seance: 'ancienne', pid: 4242, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
|
const ancien = { seance: 'ancienne', pid: 4242, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
|
||||||
fs.deposer('documents', 'mort.gtt.verrou', JSON.stringify(ancien));
|
fs.deposer('documents', 'mort.gtt.verrou', JSON.stringify(ancien));
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'mort.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fs.verrouiller(DOC, 'mort.gtt.verrou', 'seance-b', DEPUIS), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: 'ancienne',
|
seance: 'ancienne',
|
||||||
depuis: '2026-01-01T08:00:00+00:00',
|
depuis: '2026-01-01T08:00:00+00:00',
|
||||||
|
|
@ -525,8 +526,8 @@ describe('fichiers simulés : verrou', () => {
|
||||||
test("sans verrou disponible, verrouiller rend pris sans rien écrire et deverrouiller n'efface rien", async () => {
|
test("sans verrou disponible, verrouiller rend pris sans rien écrire et deverrouiller n'efface rien", async () => {
|
||||||
const fs = preparer({ verrouDisponible: false });
|
const fs = preparer({ verrouDisponible: false });
|
||||||
fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
|
fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a'), { pris: true });
|
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a', DEPUIS), { pris: true });
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b'), { pris: true });
|
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b', DEPUIS), { pris: true });
|
||||||
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.verrou']);
|
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.verrou']);
|
||||||
});
|
});
|
||||||
|
|
@ -540,7 +541,7 @@ describe('fichiers simulés : compteurs, préparation, horloge', () => {
|
||||||
await fs.creerDossier(DOC, 'corbeille');
|
await fs.creerDossier(DOC, 'corbeille');
|
||||||
await fs.deplacer(DOC, 'a.txt', 'corbeille/a.txt');
|
await fs.deplacer(DOC, 'a.txt', 'corbeille/a.txt');
|
||||||
await fs.supprimer(DOC, 'corbeille/a.txt');
|
await fs.supprimer(DOC, 'corbeille/a.txt');
|
||||||
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS);
|
||||||
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||||
await fs.sonder(DOC);
|
await fs.sonder(DOC);
|
||||||
await assert.rejects(fs.ecrireAtomique(DOC, '../a.txt', 'a'));
|
await assert.rejects(fs.ecrireAtomique(DOC, '../a.txt', 'a'));
|
||||||
|
|
@ -638,7 +639,7 @@ describe('fichiers simulés : octets déposés et décodage strict', () => {
|
||||||
test("un verrou aux octets hors UTF-8 se lit comme un verrou illisible", async () => {
|
test("un verrou aux octets hors UTF-8 se lit comme un verrou illisible", async () => {
|
||||||
const fs = preparer();
|
const fs = preparer();
|
||||||
fs.deposerOctets('documents', 'soiree.gtt.verrou', HORS_UTF8);
|
fs.deposerOctets('documents', 'soiree.gtt.verrou', HORS_UTF8);
|
||||||
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b', DEPUIS), {
|
||||||
pris: false,
|
pris: false,
|
||||||
seance: null,
|
seance: null,
|
||||||
depuis: null,
|
depuis: null,
|
||||||
|
|
@ -655,7 +656,7 @@ describe('fichiers simulés : préconditions et dossiers', () => {
|
||||||
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\nb'), TypeError);
|
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\nb'), TypeError);
|
||||||
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\r'), TypeError);
|
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\r'), TypeError);
|
||||||
await assert.rejects(fs.ecrireAtomique(DOC, 'etat.json', new Uint8Array([0x61])), TypeError);
|
await assert.rejects(fs.ecrireAtomique(DOC, 'etat.json', new Uint8Array([0x61])), TypeError);
|
||||||
await assert.rejects(fs.verrouiller(DOC, 'soiree.gtt.verrou', ''), TypeError);
|
await assert.rejects(fs.verrouiller(DOC, 'soiree.gtt.verrou', '', DEPUIS), TypeError);
|
||||||
assert.deepEqual(await fs.lister(DOC, ''), []);
|
assert.deepEqual(await fs.lister(DOC, ''), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -668,7 +669,7 @@ describe('fichiers simulés : préconditions et dossiers', () => {
|
||||||
await echoue(fs.lister(DOC, 'soiree.gtt.json'), 'ABSENT', { chemin: 'soiree.gtt.json' });
|
await echoue(fs.lister(DOC, 'soiree.gtt.json'), 'ABSENT', { chemin: 'soiree.gtt.json' });
|
||||||
await echoue(fs.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
await echoue(fs.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
||||||
await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
||||||
await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a', DEPUIS), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
||||||
await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
||||||
await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR'));
|
||||||
await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST'));
|
await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue