From d5a15609153038a951f2784b3db3c99237302507 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 04:00:22 -0400 Subject: [PATCH] [FIX] core: bounded geometry, dated lock, empty seat, neighbour lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/fichiers.js | 66 +++--- electron/main.js | 19 +- src/application/commandes_mobilier.js | 90 ++++++- src/application/commandes_mobilier.test.js | 233 ++++++++++++++----- src/application/commandes_places.js | 15 +- src/application/commandes_places.test.js | 32 ++- src/application/conflits_placement.js | 31 ++- src/application/conflits_placement.test.js | 18 ++ src/application/evenements.js | 63 ++--- src/application/evenements.test.js | 172 +++++++++++++- src/application/format.js | 39 +++- src/application/format.test.js | 45 +++- src/application/horloge.js | 47 ++-- src/application/horloge.test.js | 65 ++++++ src/application/libelles.test.js | 24 +- src/application/libelles/aides.js | 20 +- src/application/libelles/mobilier.js | 6 + src/application/libelles/places.js | 6 +- src/application/places.js | 41 ++-- src/application/places.test.js | 5 +- src/application/seance.js | 59 +++-- src/application/seance.test.js | 162 +++++++++---- src/geometrie/designation.test.js | 16 ++ src/geometrie/echelle.js | 16 +- src/geometrie/echelle.test.js | 17 ++ src/geometrie/tables.js | 60 ++++- src/geometrie/tables.test.js | 100 ++++---- src/geometrie/vue.js | 22 +- src/geometrie/vue.test.js | 48 +++- src/moteur/diagnostic.js | 39 ++-- src/moteur/diagnostic.test.js | 24 +- src/stockage/correctifs.js | 28 ++- src/stockage/depot.js | 62 +++-- src/stockage/depot.test.js | 16 +- src/stockage/document.js | 5 +- src/stockage/document.test.js | 16 ++ src/stockage/fichiers_electron.js | 9 +- src/stockage/fichiers_web.js | 5 +- src/stockage/fichiers_web.navigateur.test.js | 13 +- src/stockage/journal.js | 20 +- src/stockage/operations.js | 2 +- src/stockage/operations.test.js | 19 +- src/stockage/placements.js | 4 +- src/stockage/placements.test.js | 4 + src/stockage/systeme_fichiers.js | 21 +- src/stockage/types.js | 15 +- test/bandes.js | 56 +++++ test/contrat_fichiers.js | 43 +++- test/coquille.test.js | 6 + test/electron_factice.js | 9 +- test/fichiers_electron.long.test.js | 20 +- test/fichiers_electron.test.js | 143 +++++++++--- test/fichiers_simules.js | 28 +-- test/fichiers_simules.test.js | 63 ++--- 54 files changed, 1659 insertions(+), 548 deletions(-) create mode 100644 test/bandes.js diff --git a/electron/fichiers.js b/electron/fichiers.js index 14717c9..77b4423 100644 --- a/electron/fichiers.js +++ b/electron/fichiers.js @@ -34,7 +34,7 @@ // alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE // {cause}, l'explorateur du système qui ne s'ouvre pas. 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 { hostname } from 'node:os'; 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 // 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, -// 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({ lstat, stat, @@ -216,6 +218,8 @@ const FS_NODE = Object.freeze({ unlink, mkdir, readdir, + readFileSync, + unlinkSync, }); // 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; } -// 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); /** @@ -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 * Le reste remplace le système, pour l'épreuve : * @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 {string} [reglages.plateforme] process.platform * @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 * @param {number} [reglages.pid] * @param {string} [reglages.hote] - * @param {() => Date} [reglages.maintenant] * @returns {Object} les primitives, par nom, et - * verrousTenus(), qui n'en est pas une + * rendreVerrousTenus(), qui n'en est pas une */ export function creerFichiers({ emplacements, @@ -355,7 +346,6 @@ export function creerFichiers({ demarrage, pid = process.pid, hote = hostname(), - maintenant = () => new Date(), }) { // 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. @@ -366,7 +356,7 @@ export function creerFichiers({ // Le démarrage du processus courant, lu une fois qu'il est connu. let demarrageCourant = null; // 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(); // 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 - // inscrit la séance, le pid, l'hôte, l'instant de la prise et le - // démarrage du processus, lus avant la création : la création et - // l'écriture se suivent sans attente. Un verrou présent + // inscrit la séance, le pid, l'hôte, l'instant de la prise reçu de la + // page, tel quel, et le démarrage du processus, lu avant la création : + // 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 // 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); + exigerTexte(depuis, "l'instant de prise"); const loc = localiser(racine, chemin); try { await confiner(loc); 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`; for (let essai = 0; ; essai += 1) { let poignee; @@ -895,11 +889,23 @@ export function creerFichiers({ } }, - // Hors des primitives, que servir ne publie pas : les verrous pris et - // pas rendus, [{ chemin, seance }], chemin absolu, dans l'ordre de leur - // prise. Le processus principal les efface à sa sortie. - verrousTenus() { - return [...tenus].map(([chemin, seance]) => ({ chemin, seance })); + // Hors des primitives, que servir ne publie pas : rend les verrous pris + // et pas rendus, dans l'ordre de leur prise, à la sortie du processus + // principal (§ 8.8). Synchrone, comme le retrait du profil : Electron + // n'attend aucune promesse d'un écouteur de sortie. Chacun se relit et + // 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 diff --git a/electron/main.js b/electron/main.js index 61bb988..572f31b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -15,7 +15,7 @@ // importé interprété comme du balisage reste ainsi une faute d'affichage, et // non un accès au disque. 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 { join } from 'node:path'; 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) : // 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 -// façon synchrone comme le retrait du profil — Electron n'attend aucune -// promesse d'un écouteur de sortie —, et ne s'efface que s'il porte encore la -// séance qui l'a pris : un verrou repris par une autre séance reste. -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. - } - } -}); +// ne lui laisse pas le temps de les rendre elle-même. rendreVerrousTenus les +// relit et les efface de façon synchrone, comme le retrait du profil, chacun +// seulement s'il porte encore la séance qui l'a pris. +app.on('quit', () => fichiers.rendreVerrousTenus()); function creerFenetre() { fenetre = new BrowserWindow({ diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index c9de1cb..804250b 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -22,8 +22,8 @@ // 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 // table et changer les tours laissent propositions et retenu en dérive. -import { caseLibre } from '../geometrie/tables.js'; -import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js'; +import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js'; +import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite, cibleRedondance } from '../stockage/document.js'; import { fautes } from '../stockage/placements.js'; import { bilanChangementDefaut } from './bilans.js'; import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js'; @@ -81,6 +81,67 @@ function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefa 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 // refus levé. 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) : * 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 } = {}) { 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)}`); const sieges = entierSaisi(saisie); const avertissements = exigerCapacite(charge, id, sieges, origine); + const suivante = avecSieges(charge, new Map([[id, sieges]])); return { - charge: avecSieges(charge, new Map([[id, sieges]])), + charge: suivante, 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 sieges = charge.evenement.siegesParDefaut; const avertissements = exigerCapacite(charge, id, sieges, 'champ'); + const suivante = avecSieges(charge, new Map([[id, null]])); return { - charge: avecSieges(charge, new Map([[id, null]])), + charge: suivante, 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 }); } 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 { - charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges), + charge: suivante, libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }), avertissements, }; @@ -264,12 +331,15 @@ function modifierReglage(charge, { cle, valeur } = {}) { * Σ 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, * 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 - * que la cible courante est sans effet. + * s'écrit alors comme une charge qui ne l'a jamais porté. La même valeur que + * 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 } = {}) { exigerModifiable(charge); const cible = entierSaisi(saisie); + if (cible === cibleRedondance(charge)) throw refus('SANS_EFFET', { commande: 'changerCibleRedondance' }); const suivante = structuredClone(charge); if (cible === CIBLE_REDONDANCE_PAR_DEFAUT) delete suivante.reglages.cibleRedondance; else suivante.reglages.cibleRedondance = cible; diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js index 81b75a1..8ef815d 100644 --- a/src/application/commandes_mobilier.test.js +++ b/src/application/commandes_mobilier.test.js @@ -9,12 +9,11 @@ // 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é. import assert from 'node:assert/strict'; +import { fautesBandes as fautesBandesPosees } from '../../test/bandes.js'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { texteLivre } from '../demo/livrees.js'; -import { constante } from '../geometrie/constantes.js'; -import { caseLibre, geometrieTable } from '../geometrie/tables.js'; -import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js'; +import { caseLibre } from '../geometrie/tables.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { ErreurConfiguration } from '../moteur/erreurs.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, -// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le -// dessin la mesure au navigateur. -const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM; - -// Les fautes « une liste passe sur une autre table » d'une charge : pour -// chaque paire ordonnée de tables, la bande du bloc de noms de la première — -// 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 }; -} +// Les fautes « une liste passe sur une autre table » des tables d'une +// charge, chacune à sa capacité (test/bandes.js) ; avec, l'identifiant de la +// table dont seules les paires s'examinent. +const fautesBandes = (charge, options) => + fautesBandesPosees( + charge.tables.map((table) => ({ id: table.id, position: table.position, forme: table.forme, places: capacite(charge, table) })), + options, + ); // 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. @@ -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 // 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 - // la liste de sa voisine, ce que la création ne change pas. - charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge; + // la liste de sa voisine, la table 2 : l'agrandissement le dit + // (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; 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 }); @@ -317,13 +289,141 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § let charge = analyser(texteLivre('grande')).charge; const premiere = charge.tables.find(({ numero }) => numero === 1); 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; assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 }); const creee = charge.tables.at(-1).id; 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)', () => { // 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. @@ -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', () => { const charge = chargeDe({ tables: [{ id: 1 }] }); - assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), TypeError); - assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), TypeError); - assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), TypeError); + // Le message nomme la faute : une TypeError levée par une lecture de + // propriété sur undefined ne passerait pas pour elle. + 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', () => { @@ -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(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. - const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges: true, reservations: [resa(10, 1, null)] }); - placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true }); - const realignee = accepter('realignerTable', dix, { id: 1 }); - assert.equal(invariantViole(realignee.charge), null); - assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10]); - assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }]); + // Réaligner une table surchargée à 10, la personne 10 réservée sans + // 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 }); + const realignee = accepter('realignerTable', dix, { id: 1 }); + const cas = `realignerTable, attribuerSieges ${attribuerSieges}`; + assert.equal(invariantViole(realignee.charge), null, cas); + 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', () => { @@ -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'); 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); + }); }); diff --git a/src/application/commandes_places.js b/src/application/commandes_places.js index cbcefa3..e3337ba 100644 --- a/src/application/commandes_places.js +++ b/src/application/commandes_places.js @@ -408,7 +408,7 @@ function enReserve(retenu, tour, id) { // 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 -// retenue (A4 l'y ajoute aussi). +// retenue, que creerTable y ajoute aussi. function rangDeTable(retenu, idTable, places) { const rang = retenu.tables.indexOf(idTable); 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 -// table interdit : le retrait de cette réservation, la première dans l'ordre -// canonique qui tient la personne à la table de la violation au tour, « tous -// les tours » avant « ce tour ». null quand aucune violation RESERVATION ne -// nomme la personne. +// table interdit : le retrait de cette réservation, celle qui tient la +// personne à la table de la violation au tour, la plus étroite d'abord — +// « ce tour » avant « tous les tours », puis l'ordre canonique. Quand les +// 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) { const violation = violations.find((v) => v.code === 'RESERVATION' && v.participant === participant); if (violation === undefined) return null; + const largeur = ({ portee }) => (portee === 'tous' ? 1 : 0); const [tenue] = charge.reservations .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; return { commande: 'retirerReservation', diff --git a/src/application/commandes_places.test.js b/src/application/commandes_places.test.js index 1a5b181..ff80b75 100644 --- a/src/application/commandes_places.test.js +++ b/src/application/commandes_places.test.js @@ -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', () => { const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] }); 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']], ['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']], ['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']], ]); @@ -955,11 +976,12 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => { assert.ok(!texte.includes('undefined'), `${code} : ${texte}`); } 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 - // promettre du glissement vers une autre chaise : deplacerReservation y - // garde la portée « tous », et la déplace à chaque tour. + // Le refus du dépôt sur le sol, et de « Retirer du tour », dit ce que le + // retrait d'un seul tour ne fait pas, sans rien promettre du glissement + // 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 }); - 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.equal(libelle('places.titreParDefaut'), 'animateur'); }); diff --git a/src/application/conflits_placement.js b/src/application/conflits_placement.js index 4e4d04e..7f4b4b5 100644 --- a/src/application/conflits_placement.js +++ b/src/application/conflits_placement.js @@ -44,19 +44,30 @@ import { etatPlaces } from './places.js'; * * @param {import('../stockage/types.js').Charge} charge * @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} */ -export function conflitsDuPlacement(charge, { proposition = null } = {}) { +export function conflitsDuPlacement(charge, { proposition = null } = {}, { etatPlaces: lire = etatPlaces } = {}) { const conflits = []; // Chaque tour déjà lu : sa source, sa proposition, et la table de chaque // personne assise, {id, numero}. 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; 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) { 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 regle of deTable?.regles ?? []) { 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 source d'etat : un tour lu dans une autre source se relit par -// etatPlaces avec la proposition d'etat, null pour le retenu. La source -// d'etat porte son tour, donc tous ceux qui le précèdent, et la relecture -// rend la même source. -function historique(charge, etat, lus) { +// la source d'etat : un tour lu dans une autre source se relit par relire(rang), +// la lecture du tour avec la proposition d'etat, null pour le retenu, que +// l'appelant garde. La source d'etat porte son tour, donc tous ceux qui le +// précèdent, et la relecture rend la même source. +function historique(etat, lus, relire) { return lus.map(({ source, proposition, tableDe }, rang) => - source === etat.source && proposition === etat.proposition - ? tableDe - : tablesDe(etatPlaces(charge, { tour: rang + 1, proposition: etat.proposition })), + source === etat.source && proposition === etat.proposition ? tableDe : relire(rang), ); } diff --git a/src/application/conflits_placement.test.js b/src/application/conflits_placement.test.js index 2342cc8..590f5c9 100644 --- a/src/application/conflits_placement.test.js +++ b/src/application/conflits_placement.test.js @@ -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', () => { const c = executer('ajouterParticipant', charge({ retenu: 0 }), { champs: { nom: 'Sarcelle', prenom: 'Capucine' } }).charge; assert.ok(etatPlaces(c, { tour: 1 }).evalue === false); diff --git a/src/application/evenements.js b/src/application/evenements.js index 25e6474..5c04ce6 100644 --- a/src/application/evenements.js +++ b/src/application/evenements.js @@ -36,7 +36,6 @@ // libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis // et questions sont des codes et leurs détails. 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 { ErreurStockage } from '../stockage/erreurs.js'; import { decoderJournal, lireJournal } from '../stockage/journal.js'; @@ -51,7 +50,6 @@ import { renommer as renommerSurDisque, supprimer as supprimerSurDisque, } from '../stockage/operations.js'; -import { examiner } from '../stockage/placements.js'; import { lireReglages } from '../stockage/reglages_locaux.js'; import { ErreurCommande, refus } from './erreurs.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é. const AUCUN_RESUME = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null }); -// La charge examinée de l'état base, lue sans rien écrire (examiner, § 8.9, -// point 3) : celle que l'ouverture garde, où une proposition fautive, dont -// l'identifiant se répète ou atteint le compteur, n'entre pas au classement — -// qui lève sur un identifiant répété —, si bien que la liste nomme la -// proposition au rang 1 que le bloc de génération de l'événement ouvert -// nomme. null pour un état absent ou qui ne se lit pas. +// La charge de l'événement base telle que l'ouverture la lit, sans rien +// écrire (ouvrir du dépôt, § 8.8) : un journal en avance d'une entrée la +// rattrape en mémoire, par la règle de rattraper, et examiner (§ 8.9, point +// 3) en écarte une proposition fautive, dont l'identifiant se répète ou +// atteint le compteur — le classement lève sur un identifiant répété. La +// 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) { try { - const texte = await fs.lireTexte(racine, base + SUFFIXES.etat); - if (texte === null) return null; - return examiner(analyser(texte).charge).charge; + return (await ouvrirDepot(fs, racine, base)).charge; } catch (erreur) { if (erreur instanceof ErreurStockage) return null; 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 {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, - // resté d'un rendu refusé, est déjà pris ; celui d'une séance morte - // (vivant faux) s'efface en son nom, puis se prend ; tout autre lève - // VERROU_PRIS, sans rien changer. + // Le verrou de l'événement cible pris sous la séance de la façade, daté de + // l'horloge lue une fois. Le sien, resté d'un rendu refusé, est déjà pris ; + // celui d'une séance morte (vivant faux) s'efface en son nom, puis se + // prend ; tout autre lève VERROU_PRIS, sans rien changer. 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.vivant === false) { await deverrouiller(cible, verrou.seance); - verrou = await verrouiller(cible, seance); + verrou = await verrouiller(cible, seance, depuis); } if (!verrou.pris) { const { seance: tenue, depuis, vivant } = verrou; @@ -406,21 +405,31 @@ export function creerFacade({ 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 }) { const texte = nomSaisi(nom); const courante = await exigerDossier(); const source = await ouvrirDepot(fs, courante, base); // L'instant copié, par la règle même qui l'inscrit à la filiation. const instant = libelleDeLInstant(source, libelleReprise); - const copie = await copierSurDisque(source, { - id: identifiant(), - nom: texte, - libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }), - nomJalon, - horodatage: horloge.horodatage(), - produitVersion, - libelleReprise, - }); + let copie; + try { + copie = await copierSurDisque(source, { + id: identifiant(), + nom: texte, + libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }), + nomJalon, + horodatage: horloge.horodatage(), + produitVersion, + 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); return { base: copie.base }; } diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js index 9a0ce42..34219b6 100644 --- a/src/application/evenements.test.js +++ b/src/application/evenements.test.js @@ -15,8 +15,8 @@ import { CATALOGUE } from '../demo/catalogue.js'; import { LIVREES, texteLivre } from '../demo/livrees.js'; import { rechercher } from '../moteur/recherche.js'; import { serialiser } from '../stockage/canonique.js'; -import { creer as creerDansLeDepot } from '../stockage/depot.js'; -import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js'; +import { creer as creerDansLeDepot, enregistrer as enregistrerDansLeDepot } from '../stockage/depot.js'; +import { FORMAT, analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { lireJournal } from '../stockage/journal.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]]); }); - 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 = [ [{}, [{ code: 'NON_PUBLIE', 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 }); }); - 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(); fs.deposer('documents', 'reglages_locaux.json', '{ pas du JSON'); const { facade } = monter({ fs }); @@ -247,6 +247,28 @@ describe('demarrer : une détermination par séance (§ 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)', () => { // data/ porte deux événements, posés par un premier processus ; le second, // 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'); }); + 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 () => { const { fs, sondes } = await dataQuiRefuse(); 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 () => { const fs = creerFichiersSimules(); const { facade } = monter({ fs }); @@ -642,6 +724,36 @@ describe('lister (§ 8.1, § 19.4)', () => { 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 () => { const { fs, facade } = await deuxEvenements(); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); @@ -1068,6 +1180,18 @@ describe('copier (§ 8.7)', () => { 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 () => { const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); await facade.demarrer(); @@ -1218,6 +1342,39 @@ describe('renommer (§ 8.7, § 8.8)', () => { 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 () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); @@ -1225,7 +1382,7 @@ describe('renommer (§ 8.7, § 8.8)', () => { await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); 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); assert.equal(Object.keys(avant).length, 3); const ecrituresAvant = ecritures(fs); @@ -1286,7 +1443,7 @@ describe('supprimer (§ 8.7, § 8.8)', () => { await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); 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); const avant = ecritures(fs); await facade.supprimer(LUCIOLES); @@ -1329,13 +1486,14 @@ describe('supprimer (§ 8.7, § 8.8)', () => { await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); 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); assert.equal(Object.keys(avant).length, 3); const ecrituresAvant = ecritures(fs); const refus = await refusDe(facade.supprimer(LUCIOLES)); assert.equal(refus.code, 'VERROU_PRIS'); assert.equal(refus.details.vivant, vivant); + assert.equal(refus.details.remede, null); // La seule sonde, refusée. assert.equal(ecritures(fs), ecrituresAvant + 1); assert.deepEqual(await photographier(fs, DOC), avant); diff --git a/src/application/format.js b/src/application/format.js index 52fdd7b..b5afb43 100644 --- a/src/application/format.js +++ b/src/application/format.js @@ -10,6 +10,8 @@ // témoin, il est encadré de la marque une fois. Un nombre, qui ne porte // aucune lettre, n'y passe pas. +import { estHorodatage } from '../stockage/journal.js'; + const ESPACE_FINE = '\u{202F}'; const MOINS = '\u{2212}'; @@ -30,7 +32,6 @@ const PAS = [ ]; 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. function joursDuMois(annee, mois) { @@ -47,6 +48,31 @@ function lireDate(annee, mois, jour, texte) { 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. function exigerFini(valeur, quoi) { 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. */ function horodatage(texte) { - const morceaux = HORODATAGE.exec(String(texte)); - if (morceaux === null) { - 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 }); + const { annee, mois, jour, heure, minute } = lireHorodatage(texte); + return t('format.horodatage', { annee, mois, jour, heure, minute }); } return { nombre, duree, ordreDeGrandeur, date, horodatage }; diff --git a/src/application/format.test.js b/src/application/format.test.js index 145a13c..cd97d94 100644 --- a/src/application/format.test.js +++ b/src/application/format.test.js @@ -6,8 +6,12 @@ // 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. 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 { creerFormat } from './format.js'; +import { creerFormat, lireHorodatage } from './format.js'; +import { dater } from './libelles/aides.js'; import { TABLES_APPLICATION } from './libelles.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', () => { 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}`); diff --git a/src/application/horloge.js b/src/application/horloge.js index 2d5645f..c6e62ca 100644 --- a/src/application/horloge.js +++ b/src/application/horloge.js @@ -26,6 +26,8 @@ * @property {(id: unknown) => void} annuler */ +import { lireHorodatage } from './format.js'; + const deux = (n) => String(n).padStart(2, '0'); // 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. 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. function joursDepuisOrigine(annee, mois, jour) { @@ -101,19 +102,12 @@ function dateCivile(jours) { 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 -// en minutes à l'est de UTC. Une date hors du calendrier lève. +// Un horodatage de départ lu par lireHorodatage (format.js) : l'instant UTC +// 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) { - const morceaux = FORME.exec(String(depart)); - if (morceaux === null) throw new RangeError(`horloge : départ AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${depart}`); - 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; + const { annee, mois, jour, heure, minute, seconde, decalage } = lireHorodatage(depart); + const local = joursDepuisOrigine(annee, mois, jour) * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000; 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, * 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 - * 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. * @@ -174,18 +169,28 @@ export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = { 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) { 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)}`); } - const fin = ecoule + ms; - for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) { - const { fn } = enAttente.get(suivante.id); - enAttente.delete(suivante.id); - ecoule = suivante.echeance; - fn(); + if (enCours) throw new Error('horloge : avancer ré-entrant, appelé depuis un rappel'); + enCours = true; + try { + const fin = ecoule + ms; + for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) { + const { fn } = enAttente.get(suivante.id); + enAttente.delete(suivante.id); + ecoule = suivante.echeance; + fn(); + } + ecoule = fin; + } finally { + enCours = false; } - ecoule = fin; return undefined; } diff --git a/src/application/horloge.test.js b/src/application/horloge.test.js index 3e1e9c8..98ca86d 100644 --- a/src/application/horloge.test.js +++ b/src/application/horloge.test.js @@ -7,6 +7,7 @@ // aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures // de chaque horloge répondent détachées de leur objet. import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { describe, test } from '../../test/lanceur.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', () => { const horloge = creerHorlogePilotee(); 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', () => { 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(); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 0e4c408..a789c43 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -530,6 +530,7 @@ const EXEMPLES = new Map([ ['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }], ['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }], ['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_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }], ['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', () => { assert.deepEqual([...CODES], [...new Set(CODES)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))); 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`); }); - 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 ATTENDUS = [ ['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)', () => { 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 { TABLE: MOTEUR_TABLE } = await import('./libelles/moteur.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 - // stockage aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION sont - // dans libelles/moteur.js depuis la séance (A2), et ne s'y redéfinissent pas. + // INCONNU est déjà une clé de la table du stockage, une faute de + // placement aux mêmes détails ; PLACES_MANQUANTES et SURRESERVATION, + // 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]]); let comptes = 0; for (const code of codes) { diff --git a/src/application/libelles/aides.js b/src/application/libelles/aides.js index 8885a31..e543879 100644 --- a/src/application/libelles/aides.js +++ b/src/application/libelles/aides.js @@ -9,6 +9,7 @@ // // 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. +import { lireHorodatage } from '../format.js'; const NOMS_DES_ETATS = new Map([ ['brouillon', 'Brouillon'], @@ -80,20 +81,21 @@ export function phraseDeRaison(raison, details, texte) { /** 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}`))); -// 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 à * 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) { - const morceaux = HORODATAGE.exec(String(horodatage)); - if (morceaux === null) return String(horodatage); - const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number); - if (mois < 1 || mois > 12) return String(horodatage); + let lu; + try { + lu = lireHorodatage(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 })}`; } diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index 65bbc72..4a333c4 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -84,6 +84,12 @@ export const TABLE = new Map([ 'AU_DELA_LISIBLE', ({ 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) --------------------------------- ['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`], diff --git a/src/application/libelles/places.js b/src/application/libelles/places.js index b5e6008..fea51cc 100644 --- a/src/application/libelles/places.js +++ b/src/application/libelles/places.js @@ -60,7 +60,9 @@ export const TABLE = new Map([ ['RESERVATION_ABSENTE', () => "Cette réservation n'existe pas."], [ '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) ---------------------------------------------- @@ -77,7 +79,7 @@ export const TABLE = new Map([ [ '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.', ], diff --git a/src/application/places.js b/src/application/places.js index 04c770f..d6b033f 100644 --- a/src/application/places.js +++ b/src/application/places.js @@ -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. * - * @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 * @returns {{tenu: Map, siegeDe: Map, sansSiege: Map}} */ @@ -172,10 +173,12 @@ export function placesTenues(couvrantes, places) { // 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 -// tours » ; la chaise est pleine quand le placement la tient, hachurée — à -// pourvoir — quand il la laisse vide ou y assied une autre personne. 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. +// tours » ; la chaise est pleine quand le placement la tient, hachurée quand +// il la laisse vide ou y assied une autre personne — la réservation n'y est +// pas tenue. Cette hachure ne dit rien du titre de la chaise, que la chaise +// 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) { if (reservation !== null) { 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 * 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 - * le rang s de la liste de la source ; sinon, et sous les seules - * réservations, repartirSieges répartit les occupants de la table, chacun à - * son siège réservé, une personne réservée sans siège qui pourvoit un titre - * au siège de ce titre ; quand la liste dépasse la capacité, les personnes - * réservées à la table passent avant les autres. Sièges attribués, la - * source se lit telle quelle : la réservation qui tient un siège que la - * liste donne à une autre personne, ou laisse vide, se lit sur la chaise - * sans la remplir — hachure et cadenas fermé —, et sa personne est là où - * la liste l'assied. Un exclu, une personne que la charge n'a plus, - * n'occupe rien et n'attend pas ; une personne présente qu'aucune 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. + * le rang s de la liste de la source, et la source se lit telle quelle : la + * réservation qui tient un siège que la liste donne à une autre personne, + * ou laisse vide, se lit sur la chaise sans la remplir — hachure et cadenas + * fermé —, et sa personne est là où la liste l'assied. Sans attribution, + * repartirSieges répartit les occupants de la liste, chacun à son siège + * réservé, une personne réservée sans siège qui pourvoit un titre au siège + * de ce titre ; quand la liste dépasse la capacité, les personnes réservées + * à la table passent avant les autres. Sous les seules réservations, les + * personnes que les réservations du tour tiennent à la table s'y répartissent + * de même. Un exclu, une personne que la charge n'a plus, n'occupe rien et + * n'attend pas ; une personne déjà assise à une table précédente de la + * charge ne s'assied pas une seconde fois ; une personne présente qu'aucune + * 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. * diff --git a/src/application/places.test.js b/src/application/places.test.js index 6f2f7d9..888625a 100644 --- a/src/application/places.test.js +++ b/src/application/places.test.js @@ -475,7 +475,7 @@ describe('etatPlaces : la réservation au siège que le placement ne tient pas ( 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(); charge.reservations.push(resa(2, 1, 3)); 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.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null }); 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', []]); }); diff --git a/src/application/seance.js b/src/application/seance.js index 3012061..bbbfee5 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -237,7 +237,7 @@ export function creerSeance({ cadrage: null, }; - // --- L'observation (A2) ----------------------------------------------------- + // --- L'observation ------------------------------------------------------------ // Les abonnés de subscribe, ceux de surAnnulation, et l'instantané rendu // 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 // 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) { try { const { reglages } = await lireReglages(fs, ouvert.racine); @@ -445,9 +445,16 @@ export function creerSeance({ async function prendreVerrou(ouvert, reprise = null) { let verrou; 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 { - verrou = await verrouiller(ouvert, id); - if (!verrou.pris && verrou.seance === id) verrou = { pris: true }; + verrou = await prendre(); const confirme = !verrou.pris && reprise !== null && @@ -456,8 +463,8 @@ export function creerSeance({ verrou.depuis === reprise.depuis; if (!verrou.pris && (verrou.vivant === false || confirme)) { const ancien = verrou; - await effacerVerrou(ouvert, ancien.seance); - verrou = await verrouiller(ouvert, id); + await effacerVerrou(ouvert, ancien.seance, depuis); + verrou = await prendre(); if (verrou.pris) { 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 - // 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 - // s'efface. - async function effacerVerrou(ouvert, seance) { + // 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 : il se relit + // juste avant l'effacement, par verrouiller, et son fichier ne s'efface que + // 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) { await deverrouiller(ouvert, seance); 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); } @@ -945,19 +959,19 @@ export function creerSeance({ // 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 // 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 - // admet la reprise : sur un plan bloqué, ou relu bloqué, le verrou repris - // se rend aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit - // parmi les avertissements du dernier geste ; débloquer passe ensuite. + // verrou refusé, c'est passerEnEcriture, ses refus compris : un plan + // bloqué lève LECTURE_SEULE BLOQUE. Le refus d'un débloquer admet la + // reprise : sur un plan bloqué, ou relu bloqué, le verrou repris se rend + // 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() { const ouvert = exigerOuvert(); + if (etat.verrouAutre === null) return passerEnEcriture(); if (ouvert.formatPlusRecent) throw refusFormat(); if (etat.mode === 'ecriture') return { avertissements: [] }; exigerSupport(); - const reprise = etat.verrouAutre; - return entrerSousVerrou(ouvert, reprise === null ? null : { seance: reprise.seance, depuis: reprise.depuis }, { - bloqueAdmis: true, - }); + const { seance, depuis } = etat.verrouAutre; + return entrerSousVerrou(ouvert, { seance, depuis }, { bloqueAdmis: true }); } // 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 // type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier} // 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 ; // son annulation ne change rien, et rend null. @@ -1076,6 +1091,12 @@ export function creerSeance({ ailleurs: 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') { try { avertissements.push(...(await prendreVerrou(ailleurs))); diff --git a/src/application/seance.test.js b/src/application/seance.test.js index 69efcdf..91f2343 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -210,7 +210,7 @@ async function refus(promesse, code, details) { 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 BLOQUE_SEUL = { raison: 'BLOQUE', remede: { geste: 'debloquer' } }; 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'); }); + 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 () => { const disque = new Map(); 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'); }); + 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 () => { const disque = new Map(); 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']); }); + 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 () => { const montage = monter({ prefixe: 'bloqueuse' }); 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']); }); + 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 () => { const { fs, seance } = await surPortable(); 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. const details = { chemin: VERROU, dossier: DOCUMENTS, cause: 'EACCES' }; 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); - return verrouiller(racine, chemin, jeton); + return verrouiller(racine, chemin, jeton, depuis); }; const { base, avertissements } = await seance.ecrireAilleurs(DOC); 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 // é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 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 () => { const fs = creerFichiersSimules(); 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 () => { const fs = creerFichiersSimules(); 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 () => { const fs = creerFichiersSimules(); 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 () => { const montage = await ouvertSur(chargeBloquee()); 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 () => { const montage = monter(); 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 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. function sallePleine() { 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 () => { const disque = new Map(); 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. async function surPortable() { 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 () => { assert.equal(monter().seance.etat().refus, null); 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 () => { const charge = chargeEssai(); 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 () => { const charge = avecRetenu(chargeEssai()); 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 () => { const fs = creerFichiersSimules(); 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)", () => { - const TRACES_A2 = [/\bundefined\b/, /\bnull\b/, /\bNaN\b/, /\b(?:true|false)\b/, /\[object /]; - const EXEMPLES_A2 = [ - ['RETOUR_AUTOMATIQUE', {}], - ['INVARIANT_VIOLE', { proposition: 3, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }, { code: 'DOUBLE_PLACE', participant: 4, tour: 2 }] }], - ['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} »`); - } - }); - +// Les textes des codes que la séance ajoute se composent, sans trace de +// valeur absente, sous les détails d'exemple de la table que +// libelles.test.js partage pour chaque code ; les épreuves qui suivent +// lisent ce que chaque texte nomme. +describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6)", () => { 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: 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", () => { - 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.match(derive, /n° 2/); 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.match(viole, /n° 3/); for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) { diff --git a/src/geometrie/designation.test.js b/src/geometrie/designation.test.js index 0e005a5..baa683d 100644 --- a/src/geometrie/designation.test.js +++ b/src/geometrie/designation.test.js @@ -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 }); }); + 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', () => { // À 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. diff --git a/src/geometrie/echelle.js b/src/geometrie/echelle.js index b7a93c7..31ff5ec 100644 --- a/src/geometrie/echelle.js +++ b/src/geometrie/echelle.js @@ -14,13 +14,21 @@ const MANTISSES = [5, 2, 1]; // par 10ⁿ, qu'elle arrondit. 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 * 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 * 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. - * 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} largeurMaxPx * @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)}`); } } - 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) { const longueurCm = ronde(m, n); if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k }; diff --git a/src/geometrie/echelle.test.js b/src/geometrie/echelle.test.js index f0f269d..399adb7 100644 --- a/src/geometrie/echelle.test.js +++ b/src/geometrie/echelle.test.js @@ -52,4 +52,21 @@ describe("échelle : l'échelle graphique (§ 7.7)", () => { 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}`); + } + }); }); diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js index 147d839..18a989f 100644 --- a/src/geometrie/tables.js +++ b/src/geometrie/tables.js @@ -10,6 +10,7 @@ // 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. import { constante } from './constantes.js'; +import { TAILLE_TEXTE_LISTE_CM } from './texte.js'; /** * @typedef {Object} GeometrieTable en cm @@ -39,14 +40,20 @@ export const RAYON_CHAISE = 22; export const RAYON_POIGNEE = 15; /** Distance entre l'emprise et le bord de la poignée, en cm. */ export const ECART_POIGNEE = 8; -/** 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 à - * 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et - * aucune marque ne couvre la liste. */ +/** 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'en écarte + * de ECART_FOCUS (src/interface/plan/modele.js), plus la moitié de son + * trait, en deçà de cette distance : le bloc commence au-delà, et aucune + * marque ne couvre la liste. */ export const ECART_LISTE = 36; /** Écart minimal entre les centres de deux rangées de la grille des tables, * en cm. */ 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 // 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 * 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 à - * ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors - * de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée, - * ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne - * à travers une colonne. + * ECART_LISTE au moins de l'emprise nouvelle. Ainsi, à travers une colonne, + * une table déplacée hors de la grille, ou une grille dont le pas a crû + * depuis qu'elle a été posée, ne reçoit pas la liste de la table nouvelle et + * 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 * 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 × * 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} 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 */ 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)); } diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js index 5951715..e04a8f8 100644 --- a/src/geometrie/tables.test.js +++ b/src/geometrie/tables.test.js @@ -7,6 +7,7 @@ // 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. import assert from 'node:assert/strict'; +import { HAUTEUR_LIGNE_SUPPOSEE, fautesBandes } from '../../test/bandes.js'; import { describe, test } from '../../test/lanceur.js'; import { constante } from './constantes.js'; import { surLeSol } from './designation.js'; @@ -26,6 +27,7 @@ import { ecartsGrille, englobant, geometrieTable, + listeSurTable, placesSousPoignee, } 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. 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', () => { test('les valeurs du contrat', () => { assert.equal(PLANCHER_DIAMETRE, 70); @@ -97,6 +57,7 @@ describe('constantes', () => { assert.equal(ECART_POIGNEE, 8); assert.equal(ECART_LISTE, 36); 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', () => { 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), 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)}`); + } + }); }); diff --git a/src/geometrie/vue.js b/src/geometrie/vue.js index c9ab959..3f16b82 100644 --- a/src/geometrie/vue.js +++ b/src/geometrie/vue.js @@ -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 * (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève * 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 {{kMin: number, kMax: number}|null} [bornes] * @returns {number} */ -export function facteurMolette(deltaY, deltaMode) { +export function facteurMolette(deltaY, deltaMode, bornes = null) { const cran = CRAN_PAR_MODE[deltaMode]; if (!Number.isInteger(deltaMode) || cran === undefined) { 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. - * 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 * @returns {Vue} */ export function vueDepuisTransform(texte) { const lu = FORME_TRANSFORM.exec(texte); - const k = lu === null ? Number.NaN : Number(lu[3]); - if (lu === null || !(k > 0) || !Number.isFinite(k)) { + const [tx, ty, k] = lu === null ? [] : [Number(lu[1]), Number(lu[2]), Number(lu[3])]; + if (lu === null || !(k > 0) || ![k, tx, ty].every(Number.isFinite)) { 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 }; } diff --git a/src/geometrie/vue.test.js b/src/geometrie/vue.test.js index 0f2363a..4fc095d 100644 --- a/src/geometrie/vue.test.js +++ b/src/geometrie/vue.test.js @@ -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', () => { const GRAINE = 20260; const alea = creerPcg32(GRAINE, FLUX.DEMO); - let nuls = 0; - let rendus = 0; + // Les natures tirées : 0, un cadre plus étroit que ses marges ; 1, un + // 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) { - const nature = i % 5; + const nature = i % 6; const cadre = { gauche: alea.borne(2000) - 1000, haut: alea.borne(2000) - 1000, - largeur: nature === 0 ? alea.borne(33) : alea.borne(3000), - hauteur: nature === 1 ? 0 : alea.borne(3000), + largeur: nature === 0 ? alea.borne(33) : 33 + alea.borne(3000), + hauteur: nature === 1 ? 0 : 33 + alea.borne(3000), }; let englobant = null; - if (nature >= 2) { + if (nature >= 2 && nature <= 4) { const minX = alea.borne(20000) - 10000; const minY = alea.borne(20000) - 10000; 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); if (k === null) { assert.equal(vue, null, `tirage ${i}`); - nuls += 1; + comptes[nature].nuls += 1; continue; } - rendus += 1; + comptes[nature].rendus += 1; 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)}`); const bornes = bornesZoom(k); 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', () => { @@ -324,6 +332,25 @@ describe("vue : la molette et l'attribut transform", () => { 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', () => { const vues = [ { k: 2, tx: 10, ty: 20 }, @@ -341,6 +368,9 @@ describe("vue : la molette et l'attribut transform", () => { 'translate(10,20) scale(-2)', 'translate(a,20) scale(2)', '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); } diff --git a/src/moteur/diagnostic.js b/src/moteur/diagnostic.js index 3d13e83..25fb88d 100644 --- a/src/moteur/diagnostic.js +++ b/src/moteur/diagnostic.js @@ -109,7 +109,12 @@ function pairesMinimales(poses, libres) { // 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, // 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(() => []); for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p); const planchers = []; @@ -121,8 +126,8 @@ function planchersCollisions({ N, T, R, groupe, groupes, fixe }) { let libres = 0; for (const p of membres[g]) { const t = fixe[p * R + r]; - if (t === LIBRE) libres += 1; - else poses[t] += 1; + if (t !== LIBRE) poses[t] += 1; + else if (avecLibres) libres += 1; } const minimum = pairesMinimales(poses, libres); cumulees += minimum; @@ -213,26 +218,29 @@ function repartition(aPriori, statut) { // Chaque plancher de collisions, complété de tablesManquantes. La sonde à m // tables ajoute m tables de la plus grande capacité présente, d'identifiants -// au-delà du plus grand, et rechiffre les planchers par planchersCollisions : -// le premier m où le groupe n'y figure plus est son remède. Un groupe de s +// au-delà du plus grand, et rechiffre les planchers par chiffrer : le +// 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, // 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 -// changent pas le compte ; elles disent quelles tables ajouter. Un plancher -// suppose une table au moins. -function avecTablesManquantes(configuration, instance, planchers) { +// le plancher des seules places réservées (avecLibres faux), qu'aucune table +// ajoutée n'abaisse, le dit d'un seul chiffrage, et le groupe garde null +// sans être sondé. Les capacités, relâchées dans le plancher, ne changent +// 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 })); 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 premierId = Math.max(...instance.idsTables) + 1; const ajoutees = []; for (let m = 1; enAttente(m); m += 1) { ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite }); 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) { - 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; } } @@ -388,9 +396,12 @@ function etendue(valeurs) { * - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null. * * @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} */ -export function diagnostiquer(configuration) { +export function diagnostiquer(configuration, { planchersCollisions: chiffrer = planchersCollisions } = {}) { const instance = normaliser(configuration); const aPriori = plafondsAPriori(instance); const memeAppartenance = animateursMemeAppartenance(instance); @@ -404,7 +415,7 @@ export function diagnostiquer(configuration) { parPersonne: aPriori, repartition: repartition(aPriori, instance.statut), }, - collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)), + collisions: avecTablesManquantes(configuration, instance, chiffrer(instance), chiffrer), redondanceMinimale: minimale, remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) }, animateursMemeAppartenance: memeAppartenance, diff --git a/src/moteur/diagnostic.test.js b/src/moteur/diagnostic.test.js index c46a768..d803eb0 100644 --- a/src/moteur/diagnostic.test.js +++ b/src/moteur/diagnostic.test.js @@ -13,7 +13,7 @@ import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; import { conflitsParTable } from './conflits.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 { mesurer } from './indicateurs.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 // par tour, deux tours. test('un membre exclu sort de l’effectif et du plancher', () => { diff --git a/src/stockage/correctifs.js b/src/stockage/correctifs.js index 13d9e73..13e6dfd 100644 --- a/src/stockage/correctifs.js +++ b/src/stockage/correctifs.js @@ -8,8 +8,9 @@ // Une opération pose une valeur au bout d'un chemin, ou retire // l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou // { op: 'retirer', chemin }. Un 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 -// charge : une clé d'objet, chaîne ; un rang de liste, entier ≥ 0 ; ou +// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Un +// chemin est une liste d'étapes depuis la charge : une clé d'objet, chaîne ; +// un rang de liste, entier ≥ 0 ; ou // { id }, qui désigne dans une liste d'enregistrements à identifiant — // participants, tables, propositions — l'enregistrement de cet identifiant, // et non une place. @@ -135,24 +136,26 @@ function comparer(a, b, regle, chemin, operations) { } else if (a.length !== b.length) { operations.push({ op: 'poser', chemin, valeur: b }); } else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) { - comparerIdentifiants(a, b, regle.element, chemin, operations); + comparerIdentifiants(a, b, chemin, operations); } else { 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 -// même longueur, d'élément de règle regleElement, au bout de chemin : une -// pose par rang qui change, ou b entière quand ces poses pèsent plus que la +// Ajoute à operations ce qui mène de a à b, deux listes de même longueur +// d'identifiants ou d'occupants, des scalaires, au bout de chemin : une pose +// 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 // 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 // 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 // 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 = []; - 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 }; 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 * soit ; retirer autre chose qu'un enregistrement par { id } ou un champ * 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 - * porte pas la valeur qu'elle remplace, et un correctif dont chaque chemin - * existe aussi dans une autre charge s'y applique sans lever. Un correctif - * qui n'est pas une liste lève TypeError. + * sa règle, et canoniser, difference et serialiser la lisent sans lever. Le + * contrôle porte sur la forme : une opération ne porte pas la valeur qu'elle + * remplace, et un correctif dont chaque chemin existe aussi dans une autre + * charge s'y applique sans lever. Un correctif qui n'est pas une liste lève + * TypeError. * * @param {import('./types.js').Charge} a * @param {Operation[]} correctif diff --git a/src/stockage/depot.js b/src/stockage/depot.js index 1d56a5b..fde71f3 100644 --- a/src/stockage/depot.js +++ b/src/stockage/depot.js @@ -562,21 +562,38 @@ function avertir({ examen, journal, revision, rattrape, ecartees, recree, format 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 -// journal trouvé pour elle. rattraper : un journal en avance d'une seule -// entrée sur la source donne sa charge, sa révision et sa version, et ce que -// l'en-tête de la source annonce sans que sa charge le porte ne compte plus. -// La source d'un format plus récent, ou un journal d'un format plus récent, -// gardent l'Ouvert en lecture seule. -function assembler({ fs, racine, base, source, trouve, instant, rattraper }) { - 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; - } +// journal trouvé pour elle. avecRattrapage : la source suit la règle de +// rattraper. La source d'un format plus récent, ou un journal d'un format +// plus récent, gardent l'Ouvert en lecture seule. +function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }) { + const lu = avecRattrapage ? rattraper(source, instant) : { ...source, rattrape: false }; + const { charge, revision, produitVersion, manquants, rattrape } = lu; const journal = instant?.journal ?? null; const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0; 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 }); const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id); 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 trouve = await journalDe(vue, base, lecture.charge.evenement.id); 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 ? journalSansEtat(vue, base) @@ -823,7 +840,7 @@ export async function ouvrirSecours(fs, racine, base, source) { manquants: AUCUN_MANQUANT, ...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 @@ -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 - * le système de fichiers, tel quel. + * Prend le verrou de la séance sur l'événement (§ 8.8), daté de depuis, que + * la séance lit à son horloge : le Verrou que rend le système de fichiers, + * tel quel. * * @param {Ouvert} ouvert * @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} */ -export async function verrouiller({ fs, racine, base }, seance) { - return fs.verrouiller(racine, base + SUFFIXES.verrou, seance); +export async function verrouiller({ fs, racine, base }, seance, depuis) { + return fs.verrouiller(racine, base + SUFFIXES.verrou, seance, depuis); } /** diff --git a/src/stockage/depot.test.js b/src/stockage/depot.test.js index d6ec41d..25b2190 100644 --- a/src/stockage/depot.test.js +++ b/src/stockage/depot.test.js @@ -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)', () => { - 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 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 { depuis } = JSON.parse(texte); + assert.equal(JSON.parse(texte).depuis, depuis); 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); 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, seance: 'seance-a', depuis, @@ -2448,9 +2450,9 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => { await deverrouiller(ouvert, 'seance-a'); 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); - 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]); }); diff --git a/src/stockage/document.js b/src/stockage/document.js index 95d6864..de117bc 100644 --- a/src/stockage/document.js +++ b/src/stockage/document.js @@ -113,10 +113,11 @@ const NOMBRE = regle('nombre'); const IDENTIFIANT = entier(1, 2 ** 31 - 1); // 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 -// (§ 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 // 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 }); // Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js index cd89a06..91428ed 100644 --- a/src/stockage/document.test.js +++ b/src/stockage/document.test.js @@ -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", () => { const documentLu = documentValide(); documentLu.charge.propositions = [propositionAChaiseVide()]; diff --git a/src/stockage/fichiers_electron.js b/src/stockage/fichiers_electron.js index 409f72b..11f2fea 100644 --- a/src/stockage/fichiers_electron.js +++ b/src/stockage/fichiers_electron.js @@ -13,6 +13,7 @@ // // L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille. import { ErreurStockage } from './erreurs.js'; +import { exigerDepuis } from './systeme_fichiers.js'; /** * 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), deplacer: (racine, de, vers) => appeler('deplacer', racine, de, vers), 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), ouvrirDansExplorateur: (racine) => appeler('ouvrirDansExplorateur', racine), choisirFichierAImporter: () => appeler('choisirFichierAImporter'), diff --git a/src/stockage/fichiers_web.js b/src/stockage/fichiers_web.js index a91451f..354433b 100644 --- a/src/stockage/fichiers_web.js +++ b/src/stockage/fichiers_web.js @@ -64,7 +64,7 @@ // le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend // le nom proposé. 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. 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); + exigerDepuis(depuis); localiser(racine, chemin); return { pris: true }; }, diff --git a/src/stockage/fichiers_web.navigateur.test.js b/src/stockage/fichiers_web.navigateur.test.js index bf98c00..e2cd881 100644 --- a/src/stockage/fichiers_web.navigateur.test.js +++ b/src/stockage/fichiers_web.navigateur.test.js @@ -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)', () => { test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => { 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')).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', PRISE)).toStrictEqual({ pris: true }); expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); // 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.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b'); 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); }); @@ -609,7 +613,8 @@ describe('fichiers web : fautes de programmation', () => { () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42), () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'), () => 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.enregistrerSous('', BRUTS), () => fs.enregistrerSous('liste.csv', 'nom;prenom'), diff --git a/src/stockage/journal.js b/src/stockage/journal.js index e3abd33..90cd65d 100644 --- a/src/stockage/journal.js +++ b/src/stockage/journal.js @@ -14,10 +14,10 @@ // 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 // de ligne — OUVERTURE, ENTREE, JALON — donne à la fois l'ordre des clés à -// l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par TypeError la ligne dont la lecture -// refuserait la forme. La suite des révisions et la cadence des instantanés, -// que la lecture contrôle aussi, dépendent des lignes voisines : elles -// appartiennent à l'appelant. +// l'écriture et la règle de chaque clé à la lecture : l'écriture refuse par +// TypeError la ligne dont la lecture refuserait la forme. La suite des +// révisions et la cadence des instantanés, que la lecture contrôle aussi, +// dépendent des lignes voisines : elles appartiennent à l'appelant. // // Une entrée porte soit l'instantané de la charge qui en résulte, soit le // correctif qui y mène depuis la charge de l'entrée qui la précède dans @@ -76,7 +76,17 @@ const SENS = [null, 'defaire', 'refaire', 'revenir']; const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1; const estTexte = (valeur) => typeof valeur === 'string' && valeur !== ''; -const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur); + +/** + * 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 // refuserait, ou null : la forme de la charge, propositions et retenu lus diff --git a/src/stockage/operations.js b/src/stockage/operations.js index 2e9c223..62e2a48 100644 --- a/src/stockage/operations.js +++ b/src/stockage/operations.js @@ -582,7 +582,7 @@ export async function supprimer(fs, racine, base, { horodatage, seance }) { const dossier = dossierCorbeille(horodatage, existants); // La sonde vise le verrou sous son nom sur le disque, celui qui part. 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) { throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant }); } diff --git a/src/stockage/operations.test.js b/src/stockage/operations.test.js index 0310868..cd2e09a 100644 --- a/src/stockage/operations.test.js +++ b/src/stockage/operations.test.js @@ -481,7 +481,7 @@ describe('opérations : copier (§ 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 () => { 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 etatAvant = fs.contenu('documents', ETAT); 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)', () => { 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(); - await verrouiller(ouvert, 'seance-a'); + await verrouiller(ouvert, 'seance-a', HEURE_SUPPRESSION); fs.deposer('documents', `${ETAT}.ecriture`, 'partiel'); fs.deposer('documents', `${JOURNAL}.ecriture`, 'partiel aussi'); 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 () => { for (let n = 0; ; n += 1) { const { disque, fs, ouvert } = await deuxRevisions(); - await verrouiller(ouvert, 'seance-a'); + await verrouiller(ouvert, 'seance-a', HEURE_SUPPRESSION); fs.deposer('documents', `${ETAT}.ecriture`, 'partiel'); const avant = await arbre(disque); 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 () => { 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", - (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 }, ], ['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 // écriture et continue ses gestes ; le second ne le supprime pas. const partage = await deuxRevisions(); - await verrouiller(partage.ouvert, SEANCE); + await verrouiller(partage.ouvert, SEANCE, HEURE_SUPPRESSION); const second = creerFichiersSimules({ disque: partage.disque }); 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]); @@ -1643,12 +1643,12 @@ describe('opérations : supprimer vers la corbeille (§ 8.7)', () => { "une séance coupée", async (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); 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], ]; 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); 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.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); } }); diff --git a/src/stockage/placements.js b/src/stockage/placements.js index a8f7b01..b1dc72b 100644 --- a/src/stockage/placements.js +++ b/src/stockage/placements.js @@ -340,8 +340,8 @@ export function versFichier(proposition, configuration, { produitVersion, attrib * 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 * 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 - * n'est pas cohérent (fautes). + * rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec + * p. Lève TypeError quand p n'est pas cohérent (fautes). * * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p * @returns {import('../moteur/types.js').Plan} diff --git a/src/stockage/placements.test.js b/src/stockage/placements.test.js index 7e76b59..12a47fd 100644 --- a/src/stockage/placements.test.js +++ b/src/stockage/placements.test.js @@ -1569,6 +1569,10 @@ describe('le marqueur de chaise vide : null avant le dernier occupant, sièges a p.siegesAttribues = false; p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] }; }, '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 parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'], ['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'], diff --git a/src/stockage/systeme_fichiers.js b/src/stockage/systeme_fichiers.js index 4370898..0e64a2d 100644 --- a/src/stockage/systeme_fichiers.js +++ b/src/stockage/systeme_fichiers.js @@ -39,6 +39,7 @@ // // types.js donne la table entière des codes du stockage. import { ErreurStockage } from './erreurs.js'; +import { estHorodatage } from './journal.js'; /** * 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 * @property {(r: Racine, chemin: string) => Promise} supprimer * un fichier ; un fichier absent n'est pas une faute - * @property {(r: Racine, chemin: string, seance: string) => Promise} verrouiller + * @property {(r: Racine, chemin: string, seance: string, depuis: string) => Promise} verrouiller * 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} deverrouiller * n'efface que le verrou de cette séance ; un verrou absent n'est * pas une faute @@ -176,3 +181,15 @@ export function exigerCheminRelatif(chemin) { } 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)}`); +} diff --git a/src/stockage/types.js b/src/stockage/types.js index eb9f8a6..d1f192d 100644 --- a/src/stockage/types.js +++ b/src/stockage/types.js @@ -162,14 +162,16 @@ * correspondent rang à rang ; placement porte un tour par élément : * sieges[i] liste les occupants de tables[i] dans l'ordre des sièges — * triés par identifiant quand siegesAttribues est faux —, reserve ceux qui - * ne sont assis nulle part à ce tour. siegesAttribues appartient à la + * 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 * ne détruit pas l'ordre des sièges d'une proposition déjà produite. * participants et chaque reserve sont des ensembles, triés par identifiant * croissant quel que soit siegesAttribues. * * @typedef {Object} TourDePlacement - * @property {number[][]} sieges + * @property {(number|null)[][]} sieges null : une chaise vide * @property {number[]} reserve triée par identifiant croissant * * @typedef {Object} PropositionFichier @@ -452,9 +454,12 @@ * signalé et gardé (§ 8.9, point 3). Les raisons de dérive d'un placement * cohérent sont décrites avec derive (placements.js, Raison). * - * FORME {chemin} une valeur sort de sa règle, ou la - * déclaration se contredit ; chemin - * dans le placement, '' pour lui-même + * FORME {chemin} une valeur sort de sa règle, la + * déclaration se contredit, ou une + * 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 * declare, ecrit} que le placement déclare : nombre de * tours (tour et table null), de listes diff --git a/test/bandes.js b/test/bandes.js new file mode 100644 index 0000000..d6a0565 --- /dev/null +++ b/test/bandes.js @@ -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 }; +} diff --git a/test/contrat_fichiers.js b/test/contrat_fichiers.js index 63d0f8e..42cd83f 100644 --- a/test/contrat_fichiers.js +++ b/test/contrat_fichiers.js @@ -20,7 +20,8 @@ // ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté // d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la // suite exige du verrou ce que la plateforme promet à la place : verrouiller -// rend { pris: true } à chaque appel, et deverrouiller ne lève pas. +// 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'; // Les primitives de l'interface, dans l'ordre où elle les déclare. @@ -44,6 +45,15 @@ const PRIMITIVES = [ '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, // absolu sous Windows, remontant après un détour. 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, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)], ['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)], - ['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a')], + ['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a', DEPUIS)], ['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'); }); - 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(); - egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true }); - const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b'); + 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', DEPUIS); if (!fs.verrouDisponible) { egal(second, { pris: true }); return; } - vrai(typeof second.depuis === 'string' && second.depuis !== '', `depuis ${second.depuis}`); - egal(second, { pris: false, seance: 'seance-a', depuis: second.depuis, vivant: true }); + egal(second, { pris: false, seance: 'seance-a', depuis: DEPUIS_LOINTAIN, 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 () => { const { fs, racine } = await fabrique(); await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a'); - await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'); + await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a', DEPUIS); await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b'); 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; } - const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'); - egal([tenu.pris, tenu.seance], [false, 'seance-a']); + const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c', DEPUIS_LOINTAIN); + egal([tenu.pris, tenu.seance, tenu.depuis], [false, 'seance-a', DEPUIS]); 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 () => { diff --git a/test/coquille.test.js b/test/coquille.test.js index 87bdfa4..3add23f 100644 --- a/test/coquille.test.js +++ b/test/coquille.test.js @@ -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", () => { const { restes } = coquille(); assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort()); diff --git a/test/electron_factice.js b/test/electron_factice.js index c3a6510..c2ee6a1 100644 --- a/test/electron_factice.js +++ b/test/electron_factice.js @@ -64,6 +64,9 @@ const VERROU_REPRIS = 'reprise.gtt.verrou'; const SEANCE_PAGE = 'seance-page'; const SEANCE_REPRISE = 'seance-reprise'; 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; // Ce que rend app.getPath, par nom. @@ -90,7 +93,7 @@ const ARGUMENTS_DE_LA_PAGE = { creerDossier: () => [INCONNUE, 'corbeille'], deplacer: () => [INCONNUE, 'soiree.gtt.json', 'corbeille/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'], ouvrirDansExplorateur: (racines) => [racines?.documents], enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])], @@ -339,10 +342,10 @@ async function jouerLePrechargement() { const documents = racines?.documents ?? null; const verrou = documents === null || fichiers === undefined ? 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 ? 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) { const chemin = join(documents.chemin, VERROU_REPRIS); const contenu = JSON.parse(readFileSync(chemin, 'utf8')); diff --git a/test/fichiers_electron.long.test.js b/test/fichiers_electron.long.test.js index ee2d7b2..231a274 100644 --- a/test/fichiers_electron.long.test.js +++ b/test/fichiers_electron.long.test.js @@ -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 // 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. +// 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_LINUX = process.platform === 'linux'; 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.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.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' }); if (!SOUS_WINDOWS) { symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt')); @@ -291,10 +295,10 @@ describe('coquille sur un vrai disque', () => { const { fs, documents } = systemeSur(dossierTemporaire()); mkdirSync(documents, { recursive: true }); 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')); 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 }); const termine = spawnSync(process.execPath, ['-e', '']); @@ -304,7 +308,7 @@ describe('coquille sur un vrai disque', () => { join(documents, 'ancienne.gtt.verrou'), `${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, seance: 'seance-terminee', depuis, @@ -324,7 +328,7 @@ describe('coquille sur un vrai disque', () => { const { fs, documents } = systemeSur(dossierTemporaire()); mkdirSync(documents, { recursive: true }); 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')); assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']); 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`, ); 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 }, nom, ); @@ -372,7 +376,7 @@ describe('coquille sur un vrai disque', () => { shell: SANS_APPEL, 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 enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], { env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' }, @@ -387,7 +391,7 @@ describe('coquille sur un vrai disque', () => { const lecteur = [ '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 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 }));', ].join('\n'); const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], { diff --git a/test/fichiers_electron.test.js b/test/fichiers_electron.test.js index 8064f99..a97bb16 100644 --- a/test/fichiers_electron.test.js +++ b/test/fichiers_electron.test.js @@ -48,8 +48,9 @@ const DEV_DISQUE = 0x801n; const HOTE = 'poste-essai'; const PID = 4242; -// Instant que rend l'horloge injectée. -const INSTANT = new Date(Date.UTC(2026, 9, 6, 23, 2, 11)); +// L'instant de prise qu'un verrou reçoit, de la forme qu'écrit l'horloge de +// l'application. +const PRISE = '2026-10-06T19:02:11-04:00'; // Les racines de l'épreuve, et ce que les échecs y nomment. const DOCUMENTS = '/poste/exemple/Documents/Gestion table tournante Libre'; @@ -201,6 +202,21 @@ function creerDisque() { if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin); 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) { appel('open', chemin, drapeaux); const exclusif = drapeaux === 'wx'; @@ -385,7 +401,6 @@ function preparer({ demarrage, pid: PID, hote, - maintenant: () => INSTANT, }); 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, cible', (fichiers, racine, chemin) => fichiers.deplacer(racine, 'ici.txt', 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')], ]; @@ -986,19 +1001,69 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`; 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 { 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}`]); const texte = disque.contenu(VERROU); assert.ok(texte.endsWith('}\n'), texte); const contenu = JSON.parse(texte); 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.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 () => { @@ -1010,7 +1075,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { const { disque, fichiers, signaux } = preparer({ signaler }); deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: '2026-10-06T19:02:11-04:00' }); 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 }, nom, ); @@ -1028,13 +1093,13 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { 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); 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'); 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.deepEqual(lus, [PID, PID]); }); @@ -1056,7 +1121,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { const processus = tableDeProcessus([[PID, '1'], [777, demarrageLu]]); const { disque, fichiers } = preparer(processus); 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 // pid inscrit une fois le verrou trouvé. assert.deepEqual(processus.lus, lus, nom); @@ -1066,8 +1131,8 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { const processus = tableDeProcessus([[PID, '1']]); const { disque, fichiers } = preparer(processus); 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'), 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', PRISE), tenu(false)); 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: {} }]) { deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: 'x', ...inscrit }); 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 }, JSON.stringify(inscrit), ); @@ -1097,11 +1162,11 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { // ligne en porte assez. disque.deposer('/proc/782/stat', stat(782, 5000).replace(/[()]/g, '')); 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'); 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' }); - 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); }, }); - 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.deepEqual(commandes.map(({ commande }) => commande), ['powershell.exe']); 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 }); 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' }); - 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' }); - 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, seance: 'seance-a', depuis: DEPUIS, @@ -1157,7 +1222,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { plateforme: 'darwin', 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.deepEqual(commandes[0], { commande: 'ps', @@ -1166,11 +1231,11 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { }); 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' }); - 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' }); - 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.deepEqual(ailleurs.commandes, []); }); @@ -1186,17 +1251,17 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { let fuseauDuPoste = 'America/Montreal'; const lancer = async (_commande, _arguments, { variables }) => `${ecrit.get(variables.TZ ?? fuseauDuPoste)}\n`; 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'; 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 }); }); 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(); 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, seance: 'seance-a', 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 }; for (const texte of ['{"seance":"seance-a"', '{"seance":"seance-a","pid":0,"hote":"poste-essai","depuis":"x"}', '[]']) { 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, []); }); @@ -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 () => { const { disque, fichiers } = preparer(); 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'); assert.equal(disque.existe(VERROU), true); 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 () => { const { disque, fichiers } = preparer(); 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); 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", () => { @@ -1249,7 +1314,7 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => { test('la séance est une chaîne non vide', async () => { const { disque, fichiers } = preparer(); 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)); } assert.deepEqual(disque.appels, []); @@ -1712,7 +1777,7 @@ describe('page : src/stockage/fichiers_electron.js', () => { ['creerDossier', [DOC, 'corbeille']], ['deplacer', [DOC, 'a.txt', 'b.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']], ['ouvrirDansExplorateur', [DOC]], ['choisirFichierAImporter', []], @@ -1726,6 +1791,16 @@ describe('page : src/stockage/fichiers_electron.js', () => { 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 () => { const { pont } = pontFactice([ { ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } }, diff --git a/test/fichiers_simules.js b/test/fichiers_simules.js index cead5cd..369f5cb 100644 --- a/test/fichiers_simules.js +++ b/test/fichiers_simules.js @@ -23,7 +23,7 @@ // ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme // le verrait la coquille. import { ErreurStockage } from '../src/stockage/erreurs.js'; -import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js'; +import { exigerCheminRelatif, exigerDepuis } from '../src/stockage/systeme_fichiers.js'; /** * 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 // 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une -// primitive l'avance d'une seconde, si bien que modifie et la prise d'un -// verrou ne dépendent que de la suite des écritures. +// primitive l'avance d'une seconde, si bien que modifie ne dépend que de la +// 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 PAS_HORLOGE = 1000; @@ -101,10 +102,6 @@ function replier(texte) { }).join(''); } -// Un instant de l'horloge sous la forme de celle de l'application, -// AAAA-MM-JJTHH:MM:SS±HH:MM. -const horodatage = (millisecondes) => `${new Date(millisecondes).toISOString().slice(0, 19)}+00:00`; - function exigerChaine(valeur, quoi) { if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`); } @@ -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"); } +// 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}. const estContenuVerrou = (contenu) => contenu !== null && @@ -150,7 +151,8 @@ const estContenuVerrou = (contenu) => * @param {string} [reglages.hote] machine du processus ; un verrou d'un autre * hôte rend vivant à null * @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 compté * 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 @@ -204,8 +206,8 @@ export function creerFichiersSimules({ hote = 'poste-epreuve', dossiersChoisis = [], } = {}) { - if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || chemin === '')) { - throw new TypeError('dossiersChoisis : un tableau de chemins, chaînes non vides'); + if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || !CHEMIN_ABSOLU.test(chemin))) { + throw new TypeError('dossiersChoisis : un tableau de chemins absolus'); } const etat = etatDuDisque(disque, insensibleCasse, separateur); const pid = etat.prochainPid; @@ -511,8 +513,9 @@ export function creerFichiersSimules({ disque.delete(loc.cle); }), - verrouiller: primitive('ecritures', (racine, chemin, seance) => { + verrouiller: primitive('ecritures', (racine, chemin, seance, depuis) => { exigerSeance(seance); + exigerDepuis(depuis); const loc = localiser(racine, chemin); if (!verrouDisponible) return { pris: true }; exigerInscriptible(loc); @@ -520,8 +523,7 @@ export function creerFichiersSimules({ exigerPasDossier(loc); const present = disque.get(loc.cle); if (present !== undefined) return verrouPresent(texteDuNoeud(present)); - const prise = avancer(); - poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise); + poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis })}\n`, avancer()); return { pris: true }; }), diff --git a/test/fichiers_simules.test.js b/test/fichiers_simules.test.js index 9ed8524..82e3b97 100644 --- a/test/fichiers_simules.test.js +++ b/test/fichiers_simules.test.js @@ -15,6 +15,10 @@ import { eprouverContrat } from './contrat_fichiers.js'; import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.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. async function rejette(promesse, message) { let raison; @@ -167,7 +171,7 @@ describe('fichiers simulés : racines et emplacements', () => { await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' }); await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json')); await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl')); - await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', absent('soiree.gtt.verrou')); + 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.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: '' }); }); - test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide lève TypeError à la création', () => { - for (const fautif of [[''], [null], 'D:\\Secours']) { + 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', ['Secours'], ['secours\\soirees'], ['D:\\Secours', './Repli']]) { 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.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json')); await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json')); - await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b'), 'ECRITURE', refus('neuf.gtt.verrou')); + await echoue(fs.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')); 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', () => { - 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(); - 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'); - assert.match( - texte, - /^\{"seance":"seance-a","pid":\d+,"hote":"poste-epreuve","depuis":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00"\}\n$/, - ); - const { pid, depuis } = JSON.parse(texte); - assert.equal(pid, fs.pid); + assert.equal(texte, `${JSON.stringify({ seance: 'seance-a', pid: fs.pid, hote: 'poste-epreuve', depuis: DEPUIS })}\n`); 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, seance: 'seance-a', - depuis, + depuis: DEPUIS, 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 () => { const fs = preparer(); - await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); - const second = 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', DEPUIS); assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]); }); @@ -482,22 +483,22 @@ describe('fichiers simulés : verrou', () => { a.deposerDossier('documents'); assert.notEqual(a.pid, b.pid); - await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a'); - const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b'); + await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a', DEPUIS); + const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b', DEPUIS); assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]); - await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c'); - const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x'); + await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c', DEPUIS); + const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x', DEPUIS); assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]); // Le pid d'un processus de l'autre machine ne désigne aucun processus de // celle-ci. const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' }; a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe)); - assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x')).vivant, false); + assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x', DEPUIS)).vivant, false); 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]); }); @@ -505,16 +506,16 @@ describe('fichiers simulés : verrou', () => { const fs = preparer(); const illisible = { pris: false, seance: null, depuis: null, vivant: null }; fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":'); - assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'), illisible); + assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a', DEPUIS), illisible); await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'); assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":'); fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}'); - assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b'), illisible); + 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' }; 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, seance: 'ancienne', 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 () => { const fs = preparer({ verrouDisponible: false }); fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}'); - assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a'), { pris: true }); - assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b'), { pris: true }); + 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', DEPUIS), { pris: true }); await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); 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.deplacer(DOC, 'a.txt', 'corbeille/a.txt'); await fs.supprimer(DOC, 'corbeille/a.txt'); - await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a', DEPUIS); await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); await fs.sonder(DOC); 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 () => { const fs = preparer(); 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, seance: 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\r'), 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, ''), []); }); @@ -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.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR')); await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR')); - await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a', DEPUIS), 'ECRITURE', refus('corbeille', 'EISDIR')); await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR')); await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR')); await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST'));