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'));