Compare commits
4 commits
f2b66f2f92
...
fa2fc0eb2b
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2fc0eb2b | |||
| aa5fafab1a | |||
| fa32ad9fae | |||
| 15148e3a26 |
16 changed files with 5285 additions and 308 deletions
4
Makefile
4
Makefile
|
|
@ -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
144
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
238
spec.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 apres = copie(avant);
|
||||
const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1];
|
||||
// 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
|
||||
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 [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]));
|
||||
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));
|
||||
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 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', cas);
|
||||
assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4), cas);
|
||||
formes.add(chemin.length);
|
||||
}
|
||||
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);
|
||||
}
|
||||
assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(apres));
|
||||
// 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
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
2311
src/stockage/depot.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue