Compare commits

...

4 commits

Author SHA1 Message Date
fa2fc0eb2b [UPD] spec: storage, CSV and workstation rules as the code has them
The spec said less than the code enforces, so a reader could not tell a rule
from an accident. It now states: ten minutes of inactivity before read mode
(§ 8.4); reglages_locaux.json (§ 8.5); the probe that creates the chosen
working folder, the temporary Chromium profile, and the name bound on the
longest path an event writes, trash and atomic write included (§ 8.6); the
proposition counter and per-proposition seat flag (§ 8.9); the closed list of
exclu values, encoding refusals and the one-column file (§ 10.1); the layer
and determinism guards, the coverage floors, and the workstation (§ 13, § 16).
Checked: 1266 node tests on the result.

--- FR ---

[UPD] spec : stockage, CSV et poste de développement selon le code

Le spec en disait moins que le code n'en impose : un lecteur ne distinguait
pas une règle d'un hasard. Il énonce désormais : dix minutes d'inactivité
avant la lecture (§ 8.4) ; reglages_locaux.json (§ 8.5) ; la sonde qui crée le
dossier de travail retenu, le profil temporaire de Chromium et la borne posée
sur le plus long chemin écrit, corbeille et écriture atomique comprises
(§ 8.6) ; le compteur des propositions et le drapeau des sièges (§ 8.9) ; la
liste fermée d'exclu, les refus d'encodage, le fichier à une colonne
(§ 10.1) ; les gardes des couches et du déterminisme, les seuils de
couverture et le poste de développement (§ 13, § 16).
Vérifié : 1266 épreuves node sur le résultat.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:42:00 -04:00
aa5fafab1a [ADD] tests: per-module coverage, 100 % branch floors, layer guards
make couverture runs the node series under V8 instrumentation and prints one
row per module, full ones included, plus a line-by-line HTML report. Four
modules hold a hard 100 % branch floor — indicateurs.js, plafond.js,
depot.js, journal.js — and the command fails below it; there is no global
threshold, which covering the easy modules would satisfy. The tree guard
refuses a lowered or global floor, a floor on a file the measure never loads,
and a command that does not measure. It extends determinism and layer
boundaries to src/stockage, src/csv and src/application (§ 13.4), and no
longer takes a string literal for DOM access.
Checked: 1266 node tests; the four floors at 100 %.

--- FR ---

[ADD] épreuves : couverture par module, seuils durs, gardes des couches

make couverture joue la série node sous l'instrumentation de V8 et imprime une
ligne par module, pleins compris, et un rapport HTML ligne à ligne. Quatre
modules tiennent un seuil dur de 100 % des branches — indicateurs.js,
plafond.js, depot.js, journal.js — et la commande échoue en deçà ; aucun seuil
global, qu'on satisferait en couvrant les modules faciles. La garde de
l'arborescence refuse un seuil abaissé ou global, un seuil sur un fichier que
la mesure ne charge pas, et une commande qui ne mesure pas. Elle étend le
déterminisme et les frontières des couches à src/stockage, src/csv et
src/application (§ 13.4), et ne prend plus une chaîne littérale pour le DOM.
Vérifié : 1266 épreuves node ; les quatre seuils à 100 %.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:41:34 -04:00
fa32ad9fae [ADD] storage: event store — list, create, open, save, recover
An event is a state file and a journal paired by its internal identifier,
never by name (§ 8.6): a hand copy of a state does not take the original's
journal, and a renamed journal stays paired. Each gesture writes its journal
line first, then the .precedent and the state (§ 8.2); recovery compares
revisions, never file dates, so a cut leaves the journal one entry ahead at
most. Opening never writes (§ 8.4); an unreadable state names its readable
secours instead of opening an empty event, and a newer format stays
read-only. Writing elsewhere after a removed medium aligns the pair first.
Checked: 60 tests, 100 % branches of depot.js, 1257 node tests on HEAD.

--- FR ---

[ADD] stockage : dépôt d'événements — lister, créer, ouvrir, reprendre

Un événement est un fichier d'état et un journal appariés par l'identifiant
interne, jamais par le nom (§ 8.6) : un état copié à la main ne prend pas le
journal de l'original, et un journal renommé reste apparié. Chaque geste
écrit sa ligne de journal d'abord, puis le .precedent et l'état (§ 8.2) ; la
reprise compare les révisions, jamais la date des fichiers : une coupure
laisse le journal en avance d'une entrée au plus. Ouvrir n'écrit jamais
(§ 8.4) ; un état illisible nomme ses secours lisibles au lieu d'ouvrir un
événement vide, et un format plus récent reste en lecture seule. Écrire
ailleurs après un support retiré aligne d'abord la paire.
Vérifié : 60 épreuves, 100 % des branches de depot.js, 1257 épreuves node.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:39:49 -04:00
15148e3a26 [FIX] storage: compact seat patches, checked poses, 32-bit identifiers
Without seat attribution a table list stays sorted, so swapping two
people shifted every rank between them and a patch grew to over a
kilobyte; a same-length list of ids is now set whole when that is
lighter, and a swap costs two small operations (§ 8.9). A patch that
sets a malformed proposition or retained placement is refused at its
rank, instead of a TypeError at the next application. Identifiers stay
below 2^31, the engine's Int32Array bound: beyond, the record is a
form fault, and a proposition is set aside.

Checked: storage tests; reviewed, remaining findings minor.

--- FR ---

[FIX] stockage : correctifs compacts, poses contrôlées, id sous 2^31

Sans attribution des sièges, une liste de table reste triée : échanger
deux personnes décalait chaque rang entre elles, et un correctif passait
le kilo-octet ; une liste d'identifiants de même longueur se pose
désormais entière quand c'est plus léger, et un échange coûte deux
petites opérations (§ 8.9). Un correctif qui pose une proposition ou un
retenu mal formé est refusé à son rang, au lieu d'une TypeError à
l'application suivante. Les identifiants restent sous 2^31, borne des
Int32Array du moteur : au-delà, faute de forme, et proposition écartée.

Vérifié : épreuves du stockage ; revue, constats restants mineurs.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 19:22:02 -04:00
16 changed files with 5285 additions and 308 deletions

View file

@ -64,6 +64,10 @@ test_navigateur: ## Joue les épreuves du navigateur, sous Chromium
test_tout: ## Joue les trois séries
$(AVEC_NODE) npm run test:tout
.PHONY: couverture
couverture: ## Mesure la couverture de la série node, module par module ; seuils durs du § 14.13
$(AVEC_NODE) npm run couverture
.PHONY: build
build: ## Construit la page dans www/
$(AVEC_NODE) npm run build

144
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@capacitor/core": "^8.5.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@vitest/browser-playwright": "^5.0.3",
"@vitest/coverage-v8": "^5.0.3",
"electron": "^44.5.1",
"electron-builder": "^26.15.3",
"fast-check": "^4.10.2",
@ -22,6 +23,66 @@
"vitest": "^5.0.3"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@blazediff/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz",
@ -1249,6 +1310,58 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz",
"integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/istanbul-lib-coverage": "^1.0.0",
"@vitest/istanbul-lib-report": "^1.0.0",
"ast-v8-to-istanbul": "^1.0.5",
"magicast": "^0.5.4",
"obug": "^2.1.4",
"std-env": "^4.2.0",
"tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "5.0.3",
"vitest": "5.0.3"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/istanbul-lib-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz",
"integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22"
}
},
"node_modules/@vitest/istanbul-lib-report": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz",
"integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/istanbul-lib-coverage": "1.0.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
@ -1693,6 +1806,18 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz",
"integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@ -3521,6 +3646,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@ -3936,6 +4068,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/magicast": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz",
"integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"source-map-js": "^1.2.1"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",

View file

@ -17,13 +17,15 @@
"test:surveille": "vitest --project node",
"test:long": "vitest run --project node-long",
"test:navigateur": "vitest run --project navigateur",
"test:tout": "vitest run"
"test:tout": "vitest run",
"couverture": "vitest run --project node --coverage --reporter=default"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@vitest/browser-playwright": "^5.0.3",
"@vitest/coverage-v8": "^5.0.3",
"electron": "^44.5.1",
"electron-builder": "^26.15.3",
"fast-check": "^4.10.2",

238
spec.md
View file

@ -1191,8 +1191,9 @@ tienne : si naviguer exigeait de passer en écriture, l'opérateur y resterait e
permanence et le mode lecture ne protégerait plus rien.
**On en sort par un seul geste délibéré** — une commande « Modifier » à place
fixe — et le logiciel y retourne **seul après inactivité**. Ce retour ne coûte
rien et ne perd rien, puisque tout geste achevé est déjà sur le disque.
fixe — et le logiciel y retourne **seul après dix minutes d'inactivité**. Ce
retour ne coûte rien et ne perd rien, puisque tout geste achevé est déjà sur le
disque.
**Le compteur d'inactivité ne court pas pendant un geste** : il est suspendu
tant qu'un pointeur est enfoncé, qu'un champ contient une saisie non validée ou
@ -1241,11 +1242,11 @@ involontaire, ou déplacer une personne dans un brouillon exigerait de
« débloquer ».
**Le cadrage se range avec le mode, hors du fichier** — dans un **fichier de
réglages locaux** du dossier de travail (§ 8.6), distinct des fichiers
d'événements. Écrire le zoom dans le fichier d'état contredirait l'équivalence du
§ 8.2, et **zoomer sur un plan bloqué écrirait dans le fichier d'un plan qui
refuse toute modification**. Ce réglage n'est ni transmis avec le fichier, ni
restitué par un retour arrière.
réglages locaux**, `reglages_locaux.json` à la racine du dossier de travail
(§ 8.6), distinct des fichiers d'événements. Écrire le zoom dans le fichier
d'état contredirait l'équivalence du § 8.2, et **zoomer sur un plan bloqué
écrirait dans le fichier d'un plan qui refuse toute modification**. Ce réglage
n'est ni transmis avec le fichier, ni restitué par un retour arrière.
Ce fichier porte **deux choses, et deux seulement** :
@ -1287,12 +1288,12 @@ règle écrite, et l'affiche :
l'interdit. Le livrable est pour cette raison un **exécutable portable à
fichier unique, non un installateur** (§ 16).
3. Sinon, le logiciel **éprouve l'écriture** dans `data/` à côté de
l'exécutable : il écrit un fichier témoin, le relit, l'efface, et efface un
témoin resté d'une séance précédente. Un contrôle de droits ne répond pas à
la question posée — les droits effectifs dépendent d'héritages,
d'appartenances de groupe, de stratégies d'antivirus, et un support amovible
en lecture seule présente un dossier d'apparence inscriptible. Une écriture
suivie d'une relecture répond.
l'exécutable, qu'il crée au besoin : il écrit un fichier témoin, le relit,
l'efface, et efface un témoin resté d'une séance précédente. Un contrôle de
droits ne répond pas à la question posée — les droits effectifs dépendent
d'héritages, d'appartenances de groupe, de stratégies d'antivirus, et un
support amovible en lecture seule présente un dossier d'apparence
inscriptible. Une écriture suivie d'une relecture répond.
4. L'épreuve réussit → `data/` est le dossier de travail. C'est le **mode
portable** : l'exécutable et les événements se déplacent ensemble, et la
sauvegarde est la copie d'un seul dossier.
@ -1300,6 +1301,13 @@ règle écrite, et l'affiche :
un dossier nommé d'après le produit, dans les *Documents* de l'opérateur. Le
logiciel **dit pourquoi**, une fois, à la première ouverture, sans bloquer.
**Le dossier retenu est éprouvé à son tour, et c'est cette épreuve qui le
crée** : le dossier des *Documents* naît à la détermination, avant que la liste
des événements s'ouvre, et non à la première écriture. Aucun geste ne crée
ensuite le dossier où il écrit : un dossier de travail qui disparaît en cours de
séance ne renaît pas vide, en silence. Si l'épreuve échoue, le logiciel le dit
avant d'ouvrir la liste, puisqu'aucun geste ne s'y écrirait.
**Ce qui arrive ensuite est aussi réglé.**
- Si `data/` existe, **contient des événements** et cesse d'être inscriptible, le
@ -1308,7 +1316,8 @@ règle écrite, et l'affiche :
de la veille a disparu, et l'opérateur conclurait à une perte.
- La même règle vaut **en cours de séance**, pas seulement au démarrage : une clé
se retire. Quand une écriture échoue, le logiciel nomme le fichier et le
dossier, et propose d'écrire ailleurs. Il énonce alors que l'équivalence du
dossier, et propose d'écrire ailleurs, dans un dossier qu'il crée au besoin
puisque l'opérateur l'a désigné. Il énonce alors que l'équivalence du
§ 8.2 est rompue **par le support**, au lieu de laisser croire que le dernier
geste est sur le disque.
- Si le dossier de travail est neuf et vide alors que l'autre emplacement
@ -1332,6 +1341,16 @@ propriétaire — et non du chemin.
Ce que le logiciel refuse, inchangé : **jamais de répertoire de données caché**,
jamais `AppData`, jamais un emplacement que l'opérateur ne peut pas nommer.
**Le profil de Chromium n'est pas un répertoire de données.** Chromium,
qu'emporte la coquille Electron, range par défaut son profil — cache,
préférences, stockage de la page — dans un dossier persistant des données
applicatives de l'utilisateur. Le logiciel n'y garde rien : la coquille pose ce
profil dans un dossier neuf du dossier temporaire du système, et l'efface à la
sortie ; chaque démarrage efface aussi ceux des séances terminées, que Chromium
a récrits après la sortie ou qu'une séance coupée a laissés. Un exécutable
portable ne laisse ainsi, hors du dossier de travail, rien qui survive à la
séance suivante.
**Deux fichiers par événement :**
| fichier | contenu | écriture |
@ -1347,12 +1366,20 @@ opaques est inutilisable dans un explorateur. La dérivation retire les
caractères que Windows refuse, **refuse les noms réservés quelle que soit la
casse**, **compare les collisions sans égard à la casse** (le système de
livraison y est insensible, celui de développement non), **borne le chemin
complet sur le plus long des deux suffixes** — `.gtt-journal.jsonl`, dix-neuf
caractères, et non `.gtt.json`, neuf ; sinon l'état tient et son journal déborde
—, et **retombe sur un nom générique** quand la dérivation ne laisse rien. **La
dérivation reçoit la racine du dossier de travail en paramètre**, celle-ci
n'étant plus connue à l'écriture du code, et son test l'exerce sur les deux
issues. **L'autorité reste le nom inscrit dans le fichier.**
complet sur le plus long chemin que l'événement écrit**, et **retombe sur un nom
générique** quand la dérivation ne laisse rien. Ce plus long chemin assemble les
plus longues parties qu'un fichier de l'événement peut porter : le dossier daté
de la corbeille (§ 8.7) au rang de collision le plus long — deux suppressions
dans la même seconde se départagent par `_2`, `_3`… jusqu'à `_99` —, le plus long
suffixe, celui de la génération de secours, `.gtt.json.precedent`, dix-neuf
caractères, et le suffixe `.ecriture` de l'écriture atomique (§ 8.8) :
`corbeille/AAAA-MM-JJ_HH-MM-SS_99/<nom>.gtt.json.precedent.ecriture`, soixante
et un caractères outre le nom. `.gtt-journal.jsonl` n'en compte que dix-huit, et
`.gtt.json` neuf : une borne posée sur l'état ou sur le journal laisserait
déborder tout le reste. **La dérivation reçoit la racine du dossier de travail
en paramètre**, celle-ci n'étant plus connue à l'écriture du code, et son test
l'exerce sur les deux issues. **L'autorité reste le nom inscrit dans le
fichier.**
**Pourquoi l'historique n'est pas dans le fichier d'état**, pour deux raisons
distinctes. *La taille* : un plan de 260 personnes sur 4 tours pèse de l'ordre
@ -1500,6 +1527,15 @@ Une proposition porte un **identifiant entier séquentiel**, et les propositions
sont rangées **en liste ordonnée** — jamais en objet indexé par une clé, dont
l'ordre n'est pas garanti par la forme. L'identifiant vient de la place dans la
suite de graines dérivées (§ 5.7), et ne dérive ni de l'horloge ni d'un tirage.
Il se compte à partir d'un compteur du fichier, `prochainsIds.proposition`, qui
dépasse tout identifiant de proposition jamais attribué, celui d'où vient le
placement retenu compris : une génération numérote ses propositions dans
l'ordre de ses graines à partir de lui, puis le porte au-delà de la dernière. Le
compteur ne recule jamais — ni quand une commande efface les propositions
(§ 5.7), ni quand une main l'abaisse dans le fichier, l'ouverture le relevant
au-delà de chaque identifiant qu'elle lit. Les générations s'accumulent ainsi
sans qu'un identifiant se répète ni revienne, et le retenu désigne toujours la
même proposition d'origine.
Chaque proposition déclare, **une fois** :
@ -1507,6 +1543,8 @@ Chaque proposition déclare, **une fois** :
- la **liste des capacités** correspondantes ;
- le **nombre de tours** ;
- l'**ensemble des identifiants de participants** qu'elle place ;
- le drapeau `siegesAttribues` : ses **sièges sont-ils attribués** (§ 5.3),
l'ordre d'une liste de table étant alors celui des sièges ;
- sa **graine dérivée** (§ 5.7), le **compte d'arrêt** et la **longueur de
l'historique** d'acceptation de la recherche qui l'a produite : les trois
réglages qui la déterminent.
@ -1532,7 +1570,13 @@ siège n'est pas stocké : il est la position dans la liste.
**Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une
table ne porte aucune information : le logiciel l'écrit **trié par identifiant
croissant**. Sans cette règle, deux états identiques produisent deux fichiers
différents, et le § 8.8 est violé par la seule forme du stockage.
différents, et le § 8.8 est violé par la seule forme du stockage. **C'est le
drapeau de la proposition qui décide**, jamais le réglage courant de
l'événement : changer le réglage n'efface pas l'ordre des sièges d'une
proposition déjà produite. Le placement retenu a la même forme et son propre
drapeau, avec l'identifiant de la proposition dont il vient. L'ensemble des
participants et chaque réserve se trient par identifiant croissant dans tous
les cas : ce sont des ensembles.
```
# illustration, annotée ; le fichier livré est du JSON strict
@ -1719,25 +1763,47 @@ Les en-têtes se reconnaissent **sans tenir compte de la casse, des accents ni
des espaces**. **Deux colonnes reconnues sous le même nom** ne se départagent
pas toutes seules : le logiciel n'associe ni l'une ni l'autre et demande.
**La valeur de `exclu`** appartient à une liste fermée ; toute autre valeur
**refuse la ligne**. Traiter l'inconnu comme `non` placerait dans la salle une
personne qui s'est désistée, et la chaise vide ne se verrait que le soir même.
**La valeur de `exclu`** appartient à une liste fermée : `oui`, `o`, `vrai`,
`1` ou `x` pour une exclusion ; `non`, `n`, `faux`, `0` ou vide pour aucune ;
sans égard à la casse, aux accents ni aux espaces. Toute autre valeur **refuse
la ligne**. Traiter l'inconnu comme `non` placerait dans la salle une personne
qui s'est désistée, et la chaise vide ne se verrait que le soir même.
**L'encodage se tranche en quatre temps, dans cet ordre :** marque d'ordre
d'octets UTF-8 ; marque UTF-16 ; **un octet nul dans les quatre premiers
kibioctets sans marque** → refus global avec le remède nommé ; sinon décodage
UTF-8 strict, et repli sur windows-1252 en cas d'échec.
kibioctets sans marque** → refus global avec le remède nommé
(`UTF16_SANS_MARQUE`) ; sinon décodage UTF-8 strict, et repli sur windows-1252
en cas d'échec.
> L'étape 3 n'est pas une précaution de principe : **un texte UTF-16 dont le
> contenu est latin est valide en UTF-8**, chaque octet nul s'y décodant comme
> le caractère nul. Un ordre à trois temps produirait des noms entrelardés de
> caractères nuls, qui ne font échouer aucun calcul. Le mode de défaillance est
> muet : « Benoît » importé en « Benoît » se découvre sur le plan imprimé.
**Une marque déclare l'encodage, et rien ne la dément.** Des octets qui ne sont
pas l'UTF-8 ou l'UTF-16 qu'elle annonce — une séquence invalide, un nombre
impair d'octets, un substitut isolé — refusent le fichier (`UTF8_INVALIDE`,
`UTF16_INVALIDE`) : un repli sur windows-1252 changerait chaque accent de la
partie valide en caractères parasites, « Benoît » en « Benoît », et un décodage
indulgent sèmerait des caractères de remplacement dans les noms. Quel que soit
le temps qui a décodé, **un texte qui porte le caractère nul est refusé**
(`CARACTERE_NUL`) — un collage aussi, qui ne porte pas d'octets : le refus
rattrape un octet nul au-delà des quatre kibioctets, et un fichier que sa marque
fait lire sans erreur sous un autre encodage, la marque de l'UTF-32 LE
commençant par celle de l'UTF-16 LE. Chaque refus d'encodage nomme le même
remède : réenregistrer le fichier en UTF-8.
> L'étape 3 n'est pas une précaution de principe : **un texte UTF-16 sans
> marque se décode sans erreur dans l'un ou l'autre des temps suivants**. En
> ASCII, il est de l'UTF-8 valide, chaque octet nul s'y décodant comme le
> caractère nul ; accentué, il cesse de l'être et retombe sur windows-1252, qui
> accepte tout octet. Dans les deux cas, les noms arrivent entrelardés de
> caractères nuls, qui ne font échouer aucun calcul. Un ordre à trois temps
> produirait ce défaut muet, qui ne se découvre que sur le plan imprimé.
**Le séparateur** se choisit entre `;`, `,` et la tabulation, en **analysant**
les vingt premiers enregistrements : le candidat retenu termine sans guillemet
ouvert, donne le même nombre de champs partout, **et ce nombre dépasse 1**. À
égalité, celui qui donne **le plus d'en-têtes reconnus**.
égalité, celui qui donne **le plus d'en-têtes reconnus**. Quand aucun candidat
ne convient et que la première ligne entière est un en-tête reconnu, **le
fichier n'a qu'une colonne** — une liste de noms — et chaque ligne est un seul
champ : un nom qui porte une virgule reste entier. Sinon, le fichier est
refusé, et le refus nomme ce qui écarte le séparateur.
> Les deux dernières conditions ne sont pas des raffinements. Sur un fichier
> séparé par des virgules, `;` donne lui aussi un nombre de champs parfaitement
@ -1759,11 +1825,16 @@ une appartenance nommée « ».
**La ligne invalide : importer le reste.** Le logiciel importe les lignes
valides et **réexporte les refusées** en un CSV reprenant les colonnes
d'origine, augmenté de `ligne` et `motif` — deux colonnes qu'aucun en-tête ne
reconnaît, donc le fichier corrigé se réimporte tel quel. Sur 260 lignes, un
refus global coûterait l'import entier pour une faute en ligne 213.
reconnaît, donc le fichier corrigé se réimporte tel quel. `ligne` porte le
numéro de l'enregistrement, en-tête compris : celui qu'un tableur affiche, un
champ cité sur plusieurs lignes du texte n'y comptant qu'une fois. Sur 260
lignes, un refus global coûterait l'import entier pour une faute en ligne 213.
Trois cas restent des refus **globaux** : aucun en-tête reconnaissable, aucune
colonne associée à `nom`, zéro ligne valide.
Hors de l'encodage et du séparateur, quatre cas restent des refus **globaux** :
aucun en-tête reconnaissable, aucune colonne associée à `nom`, zéro ligne
valide, et **un guillemet resté ouvert** jusqu'à la fin du texte
(`GUILLEMET_OUVERT`, avec l'enregistrement où il s'ouvre) — tout ce qui le suit
tiendrait dans un seul champ, et deux personnes se fondraient en une.
**L'import entier est une seule entrée d'historique.**
@ -2780,8 +2851,8 @@ complet au disque.
Le code que Vite compile vit sous **`src/`**, en modules nommés d'après les
couches du § 13.4 : `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`,
`src/pdf`, `src/demo`, `src/interface`. À la racine du projet,
**`version.json`** est l'unique source de la version (§ 18.3) et
`src/pdf`, `src/demo`, `src/application`, `src/interface`. À la racine du
projet, **`version.json`** est l'unique source de la version (§ 18.3) et
**`src/version.genere.js`** le module qu'elle engendre — **versionné dans le
dépôt**, parce qu'une séance de développement lancée sans l'étape de construction
doit afficher une version et non une importation manquante, et **contrôlé par
@ -2876,11 +2947,18 @@ qu'elle décide de l'endroit où vit la soirée.
**La frontière des couches est gardée mécaniquement**, et pas seulement par
discipline : un test du **graphe d'imports** refuse qu'un module de `src/moteur`
ou de `src/geometrie` importe de `src/interface`, de `src/stockage` ou d'une
interface de plateforme. Le projet `node` ne chargeant pas le greffon Svelte
(§ 14.8), un test de moteur qui importerait un composant échoue au chargement —
mais cette garde-là ne couvre que les composants, et un module qui touche
`document` ne tomberait qu'à l'exécution de la branche fautive.
ou de `src/geometrie` importe de `src/interface`, de `src/application`, de
`src/stockage` ou d'une interface de plateforme. Il refuse de même que
`src/stockage` importe de `src/csv`, de `src/application`, de `src/interface` ou
d'une plateforme, ou touche le navigateur hors de ses deux implémentations du
système de fichiers, qu'aucun autre de ses modules n'importe ; que `src/csv`,
qui lit le modèle du stockage, importe de `src/application`, de `src/interface`
ou d'une plateforme ; et que `src/application` importe de `src/interface` ou de
Svelte. Un type que la documentation d'un module importe compte comme un
import. Le projet `node` ne chargeant pas le greffon Svelte (§ 14.8), un test de
moteur qui importerait un composant échoue au chargement — mais cette garde-là
ne couvre que les composants, et un module qui touche `document` ne tomberait
qu'à l'exécution de la branche fautive.
### 13.5 Ce que le SVG donne et que le canvas ferait payer
@ -3016,12 +3094,15 @@ est écrit en dur.
### 14.7 Le déterminisme
Aucune source non reproductible — `Math.random`, `Date.now`, `performance.now`,
`crypto.getRandomValues` — dans le **moteur** ni dans le **générateur de
démonstrations**. Un test de l'arborescence le refuse, **et échoue si son
balayage ne trouve aucun fichier**. Son périmètre est nommé : `src/moteur` et
`src/demo`, à l'exclusion du code d'épreuve, où un tirage sert légitimement
(§ 14.12). L'interdit ne porte pas sur le reste de l'application : l'historique
horodate ses entrées, et le banc de mesure du § 19.10 relève des temps par image.
`crypto.getRandomValues` — dans le **moteur**, dans le **générateur de
démonstrations**, ni dans le **stockage** et l'**analyseur CSV**, qui reçoivent
l'horloge et l'aléa en paramètre. Un test de l'arborescence le refuse, **et
échoue si son balayage ne trouve aucun fichier**. Son périmètre est nommé :
`src/moteur`, `src/demo`, `src/stockage` et `src/csv`, à l'exclusion du code
d'épreuve, où un tirage sert légitimement (§ 14.12). L'interdit ne porte pas sur
le reste de l'application : l'application lit l'horloge pour horodater les
entrées de l'historique, que le stockage écrit telles qu'il les reçoit, et le
banc de mesure du § 19.10 relève des temps par image.
**Ce n'est pas une règle d'hygiène.** L'interdit de `performance.now` dans le
moteur est ce qui rend vraie la phrase « à graine et entrée égales, placement
@ -3230,10 +3311,12 @@ fichiers, que recopier un dossier suffit à fausser.
entrées corrompu à la trente-septième : trente-six entrées retenues,
soixante-quatre annoncées écartées.
**La dérivation des noms refuse `con` comme `CON`**, et borne le chemin sur
`.gtt-journal.jsonl`, dix-neuf caractères, et non sur `.gtt.json`, neuf. Elle
reçoit la racine en paramètre et son test l'exerce sur les **deux** dossiers de
travail possibles (§ 8.6).
**La dérivation des noms refuse `con` comme `CON`**, et borne le plus long
chemin que l'événement écrit, corbeille et écriture atomique comprises (§ 8.6) :
le test recompose ce chemin en entier, et échoue sur une borne posée sur
`.gtt.json`, neuf caractères, sur le seul `.gtt-journal.jsonl`, dix-huit, ou qui
oublie la corbeille ou l'écriture atomique. Elle reçoit la racine en paramètre et
son test l'exerce sur les **deux** dossiers de travail possibles (§ 8.6).
**Un renommage atomique qui échoue ne détruit pas la cible.** L'implémentation
d'épreuve échoue au renommage : la cible conserve son contenu antérieur, et le
@ -3473,6 +3556,19 @@ branche non couverte y est un cas que l'opérateur rencontre au pire moment. Une
branche qu'on ne sait pas couvrir est soit du code mort — on le retire — soit un
cas que la spécification n'a pas prévu — on l'écrit.
**La mesure est sa propre commande**, `make couverture` : la série `node` sous
instrumentation, hors des budgets du § 14.14. Elle publie un tableau par couche
et par module, et un rapport à parcourir qui montre, ligne à ligne, ce qui n'est
jamais exécuté. Les deux seuils portent sur quatre fichiers —
`src/moteur/indicateurs.js` et `src/moteur/plafond.js`, `src/stockage/depot.js`
et `src/stockage/journal.js` — et la commande échoue en deçà. Un test de
l'arborescence refuse un seuil abaissé, un seuil global, et un seuil posé sur un
fichier absent ou hors de la mesure : l'outil tiendrait ce dernier pour atteint,
faute de branche à compter. Il refuse de même une commande qui ne mesure pas —
sans instrumentation, la série passe et aucun seuil ne se lit —, une commande
dont les arguments redéfinissent la mesure ou ce qu'elle exécute, un tableau qui
tait les modules pleins, et l'absence du rapport ligne à ligne.
**Ce qui l'empêche de devenir une case à cocher** ne vient pas de l'outil de
couverture :
@ -3875,6 +3971,42 @@ que le lecteur reproduise exactement ce qu'il lit. Il est relu par quelqu'un qui
n'a pas écrit le logiciel ; un guide relu par son auteur ne révèle aucune étape
manquante.
### 16.2 Le poste de développement
Le projet s'installe et se lance d'une commande, la même sur chaque système
Linux, NixOS compris, et sous macOS ; Windows a la sienne. Une étape laissée à
la main est celle qu'un nouveau venu oublie, sans savoir ensuite laquelle.
| commande | ce qu'elle fait |
|---|---|
| `./install.sh` | pose les paquets du système qui manquent — par apt, dnf, zypper ou pacman, sous `sudo` —, puis le Node du projet et les dépendances que fixe `package-lock.json` ; relancée, ne refait que ce qui manque |
| `./install_dev.sh` | ajoute le Chromium des épreuves du navigateur, des polices et, sur amd64, `podman`, sous lequel se construit l'exécutable |
| `./run.sh` | lance la coquille Electron sur une session graphique ; sans écran, ou là où Electron n'est pas publié, sert l'application à un navigateur par le serveur de Vite |
| `make` | lance l'application comme `./run.sh` ; `make help` nomme les autres cibles — les trois séries d'épreuves, la couverture (§ 14.13), la construction, le contrôle de version (§ 18.5), l'essai de démarrage |
**Le Node du projet a une seule source** : la majeure écrite dans
`.node-version`, que lisent les scripts bash, les scripts PowerShell et
`shell.nix`. L'installation télécharge cette version de nodejs.org, la vérifie
contre sa somme et la range hors du dépôt, dans le dossier de l'utilisateur :
elle n'exige aucun droit d'administration et ne dépend pas du Node que porte —
ou ne porte pas — le système. Chaque cible du `Makefile` passe par ce Node.
**Sous Windows**, `install.cmd`, `install_dev.cmd` et `run.cmd` passent la main à
des scripts PowerShell 5.1, la version que porte tout Windows. **Sous NixOS**,
où les binaires téléchargés ne trouvent ni leur chargeur ni leurs bibliothèques
aux chemins qu'ils attendent, `scripts/installation/shell.nix` déclare Node,
Electron et Chromium d'un nixpkgs épinglé par révision et par somme ; les scripts
du projet s'y relancent d'eux-mêmes.
**`make verifier_systemes` éprouve l'installation elle-même.** Sur chaque système
du catalogue — les familles Debian, Fedora, openSUSE et Arch, et Nix —, un
conteneur `podman` exécute, sous un compte ordinaire qui passe par `sudo`,
`./install_dev.sh`, puis les trois séries d'épreuves, l'essai de démarrage
d'Electron et le serveur de Vite ; les scripts PowerShell s'y éprouvent sous
`pwsh`. Ce qu'un conteneur n'éprouve pas est nommé, et reste à vérifier sur une
machine : le bac à sable d'Electron, une autre architecture que celle de l'hôte,
NixOS lui-même, une vraie session graphique, Windows et macOS réels.
---
## 17. Ce que ce document ne tranche pas

View file

@ -221,6 +221,18 @@ function geler(valeur) {
return valeur;
}
// Chaque objet et chaque liste que porte valeur, elle comprise :
// JSON.stringify présente chacun à la fonction de remplacement avant de
// l'écrire.
function objetsDe(valeur) {
const objets = new Set();
JSON.stringify(valeur, (_, enfant) => {
if (typeof enfant === 'object' && enfant !== null) objets.add(enfant);
return enfant;
});
return objets;
}
// Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur
// lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ;
// rend ce que rend fonction.
@ -644,7 +656,7 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => {
assert.equal(serialiser(aRebours(charge), ENTETE), attendu);
});
test("chaque retenu que sa règle refuse et que l'analyse admet s'écrit, se relit au même retenu et se réécrit octet pour octet, quel que soit l'ordre de ses clés ; canoniser en rend une copie détachée", () => {
test("chaque retenu que sa règle refuse et que l'analyse admet s'écrit, se relit au même retenu et se réécrit octet pour octet, quel que soit l'ordre de ses clés ; canoniser en rend une copie détachée, qui ne partage aucun objet ni aucune liste avec lui", () => {
assert.ok(FACONS.length > 0, 'aucune façon examinée');
const ecarts = FACONS.flatMap(([libelle, facon]) => {
const charge = abimee(facon);
@ -658,8 +670,11 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => {
assert.equal(serialiser(aRebours(charge), ENTETE), texte);
assert.equal(serialiserCharge(charge), serialiserCharge(relue));
const avant = JSON.stringify(charge.retenu);
const copie = canoniser(geler(structuredClone(charge))).retenu;
const source = geler(structuredClone(charge));
const copie = canoniser(source).retenu;
assert.deepEqual(copie, charge.retenu);
const recus = objetsDe(source.retenu);
assert.deepEqual([...objetsDe(copie)].filter((objet) => recus.has(objet)), []);
assert.equal(JSON.stringify(canoniser(charge).retenu), JSON.stringify(copie));
assert.equal(JSON.stringify(charge.retenu), avant);
return [];
@ -670,6 +685,17 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => {
assert.deepEqual(ecarts, []);
});
test("un retenu que sa règle admet mais qui se contredit — cinq tours déclarés, deux écrits — suit la mise en page d'une proposition : seul un retenu que sa règle refuse s'écrit compact", () => {
const charge = chargeAvecRetenu();
charge.retenu.tours = 5;
const ligne = '"retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":';
const attendu = remplacer(TEXTE_AVEC_RETENU, `${ligne}2,`, `${ligne}5,`);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.equal(serialiser(aRebours(charge), ENTETE), attendu);
const { entete, charge: relue } = analyser(attendu);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), attendu);
});
test("un retenu qui n'est pas un objet, que l'analyse refuse, lève TypeError : aucun texte qu'elle refuserait ne s'écrit", () => {
for (const retenu of [[], 42, 'retenu', false]) {
const charge = chargeContrat();

View file

@ -26,19 +26,24 @@
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
// ils sont attribués. Sans attribution, chaque liste se trie par
// identifiant, et l'entrée qui change de rang décale celles qu'elle
// franchit dans sa liste. Une liste qui change de longueur se pose entière.
// franchit ; une liste d'identifiants de même longueur se pose alors
// entière dès que ses poses rang par rang pèsent plus que cette pose, et
// l'échange ne coûte jamais plus que les deux listes posées entières. Une
// liste qui change de longueur se pose entière.
//
// Une charge que rend appliquer, l'analyse l'admet à la forme près comme
// celle qu'il reçoit : une valeur posée doit être admise à sa place, lue
// par le parcours même de l'analyse (premiereFauteEnPlace). Dans les
// propositions et le retenu, l'analyse ne lit que le conteneur, et le
// contrôle des placements juge le reste (§ 8.9) ; une valeur posée plus bas
// ne s'examine donc pas. Un retenu que sa règle refuse, que la lecture
// admet et que le contrôle des placements garde, canonique.js le recopie
// hors du schéma, et difference le pose entier : le journal le libère, le
// remet en place ou le remplace tel quel, quelle que soit sa faute.
// Une charge que rend appliquer, l'analyse l'admet comme celle qu'il
// reçoit, et chacune de ses propositions suit sa règle, comme dans la
// charge que rend examiner : une valeur posée suit la règle de sa place, sa
// forme entière (premiereFaute), jusque dans les propositions et le retenu,
// que l'analyse ne lit que comme conteneurs (§ 8.9). Le retenu posé entier
// fait seul exception. Un retenu que sa règle refuse, que la lecture admet
// et que le contrôle des placements garde, canonique.js le recopie hors du
// schéma, et difference le pose entier : à sa place, seul son conteneur se
// contrôle (premiereFauteEnPlace), et le journal le libère, le remet en
// place ou le remplace tel quel, quelle que soit sa faute. Sous un tel
// retenu, rien ne se pose : difference ne descend pas en lui.
import { canoniser, retenuHorsDeSaRegle } from './canonique.js';
import { SCHEMA, premiereFauteEnPlace } from './document.js';
import { SCHEMA, premiereFaute, premiereFauteEnPlace } from './document.js';
import { ErreurStockage } from './erreurs.js';
/**
@ -59,8 +64,12 @@ import { ErreurStockage } from './erreurs.js';
* @typedef {OperationPoser|OperationRetirer} Operation
*/
// Règle de la charge dans le schéma du fichier d'état.
const CHARGE = SCHEMA.champs.find(([cle]) => cle === 'charge')[1];
// Règles de la charge, du retenu et d'un identifiant dans le schéma du
// fichier d'état.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const RETENU = regleDuChamp(CHARGE, 'retenu');
const IDENTIFIANT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'id');
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
@ -82,6 +91,10 @@ function identifiantsDistincts(liste) {
return true;
}
// Poids d'opérations dans un correctif : la longueur de leur texte JSON, en
// unités UTF-16, des octets pour les chemins du schéma et les identifiants.
const poids = (operations) => JSON.stringify(operations).length;
// Ajoute à operations ce qui mène de a à b, deux valeurs canoniques de la
// règle regle, au bout de chemin. null contre une valeur, et deux scalaires
// différents, posent b. Un retenu que sa règle refuse, d'un côté ou de
@ -89,8 +102,10 @@ function identifiantsDistincts(liste) {
// les deux copies canoniques s'écrivent autrement, et rien sinon. Deux
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
// d'enregistrements à identifiants distincts se comparent par identifiant ;
// deux autres listes, rang par rang quand elles ont la même longueur, et b
// se pose entière sinon.
// deux autres listes, de longueurs différentes, posent b entière, et de même
// longueur se comparent rang par rang — deux listes d'identifiants
// exceptées, qui posent b entière quand leurs poses rang par rang pèsent
// plus que la sienne.
function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
@ -100,13 +115,29 @@ function comparer(a, b, regle, chemin, operations) {
for (const [cle, regleDeCle] of regle.champs) comparer(a[cle], b[cle], regleDeCle, [...chemin, cle], operations);
} else if (identifiantsDistincts(a) && identifiantsDistincts(b)) {
comparerParIdentifiant(a, b, regle.element, chemin, operations);
} else if (a.length === b.length) {
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
} else {
} else if (a.length !== b.length) {
operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.element === IDENTIFIANT) {
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, 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.
function comparerIdentifiants(a, b, chemin, operations) {
const parRang = [];
a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang));
const entiere = { op: 'poser', chemin, valeur: b };
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
}
// Fusion de deux listes d'enregistrements rangées par identifiant croissant,
// comme les laisse canoniser : un identifiant de a seul est retiré, un
// identifiant de b seul est posé entier, un identifiant commun se compare
@ -145,10 +176,15 @@ function comparerParIdentifiant(a, b, regleElement, chemin, operations) {
* par identifiant : retirés, posés entiers, communs comparés ; une telle
* liste dont un identifiant se répète, ce que la lecture admet pour deux
* propositions, se compare comme les autres listes ; les autres listes de
* même longueur rang par rang, et de longueurs différentes posées
* entières ; null contre une valeur, et deux scalaires différents, posent la
* valeur de b ; un retenu que sa règle refuse, dans a ou dans b, se pose
* entier quand les deux diffèrent, sans rien pour lui sinon.
* longueurs différentes posées entières, et de même longueur rang par rang,
* sauf une liste d'identifiants dont les poses rang par rang pèsent plus,
* en JSON, que sa pose entière : elle se pose entière ; null contre une
* valeur, et deux scalaires différents, posent la valeur de b ; un retenu
* que sa règle refuse, dans a ou dans b, se pose entier quand les deux
* diffèrent, sans rien pour lui sinon. L'échange de deux personnes, au même
* tour d'une proposition, ne touche ainsi que les listes de leurs deux
* tables, et ne pèse jamais plus que ces deux listes posées entières,
* attribution des sièges ou non (§ 8.9).
*
* @param {import('./types.js').Charge} a
* @param {import('./types.js').Charge} b
@ -163,9 +199,8 @@ export function difference(a, b) {
// Règle de ce que désigne etape dans une valeur de règle regle, lue dans le
// schéma : pour un objet, le champ de cette clé ; pour une liste, son
// élément, la valeur refusant toute étape qui n'y est ni un rang ni { id }.
// undefined quand l'étape sort du schéma — une clé qu'une valeur posée dans
// une proposition ou dans le retenu apporte, par exemple —, et pour toute
// étape qui la suit.
// undefined quand l'étape sort du schéma — une clé que porte un retenu
// recopié hors du schéma, par exemple —, et pour toute étape qui la suit.
function regleSuivante(regle, etape) {
if (regle?.genre === 'objet') return regle.champs.find(([cle]) => cle === etape)?.[1];
return regle?.genre === 'liste' ? regle.element : undefined;
@ -175,49 +210,48 @@ function regleSuivante(regle, etape) {
// dont l'élément a un champ id : participants, tables, propositions.
const estListeDEnregistrements = (regle) => regle?.element?.cles?.has('id') === true;
// Lecture de chemin dans le schéma, depuis la charge : regle, la règle de la
// place qu'il désigne, undefined quand une étape sort du schéma ; et
// sousAPart, vrai quand une règle aPart le précède, c'est-à-dire quand la
// place est dans une proposition ou dans le retenu, que l'analyse n'examine
// que comme conteneurs (§ 8.9). null quand une étape { id } ne tombe pas
// dans une liste d'enregistrements à identifiant. C'est le schéma qui en
// décide, et non le contenu de la liste : une liste vide n'a aucun élément
// qui montre la forme des autres, et tout identifiant y est absent, ce qui
// ouvrirait une insertion par { id } dans une réserve, une liste de table
// ou les réservations. Une clé et un rang se contrôlent sur la valeur, qui
// les porte ou non.
// Lecture de chemin dans le schéma, depuis la charge : { regle }, la règle
// de la place qu'il désigne, undefined quand une étape sort du schéma ; null
// quand une étape { id } ne tombe pas dans une liste d'enregistrements à
// identifiant. C'est le schéma qui en décide, et non le contenu de la
// liste : une liste vide n'a aucun élément qui montre la forme des autres,
// et tout identifiant y est absent, ce qui ouvrirait une insertion par
// { id } dans une réserve, une liste de table ou les réservations. Une clé
// et un rang se contrôlent sur la valeur, qui les porte ou non.
function lireChemin(chemin) {
let regle = CHARGE;
let sousAPart = false;
for (const etape of chemin) {
if (estObjet(etape) && !estListeDEnregistrements(regle)) return null;
sousAPart ||= regle?.aPart === true;
regle = regleSuivante(regle, etape);
}
return { regle, sousAPart };
return { regle };
}
// Vrai quand l'analyse admettrait valeur à la place que lecture désigne
// (§ 8.8) : hors des propositions et du retenu, sa forme entière ; à la
// place de la liste des propositions ou du retenu, son conteneur ; plus bas,
// aucune valeur ne s'examine, et le contrôle des placements juge ce qu'elle
// y fait (§ 8.9). Une place hors du schéma n'en admet aucune.
function valeurAdmise(valeur, { regle, sousAPart }) {
if (sousAPart) return true;
return regle !== undefined && premiereFauteEnPlace(valeur, regle) === null;
// Vrai quand valeur, posée au bout de chemin dans charge, à la place de
// règle regle, suit cette règle, sa forme entière (premiereFaute),
// propositions et retenu compris : une proposition qui suit sa règle la
// suit encore après la pose, et canoniser comme serialiser la lisent. À la
// place du retenu, son conteneur seul (premiereFauteEnPlace) : un retenu que
// sa règle refuse se pose entier. Sous le retenu, une valeur ne se pose que
// quand il suit sa règle, et une valeur qui suit la sienne l'y garde. Une
// place hors du schéma n'en admet aucune.
function valeurAdmise(charge, chemin, regle, valeur) {
if (regle === undefined) return false;
if (regle === RETENU) return premiereFauteEnPlace(valeur, RETENU) === null;
if (chemin[0] === 'retenu' && premiereFaute(charge.retenu, RETENU) !== null) return false;
return premiereFaute(valeur, regle) === null;
}
// Rangs des éléments de liste dont l'id vaut id, ou null quand un élément
// n'est pas un objet à id entier exact, ce qu'une valeur posée dans la liste
// des propositions peut y mettre : { id } n'y désigne rien. liste est une
// liste d'enregistrements du schéma, que valeurAdmise garde une liste.
// Rangs des éléments de liste dont l'id vaut id, ou null quand id n'est pas
// un entier exact : { id } n'y désigne rien. liste est une liste
// d'enregistrements du schéma — participants, tables, propositions —,
// chacun un objet à id entier, que l'analyse et valeurAdmise gardent
// conformes à leur règle.
function rangsDeLIdentifiant(liste, id) {
if (!Number.isSafeInteger(id)) return null;
const rangs = [];
for (let rang = 0; rang < liste.length; rang += 1) {
const element = liste[rang];
if (!estObjet(element) || !Number.isSafeInteger(element.id)) return null;
if (element.id === id) rangs.push(rang);
if (liste[rang].id === id) rangs.push(rang);
}
return rangs;
}
@ -246,16 +280,16 @@ function descendre(conteneur, etape) {
// Pose une copie de valeur à la place que désigne etape dans conteneur :
// une clé qu'il porte déjà, un rang qu'il contient, ou { id } absent de la
// liste, quand valeur est l'enregistrement de cet id ; l'enregistrement
// s'insère alors avant le premier d'id plus grand, ce qui garde une liste
// rangée par identifiant croissant. Rend faux quand etape ne désigne
// aucune de ces places.
// liste, quand valeur, un enregistrement que valeurAdmise a contrôlé, porte
// cet id ; l'enregistrement s'insère alors avant le premier d'id plus
// grand, ce qui garde une liste rangée par identifiant croissant. Rend faux
// quand etape ne désigne aucune de ces places.
function poser(conteneur, etape, valeur) {
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) {
conteneur[etape] = structuredClone(valeur);
return true;
}
if (rangsDe(conteneur, etape)?.length !== 0 || !estObjet(valeur) || valeur.id !== etape.id) return false;
if (rangsDe(conteneur, etape)?.length !== 0 || valeur.id !== etape.id) return false;
const suivant = conteneur.findIndex((element) => element.id > etape.id);
conteneur.splice(suivant === -1 ? conteneur.length : suivant, 0, structuredClone(valeur));
return true;
@ -275,9 +309,10 @@ function retirer(conteneur, etape) {
// Applique operation à charge, en place ; rend faux, sans rien changer,
// quand elle est mal formée, qu'une étape { id } de son chemin tombe hors
// d'une liste d'enregistrements, que ce chemin ne désigne pas une place de
// charge, ou que la valeur posée n'y serait pas admise par l'analyse. Un
// chemin vide n'a pas de dernière étape, et undefined n'en désigne aucune.
// Une opération poser porte une valeur : JSON n'en écrit pas d'undefined.
// charge, ou que la valeur posée n'y suivrait pas sa règle (valeurAdmise).
// Un chemin vide n'a pas de dernière étape, et undefined n'en désigne
// aucune. Une opération poser porte une valeur : JSON n'en écrit pas
// d'undefined.
function executer(charge, operation) {
if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false;
const { op, chemin, valeur } = operation;
@ -290,7 +325,8 @@ function executer(charge, operation) {
}
const derniere = chemin[chemin.length - 1];
if (op === 'poser') {
return valeur !== undefined && valeurAdmise(valeur, lecture) && poser(conteneur, derniere, valeur);
if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false;
return poser(conteneur, derniere, valeur);
}
if (op === 'retirer') return retirer(conteneur, derniere);
return false;
@ -299,9 +335,11 @@ function executer(charge, operation) {
/**
* Applique un correctif à la copie canonique de a (§ 8.6), opération après
* opération, et rend cette copie : une charge neuve, qui ne partage aucun
* objet avec a ni avec le correctif. a n'est pas modifiée. Appliqué à a, le
* correctif de difference(a, b) rend la copie canonique de b. Une insertion
* par { id } garde l'ordre des identifiants.
* objet avec a ni avec le correctif. a n'est pas modifiée ; c'est une charge
* que l'analyse admet et dont chaque proposition suit sa règle, comme celle
* que rend examiner (placements.js). Appliqué à a, le correctif de
* difference(a, b) rend la copie canonique de b. Une insertion par { id }
* garde l'ordre des identifiants.
*
* Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne
* s'applique pas — rang est sa place dans le correctif, à partir de 0 — :
@ -311,14 +349,16 @@ function executer(charge, operation) {
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte
* pas ou au-delà de la fin d'une liste ; poser par { id } un identifiant
* déjà présent, ou une valeur qui n'est pas l'enregistrement de cet
* identifiant ; poser une valeur que l'analyse refuserait à sa place —
* hors des propositions et du retenu, une valeur qui sort de sa règle, en
* genre, en domaine, null compris, ou en clés ; à la place de la liste des
* propositions ou du retenu, un autre conteneur — ; retirer autre chose
* qu'un enregistrement par { id }. Le contrôle porte sur la forme : une
* opération ne porte pas la valeur qu'elle remplace, et un correctif dont
* chaque chemin existe aussi dans une autre charge s'y applique sans lever.
* Un correctif qui n'est pas une liste lève TypeError.
* identifiant ; poser une valeur qui sort de la règle de sa place, en
* genre, en domaine, null compris, ou en clés, jusque dans les propositions
* et le retenu ; à la place du retenu, autre chose 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 }. 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.
*
* @param {import('./types.js').Charge} a
* @param {Operation[]} correctif

View file

@ -658,27 +658,65 @@ describe('difference : localité sur la grande démonstration (§ 8.9)', () => {
assert.ok(taille < 200, `${taille} octets`);
});
test('sans attribution, un échange qui décale les rangs dans les listes triées ne touche que ces deux listes, une entrée par rang changé', () => {
test("sans attribution, un échange entre deux tables pleines tient en deux opérations de moins de 250 octets, une par liste : une liste d'identifiants se pose entière quand ses opérations rang par rang pèsent plus qu'elle", () => {
const avant = grande();
const listes = avant.propositions.find((p) => p.id === 4).placement[1].sieges;
// Échange [t1, r1, t2, r2] : la personne du rang r1 de la table de rang
// t1 contre celle du rang r2 de la table de rang t2, puis chaque liste
// rangée par identifiant, comme la copie canonique d'une proposition
// sans attribution. Chacune entre dans l'autre liste à son rang, et les
// entrées qu'elle franchit se décalent d'un rang.
const echanger = ([t1, r1, t2, r2]) => {
const apres = copie(avant);
const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1];
[sieges[t1][r1], sieges[t2][r2]] = [sieges[t2][r2], sieges[t1][r1]];
for (const t of [t1, t2]) sieges[t].sort(croissant);
return { apres, sieges };
};
// Les deux listes posées entières, dans l'ordre des rangs de table.
const entieres = (sieges, tables) =>
[...tables].sort(croissant).map((t) => ({ op: 'poser', chemin: [...listesDuTour2(4), t], valeur: sieges[t] }));
// La plus petite personne de la table de rang 4 contre la plus grande de
// celle de rang 30 : chacune entre vers un bout de l'autre liste, et les
// entrées qu'elle franchit se décalent d'un rang.
const [premiere, seconde] = [sieges[4], sieges[30]];
const [plusPetit, plusGrand] = [premiere[0], seconde[seconde.length - 1]];
sieges[4] = [...premiere.slice(1), plusGrand].sort(croissant);
sieges[30] = [plusPetit, ...seconde.slice(0, -1)].sort(croissant);
const changes = (avantEchange, apresEchange) => avantEchange.filter((id, rang) => apresEchange[rang] !== id).length;
const correctif = difference(avant, apres);
assert.ok(correctif.length > 2, `${correctif.length} opérations`);
assert.equal(correctif.length, changes(premiere, sieges[4]) + changes(seconde, sieges[30]));
// celle de rang 30 : chacune franchit toute l'autre liste, et ses poses
// rang par rang pèseraient plus que les deux listes entières.
const extremes = [4, 0, 30, listes[30].length - 1];
const { apres, sieges } = echanger(extremes);
assert.deepEqual(difference(avant, apres), entieres(sieges, [4, 30]));
// Deux identifiants consécutifs, que l'échange laisse chacun au rang de
// l'autre : une pose par liste, au rang qui change. Puis, de chaque
// table, une personne contre une personne de la table onze rangs plus
// loin, à des rangs qui varient d'une table à l'autre.
const voisin = listes[4][2] + 1;
const tableDuVoisin = listes.findIndex((liste) => liste.includes(voisin));
const echanges = [
extremes,
[4, 2, tableDuVoisin, listes[tableDuVoisin].indexOf(voisin)],
...listes.map((liste, t) => {
const autre = (t + 11) % listes.length;
return [t, t % liste.length, autre, (3 * t + 1) % listes[autre].length];
}),
];
const formes = new Set();
for (const echange of echanges) {
const [t1, , t2] = echange;
const { apres: rendu, sieges: rangees } = echanger(echange);
const correctif = difference(avant, rendu);
const cas = JSON.stringify(echange);
assert.equal(correctif.length, 2, cas);
assert.deepEqual(correctif.map(({ chemin }) => chemin[5]), [t1, t2].sort(croissant), cas);
for (const { op, chemin } of correctif) {
assert.equal(op, 'poser');
assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4));
assert.ok(chemin.length === 7 && (chemin[5] === 4 || chemin[5] === 30), JSON.stringify(chemin));
assert.equal(op, 'poser', cas);
assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4), cas);
formes.add(chemin.length);
}
assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(apres));
const taille = octets(JSON.stringify(correctif));
assert.ok(taille < 250, `${cas} : ${taille} octets`);
assert.ok(taille <= octets(JSON.stringify(entieres(rangees, [t1, t2]))), `${cas} : ${taille} octets`);
assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(rendu), cas);
}
// Le balayage rencontre les deux issues : une liste posée entière, et une
// seule pose au rang qui change.
assert.deepEqual([...formes].sort(croissant), [6, 7]);
});
test('ajouter un participant donne une opération poser à { id }, et prochainsIds avance', () => {
@ -746,6 +784,15 @@ describe('difference : localité sur la grande démonstration (§ 8.9)', () => {
});
describe('difference : les règles (§ 8.6)', () => {
// Poses au rang qui mènent de la liste d'identifiants avant à la liste
// apres, de même longueur, au bout de chemin : une par rang qui change.
const posesAuRang = (avant, apres, chemin) =>
avant.flatMap((id, rang) =>
id === apres[rang] ? [] : [{ op: 'poser', chemin: [...chemin, rang], valeur: apres[rang] }],
);
// Poids d'opérations : les octets de leur texte JSON.
const poids = (operations) => octets(JSON.stringify(operations));
test("deux charges égales à l'ordre près donnent [] : ordre des clés, des collections, et des tables d'une proposition sans attribution", () => {
const a = petite();
const b = copie(a);
@ -885,7 +932,7 @@ describe('difference : les règles (§ 8.6)', () => {
}
});
test('les autres listes : de même longueur, rang par rang ; de longueurs différentes, posées entières', () => {
test("les autres listes : de même longueur, rang par rang, des capacités toutes changées comprises ; de longueurs différentes, posées entières", () => {
const a = petite();
a.reservations = [
{ participant: 2, table: 1, siege: 1, portee: 'tous', tour: null },
@ -895,6 +942,10 @@ describe('difference : les règles (§ 8.6)', () => {
const b = copie(a);
b.reservations[1].siege = 2;
b.titres.push({ table: 2, siege: 3, libelle: 'accueil' });
// Une capacité ne change que son rang : la pose d'une liste entière plus
// légère que ses poses rang par rang ne vaut que pour une liste
// d'identifiants, que l'ordre canonique décale.
b.propositions[0].capacites = [4, 4, 4, 4];
// Au tour 1, la première personne de la table de rang 0 passe à la réserve.
const tour1 = b.propositions[0].placement[0];
const [personne, ...restants] = tour1.sieges[0];
@ -910,6 +961,11 @@ describe('difference : les règles (§ 8.6)', () => {
{ table: 2, siege: 3, libelle: 'accueil' },
],
},
...[0, 1, 2, 3].map((rang) => ({
op: 'poser',
chemin: ['propositions', { id: 1 }, 'capacites', rang],
valeur: 4,
})),
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0], valeur: restants },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [personne] },
]);
@ -985,6 +1041,26 @@ describe('difference : les règles (§ 8.6)', () => {
assert.deepEqual(ecarts, []);
});
test("un retenu que sa règle admet mais qui se contredit — cinq tours déclarés, quatre écrits — se compare champ par champ, comme un retenu cohérent : seul un retenu hors de sa règle se pose entier (§ 8.9, point 3)", () => {
const a = petite();
a.retenu = { ...retenuDe(a.propositions[0]), tours: 5 };
a.evenement.etat = 'retenu';
// Relu, le retenu est gardé, et sa contradiction nommée.
const examen = examiner(analyser(serialiser(a, ENTETE)).charge);
assert.deepEqual(examen.retenu.fautes, [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }]);
assert.equal(porteUnRetenuAbime(a), false);
// Basculer le drapeau garde l'ordre des listes, rangées sans
// attribution ; la première liste du premier tour s'inverse.
const b = copie(a);
b.retenu.siegesAttribues = true;
b.retenu.placement[0].sieges[0].reverse();
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['retenu', 'siegesAttribues'], valeur: true },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: b.retenu.placement[0].sieges[0] },
]);
assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b));
});
test("deux propositions de même identifiant : la liste se compare par rang dans l'ordre canonique, ou se pose entière", () => {
const a = petite();
a.propositions.push({ ...copie(a.propositions[0]), graine: 7 });
@ -1006,11 +1082,124 @@ describe('difference : les règles (§ 8.6)', () => {
]);
const b = copie(a);
for (const proposition of b.propositions) proposition.placement[0].sieges[0].reverse();
const [premier, , dernier] = a.propositions[1].placement[0].sieges[0];
// Le premier et le dernier des trois sièges changent : leurs deux poses
// pèsent plus que la liste posée entière.
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0, 0], valeur: dernier },
{ op: 'poser', chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0, 2], valeur: premier },
{
op: 'poser',
chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0],
valeur: [...a.propositions[1].placement[0].sieges[0]].reverse(),
},
]);
assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b));
});
test("à poids égal, la pose au rang reste : la seule personne de la réserve échangée contre un occupant donne une pose au rang de chaque liste", () => {
// Au tour 1 d'une proposition à sièges attribués, la troisième personne
// de la première table attend à la réserve, puis prend le siège du
// milieu de la deuxième table, dont l'occupant passe à la réserve.
const a = chargeDe('petite', [{ id: 1, siegesAttribues: true }]);
const tour1 = a.propositions[0].placement[0];
tour1.reserve = [tour1.sieges[0].pop()];
const b = copie(a);
const { sieges, reserve } = b.propositions[0].placement[0];
[sieges[1][1], reserve[0]] = [reserve[0], sieges[1][1]];
const chemin = ['propositions', { id: 1 }, 'placement', 0];
const correctif = difference(a, b);
assert.deepEqual(correctif, [
{ op: 'poser', chemin: [...chemin, 'sieges', 1, 1], valeur: sieges[1][1] },
{ op: 'poser', chemin: [...chemin, 'reserve', 0], valeur: reserve[0] },
]);
// La réserve posée entière pèserait autant que sa pose au rang.
const entiere = { op: 'poser', chemin: [...chemin, 'reserve'], valeur: reserve };
assert.equal(octets(JSON.stringify([entiere])), octets(JSON.stringify([correctif[1]])));
assert.equal(serialiserCharge(appliquer(a, correctif)), serialiserCharge(b));
});
test("une liste d'identifiants se pose entière selon le poids de ses poses au rang, et non selon leur nombre : deux rangs changés gardent leurs deux poses dans les 260 participants d'une proposition, et cèdent la place à la liste entière dans une liste de table de huit", () => {
const a = grande();
const chemin = ['propositions', { id: 4 }];
// Dans les participants de la proposition 4, 259 cède la place à 261 :
// la liste rangée change à ses deux derniers rangs, et leurs deux poses
// pèsent bien moins que la liste entière.
const b = copie(a);
const declaree = b.propositions.find((p) => p.id === 4);
assert.deepEqual(declaree.participants.slice(-2), [259, 260]);
declaree.participants = [...declaree.participants.slice(0, -2), 260, 261];
const cheminDesParticipants = [...chemin, 'participants'];
const deuxPoses = [
{ op: 'poser', chemin: [...cheminDesParticipants, 258], valeur: 260 },
{ op: 'poser', chemin: [...cheminDesParticipants, 259], valeur: 261 },
];
assert.deepEqual(difference(a, b), deuxPoses);
const listeEntiere = { op: 'poser', chemin: cheminDesParticipants, valeur: declaree.participants };
assert.ok(poids(deuxPoses) < poids([listeEntiere]), `${poids(deuxPoses)} octets au rang`);
assert.equal(serialiserCharge(appliquer(a, deuxPoses)), serialiserCharge(b));
// Au tour 2, la quatrième personne de la table de rang 0 contre la
// cinquième de celle de rang 1 : chaque liste de huit, rangée, change à
// deux rangs, et ses deux poses pèsent plus qu'elle posée entière.
const c = copie(a);
const avant = a.propositions.find((p) => p.id === 4).placement[1].sieges;
const { sieges } = c.propositions.find((p) => p.id === 4).placement[1];
[sieges[0][3], sieges[1][4]] = [sieges[1][4], sieges[0][3]];
for (const t of [0, 1]) sieges[t].sort(croissant);
const cheminDesListes = [...chemin, 'placement', 1, 'sieges'];
const entieres = [0, 1].map((t) => ({ op: 'poser', chemin: [...cheminDesListes, t], valeur: sieges[t] }));
for (const [t, entiere] of entieres.entries()) {
const auRang = posesAuRang(avant[t], sieges[t], entiere.chemin);
assert.equal(auRang.length, 2, `table de rang ${t}`);
assert.ok(poids(auRang) > poids([entiere]), `table de rang ${t} : ${poids(auRang)} octets au rang`);
}
assert.deepEqual(difference(a, c), entieres);
assert.equal(serialiserCharge(appliquer(a, entieres)), serialiserCharge(c));
});
test("la pose entière vaut pour chaque sorte de liste d'identifiants, d'une proposition comme du retenu — ses tables, ses participants, la réserve d'un tour — : une liste qui change à chaque rang donne une seule pose, la liste entière", () => {
// La grande démonstration : au tour 2 de la proposition 4, trois
// personnes de la table de rang 0 attendent à la réserve ; le retenu
// vient de cette proposition.
const a = grande();
const proposition = a.propositions.find((p) => p.id === 4);
const tour2 = proposition.placement[1];
tour2.reserve = tour2.sieges[0].splice(1, 3);
a.retenu = retenuDe(proposition);
a.evenement.etat = 'retenu';
// Chaque liste change dans une copie de a, à chaque rang : les tables
// tournent d'un rang ; dans un ensemble rangé, le plus petit identifiant
// cède la place à 261, plus grand que tous.
const tourner = (liste) => [...liste.slice(1), liste[0]];
const remplacerLePremier = (liste) => [...liste.slice(1), 261];
const listes = [
[['tables'], tourner],
[['participants'], remplacerLePremier],
[['placement', 1, 'reserve'], remplacerLePremier],
];
const porteurs = [
[['propositions', { id: 4 }], (charge) => charge.propositions.find((p) => p.id === 4)],
[['retenu'], (charge) => charge.retenu],
];
const ecarts = porteurs.flatMap(([cheminDuPorteur, porteurDe]) =>
listes.flatMap(([cles, changer]) => {
const b = copie(a);
const parent = cles.slice(0, -1).reduce((valeur, cle) => valeur[cle], porteurDe(b));
const cle = cles.at(-1);
const avant = parent[cle];
parent[cle] = changer(avant);
const chemin = [...cheminDuPorteur, ...cles];
const attendu = [{ op: 'poser', chemin, valeur: parent[cle] }];
const auRang = posesAuRang(avant, parent[cle], chemin);
const correctif = difference(a, b);
const cas = JSON.stringify(chemin);
// Chaque rang change : rang par rang, le correctif compterait
// plusieurs poses.
if (auRang.length < 2 || auRang.length !== avant.length) return [`${cas} : ${auRang.length} rangs changés`];
if (JSON.stringify(correctif) !== JSON.stringify(attendu)) {
return [`${cas} : ${correctif.length} opérations, ${poids(correctif)} octets`];
}
return serialiserCharge(appliquer(a, correctif)) === serialiserCharge(b) ? [] : [`${cas} : cible non rendue`];
}),
);
assert.deepEqual(ecarts, []);
});
});
@ -1024,7 +1213,10 @@ describe('appliquer : une copie canonique, sans partage (§ 8.6)', () => {
b.tables.push({ id: b.prochainsIds.table, numero: 34, sieges: null, forme: 'carree', position: { x: 0, y: 1500 } });
b.prochainsIds.table += 1;
b.tables[0].position.y = 75;
b.propositions.find((p) => p.id === 7).placement[3].sieges[0].reverse();
// Deux personnes échangent leurs sièges attribués : une pose au rang de
// chacune des deux listes.
const { sieges: echangees } = b.propositions.find((p) => p.id === 7).placement[3];
[echangees[0][1], echangees[1][2]] = [echangees[1][2], echangees[0][1]];
const tour1 = b.propositions.find((p) => p.id === 9).placement[0];
tour1.reserve = [tour1.sieges[0].pop()];
b.retenu = retenuDe(b.propositions[0]);
@ -1047,6 +1239,34 @@ describe('appliquer : une copie canonique, sans partage (§ 8.6)', () => {
assert.equal(partages.length, 0);
});
test("un retenu hors de sa règle, recopié hors du schéma, ne se partage pas davantage : le résultat ne partage rien avec a ni avec le correctif, et le correctif rien avec sa cible", () => {
// La petite démonstration, son retenu hors de sa règle : « tours »
// renommé « toura », une clé inconnue. Ses listes d'entiers se recopient
// hors du schéma.
const a = petite();
const { tours, ...plan } = retenuDe(a.propositions[0]);
a.retenu = { ...plan, toura: tours, note: 'à revoir' };
a.evenement.etat = 'retenu';
geler(a);
assert.ok(porteUnRetenuAbime(a));
// Un nom changé : le retenu du résultat vient de la copie de a. Une
// capacité du retenu changée : le correctif pose le retenu entier.
const renommee = copie(a);
renommee.participants[0].nom = 'Brindille';
const retouchee = copie(a);
retouchee.retenu.capacites[0] = 4;
assert.deepEqual(difference(a, retouchee), [{ op: 'poser', chemin: ['retenu'], valeur: retouchee.retenu }]);
for (const cible of [renommee, retouchee]) {
const correctif = geler(difference(a, cible));
const rendu = appliquer(a, correctif);
assert.equal(serialiserCharge(rendu), serialiserCharge(cible));
const ailleurs = new Set([...objetsDe(a), ...objetsDe(correctif)]);
assert.deepEqual([...objetsDe(rendu)].filter((objet) => ailleurs.has(objet)), []);
const deLaCible = objetsDe(cible);
assert.deepEqual([...objetsDe(correctif)].filter((objet) => deLaCible.has(objet)), []);
}
});
test('un correctif vide rend la copie canonique de a, et non a', () => {
const a = petite();
a.participants.reverse();
@ -1086,6 +1306,12 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
// La petite démonstration, et la même avec deux propositions d'id 1.
const jumelles = petite();
jumelles.propositions.push({ ...copie(jumelles.propositions[0]), graine: 7 });
// La même avec un retenu dans sa règle ; puis avec un retenu hors de sa
// règle, dont une clé inconnue porte une liste d'objets à identifiant.
const avecRetenu = petite();
avecRetenu.retenu = retenuDe(avecRetenu.propositions[0]);
const retenuAbime = petite();
retenuAbime.retenu = { ...retenuDe(retenuAbime.propositions[0]), note: [{ id: 1 }] };
// Chaque cas suit, au rang 0, une pose qu'appliquer admet : POSE, ou
// celle que le cas nomme après son opération.
const POSE = { op: 'poser', chemin: ['evenement', 'nom'], valeur: 'Soirée renommée' };
@ -1199,9 +1425,8 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
['une étape qui est une liste', base, { op: 'retirer', chemin: ['participants', [3]] }],
['un { id } qui est un texte', base, { op: 'retirer', chemin: ['participants', { id: '3' }] }],
['une étape objet sans id', base, { op: 'retirer', chemin: ['participants', {}] }],
// Une valeur posée est une valeur que l'analyse admet à sa place : hors
// des propositions et du retenu, sa forme entière ; à leur place, leur
// conteneur. Un cas par genre de règle.
// Une valeur posée suit la règle de sa place, sa forme entière ; à la
// place du retenu, son seul conteneur. Un cas par genre de règle.
["poser un nombre à la place d'un objet", base, { op: 'poser', chemin: ['evenement'], valeur: 5 }],
['poser un objet sans les clés de sa règle', base, { op: 'poser', chemin: ['evenement'], valeur: {} }],
[
@ -1234,36 +1459,97 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
base,
{ op: 'poser', chemin: ['participants', { id: 13 }], valeur: { ...troisieme, id: 13, nom: '' } },
],
// Un identifiant reste sous 2^31. Rien ne compare ici l'enregistrement
// posé au compteur, qui borne déjà l'identifiant à l'analyse : seule sa
// règle le refuse.
[
"poser par { id } un participant d'identifiant 2^31",
base,
{ op: 'poser', chemin: ['participants', { id: 2 ** 31 }], valeur: { ...troisieme, id: 2 ** 31 } },
],
[
"poser par { id } une table d'identifiant 2^31",
base,
{ op: 'poser', chemin: ['tables', { id: 2 ** 31 }], valeur: { ...copie(base.tables[2]), id: 2 ** 31 } },
],
['poser un objet à la place des propositions', base, { op: 'poser', chemin: ['propositions'], valeur: {} }],
['poser un nombre à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: 5 }],
['poser une liste à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: [] }],
// L'analyse n'entre ni dans les propositions ni dans le retenu : une
// valeur posée plus bas ne s'examine pas, et peut donner à une liste
// une forme qui n'est pas la sienne. { id } n'y vaut toujours que dans
// une liste d'enregistrements du schéma, et sur des enregistrements.
// Les propositions et le retenu, l'analyse ne les lit que comme
// conteneurs ; une valeur posée en eux suit pourtant la règle de sa
// place : une charge rendue n'a jamais une proposition que canoniser,
// difference ou serialiser ne sauraient lire.
[
"descendre par { id } dans un placement qu'une pose a doté d'identifiants",
"poser des propositions qui sont une liste d'entiers",
base,
{ op: 'poser', chemin: ['propositions'], valeur: [12] },
],
[
'poser par { id } une proposition sans les clés de sa règle',
base,
{ op: 'poser', chemin: ['propositions', { id: 9 }], valeur: { id: 9 } },
],
[
'poser par { id } la valeur null dans les propositions',
base,
{ op: 'poser', chemin: ['propositions', { id: 13 }], valeur: null },
],
[
'poser un placement qui est un objet',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: {} },
],
[
'poser un placement dont un tour porte un identifiant',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', { id: 1 }, 'reserve'], valeur: [] },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: [{ id: 1, sieges: [], reserve: [] }] },
],
[
"poser par { id } dans une liste vide qu'une pose a ajoutée sous une clé hors du schéma",
'poser un tour à clé hors du schéma',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'x', { id: 1 }], valeur: { id: 1 } },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0], valeur: { sieges: [], reserve: [], x: [] } },
],
[
"poser par { id } dans des propositions qu'une pose a faites liste d'entiers",
"poser un occupant qui n'est pas un identifiant",
base,
{ op: 'poser', chemin: ['propositions', { id: 13 }], valeur: { ...base.propositions[0], id: 13 } },
{ op: 'poser', chemin: ['propositions'], valeur: [12] },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 'Brindille' },
],
[
'poser un occupant au-delà de 2^31 − 1',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 2 ** 31 },
],
[
'poser sous le retenu un placement objet',
avecRetenu,
{ op: 'poser', chemin: ['retenu', 'placement'], valeur: {} },
],
[
"poser sous le retenu une table qui n'est pas un identifiant",
avecRetenu,
{ op: 'poser', chemin: ['retenu', 'tables', 0], valeur: 'x' },
],
// Un retenu hors de sa règle se pose entier, jamais par morceaux :
// difference ne descend pas en lui.
[
'poser sous un retenu hors de sa règle, même une valeur que sa place admet',
retenuAbime,
{ op: 'poser', chemin: ['retenu', 'capacites', 0], valeur: 3 },
],
// Une étape { id } ne vaut que dans une liste d'enregistrements du
// schéma : une clé hors du schéma n'en mène à aucune, même quand elle
// porte des objets à identifiant.
[
"retirer par { id } dans une liste qu'un retenu hors de sa règle porte sous une clé hors du schéma",
retenuAbime,
{ op: 'retirer', chemin: ['retenu', 'note', { id: 1 }] },
],
['poser par { id } la valeur null dans les propositions', base, { op: 'poser', chemin: ['propositions', { id: 13 }], valeur: null }],
];
const texteBase = JSON.stringify(base);
geler(base);
geler(jumelles);
geler(avecRetenu);
geler(retenuAbime);
const ecarts = cas.flatMap(([libelle, charge, operation, prealable = POSE]) => {
const refus = refusDe(charge, geler([prealable, operation]));
return JSON.stringify(refus) === JSON.stringify({ rang: 1 }) ? [] : [`${libelle} : ${JSON.stringify(refus)}`];
@ -1311,7 +1597,7 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
assert.deepEqual(ecarts, []);
});
test("une valeur posée passe la forme que l'analyse donne à sa place : hors des propositions et du retenu, à chaque place, une valeur d'une autre sorte est refusée et la sienne admise ; plus bas, aucune ne s'examine", () => {
test("une valeur posée suit la règle de sa place : à chaque place, propositions et retenu compris, une valeur d'une autre sorte est refusée et la sienne admise ; à la place du retenu, un retenu hors de sa règle reste admis", () => {
// La petite démonstration, avec une date, une filiation, une
// réservation, un titre et un retenu : chaque règle du schéma y a une
// valeur à éprouver.
@ -1341,12 +1627,11 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
// qui le remet en place se rejoue.
const retenuFautif = { ...retenuDe(charge.propositions[0]), tours: 0 };
assert.equal(refusDe(canonique, [{ op: 'poser', chemin: ['retenu'], valeur: retenuFautif }]), 'aucun refus');
const sousAPart = (chemin) => chemin.length > 1 && (chemin[0] === 'propositions' || chemin[0] === 'retenu');
const ecarts = valeurs.flatMap(({ chemin, valeur }) => {
const autre = Array.isArray(valeur) ? {} : [];
return [
['sa valeur', valeur, 'aucun refus'],
["une valeur d'une autre sorte", autre, sousAPart(chemin) ? 'aucun refus' : { rang: 0 }],
["une valeur d'une autre sorte", autre, { rang: 0 }],
].flatMap(([cas, posee, attendu]) => {
const refus = JSON.stringify(refusDe(canonique, [{ op: 'poser', chemin, valeur: posee }]));
return refus === JSON.stringify(attendu) ? [] : [`${JSON.stringify(chemin)}, ${cas} : ${refus}`];

1140
src/stockage/depot.js Normal file

File diff suppressed because it is too large Load diff

2311
src/stockage/depot.test.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,9 @@
// l'analyse parcourt la table pour contrôler la forme. Chaque règle porte
// aussi ce que canonique.js lit pour écrire : la mise en page et l'ordre des
// listes. Les formes sont décrites dans types.js. Ce parcours de la forme
// est le seul : premiereFaute le rend au contrôle des placements, et
// premiereFauteEnPlace aux correctifs.
// est le seul : premiereFaute le rend au contrôle des placements et aux
// correctifs, et premiereFauteEnPlace aux correctifs, pour le retenu posé à
// sa place.
//
// analyser lit un texte sans rien écrire et lève, à la première faute,
// ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), en contrôlant dans
@ -69,7 +70,14 @@ const CHAINE = chaine();
const CHAINE_OU_NUL = chaine({ nul: true });
const BOOLEEN = regle('booleen');
const NOMBRE = regle('nombre');
const IDENTIFIANT = entier(1);
// Un identifiant entier — d'un participant, d'une table, d'une proposition,
// ou de ce qu'une réservation, un titre ou un placement désigne — reste sous
// 2^31, dans un entier signé de 32 bits, la largeur des tableaux du moteur
// (Int32Array). Chaque compteur de prochainsIds, le prochain identifiant à
// attribuer, suit la même règle : relevé au-delà d'un identifiant lu, il
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
// valeur sort de sa règle.
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
const DATE_OU_NUL = regle('date', { nul: true });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
@ -459,8 +467,8 @@ export function premiereFaute(valeur, regle, chemin = '') {
/**
* premiereFaute de valeur lue à la place d'une règle regle sous une autre,
* comme l'analyse la lit : une règle aPart n'y examine que son conteneur,
* même donnée ici. Les correctifs contrôlent ainsi une valeur posée là où
* l'analyse la lirait.
* même donnée ici. Les correctifs contrôlent ainsi le retenu posé à sa
* place, que la lecture admet hors de sa règle.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle

View file

@ -334,8 +334,8 @@ describe('analyser : ce qui se lit (§ 8.8)', () => {
// il ne se compare pas à prochainsIds.proposition, et le contrôle des
// placements écarte la proposition ou signale le retenu.
["identifiants de proposition qui n'en sont pas, au-delà de prochainsIds.proposition", abime((d) => {
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 53 }, [9]);
d.entete.comptes.propositions = 6;
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 31 }, { id: 2 ** 53 }, [9]);
d.entete.comptes.propositions = 7;
d.charge.retenu = { ...retenuValide(), proposition: '9' };
d.entete.comptes.retenu = 1;
})],
@ -347,6 +347,10 @@ describe('analyser : ce qui se lit (§ 8.8)', () => {
d.charge.retenu = retenuValide();
d.entete.comptes.retenu = 1;
})],
['compteurs au plus grand identifiant, 2^31 − 1, et une personne juste en deçà', abime((d) => {
d.charge.prochainsIds = { participant: 2 ** 31 - 1, table: 2 ** 31 - 1, proposition: 2 ** 31 - 1 };
d.charge.participants[3].id = 2 ** 31 - 2;
})],
['toutes les listes vides', abime((d) => {
for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) {
d.charge[cle] = [];
@ -490,6 +494,24 @@ describe('analyser : forme (§ 8.8)', () => {
'charge.prochainsIds.proposition'],
['identifiant 0', (d) => { d.charge.participants[0].id = 0; }, 'charge.participants[0].id'],
['identifiant en texte', (d) => { d.charge.participants[0].id = '1'; }, 'charge.participants[0].id'],
// Un identifiant, et chaque compteur qui en est le prochain, reste sous
// 2^31 ; une référence vers un identifiant au-delà sort de sa règle
// avant de désigner quoi que ce soit. L'identifiant d'un participant
// ou d'une table, l'analyse le compare aussi à son compteur, qui le
// tient déjà sous 2^31 − 1, sous le même chemin : sa règle s'éprouve
// là où rien ne le compare au compteur, dans les poses d'un correctif
// (correctifs.test.js).
['prochain identifiant à 2^31', (d) => { d.charge.prochainsIds.participant = 2 ** 31; },
'charge.prochainsIds.participant'],
['prochain identifiant de table à 2^31', (d) => { d.charge.prochainsIds.table = 2 ** 31; },
'charge.prochainsIds.table'],
['prochain identifiant de proposition à 2^31', (d) => { d.charge.prochainsIds.proposition = 2 ** 31; },
'charge.prochainsIds.proposition'],
['réservation de la personne 2^31', (d) => { d.charge.reservations[0].participant = 2 ** 31; },
'charge.reservations[0].participant'],
['réservation de la table 2^31', (d) => { d.charge.reservations[1].table = 2 ** 31; },
'charge.reservations[1].table'],
['titre de la table 2^31', (d) => { d.charge.titres[0].table = 2 ** 31; }, 'charge.titres[0].table'],
['révision au-delà des entiers exacts', (d) => { d.entete.revision = 2 ** 53; }, 'entete.revision'],
['nom de participant vide', (d) => { d.charge.participants[1].nom = ''; }, 'charge.participants[1].nom'],
['prénom vide au lieu de null', (d) => { d.charge.participants[1].prenom = ''; }, 'charge.participants[1].prenom'],

View file

@ -26,6 +26,7 @@
// manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit
// l'horloge ni un aléa.
import { SCHEMA, capacite, premiereFaute } from './document.js';
import { ErreurStockage } from './erreurs.js';
/**
* Une faute d'un placement ; types.js en donne la table des codes. FORME :
@ -248,11 +249,17 @@ function exigerCoherent(p, fonction) {
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
* modifiés.
*
* Lève TypeError, sans rien rendre, quand decalage n'est pas un entier ≥ 0,
* ou quand la forme produite serait fautive : attribuerSieges qui n'est pas
* un booléen, version vide ou absente, réglage hors de son domaine, plan que
* la configuration contredit — table inconnue d'elle, nombre de tours,
* personne non placée, liste au-delà d'une capacité.
* Lève ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' }), sans
* rien rendre, quand l'identifiant atteint 2^31 − 1, le plus grand
* qu'admette la règle d'un identifiant, que suit aussi le compteur, qui
* n'aurait alors aucune valeur au-delà de lui. Un fichier valide y mène — un
* compteur écrit à la main à 2^31 − 1, ou un identifiant qui l'y relève — et
* le compteur ne recule jamais : l'appelant lit ce code avant d'écrire la
* génération. Lève TypeError, sans rien rendre, quand decalage n'est pas un
* entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges
* qui n'est pas un booléen, version vide ou absente, réglage hors de son
* domaine, plan que la configuration contredit — table inconnue d'elle,
* nombre de tours, personne non placée, liste au-delà d'une capacité.
*
* @param {import('../moteur/recherche.js').Proposition} proposition
* @param {import('../moteur/types.js').Configuration} configuration
@ -265,11 +272,16 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
if (!Number.isSafeInteger(decalage) || decalage < 0) {
throw new TypeError(`versFichier : décalage entier ≥ 0 attendu, reçu ${JSON.stringify(decalage)}`);
}
// Le compteur que l'appelant porte au-delà de l'identifiant écrit suit la
// règle d'un identifiant : un identifiant qui atteint le plus grand
// qu'elle admet ne laisserait aucun compteur au-delà de lui.
const identifiant = decalage + proposition.id;
if (identifiant >= IDENTIFIANT.max) throw new ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' });
const { plan } = proposition;
const capaciteDe = new Map(configuration.tables.map(({ id, capacite: places }) => [id, places]));
const ordonner = (liste) => (attribuerSieges ? [...liste] : [...liste].sort(croissant));
const fichier = {
id: decalage + proposition.id,
id: identifiant,
graine: proposition.graine,
arret: proposition.arret,
historique: proposition.historique,
@ -435,16 +447,17 @@ export function formeNommee(p, charge) {
return lignes;
}
// valeur quand elle est un identifiant de proposition, null sinon.
// valeur quand elle est un identifiant de proposition, null sinon : un
// entier au-delà de 2^31 − 1 n'en est pas un.
const identifiantLisible = (valeur) => (premiereFaute(valeur, IDENTIFIANT) === null ? valeur : null);
// Compteur des propositions de la charge que rend examiner : celui de la
// charge, relevé au-delà de plusGrand, le plus grand identifiant lu, 0 quand
// aucun ne se lit ; il ne recule jamais. Le compteur est un identifiant, un
// entier exact : relevé, il s'arrête à 2^53 − 1, et un identifiant de cette
// valeur reste hors de lui.
// aucun ne se lit ; il ne recule jamais. Le compteur suit la règle d'un
// identifiant : relevé, il s'arrête au plus grand qu'elle admet, 2^31 − 1,
// et un identifiant de cette valeur reste hors de lui.
const compteurReleve = (charge, plusGrand) =>
Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, Number.MAX_SAFE_INTEGER));
Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, IDENTIFIANT.max));
// Faute d'un identifiant de proposition que la charge a attribué au-delà de
// son compteur, ou [] : prochainsIds.proposition dépasse tout identifiant
@ -494,13 +507,17 @@ function examinerRetenu(charge) {
* y vaut le plus grand du compteur reçu et du plus grand identifiant lu plus
* un — celui de chaque élément de la liste, gardé ou écarté, quelle que soit
* sa faute, et la proposition d'origine du retenu, fautif ou non ; une
* valeur qui n'est pas un identifiant n'y entre pas. Le compteur ne recule
* donc jamais, et une génération numérotée depuis lui, comme le dit
* versFichier, ne réattribue ni l'identifiant d'une proposition écartée ni
* celui de l'origine du retenu : le retenu ne désigne jamais une autre
* proposition que la sienne. Le compteur reste un entier exact, qui s'arrête
* à 2^53 − 1 ; un identifiant de cette valeur reste hors de lui, et
* IDENTIFIANT_HORS_COMPTEUR le nomme tant que le fichier le porte.
* valeur qui n'est pas un identifiant, un entier au-delà de 2^31 − 1
* compris, n'y entre pas : la proposition qui la porte est fautive, FORME,
* le retenu aussi. Le compteur ne recule donc jamais, et une génération
* numérotée depuis lui, comme le dit versFichier, ne réattribue ni
* l'identifiant d'une proposition écartée ni celui de l'origine du retenu :
* le retenu ne désigne jamais une autre proposition que la sienne. Le
* compteur suit la règle d'un identifiant, qui s'arrête à 2^31 − 1 ; un
* identifiant de cette valeur reste hors de lui, et
* IDENTIFIANT_HORS_COMPTEUR le nomme tant que le fichier le porte. Un
* compteur à 2^31 − 1 ne numérote plus aucune génération : versFichier lève
* COMPTEUR_SATURE.
*
* Rend une charge neuve, dont propositions ne porte que les gardées, dans
* l'ordre de la liste, et prochainsIds un objet neuf, son compteur des

View file

@ -20,6 +20,7 @@ import { HISTORIQUE_PAR_DEFAUT, rechercher, regenerer } from '../moteur/recherch
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from './document.js';
import { ErreurStockage } from './erreurs.js';
import { derive, examiner, fautes, formeNommee, planDepuisFichier, versFichier } from './placements.js';
const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee });
@ -192,6 +193,22 @@ function leveTypeError(appel, motif, message) {
assert.throws(appel, (erreur) => erreur instanceof TypeError && motif.test(erreur.message), message);
}
// Code et détails de l'ErreurStockage que lève appel, ou un texte qui dit
// l'écart : une autre erreur, aucun refus.
function refusDe(appel) {
try {
appel();
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
return { code: erreur.code, details: erreur.details };
}
return 'aucun refus';
}
// Le refus d'un identifiant qui ne laisserait au compteur des propositions
// aucune valeur au-delà.
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
// Une ligne attendue de la forme nommée.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
tour,
@ -356,6 +373,27 @@ describe('versFichier et planDepuisFichier : la proposition du moteur et sa form
leveTypeError(() => versFichier(PARFAITE, sansTable, options), /"FORME"/, 'table 4 absente');
});
test("versFichier refuse sous un code l'identifiant qui ne laisserait au compteur aucune valeur au-delà, 2^31 − 1 et plus : ErreurStockage COMPTEUR_SATURE, qui nomme le compteur des propositions ; 2^31 − 2, le dernier qui lui en laisse une, s'écrit", () => {
// Une troisième proposition, que son décalage porte comme la première à
// l'identifiant 2^31 − 1.
const troisieme = { ...PARFAITE, id: 3 };
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
assert.equal(versFichier(PARFAITE, petite(), { ...options, decalage: 2 ** 31 - 3 }).id, 2 ** 31 - 2);
const refus = [
[PARFAITE, 2 ** 31 - 2],
[troisieme, 2 ** 31 - 4],
[PARFAITE, 2 ** 31 - 1],
].map(([proposition, decalage]) => [
decalage + proposition.id,
refusDe(() => versFichier(proposition, petite(), { ...options, decalage })),
]);
assert.deepStrictEqual(refus, [
[2 ** 31 - 1, COMPTEUR_SATURE],
[2 ** 31 - 1, COMPTEUR_SATURE],
[2 ** 31, COMPTEUR_SATURE],
]);
});
test("le fichier écrit se relit : examiner garde chaque proposition telle qu'écrite, sans faute ni dérive", () => {
const charge = chargeAvecGeneration();
const examen = examiner(relire(charge));
@ -573,6 +611,12 @@ describe('fautes : la corruption délibérée (§ 8.9, points 1 à 3)', () => {
['liste de table en nombre', (p) => { p.placement[2].sieges[1] = 7; return p; }, 'placement[2].sieges[1]'],
['occupant fractionnaire', (p) => { p.placement[3].sieges[0][2] = 2.5; return p; }, 'placement[3].sieges[0][2]'],
['occupant au-delà des entiers exacts', (p) => { p.placement[3].sieges[1][0] = 2 ** 53; return p; }, 'placement[3].sieges[1][0]'],
// Un identifiant reste sous 2^31.
['identifiant 2^31', (p) => { p.id = 2 ** 31; return p; }, 'id'],
['identifiant de table 2^31', (p) => { p.tables[3] = 2 ** 31; return p; }, 'tables[3]'],
['participant déclaré 2^31', (p) => { p.participants[11] = 2 ** 31; return p; }, 'participants[11]'],
['occupant 2^31', (p) => { p.placement[3].sieges[1][0] = 2 ** 31; return p; }, 'placement[3].sieges[1][0]'],
['en réserve, 2^31', (p) => { p.placement[2].reserve = [2 ** 31]; return p; }, 'placement[2].reserve[0]'],
['réserve absente', (p) => { delete p.placement[0].reserve; return p; }, 'placement[0].reserve'],
['graine absente', (p) => { delete p.graine; return p; }, 'graine'],
['deux clés inconnues : la plus petite', (p) => { p.zeta = 1; p.alpha = 2; return p; }, 'alpha'],
@ -919,9 +963,9 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => {
assert.deepStrictEqual(examen.retenu, { fautes: [longueur], raisons: [] });
});
test("la charge rendue relève prochainsIds.proposition au-delà de chaque identifiant lu — d'une proposition écartée, quelle que soit sa faute, et de l'origine du retenu, cohérent ou non — ; le compteur ne recule jamais, ce qui n'est pas un identifiant n'y entre pas, et il reste un entier exact que l'analyse relit", () => {
test("la charge rendue relève prochainsIds.proposition au-delà de chaque identifiant lu — d'une proposition écartée, quelle que soit sa faute, et de l'origine du retenu, cohérent ou non — ; le compteur ne recule jamais, ce qui n'est pas un identifiant, au-delà de 2^31 − 1 compris, n'y entre pas, et relevé, il s'arrête au plus grand identifiant, que l'analyse relit", () => {
const HORS = 'IDENTIFIANT_HORS_COMPTEUR';
const MAX = Number.MAX_SAFE_INTEGER;
const PLUS_GRAND = 2 ** 31 - 1;
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// troisième. Chaque cas abîme la charge du fichier écrit, que l'analyse
// relit et qui se fige avant l'examen. Il nomme les écartées, [id,
@ -942,6 +986,9 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => {
['la troisième répète la deuxième', (c) => { c.propositions[2].id = 2; }, [[2, 'IDENTIFIANT_REPETE']], [], 4],
['la troisième à 9.5', (c) => { c.propositions[2].id = 9.5; }, [[null, 'FORME']], [], 4],
['la troisième à 2^53', (c) => { c.propositions[2].id = 2 ** 53; }, [[null, 'FORME']], [], 4],
['la troisième à 2^31', (c) => { c.propositions[2].id = 2 ** 31; }, [[null, 'FORME']], [], 4],
['la troisième au plus grand identifiant', (c) => { c.propositions[2].id = PLUS_GRAND; },
[[PLUS_GRAND, HORS]], [], PLUS_GRAND],
["l'origine du retenu à 9", (c) => { c.retenu.proposition = 9; }, [], [HORS], 10],
["l'origine du retenu à 9, qui se contredit", (c) => { Object.assign(c.retenu, { proposition: 9, tours: 5 }); },
[], ['LONGUEUR'], 10],
@ -950,7 +997,11 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => {
delete c.retenu.tours;
}, [], ['FORME'], 10],
["l'origine du retenu à 9.5", (c) => { c.retenu.proposition = 9.5; }, [], ['FORME'], 4],
["l'origine du retenu au plus grand entier exact", (c) => { c.retenu.proposition = MAX; }, [], [HORS], MAX],
["l'origine du retenu au plus grand entier exact", (c) => { c.retenu.proposition = Number.MAX_SAFE_INTEGER; },
[], ['FORME'], 4],
["l'origine du retenu à 2^31", (c) => { c.retenu.proposition = 2 ** 31; }, [], ['FORME'], 4],
["l'origine du retenu au plus grand identifiant", (c) => { c.retenu.proposition = PLUS_GRAND; },
[], [HORS], PLUS_GRAND],
];
// Le compteur que l'analyse relit dans le fichier de la charge rendue,
// ou le refus qu'elle lève.
@ -984,6 +1035,95 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => {
);
});
test("un identifiant au-delà de 2^31 − 1, écrit à la main à l'origine du retenu ou à une proposition, n'en est pas un : le retenu est fautif, la proposition écartée, le compteur reste à 4 ; la génération suivante, numérotée comme le dit versFichier, prend 4, 5 et 6, et le fichier se rouvre", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// première. Une valeur change dans le texte écrit, que l'analyse lit
// sans entrer dans les propositions ni dans le retenu.
const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 });
const charge = chargeAvecGeneration();
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
const FORME_ORIGINE = { code: 'FORME', chemin: 'proposition' };
const cas = [
["l'origine du retenu à 2^53 − 2", '"retenu": {"proposition":1,', 2 ** 53 - 2, [1, 2, 3], [FORME_ORIGINE]],
["l'origine du retenu à 2^31", '"retenu": {"proposition":1,', 2 ** 31, [1, 2, 3], [FORME_ORIGINE]],
['la troisième proposition à 2^53 − 2', '{"id":3,"graine"', 2 ** 53 - 2, [1, 2], []],
['la troisième proposition à 2^31', '{"id":3,"graine"', 2 ** 31, [1, 2], []],
];
const observes = [];
const attendus = [];
for (const [libelle, avant, valeur, gardees, fautesDuRetenu] of cas) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
const ouverte = examiner(analyser(texte.replace(avant, avant.replace(/\d+,/, `${valeur},`))).charge).charge;
// L'appelant, comme le dit versFichier : la génération décalée de
// prochainsIds.proposition − 1, puis le compteur porté au-delà.
const decalage = ouverte.prochainsIds.proposition - 1;
const configuration = configurationDepuisCharge(ouverte);
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const generee = {
...ouverte,
prochainsIds: { ...ouverte.prochainsIds, proposition: decalage + suivante.length + 1 },
propositions: [
...ouverte.propositions,
...suivante.map((proposition) => versFichier(proposition, configuration, options)),
],
};
const rouverte = examiner(relire(generee));
observes.push([
libelle,
decalage,
rouverte.charge.propositions.map(({ id }) => id),
rouverte.fautives,
rouverte.retenu.fautes,
rouverte.charge.prochainsIds.proposition,
]);
attendus.push([libelle, 3, [...gardees, 4, 5, 6], [], fautesDuRetenu, 7]);
}
assert.deepStrictEqual(observes, attendus);
});
test("un identifiant de 2^31 − 1, le plus grand, écrit à la main à l'origine du retenu ou à une proposition, ou le compteur écrit à cette valeur, laisse à 2^31 − 1 le compteur de la charge rendue : la génération suivante, d'une proposition ou de trois, numérotée comme le dit versFichier, lève COMPTEUR_SATURE à sa première proposition, un code que l'appelant lit avant toute écriture", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// première. Une valeur change dans le texte écrit, que l'analyse admet :
// elle ne lit des propositions et du retenu que le conteneur, et le
// compteur reste dans sa règle.
const PLUS_GRAND = 2 ** 31 - 1;
const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 });
const charge = chargeAvecGeneration();
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
const cas = [
["l'origine du retenu", '"retenu": {"proposition":1,', `"retenu": {"proposition":${PLUS_GRAND},`],
['la troisième proposition', '{"id":3,"graine"', `{"id":${PLUS_GRAND},"graine"`],
['le compteur', '"proposition":4}', `"proposition":${PLUS_GRAND}}`],
];
const observes = [];
const attendus = [];
for (const [libelle, avant, apres] of cas) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
const ouverte = examiner(analyser(texte.replace(avant, apres)).charge).charge;
// L'appelant, comme le dit versFichier : la génération décalée de
// prochainsIds.proposition − 1, chacune de ses propositions mise en
// forme avant qu'aucune ne s'écrive.
const decalage = ouverte.prochainsIds.proposition - 1;
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const configuration = configurationDepuisCharge(ouverte);
for (const nombre of [1, 3]) {
const generation = suivante.slice(0, nombre);
observes.push([
libelle,
nombre,
ouverte.prochainsIds.proposition,
refusDe(() => generation.map((proposition) => versFichier(proposition, configuration, options))),
]);
attendus.push([libelle, nombre, PLUS_GRAND, COMPTEUR_SATURE]);
}
}
assert.deepStrictEqual(observes, attendus);
});
test("un compteur abaissé à la main, puis une génération numérotée depuis la charge rendue, comme le dit versFichier : aucun identifiant écarté ne revient, et l'origine du retenu n'est jamais réattribuée", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de
// l'une d'elles. Le compteur, abaissé à 1, 2 ou 3 dans le fichier écrit,

View file

@ -14,7 +14,10 @@
// Les chaînes de saisie sont en NFC : la conversion se fait à l'entrée — CSV,
// commandes —, jamais à l'écriture. Une chaîne « non vide » compte au moins
// une unité ; un champ « chaîne ou null » porte null plutôt qu'une chaîne
// vide. Un entier est un entier exact, au plus 2^53 − 1.
// vide. Un entier est un entier exact, au plus 2^53 − 1 ; un identifiant —
// d'un participant, d'une table, d'une proposition, et ce qu'une
// réservation, un titre ou un placement désigne — et chaque compteur de
// prochainsIds, au plus 2^31 − 1.
/**
* Le fichier d'état, format 1 (§ 8.8, § 8.9). Deux régions : l'en-tête et la
@ -94,9 +97,10 @@
* @property {{nombre: number, arret: number, historique: number}} generation
* entiers ≥ 1
*
* @typedef {Object} ProchainsIds entiers ≥ 1, au-delà de tout identifiant
* attribué ; ils ne reculent jamais, et un
* identifiant ne se réattribue pas (§ 4)
* @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 1, au-delà de tout
* identifiant attribué ; ils ne reculent
* jamais, et un identifiant ne se
* réattribue pas (§ 4)
* @property {number} participant
* @property {number} table
* @property {number} proposition au-delà de chaque identifiant de
@ -111,10 +115,14 @@
* chaque identifiant lu, écartées et
* origine du retenu comprises : abaissé à
* la main, il ne fait réattribuer aucun
* d'eux
* d'eux. Relevé, il s'arrête à 2^31 − 1 ;
* un entier au-delà n'est pas un
* identifiant, et n'y entre pas. À
* 2^31 − 1, aucune génération ne se
* numérote plus (COMPTEUR_SATURE)
*
* @typedef {Object} Participant
* @property {number} id entier ≥ 1, unique
* @property {number} id identifiant, unique
* @property {string} nom non vide
* @property {string|null} prenom
* @property {string|null} appartenance
@ -124,7 +132,7 @@
* @property {boolean} exclu (§ 4.4)
*
* @typedef {Object} Table
* @property {number} id entier ≥ 1, unique
* @property {number} id identifiant, unique
* @property {number} numero affiché, entier ≥ 1
* @property {number|null} sieges null : suit le défaut ; entier ≥ 2 :
* surcharge (§ 6.1)
@ -161,7 +169,7 @@
* @property {number[]} reserve triée par identifiant croissant
*
* @typedef {Object} PropositionFichier
* @property {number} id entier ≥ 1 : place dans la suite des
* @property {number} id identifiant : place dans la suite des
* graines dérivées (§ 5.7)
* @property {number} graine graine dérivée, 0 ≤ g < 2^32
* @property {number} arret compte d'arrêt, ≥ 1
@ -184,7 +192,9 @@
* l'admet et le contrôle des placements le garde
* en le signalant (§ 8.9, point 3) : canonique.js
* l'écrit compact à la clé retenu, ses clés
* rangées, ses listes dans l'ordre écrit
* rangées, ses listes dans l'ordre écrit ; un
* correctif le pose entier, et ne pose rien sous
* lui (correctifs.js)
* @property {number} proposition id de la proposition d'origine, sous
* prochainsIds.proposition
* @property {boolean} siegesAttribues
@ -344,6 +354,16 @@
* collision dépasserait les
* deux chiffres que la borne
* réserve (§ 8.7)
* COMPTEUR_SATURE {compteur} l'identifiant à attribuer
* atteindrait 2^31 − 1 : le
* compteur de prochainsIds
* que compteur nomme —
* 'proposition' pour
* versFichier — n'aurait
* aucune valeur au-delà ; un
* fichier valide y mène, et
* l'appelant le lit avant
* toute écriture
*
* @typedef {'ABSENT'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
@ -351,8 +371,9 @@
* VIDE zéro octet, ou des blancs seulement
* JSON le texte n'est pas du JSON
* FORME type, clé manquante ou inconnue, valeur hors de son
* domaine, identifiant de participant ou de table en
* double ou atteignant prochainsIds, tour d'une
* domaine — un identifiant ou un compteur au-delà de
* 2^31 − 1 compris —, identifiant de participant ou de
* table en double ou atteignant prochainsIds, tour d'une
* réservation hors de sa portée ; des propositions et du
* retenu, seul le conteneur
* COMPTES un compte de l'en-tête contredit sa liste
@ -421,5 +442,5 @@
* ses raisons de dérive ; la charge
* qu'examiner rend relève le compteur
* au-delà de l'identifiant, qui ne se
* réattribue pas
* réattribue pas, jusqu'à 2^31 − 1
*/

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,32 @@ export default defineConfig({
// l'exécution quand node ou node-long dépasse son plafond (§ 14.14). Un
// chemin relatif se lit depuis la racine du projet.
reporters: ['default', './test/rapporteur.js'],
// La couverture (§ 14.13), que lancent npm run couverture et make
// couverture : la série node sous l'instrumentation de V8, sans le
// rapporteur, dont les plafonds bornent la série surveillée et la série
// longue, non une mesure. Aucun seuil global : un pourcentage d'ensemble
// se satisfait en couvrant les modules faciles. Le tableau imprime une
// ligne par couche et par module de src/, pleins compris — que Vitest
// tait sinon quand un agent le lance — et à 0 % ceux qu'aucune épreuve ne
// charge ; coverage/index.html montre, ligne à ligne, ce qui n'est jamais
// exécuté. Quatre modules tiennent 100 % de branches, seuil dur : la
// commande échoue en deçà. Une branche nue s'y couvre par un cas ajouté,
// ou part avec le code mort ; le seuil ne s'abaisse pas, et
// test/arborescence.test.js garde ces quatre seuils, ces deux rapports
// et les deux commandes qui lancent la mesure.
coverage: {
provider: 'v8',
include: ['src/**/*.js'],
exclude: ['src/**/*.test.js'],
reporter: [['text', { skipFull: false }], 'html'],
reportsDirectory: 'coverage',
thresholds: {
'src/moteur/indicateurs.js': { branches: 100 },
'src/moteur/plafond.js': { branches: 100 },
'src/stockage/depot.js': { branches: 100 },
'src/stockage/journal.js': { branches: 100 },
},
},
// Sous surveillance, une épreuve se relance quand un module de son graphe
// d'imports change. Les fichiers des motifs se lisent hors de tout
// graphe : le contrôle de version (§ 18.5) lit les premiers par le