forked from yvv/decision
Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,22 +36,29 @@ crates/
|
|||||||
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane,
|
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane,
|
||||||
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
|
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
|
||||||
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
|
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
|
||||||
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), order (l'ordre
|
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
|
||||||
|
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
|
||||||
|
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
|
||||||
# d'une section : qui en est, complété à la fin, places), pending (modules à voter)
|
# d'une section : qui en est, complété à la fin, places), pending (modules à voter)
|
||||||
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
||||||
# chaîne), groupes par défaut
|
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
|
||||||
|
# groupes par défaut
|
||||||
ld-server/
|
ld-server/
|
||||||
migrations/ # schéma SQLite — additif seulement
|
migrations/ # schéma SQLite — additif seulement
|
||||||
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||||
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
|
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter,
|
||||||
# traduire) · adoption.rs (balayage, verrou) · participation.rs (p̃, une fois par
|
# traduire) · objections.rs (faire, lever, réitérer, la version retenue) · typos.rs
|
||||||
|
# (les coquilles des autres) · messages.rs (la boîte « Messages ») · deadlines.rs
|
||||||
|
# (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs
|
||||||
|
# (balayage, verrou) · participation.rs (p̃, une fois par
|
||||||
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
||||||
# seed_amend.rs / inertia_amend.rs (amendements gardés) · ballots.rs (vote groupé,
|
# seed_amend.rs / inertia_amend.rs (amendements gardés) · ballots.rs (vote groupé,
|
||||||
# bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments)
|
# bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments)
|
||||||
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
|
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
|
||||||
# section, le titre du document) · module_amend.rs (ajout gardé) · web.rs (sert web/build)
|
# section, le titre du document) · module_amend.rs (ajout gardé) · web.rs (sert web/build)
|
||||||
tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) ·
|
tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) ·
|
||||||
# modules.rs (ordre, titre, ajout gardé)
|
# objections.rs · typos.rs (lot F, horloge avancée, migration 0008) · modules.rs (ordre,
|
||||||
|
# titre, ajout gardé)
|
||||||
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ;
|
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ;
|
||||||
# inertie 3, E1/E2 à 4 ; modules : le titre à 4, l'ordre de chaque section à 2)
|
# inertie 3, E1/E2 à 4 ; modules : le titre à 4, l'ordre de chaque section à 2)
|
||||||
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
|
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
|
||||||
@@ -62,14 +69,16 @@ web/src/
|
|||||||
# OrderModule / OrderEditor (l'ordre d'une section, la liste où l'on clique),
|
# OrderModule / OrderEditor (l'ordre d'une section, la liste où l'on clique),
|
||||||
# TitleBar / TitlePropose (le titre du document), Seal…
|
# TitleBar / TitlePropose (le titre du document), Seal…
|
||||||
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
||||||
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, modules
|
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules
|
||||||
|
lib/objections.ts # où l'auteur répond à une objection · lib/diff.ts : le diff resserré d'une coquille
|
||||||
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
||||||
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
||||||
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
||||||
# vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages)
|
# vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages)
|
||||||
# · g/[group]/decisions · g/[group]/mandats ·
|
# · g/[group]/decisions · g/[group]/mandats ·
|
||||||
# g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent
|
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
||||||
web/e2e/ # Playwright C1–C27 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
# /groupes, /decisions… redirigent
|
||||||
|
web/e2e/ # Playwright C1–C33 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
||||||
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
@@ -91,7 +100,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
|||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 29 contrôles
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 34 contrôles (35 tests, C25 en deux)
|
||||||
|
|
||||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
@@ -106,8 +115,10 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
|
|||||||
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
||||||
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
||||||
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
|
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
|
||||||
une coquille ne demande pas de vote, un changement de fond si ; un signalement de
|
une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend
|
||||||
contradiction bloque ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
|
son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée
|
||||||
|
change le texte ; une objection rend la dernière arrivée non votable jusqu'à sa levée (jamais un
|
||||||
|
texte en vigueur ; ADR 0010) ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
|
||||||
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
|
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
|
||||||
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
|
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
|
||||||
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
|
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Acte — Mise en ligne M2 + M3a (le vote déplié et groupé, l'inertie à cinq crans)
|
||||||
|
Classe : déploie
|
||||||
|
Projet : libreDecision Date : 2026-10-31
|
||||||
|
**État : FIGÉ (2026-10-31, 01 h 30)** sur `74f96be` — relectures « a576b68 : ok », « 71819f9 : ok »,
|
||||||
|
« 74f96be : ok » inscrites
|
||||||
|
|
||||||
|
## Intention
|
||||||
|
|
||||||
|
Mettre en ligne en une fois les points L9 et L10 de la revue d'Yvv du 30/10 :
|
||||||
|
- L10 (lot C, déjà relu, dossier M3a) : l'inertie à cinq crans, l'échelle calée sur la
|
||||||
|
participation, les réglages de la formule ;
|
||||||
|
- L9 (lot B) :
|
||||||
|
- le vote qui se déplie sous l'article, avec ton vote, les totaux et la distance au seuil ;
|
||||||
|
- le vote groupé (document, section, article) dans un panier ;
|
||||||
|
- le vote par transaction Ğ1, livré **éteint**.
|
||||||
|
|
||||||
|
**Le vote par la chaîne reste éteint** : `LD_VOTE_ACCOUNT` n'est pas posé. La relecture de
|
||||||
|
`74f96be` a relevé trois défauts à corriger avant de l'ouvrir (lot B2, en cours), et Yvv doit
|
||||||
|
encore trancher le secret du vote, l'extension et le compte de vote. Dans Monnaie libre,
|
||||||
|
l'écran dit pourquoi on ne peut pas encore voter. Dans le groupe d'essai, le vote marche côté
|
||||||
|
serveur, déplié et groupé.
|
||||||
|
|
||||||
|
Si cet acte est armé, il **remplace** le dossier M3a (`actes/2026-10-30-m3-inertie-cinq-crans.md`),
|
||||||
|
qui n'aura plus d'objet. Tout ce que M3a écrit en base est repris ici.
|
||||||
|
|
||||||
|
## Ce qui part
|
||||||
|
|
||||||
|
Version : `74f96be`, qui contient la version en production `8727315`. En plus de M3a
|
||||||
|
(`8727315..71819f9`, voir son dossier) :
|
||||||
|
- lot B : `3541e1b` (le vote par la chaîne, moteur et serveur), `b260596` (le vote déplié, le
|
||||||
|
panier, la signature), `ef27165` (« Voter en lot »), `032fcbb` (ADR 0008, contrôles C21–C24) ;
|
||||||
|
- la fusion `74f96be`.
|
||||||
|
|
||||||
|
Ce qui change hors du code : `docker/app-compose.yml` reçoit **trois lignes de commentaire** (où
|
||||||
|
poser `LD_VOTE_ACCOUNT`), et rien d'autre (prémisse 4). Nouvelles dépendances :
|
||||||
|
- `polkadot-api` (67 Ko compressés, chargé seulement au moment de signer) ;
|
||||||
|
- `uqr` (4 Ko, le QR code) ;
|
||||||
|
- la crate `blake2`.
|
||||||
|
|
||||||
|
## Ce que l'acte écrit dans la base de production (donnée runtime)
|
||||||
|
|
||||||
|
- **Tout ce que dit le dossier M3a** :
|
||||||
|
- migration `0005` (crans, avec les anciens mots gardés) ;
|
||||||
|
- amendements gardés « E1 et E2 au cran 4 » ;
|
||||||
|
- mesure de participation dans `settings` ;
|
||||||
|
- republication de Monnaie libre.
|
||||||
|
- **La migration `0006`**, additive :
|
||||||
|
- tables `ballots` et `ballot_lines` ;
|
||||||
|
- quatre colonnes ajoutées à `votes` (`source`, `server` par défaut ; `block`, `event_index`,
|
||||||
|
`ballot_id`) ;
|
||||||
|
- aucun vote réécrit (il n'y en a aucun en production).
|
||||||
|
- **Répétition faite le 31/10 à 01 h 25**, avec le serveur de cette version, sur une copie
|
||||||
|
fraîche de la base de production (la santé disait `87273156`) :
|
||||||
|
- migrations 1 à 6 appliquées ;
|
||||||
|
- 4 « inertia amendment applied » ;
|
||||||
|
- 62 articles au cran 3, 4 au cran 4 ;
|
||||||
|
- 0 bulletin, 0 vote, une identité ;
|
||||||
|
- `integrity_check` ok, `foreign_key_check` vide.
|
||||||
|
|
||||||
|
## Avant — prémisses vivantes
|
||||||
|
|
||||||
|
1. La production sert `8727315` : `curl -s https://decision.librodrome.org/api/health` →
|
||||||
|
`"build":"87273156"`. Si elle sert `71819f9` (M3a déjà en ligne), l'acte reste valable :
|
||||||
|
seule la migration `0006` s'applique alors.
|
||||||
|
2. `git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
|
||||||
|
→ la même version que la santé, deux fois.
|
||||||
|
3. Les relectures `a576b68`, `71819f9` et `74f96be` sont inscrites « ok » dans
|
||||||
|
`~/harness/etat/relectures.json`. Aucune autre relecture de ce code n'est en cours.
|
||||||
|
4. `git diff 8727315 74f96be -- docker/app-compose.yml .woodpecker/ .env data/` ne montre que
|
||||||
|
les trois lignes de commentaire sur `LD_VOTE_ACCOUNT` (vérifié le 31/10 à 01 h 30). Et sur sonic,
|
||||||
|
`grep -c LD_VOTE_ACCOUNT /opt/libredecision-app/secrets.env` → `0`, ou fichier absent : le
|
||||||
|
vote par la chaîne reste éteint (fichier absent le 31/10 à 01 h 30).
|
||||||
|
5. La boucle est verte sur `74f96be` : fait le 31/10 vers 0 h 15 (149 tests Rust, svelte-check
|
||||||
|
0 avertissement, 32 vitest, e2e 25 / 25). L'image de production se construit (sortie 0).
|
||||||
|
6. Sur une copie fraîche de la base de production :
|
||||||
|
- `SELECT inertia, COUNT(*) FROM units GROUP BY 1` → `standard|66` (ou, si M3a est en
|
||||||
|
ligne, `3|62` et `4|4`) ;
|
||||||
|
- `SELECT COUNT(*) FROM votes` → `0` ;
|
||||||
|
- `SELECT COUNT(*) FROM adoptions WHERE kind = 'inertia'` → `0`.
|
||||||
|
|
||||||
|
## Commandes exactes
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/yvv/Documents/PROD/DEV/libreDecision
|
||||||
|
git push prod 74f96be:main
|
||||||
|
git push origin 74f96be:main
|
||||||
|
git push origin chantier/acte-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
## Effet attendu
|
||||||
|
|
||||||
|
Les visiteurs voient :
|
||||||
|
- l'inertie en crans et les réglages de la formule ;
|
||||||
|
- « Le vote » qui se déplie sous l'article ;
|
||||||
|
- « Voter en lot » et « Voter cette section », et le panier ;
|
||||||
|
- dans Monnaie libre, la raison pour laquelle on ne vote pas encore.
|
||||||
|
|
||||||
|
E1 et E2 passent au cran 4. Le dépôt des textes reçoit un `document.json` qui porte les crans.
|
||||||
|
|
||||||
|
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
|
||||||
|
- les crans écrits en base, avec les mots d'origine à côté ;
|
||||||
|
- E1 et E2 au cran 4 ;
|
||||||
|
- les tables du vote, vides ;
|
||||||
|
- le commit publié dans le dépôt des textes.
|
||||||
|
|
||||||
|
## Vérifications après
|
||||||
|
|
||||||
|
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"74f96bec"`.
|
||||||
|
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "inertia amendment|participation measured|published|ballot"'`
|
||||||
|
→ quatre « inertia amendment applied », les mesures, une publication, aucune erreur de
|
||||||
|
bulletin.
|
||||||
|
- [ ] À l'écran (Yvv) :
|
||||||
|
- Monnaie libre → Acte d'engagement → « Le vote » sous la Raison d'être se déplie, et dit
|
||||||
|
que le vote par la chaîne n'est pas encore ouvert ;
|
||||||
|
- les réglages de la formule montrent la référence de 100 votants ;
|
||||||
|
- dans le groupe d'essai, voter « Pour » sous un article marche, et le panier groupe une
|
||||||
|
section.
|
||||||
|
|
||||||
|
## Retour
|
||||||
|
|
||||||
|
- **Le code**, au plus court, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
|
||||||
|
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
|
||||||
|
- **Vers `71819f9`** : la relecture a montré qu'elle tolère la migration `0006` et continue
|
||||||
|
d'écrire ses votes.
|
||||||
|
- **Vers `8727315`** : pas de perte, mais l'affichage se dégrade. Ce sont les clés brutes
|
||||||
|
« inertia.3 », les propositions vers 5 lues « vers standard », d'adoption impossible. Voir le
|
||||||
|
dossier M3a. On n'y reste pas.
|
||||||
|
- **Les crans, E1 et E2 au cran 4, les tables du vote** : pas de retour automatique. Elles sont
|
||||||
|
additives, ou gardent l'ancien mot à côté.
|
||||||
|
- **Le commit publié** dans le dépôt des textes : on ne le retire pas sans réécrire l'historique
|
||||||
|
de ce dépôt, ce qui serait un acte « supprime ». On publierait par-dessus.
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
/// The SS58 prefix of the Ğ1 chain.
|
/// The SS58 prefix of the Ğ1 chain.
|
||||||
pub const G1_PREFIX: u16 = 4450;
|
pub const G1_PREFIX: u16 = 4450;
|
||||||
|
|
||||||
|
/// The longest text read as an address. An SS58 address of a 32-byte key is
|
||||||
|
/// about 48 characters; base58 decoding costs the square of the length, so a
|
||||||
|
/// longer text is refused before it is decoded (review of 74f96be: 250 000
|
||||||
|
/// characters took 32 s on a thread of the server).
|
||||||
|
pub const MAX_LEN: usize = 64;
|
||||||
|
|
||||||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
||||||
@@ -72,6 +78,9 @@ fn prefix_bytes(prefix: u16) -> Vec<u8> {
|
|||||||
|
|
||||||
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
|
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
|
||||||
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
|
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
|
||||||
|
if addr.len() > MAX_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let data = base58_decode(addr)?;
|
let data = base58_decode(addr)?;
|
||||||
let (prefix, at) = match *data.first()? {
|
let (prefix, at) = match *data.first()? {
|
||||||
b @ 0..=63 => (u16::from(b), 1),
|
b @ 0..=63 => (u16::from(b), 1),
|
||||||
@@ -146,6 +155,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_text_is_refused_before_it_is_decoded() {
|
||||||
|
// Base58 decoding costs the square of the length, on a thread of the server:
|
||||||
|
// 250 000 characters took 32 s (review of 74f96be). An SS58 address is about 48.
|
||||||
|
for (g1, _, generic) in VECTORS {
|
||||||
|
assert!(g1.len() <= 64 && generic.len() <= 64);
|
||||||
|
}
|
||||||
|
let long = "z".repeat(40_000);
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
assert_eq!(decode(&long), None);
|
||||||
|
assert_eq!(to_g1(&long), None);
|
||||||
|
assert!(t.elapsed() < std::time::Duration::from_millis(20), "{:?}", t.elapsed());
|
||||||
|
// One character too many, even of leading ones (zero bytes), is refused.
|
||||||
|
assert_eq!(decode(&format!("{}{}", "1".repeat(65 - VECTORS[0].0.len()), VECTORS[0].0)), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn what_is_not_an_address() {
|
fn what_is_not_an_address() {
|
||||||
let (g1, _, _) = VECTORS[0];
|
let (g1, _, _) = VECTORS[0];
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
//! per identity, in the order of the chain — block, then rank of the event in
|
//! per identity, in the order of the chain — block, then rank of the event in
|
||||||
//! the block. A ballot confirmed late never overrides a more recent one.
|
//! the block. A ballot confirmed late never overrides a more recent one.
|
||||||
//!
|
//!
|
||||||
|
//! Anyone who sees a remark may copy it. A ballot bound to an account is settled
|
||||||
|
//! by that account's comment only; a ballot bound to none (a Ğecko code) by a
|
||||||
|
//! **paid** comment only — a bare remark costs nothing to send, a transfer of
|
||||||
|
//! the amount does.
|
||||||
|
//!
|
||||||
//! Pure: the salt comes from the caller, the facts of the chain too.
|
//! Pure: the salt comes from the caller, the facts of the chain too.
|
||||||
|
|
||||||
use crate::address::{self, G1_PREFIX};
|
use crate::address::{self, G1_PREFIX};
|
||||||
@@ -217,6 +222,10 @@ pub enum Verdict {
|
|||||||
Paid { identity: ChainIdentity },
|
Paid { identity: ChainIdentity },
|
||||||
/// Signed by another account than the one bound: a copy of the remark, ignored.
|
/// Signed by another account than the one bound: a copy of the remark, ignored.
|
||||||
OtherAuthor,
|
OtherAuthor,
|
||||||
|
/// A ballot bound to no account (a Ğecko code), and a comment without the
|
||||||
|
/// transfer: a copy of the remark anyone may send for free, ignored. Only a
|
||||||
|
/// paid comment settles such a ballot.
|
||||||
|
Unpaid,
|
||||||
/// The account is not that of an identity.
|
/// The account is not that of an identity.
|
||||||
NoIdentity,
|
NoIdentity,
|
||||||
/// No transfer of the amount to the vote account in the same transaction.
|
/// No transfer of the amount to the vote account in the same transaction.
|
||||||
@@ -227,30 +236,46 @@ impl Verdict {
|
|||||||
/// Why a ballot is refused, as stored and translated.
|
/// Why a ballot is refused, as stored and translated.
|
||||||
pub fn reason(self) -> Option<&'static str> {
|
pub fn reason(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Paid { .. } | Self::OtherAuthor => None,
|
Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None,
|
||||||
Self::NoIdentity => Some("no_identity"),
|
Self::NoIdentity => Some("no_identity"),
|
||||||
Self::NoTransfer => Some("transfer"),
|
Self::NoTransfer => Some("transfer"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this comment settles the ballot (counted or refused), or is a copy to pass over.
|
||||||
|
pub fn settles(self) -> bool {
|
||||||
|
!matches!(self, Self::OtherAuthor | Self::Unpaid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the comment's transaction carries the transfer the ballot asks:
|
||||||
|
/// from its signer, to the vote account, at least the amount.
|
||||||
|
pub fn paid(c: &Comment, e: &Expected) -> bool {
|
||||||
|
c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
|
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
|
||||||
if e.author.is_some_and(|a| a != c.author) {
|
if e.author.is_some_and(|a| a != c.author) {
|
||||||
return Verdict::OtherAuthor;
|
return Verdict::OtherAuthor;
|
||||||
}
|
}
|
||||||
|
let paid = paid(c, e);
|
||||||
|
if e.author.is_none() && !paid {
|
||||||
|
return Verdict::Unpaid;
|
||||||
|
}
|
||||||
let Some(identity) = c.identity else { return Verdict::NoIdentity };
|
let Some(identity) = c.identity else { return Verdict::NoIdentity };
|
||||||
if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) {
|
if !paid {
|
||||||
return Verdict::NoTransfer;
|
return Verdict::NoTransfer;
|
||||||
}
|
}
|
||||||
Verdict::Paid { identity }
|
Verdict::Paid { identity }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comment that settles a ballot: the first in the order of the chain that is
|
/// The comment that settles a ballot: the first in the order of the chain that is
|
||||||
/// not a copy by another account. `None`: nothing yet, keep waiting.
|
/// not a copy — by another account than the one bound, or, unbound, without the
|
||||||
|
/// transfer. `None`: nothing yet, keep waiting.
|
||||||
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
|
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
|
||||||
let mut sorted: Vec<&Comment> = comments.iter().collect();
|
let mut sorted: Vec<&Comment> = comments.iter().collect();
|
||||||
sorted.sort_by_key(|c| c.position);
|
sorted.sort_by_key(|c| c.position);
|
||||||
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| *v != Verdict::OtherAuthor)
|
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -400,15 +425,18 @@ mod tests {
|
|||||||
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
|
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
|
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
|
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
|
||||||
// Too little, elsewhere, or nothing.
|
// Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound.
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
|
let bound = Expected { author: Some(key(SIMONE)), ..e };
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
|
for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] {
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
|
let c = comment(1, SIMONE, Some((1304, true)), unpaid);
|
||||||
// Paid from another account than the signer's.
|
assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}");
|
||||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
|
assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}");
|
||||||
|
}
|
||||||
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
||||||
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
||||||
assert_eq!(ok.reason(), None);
|
assert_eq!(ok.reason(), None);
|
||||||
|
assert_eq!(Verdict::Unpaid.reason(), None);
|
||||||
|
assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -429,4 +457,31 @@ mod tests {
|
|||||||
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
|
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
|
||||||
assert_eq!(judge(&[], &free), None);
|
assert_eq!(judge(&[], &free), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unbound_ballot_is_settled_by_a_paid_comment_only() {
|
||||||
|
// A Ğecko code binds no account. A remark alone costs nothing to send:
|
||||||
|
// a bare copy must not settle the ballot (review of 74f96be, P3).
|
||||||
|
let free = Expected { account: key(ALICE), amount: 10, author: None };
|
||||||
|
let bare = comment(8, SIMONE, None, &[]);
|
||||||
|
let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
|
||||||
|
let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]);
|
||||||
|
assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting");
|
||||||
|
let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
|
||||||
|
assert_eq!(
|
||||||
|
judge(&[paid.clone(), elsewhere, short, bare], &free),
|
||||||
|
Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER }))
|
||||||
|
);
|
||||||
|
// Paid by an account without identity: paid, so it settles — refused.
|
||||||
|
let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]);
|
||||||
|
assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity)));
|
||||||
|
// The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot.
|
||||||
|
let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
|
||||||
|
let theirs = ChainIdentity { index: 1, member: true };
|
||||||
|
assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs })));
|
||||||
|
// Bound to an account: its own comment without the transfer is refused, and told.
|
||||||
|
let bound = Expected { author: Some(key(SIMONE)), ..free };
|
||||||
|
let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
|
||||||
|
assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is
|
||||||
|
//! does not change: `edit` (letters and spaces, judged against the voted text).
|
||||||
|
//!
|
||||||
|
//! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais
|
||||||
|
//! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule
|
||||||
|
//! une coquille validée corrige le texte en vote. »
|
||||||
|
//!
|
||||||
|
//! - The person who **answers for the version** — its author; for a text
|
||||||
|
//! without author (a starting text), an administrator of trustinWallet
|
||||||
|
//! (`trustin_access::answers_typo`) — applies a fix at once (`route`).
|
||||||
|
//! - Anyone else's fix **waits**: the one who answers is told by a message,
|
||||||
|
//! validates or refuses, and **without an answer within 3 days it applies by
|
||||||
|
//! itself** (`tick`). On a text in force, only a validated fix (or one passed
|
||||||
|
//! at the deadline) corrects it.
|
||||||
|
//! - **The author keeps the hand while the version is not in force**: a fix
|
||||||
|
//! applied (validated or passed at the deadline) may still be refused — the
|
||||||
|
//! text it was written over comes back —, and a refused one validated after
|
||||||
|
//! all. Once in force, an answer is final.
|
||||||
|
//! - A fix is written only over the text it was written over (`fits`): two
|
||||||
|
//! fixes waiting on one text are each judged against the voted text; once one
|
||||||
|
//! applies, the other no longer fits and is **stale** (« recharge, puis
|
||||||
|
//! recommence »). Slips never add up.
|
||||||
|
//! - Translations stay out of this: editorial, without validation.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Days the one who answers for the version has, before a fix applies by itself.
|
||||||
|
pub const ANSWER_DAYS: i64 = 3;
|
||||||
|
|
||||||
|
/// Where a fix goes when it is written.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Route {
|
||||||
|
/// Applied at once: the writer answers for the version.
|
||||||
|
Apply,
|
||||||
|
/// Waits for the one who answers for the version, or for the deadline.
|
||||||
|
Await,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn route(writer_answers: bool) -> Route {
|
||||||
|
if writer_answers { Route::Apply } else { Route::Await }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TypoStatus {
|
||||||
|
/// Written, waiting for an answer or the deadline.
|
||||||
|
Pending,
|
||||||
|
/// In the text.
|
||||||
|
Applied,
|
||||||
|
/// Not in the text: refused (or taken back by the author before the text was in force).
|
||||||
|
Refused,
|
||||||
|
/// The text changed before it could apply: written over a text that is no longer there.
|
||||||
|
Stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypoStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Applied => "applied",
|
||||||
|
Self::Refused => "refused",
|
||||||
|
Self::Stale => "stale",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
Validate,
|
||||||
|
Refuse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a step does to the text of the version.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TextChange {
|
||||||
|
/// Write the fix (only where `fits`).
|
||||||
|
Write,
|
||||||
|
/// Put back the text the fix was written over (only where the fix is still the text).
|
||||||
|
Restore,
|
||||||
|
Keep,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Settle {
|
||||||
|
pub status: TypoStatus,
|
||||||
|
pub text: TextChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TypoRefusal {
|
||||||
|
/// Already answered (or stale): nothing left to decide.
|
||||||
|
Settled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
|
||||||
|
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
|
||||||
|
let to = |status, text| Ok(Settle { status, text });
|
||||||
|
match (status, answer) {
|
||||||
|
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
|
||||||
|
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
|
||||||
|
// The author keeps the hand while the version is not in force.
|
||||||
|
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore),
|
||||||
|
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
|
||||||
|
_ => Err(TypoRefusal::Settled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
|
||||||
|
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
|
||||||
|
(status == TypoStatus::Pending && due <= now)
|
||||||
|
.then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text as a fix sees it: the revision it belongs to, its title and body.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Text<'a> {
|
||||||
|
pub revision: i64,
|
||||||
|
pub title: &'a str,
|
||||||
|
pub body: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fix is written only over the very text it was written over: same revision
|
||||||
|
/// (no change of substance since), same title and body (no other fix since).
|
||||||
|
pub fn fits(base: Text<'_>, current: Text<'_>) -> bool {
|
||||||
|
base == current
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::edit::{EditKind, classify};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_one_who_answers_applies_at_once_anyone_else_waits() {
|
||||||
|
assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text");
|
||||||
|
assert_eq!(route(false), Route::Await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pending_fix_is_validated_or_refused() {
|
||||||
|
for in_force in [false, true] {
|
||||||
|
assert_eq!(
|
||||||
|
answer(TypoStatus::Pending, Answer::Validate, in_force),
|
||||||
|
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
answer(TypoStatus::Pending, Answer::Refuse, in_force),
|
||||||
|
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_author_keeps_the_hand_until_the_text_is_in_force() {
|
||||||
|
// A proposal: an applied fix may be taken back, a refused one validated after all.
|
||||||
|
assert_eq!(
|
||||||
|
answer(TypoStatus::Applied, Answer::Refuse, false),
|
||||||
|
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
answer(TypoStatus::Refused, Answer::Validate, false),
|
||||||
|
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
||||||
|
);
|
||||||
|
// In force: an answer is final.
|
||||||
|
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
|
||||||
|
assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled));
|
||||||
|
// Nothing to answer twice the same way, and never a stale fix.
|
||||||
|
assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled));
|
||||||
|
assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled));
|
||||||
|
for a in [Answer::Validate, Answer::Refuse] {
|
||||||
|
assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_an_answer_it_applies_at_the_deadline() {
|
||||||
|
let due = "2026-11-03T09:00:00Z";
|
||||||
|
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
|
||||||
|
assert_eq!(
|
||||||
|
tick(TypoStatus::Pending, due, due),
|
||||||
|
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
||||||
|
);
|
||||||
|
// A deadline passed while the server was stopped is taken at the next tick.
|
||||||
|
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
|
||||||
|
for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
|
||||||
|
assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_fixes_waiting_on_one_text_the_second_goes_stale() {
|
||||||
|
let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" };
|
||||||
|
let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" };
|
||||||
|
let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" };
|
||||||
|
// Each is judged against the voted text, as before: each is a typo there.
|
||||||
|
assert_eq!(classify(voted.body, first.body), EditKind::Typographic);
|
||||||
|
assert_eq!(classify(voted.body, second.body), EditKind::Typographic);
|
||||||
|
// Both were written over the voted text; the first applies.
|
||||||
|
assert!(fits(voted, voted));
|
||||||
|
// Then the text is the first fix: the second no longer fits — stale, never merged.
|
||||||
|
assert!(!fits(voted, first));
|
||||||
|
// A change of substance in between (a new revision) does the same, even back to the same words.
|
||||||
|
assert!(!fits(voted, Text { revision: 2, ..voted }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_round_trip() {
|
||||||
|
for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
|
||||||
|
assert_eq!(TypoStatus::parse(s.as_str()), Some(s));
|
||||||
|
}
|
||||||
|
assert_eq!(TypoStatus::parse("typo"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ pub mod address;
|
|||||||
pub mod assist;
|
pub mod assist;
|
||||||
pub mod ballot;
|
pub mod ballot;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
|
pub mod correction;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod inertia;
|
pub mod inertia;
|
||||||
pub mod names;
|
pub mod names;
|
||||||
|
pub mod objection;
|
||||||
pub mod order;
|
pub mod order;
|
||||||
pub mod pending;
|
pub mod pending;
|
||||||
pub mod threshold;
|
pub mod threshold;
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
//! Objections — « redit » and « contredit » (Yvv, 2026-10-30, L12).
|
||||||
|
//!
|
||||||
|
//! « Faudrait un "redit" en option avec "contredit" → faut rendre plus explicite
|
||||||
|
//! que l'objection doit être levée par l'auteur de la dernière arrivée pour être
|
||||||
|
//! une proposition votable. Pour ce qui constitue une répétition → modifier
|
||||||
|
//! l'article cible ; pour une contradiction → reformuler. → ping messagerie
|
||||||
|
//! TrustinWallet → Lever l'objection ou Réitérer l'objection ; si pas de réponse
|
||||||
|
//! sous 3j, alors "levée". »
|
||||||
|
//!
|
||||||
|
//! A member objects to a pair of versions (A, B), with a reason: B **repeats** A
|
||||||
|
//! (`Redit`) or B **contradicts** A (`Contredit`). The rules, purely:
|
||||||
|
//!
|
||||||
|
//! - **the latest arrival** of the two (submitted last; same instant: written
|
||||||
|
//! last) is no longer votable while the objection stands — unless it is in
|
||||||
|
//! force: a version in force is never made non-votable, the other one is
|
||||||
|
//! (`target`). Two versions both in force are not an objection: one of them
|
||||||
|
//! is changed by a proposal. The target is fixed when the objection is raised;
|
||||||
|
//! - the votes already cast on it stay, but it cannot enter into force
|
||||||
|
//! (`votable` and the adoption: a non-votable version is blocked);
|
||||||
|
//! - its author **answers**: a substantive revision of it, or a proposal linked
|
||||||
|
//! to the objection (on the article it repeats, for a « redit »; a reworded
|
||||||
|
//! version, for a version without author). A substantive revision of **the
|
||||||
|
//! other side** answers too: the pair changed, the objector judges again
|
||||||
|
//! (`answers`). A typo fix answers nothing;
|
||||||
|
//! - then the **objector** is asked, by a message: lift the objection (the
|
||||||
|
//! version is votable again) or reiterate it (it stands, the author is told
|
||||||
|
//! again). **Without an answer within 3 days, it is lifted** by itself;
|
||||||
|
//! - **one cycle at a time**: while the objector is asked, another answer of the
|
||||||
|
//! author changes nothing, and the deadline does not move;
|
||||||
|
//! - the objector may lift at any time; an administrator of trustinWallet may
|
||||||
|
//! lift too (never reiterate: the objection is not theirs); withdrawing either
|
||||||
|
//! version ends it.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
/// Days the objector has to lift or reiterate once the author answered.
|
||||||
|
pub const ANSWER_DAYS: i64 = 3;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ObjectionKind {
|
||||||
|
/// The latest repeats the other: its author changes the article it repeats.
|
||||||
|
Redit,
|
||||||
|
/// The latest contradicts the other: its author rewords it.
|
||||||
|
Contredit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectionKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Redit => "redit",
|
||||||
|
Self::Contredit => "contredit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::Redit, Self::Contredit].into_iter().find(|k| k.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One version of the pair, as it stands when the objection is raised.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Side<'a> {
|
||||||
|
pub id: &'a str,
|
||||||
|
/// When it arrived (submitted), as a sortable stamp (RFC 3339 UTC).
|
||||||
|
pub arrived_at: &'a str,
|
||||||
|
/// The order it was written in, for versions of the same instant (a seed).
|
||||||
|
pub seq: i64,
|
||||||
|
pub in_force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PairError {
|
||||||
|
/// A version does not object to itself.
|
||||||
|
Same,
|
||||||
|
/// Both are in force: nothing to hold back; a proposal changes one of them.
|
||||||
|
BothInForce,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrival<'a>(s: &Side<'a>) -> (&'a str, i64) {
|
||||||
|
(s.arrived_at, s.seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version an objection makes non-votable: the latest arrival of the two,
|
||||||
|
/// unless it is in force — then the other one.
|
||||||
|
pub fn target<'a>(a: &Side<'a>, b: &Side<'a>) -> Result<&'a str, PairError> {
|
||||||
|
if a.id == b.id {
|
||||||
|
return Err(PairError::Same);
|
||||||
|
}
|
||||||
|
if a.in_force && b.in_force {
|
||||||
|
return Err(PairError::BothInForce);
|
||||||
|
}
|
||||||
|
let latest = match arrival(a).cmp(&arrival(b)) {
|
||||||
|
Ordering::Greater => a,
|
||||||
|
Ordering::Less => b,
|
||||||
|
// Same instant, same rank: the larger id, so that the answer never depends on the order given.
|
||||||
|
Ordering::Equal => {
|
||||||
|
if a.id > b.id {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let other = if latest.id == a.id { b } else { a };
|
||||||
|
Ok(if latest.in_force { other.id } else { latest.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an objection was lifted.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Lift {
|
||||||
|
/// By the person who raised it.
|
||||||
|
Objector,
|
||||||
|
/// By an administrator of trustinWallet.
|
||||||
|
Admin,
|
||||||
|
/// Nobody answered within the days given: lifted by itself.
|
||||||
|
Deadline,
|
||||||
|
/// One of the two versions was withdrawn.
|
||||||
|
Withdrawn,
|
||||||
|
/// A contradiction signal of before 2026-10-31 that lapsed when the text changed.
|
||||||
|
Revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lift {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Objector => "objector",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
Self::Deadline => "deadline",
|
||||||
|
Self::Withdrawn => "withdrawn",
|
||||||
|
Self::Revision => "revision",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::Objector, Self::Admin, Self::Deadline, Self::Withdrawn, Self::Revision]
|
||||||
|
.into_iter()
|
||||||
|
.find(|l| l.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an objection stands. Cycles count from 1: a reiteration opens the next.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Waiting for the author of the target to answer. No deadline: the version
|
||||||
|
/// stays non-votable until the author answers or the objection is lifted.
|
||||||
|
Author {
|
||||||
|
cycle: u32,
|
||||||
|
},
|
||||||
|
/// The author answered: waiting for the objector, until `due` (sortable stamp).
|
||||||
|
Objector {
|
||||||
|
cycle: u32,
|
||||||
|
due: String,
|
||||||
|
},
|
||||||
|
Lifted(Lift),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
pub fn standing(&self) -> bool {
|
||||||
|
!matches!(self, Self::Lifted(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle(&self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
Self::Author { cycle } | Self::Objector { cycle, .. } => Some(*cycle),
|
||||||
|
Self::Lifted(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who acts on an objection.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Who {
|
||||||
|
/// The person who raised it (an administrator who raised it acts as such).
|
||||||
|
Objector,
|
||||||
|
/// An administrator of trustinWallet who did not raise it.
|
||||||
|
Admin,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Event<'a> {
|
||||||
|
/// The author answered (`answers`); `due` = now + `ANSWER_DAYS`.
|
||||||
|
Answered {
|
||||||
|
due: &'a str,
|
||||||
|
},
|
||||||
|
Lift(Who),
|
||||||
|
Reiterate(Who),
|
||||||
|
/// The clock, as a sortable stamp.
|
||||||
|
Tick {
|
||||||
|
now: &'a str,
|
||||||
|
},
|
||||||
|
/// One of the two versions was withdrawn.
|
||||||
|
Withdrawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a step asks the server to do besides storing the phase.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Effect {
|
||||||
|
/// Ask the objector: lift or reiterate, by the deadline of the phase.
|
||||||
|
AskObjector,
|
||||||
|
/// Tell the author of the target (again): answer the objection.
|
||||||
|
TellAuthor,
|
||||||
|
/// The objection ended: every message about it is done.
|
||||||
|
Ended,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Step {
|
||||||
|
pub phase: Phase,
|
||||||
|
pub effects: Vec<Effect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Refusal {
|
||||||
|
/// The objection no longer stands.
|
||||||
|
Lifted,
|
||||||
|
/// Only the objector (or an administrator, to lift) may.
|
||||||
|
NotYours,
|
||||||
|
/// Reiterating answers an answer of the author: there is none yet.
|
||||||
|
NothingToReiterate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of an objection. `Ok(None)`: nothing changes (an answer while the
|
||||||
|
/// objector is already asked — one cycle at a time —, a tick before the
|
||||||
|
/// deadline, an answer once lifted).
|
||||||
|
pub fn step(phase: &Phase, event: &Event<'_>) -> Result<Option<Step>, Refusal> {
|
||||||
|
let ended = |lift| Some(Step { phase: Phase::Lifted(lift), effects: vec![Effect::Ended] });
|
||||||
|
match (phase, event) {
|
||||||
|
(Phase::Lifted(_), Event::Answered { .. } | Event::Tick { .. } | Event::Withdrawn) => Ok(None),
|
||||||
|
(Phase::Lifted(_), Event::Lift(_) | Event::Reiterate(_)) => Err(Refusal::Lifted),
|
||||||
|
(Phase::Author { cycle }, Event::Answered { due }) => Ok(Some(Step {
|
||||||
|
phase: Phase::Objector { cycle: *cycle, due: (*due).to_owned() },
|
||||||
|
effects: vec![Effect::AskObjector],
|
||||||
|
})),
|
||||||
|
(Phase::Objector { .. }, Event::Answered { .. }) => Ok(None),
|
||||||
|
(_, Event::Lift(Who::Objector)) => Ok(ended(Lift::Objector)),
|
||||||
|
(_, Event::Lift(Who::Admin)) => Ok(ended(Lift::Admin)),
|
||||||
|
(_, Event::Lift(Who::Other) | Event::Reiterate(Who::Admin | Who::Other)) => Err(Refusal::NotYours),
|
||||||
|
(Phase::Author { .. }, Event::Reiterate(Who::Objector)) => Err(Refusal::NothingToReiterate),
|
||||||
|
(Phase::Objector { cycle, .. }, Event::Reiterate(Who::Objector)) => {
|
||||||
|
Ok(Some(Step { phase: Phase::Author { cycle: cycle + 1 }, effects: vec![Effect::TellAuthor] }))
|
||||||
|
}
|
||||||
|
(Phase::Objector { due, .. }, Event::Tick { now }) if due.as_str() <= *now => Ok(ended(Lift::Deadline)),
|
||||||
|
(Phase::Author { .. } | Phase::Objector { .. }, Event::Tick { .. }) => Ok(None),
|
||||||
|
(Phase::Author { .. } | Phase::Objector { .. }, Event::Withdrawn) => Ok(ended(Lift::Withdrawn)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which side of the pair a change touches.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Touched {
|
||||||
|
/// The version the objection holds back.
|
||||||
|
Target,
|
||||||
|
/// The version it is said to repeat or contradict.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change made to the pair.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Change {
|
||||||
|
/// A substantive revision (a new revision, votes to reconfirm).
|
||||||
|
Revision(Touched),
|
||||||
|
/// A typo fix: the meaning stays, nothing is answered.
|
||||||
|
Typo(Touched),
|
||||||
|
/// A proposal linked to the objection was submitted (a draft answers nothing).
|
||||||
|
LinkedProposal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this change answer the objection (and ask the objector)?
|
||||||
|
pub fn answers(change: Change) -> bool {
|
||||||
|
match change {
|
||||||
|
Change::Revision(_) | Change::LinkedProposal => true,
|
||||||
|
Change::Typo(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who may answer with a linked proposal: the author of the target; anyone of
|
||||||
|
/// the group when the target has no author (a starting text).
|
||||||
|
pub fn may_link(target_author: Option<&str>, who: &str) -> bool {
|
||||||
|
target_author.is_none_or(|a| a == who)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a linked proposal may go: on the article the target repeats or
|
||||||
|
/// contradicts (« modifier l'article visé »), or on the target's own article (a
|
||||||
|
/// reworded version, for a target without author).
|
||||||
|
pub fn link_fits(unit: &str, target_unit: &str, other_unit: &str) -> bool {
|
||||||
|
unit == other_unit || unit == target_unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a version votable? A version in force always is; any other is not while a
|
||||||
|
/// standing objection holds it back. `held` = the targets of the standing objections.
|
||||||
|
pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
|
||||||
|
in_force || !held.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn side<'a>(id: &'a str, at: &'a str, seq: i64, in_force: bool) -> Side<'a> {
|
||||||
|
Side { id, arrived_at: at, seq, in_force }
|
||||||
|
}
|
||||||
|
|
||||||
|
const T1: &str = "2026-10-30T10:00:00Z";
|
||||||
|
const T2: &str = "2026-10-31T09:00:00Z";
|
||||||
|
const DUE: &str = "2026-11-03T09:00:00Z";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_latest_arrival_is_held_back() {
|
||||||
|
let a = side("a", T1, 5, false);
|
||||||
|
let b = side("b", T2, 3, false);
|
||||||
|
assert_eq!(target(&a, &b), Ok("b"));
|
||||||
|
assert_eq!(target(&b, &a), Ok("b"), "whatever the order given");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_instant_the_one_written_last() {
|
||||||
|
// The versions of a seed share one second: the order they were written in decides.
|
||||||
|
let a = side("zz", T1, 1, false);
|
||||||
|
let b = side("aa", T1, 2, false);
|
||||||
|
assert_eq!(target(&a, &b), Ok("aa"));
|
||||||
|
assert_eq!(target(&b, &a), Ok("aa"));
|
||||||
|
// Same rank too: the larger id, never the order of the arguments.
|
||||||
|
let c = side("c", T1, 7, false);
|
||||||
|
let d = side("d", T1, 7, false);
|
||||||
|
assert_eq!((target(&c, &d), target(&d, &c)), (Ok("d"), Ok("d")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_version_in_force_is_never_held_back() {
|
||||||
|
// The latest arrival is in force (it entered after the other was submitted): the other is held.
|
||||||
|
let older = side("old", T1, 1, false);
|
||||||
|
let in_force = side("inf", T2, 2, true);
|
||||||
|
assert_eq!(target(&older, &in_force), Ok("old"));
|
||||||
|
// The usual case: a new proposal against the text in force.
|
||||||
|
let seed = side("seed", T1, 1, true);
|
||||||
|
let fresh = side("new", T2, 9, false);
|
||||||
|
assert_eq!(target(&seed, &fresh), Ok("new"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_versions_in_force_or_one_alone_are_no_objection() {
|
||||||
|
let a = side("a", T1, 1, true);
|
||||||
|
let b = side("b", T2, 2, true);
|
||||||
|
assert_eq!(target(&a, &b), Err(PairError::BothInForce));
|
||||||
|
assert_eq!(target(&a, &a), Err(PairError::Same));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn votable_unless_held_and_never_in_force() {
|
||||||
|
assert!(votable("x", false, &[]));
|
||||||
|
assert!(!votable("x", false, &["x"]));
|
||||||
|
assert!(votable("y", false, &["x"]));
|
||||||
|
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_full_cycle_answer_reiterate_answer_lift() {
|
||||||
|
let start = Phase::Author { cycle: 1 };
|
||||||
|
assert!(start.standing());
|
||||||
|
// The objector cannot reiterate before the author answered.
|
||||||
|
assert_eq!(step(&start, &Event::Reiterate(Who::Objector)), Err(Refusal::NothingToReiterate));
|
||||||
|
// No deadline while the author has not answered.
|
||||||
|
assert_eq!(step(&start, &Event::Tick { now: "2027-01-01T00:00:00Z" }), Ok(None));
|
||||||
|
let asked = step(&start, &Event::Answered { due: DUE }).expect("ok").expect("changes");
|
||||||
|
assert_eq!(asked.phase, Phase::Objector { cycle: 1, due: DUE.into() });
|
||||||
|
assert_eq!(asked.effects, vec![Effect::AskObjector]);
|
||||||
|
let again = step(&asked.phase, &Event::Reiterate(Who::Objector)).expect("ok").expect("changes");
|
||||||
|
assert_eq!(again.phase, Phase::Author { cycle: 2 });
|
||||||
|
assert_eq!(again.effects, vec![Effect::TellAuthor], "the author is told again");
|
||||||
|
let asked2 = step(&again.phase, &Event::Answered { due: DUE }).expect("ok").expect("changes");
|
||||||
|
assert_eq!(asked2.phase.cycle(), Some(2));
|
||||||
|
let lifted = step(&asked2.phase, &Event::Lift(Who::Objector)).expect("ok").expect("changes");
|
||||||
|
assert_eq!(lifted.phase, Phase::Lifted(Lift::Objector));
|
||||||
|
assert_eq!(lifted.effects, vec![Effect::Ended]);
|
||||||
|
assert!(!lifted.phase.standing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_cycle_at_a_time() {
|
||||||
|
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
|
||||||
|
// A second answer while the objector is asked: nothing changes, the deadline stays.
|
||||||
|
assert_eq!(step(&asked, &Event::Answered { due: "2026-11-09T00:00:00Z" }), Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_an_answer_of_the_objector_it_is_lifted_at_the_deadline() {
|
||||||
|
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
|
||||||
|
assert_eq!(step(&asked, &Event::Tick { now: "2026-11-03T08:59:59Z" }), Ok(None));
|
||||||
|
let done = step(&asked, &Event::Tick { now: DUE }).expect("ok").expect("changes");
|
||||||
|
assert_eq!(done.phase, Phase::Lifted(Lift::Deadline));
|
||||||
|
// A deadline passed while the server was stopped is taken at the next tick.
|
||||||
|
let late = step(&asked, &Event::Tick { now: "2026-12-25T00:00:00Z" }).expect("ok").expect("changes");
|
||||||
|
assert_eq!(late.phase, Phase::Lifted(Lift::Deadline));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_may_lift_and_reiterate() {
|
||||||
|
for phase in [Phase::Author { cycle: 1 }, Phase::Objector { cycle: 1, due: DUE.into() }] {
|
||||||
|
// The objector may change their mind at any time; an administrator may lift.
|
||||||
|
assert_eq!(
|
||||||
|
step(&phase, &Event::Lift(Who::Objector)).expect("ok").map(|s| s.phase),
|
||||||
|
Some(Phase::Lifted(Lift::Objector))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
step(&phase, &Event::Lift(Who::Admin)).expect("ok").map(|s| s.phase),
|
||||||
|
Some(Phase::Lifted(Lift::Admin))
|
||||||
|
);
|
||||||
|
assert_eq!(step(&phase, &Event::Lift(Who::Other)), Err(Refusal::NotYours));
|
||||||
|
// Never reiterate someone else's objection, even as an administrator.
|
||||||
|
assert_eq!(step(&phase, &Event::Reiterate(Who::Admin)), Err(Refusal::NotYours));
|
||||||
|
assert_eq!(step(&phase, &Event::Reiterate(Who::Other)), Err(Refusal::NotYours));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn withdrawing_either_version_ends_it_and_a_lifted_one_stays_lifted() {
|
||||||
|
let w = step(&Phase::Author { cycle: 1 }, &Event::Withdrawn).expect("ok").expect("changes");
|
||||||
|
assert_eq!(w.phase, Phase::Lifted(Lift::Withdrawn));
|
||||||
|
let lifted = Phase::Lifted(Lift::Deadline);
|
||||||
|
assert_eq!(step(&lifted, &Event::Lift(Who::Objector)), Err(Refusal::Lifted));
|
||||||
|
assert_eq!(step(&lifted, &Event::Reiterate(Who::Objector)), Err(Refusal::Lifted));
|
||||||
|
assert_eq!(step(&lifted, &Event::Answered { due: DUE }), Ok(None));
|
||||||
|
assert_eq!(step(&lifted, &Event::Tick { now: DUE }), Ok(None));
|
||||||
|
assert_eq!(step(&lifted, &Event::Withdrawn), Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_answers_an_objection() {
|
||||||
|
assert!(answers(Change::Revision(Touched::Target)), "the author rewords");
|
||||||
|
assert!(answers(Change::Revision(Touched::Other)), "the other side changed: the objector judges again");
|
||||||
|
assert!(answers(Change::LinkedProposal), "the article it repeats is changed");
|
||||||
|
assert!(!answers(Change::Typo(Touched::Target)), "a typo keeps the meaning");
|
||||||
|
assert!(!answers(Change::Typo(Touched::Other)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_answers_with_a_linked_proposal_and_where() {
|
||||||
|
assert!(may_link(Some("chiara"), "chiara"));
|
||||||
|
assert!(!may_link(Some("chiara"), "yvv"));
|
||||||
|
assert!(may_link(None, "yvv"), "a starting text has no author: anyone of the group");
|
||||||
|
assert!(link_fits("other-unit", "target-unit", "other-unit"), "the article it repeats");
|
||||||
|
assert!(link_fits("target-unit", "target-unit", "other-unit"), "a reworded version");
|
||||||
|
assert!(!link_fits("third", "target-unit", "other-unit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_round_trip() {
|
||||||
|
for k in [ObjectionKind::Redit, ObjectionKind::Contredit] {
|
||||||
|
assert_eq!(ObjectionKind::parse(k.as_str()), Some(k));
|
||||||
|
}
|
||||||
|
for l in [Lift::Objector, Lift::Admin, Lift::Deadline, Lift::Withdrawn, Lift::Revision] {
|
||||||
|
assert_eq!(Lift::parse(l.as_str()), Some(l));
|
||||||
|
}
|
||||||
|
assert_eq!(ObjectionKind::parse("signal"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- Objections « redit » / « contredit », typo fixes validated, and the messages
|
||||||
|
-- of trustinWallet (2026-10-31, lot F, ADR 0010). Additive only: nothing is
|
||||||
|
-- dropped, nothing is rewritten that an older image needs.
|
||||||
|
--
|
||||||
|
-- The contradiction signals of before become objections « contredit », in the
|
||||||
|
-- same table: an older image (rollback) still reads them as signals, and every
|
||||||
|
-- lifting still writes `lifted_at` / `lifted_by` as before.
|
||||||
|
|
||||||
|
ALTER TABLE signals ADD COLUMN kind TEXT NOT NULL DEFAULT 'contredit'; -- redit | contredit
|
||||||
|
ALTER TABLE signals ADD COLUMN held_id TEXT; -- the version held back (not votable): the latest arrival, never one in force
|
||||||
|
ALTER TABLE signals ADD COLUMN phase TEXT NOT NULL DEFAULT 'author'; -- author | objector | lifted
|
||||||
|
ALTER TABLE signals ADD COLUMN cycle INTEGER NOT NULL DEFAULT 1; -- a reiteration opens the next
|
||||||
|
ALTER TABLE signals ADD COLUMN due_at TEXT; -- phase objector: lifted by itself at this time
|
||||||
|
ALTER TABLE signals ADD COLUMN answered_at TEXT; -- the last answer of the author
|
||||||
|
ALTER TABLE signals ADD COLUMN answer TEXT; -- revision | other | proposal
|
||||||
|
ALTER TABLE signals ADD COLUMN answer_id TEXT; -- the version revised, or the proposal linked
|
||||||
|
ALTER TABLE signals ADD COLUMN lift TEXT; -- objector | admin | deadline | withdrawn | revision
|
||||||
|
|
||||||
|
-- The signals of before that were lifted stay lifted: by their author, by an
|
||||||
|
-- administrator, or because the text changed (the rule of before).
|
||||||
|
UPDATE signals SET phase = 'lifted', lift = CASE
|
||||||
|
WHEN lifted_by = 'revision' THEN 'revision'
|
||||||
|
WHEN lifted_by = author_id THEN 'objector'
|
||||||
|
ELSE 'admin' END
|
||||||
|
WHERE lifted_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- The standing ones wait for the author of the version they now hold back: the
|
||||||
|
-- latest arrival of the two (submitted last; the same instant: written last),
|
||||||
|
-- unless it is in force — then the other. Both in force: none is held.
|
||||||
|
UPDATE signals SET held_id = (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN a.status = 'in_force' AND b.status = 'in_force' THEN NULL
|
||||||
|
WHEN (IFNULL(a.submitted_at, a.created_at), a.rowid, a.id) > (IFNULL(b.submitted_at, b.created_at), b.rowid, b.id)
|
||||||
|
THEN CASE WHEN a.status = 'in_force' THEN b.id ELSE a.id END
|
||||||
|
ELSE CASE WHEN b.status = 'in_force' THEN a.id ELSE b.id END
|
||||||
|
END
|
||||||
|
FROM proposals a, proposals b
|
||||||
|
WHERE a.id = signals.proposal_id AND b.id = signals.target_proposal_id)
|
||||||
|
WHERE lifted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX signals_proposal ON signals(proposal_id);
|
||||||
|
CREATE INDEX signals_target ON signals(target_proposal_id);
|
||||||
|
CREATE INDEX signals_due ON signals(phase, due_at);
|
||||||
|
|
||||||
|
-- A proposal written to answer an objection (« modifier l'article visé »).
|
||||||
|
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
|
||||||
|
|
||||||
|
-- A typo fix written by someone other than the one who answers for the version:
|
||||||
|
-- it waits for them (or for the deadline), then applies — or not.
|
||||||
|
CREATE TABLE typos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
writer_id TEXT NOT NULL REFERENCES identities(id), -- who wrote the fix
|
||||||
|
answerer_id TEXT, -- the author of the version, who answers; NULL = administrators (no author)
|
||||||
|
revision INTEGER NOT NULL, -- the revision it was written over
|
||||||
|
base_title TEXT NOT NULL, -- the text it was written over
|
||||||
|
base_body TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL, -- the fix
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- pending | applied | refused | stale
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
due_at TEXT NOT NULL, -- applied by itself then, if nobody answered
|
||||||
|
settled_at TEXT,
|
||||||
|
settled_by TEXT -- identity id, 'deadline', or 'changed' (stale)
|
||||||
|
);
|
||||||
|
CREATE INDEX typos_proposal ON typos(proposal_id, status);
|
||||||
|
CREATE INDEX typos_due ON typos(status, due_at);
|
||||||
|
|
||||||
|
-- The revision a validated fix wrote, or the one that took it back (kind 'revert'),
|
||||||
|
-- and who let it in: the identity that validated it, or 'deadline'.
|
||||||
|
ALTER TABLE revisions ADD COLUMN typo_id TEXT;
|
||||||
|
ALTER TABLE revisions ADD COLUMN answered_by TEXT;
|
||||||
|
|
||||||
|
-- The messages of trustinWallet: one person, one box. The text is told by the
|
||||||
|
-- interface from the subject (in the language of the reader); the gestures
|
||||||
|
-- follow the subject's state, so a message dealt with offers none.
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
recipient_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
topic TEXT NOT NULL, -- objection | typo
|
||||||
|
subject_id TEXT NOT NULL, -- signals.id | typos.id
|
||||||
|
kind TEXT NOT NULL, -- objection_raised | objection_reiterated | objection_answered | typo_pending
|
||||||
|
cycle INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
due_at TEXT,
|
||||||
|
read_at TEXT,
|
||||||
|
done_at TEXT,
|
||||||
|
outcome TEXT -- answered | lifted | reiterated | deadline | admin | withdrawn | applied | refused | stale
|
||||||
|
);
|
||||||
|
CREATE INDEX messages_box ON messages(recipient_id, created_at);
|
||||||
|
CREATE INDEX messages_subject ON messages(topic, subject_id);
|
||||||
@@ -206,7 +206,8 @@ fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> se
|
|||||||
|
|
||||||
/// The electorate changes (the web of trust grows or shrinks): every few
|
/// The electorate changes (the web of trust grows or shrinks): every few
|
||||||
/// minutes, every document is passed through the engine again — after the
|
/// minutes, every document is passed through the engine again — after the
|
||||||
/// usual participation of each group is measured again, once a day.
|
/// usual participation of each group is measured again, once a day, and after
|
||||||
|
/// the deadlines of three days are passed (`deadlines`).
|
||||||
pub async fn sweeper(state: AppState) {
|
pub async fn sweeper(state: AppState) {
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
||||||
loop {
|
loop {
|
||||||
@@ -214,6 +215,10 @@ pub async fn sweeper(state: AppState) {
|
|||||||
if let Err(e) = crate::participation::refresh(&state, false).await {
|
if let Err(e) = crate::participation::refresh(&state, false).await {
|
||||||
tracing::warn!(error = %e, "usual participation");
|
tracing::warn!(error = %e, "usual participation");
|
||||||
}
|
}
|
||||||
|
// Objections lifted and typo fixes applied at their deadline (three days).
|
||||||
|
if let Err(e) = crate::deadlines::sweep(&state).await {
|
||||||
|
tracing::warn!(error = %e, "deadlines");
|
||||||
|
}
|
||||||
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+276
-39
@@ -15,9 +15,12 @@
|
|||||||
//! - **The last one stands**, per proposal and per identity, in the order of
|
//! - **The last one stands**, per proposal and per identity, in the order of
|
||||||
//! the chain: a ballot confirmed late never overrides a more recent one.
|
//! the chain: a ballot confirmed late never overrides a more recent one.
|
||||||
//!
|
//!
|
||||||
//! A background task reads the indexer for pending ballots every 10 seconds
|
//! A background task reads the indexer for pending ballots every 10 seconds,
|
||||||
//! while there are some, lets them expire after 24 hours, and runs the
|
//! at most 50 a pass, the least recently read first: every pending ballot is
|
||||||
//! adoption pass after each confirmation.
|
//! read at least every ⌈n / 50⌉ passes, whether a page waits for it or not (a
|
||||||
|
//! Ğecko code). A ballot is read one last time at its end (24 hours), then let
|
||||||
|
//! go; the adoption pass runs after each confirmation. Ballots let go are purged
|
||||||
|
//! after a while (a day for those the wallet never signed, a week for the others).
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::adoption::{self, Entered};
|
use crate::adoption::{self, Entered};
|
||||||
@@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading};
|
|||||||
use crate::util;
|
use crate::util;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderValue, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
use ld_engine::address;
|
use ld_engine::address;
|
||||||
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -40,15 +45,26 @@ use trustin_access::VoteWay;
|
|||||||
|
|
||||||
/// Every article of a long document, with room to spare.
|
/// Every article of a long document, with room to spare.
|
||||||
const MAX_LINES: usize = 200;
|
const MAX_LINES: usize = 200;
|
||||||
/// Pending ballots one session may hold at once.
|
/// Ballots one identity may hold at once: those pending, and those let go within
|
||||||
|
/// the last hour (`DELETE`, the wallet did not sign) — whatever the session.
|
||||||
const MAX_PENDING: i64 = 20;
|
const MAX_PENDING: i64 = 20;
|
||||||
|
const DROPPED_WINDOW_SECS: i64 = 3600;
|
||||||
/// A ballot not seen on the chain within this time is let go.
|
/// A ballot not seen on the chain within this time is let go.
|
||||||
pub const EXPIRES_HOURS: i64 = 24;
|
pub const EXPIRES_HOURS: i64 = 24;
|
||||||
|
/// At its end, a ballot no indexer could be read for — or whose comment waits
|
||||||
|
/// for a final block — is kept and read again, this long at most.
|
||||||
|
const GRACE_SECS: i64 = 3600;
|
||||||
|
/// Let go, a ballot is purged, lines and all: a day after the wallet did not sign
|
||||||
|
/// it, a week after it was not seen in time (under the article, it says so meanwhile).
|
||||||
|
const PURGE_DROPPED_SECS: i64 = 24 * 3600;
|
||||||
|
const PURGE_EXPIRED_SECS: i64 = 7 * 24 * 3600;
|
||||||
/// The page asks often; the indexer is read at most this often per ballot.
|
/// The page asks often; the indexer is read at most this often per ballot.
|
||||||
const RECHECK_SECS: i64 = 3;
|
const RECHECK_SECS: i64 = 3;
|
||||||
const WATCH_EVERY: Duration = Duration::from_secs(10);
|
const WATCH_EVERY: Duration = Duration::from_secs(10);
|
||||||
/// Pending ballots read per pass of the background task.
|
/// Pending ballots read per pass of the background task, the least recently read first.
|
||||||
const PER_PASS: i64 = 50;
|
const PER_PASS: i64 = 50;
|
||||||
|
/// Ballots a history shows, the most recent first.
|
||||||
|
const HISTORY: i64 = 200;
|
||||||
|
|
||||||
/// One confirmation at a time: « the last one stands » reads the other ballots of
|
/// One confirmation at a time: « the last one stands » reads the other ballots of
|
||||||
/// the identity, then writes; two at once could both read before either writes.
|
/// the identity, then writes; two at once could both read before either writes.
|
||||||
@@ -104,6 +120,10 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
|||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
return Err(AppError::Conflict("not_votable"));
|
||||||
}
|
}
|
||||||
|
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010).
|
||||||
|
if !st.votable(p) {
|
||||||
|
return Err(AppError::Conflict("objection"));
|
||||||
|
}
|
||||||
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
|
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -226,14 +246,16 @@ pub async fn create(
|
|||||||
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
|
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let pending: i64 = sqlx::query_scalar(
|
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
||||||
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
|
let held: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ballots WHERE requested_by = ?
|
||||||
|
AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))",
|
||||||
)
|
)
|
||||||
.bind(&user.id)
|
.bind(&user.id)
|
||||||
.bind(util::now())
|
.bind(util::in_seconds(-DROPPED_WINDOW_SECS))
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if pending >= MAX_PENDING {
|
if held >= MAX_PENDING {
|
||||||
return Err(AppError::Conflict("too_many_ballots"));
|
return Err(AppError::Conflict("too_many_ballots"));
|
||||||
}
|
}
|
||||||
let lines: Vec<ballot::Line> = casts
|
let lines: Vec<ballot::Line> = casts
|
||||||
@@ -333,6 +355,160 @@ pub async fn drop(
|
|||||||
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The receipt: the ballot itself, for the one who asked — never anyone else
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A title as stored (`{lang: text}`), as JSON; a bare text if it is not a map.
|
||||||
|
fn title_json(raw: &str) -> serde_json::Value {
|
||||||
|
serde_json::from_str::<serde_json::Value>(raw).ok().filter(|v| v.is_object()).unwrap_or_else(|| json!(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/ballots` — the history of the person's ballots, the most recent
|
||||||
|
/// first: those of its identity only, and not those the wallet never signed.
|
||||||
|
pub async fn mine(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: String,
|
||||||
|
doc_id: String,
|
||||||
|
group_slug: String,
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
digest: String,
|
||||||
|
status: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
block: Option<i64>,
|
||||||
|
voter: Option<String>,
|
||||||
|
lines: i64,
|
||||||
|
created_at: String,
|
||||||
|
settled_at: Option<String>,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(&format!(
|
||||||
|
"SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block,
|
||||||
|
i.display_name AS voter, (SELECT COUNT(*) FROM ballot_lines l WHERE l.ballot_id = b.id) AS lines,
|
||||||
|
b.created_at, b.settled_at
|
||||||
|
FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id
|
||||||
|
WHERE b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
||||||
|
ORDER BY b.created_at DESC, b.rowid DESC LIMIT {HISTORY}"
|
||||||
|
))
|
||||||
|
.bind(&user.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let list: Vec<serde_json::Value> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) },
|
||||||
|
"comment": format!("{}{}", ballot::PREFIX, r.digest), "status": r.status, "reason": r.reason,
|
||||||
|
"block": r.block, "voter": r.voter, "lines": r.lines, "createdAt": r.created_at, "settledAt": r.settled_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "ballots": list })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/ballots/{id}/receipt` — the ballot itself, to download: its lines,
|
||||||
|
/// its salt, the canonical text they make, its seal and where it stands. With
|
||||||
|
/// it, the person recomputes the seal and finds it on the chain; nobody else
|
||||||
|
/// ever gets it (the chain shows who voted, never what).
|
||||||
|
pub async fn receipt(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Response> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Extra {
|
||||||
|
salt: String,
|
||||||
|
signer: Option<String>,
|
||||||
|
event_id: Option<String>,
|
||||||
|
settled_at: Option<String>,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct LineRow {
|
||||||
|
proposal_id: String,
|
||||||
|
revision: i64,
|
||||||
|
choice: String,
|
||||||
|
code: String,
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
let row = load(&state, &id).await?;
|
||||||
|
if row.requested_by != user.id {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let extra = sqlx::query_as::<_, Extra>(
|
||||||
|
"SELECT b.salt, b.signer, b.event_id, b.settled_at, d.title FROM ballots b JOIN documents d ON d.id = b.doc_id
|
||||||
|
WHERE b.id = ?",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
let lines = sqlx::query_as::<_, LineRow>(
|
||||||
|
"SELECT l.proposal_id, l.revision, l.choice, u.code, p.title FROM ballot_lines l
|
||||||
|
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||||
|
WHERE l.ballot_id = ? ORDER BY l.proposal_id",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let sealed: Vec<ballot::Line> = lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| {
|
||||||
|
Some(ballot::Line {
|
||||||
|
proposal_id: l.proposal_id.clone(),
|
||||||
|
revision: u32::try_from(l.revision).ok()?,
|
||||||
|
choice: Choice::parse(&l.choice)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let canonical = ballot::canonical(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||||
|
let seal = ballot::seal(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||||
|
if seal.digest != row.digest || sealed.len() != lines.len() {
|
||||||
|
return Err(anyhow::anyhow!("ballot {id}: its lines no longer make its seal").into());
|
||||||
|
}
|
||||||
|
let voter: Option<String> = match &row.voter_id {
|
||||||
|
Some(v) => {
|
||||||
|
sqlx::query_scalar("SELECT display_name FROM identities WHERE id = ?")
|
||||||
|
.bind(v)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let body = json!({
|
||||||
|
"format": "ld1",
|
||||||
|
"id": row.id,
|
||||||
|
"doc": row.doc_id,
|
||||||
|
"docTitle": title_json(&extra.title),
|
||||||
|
"author": row.author,
|
||||||
|
"lines": lines.iter().map(|l| json!({
|
||||||
|
"proposalId": l.proposal_id, "revision": l.revision, "choice": l.choice, "article": l.code, "title": l.title,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"salt": extra.salt,
|
||||||
|
"canonical": canonical,
|
||||||
|
"digest": seal.digest,
|
||||||
|
"comment": seal.comment,
|
||||||
|
"remarkHash": seal.remark_hash,
|
||||||
|
"account": row.account,
|
||||||
|
"amount": row.amount,
|
||||||
|
"status": row.status,
|
||||||
|
"reason": row.reason,
|
||||||
|
"block": row.block,
|
||||||
|
"event": extra.event_id,
|
||||||
|
"signer": extra.signer,
|
||||||
|
"voter": voter,
|
||||||
|
"createdAt": row.created_at,
|
||||||
|
"settledAt": extra.settled_at,
|
||||||
|
});
|
||||||
|
let day = row.created_at.get(..10).unwrap_or("vote");
|
||||||
|
let name = format!("recu-vote-{day}-{}.json", row.id.get(..8).unwrap_or(&row.id));
|
||||||
|
let mut res = Json(body).into_response();
|
||||||
|
let headers = res.headers_mut();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&format!("attachment; filename=\"{name}\"")) {
|
||||||
|
headers.insert(header::CONTENT_DISPOSITION, v);
|
||||||
|
}
|
||||||
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
||||||
let voter: Option<String> = match &row.voter_id {
|
let voter: Option<String> = match &row.voter_id {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
@@ -352,29 +528,47 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value>
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the indexer for one pending ballot and settles it if the chain has spoken.
|
/// Reads the indexer for one pending ballot and settles it if the chain has spoken
|
||||||
|
/// — in a final block. At its end, a ballot is read one last time, then let go.
|
||||||
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||||
let now = util::now();
|
let now = util::now();
|
||||||
if row.expires_at <= now {
|
let due = row.expires_at <= now;
|
||||||
expire(state).await?;
|
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let reading = indexer::comments(state, &row.remark_hash).await;
|
|
||||||
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
|
|
||||||
let Reading::Found(list) = reading else {
|
|
||||||
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let comment = row.comment();
|
|
||||||
let list: Vec<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
|
|
||||||
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
|
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
|
||||||
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
|
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
|
||||||
};
|
};
|
||||||
let expected =
|
let expected =
|
||||||
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
|
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
|
||||||
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
|
let comment = row.comment();
|
||||||
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
|
// Only the comments that carry exactly the remark of the ballot.
|
||||||
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
|
let judged = |list: &[Found]| {
|
||||||
|
let mine: Vec<ballot::Comment> =
|
||||||
|
list.iter().filter(|f| f.remark == comment).map(|f| f.comment.clone()).collect();
|
||||||
|
ballot::judge(&mine, &expected)
|
||||||
|
};
|
||||||
|
let filter = indexer::filter(&row.account, amount, row.author.as_deref());
|
||||||
|
let reading = indexer::comments(state, &row.remark_hash, &filter, |list| judged(list).is_some()).await;
|
||||||
|
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
|
||||||
|
let (list, unfinal) = match reading {
|
||||||
|
Reading::Found { comments, unfinal } => (comments, unfinal),
|
||||||
|
Reading::Silent => {
|
||||||
|
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
|
||||||
|
if past_grace {
|
||||||
|
let_go(state, row).await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some((position, verdict)) = judged(&list) else {
|
||||||
|
// Nothing final settles it. At its end: let go, unless a comment waits for its block to be final.
|
||||||
|
if due && (!unfinal || past_grace) {
|
||||||
|
let_go(state, row).await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(found) = list.iter().find(|f| f.comment.position == position && f.remark == comment) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
match verdict {
|
match verdict {
|
||||||
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
||||||
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
|
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
|
||||||
@@ -516,25 +710,59 @@ async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lets go of the pending ballots not seen on the chain in time.
|
/// Lets go of one pending ballot not seen on the chain in time.
|
||||||
|
async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||||
|
sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&row.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets go of the pending ballots past their end and the grace after it: those
|
||||||
|
/// the indexer could not be read for in time.
|
||||||
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
||||||
let now = util::now();
|
|
||||||
let done = sqlx::query(
|
let done = sqlx::query(
|
||||||
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
||||||
)
|
)
|
||||||
.bind(&now)
|
.bind(util::now())
|
||||||
.bind(&now)
|
.bind(util::in_seconds(-GRACE_SECS))
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(done.rows_affected())
|
Ok(done.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pass of the background task: expire, then read the indexer for each
|
/// Purges the ballots let go long enough ago, lines and all. A ballot let go
|
||||||
/// pending ballot, oldest first. Returns how many were read.
|
/// never counted: nothing of a vote is lost.
|
||||||
|
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
||||||
|
const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL
|
||||||
|
AND ((reason = 'dropped' AND settled_at < ?) OR settled_at < ?)";
|
||||||
|
let (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS));
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})"))
|
||||||
|
.bind(&dropped)
|
||||||
|
.bind(&expired)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let done = sqlx::query(&format!("DELETE FROM ballots WHERE id IN ({WHICH})"))
|
||||||
|
.bind(&dropped)
|
||||||
|
.bind(&expired)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(done.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass of the background task: let go and purge, then read the indexer for
|
||||||
|
/// the pending ballots, the least recently read first (never read, then the
|
||||||
|
/// oldest reading). Returns how many were read.
|
||||||
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||||
expire(state).await?;
|
expire(state).await?;
|
||||||
|
purge(state).await?;
|
||||||
let rows = sqlx::query_as::<_, BallotRow>(&format!(
|
let rows = sqlx::query_as::<_, BallotRow>(&format!(
|
||||||
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
|
"{BALLOT_SELECT} WHERE status = 'pending'
|
||||||
|
ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}"
|
||||||
))
|
))
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -600,9 +828,10 @@ pub struct MyBallot {
|
|||||||
pub choice: String,
|
pub choice: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per version: the most recent pending ballot of `me`; else its most recent
|
/// Per version: the most recent pending ballot of `me` asked after its last
|
||||||
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
|
/// confirmed one; else its most recent ballot if that one was refused or let go
|
||||||
/// A ballot the wallet never signed (dropped) says nothing.
|
/// (a confirmed one shows as its vote). A ballot the wallet never signed
|
||||||
|
/// (dropped) says nothing; one asked before a confirmed vote says nothing either.
|
||||||
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -626,12 +855,20 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
|
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
|
||||||
if b.status == "pending" {
|
match b.status.as_str() {
|
||||||
pending.insert(r.proposal_id.clone(), b.clone());
|
// A confirmed vote: what was asked before it says nothing more.
|
||||||
|
"confirmed" => {
|
||||||
|
latest.remove(&r.proposal_id);
|
||||||
|
pending.remove(&r.proposal_id);
|
||||||
|
}
|
||||||
|
"pending" => {
|
||||||
|
pending.insert(r.proposal_id, b);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
latest.insert(r.proposal_id, b);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
latest.insert(r.proposal_id, b);
|
|
||||||
}
|
}
|
||||||
latest.retain(|_, b| b.status != "confirmed");
|
|
||||||
latest.extend(pending);
|
latest.extend(pending);
|
||||||
Ok(latest)
|
Ok(latest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! The clock of the deadlines (objections, typo fixes): the time of the
|
||||||
|
//! machine, plus an offset that only tests move — the API tests directly, the
|
||||||
|
//! end-to-end tests through `POST /api/dev/clock`, open with the dev login only.
|
||||||
|
//! A deadline is never waited for with a sleep.
|
||||||
|
|
||||||
|
use crate::util;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Clock {
|
||||||
|
offset_secs: Arc<AtomicI64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clock {
|
||||||
|
pub fn at(&self) -> OffsetDateTime {
|
||||||
|
OffsetDateTime::now_utc() + Duration::seconds(self.offset_secs.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Now, as a sortable stamp (RFC 3339 UTC, whole seconds).
|
||||||
|
pub fn now(&self) -> String {
|
||||||
|
util::stamp(self.at())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn in_days(&self, days: i64) -> String {
|
||||||
|
util::stamp(self.at() + Duration::days(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the clock forward (tests only).
|
||||||
|
pub fn advance(&self, secs: i64) {
|
||||||
|
self.offset_secs.fetch_add(secs, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset(&self) -> i64 {
|
||||||
|
self.offset_secs.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advancing_moves_now_and_the_deadlines() {
|
||||||
|
let c = Clock::default();
|
||||||
|
let before = c.now();
|
||||||
|
c.advance(3 * 86_400);
|
||||||
|
assert!(c.now() > before);
|
||||||
|
assert!(c.in_days(0) >= c.now());
|
||||||
|
assert_eq!(c.offset(), 3 * 86_400);
|
||||||
|
// Clones share the offset: the server and its tasks read one clock.
|
||||||
|
let other = c.clone();
|
||||||
|
other.advance(1);
|
||||||
|
assert_eq!(c.offset(), 3 * 86_400 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! The deadlines of three days (2026-10-31, lot F): an objection whose author
|
||||||
|
//! answered and whose objector said nothing is lifted; a typo fix nobody
|
||||||
|
//! answered applies. Run by the adoption sweeper (every 5 minutes) and once at
|
||||||
|
//! start, so a deadline passed while the server was stopped is dealt with as
|
||||||
|
//! soon as it starts again. Tests move the clock (`AppState::clock`), never sleep.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::adoption;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::{objections, typos};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Swept {
|
||||||
|
/// Objections lifted at their deadline.
|
||||||
|
pub lifted: usize,
|
||||||
|
/// Typo fixes applied at their deadline.
|
||||||
|
pub applied: usize,
|
||||||
|
/// Typo fixes that could not apply: their text was gone.
|
||||||
|
pub stale: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass: the objections, then the typo fixes, then the adoption of the
|
||||||
|
/// documents where an objection was lifted (a version may now enter into force).
|
||||||
|
pub async fn sweep(state: &AppState) -> AppResult<Swept> {
|
||||||
|
let (lifted, docs) = objections::due(state).await?;
|
||||||
|
let fixes = typos::due(state).await?;
|
||||||
|
for doc in &docs {
|
||||||
|
adoption::run(state, doc).await?;
|
||||||
|
}
|
||||||
|
Ok(Swept { lifted, applied: fixes.applied, stale: fixes.stale })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ClockBody {
|
||||||
|
seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/dev/clock` — the end-to-end tests move the server's clock forward
|
||||||
|
/// and pass the deadlines at once. Open with the dev login only, like it.
|
||||||
|
pub async fn dev_clock(State(state): State<AppState>, Json(b): Json<ClockBody>) -> AppResult<Json<Value>> {
|
||||||
|
if !state.cfg.dev_login {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
if b.seconds <= 0 || b.seconds > 400 * 86_400 {
|
||||||
|
return Err(AppError::Invalid("seconds"));
|
||||||
|
}
|
||||||
|
state.clock.advance(b.seconds);
|
||||||
|
let swept = sweep(&state).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"now": state.clock.now(), "offset": state.clock.offset(),
|
||||||
|
"lifted": swept.lifted, "applied": swept.applied, "stale": swept.stale,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does this identity open the group now (see `trustin_access::opens`)?
|
||||||
|
pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||||
|
let has = granted(state, &group.slug, &user.id).await?;
|
||||||
|
Ok(trustin_access::opens(&user.to_identity(), &group.to_group(), has))
|
||||||
|
}
|
||||||
|
|
||||||
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||||
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||||
let has = granted(state, &group.slug, &user.id).await?;
|
let has = granted(state, &group.slug, &user.id).await?;
|
||||||
@@ -259,7 +265,9 @@ pub struct DocState {
|
|||||||
pub module_kinds: HashMap<String, ModuleKind>,
|
pub module_kinds: HashMap<String, ModuleKind>,
|
||||||
pub proposals: Vec<ProposalRow>,
|
pub proposals: Vec<ProposalRow>,
|
||||||
pub tallies: HashMap<String, Tally>,
|
pub tallies: HashMap<String, Tally>,
|
||||||
|
/// Versions held back by a standing objection: not votable, never entering into force.
|
||||||
pub blocked: HashSet<String>,
|
pub blocked: HashSet<String>,
|
||||||
|
/// Standing objections touching each version, either side.
|
||||||
pub signal_counts: HashMap<String, i64>,
|
pub signal_counts: HashMap<String, i64>,
|
||||||
pub electorate: u32,
|
pub electorate: u32,
|
||||||
pub electorate_source: &'static str,
|
pub electorate_source: &'static str,
|
||||||
@@ -344,32 +352,9 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
// A standing objection holds back the latest arrival of its pair (never a version
|
||||||
struct SignalRow {
|
// in force): not votable, and it cannot enter into force (ADR 0010).
|
||||||
proposal_id: String,
|
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
|
||||||
target_proposal_id: String,
|
|
||||||
}
|
|
||||||
let signals = sqlx::query_as::<_, SignalRow>(
|
|
||||||
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
|
|
||||||
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(doc_id)
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?;
|
|
||||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
|
||||||
let mut blocked = HashSet::new();
|
|
||||||
let mut signal_counts: HashMap<String, i64> = HashMap::new();
|
|
||||||
for s in &signals {
|
|
||||||
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
|
|
||||||
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
|
|
||||||
// Two texts that contradict each other never stand in force together.
|
|
||||||
if in_force.contains(s.target_proposal_id.as_str()) {
|
|
||||||
blocked.insert(s.proposal_id.clone());
|
|
||||||
}
|
|
||||||
if in_force.contains(s.proposal_id.as_str()) {
|
|
||||||
blocked.insert(s.target_proposal_id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (electorate, electorate_source) = electorate(state, &group).await?;
|
let (electorate, electorate_source) = electorate(state, &group).await?;
|
||||||
let measure = participation::stored(state, &group.slug).await?;
|
let measure = participation::stored(state, &group.slug).await?;
|
||||||
let participation = Participation::of(
|
let participation = Participation::of(
|
||||||
@@ -410,6 +395,25 @@ impl DocState {
|
|||||||
self.tallies.get(id).copied().unwrap_or_default()
|
self.tallies.get(id).copied().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The totals shown to people: today the live count, as `tally`. The one
|
||||||
|
/// place where a publication by steps would plug in — on the chain, who
|
||||||
|
/// voted and when is public, and live totals tell what (the secret of the
|
||||||
|
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
|
||||||
|
/// `tally`; the published texts (`publish.rs`) would follow this one.
|
||||||
|
pub fn shown_tally(&self, id: &str) -> Tally {
|
||||||
|
self.tally(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the threshold is, as shown: on `shown_tally`.
|
||||||
|
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||||
|
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open to a vote: open or in force, and not held back by an objection.
|
||||||
|
pub fn votable(&self, p: &ProposalRow) -> bool {
|
||||||
|
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
|
||||||
|
}
|
||||||
|
|
||||||
/// The module a unit is, if it is one.
|
/// The module a unit is, if it is one.
|
||||||
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
||||||
self.module_kinds.get(unit_id).copied()
|
self.module_kinds.get(unit_id).copied()
|
||||||
@@ -627,7 +631,7 @@ fn proposal_json(
|
|||||||
me: &str,
|
me: &str,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let (mine, ballot) = mine;
|
let (mine, ballot) = mine;
|
||||||
let eval = st.evaluation(unit, p);
|
let eval = st.shown_evaluation(unit, p);
|
||||||
let (kind, target) = match p.kind() {
|
let (kind, target) = match p.kind() {
|
||||||
// A module's version says what it holds (ADR 0009).
|
// A module's version says what it holds (ADR 0009).
|
||||||
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
|
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
|
||||||
@@ -646,11 +650,12 @@ fn proposal_json(
|
|||||||
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||||
"tally": st.tally(&p.id),
|
"tally": st.shown_tally(&p.id),
|
||||||
"evaluation": eval,
|
"evaluation": eval,
|
||||||
"votedAtInertia": voted_at,
|
"votedAtInertia": voted_at,
|
||||||
"blocked": st.blocked.contains(&p.id),
|
"blocked": st.blocked.contains(&p.id),
|
||||||
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
"votable": st.votable(p),
|
||||||
|
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||||
"myVote": mine.map(|v| v.choice.as_str()),
|
"myVote": mine.map(|v| v.choice.as_str()),
|
||||||
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
||||||
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
|
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
|
||||||
|
|||||||
+200
-39
@@ -7,6 +7,25 @@
|
|||||||
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
|
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
|
||||||
//! errors, is skipped; one that answers « nothing » may be behind, so the next
|
//! errors, is skipped; one that answers « nothing » may be behind, so the next
|
||||||
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
|
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
|
||||||
|
//!
|
||||||
|
//! **Finality.** The indexer serves the best chain, blocks not yet final
|
||||||
|
//! included. `squidStatus` exposes `height` and `hash` only (no
|
||||||
|
//! `finalizedHeight`); `height` is the last final block it processed — relevé
|
||||||
|
//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block
|
||||||
|
//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the
|
||||||
|
//! finality of Duniter v2. Only a comment at or below it settles a ballot: a
|
||||||
|
//! comment above may still vanish with its block.
|
||||||
|
//!
|
||||||
|
//! **What is asked.** Only what may settle the ballot: for a ballot bound to an
|
||||||
|
//! account, that account's comments; for one bound to none (a Ğecko code), the
|
||||||
|
//! comments paid to the vote account — a bare copy of a remark costs nothing to
|
||||||
|
//! send, and must never hide the real one. The engine judges again what comes
|
||||||
|
//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes
|
||||||
|
//! nothing but the number of pages.
|
||||||
|
//!
|
||||||
|
//! **Pages.** In the order of the chain, 50 comments a page, page after page
|
||||||
|
//! until a comment settles the ballot, a block not yet final is reached, or the
|
||||||
|
//! last page — at most 20 pages a reading.
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use ld_engine::address;
|
use ld_engine::address;
|
||||||
@@ -15,11 +34,19 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
|
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
|
||||||
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
|
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
|
||||||
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
|
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
|
||||||
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
|
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \
|
||||||
|
nodes { id blockNumber remark author { id identity { index name isMember } } \
|
||||||
|
transfersByCommentId { nodes { fromId toId amount } } } } }";
|
||||||
|
|
||||||
|
/// Pages read at most for one ballot, one reading.
|
||||||
|
pub const MAX_PAGES: usize = 20;
|
||||||
|
/// The largest answer read: a page of 50 comments weighs about 30 KB.
|
||||||
|
const MAX_BODY: usize = 1 << 20;
|
||||||
|
/// A Ğ1 identity name is 42 characters at most.
|
||||||
|
const MAX_NAME: usize = 64;
|
||||||
const TIMEOUT: Duration = Duration::from_secs(6);
|
const TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -32,7 +59,28 @@ struct Answer {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct Data {
|
struct Data {
|
||||||
tx_comments: Nodes<Node>,
|
squid_status: Status,
|
||||||
|
tx_comments: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Status {
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Connection {
|
||||||
|
#[serde(default)]
|
||||||
|
page_info: Option<PageInfo>,
|
||||||
|
nodes: Vec<Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PageInfo {
|
||||||
|
has_next_page: bool,
|
||||||
|
end_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -85,12 +133,23 @@ pub struct Found {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One page of an answer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Page {
|
||||||
|
/// The last final block (`squidStatus.height`).
|
||||||
|
pub finalized: u32,
|
||||||
|
pub comments: Vec<Found>,
|
||||||
|
/// The cursor of the next page, if there is one.
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Reading {
|
pub enum Reading {
|
||||||
/// No indexer answered.
|
/// No indexer answered.
|
||||||
Silent,
|
Silent,
|
||||||
/// The comments found (possibly none).
|
/// What an indexer answered: the comments in final blocks, in the order of the
|
||||||
Found(Vec<Found>),
|
/// chain (possibly none), and whether others wait in blocks not yet final.
|
||||||
|
Found { comments: Vec<Found>, unfinal: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
fn amount(v: &serde_json::Value) -> Option<u64> {
|
fn amount(v: &serde_json::Value) -> Option<u64> {
|
||||||
@@ -106,6 +165,9 @@ fn found(node: Node) -> Option<Found> {
|
|||||||
if position.block != node.block_number {
|
if position.block != node.block_number {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let (_, author) = address::decode(&node.author.id)?;
|
let (_, author) = address::decode(&node.author.id)?;
|
||||||
let transfers = node
|
let transfers = node
|
||||||
.transfers_by_comment_id
|
.transfers_by_comment_id
|
||||||
@@ -133,59 +195,127 @@ fn found(node: Node) -> Option<Found> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comments of one answer of the indexer; `None` if it is not one.
|
/// One page of an answer of the indexer; `None` if it is not one.
|
||||||
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
|
pub fn parse(body: &serde_json::Value) -> Option<Page> {
|
||||||
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
||||||
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let nodes = answer.data?.tx_comments.nodes;
|
let data = answer.data?;
|
||||||
|
let nodes = data.tx_comments.nodes;
|
||||||
let count = nodes.len();
|
let count = nodes.len();
|
||||||
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
||||||
if out.len() != count {
|
if comments.len() != count {
|
||||||
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
|
tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside");
|
||||||
}
|
}
|
||||||
Some(out)
|
let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor);
|
||||||
|
Some(Page { finalized: data.squid_status.height, comments, next })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comments carrying the remark of hash `remark_hash` (`0x…`).
|
/// What may settle a ballot, asked of the indexer: the comments of the bound
|
||||||
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
|
/// account; unbound, those paid to the vote account — at least the amount.
|
||||||
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
|
pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value {
|
||||||
|
match author {
|
||||||
|
Some(a) => json!({ "authorId": { "equalTo": a } }),
|
||||||
|
None => json!({ "transfersByCommentId": { "some": {
|
||||||
|
"toId": { "equalTo": account },
|
||||||
|
"amount": { "greaterThanOrEqualTo": amount.to_string() },
|
||||||
|
} } }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a body of at most `MAX_BODY` bytes as JSON.
|
||||||
|
async fn read_json(mut res: reqwest::Response) -> Option<serde_json::Value> {
|
||||||
|
if res.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
while let Some(chunk) = res.chunk().await.ok()? {
|
||||||
|
if buf.len() + chunk.len() > MAX_BODY {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&buf).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page(state: &AppState, url: &str, body: &serde_json::Value) -> Option<Page> {
|
||||||
|
let res = state.http.post(url).timeout(TIMEOUT).json(body).send().await;
|
||||||
|
let value = match res {
|
||||||
|
Ok(r) if r.status().is_success() => read_json(r).await,
|
||||||
|
Ok(r) => {
|
||||||
|
tracing::debug!(%url, status = %r.status(), "indexer");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(%url, error = %e, "indexer");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
value.as_ref().and_then(parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comments in final blocks that carry the remark of hash `remark_hash`
|
||||||
|
/// (`0x…`) and pass `filter`, page after page until `settled` says one settles
|
||||||
|
/// the ballot.
|
||||||
|
pub async fn comments(
|
||||||
|
state: &AppState,
|
||||||
|
remark_hash: &str,
|
||||||
|
filter: &serde_json::Value,
|
||||||
|
settled: impl Fn(&[Found]) -> bool,
|
||||||
|
) -> Reading {
|
||||||
let mut answered = false;
|
let mut answered = false;
|
||||||
for url in &state.cfg.indexers {
|
for url in &state.cfg.indexers {
|
||||||
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
|
let mut kept: Vec<Found> = Vec::new();
|
||||||
let value: Option<serde_json::Value> = match res {
|
let mut unfinal = false;
|
||||||
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
let mut after: Option<String> = None;
|
||||||
Ok(r) => {
|
let mut finalized: Option<u32> = None;
|
||||||
tracing::debug!(%url, status = %r.status(), "indexer");
|
for n in 0..MAX_PAGES {
|
||||||
None
|
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } });
|
||||||
|
let Some(p) = page(state, url, &body).await else { break };
|
||||||
|
// The finality of the first page holds for the reading: later pages may only see more.
|
||||||
|
let last_final = *finalized.get_or_insert(p.finalized);
|
||||||
|
answered = true;
|
||||||
|
for f in p.comments {
|
||||||
|
if f.comment.position.block > last_final {
|
||||||
|
unfinal = true;
|
||||||
|
} else {
|
||||||
|
kept.push(f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
if unfinal || settled(&kept) {
|
||||||
tracing::debug!(%url, error = %e, "indexer");
|
break;
|
||||||
None
|
|
||||||
}
|
}
|
||||||
};
|
match p.next {
|
||||||
match value.as_ref().and_then(parse) {
|
Some(cursor) => after = Some(cursor),
|
||||||
Some(list) if !list.is_empty() => return Reading::Found(list),
|
None => break,
|
||||||
Some(_) => answered = true,
|
}
|
||||||
None => {}
|
if n + 1 == MAX_PAGES {
|
||||||
|
tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !kept.is_empty() || unfinal {
|
||||||
|
return Reading::Found { comments: kept, unfinal };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
|
if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
|
/// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), for the remark « Merci ».
|
||||||
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
|
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_real_answer_reads() {
|
fn a_real_answer_reads() {
|
||||||
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
||||||
let list = parse(&body).expect("an answer");
|
let page = parse(&body).expect("an answer");
|
||||||
assert_eq!(list.len(), 4);
|
assert_eq!(page.finalized, 3_407_492);
|
||||||
|
assert_eq!(page.next, None, "one page: 26 comments, fewer than 50");
|
||||||
|
let list = page.comments;
|
||||||
|
assert_eq!(list.len(), 26, "the old query stopped at 20");
|
||||||
let first = &list[0];
|
let first = &list[0];
|
||||||
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
||||||
assert_eq!(first.remark, "Merci ");
|
assert_eq!(first.remark, "Merci ");
|
||||||
@@ -198,6 +328,8 @@ mod tests {
|
|||||||
// An account without identity.
|
// An account without identity.
|
||||||
assert_eq!(list[2].comment.identity, None);
|
assert_eq!(list[2].comment.identity, None);
|
||||||
assert_eq!(list[2].name, None);
|
assert_eq!(list[2].name, None);
|
||||||
|
// In the order of the chain.
|
||||||
|
assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -205,19 +337,48 @@ mod tests {
|
|||||||
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
||||||
assert!(parse(&json!({ "data": null })).is_none());
|
assert!(parse(&json!({ "data": null })).is_none());
|
||||||
assert!(parse(&json!({ "nope": 1 })).is_none());
|
assert!(parse(&json!({ "nope": 1 })).is_none());
|
||||||
assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0));
|
// Without the finality, an answer cannot settle anything: no answer.
|
||||||
|
assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none());
|
||||||
|
let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } });
|
||||||
|
assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None)));
|
||||||
|
let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": {
|
||||||
|
"pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } });
|
||||||
|
assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_node_that_does_not_read_is_left_aside() {
|
fn a_node_that_does_not_read_is_left_aside() {
|
||||||
let body = json!({ "data": { "txComments": { "nodes": [
|
let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
|
||||||
|
let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [
|
||||||
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
|
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
|
||||||
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
|
"author": { "id": author, "identity": null },
|
||||||
"transfersByCommentId": { "nodes": [] } },
|
"transfersByCommentId": { "nodes": [] } },
|
||||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||||
"author": { "id": "not an address", "identity": null },
|
"author": { "id": "not an address", "identity": null },
|
||||||
"transfersByCommentId": { "nodes": [] } },
|
"transfersByCommentId": { "nodes": [] } },
|
||||||
|
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||||
|
"author": { "id": "z".repeat(100_000), "identity": null },
|
||||||
|
"transfersByCommentId": { "nodes": [] } },
|
||||||
|
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||||
|
"author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } },
|
||||||
|
"transfersByCommentId": { "nodes": [] } },
|
||||||
] } } });
|
] } } });
|
||||||
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
|
assert_eq!(
|
||||||
|
parse(&body).map(|p| p.comments.len()),
|
||||||
|
Some(0),
|
||||||
|
"block and id disagree; a bad address; a long one; a name no identity has"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_is_asked_is_what_may_settle() {
|
||||||
|
let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM";
|
||||||
|
let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
|
||||||
|
assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } }));
|
||||||
|
assert_eq!(
|
||||||
|
filter(vote, 10, None),
|
||||||
|
json!({ "transfersByCommentId": { "some": {
|
||||||
|
"toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,27 @@
|
|||||||
//!
|
//!
|
||||||
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
||||||
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
||||||
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
|
//! web of trust), the adoption sweeper (with the deadlines of three days of
|
||||||
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
|
//! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain,
|
||||||
//! that client applications read).
|
//! confirmed on the indexer) and the publisher (adopted texts pushed to the git
|
||||||
|
//! repository that client applications read).
|
||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod adoption;
|
pub mod adoption;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod ballots;
|
pub mod ballots;
|
||||||
|
pub mod clock;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod deadlines;
|
||||||
pub mod doc_title;
|
pub mod doc_title;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod indexer;
|
pub mod indexer;
|
||||||
pub mod inertia_amend;
|
pub mod inertia_amend;
|
||||||
|
pub mod messages;
|
||||||
pub mod module_amend;
|
pub mod module_amend;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
|
pub mod objections;
|
||||||
pub mod order;
|
pub mod order;
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
pub mod participation;
|
pub mod participation;
|
||||||
@@ -26,6 +31,7 @@ pub mod publish;
|
|||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod seed_amend;
|
pub mod seed_amend;
|
||||||
pub mod team;
|
pub mod team;
|
||||||
|
pub mod typos;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
pub mod wot;
|
pub mod wot;
|
||||||
@@ -54,6 +60,8 @@ pub struct AppState {
|
|||||||
pub publish_wake: Arc<Notify>,
|
pub publish_wake: Arc<Notify>,
|
||||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||||
pub upcoming: Arc<overview::Upcoming>,
|
pub upcoming: Arc<overview::Upcoming>,
|
||||||
|
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
||||||
|
pub clock: clock::Clock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -63,7 +71,14 @@ impl AppState {
|
|||||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||||
.build()?;
|
.build()?;
|
||||||
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
|
Ok(Self {
|
||||||
|
db,
|
||||||
|
cfg: Arc::new(cfg),
|
||||||
|
http,
|
||||||
|
publish_wake: Arc::new(Notify::new()),
|
||||||
|
upcoming,
|
||||||
|
clock: clock::Clock::default(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +140,25 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/docs/{id}/proposals", post(proposals::create))
|
.route("/docs/{id}/proposals", post(proposals::create))
|
||||||
.route("/docs/{id}/votes", post(ballots::vote_many))
|
.route("/docs/{id}/votes", post(ballots::vote_many))
|
||||||
.route("/docs/{id}/ballots", post(ballots::create))
|
.route("/docs/{id}/ballots", post(ballots::create))
|
||||||
|
.route("/ballots", get(ballots::mine))
|
||||||
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
||||||
|
.route("/ballots/{id}/receipt", get(ballots::receipt))
|
||||||
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
||||||
.route("/proposals/{id}/submit", post(proposals::submit))
|
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||||
.route("/proposals/{id}/split", post(proposals::split))
|
.route("/proposals/{id}/split", post(proposals::split))
|
||||||
.route("/proposals/{id}/vote", post(proposals::vote))
|
.route("/proposals/{id}/vote", post(proposals::vote))
|
||||||
.route("/proposals/{id}/signals", post(proposals::signal))
|
.route("/proposals/{id}/objections", post(objections::raise))
|
||||||
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||||
.route("/signals/{id}/lift", post(proposals::lift_signal))
|
.route("/objections/{id}/lift", post(objections::lift))
|
||||||
|
.route("/objections/{id}/reiterate", post(objections::reiterate))
|
||||||
|
.route("/typos/{id}/validate", post(typos::validate))
|
||||||
|
.route("/typos/{id}/refuse", post(typos::refuse))
|
||||||
|
.route("/messages", get(messages::list))
|
||||||
|
.route("/messages/unread", get(messages::unread))
|
||||||
|
.route("/messages/read", post(messages::read_all))
|
||||||
|
.route("/messages/{id}/read", post(messages::read))
|
||||||
|
.route("/dev/clock", post(deadlines::dev_clock))
|
||||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||||
.route("/units/{id}/order", post(order::propose))
|
.route("/units/{id}/order", post(order::propose))
|
||||||
.route("/units/{id}/title", post(doc_title::propose))
|
.route("/units/{id}/title", post(doc_title::propose))
|
||||||
@@ -216,6 +241,12 @@ pub async fn run() -> anyhow::Result<()> {
|
|||||||
tracing::warn!(error = %e, "usual participation");
|
tracing::warn!(error = %e, "usual participation");
|
||||||
}
|
}
|
||||||
auth::bootstrap(&state).await?;
|
auth::bootstrap(&state).await?;
|
||||||
|
// A deadline passed while the server was stopped is dealt with now.
|
||||||
|
match deadlines::sweep(&state).await {
|
||||||
|
Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "deadlines"),
|
||||||
|
}
|
||||||
if let Err(e) = publish::ensure_key(&state).await {
|
if let Err(e) = publish::ensure_key(&state).await {
|
||||||
tracing::warn!(error = %e, "publication key");
|
tracing::warn!(error = %e, "publication key");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the
|
||||||
|
//! application, in the menu, with the number of unread messages — no mail.
|
||||||
|
//!
|
||||||
|
//! A message is about a subject (an objection, a typo fix) and carries the
|
||||||
|
//! gestures that subject asks of its recipient now — lift or reiterate,
|
||||||
|
//! validate or refuse — and its deadline. A message dealt with offers no
|
||||||
|
//! gesture any more. Its text is told by the interface, in the language of the
|
||||||
|
//! reader, from the subject. Each person reads their own messages only
|
||||||
|
//! (`trustin_access::reads_message`); acting on a subject marks one's messages
|
||||||
|
//! about it as read.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::{IdentityRow, User};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::{docs, objections, typos, util};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// The most recent messages shown in the box.
|
||||||
|
const SHOWN: i64 = 100;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Topic {
|
||||||
|
Objection,
|
||||||
|
Typo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Topic {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Objection => "objection",
|
||||||
|
Self::Typo => "typo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a message in someone's box.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn notify(
|
||||||
|
state: &AppState,
|
||||||
|
recipient: &str,
|
||||||
|
doc_id: &str,
|
||||||
|
topic: Topic,
|
||||||
|
subject_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
cycle: i64,
|
||||||
|
due: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(util::id())
|
||||||
|
.bind(recipient)
|
||||||
|
.bind(doc_id)
|
||||||
|
.bind(topic.as_str())
|
||||||
|
.bind(subject_id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(cycle)
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(due)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages about a subject (of some kinds, or all) are dealt with: what became of it.
|
||||||
|
pub async fn close(
|
||||||
|
state: &AppState,
|
||||||
|
topic: Topic,
|
||||||
|
subject_id: &str,
|
||||||
|
kinds: Option<&[&str]>,
|
||||||
|
outcome: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let now = state.clock.now();
|
||||||
|
match kinds {
|
||||||
|
None => {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(topic.as_str())
|
||||||
|
.bind(subject_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(kinds) => {
|
||||||
|
for kind in kinds {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE messages SET done_at = ?, outcome = ?
|
||||||
|
WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(topic.as_str())
|
||||||
|
.bind(subject_id)
|
||||||
|
.bind(kind)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone acted on a subject: their messages about it are read.
|
||||||
|
pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(recipient)
|
||||||
|
.bind(topic.as_str())
|
||||||
|
.bind(subject_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unread_of(state: &AppState, recipient: &str) -> AppResult<i64> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL")
|
||||||
|
.bind(recipient)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct MessageRow {
|
||||||
|
id: String,
|
||||||
|
recipient_id: String,
|
||||||
|
doc_id: String,
|
||||||
|
topic: String,
|
||||||
|
subject_id: String,
|
||||||
|
kind: String,
|
||||||
|
cycle: i64,
|
||||||
|
created_at: String,
|
||||||
|
due_at: Option<String>,
|
||||||
|
read_at: Option<String>,
|
||||||
|
done_at: Option<String>,
|
||||||
|
outcome: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DocLine {
|
||||||
|
group_slug: String,
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
|
||||||
|
let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?")
|
||||||
|
.bind(&m.doc_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
let Some(doc) = doc else { return Ok(None) };
|
||||||
|
// A message tells what is in a group: shown only while its recipient opens it.
|
||||||
|
if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (subject, gestures) = match m.topic.as_str() {
|
||||||
|
"objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? {
|
||||||
|
Some((v, g)) => (json!({ "objection": v }), g),
|
||||||
|
None => return Ok(None),
|
||||||
|
},
|
||||||
|
"typo" => match typos::for_message(state, me, &m.subject_id).await? {
|
||||||
|
Some((v, g)) => (json!({ "typo": v }), g),
|
||||||
|
None => return Ok(None),
|
||||||
|
},
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
let mut out = json!({
|
||||||
|
"id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at,
|
||||||
|
"dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(),
|
||||||
|
"outcome": m.outcome, "gestures": gestures,
|
||||||
|
"doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) },
|
||||||
|
});
|
||||||
|
if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) {
|
||||||
|
o.extend(s);
|
||||||
|
}
|
||||||
|
Ok(Some(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/messages` — one's own box, newest first.
|
||||||
|
pub async fn list(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||||
|
let rows = sqlx::query_as::<_, MessageRow>(
|
||||||
|
"SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome
|
||||||
|
FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&me.id)
|
||||||
|
.bind(SHOWN)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for m in &rows {
|
||||||
|
if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(v) = view(&state, &me, m).await? {
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/messages/unread` — the number next to « Messages » in the menu.
|
||||||
|
pub async fn unread(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/messages/{id}/read` — one's own message only (404 otherwise).
|
||||||
|
pub async fn read(State(state): State<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||||
|
let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
if !trustin_access::reads_message(&me.to_identity(), &recipient) {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL")
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/messages/read` — every message of one's box.
|
||||||
|
pub async fn read_all(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||||
|
sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL")
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(&me.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "unread": 0 })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
|
||||||
|
//!
|
||||||
|
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
|
||||||
|
//! the pair, never a version in force, is no longer votable while the objection
|
||||||
|
//! stands; its author answers (a substantive revision, or a proposal linked to
|
||||||
|
//! the objection — a revision of the other side answers too); the objector is
|
||||||
|
//! then asked, by a message, to lift or reiterate; without an answer within 3
|
||||||
|
//! days the objection is lifted (`due`, run by the sweeper and at start).
|
||||||
|
//!
|
||||||
|
//! Objections live in the table of the contradiction signals of before
|
||||||
|
//! (`signals`, migration 0008): an older image still reads them as signals, and
|
||||||
|
//! every lifting writes `lifted_at` / `lifted_by` as it did.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::adoption;
|
||||||
|
use crate::auth::{IdentityRow, User};
|
||||||
|
use crate::docs::{self, DocState, UnitRow};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::messages::{self, Topic};
|
||||||
|
use crate::proposals;
|
||||||
|
use crate::util;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use ld_engine::edit;
|
||||||
|
use ld_engine::objection::{
|
||||||
|
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
pub const MIN_REASON: usize = 10;
|
||||||
|
pub const MAX_REASON: usize = 600;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct ObjectionRow {
|
||||||
|
pub id: String,
|
||||||
|
/// The version the objection was raised from.
|
||||||
|
pub proposal_id: String,
|
||||||
|
/// The version it was raised against.
|
||||||
|
pub target_proposal_id: String,
|
||||||
|
pub reason: String,
|
||||||
|
/// The objector.
|
||||||
|
pub author_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub lifted_at: Option<String>,
|
||||||
|
pub lifted_by: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
/// The version held back (not votable).
|
||||||
|
pub held_id: Option<String>,
|
||||||
|
pub phase: String,
|
||||||
|
pub cycle: i64,
|
||||||
|
pub due_at: Option<String>,
|
||||||
|
pub answered_at: Option<String>,
|
||||||
|
pub answer: Option<String>,
|
||||||
|
pub answer_id: Option<String>,
|
||||||
|
pub lift: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
|
||||||
|
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
|
||||||
|
|
||||||
|
impl ObjectionRow {
|
||||||
|
pub fn phase(&self) -> Phase {
|
||||||
|
let cycle = u32::try_from(self.cycle).unwrap_or(1);
|
||||||
|
match self.phase.as_str() {
|
||||||
|
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
|
||||||
|
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
|
||||||
|
_ => Phase::Author { cycle },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> ObjectionKind {
|
||||||
|
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version the held one is said to repeat or contradict.
|
||||||
|
pub fn other_id(&self) -> &str {
|
||||||
|
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
|
||||||
|
&self.target_proposal_id
|
||||||
|
} else {
|
||||||
|
&self.proposal_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touches(&self, id: &str) -> Option<Touched> {
|
||||||
|
if self.held_id.as_deref() == Some(id) {
|
||||||
|
Some(Touched::Target)
|
||||||
|
} else if self.proposal_id == id || self.target_proposal_id == id {
|
||||||
|
Some(Touched::Other)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
|
||||||
|
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standing objections touching one version, either side.
|
||||||
|
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||||
|
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
|
||||||
|
))
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For a document: the versions held back by a standing objection (never one in
|
||||||
|
/// force), and how many standing objections touch each version.
|
||||||
|
pub async fn held(
|
||||||
|
state: &AppState,
|
||||||
|
doc_id: &str,
|
||||||
|
units: &[UnitRow],
|
||||||
|
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
|
||||||
|
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||||
|
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
|
||||||
|
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
|
||||||
|
))
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||||
|
let mut held = HashSet::new();
|
||||||
|
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||||
|
for o in &rows {
|
||||||
|
*counts.entry(o.proposal_id.clone()).or_default() += 1;
|
||||||
|
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
|
||||||
|
if let Some(h) = &o.held_id
|
||||||
|
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
|
||||||
|
{
|
||||||
|
held.insert(h.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((held, counts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order a version arrived in: its submission (or creation), then the order it was written in.
|
||||||
|
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
|
||||||
|
let seq: i64 =
|
||||||
|
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
|
||||||
|
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_in_force(st: &DocState, id: &str) -> bool {
|
||||||
|
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn who(user: &IdentityRow, objector: &str) -> Who {
|
||||||
|
let me = user.to_identity();
|
||||||
|
if trustin_access::reiterates_objection(&me, objector) {
|
||||||
|
Who::Objector
|
||||||
|
} else if trustin_access::lifts_objection(&me, objector) {
|
||||||
|
Who::Admin
|
||||||
|
} else {
|
||||||
|
Who::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(r: Refusal) -> AppError {
|
||||||
|
match r {
|
||||||
|
Refusal::Lifted => AppError::Conflict("lifted"),
|
||||||
|
Refusal::NotYours => AppError::Forbidden,
|
||||||
|
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
|
||||||
|
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
|
||||||
|
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a step, only over the phase it was computed from (two people acting at
|
||||||
|
/// once: the second gets a conflict), then does its effects. Returns false when
|
||||||
|
/// the phase had changed meanwhile.
|
||||||
|
async fn store(
|
||||||
|
state: &AppState,
|
||||||
|
o: &ObjectionRow,
|
||||||
|
step: &Step,
|
||||||
|
by: &str,
|
||||||
|
extra: Option<(&str, &str)>,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
let now = state.clock.now();
|
||||||
|
let (phase, cycle, due, lift) = match &step.phase {
|
||||||
|
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
|
||||||
|
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
|
||||||
|
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
|
||||||
|
};
|
||||||
|
let (answer, answer_id) =
|
||||||
|
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
|
||||||
|
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
|
||||||
|
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
|
||||||
|
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
|
||||||
|
WHERE id = ? AND phase = ? AND cycle = ?",
|
||||||
|
)
|
||||||
|
.bind(phase)
|
||||||
|
.bind(cycle)
|
||||||
|
.bind(&due)
|
||||||
|
.bind(lift.map(Lift::as_str))
|
||||||
|
.bind(&answer)
|
||||||
|
.bind(&answer_id)
|
||||||
|
.bind(&answered_at)
|
||||||
|
.bind(phase)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(phase)
|
||||||
|
.bind(by)
|
||||||
|
.bind(&o.id)
|
||||||
|
.bind(&o.phase)
|
||||||
|
.bind(o.cycle)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if done.rows_affected() != 1 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let doc_id = doc_of(state, &o.proposal_id).await?;
|
||||||
|
let held_author = match &o.held_id {
|
||||||
|
Some(h) => author_of(state, h).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
for effect in &step.effects {
|
||||||
|
match effect {
|
||||||
|
Effect::AskObjector => {
|
||||||
|
// The author's question of this cycle is answered.
|
||||||
|
messages::close(
|
||||||
|
state,
|
||||||
|
Topic::Objection,
|
||||||
|
&o.id,
|
||||||
|
Some(&["objection_raised", "objection_reiterated"]),
|
||||||
|
"answered",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if held_author.as_deref() != Some(o.author_id.as_str()) {
|
||||||
|
messages::notify(
|
||||||
|
state,
|
||||||
|
&o.author_id,
|
||||||
|
&doc_id,
|
||||||
|
Topic::Objection,
|
||||||
|
&o.id,
|
||||||
|
"objection_answered",
|
||||||
|
cycle,
|
||||||
|
due.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::TellAuthor => {
|
||||||
|
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
|
||||||
|
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
|
||||||
|
messages::notify(
|
||||||
|
state,
|
||||||
|
author,
|
||||||
|
&doc_id,
|
||||||
|
Topic::Objection,
|
||||||
|
&o.id,
|
||||||
|
"objection_reiterated",
|
||||||
|
cycle,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::Ended => {
|
||||||
|
let outcome = match lift {
|
||||||
|
Some(Lift::Admin) => "admin",
|
||||||
|
Some(Lift::Deadline) => "deadline",
|
||||||
|
Some(Lift::Withdrawn) => "withdrawn",
|
||||||
|
_ => "lifted",
|
||||||
|
};
|
||||||
|
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raise, lift, reiterate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RaiseBody {
|
||||||
|
other_id: String,
|
||||||
|
kind: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
|
||||||
|
/// contradicts) that one ». The latest arrival of the two is held back.
|
||||||
|
pub async fn raise(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<RaiseBody>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (p, st) = proposals::guarded(&state, &user, &id).await?;
|
||||||
|
let reason = b.reason.trim();
|
||||||
|
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
||||||
|
return Err(AppError::Invalid("reason"));
|
||||||
|
}
|
||||||
|
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
|
||||||
|
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
|
||||||
|
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
|
||||||
|
if other.id == p.id || !voted(other) || !voted(&p) {
|
||||||
|
return Err(AppError::Invalid("other"));
|
||||||
|
}
|
||||||
|
if other.unit_id == p.unit_id {
|
||||||
|
// Rivals on one article never stand together anyway.
|
||||||
|
return Err(AppError::Invalid("same_unit"));
|
||||||
|
}
|
||||||
|
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
|
||||||
|
let held = objection::target(
|
||||||
|
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
|
||||||
|
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
|
||||||
|
)
|
||||||
|
.map_err(|e| match e {
|
||||||
|
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
|
||||||
|
objection::PairError::Same => AppError::Invalid("other"),
|
||||||
|
})?
|
||||||
|
.to_owned();
|
||||||
|
let exists: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
|
||||||
|
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
|
||||||
|
)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(&other.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if exists > 0 {
|
||||||
|
return Err(AppError::Conflict("objection_exists"));
|
||||||
|
}
|
||||||
|
let oid = util::id();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
||||||
|
)
|
||||||
|
.bind(&oid)
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(&other.id)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(kind.as_str())
|
||||||
|
.bind(&held)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
|
||||||
|
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
|
||||||
|
}
|
||||||
|
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
|
||||||
|
Ok(Json(json!({ "id": oid, "heldId": held })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
|
||||||
|
let o = load(state, id).await?;
|
||||||
|
proposals::guarded(state, user, &o.proposal_id).await?;
|
||||||
|
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
|
||||||
|
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
|
||||||
|
if !store(state, &o, &step, &user.id, None).await? {
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
|
||||||
|
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
|
||||||
|
Ok(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
|
||||||
|
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||||
|
let o = act(&state, &user, &id, true).await?;
|
||||||
|
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
|
||||||
|
Ok(Json(json!({ "entered": entered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
|
||||||
|
pub async fn reiterate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
act(&state, &user, &id, false).await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// What answers an objection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A version changed (by `by`): the objections around it whose author it
|
||||||
|
/// answers move on; the messages of the one who answered are read.
|
||||||
|
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> {
|
||||||
|
for o in standing_around(state, proposal_id).await? {
|
||||||
|
let Some(side) = o.touches(proposal_id) else { continue };
|
||||||
|
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
|
||||||
|
if !objection::answers(change) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||||
|
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
|
||||||
|
let answer = if side == Touched::Target { "revision" } else { "other" };
|
||||||
|
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
|
||||||
|
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// May this person write a proposal linked to this objection, on this article?
|
||||||
|
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
|
||||||
|
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
|
||||||
|
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
|
||||||
|
let unit_of = |id: String| async move {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
|
||||||
|
let author = author_of(state, held).await?;
|
||||||
|
if !o.phase().standing()
|
||||||
|
|| !objection::may_link(author.as_deref(), &user.id)
|
||||||
|
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
|
||||||
|
{
|
||||||
|
return Err(AppError::Invalid("link"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposal linked to an objection was submitted (by `by`): it answers the objection.
|
||||||
|
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||||
|
let o = load(state, objection_id).await?;
|
||||||
|
if !objection::answers(Change::LinkedProposal) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||||
|
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)?
|
||||||
|
&& store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?
|
||||||
|
{
|
||||||
|
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A version was withdrawn: the objections around it end.
|
||||||
|
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||||
|
for o in standing_around(state, proposal_id).await? {
|
||||||
|
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
|
||||||
|
store(state, &o, &step, by, None).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The objections whose deadline passed are lifted by themselves; returns how
|
||||||
|
/// many, and the documents touched.
|
||||||
|
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
|
||||||
|
let now = state.clock.now();
|
||||||
|
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
|
||||||
|
.bind(&now)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut docs = Vec::new();
|
||||||
|
let mut lifted = 0;
|
||||||
|
for o in rows {
|
||||||
|
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
|
||||||
|
&& store(state, &o, &step, "deadline", None).await?
|
||||||
|
{
|
||||||
|
tracing::info!(objection = %o.id, "objection lifted at its deadline");
|
||||||
|
lifted += 1;
|
||||||
|
let doc = doc_of(state, &o.proposal_id).await?;
|
||||||
|
if !docs.contains(&doc) {
|
||||||
|
docs.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((lifted, docs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// What the screen shows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn version_json(st: &DocState, id: &str) -> Value {
|
||||||
|
let p = st.proposals.iter().find(|p| p.id == id);
|
||||||
|
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||||
|
json!({
|
||||||
|
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
|
||||||
|
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
|
||||||
|
"authorId": p.and_then(|p| p.author_id.as_ref()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||||
|
let row: Option<(String, bool)> =
|
||||||
|
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
|
||||||
|
/// it stands, and what the viewer may do.
|
||||||
|
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
|
||||||
|
let (objector, invited) = name_of(state, &o.author_id).await?;
|
||||||
|
let phase = o.phase();
|
||||||
|
let held_author = match &o.held_id {
|
||||||
|
Some(h) => author_of(state, h).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let me = viewer.to_identity();
|
||||||
|
let standing = phase.standing();
|
||||||
|
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
|
||||||
|
let asked = matches!(phase, Phase::Objector { .. });
|
||||||
|
Ok(json!({
|
||||||
|
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
|
||||||
|
"mine": o.author_id == viewer.id, "at": o.created_at,
|
||||||
|
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
|
||||||
|
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
|
||||||
|
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
|
||||||
|
"other": version_json(st, o.other_id()),
|
||||||
|
"iAnswer": i_answer,
|
||||||
|
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
|
||||||
|
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The objections around one version, newest first, each as `viewer` sees it;
|
||||||
|
/// `held` tells whether this version is the one held back.
|
||||||
|
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
|
||||||
|
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||||
|
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
|
||||||
|
))
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for o in rows {
|
||||||
|
let mut v = view(state, st, viewer, &o).await?;
|
||||||
|
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An objection, for a message of its box: what it is about, and the gestures
|
||||||
|
/// the message offers now (none once dealt with).
|
||||||
|
pub async fn for_message(
|
||||||
|
state: &AppState,
|
||||||
|
viewer: &IdentityRow,
|
||||||
|
subject_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
cycle: i64,
|
||||||
|
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||||
|
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
|
||||||
|
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
|
||||||
|
let v = view(state, &st, viewer, &o).await?;
|
||||||
|
let current = o.cycle == cycle;
|
||||||
|
let gestures = match (o.phase(), kind) {
|
||||||
|
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
|
||||||
|
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
|
||||||
|
vec!["lift", "reiterate"]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
Ok(Some((v, gestures)))
|
||||||
|
}
|
||||||
@@ -137,10 +137,12 @@ pub async fn decisions(
|
|||||||
/// order of each section (ADR 0009): the version in force (adopted by a vote,
|
/// order of each section (ADR 0009): the version in force (adopted by a vote,
|
||||||
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
||||||
/// on one of them. A change of inertia is not a version of the text; the order
|
/// on one of them. A change of inertia is not a version of the text; the order
|
||||||
/// of a section of fewer than two articles has nothing to decide.
|
/// of a section of fewer than two articles has nothing to decide; a version held
|
||||||
|
/// by an objection is nobody's to vote while it is held (ADR 0010).
|
||||||
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct VersionRow {
|
struct VersionRow {
|
||||||
|
id: String,
|
||||||
unit_id: String,
|
unit_id: String,
|
||||||
in_force: bool,
|
in_force: bool,
|
||||||
adopted: bool,
|
adopted: bool,
|
||||||
@@ -154,7 +156,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
|||||||
.map(|u| u.id.clone())
|
.map(|u| u.id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let rows = sqlx::query_as::<_, VersionRow>(
|
let rows = sqlx::query_as::<_, VersionRow>(
|
||||||
"SELECT u.id AS unit_id,
|
"SELECT p.id AS id, u.id AS unit_id,
|
||||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||||
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
||||||
EXISTS (SELECT 1 FROM votes v
|
EXISTS (SELECT 1 FROM votes v
|
||||||
@@ -179,7 +181,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
|||||||
if let Some(m) = out.last_mut() {
|
if let Some(m) = out.last_mut() {
|
||||||
if r.in_force {
|
if r.in_force {
|
||||||
m.in_force = Some(r.adopted);
|
m.in_force = Some(r.adopted);
|
||||||
} else {
|
} else if st.proposals.iter().any(|p| p.id == r.id && st.votable(p)) {
|
||||||
m.open += 1;
|
m.open += 1;
|
||||||
}
|
}
|
||||||
m.voted |= r.voted;
|
m.voted |= r.voted;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Writing: drafts, submission, corrections, votes, contradiction signals,
|
//! Writing: drafts, submission, corrections, votes, inertia changes, editorial
|
||||||
//! inertia changes, editorial translations.
|
//! translations. Objections live in `objections`, typo fixes of others in `typos`.
|
||||||
//!
|
//!
|
||||||
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
||||||
//! système qui nécessite un vote pour corriger une coquille »):
|
//! système qui nécessite un vote pour corriger une coquille »):
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
//! - once **submitted**, a correction classified *typographic* by the engine
|
//! - once **submitted**, a correction classified *typographic* by the engine
|
||||||
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
||||||
//! new revision and every earlier vote waits for its author to reconfirm;
|
//! new revision and every earlier vote waits for its author to reconfirm;
|
||||||
//! - a text **in force** accepts typographic corrections from any member of
|
//! - any member of the group may write a typo fix on a version; it applies at
|
||||||
//! the group, recorded with their author and diff, and republished — a
|
//! once when its writer answers for the version (its author; an administrator
|
||||||
//! change of meaning goes through a proposal.
|
//! for a text without author), and waits for them otherwise — three days at
|
||||||
|
//! most (2026-10-31, `typos`). A change of meaning goes through a proposal.
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::adoption;
|
use crate::adoption;
|
||||||
@@ -17,8 +18,7 @@ use crate::auth::{IdentityRow, User};
|
|||||||
use crate::ballots;
|
use crate::ballots;
|
||||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::modules;
|
use crate::{modules, objections, typos, util};
|
||||||
use crate::util;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use ld_engine::assist::{self, Hint};
|
use ld_engine::assist::{self, Hint};
|
||||||
@@ -30,10 +30,8 @@ use serde_json::json;
|
|||||||
|
|
||||||
const MAX_TITLE: usize = 120;
|
const MAX_TITLE: usize = 120;
|
||||||
const MAX_BODY: usize = 4000;
|
const MAX_BODY: usize = 4000;
|
||||||
const MIN_REASON: usize = 10;
|
|
||||||
const MAX_REASON: usize = 600;
|
|
||||||
|
|
||||||
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||||
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
@@ -50,7 +48,11 @@ async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the document of a proposal and checks the person may open its group.
|
/// Loads the document of a proposal and checks the person may open its group.
|
||||||
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
|
pub(crate) async fn guarded(
|
||||||
|
state: &AppState,
|
||||||
|
user: &IdentityRow,
|
||||||
|
proposal_id: &str,
|
||||||
|
) -> AppResult<(ProposalRow, DocState)> {
|
||||||
let p = load_proposal(state, proposal_id).await?;
|
let p = load_proposal(state, proposal_id).await?;
|
||||||
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
||||||
let st = docs::load_state(state, &doc_id).await?;
|
let st = docs::load_state(state, &doc_id).await?;
|
||||||
@@ -172,6 +174,9 @@ pub struct CreateBody {
|
|||||||
body: String,
|
body: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
submit: bool,
|
submit: bool,
|
||||||
|
/// Written to answer an objection (« modifier l'article visé »): on the
|
||||||
|
/// article the held version repeats or contradicts, or on its own.
|
||||||
|
objection_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new article goes at the end of its section (ADR 0009): the order of the
|
/// A new article goes at the end of its section (ADR 0009): the order of the
|
||||||
@@ -259,7 +264,7 @@ pub async fn create(
|
|||||||
}
|
}
|
||||||
unit.id.clone()
|
unit.id.clone()
|
||||||
}
|
}
|
||||||
None if kind == "text" => {
|
None if kind == "text" && b.objection_id.is_none() => {
|
||||||
// A new article belongs to the section asked for, or to that of the article named.
|
// A new article belongs to the section asked for, or to that of the article named.
|
||||||
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
||||||
let section = match b.section.as_deref() {
|
let section = match b.section.as_deref() {
|
||||||
@@ -269,8 +274,12 @@ pub async fn create(
|
|||||||
};
|
};
|
||||||
new_unit(&state, &st, section).await?
|
new_unit(&state, &st, section).await?
|
||||||
}
|
}
|
||||||
|
None if kind == "text" => return Err(AppError::Invalid("link")),
|
||||||
None => return Err(AppError::Invalid("unit")),
|
None => return Err(AppError::Invalid("unit")),
|
||||||
};
|
};
|
||||||
|
if let Some(oid) = &b.objection_id {
|
||||||
|
objections::check_link(&state, &user, oid, &unit_id).await?;
|
||||||
|
}
|
||||||
let id = insert_proposal(
|
let id = insert_proposal(
|
||||||
&state,
|
&state,
|
||||||
NewProposal {
|
NewProposal {
|
||||||
@@ -284,6 +293,16 @@ pub async fn create(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if let Some(oid) = &b.objection_id {
|
||||||
|
sqlx::query("UPDATE proposals SET objection_id = ? WHERE id = ?")
|
||||||
|
.bind(oid)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if b.submit {
|
||||||
|
objections::on_linked(&state, oid, &id, &user.id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,43 +360,8 @@ pub async fn edit(
|
|||||||
};
|
};
|
||||||
match kind {
|
match kind {
|
||||||
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||||
EditKind::Typographic => {
|
// Anyone in the group may fix a typo; who applies it, and when, is `typos`.
|
||||||
// Anyone in the group may fix a typo of a seed text or of a text in force.
|
EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await,
|
||||||
if !(is_author || p.origin == "seed" || p.status == "in_force") {
|
|
||||||
return Err(AppError::Forbidden);
|
|
||||||
}
|
|
||||||
// Written only over the text that was judged: an entry into force or another
|
|
||||||
// correction in between makes it a conflict, never a silent change.
|
|
||||||
let done = sqlx::query(
|
|
||||||
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
|
||||||
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
|
|
||||||
)
|
|
||||||
.bind(title)
|
|
||||||
.bind(body)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&id)
|
|
||||||
.bind(p.revision)
|
|
||||||
.bind(&p.status)
|
|
||||||
.bind(&p.title)
|
|
||||||
.bind(&p.body)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
if done.rows_affected() != 1 {
|
|
||||||
return Err(AppError::Conflict("changed"));
|
|
||||||
}
|
|
||||||
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
|
|
||||||
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
|
||||||
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
|
||||||
.bind(util::source_hash(title, body))
|
|
||||||
.bind(&id)
|
|
||||||
.bind(util::source_hash(&p.title, &p.body))
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
if p.status == "in_force" {
|
|
||||||
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
|
|
||||||
}
|
|
||||||
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
|
|
||||||
}
|
|
||||||
EditKind::Substantive => {
|
EditKind::Substantive => {
|
||||||
if p.status == "in_force" {
|
if p.status == "in_force" {
|
||||||
return Err(AppError::Conflict("substantive_in_force"));
|
return Err(AppError::Conflict("substantive_in_force"));
|
||||||
@@ -406,14 +390,10 @@ pub async fn edit(
|
|||||||
return Err(AppError::Conflict("changed"));
|
return Err(AppError::Conflict("changed"));
|
||||||
}
|
}
|
||||||
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
||||||
// Signals were about the former text: they lapse, their authors may signal again.
|
// It answers the objections around it (the objector judges again), and
|
||||||
sqlx::query(
|
// the typo fixes waiting on the former text no longer fit.
|
||||||
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
|
objections::on_change(&state, &id, true, &user.id).await?;
|
||||||
)
|
typos::on_text_changed(&state, &id).await?;
|
||||||
.bind(&now)
|
|
||||||
.bind(&id)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,6 +430,12 @@ pub async fn submit(
|
|||||||
if initial.rows_affected() == 0 {
|
if initial.rows_affected() == 0 {
|
||||||
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
||||||
}
|
}
|
||||||
|
// Written to answer an objection: submitted, it answers it (a draft answers nothing).
|
||||||
|
let linked: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
|
||||||
|
if let Some(oid) = linked {
|
||||||
|
objections::on_linked(&state, &oid, &id, &user.id).await?;
|
||||||
|
}
|
||||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||||
}
|
}
|
||||||
@@ -472,6 +458,9 @@ pub async fn withdraw(
|
|||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Nothing left to object to, nor to correct.
|
||||||
|
objections::on_withdrawn(&state, &id, &user.id).await?;
|
||||||
|
typos::on_text_changed(&state, &id).await?;
|
||||||
Ok(Json(json!({ "status": "withdrawn" })))
|
Ok(Json(json!({ "status": "withdrawn" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,66 +550,37 @@ pub async fn detail(
|
|||||||
author_name: Option<String>,
|
author_name: Option<String>,
|
||||||
author_invited: Option<bool>,
|
author_invited: Option<bool>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
|
typo_id: Option<String>,
|
||||||
|
answered_by: Option<String>,
|
||||||
|
answered_name: Option<String>,
|
||||||
}
|
}
|
||||||
let revs = sqlx::query_as::<_, Rev>(
|
let revs = sqlx::query_as::<_, Rev>(
|
||||||
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
|
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
|
||||||
i.kind = 'invite' AS author_invited, r.created_at
|
i.kind = 'invite' AS author_invited, r.created_at, r.typo_id, r.answered_by, a.display_name AS answered_name
|
||||||
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
|
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id LEFT JOIN identities a ON a.id = r.answered_by
|
||||||
|
WHERE r.proposal_id = ? ORDER BY r.id",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
// Each correction with the diff from the text before it.
|
// Each correction with the diff from the text before it; a fix someone else
|
||||||
|
// wrote says who let it in (or that it passed at its deadline).
|
||||||
let mut history = Vec::new();
|
let mut history = Vec::new();
|
||||||
let mut previous: Option<&Rev> = None;
|
let mut previous: Option<&Rev> = None;
|
||||||
for r in &revs {
|
for r in &revs {
|
||||||
let diff = previous
|
let diff = previous
|
||||||
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
|
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
|
||||||
|
let typo = r.typo_id.as_ref().map(|t| {
|
||||||
|
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
|
||||||
|
});
|
||||||
history.push(json!({
|
history.push(json!({
|
||||||
"revision": r.revision, "kind": r.kind, "author": r.author_name,
|
"revision": r.revision, "kind": r.kind, "author": r.author_name,
|
||||||
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
|
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
|
||||||
}));
|
}));
|
||||||
previous = Some(r);
|
previous = Some(r);
|
||||||
}
|
}
|
||||||
|
let objections = objections::around(&state, &st, &user, &id).await?;
|
||||||
#[derive(sqlx::FromRow)]
|
let typos = typos::of_proposal(&state, &user, &p).await?;
|
||||||
struct Sig {
|
|
||||||
id: String,
|
|
||||||
proposal_id: String,
|
|
||||||
target_proposal_id: String,
|
|
||||||
reason: String,
|
|
||||||
author_id: String,
|
|
||||||
author_name: String,
|
|
||||||
author_invited: bool,
|
|
||||||
created_at: String,
|
|
||||||
lifted_at: Option<String>,
|
|
||||||
lifted_by: Option<String>,
|
|
||||||
}
|
|
||||||
let sigs = sqlx::query_as::<_, Sig>(
|
|
||||||
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
|
|
||||||
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
|
|
||||||
FROM signals s JOIN identities i ON i.id = s.author_id
|
|
||||||
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
|
|
||||||
)
|
|
||||||
.bind(&id)
|
|
||||||
.bind(&id)
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?;
|
|
||||||
let mut signals = Vec::new();
|
|
||||||
for s in sigs {
|
|
||||||
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
|
|
||||||
let other = load_proposal(&state, other_id).await.ok();
|
|
||||||
let other_code = match &other {
|
|
||||||
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
signals.push(json!({
|
|
||||||
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
|
|
||||||
"mine": s.author_id == user.id,
|
|
||||||
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
|
|
||||||
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
|
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||||
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
|
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||||
@@ -628,7 +588,7 @@ pub async fn detail(
|
|||||||
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let eval = st.evaluation(unit, &p);
|
let eval = st.shown_evaluation(unit, &p);
|
||||||
let voted_at = st.voting_level(unit, &p);
|
let voted_at = st.voting_level(unit, &p);
|
||||||
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
||||||
let tr = docs::translations_for(&state, &[&p]).await?;
|
let tr = docs::translations_for(&state, &[&p]).await?;
|
||||||
@@ -640,8 +600,8 @@ pub async fn detail(
|
|||||||
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||||
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
||||||
"blocked": st.blocked.contains(&p.id),
|
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
||||||
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
||||||
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
|
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
|
||||||
@@ -651,13 +611,14 @@ pub async fn detail(
|
|||||||
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
||||||
"diff": diff,
|
"diff": diff,
|
||||||
"history": history,
|
"history": history,
|
||||||
"signals": signals,
|
"objections": objections,
|
||||||
|
"typos": typos,
|
||||||
"electorate": st.electorate,
|
"electorate": st.electorate,
|
||||||
"electorateSource": st.electorate_source,
|
"electorateSource": st.electorate_source,
|
||||||
"voteFloor": st.doc.vote_floor,
|
"voteFloor": st.doc.vote_floor,
|
||||||
"participation": st.participation_json(),
|
"participation": st.participation_json(),
|
||||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
// The five levels, with the *for* votes each would ask at this version's participation.
|
||||||
"levels": st.levels_json(st.tally(&p.id).cast()),
|
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +646,10 @@ pub async fn vote(
|
|||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
return Err(AppError::Conflict("not_votable"));
|
||||||
}
|
}
|
||||||
|
// Held back by an objection: its votes stay, no new one (ADR 0010).
|
||||||
|
if !st.votable(&p) {
|
||||||
|
return Err(AppError::Conflict("objection"));
|
||||||
|
}
|
||||||
if !docs::may_vote(&state, &user, &st.group).await? {
|
if !docs::may_vote(&state, &user, &st.group).await? {
|
||||||
return Err(AppError::Conflict("members_only"));
|
return Err(AppError::Conflict("members_only"));
|
||||||
}
|
}
|
||||||
@@ -700,81 +665,6 @@ pub async fn vote(
|
|||||||
Ok(Json(json!({ "entered": entered })))
|
Ok(Json(json!({ "entered": entered })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Contradiction signals
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SignalBody {
|
|
||||||
target_id: String,
|
|
||||||
reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// « These two texts contradict each other. » While the signal stands, the two
|
|
||||||
/// can never be in force together: the one not yet in force is blocked.
|
|
||||||
pub async fn signal(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
User(user): User,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Json(b): Json<SignalBody>,
|
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
|
||||||
let (p, st) = guarded(&state, &user, &id).await?;
|
|
||||||
let reason = b.reason.trim();
|
|
||||||
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
|
||||||
return Err(AppError::Invalid("reason"));
|
|
||||||
}
|
|
||||||
if b.target_id == id {
|
|
||||||
return Err(AppError::Invalid("target"));
|
|
||||||
}
|
|
||||||
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
|
|
||||||
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
|
|
||||||
return Err(AppError::Invalid("target"));
|
|
||||||
}
|
|
||||||
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
|
|
||||||
// Rivals on one unit never stand together anyway.
|
|
||||||
return Err(AppError::Invalid("same_unit"));
|
|
||||||
}
|
|
||||||
let sid = util::id();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&sid)
|
|
||||||
.bind(&id)
|
|
||||||
.bind(&b.target_id)
|
|
||||||
.bind(reason)
|
|
||||||
.bind(&user.id)
|
|
||||||
.bind(util::now())
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({ "id": sid })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn lift_signal(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
User(user): User,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
|
||||||
let (proposal_id, author): (String, String) =
|
|
||||||
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
|
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::NotFound)?;
|
|
||||||
let (_, st) = guarded(&state, &user, &proposal_id).await?;
|
|
||||||
if author != user.id && !user.admin {
|
|
||||||
return Err(AppError::Forbidden);
|
|
||||||
}
|
|
||||||
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
|
|
||||||
.bind(util::now())
|
|
||||||
.bind(&user.id)
|
|
||||||
.bind(&id)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
|
||||||
Ok(Json(json!({ "entered": entered })))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Inertia
|
// Inertia
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
|||||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let tally = st.tally(&p.id);
|
let tally = st.shown_tally(&p.id);
|
||||||
json!({
|
json!({
|
||||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||||
@@ -409,7 +409,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let tally = st.tally(&p.id);
|
let tally = st.shown_tally(&p.id);
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ pub struct Facts {
|
|||||||
pub revisions: Vec<String>,
|
pub revisions: Vec<String>,
|
||||||
/// Translations written or corrected by a member.
|
/// Translations written or corrected by a member.
|
||||||
pub member_translations: i64,
|
pub member_translations: i64,
|
||||||
/// Contradiction signals naming it, standing or lifted.
|
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
|
||||||
pub signals: i64,
|
pub signals: i64,
|
||||||
|
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
|
||||||
|
pub typos: i64,
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
|||||||
"revised"
|
"revised"
|
||||||
} else if f.votes > 0 {
|
} else if f.votes > 0 {
|
||||||
"votes"
|
"votes"
|
||||||
} else if f.revisions != ["initial"] {
|
} else if f.revisions != ["initial"] || f.typos > 0 {
|
||||||
"corrected"
|
"corrected"
|
||||||
} else if f.member_translations > 0 {
|
} else if f.member_translations > 0 {
|
||||||
"translation_edited"
|
"translation_edited"
|
||||||
@@ -214,6 +216,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
|||||||
.bind(proposal_id)
|
.bind(proposal_id)
|
||||||
.fetch_one(&mut *conn)
|
.fetch_one(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
Ok(Some(Facts {
|
Ok(Some(Facts {
|
||||||
origin: p.origin,
|
origin: p.origin,
|
||||||
entered: p.entered_at.is_some(),
|
entered: p.entered_at.is_some(),
|
||||||
@@ -223,6 +229,7 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
|||||||
revisions,
|
revisions,
|
||||||
member_translations,
|
member_translations,
|
||||||
signals,
|
signals,
|
||||||
|
typos,
|
||||||
fingerprint: fingerprint(&p.title, &p.body),
|
fingerprint: fingerprint(&p.title, &p.body),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -383,6 +390,7 @@ mod tests {
|
|||||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||||
|
(Some(Facts { typos: 1, ..intact() }), "corrected"),
|
||||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||||
|
|||||||
@@ -97,8 +97,13 @@ pub struct JoinBody {
|
|||||||
/// `name_key` settles two openings at the same instant.
|
/// `name_key` settles two openings at the same instant.
|
||||||
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||||
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
||||||
// People already there, and those an invitation still waits for.
|
// People already there, and those an invitation still waits for. A Ğ1
|
||||||
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
|
// identity known only from a vote on the chain (never signed in here) is not
|
||||||
|
// there: as for any member of the web of trust who never came, its name stays free.
|
||||||
|
let mut taken: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
taken.extend(
|
taken.extend(
|
||||||
sqlx::query_scalar::<_, String>(
|
sqlx::query_scalar::<_, String>(
|
||||||
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010).
|
||||||
|
//!
|
||||||
|
//! What a typo is does not change (`ld_engine::edit`, judged against the voted
|
||||||
|
//! text). Who applies it, and when, is `ld_engine::correction`: the one who
|
||||||
|
//! answers for the version (its author; for a text without author, an
|
||||||
|
//! administrator of trustinWallet — `trustin_access::answers_typo`) applies a
|
||||||
|
//! fix at once; anyone else's waits for them, told by a message, and applies by
|
||||||
|
//! itself after 3 days (`due`, run by the sweeper and at start). While the
|
||||||
|
//! version is not in force its author keeps the hand: a fix applied may be taken
|
||||||
|
//! back, a fix refused validated after all. A fix is written only over the text
|
||||||
|
//! it was written over: once another one applies, or the substance changes, it
|
||||||
|
//! is stale. Translations stay out of this: editorial, without validation.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::adoption;
|
||||||
|
use crate::auth::{IDENTITY_COLUMNS, IdentityRow, User};
|
||||||
|
use crate::docs::{self, DocState, ProposalRow};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::messages::{self, Topic};
|
||||||
|
use crate::{proposals, util};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use ld_engine::correction::{self, Answer, Route, Settle, Text, TextChange, TypoStatus};
|
||||||
|
use ld_engine::edit;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct TypoRow {
|
||||||
|
pub id: String,
|
||||||
|
pub proposal_id: String,
|
||||||
|
pub doc_id: String,
|
||||||
|
pub writer_id: String,
|
||||||
|
pub answerer_id: Option<String>,
|
||||||
|
pub revision: i64,
|
||||||
|
pub base_title: String,
|
||||||
|
pub base_body: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub due_at: String,
|
||||||
|
pub settled_at: Option<String>,
|
||||||
|
pub settled_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT: &str =
|
||||||
|
"SELECT id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body,
|
||||||
|
status, created_at, due_at, settled_at, settled_by FROM typos";
|
||||||
|
|
||||||
|
impl TypoRow {
|
||||||
|
fn status(&self) -> TypoStatus {
|
||||||
|
TypoStatus::parse(&self.status).unwrap_or(TypoStatus::Stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base(&self) -> Text<'_> {
|
||||||
|
Text { revision: self.revision, title: &self.base_title, body: &self.base_body }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed(&self) -> Text<'_> {
|
||||||
|
Text { revision: self.revision, title: &self.title, body: &self.body }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(p: &ProposalRow) -> Text<'_> {
|
||||||
|
Text { revision: p.revision, title: &p.title, body: &p.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(state: &AppState, id: &str) -> AppResult<TypoRow> {
|
||||||
|
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `to` over a version, only where its text is still `from` — and, when
|
||||||
|
/// `status` is given, only while its status is that one; records the revision and
|
||||||
|
/// keeps up-to-date translations up to date. False when the text was not there.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn write_text(
|
||||||
|
tx: &mut SqliteConnection,
|
||||||
|
proposal_id: &str,
|
||||||
|
from: Text<'_>,
|
||||||
|
to: (&str, &str),
|
||||||
|
status: Option<&str>,
|
||||||
|
author: &str,
|
||||||
|
link: Option<(&str, &str)>,
|
||||||
|
kind: &str,
|
||||||
|
now: &str,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
|
||||||
|
AND (?9 IS NULL OR status = ?9)",
|
||||||
|
)
|
||||||
|
.bind(to.0)
|
||||||
|
.bind(to.1)
|
||||||
|
.bind(now)
|
||||||
|
.bind(proposal_id)
|
||||||
|
.bind(from.revision)
|
||||||
|
.bind(from.title)
|
||||||
|
.bind(from.body)
|
||||||
|
.bind(status)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if done.rows_affected() != 1 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at, typo_id, answered_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(proposal_id)
|
||||||
|
.bind(from.revision)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(to.0)
|
||||||
|
.bind(to.1)
|
||||||
|
.bind(author)
|
||||||
|
.bind(now)
|
||||||
|
.bind(link.map(|l| l.0))
|
||||||
|
.bind(link.map(|l| l.1))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
||||||
|
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
||||||
|
.bind(util::source_hash(to.0, to.1))
|
||||||
|
.bind(proposal_id)
|
||||||
|
.bind(util::source_hash(from.title, from.body))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the text of a version changed, or it closed: the fixes waiting on a text
|
||||||
|
/// that is gone are stale, and their messages say so.
|
||||||
|
pub async fn on_text_changed(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||||
|
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||||
|
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||||
|
let pending = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? AND status = 'pending'"))
|
||||||
|
.bind(proposal_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
for t in pending {
|
||||||
|
if open && correction::fits(t.base(), text_of(&p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stale(state, &t, "pending").await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stale(state: &AppState, t: &TypoRow, from: &str) -> AppResult<()> {
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE typos SET status = 'stale', settled_at = ?, settled_by = 'changed' WHERE id = ? AND status = ?",
|
||||||
|
)
|
||||||
|
.bind(state.clock.now())
|
||||||
|
.bind(&t.id)
|
||||||
|
.bind(from)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if done.rows_affected() == 1 {
|
||||||
|
messages::close(state, Topic::Typo, &t.id, None, "stale").await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn republish(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||||
|
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||||
|
if p.status == "in_force" {
|
||||||
|
let doc_id: String =
|
||||||
|
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?").bind(&p.unit_id).fetch_one(&state.db).await?;
|
||||||
|
adoption::enqueue_publication(state, &docs::load_doc(state, &doc_id).await?, "correction typographique")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fix the engine judged typographic, written by `user` on `p`: applied at once
|
||||||
|
/// by the one who answers for the version, waiting for them otherwise.
|
||||||
|
pub async fn write(
|
||||||
|
state: &AppState,
|
||||||
|
user: &IdentityRow,
|
||||||
|
p: &ProposalRow,
|
||||||
|
st: &DocState,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let diff = edit::diff(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
|
||||||
|
let answers = trustin_access::answers_typo(&user.to_identity(), p.author_id.as_deref());
|
||||||
|
let now = state.clock.now();
|
||||||
|
match correction::route(answers) {
|
||||||
|
Route::Apply => {
|
||||||
|
// Written only over the text that was judged: an entry into force or another
|
||||||
|
// correction in between makes it a conflict, never a silent change.
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
if !write_text(&mut tx, &p.id, text_of(p), (title, body), Some(&p.status), &user.id, None, "typo", &now)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
if p.status == "in_force" {
|
||||||
|
adoption::enqueue_publication(state, &st.doc, "correction typographique").await?;
|
||||||
|
}
|
||||||
|
on_text_changed(state, &p.id).await?;
|
||||||
|
Ok(Json(json!({ "edit": "typo", "diff": diff })))
|
||||||
|
}
|
||||||
|
Route::Await => {
|
||||||
|
let id = util::id();
|
||||||
|
let due = state.clock.in_days(correction::ANSWER_DAYS);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
|
||||||
|
title, body, status, created_at, due_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(&st.doc.id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&p.author_id)
|
||||||
|
.bind(p.revision)
|
||||||
|
.bind(&p.title)
|
||||||
|
.bind(&p.body)
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&due)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
// Who answers is told: the author, or for a text without author, every
|
||||||
|
// administrator who opens the group (the others could not answer).
|
||||||
|
let recipients: Vec<String> = match &p.author_id {
|
||||||
|
Some(a) => vec![a.clone()],
|
||||||
|
None => {
|
||||||
|
let admins = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||||
|
"SELECT {IDENTITY_COLUMNS} FROM identities WHERE admin = 1 AND id <> ? ORDER BY created_at"
|
||||||
|
))
|
||||||
|
.bind(&user.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut open = Vec::new();
|
||||||
|
for a in admins {
|
||||||
|
if docs::opens(state, &a, &st.group).await? {
|
||||||
|
open.push(a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for r in recipients {
|
||||||
|
messages::notify(state, &r, &st.doc.id, Topic::Typo, &id, "typo_pending", 1, Some(&due)).await?;
|
||||||
|
}
|
||||||
|
let answerer = if p.author_id.is_some() { "author" } else { "admin" };
|
||||||
|
tracing::info!(typo = %id, proposal = %p.id, answerer, "typo fix waiting for validation");
|
||||||
|
Ok(Json(json!({
|
||||||
|
"edit": "typo_pending", "diff": diff, "typo": { "id": id, "dueAt": due, "answerer": answerer },
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settles one fix, in one transaction: its status (only over the one read), and
|
||||||
|
/// its text. `Ok(Err("settled"))`: answered meanwhile; `Ok(Err("changed"))`: the
|
||||||
|
/// text it needs is gone — nothing is written.
|
||||||
|
async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -> AppResult<Result<(), &'static str>> {
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
let claimed =
|
||||||
|
sqlx::query("UPDATE typos SET status = ?, settled_at = ?, settled_by = ? WHERE id = ? AND status = ?")
|
||||||
|
.bind(s.status.as_str())
|
||||||
|
.bind(now)
|
||||||
|
.bind(by)
|
||||||
|
.bind(&t.id)
|
||||||
|
.bind(&t.status)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if claimed.rows_affected() != 1 {
|
||||||
|
return Ok(Err("settled"));
|
||||||
|
}
|
||||||
|
let written = match s.text {
|
||||||
|
TextChange::Write => {
|
||||||
|
write_text(
|
||||||
|
&mut tx,
|
||||||
|
&t.proposal_id,
|
||||||
|
t.base(),
|
||||||
|
(&t.title, &t.body),
|
||||||
|
None,
|
||||||
|
&t.writer_id,
|
||||||
|
Some((&t.id, by)),
|
||||||
|
"typo",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
TextChange::Restore => {
|
||||||
|
write_text(
|
||||||
|
&mut tx,
|
||||||
|
&t.proposal_id,
|
||||||
|
t.fixed(),
|
||||||
|
(&t.base_title, &t.base_body),
|
||||||
|
None,
|
||||||
|
by,
|
||||||
|
Some((&t.id, by)),
|
||||||
|
"revert",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
TextChange::Keep => true,
|
||||||
|
};
|
||||||
|
if !written {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(Err("changed"));
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
if s.text != TextChange::Keep {
|
||||||
|
republish(state, &t.proposal_id).await?;
|
||||||
|
on_text_changed(state, &t.proposal_id).await?;
|
||||||
|
}
|
||||||
|
Ok(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer(state: &AppState, user: &IdentityRow, id: &str, a: Answer) -> AppResult<Json<Value>> {
|
||||||
|
let t = load(state, id).await?;
|
||||||
|
let (p, _) = proposals::guarded(state, user, &t.proposal_id).await?;
|
||||||
|
if !trustin_access::answers_typo(&user.to_identity(), t.answerer_id.as_deref()) {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
let status = t.status();
|
||||||
|
if status == TypoStatus::Stale {
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
let s = correction::answer(status, a, p.status == "in_force").map_err(|_| AppError::Conflict("settled"))?;
|
||||||
|
let now = state.clock.now();
|
||||||
|
match settle(state, &t, s, &user.id, &now).await? {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err("changed") => {
|
||||||
|
// Written over a text that is gone: a pending fix is stale; one already decided stays as it is.
|
||||||
|
if status == TypoStatus::Pending {
|
||||||
|
stale(state, &t, "pending").await?;
|
||||||
|
}
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
Err(_) => return Err(AppError::Conflict("settled")),
|
||||||
|
}
|
||||||
|
if status == TypoStatus::Pending {
|
||||||
|
let outcome = if s.status == TypoStatus::Applied { "applied" } else { "refused" };
|
||||||
|
messages::close(state, Topic::Typo, &t.id, None, outcome).await?;
|
||||||
|
}
|
||||||
|
messages::read_subject(state, &user.id, Topic::Typo, &t.id).await?;
|
||||||
|
tracing::info!(typo = %t.id, status = s.status.as_str(), by = %user.id, "typo fix answered");
|
||||||
|
Ok(Json(json!({ "status": s.status })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/typos/{id}/validate`
|
||||||
|
pub async fn validate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
answer(&state, &user, &id, Answer::Validate).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/typos/{id}/refuse` — refuses a pending fix, or takes back an applied
|
||||||
|
/// one while the version is not in force.
|
||||||
|
pub async fn refuse(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||||
|
answer(&state, &user, &id, Answer::Refuse).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Due {
|
||||||
|
pub applied: usize,
|
||||||
|
pub stale: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixes whose deadline passed apply by themselves — where their text is still there.
|
||||||
|
pub async fn due(state: &AppState) -> AppResult<Due> {
|
||||||
|
let now = state.clock.now();
|
||||||
|
let rows = sqlx::query_as::<_, TypoRow>(&format!(
|
||||||
|
"{SELECT} WHERE status = 'pending' AND due_at <= ? ORDER BY created_at, rowid"
|
||||||
|
))
|
||||||
|
.bind(&now)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out = Due::default();
|
||||||
|
for t in rows {
|
||||||
|
let Some(s) = correction::tick(t.status(), &t.due_at, &now) else { continue };
|
||||||
|
match settle(state, &t, s, "deadline", &now).await? {
|
||||||
|
Ok(()) => {
|
||||||
|
messages::close(state, Topic::Typo, &t.id, None, "deadline").await?;
|
||||||
|
tracing::info!(typo = %t.id, "typo fix applied at its deadline");
|
||||||
|
out.applied += 1;
|
||||||
|
}
|
||||||
|
Err("changed") => {
|
||||||
|
stale(state, &t, "pending").await?;
|
||||||
|
out.stale += 1;
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// What the screen shows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||||
|
let row: Option<(String, bool)> =
|
||||||
|
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `viewer` may do with a fix now.
|
||||||
|
fn gestures(viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> Vec<&'static str> {
|
||||||
|
if !trustin_access::answers_typo(&viewer.to_identity(), t.answerer_id.as_deref()) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let in_force = p.status == "in_force";
|
||||||
|
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||||
|
match t.status() {
|
||||||
|
TypoStatus::Pending => vec!["validate", "refuse"],
|
||||||
|
TypoStatus::Applied if open && !in_force && correction::fits(t.fixed(), text_of(p)) => vec!["refuse"],
|
||||||
|
TypoStatus::Refused if open && !in_force && correction::fits(t.base(), text_of(p)) => vec!["validate"],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view(state: &AppState, viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> AppResult<Value> {
|
||||||
|
let (writer, invited) = name_of(state, &t.writer_id).await?;
|
||||||
|
let settled_by = match t.settled_by.as_deref() {
|
||||||
|
Some(by @ ("deadline" | "changed")) => Some(by.to_owned()),
|
||||||
|
Some(id) => name_of(state, id).await?.0,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(json!({
|
||||||
|
"id": t.id, "status": t.status, "writer": writer, "writerInvited": invited, "at": t.created_at,
|
||||||
|
"dueAt": t.due_at, "settledAt": t.settled_at, "settledBy": settled_by,
|
||||||
|
"answerer": if t.answerer_id.is_some() { "author" } else { "admin" },
|
||||||
|
"diff": edit::diff(&format!("{}\n{}", t.base_title, t.base_body), &format!("{}\n{}", t.title, t.body)),
|
||||||
|
"gestures": gestures(viewer, t, p),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixes written by others on one version, newest first, as `viewer` sees them.
|
||||||
|
pub async fn of_proposal(state: &AppState, viewer: &IdentityRow, p: &ProposalRow) -> AppResult<Vec<Value>> {
|
||||||
|
let rows =
|
||||||
|
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? ORDER BY created_at DESC, rowid DESC"))
|
||||||
|
.bind(&p.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for t in &rows {
|
||||||
|
out.push(view(state, viewer, t, p).await?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fix, for a message of its box: the gestures only while it waits.
|
||||||
|
pub async fn for_message(
|
||||||
|
state: &AppState,
|
||||||
|
viewer: &IdentityRow,
|
||||||
|
subject_id: &str,
|
||||||
|
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||||
|
let Ok(t) = load(state, subject_id).await else { return Ok(None) };
|
||||||
|
let p = proposals::load_proposal(state, &t.proposal_id).await?;
|
||||||
|
let mut v = view(state, viewer, &t, &p).await?;
|
||||||
|
let code: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT code FROM units WHERE id = ?").bind(&p.unit_id).fetch_optional(&state.db).await?;
|
||||||
|
v["version"] = json!({ "id": p.id, "title": p.title, "status": p.status, "unitId": p.unit_id, "code": code });
|
||||||
|
let gestures = if t.status() == TypoStatus::Pending { gestures(viewer, &t, &p) } else { vec![] };
|
||||||
|
v["gestures"] = json!(gestures);
|
||||||
|
Ok(Some((v, gestures)))
|
||||||
|
}
|
||||||
@@ -186,20 +186,22 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn editing_is_not_voting() {
|
async fn editing_is_not_voting() {
|
||||||
let (mut a, _) = Client::new().await;
|
let (mut a, _) = Client::new().await;
|
||||||
a.login("Yvv", false).await;
|
// An administrator of trustinWallet answers for the starting texts (no author).
|
||||||
|
a.login("Yvv", true).await;
|
||||||
let mut b = a.with(None);
|
let mut b = a.with(None);
|
||||||
b.login("Chiara", false).await;
|
b.login("Chiara", false).await;
|
||||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||||
let pid = e1["id"].as_str().expect("id").to_owned();
|
let pid = e1["id"].as_str().expect("id").to_owned();
|
||||||
// Anyone may give the text in force its typographic apostrophes, without a vote.
|
// Anyone may give the text in force its typographic apostrophes, without a vote:
|
||||||
|
// since 2026-10-31 it waits for validation (tests/typos.rs).
|
||||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||||
assert!(fixed.contains("m\u{2019}engage"));
|
assert!(fixed.contains("m\u{2019}engage"));
|
||||||
let (s, v) =
|
let (s, v) =
|
||||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||||||
assert_eq!(s, StatusCode::OK, "{v}");
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
assert_eq!(v["edit"], "typo");
|
assert_eq!(v["edit"], "typo_pending");
|
||||||
// A change of meaning in force is refused: it goes through a proposal.
|
// A change of meaning in force is refused: it goes through a proposal.
|
||||||
let (s, v) = b
|
let (s, v) = b
|
||||||
.call(
|
.call(
|
||||||
@@ -259,13 +261,14 @@ async fn editing_is_not_voting() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
||||||
// Typo fixes are measured against the voted text: slips never add up.
|
// Typo fixes are measured against the voted text: slips never add up (applied at
|
||||||
|
// once here: the administrator answers for a starting text).
|
||||||
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
||||||
let (_, v) = b
|
let (_, v) = a
|
||||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
||||||
let (s, v) = b
|
let (s, v) = a
|
||||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -273,7 +276,7 @@ async fn editing_is_not_voting() {
|
|||||||
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
||||||
"a second slip on top is not"
|
"a second slip on top is not"
|
||||||
);
|
);
|
||||||
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
let (_, v) = a.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||||||
assert_eq!(v["edit"], "typo", "back to the voted text");
|
assert_eq!(v["edit"], "typo", "back to the voted text");
|
||||||
// An address is compared exactly.
|
// An address is compared exactly.
|
||||||
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
||||||
@@ -335,8 +338,11 @@ async fn editing_is_not_voting() {
|
|||||||
assert_eq!(s, StatusCode::CONFLICT);
|
assert_eq!(s, StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces « a contradiction signal blocks entering into force » (2026-10-21):
|
||||||
|
/// since 2026-10-31 an objection makes the latest arrival non-votable (ADR 0010;
|
||||||
|
/// the full cycle: tests/objections.rs).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_contradiction_signal_blocks_entering_into_force() {
|
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
|
||||||
let (mut a, _) = Client::new().await;
|
let (mut a, _) = Client::new().await;
|
||||||
a.login("Yvv", false).await;
|
a.login("Yvv", false).await;
|
||||||
let mut b = a.with(None);
|
let mut b = a.with(None);
|
||||||
@@ -345,22 +351,25 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
|
|||||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||||
|
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
let (s, v) = b
|
let (s, v) = b
|
||||||
.call(
|
.call(
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/proposals/{k1}/signals"),
|
&format!("/api/proposals/{k1}/objections"),
|
||||||
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
Some(json!({ "otherId": e2, "kind": "contredit", "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(s, StatusCode::OK, "{v}");
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
let sid = v["id"].as_str().expect("sid").to_owned();
|
assert_eq!(v["heldId"], k1.as_str());
|
||||||
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
let oid = v["id"].as_str().expect("id").to_owned();
|
||||||
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
let (s, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")), "not votable while it stands");
|
||||||
// Only its author lifts it; then the pass lets the text in.
|
// Only its author lifts it (or an administrator); then the second voice lets the text in.
|
||||||
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
let (s, _) = a.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
let (_, v) = b.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
|
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two");
|
||||||
|
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
assert_eq!(v["entered"][0]["code"], "K1");
|
assert_eq!(v["entered"][0]["code"], "K1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,7 +1072,8 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
|||||||
.expect("count");
|
.expect("count");
|
||||||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||||||
|
|
||||||
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
|
// From now on a typo is measured against the new text (written by a member, it
|
||||||
|
// waits for an administrator), and the Préambule is a change of meaning.
|
||||||
let body = i1["body"].as_str().expect("body");
|
let body = i1["body"].as_str().expect("body");
|
||||||
let (s, v) = c
|
let (s, v) = c
|
||||||
.call(
|
.call(
|
||||||
@@ -1072,7 +1082,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
|||||||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
|
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo_pending")), "{v}");
|
||||||
let (s, v) = c
|
let (s, v) = c
|
||||||
.call(
|
.call(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -1113,6 +1123,7 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
|||||||
Touch::Vote => {
|
Touch::Vote => {
|
||||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||||
}
|
}
|
||||||
|
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
|
||||||
Touch::Typo => {
|
Touch::Typo => {
|
||||||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||||||
@@ -1133,8 +1144,8 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
|||||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||||
c.call(
|
c.call(
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/proposals/{k1}/signals"),
|
&format!("/api/proposals/{k1}/objections"),
|
||||||
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
|
Some(json!({ "otherId": pid, "kind": "contredit", "reason": "Ces règles contredisent le préambule." })),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ struct Chain {
|
|||||||
comments: Vec<Value>,
|
comments: Vec<Value>,
|
||||||
silent: bool,
|
silent: bool,
|
||||||
asked: usize,
|
asked: usize,
|
||||||
|
/// The last final block, served as `squidStatus.height`; `None`: every block is final.
|
||||||
|
finalized: Option<u32>,
|
||||||
|
/// An indexer that ignores the filter it is given (the hash only holds).
|
||||||
|
lax: bool,
|
||||||
|
/// The filters the server asked with, in order.
|
||||||
|
filters: Vec<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -49,21 +55,66 @@ struct Signer<'a> {
|
|||||||
identity: Option<(u32, &'a str, bool)>,
|
identity: Option<(u32, &'a str, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the real indexer's `filter` keeps, for the two filters the server may ask.
|
||||||
|
fn kept_by(node: &Value, filter: &Value) -> bool {
|
||||||
|
if let Some(author) = filter["authorId"]["equalTo"].as_str()
|
||||||
|
&& node["author"]["id"] != author
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let some = &filter["transfersByCommentId"]["some"];
|
||||||
|
if some.is_null() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0);
|
||||||
|
node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| {
|
||||||
|
some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to)
|
||||||
|
&& t["amount"].as_str().and_then(|a| a.parse::<u64>().ok()).is_some_and(|a| a >= min)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of a page: `first: N` in the query, as the server writes it.
|
||||||
|
fn page_size(query: &str) -> usize {
|
||||||
|
query
|
||||||
|
.split("first:")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next())
|
||||||
|
.and_then(|n| n.parse().ok())
|
||||||
|
.unwrap_or(100)
|
||||||
|
}
|
||||||
|
|
||||||
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
||||||
let mut c = chain.lock().expect("lock");
|
let mut c = chain.lock().expect("lock");
|
||||||
c.asked += 1;
|
c.asked += 1;
|
||||||
if c.silent {
|
if c.silent {
|
||||||
return StatusCode::BAD_GATEWAY.into_response();
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
}
|
}
|
||||||
let hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
|
let vars = &body["variables"];
|
||||||
|
let hash = vars["hash"].as_str().unwrap_or_default().to_owned();
|
||||||
|
let filter = vars["filter"].clone();
|
||||||
|
c.filters.push(filter.clone());
|
||||||
let mut nodes: Vec<Value> = c
|
let mut nodes: Vec<Value> = c
|
||||||
.comments
|
.comments
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
|
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
|
||||||
|
.filter(|n| c.lax || kept_by(n, &filter))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
|
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
|
||||||
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
|
// Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one.
|
||||||
|
let start: usize =
|
||||||
|
vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0);
|
||||||
|
let page: Vec<Value> =
|
||||||
|
nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect();
|
||||||
|
let end = start + page.len();
|
||||||
|
Json(json!({ "data": {
|
||||||
|
"squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) },
|
||||||
|
"txComments": {
|
||||||
|
"pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") },
|
||||||
|
"nodes": page,
|
||||||
|
},
|
||||||
|
} }))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Indexer {
|
impl Indexer {
|
||||||
@@ -103,6 +154,19 @@ impl Indexer {
|
|||||||
fn asked(&self) -> usize {
|
fn asked(&self) -> usize {
|
||||||
self.chain.lock().expect("lock").asked
|
self.chain.lock().expect("lock").asked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The chain is final up to this block.
|
||||||
|
fn finalized(&self, block: Option<u32>) {
|
||||||
|
self.chain.lock().expect("lock").finalized = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lax(&self, on: bool) {
|
||||||
|
self.chain.lock().expect("lock").lax = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filters(&self) -> Vec<Value> {
|
||||||
|
self.chain.lock().expect("lock").filters.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn alice() -> Signer<'static> {
|
fn alice() -> Signer<'static> {
|
||||||
@@ -138,6 +202,16 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let (status, _, value) = self.call_with_headers(method, uri, body).await;
|
||||||
|
(status, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_with_headers(
|
||||||
|
&mut self,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
||||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
if let Some(c) = &self.cookie {
|
if let Some(c) = &self.cookie {
|
||||||
req = req.header(header::COOKIE, c);
|
req = req.header(header::COOKIE, c);
|
||||||
@@ -155,8 +229,9 @@ impl Client {
|
|||||||
self.cookie = Some(raw);
|
self.cookie = Some(raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let headers = res.headers().clone();
|
||||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||||
@@ -169,6 +244,10 @@ impl Client {
|
|||||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn login_admin(&mut self, name: &str) {
|
||||||
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn doc(&mut self, group: &str) -> Value {
|
async fn doc(&mut self, group: &str) -> Value {
|
||||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||||
@@ -396,17 +475,22 @@ async fn a_wrong_amount_or_recipient_is_refused() {
|
|||||||
c.login("Chiara").await;
|
c.login("Chiara").await;
|
||||||
let doc = c.doc("monnaie-libre").await;
|
let doc = c.doc("monnaie-libre").await;
|
||||||
let i1 = version(&doc, "I1", "inForce");
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
|
||||||
|
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
||||||
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
|
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
|
||||||
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
|
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
|
||||||
|
// Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free.
|
||||||
|
let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
||||||
ballots::sweep(&state).await.expect("sweep");
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
for b in [&short, &elsewhere, &remark_only] {
|
for b in [&short, &elsewhere, &remark_only] {
|
||||||
let s = c.status(b).await;
|
let s = c.status(b).await;
|
||||||
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
||||||
}
|
}
|
||||||
|
assert_eq!(c.status(&gecko).await["status"], "pending");
|
||||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,8 +643,10 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
|
|||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.expect("age");
|
.expect("age");
|
||||||
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
|
// Read one last time at its end: nothing on the chain, it is let go.
|
||||||
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
|
||||||
assert_eq!(c.status(&b).await["status"], "expired");
|
assert_eq!(c.status(&b).await["status"], "expired");
|
||||||
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
|
||||||
// Seen on the chain afterwards: too late, it no longer counts.
|
// Seen on the chain afterwards: too late, it no longer counts.
|
||||||
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
||||||
ballots::sweep(&state).await.expect("sweep");
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
@@ -625,17 +711,15 @@ async fn a_session_sees_its_own_ballots_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
|
||||||
let indexer = Indexer::start().await;
|
let indexer = Indexer::start().await;
|
||||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
c.login("Chiara").await;
|
c.login("Chiara").await;
|
||||||
let doc = c.doc("monnaie-libre").await;
|
let doc = c.doc("monnaie-libre").await;
|
||||||
let e4 = version(&doc, "E4", "0");
|
let e4 = version(&doc, "E4", "0");
|
||||||
let e5 = version(&doc, "E5", "0");
|
let e5 = version(&doc, "E5", "0");
|
||||||
// A contradiction signalled on E5 against the text in force E1: E5 cannot enter.
|
|
||||||
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
|
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
|
||||||
let e1 = version(&doc, "E1", "inForce");
|
let e1 = version(&doc, "E1", "inForce");
|
||||||
c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await;
|
|
||||||
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||||
let mut last = Value::Null;
|
let mut last = Value::Null;
|
||||||
for (n, account) in keys.iter().enumerate() {
|
for (n, account) in keys.iter().enumerate() {
|
||||||
@@ -646,6 +730,27 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
|||||||
b["comment"].as_str().expect("c"),
|
b["comment"].as_str().expect("c"),
|
||||||
Signer { account, identity: Some((index, "Membre", true)) },
|
Signer { account, identity: Some((index, "Membre", true)) },
|
||||||
);
|
);
|
||||||
|
if n == 49 {
|
||||||
|
// The last ballot is signed; before the chain confirms it, an objection holds
|
||||||
|
// E5 back against the text in force E1 (ADR 0010): no new ballot may hold it.
|
||||||
|
let o = c
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{e5}/objections"),
|
||||||
|
Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(o["heldId"], e5.as_str());
|
||||||
|
let id = doc["doc"]["id"].as_str().expect("id");
|
||||||
|
let (s, v) = c
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{id}/ballots"),
|
||||||
|
Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")));
|
||||||
|
}
|
||||||
last = b;
|
last = b;
|
||||||
if n % 10 == 9 {
|
if n % 10 == 9 {
|
||||||
// A session holds at most 20 pending ballots: the background task confirms on the way.
|
// A session holds at most 20 pending ballots: the background task confirms on the way.
|
||||||
@@ -664,8 +769,396 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
|||||||
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
|
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
|
||||||
let d = c.doc("monnaie-libre").await;
|
let d = c.doc("monnaie-libre").await;
|
||||||
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
|
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
|
||||||
|
// A ballot sealed before the objection counts, as a vote already cast: kept, but
|
||||||
|
// the version held back cannot enter into force.
|
||||||
assert_eq!(tally(&d, &e5), (50, 0));
|
assert_eq!(tally(&d, &e5), (50, 0));
|
||||||
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
|
assert_eq!(find(&d, &e5)["votable"], false);
|
||||||
|
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// After the independent review of 74f96be (lot B2): its probes, kept as tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn db_status(state: &AppState, id: &str) -> (String, Option<String>) {
|
||||||
|
sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_of(b: &Value) -> &str {
|
||||||
|
b["id"].as_str().expect("id")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comment_of(b: &Value) -> &str {
|
||||||
|
b["comment"].as_str().expect("comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn age(state: &AppState, set: &str, seconds: i64, id: &str) {
|
||||||
|
sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?"))
|
||||||
|
.bind(ld_server::util::in_seconds(seconds))
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("age");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P5: the `author` field was decoded in a time that grows as the square of its
|
||||||
|
/// length, on a thread of the server (250 000 characters: 32 s).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_long_author_is_refused_at_once() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
// The body may hold 256 KiB: an author of 250 000 characters passes it.
|
||||||
|
for n in [80_000usize, 250_000] {
|
||||||
|
let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) });
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author")));
|
||||||
|
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P1: the background task read the same 50 oldest pending ballots at every pass;
|
||||||
|
/// a vote paid behind them was never confirmed, then expired.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_background_task_reaches_every_pending_ballot() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
a.login("Anna").await;
|
||||||
|
let doc = a.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let mut b = a.other();
|
||||||
|
b.login("Bruno").await;
|
||||||
|
let mut c = a.other();
|
||||||
|
c.login("Carla").await;
|
||||||
|
for _ in 0..20 {
|
||||||
|
a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
b.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
}
|
||||||
|
for _ in 0..10 {
|
||||||
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
}
|
||||||
|
let mut dora = a.other();
|
||||||
|
dora.login("Dora").await;
|
||||||
|
let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
indexer.pay(100, comment_of(&mine), alice());
|
||||||
|
// 51 pending, at most 50 read per pass: the least recently read come first.
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed");
|
||||||
|
let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("n");
|
||||||
|
assert_eq!(unread, 0, "every pending ballot was read");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn letting_ballots_go_does_not_lift_the_cap() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
a.login("Anna").await;
|
||||||
|
let doc = a.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
for _ in 0..20 {
|
||||||
|
let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await;
|
||||||
|
}
|
||||||
|
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
|
||||||
|
let url = format!("/api/docs/{doc_id}/ballots");
|
||||||
|
let (s, v) = a.call("POST", &url, Some(one.clone())).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
|
||||||
|
// The cap holds for the identity, whatever the session.
|
||||||
|
let mut again = a.other();
|
||||||
|
again.login("Anna").await;
|
||||||
|
assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT);
|
||||||
|
// An hour later, there is room again.
|
||||||
|
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
||||||
|
.bind(ld_server::util::in_seconds(-3601))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("age");
|
||||||
|
let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
// Let go more than a day ago: purged, lines and all; the pending one stays.
|
||||||
|
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
||||||
|
.bind(ld_server::util::in_seconds(-(24 * 3600 + 1)))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("age");
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let count = || async {
|
||||||
|
sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("n")
|
||||||
|
};
|
||||||
|
assert_eq!(count().await, (1, 1));
|
||||||
|
// Not seen on the chain in time: it says so under the article for a week, then it is purged.
|
||||||
|
age(&state, "expires_at", -3601 - 5, id_of(&kept)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired");
|
||||||
|
age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(count().await, (1, 1));
|
||||||
|
age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(count().await, (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
// An indexer that does not filter: the rule of the engine holds alone.
|
||||||
|
indexer.lax(true);
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]);
|
||||||
|
indexer.add(
|
||||||
|
99,
|
||||||
|
2,
|
||||||
|
comment_of(&b),
|
||||||
|
Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) },
|
||||||
|
&[(VOTE_ACCOUNT, 9)],
|
||||||
|
);
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing");
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let s = c.status(&b).await;
|
||||||
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
||||||
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes
|
||||||
|
/// an unbound ballot — its lines count for the one who paid.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let s = c.status(&b).await;
|
||||||
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie")));
|
||||||
|
let voters: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
|
||||||
|
assert_eq!(voters, ["idty:7"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 5 of the review: a pending ballot older than a confirmed vote on the same
|
||||||
|
/// line no longer shows under the article.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
// A Ğecko code shown, never paid; then a vote from the wallet, confirmed.
|
||||||
|
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
|
indexer.pay(100, comment_of(&newer), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!(find(&d, &i1)["myVote"], "for");
|
||||||
|
assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]);
|
||||||
|
let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
||||||
|
assert!(p["proposal"]["myBallot"].is_null());
|
||||||
|
// A ballot asked after the vote shows, waiting.
|
||||||
|
let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
|
||||||
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 6: only a comment in a final block counts, or refuses.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_comment_counts_once_its_block_is_final() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
|
let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await;
|
||||||
|
indexer.finalized(Some(99));
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
indexer.add(
|
||||||
|
100,
|
||||||
|
2,
|
||||||
|
comment_of(&r),
|
||||||
|
Signer { account: BOB, identity: Some((42, "Bob", false)) },
|
||||||
|
&[(VOTE_ACCOUNT, 10)],
|
||||||
|
);
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
|
||||||
|
assert_eq!(db_status(&state, id_of(&r)).await.0, "pending");
|
||||||
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
||||||
|
indexer.finalized(Some(100));
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||||
|
assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 6: the search by hash went no further than 20 comments; copies could hide the real one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn copies_never_hide_the_real_comment() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
// An indexer that would not filter: pages, one after the other.
|
||||||
|
indexer.lax(true);
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
|
for n in 0..120 {
|
||||||
|
indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
||||||
|
}
|
||||||
|
indexer.pay(300, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let s = c.status(&b).await;
|
||||||
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
||||||
|
// The indexer is asked for what may settle: the comments of the bound account;
|
||||||
|
// for a Ğecko code, those paid to the vote account.
|
||||||
|
indexer.lax(false);
|
||||||
|
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let filters = indexer.filters();
|
||||||
|
assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}");
|
||||||
|
assert!(
|
||||||
|
filters.iter().any(|f| {
|
||||||
|
let some = &f["transfersByCommentId"]["some"];
|
||||||
|
some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10"
|
||||||
|
}),
|
||||||
|
"{filters:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 5: a Ğecko code keeps its ballot; the background task settles it, the
|
||||||
|
/// page closed — even paid at the last moment, read once more at its end.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
age(&state, "expires_at", -5, id_of(&b)).await;
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||||
|
// Its end come while no indexer answers: kept and read again, for an hour at most.
|
||||||
|
let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
age(&state, "expires_at", -5, id_of(&late)).await;
|
||||||
|
indexer.silent(true);
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "pending");
|
||||||
|
age(&state, "expires_at", -3601, id_of(&late)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 7: the research note promises everyone can check their own vote with its
|
||||||
|
/// salt. The receipt goes to the one who asked, never to anyone else.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0"));
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let url = format!("/api/ballots/{}/receipt", id_of(&b));
|
||||||
|
let (s, headers, r) = c.call_with_headers("GET", &url, None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default();
|
||||||
|
assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}");
|
||||||
|
assert_eq!(
|
||||||
|
(r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()),
|
||||||
|
(Some("ld1"), Some("confirmed"), Some(100), Some("Alice"))
|
||||||
|
);
|
||||||
|
assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE)));
|
||||||
|
// It holds: its lines and its salt seal the remark the chain carries.
|
||||||
|
let lines: Vec<ld_engine::ballot::Line> = r["lines"]
|
||||||
|
.as_array()
|
||||||
|
.expect("lines")
|
||||||
|
.iter()
|
||||||
|
.map(|l| ld_engine::ballot::Line {
|
||||||
|
proposal_id: l["proposalId"].as_str().expect("p").into(),
|
||||||
|
revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"),
|
||||||
|
choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
let doc_id = r["doc"].as_str().expect("doc");
|
||||||
|
let salt = r["salt"].as_str().expect("salt");
|
||||||
|
let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal");
|
||||||
|
assert_eq!(
|
||||||
|
(sealed.comment.as_str(), sealed.remark_hash.as_str()),
|
||||||
|
(comment_of(&b), r["remarkHash"].as_str().expect("h"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r["canonical"].as_str(),
|
||||||
|
Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str())
|
||||||
|
);
|
||||||
|
// Each line says which article it is.
|
||||||
|
assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1"));
|
||||||
|
// In her history, never in anyone else's.
|
||||||
|
let mine = c.ok("GET", "/api/ballots", None).await;
|
||||||
|
assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2)));
|
||||||
|
assert_eq!(mine["ballots"][0]["status"], "confirmed");
|
||||||
|
let mut yvv = c.other();
|
||||||
|
yvv.login("Yvv").await;
|
||||||
|
assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([]));
|
||||||
|
let mut nobody = c.other();
|
||||||
|
assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point 8: an identity known only from a vote on the chain does not hold its name
|
||||||
|
/// against a person who joins by a team link; a Ğ1 identity that signed in does.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_name_known_only_from_a_chain_vote_stays_free() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut admin, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
admin.login_admin("Yvv").await;
|
||||||
|
let doc = admin.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })]
|
||||||
|
{
|
||||||
|
let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
indexer.pay(block, comment_of(&b), signer);
|
||||||
|
}
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
// Charlie has since signed in with his Ğ1 identity.
|
||||||
|
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'")
|
||||||
|
.bind(ld_server::util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("seen");
|
||||||
|
let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 });
|
||||||
|
let v = admin.ok("POST", "/api/access/team-links", Some(link)).await;
|
||||||
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||||
|
let mut charlie = admin.other();
|
||||||
|
let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||||
|
let mut alice = admin.other();
|
||||||
|
let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+483
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
"squidStatus": {
|
||||||
|
"height": 3407492
|
||||||
|
},
|
||||||
"txComments": {
|
"txComments": {
|
||||||
|
"pageInfo": {
|
||||||
|
"hasNextPage": false,
|
||||||
|
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
|
||||||
|
},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "0000196944-60692-000003",
|
"id": "0000196944-60692-000003",
|
||||||
@@ -85,6 +92,482 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0000821130-de04d-000008",
|
||||||
|
"blockNumber": 821130,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||||
|
"identity": {
|
||||||
|
"index": 3947,
|
||||||
|
"name": "Jade971",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||||
|
"toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc",
|
||||||
|
"amount": "5000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0001281169-c9002-000006",
|
||||||
|
"blockNumber": 1281169,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||||
|
"identity": {
|
||||||
|
"index": 7210,
|
||||||
|
"name": "Tell",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||||
|
"toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq",
|
||||||
|
"amount": "18000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0001303965-1b924-000006",
|
||||||
|
"blockNumber": 1303965,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||||
|
"identity": {
|
||||||
|
"index": 13370,
|
||||||
|
"name": "Florence76",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||||
|
"toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J",
|
||||||
|
"amount": "1500"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0001810083-37092-000004",
|
||||||
|
"blockNumber": 1810083,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||||
|
"identity": null
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||||
|
"toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK",
|
||||||
|
"amount": "58900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0001810417-7e2d8-000003",
|
||||||
|
"blockNumber": 1810417,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||||
|
"identity": null
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||||
|
"toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6",
|
||||||
|
"amount": "15000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0001850707-782ab-000003",
|
||||||
|
"blockNumber": 1850707,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||||
|
"identity": {
|
||||||
|
"index": 13722,
|
||||||
|
"name": "LibrA",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||||
|
"toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb",
|
||||||
|
"amount": "70680"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0002197191-e9abb-000006",
|
||||||
|
"blockNumber": 2197191,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||||
|
"identity": {
|
||||||
|
"index": 17203,
|
||||||
|
"name": "Elodietexier",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||||
|
"toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn",
|
||||||
|
"amount": "3534"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0002727091-bd26d-000003",
|
||||||
|
"blockNumber": 2727091,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||||
|
"identity": {
|
||||||
|
"index": 18509,
|
||||||
|
"name": "RobBiovoix",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||||
|
"toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r",
|
||||||
|
"amount": "5000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0002825411-05f96-000003",
|
||||||
|
"blockNumber": 2825411,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||||
|
"identity": {
|
||||||
|
"index": 18506,
|
||||||
|
"name": "BrunoAlmeida",
|
||||||
|
"isMember": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||||
|
"toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk",
|
||||||
|
"amount": "5000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0002939378-e9fef-000006",
|
||||||
|
"blockNumber": 2939378,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"identity": {
|
||||||
|
"index": 18467,
|
||||||
|
"name": "Vavafra",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||||
|
"amount": "15000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0002945277-0f2cb-000003",
|
||||||
|
"blockNumber": 2945277,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||||
|
"identity": {
|
||||||
|
"index": 18493,
|
||||||
|
"name": "moidann",
|
||||||
|
"isMember": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||||
|
"toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k",
|
||||||
|
"amount": "3000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003114157-3fc38-000006",
|
||||||
|
"blockNumber": 3114157,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"identity": {
|
||||||
|
"index": 18467,
|
||||||
|
"name": "Vavafra",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB",
|
||||||
|
"amount": "4000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003124959-a6247-000003",
|
||||||
|
"blockNumber": 3124959,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||||
|
"identity": {
|
||||||
|
"index": 7377,
|
||||||
|
"name": "Sylvaine",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||||
|
"toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3",
|
||||||
|
"amount": "5000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003128170-4521a-000003",
|
||||||
|
"blockNumber": 3128170,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||||
|
"identity": {
|
||||||
|
"index": 8912,
|
||||||
|
"name": "Altheaagathe",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||||
|
"toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK",
|
||||||
|
"amount": "40000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003226425-d15c3-000009",
|
||||||
|
"blockNumber": 3226425,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||||
|
"identity": {
|
||||||
|
"index": 11102,
|
||||||
|
"name": "Philp",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||||
|
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||||
|
"amount": "14604"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003227886-5270d-000003",
|
||||||
|
"blockNumber": 3227886,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||||
|
"identity": {
|
||||||
|
"index": 16572,
|
||||||
|
"name": "zola",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||||
|
"toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx",
|
||||||
|
"amount": "6000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003229458-31a61-000006",
|
||||||
|
"blockNumber": 3229458,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||||
|
"identity": {
|
||||||
|
"index": 11102,
|
||||||
|
"name": "Philp",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||||
|
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||||
|
"amount": "12170"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003297346-087b4-000006",
|
||||||
|
"blockNumber": 3297346,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"identity": {
|
||||||
|
"index": 18467,
|
||||||
|
"name": "Vavafra",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||||
|
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||||
|
"amount": "10000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003360722-61edb-000006",
|
||||||
|
"blockNumber": 3360722,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||||
|
"identity": {
|
||||||
|
"index": 14694,
|
||||||
|
"name": "Coca-Cola",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||||
|
"toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv",
|
||||||
|
"amount": "6085"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003384655-8fbc0-000003",
|
||||||
|
"blockNumber": 3384655,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||||
|
"identity": {
|
||||||
|
"index": 4018,
|
||||||
|
"name": "GeraldineGarrigues",
|
||||||
|
"isMember": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||||
|
"toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm",
|
||||||
|
"amount": "3000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003399415-51dbe-000003",
|
||||||
|
"blockNumber": 3399415,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||||
|
"identity": {
|
||||||
|
"index": 18493,
|
||||||
|
"name": "moidann",
|
||||||
|
"isMember": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||||
|
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||||
|
"amount": "10000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0003399566-d43e7-000003",
|
||||||
|
"blockNumber": 3399566,
|
||||||
|
"remark": "Merci ",
|
||||||
|
"author": {
|
||||||
|
"id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||||
|
"identity": {
|
||||||
|
"index": 18630,
|
||||||
|
"name": "moidann12",
|
||||||
|
"isMember": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfersByCommentId": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||||
|
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||||
|
"amount": "15000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,19 @@ async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected()
|
|||||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
|
||||||
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
|
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
|
||||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
|
||||||
|
// An objection (« redit » / « contredit », ADR 0010) is between two articles: never a module.
|
||||||
|
let i2 = unit(&doc, "I2")["proposals"][0]["id"].as_str().expect("i2").to_owned();
|
||||||
|
let title_v = doc["modules"]["title"]["inForce"]["id"].as_str().expect("title").to_owned();
|
||||||
|
for (from, other) in [(seed_order, i2.as_str()), (i2.as_str(), seed_order), (i2.as_str(), title_v.as_str())] {
|
||||||
|
let (s, v) = yvv
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{from}/objections"),
|
||||||
|
Some(json!({ "otherId": other, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("other")), "{from} → {other}");
|
||||||
|
}
|
||||||
// A module's inertia is voted like an article's.
|
// A module's inertia is voted like an article's.
|
||||||
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
|
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
|
||||||
assert_eq!(i["existing"], false);
|
assert_eq!(i["existing"], false);
|
||||||
@@ -425,11 +438,21 @@ async fn another_title_enters_into_force_and_its_translations_are_to_review() {
|
|||||||
let doc = yvv.doc("essai").await;
|
let doc = yvv.doc("essai").await;
|
||||||
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
|
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
|
||||||
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
|
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
|
||||||
// A typo of the title in force is corrected without a vote, and the document follows.
|
// A typo of the title in force is corrected without a vote, by the path of any typo
|
||||||
|
// (ADR 0010): someone else's waits for the title's author, then the document follows.
|
||||||
let fixed = "Acte d’engagement de la certification";
|
let fixed = "Acte d’engagement de la certification";
|
||||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
|
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
|
||||||
assert_eq!(r["edit"], "typo");
|
assert_eq!((r["edit"].as_str(), r["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
||||||
|
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], new_title, "nothing written yet");
|
||||||
|
let tid = r["typo"]["id"].as_str().expect("typo").to_owned();
|
||||||
|
assert_eq!(yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
||||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
|
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
|
||||||
|
// An order is never corrected: neither by its author nor by anyone else.
|
||||||
|
let doc = yvv.doc("essai").await;
|
||||||
|
let order = order_of(&doc, "conclusion")["inForce"]["id"].as_str().expect("order").to_owned();
|
||||||
|
let (s, v) =
|
||||||
|
chiara.call("PATCH", &format!("/api/proposals/{order}"), Some(json!({ "title": "", "body": "x" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||||
// The test group never publishes; the title of the publication is covered by `render`.
|
// The test group never publishes; the title of the publication is covered by `render`.
|
||||||
let n: i64 = sqlx::query_scalar(
|
let n: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
|
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
//! Objections « redit » / « contredit » and the messages of trustinWallet
|
||||||
|
//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The
|
||||||
|
//! three days of a deadline pass on the server's clock, never with a sleep.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadlines, open_db, seed};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
router: axum::Router,
|
||||||
|
cookie: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
async fn new() -> (Self, AppState) {
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
(Self { router: app(state.clone()), cookie: None }, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other(&self) -> Self {
|
||||||
|
Self { router: self.router.clone(), cookie: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
|
if let Some(c) = &self.cookie {
|
||||||
|
req = req.header(header::COOKIE, c);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.expect("request");
|
||||||
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||||
|
let status = res.status();
|
||||||
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||||
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||||
|
if raw.starts_with("ld_session=") {
|
||||||
|
self.cookie = Some(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||||
|
let (s, v) = self.call(method, uri, body).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status and the error code of a call expected to fail.
|
||||||
|
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
||||||
|
let (s, v) = self.call(method, uri, body).await;
|
||||||
|
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(&mut self, name: &str, admin: bool) {
|
||||||
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc(&mut self, group: &str) -> Value {
|
||||||
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||||
|
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||||
|
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn propose(&mut self, doc: &Value, body: Value) -> String {
|
||||||
|
let id = doc["doc"]["id"].as_str().expect("id");
|
||||||
|
self.ok("POST", &format!("/api/docs/{id}/proposals"), Some(body)).await["id"].as_str().expect("id").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn object(&mut self, from: &str, other: &str, kind: &str, reason: &str) -> Value {
|
||||||
|
self.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{from}/objections"),
|
||||||
|
Some(json!({ "otherId": other, "kind": kind, "reason": reason })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn messages(&mut self) -> Value {
|
||||||
|
self.ok("GET", "/api/messages", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detail(&mut self, id: &str) -> Value {
|
||||||
|
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||||
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
||||||
|
doc["units"]
|
||||||
|
.as_array()
|
||||||
|
.expect("units")
|
||||||
|
.iter()
|
||||||
|
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
|
||||||
|
.find(|p| p["id"] == id)
|
||||||
|
.expect("version")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gestures(m: &Value) -> Vec<&str> {
|
||||||
|
m["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPEATS: &str = "Elle répète l'unicité du compte, déjà en vigueur.";
|
||||||
|
const CONTRADICTS: &str = "Elle dit le contraire de la réciprocité en vigueur.";
|
||||||
|
|
||||||
|
/// C28, by the API: a « redit », the proposal no longer votable, its author changes
|
||||||
|
/// the article it repeats, the objector lifts.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector_lifts() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let e1 = unit(&doc, "E1");
|
||||||
|
let (e1_unit, e1_id) =
|
||||||
|
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||||||
|
let b = chiara
|
||||||
|
.propose(
|
||||||
|
&doc,
|
||||||
|
json!({ "title": "Un seul compte", "body": "Je n'ai qu'un seul compte membre, et je m'y tiens.", "submit": true }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let o = yvv.object(&b, &e1_id, "redit", REPEATS).await;
|
||||||
|
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, never the text in force");
|
||||||
|
let oid = o["id"].as_str().expect("id").to_owned();
|
||||||
|
|
||||||
|
let d = chiara.doc("essai").await;
|
||||||
|
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
|
||||||
|
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
|
||||||
|
assert_eq!(find(&d, &e1_id)["objections"], 1);
|
||||||
|
|
||||||
|
// Nobody votes on it any more: neither one vote nor a grouped one.
|
||||||
|
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
|
||||||
|
let many = yvv
|
||||||
|
.err(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{doc_id}/votes"),
|
||||||
|
Some(json!({ "lines": [{ "proposalId": b, "choice": "for" }] })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(many, (StatusCode::CONFLICT, "objection".into()));
|
||||||
|
|
||||||
|
// Its author is told, in her messages, what to do; the objector has nothing yet.
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
assert_eq!(m["unread"], 1);
|
||||||
|
let raised = &m["messages"][0];
|
||||||
|
assert_eq!((raised["kind"].as_str(), raised["topic"].as_str()), (Some("objection_raised"), Some("objection")));
|
||||||
|
assert_eq!(gestures(raised), ["respond"]);
|
||||||
|
assert_eq!(raised["objection"]["kind"], "redit");
|
||||||
|
assert_eq!(raised["objection"]["heldVersion"]["id"], b.as_str());
|
||||||
|
assert_eq!(raised["objection"]["other"]["id"], e1_id.as_str());
|
||||||
|
assert_eq!(raised["objection"]["objector"], "Yvv");
|
||||||
|
assert_eq!(raised["doc"]["groupSlug"], "essai");
|
||||||
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||||
|
let det = chiara.detail(&b).await;
|
||||||
|
let ob = &det["objections"][0];
|
||||||
|
assert_eq!(
|
||||||
|
(ob["held"].as_bool(), ob["iAnswer"].as_bool(), ob["phase"].as_str()),
|
||||||
|
(Some(true), Some(true), Some("author"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Before an answer there is nothing to reiterate; only the objector (or an administrator) lifts.
|
||||||
|
let r = yvv.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||||
|
assert_eq!(r, (StatusCode::CONFLICT, "nothing_to_reiterate".into()));
|
||||||
|
assert_eq!(chiara.err("POST", &format!("/api/objections/{oid}/lift"), None).await.0, StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// A linked proposal must go on the article repeated (or her own): never elsewhere, never by someone else.
|
||||||
|
let e2_unit = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||||
|
let elsewhere =
|
||||||
|
json!({ "unitId": e2_unit, "objectionId": oid, "title": "Réciprocité", "body": "Autre.", "submit": true });
|
||||||
|
let (s, _) = chiara.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(elsewhere)).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
let by_yvv = json!({ "unitId": e1_unit, "objectionId": oid, "title": "Unicité", "body": "Autre.", "submit": true });
|
||||||
|
assert_eq!(
|
||||||
|
yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(by_yvv)).await.0,
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chiara changes the article it repeats: a proposal on E1, linked to the objection.
|
||||||
|
let linked = chiara
|
||||||
|
.propose(
|
||||||
|
&doc,
|
||||||
|
json!({
|
||||||
|
"unitId": e1_unit, "objectionId": oid, "title": "Unicité du compte", "submit": true,
|
||||||
|
"body": "Je m'engage sur l'honneur à n'avoir qu'un seul compte membre, et à m'y tenir.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let det = yvv.detail(&b).await;
|
||||||
|
let ob = &det["objections"][0];
|
||||||
|
assert_eq!((ob["phase"].as_str(), ob["answer"].as_str()), (Some("objector"), Some("proposal")));
|
||||||
|
assert_eq!(ob["answerId"], linked.as_str());
|
||||||
|
assert_eq!((ob["canLift"].as_bool(), ob["canReiterate"].as_bool()), (Some(true), Some(true)));
|
||||||
|
|
||||||
|
// The objector is asked: lift or reiterate, by three days; her message is dealt with.
|
||||||
|
let m = yvv.messages().await;
|
||||||
|
assert_eq!(m["unread"], 1);
|
||||||
|
let asked = &m["messages"][0];
|
||||||
|
assert_eq!(asked["kind"], "objection_answered");
|
||||||
|
assert_eq!(gestures(asked), ["lift", "reiterate"]);
|
||||||
|
assert!(asked["dueAt"].as_str().is_some_and(|d| d > asked["createdAt"].as_str().expect("at")), "{asked}");
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new());
|
||||||
|
assert_eq!(m["messages"][0]["outcome"], "answered");
|
||||||
|
assert_eq!(m["unread"], 0, "answering reads one's message");
|
||||||
|
|
||||||
|
// Yvv lifts from his message: the proposal is votable again, the message offers nothing more.
|
||||||
|
yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
|
let d = yvv.doc("essai").await;
|
||||||
|
assert_eq!(find(&d, &b)["votable"], true);
|
||||||
|
let m = yvv.messages().await;
|
||||||
|
assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it");
|
||||||
|
assert_eq!(m["messages"][0]["outcome"], "lifted");
|
||||||
|
yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
// Lifted is lifted.
|
||||||
|
let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
|
assert_eq!(again, (StatusCode::CONFLICT, "lifted".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time;
|
||||||
|
/// a reiteration tells the author again; without an answer, lifted at the deadline.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() {
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||||
|
let title = "Certifier largement";
|
||||||
|
let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements.";
|
||||||
|
let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await;
|
||||||
|
let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||||
|
let phase =
|
||||||
|
|d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64());
|
||||||
|
|
||||||
|
// A typo fix answers nothing.
|
||||||
|
let typo = chiara
|
||||||
|
.ok(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{b}"),
|
||||||
|
Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(typo["edit"], "typo");
|
||||||
|
assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1)));
|
||||||
|
|
||||||
|
// Rewording answers: the objector is asked.
|
||||||
|
let reworded =
|
||||||
|
"Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées.";
|
||||||
|
let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await;
|
||||||
|
assert_eq!(r["edit"], "substantive");
|
||||||
|
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message");
|
||||||
|
let det = yvv.detail(&b).await;
|
||||||
|
assert_eq!(phase(&det), (Some("objector".into()), Some(1)));
|
||||||
|
assert_eq!(det["objections"][0]["answer"], "revision");
|
||||||
|
let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned();
|
||||||
|
|
||||||
|
// One cycle at a time: another rewording changes neither the messages nor the deadline.
|
||||||
|
state.clock.advance(3600);
|
||||||
|
let again = format!("{reworded} Et à les revoir.");
|
||||||
|
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await;
|
||||||
|
assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str());
|
||||||
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
||||||
|
|
||||||
|
// Reiterated: the objection stands, the author is told again, a second cycle opens.
|
||||||
|
yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||||
|
assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2)));
|
||||||
|
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false);
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect();
|
||||||
|
assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first");
|
||||||
|
assert_eq!(gestures(&m["messages"][0]), ["respond"]);
|
||||||
|
let m = yvv.messages().await;
|
||||||
|
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0));
|
||||||
|
|
||||||
|
// A new answer, then nobody: three days later it is lifted by itself.
|
||||||
|
let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.";
|
||||||
|
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await;
|
||||||
|
assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2)));
|
||||||
|
state.clock.advance(3 * 86_400 - 60);
|
||||||
|
deadlines::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet");
|
||||||
|
state.clock.advance(120);
|
||||||
|
let swept = deadlines::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(swept.lifted, 1);
|
||||||
|
let det = yvv.detail(&b).await;
|
||||||
|
assert_eq!(
|
||||||
|
(det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()),
|
||||||
|
(Some("lifted"), Some("deadline"))
|
||||||
|
);
|
||||||
|
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true);
|
||||||
|
let m = yvv.messages().await;
|
||||||
|
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_rewording_of_the_other_side_asks_the_objector_too() {
|
||||||
|
let (mut luna, _) = Client::new().await;
|
||||||
|
luna.login("Luna", false).await;
|
||||||
|
let mut chiara = luna.other();
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = luna.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = luna.doc("essai").await;
|
||||||
|
let a = luna
|
||||||
|
.propose(
|
||||||
|
&doc,
|
||||||
|
json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let b = chiara
|
||||||
|
.propose(
|
||||||
|
&doc,
|
||||||
|
json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await;
|
||||||
|
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from");
|
||||||
|
// Luna rewords the earlier one: the pair changed, the objector judges again.
|
||||||
|
luna.ok(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{a}"),
|
||||||
|
Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let det = yvv.detail(&b).await;
|
||||||
|
assert_eq!(
|
||||||
|
(det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()),
|
||||||
|
(Some("objector"), Some("other"))
|
||||||
|
);
|
||||||
|
assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let mut admin = chiara.other();
|
||||||
|
admin.login("Admin", true).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||||
|
let b = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||||
|
.await;
|
||||||
|
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||||
|
chiara
|
||||||
|
.ok(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{b}"),
|
||||||
|
Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// An administrator never speaks for the objector...
|
||||||
|
assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN);
|
||||||
|
// ...but may lift.
|
||||||
|
admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
|
let det = admin.detail(&b).await;
|
||||||
|
assert_eq!(det["objections"][0]["lift"], "admin");
|
||||||
|
assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin");
|
||||||
|
|
||||||
|
// Withdrawing either version ends a standing objection.
|
||||||
|
let c = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||||
|
.await;
|
||||||
|
let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||||
|
chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await;
|
||||||
|
let lifted: (String, Option<String>) = {
|
||||||
|
let det = yvv.detail(&c).await;
|
||||||
|
let ob = det["objections"].as_array().expect("o").iter().find(|o| o["id"] == oid2.as_str()).expect("o").clone();
|
||||||
|
(ob["phase"].as_str().expect("p").to_owned(), ob["lift"].as_str().map(str::to_owned))
|
||||||
|
};
|
||||||
|
assert_eq!(lifted, ("lifted".into(), Some("withdrawn".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn what_an_objection_may_be_about() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||||
|
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||||
|
let e3 = unit(&doc, "E3");
|
||||||
|
let (v1, v2) = (e3["proposals"][0]["id"].as_str().expect("v1"), e3["proposals"][1]["id"].as_str().expect("v2"));
|
||||||
|
let post = |from: &str, other: &str, kind: &str, reason: &str| {
|
||||||
|
(format!("/api/proposals/{from}/objections"), json!({ "otherId": other, "kind": kind, "reason": reason }))
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
(post(&e1, &e2, "contredit", CONTRADICTS), "both_in_force"),
|
||||||
|
(post(v1, v2, "redit", REPEATS), "same_unit"),
|
||||||
|
(post(&e1, &e1, "redit", REPEATS), "other"),
|
||||||
|
(post(v1, &e1, "signal", REPEATS), "kind"),
|
||||||
|
(post(v1, &e1, "redit", "court"), "reason"),
|
||||||
|
];
|
||||||
|
for ((uri, body), code) in cases {
|
||||||
|
let (s, e) = yvv.err("POST", &uri, Some(body)).await;
|
||||||
|
assert_eq!((s, e.as_str()), (StatusCode::BAD_REQUEST, code));
|
||||||
|
}
|
||||||
|
// A draft is nobody's business yet.
|
||||||
|
let draft = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Brouillon", "body": "Pas encore soumis au vote.", "submit": false }))
|
||||||
|
.await;
|
||||||
|
let (s, _) = yvv
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{v1}/objections"),
|
||||||
|
Some(json!({ "otherId": draft, "kind": "redit", "reason": REPEATS })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
// One standing objection per person and pair.
|
||||||
|
yvv.object(v1, &e1, "redit", REPEATS).await;
|
||||||
|
let twice = yvv
|
||||||
|
.err(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{e1}/objections"),
|
||||||
|
Some(json!({ "otherId": v1, "kind": "contredit", "reason": CONTRADICTS })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(twice, (StatusCode::CONFLICT, "objection_exists".into()));
|
||||||
|
// Someone else may object to the same pair.
|
||||||
|
chiara.object(v1, &e1, "contredit", CONTRADICTS).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Votes cast before the objection stay counted; the version cannot enter into
|
||||||
|
/// force while it is held, and enters once the objection is lifted.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||||
|
let b = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||||
|
.await;
|
||||||
|
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||||
|
// Every member of the group for it (written as they would have been before the objection).
|
||||||
|
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("members");
|
||||||
|
for m in &members {
|
||||||
|
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
|
||||||
|
.bind(&b)
|
||||||
|
.bind(m)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("vote");
|
||||||
|
}
|
||||||
|
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
|
||||||
|
let d = chiara.doc("essai").await;
|
||||||
|
assert_eq!(find(&d, &b)["tally"]["for"].as_u64(), Some(members.len() as u64), "the votes stay");
|
||||||
|
assert!(find(&d, &b)["evaluation"]["reached"].as_bool().expect("reached"));
|
||||||
|
let lifted = yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||||
|
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_message_is_read_by_its_recipient_only() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", true).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||||
|
let b = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||||
|
.await;
|
||||||
|
yvv.object(&b, &e1, "redit", REPEATS).await;
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
let mid = m["messages"][0]["id"].as_str().expect("id").to_owned();
|
||||||
|
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||||
|
// Not even an administrator reads or marks someone else's message.
|
||||||
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||||
|
assert_eq!(yvv.call("POST", &format!("/api/messages/{mid}/read"), None).await.0, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||||
|
let mut nobody = chiara.other();
|
||||||
|
assert_eq!(nobody.call("GET", "/api/messages", None).await.0, StatusCode::UNAUTHORIZED);
|
||||||
|
// Marked as read, it stays in the box.
|
||||||
|
assert_eq!(chiara.ok("POST", &format!("/api/messages/{mid}/read"), None).await["unread"], 0);
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
assert_eq!((m["messages"][0]["read"].as_bool(), gestures(&m["messages"][0])), (Some(true), vec!["respond"]));
|
||||||
|
// All at once.
|
||||||
|
let c = chiara
|
||||||
|
.propose(&doc, json!({ "title": "Deux", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||||
|
.await;
|
||||||
|
yvv.object(&c, &e1, "contredit", CONTRADICTS).await;
|
||||||
|
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||||
|
assert_eq!(chiara.ok("POST", "/api/messages/read", None).await["unread"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deadline passed while the server was stopped is dealt with at the next start.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
|
||||||
|
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let url = format!("sqlite://{}", path.display());
|
||||||
|
let (b, oid) = {
|
||||||
|
let db = open_db(&url).await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
let mut chiara = Client { router: app(state.clone()), cookie: None };
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||||
|
let b = chiara
|
||||||
|
.propose(
|
||||||
|
&doc,
|
||||||
|
json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||||
|
let reworded = "Un seul compte membre par être humain pour la certifcation.";
|
||||||
|
chiara
|
||||||
|
.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": reworded })))
|
||||||
|
.await;
|
||||||
|
// And a typo fix of Yvv's on it, waiting for Chiara.
|
||||||
|
let fixed = reworded.replace("certifcation", "certification");
|
||||||
|
let fix =
|
||||||
|
yvv.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": fixed }))).await;
|
||||||
|
assert_eq!(fix["edit"], "typo_pending");
|
||||||
|
state.db.close().await;
|
||||||
|
(b, oid)
|
||||||
|
};
|
||||||
|
// Four days later, the server starts again.
|
||||||
|
let db = open_db(&url).await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
state.clock.advance(4 * 86_400);
|
||||||
|
let swept = deadlines::sweep(&state).await.expect("start");
|
||||||
|
assert_eq!((swept.lifted, swept.applied), (1, 1));
|
||||||
|
let (phase, lift): (String, Option<String>) = sqlx::query_as("SELECT phase, lift FROM signals WHERE id = ?")
|
||||||
|
.bind(&oid)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("o");
|
||||||
|
assert_eq!((phase.as_str(), lift.as_deref()), ("lifted", Some("deadline")));
|
||||||
|
let body: String =
|
||||||
|
sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
|
||||||
|
assert_eq!(body, "Un seul compte membre par être humain pour la certification.");
|
||||||
|
state.db.close().await;
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The contradiction signals of before become objections « contredit », without
|
||||||
|
/// loss, on a base migrated up to the version before (0001 to 0007).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contradiction_signals_of_before_become_contredits() {
|
||||||
|
use sqlx::sqlite::SqliteConnectOptions;
|
||||||
|
use std::str::FromStr;
|
||||||
|
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
|
||||||
|
let mut before = sqlx::migrate!("./migrations");
|
||||||
|
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
|
||||||
|
before.run(&pool).await.expect("up to 0007");
|
||||||
|
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
|
||||||
|
// The documents as the image of before wrote them (its amendments read tables of now).
|
||||||
|
seed::documents(&state).await.expect("seed");
|
||||||
|
let id = |code: &'static str, which: &'static str| {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
let sql = if which == "in_force" {
|
||||||
|
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||||
|
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'in_force'"
|
||||||
|
} else {
|
||||||
|
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||||
|
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'open' ORDER BY p.rowid LIMIT 1"
|
||||||
|
};
|
||||||
|
sqlx::query_scalar::<_, String>(sql).bind(code).fetch_one(&pool).await.expect("id")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (e2, k1, e4, e5) =
|
||||||
|
(id("E2", "in_force").await, id("K1", "open").await, id("E4", "open").await, id("E5", "open").await);
|
||||||
|
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-30T00:00:00Z')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("identity");
|
||||||
|
let signal = |sid: &'static str, from: String, to: String, lifted: Option<&'static str>| {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by)
|
||||||
|
VALUES (?, ?, ?, 'Ces deux textes se contredisent.', 'dev:chiara', '2026-10-30T00:00:00Z', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(sid)
|
||||||
|
.bind(from)
|
||||||
|
.bind(to)
|
||||||
|
.bind(lifted.map(|_| "2026-10-30T01:00:00Z"))
|
||||||
|
.bind(lifted)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("signal");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal("s1", k1.clone(), e2.clone(), None).await;
|
||||||
|
signal("s2", e4.clone(), e5.clone(), None).await;
|
||||||
|
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
|
||||||
|
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
|
||||||
|
// The image of now starts on it.
|
||||||
|
let mut all = sqlx::migrate!("./migrations");
|
||||||
|
all.set_ignore_missing(true);
|
||||||
|
all.run(&pool).await.expect("0008");
|
||||||
|
type Row = (String, String, String, Option<String>, Option<String>);
|
||||||
|
let rows: Vec<Row> = sqlx::query_as("SELECT id, kind, phase, held_id, lift FROM signals ORDER BY id")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("rows");
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
("s1".into(), "contredit".into(), "author".into(), Some(k1.clone()), None),
|
||||||
|
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
|
||||||
|
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
|
||||||
|
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
|
||||||
|
],
|
||||||
|
"K1 against the text in force; E5 written after E4 in the same second"
|
||||||
|
);
|
||||||
|
// Nothing lost: reasons, authors and dates as they were.
|
||||||
|
let kept: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM signals WHERE reason = 'Ces deux textes se contredisent.' AND author_id = 'dev:chiara'
|
||||||
|
AND created_at = '2026-10-30T00:00:00Z'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(kept, 4);
|
||||||
|
// And the server reads them as objections: K1 and E5 are no longer votable.
|
||||||
|
let st = ld_server::docs::load_state(
|
||||||
|
&state,
|
||||||
|
&sqlx::query_scalar::<_, String>("SELECT id FROM documents WHERE group_slug = 'essai'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("doc"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("state");
|
||||||
|
assert!(
|
||||||
|
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -210,6 +210,34 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
|||||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
|
||||||
|
let mut yvv = Client::new().await;
|
||||||
|
yvv.login("Yvv", true).await;
|
||||||
|
let doc = the_doc(&mut yvv, "essai").await;
|
||||||
|
// Two articles of a single open version each: one of them is held (ADR 0010).
|
||||||
|
let (i2, k1) = (version(&doc, "I2", "0"), version(&doc, "K1", "0"));
|
||||||
|
let (s, v) = yvv
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{i2}/objections"),
|
||||||
|
Some(json!({ "otherId": k1, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
// Nobody can vote on it while it is held: one module fewer to vote, for everyone.
|
||||||
|
let d = decisions(&mut yvv, "essai").await;
|
||||||
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
||||||
|
// The list of the group still counts it among the proposals in progress: it is open.
|
||||||
|
let g = yvv.get("/api/groups/essai").await;
|
||||||
|
let open = doc["units"]
|
||||||
|
.as_array()
|
||||||
|
.expect("units")
|
||||||
|
.iter()
|
||||||
|
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|p| p["status"] == "open").count() as u64);
|
||||||
|
assert_eq!(g["docs"][0]["open"].as_u64(), Some(open.sum::<u64>()));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
||||||
let mut admin = Client::new().await;
|
let mut admin = Client::new().await;
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010), end to end on an
|
||||||
|
//! in-memory database. What a typo is does not change (`ld_engine::edit`); who
|
||||||
|
//! applies it, and when, does.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, deadlines, open_db, seed};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
router: axum::Router,
|
||||||
|
cookie: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
async fn new() -> (Self, AppState) {
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
(Self { router: app(state.clone()), cookie: None }, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other(&self) -> Self {
|
||||||
|
Self { router: self.router.clone(), cookie: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
|
if let Some(c) = &self.cookie {
|
||||||
|
req = req.header(header::COOKIE, c);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.expect("request");
|
||||||
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||||
|
let status = res.status();
|
||||||
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||||
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||||
|
if raw.starts_with("ld_session=") {
|
||||||
|
self.cookie = Some(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||||
|
let (s, v) = self.call(method, uri, body).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
||||||
|
let (s, v) = self.call(method, uri, body).await;
|
||||||
|
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(&mut self, name: &str, admin: bool) {
|
||||||
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc(&mut self, group: &str) -> Value {
|
||||||
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||||
|
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||||
|
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn propose(&mut self, doc: &Value, title: &str, body: &str) -> String {
|
||||||
|
let id = doc["doc"]["id"].as_str().expect("id");
|
||||||
|
let v = self
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{id}/proposals"),
|
||||||
|
Some(json!({ "title": title, "body": body, "submit": true })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
v["id"].as_str().expect("id").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fix(&mut self, id: &str, title: &str, body: &str) -> Value {
|
||||||
|
self.ok("PATCH", &format!("/api/proposals/{id}"), Some(json!({ "title": title, "body": body }))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detail(&mut self, id: &str) -> Value {
|
||||||
|
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn messages(&mut self) -> Value {
|
||||||
|
self.ok("GET", "/api/messages", None).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||||
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gestures(v: &Value) -> Vec<&str> {
|
||||||
|
v["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last(history: &Value) -> &Value {
|
||||||
|
history.as_array().expect("history").last().expect("entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE: &str = "Réciprocité";
|
||||||
|
const VOTED: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagemnts.";
|
||||||
|
const FIX_1: &str = "Je m'engage à ne certifier que des personnes qui respectent ces engagemnts.";
|
||||||
|
const FIX_2: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagements.";
|
||||||
|
|
||||||
|
/// C30, by the API: someone else's typo on a proposal waits for its author, who
|
||||||
|
/// validates it from her messages — and keeps the hand while it is not in force.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||||
|
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
|
||||||
|
let w = yvv.fix(&p, TITLE, FIX_1).await;
|
||||||
|
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
||||||
|
assert!(w["typo"]["dueAt"].is_string());
|
||||||
|
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
// Nothing written yet: the text stays, and the pending fix shows with its diff.
|
||||||
|
let d = yvv.detail(&p).await;
|
||||||
|
assert_eq!(d["proposal"]["body"], VOTED);
|
||||||
|
let t = &d["typos"][0];
|
||||||
|
assert_eq!((t["status"].as_str(), t["writer"].as_str()), (Some("pending"), Some("Yvv")));
|
||||||
|
assert!(t["diff"].as_array().is_some_and(|x| !x.is_empty()));
|
||||||
|
assert_eq!(gestures(t), Vec::<&str>::new(), "the writer does not answer");
|
||||||
|
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), ["validate", "refuse"]);
|
||||||
|
assert_eq!(yvv.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// Its author is told, by a message with the gestures.
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
let msg = &m["messages"][0];
|
||||||
|
assert_eq!((msg["kind"].as_str(), msg["topic"].as_str()), (Some("typo_pending"), Some("typo")));
|
||||||
|
assert_eq!(gestures(msg), ["validate", "refuse"]);
|
||||||
|
assert_eq!(msg["typo"]["writer"], "Yvv");
|
||||||
|
assert_eq!(msg["typo"]["version"]["id"], p.as_str());
|
||||||
|
|
||||||
|
// Validated: the fix is in the text, the vote stays.
|
||||||
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
||||||
|
let d = chiara.detail(&p).await;
|
||||||
|
assert_eq!(d["proposal"]["body"], FIX_1);
|
||||||
|
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
||||||
|
let h = last(&d["history"]);
|
||||||
|
assert_eq!(
|
||||||
|
(h["kind"].as_str(), h["author"].as_str(), h["typo"]["answeredBy"].as_str()),
|
||||||
|
(Some("typo"), Some("Yvv"), Some("Chiara"))
|
||||||
|
);
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
assert_eq!(
|
||||||
|
(gestures(&m["messages"][0]).len(), m["messages"][0]["outcome"].as_str(), m["unread"].as_i64()),
|
||||||
|
(0, Some("applied"), Some(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
// The author keeps the hand while it is not in force: she takes it back, then validates it again.
|
||||||
|
assert_eq!(gestures(&d["typos"][0]), ["refuse"]);
|
||||||
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
||||||
|
let d = chiara.detail(&p).await;
|
||||||
|
assert_eq!(d["proposal"]["body"], VOTED);
|
||||||
|
assert_eq!(last(&d["history"])["kind"], "revert");
|
||||||
|
assert_eq!(gestures(&d["typos"][0]), ["validate"]);
|
||||||
|
chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||||
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
||||||
|
|
||||||
|
// Her own fixes apply at once, as before.
|
||||||
|
let own = chiara.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await;
|
||||||
|
assert_eq!(own["edit"], "typo");
|
||||||
|
// A change of meaning stays refused to anyone else.
|
||||||
|
let s = yvv
|
||||||
|
.err("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": TITLE, "body": "Tout autre chose." })))
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, (StatusCode::CONFLICT, "substantive_not_author".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C31, by the API: on a text in force, only a validated fix corrects it — by its
|
||||||
|
/// author, or an administrator for a starting text; else by itself after three days.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_a_text_in_force_only_a_validated_typo_corrects_it() {
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", true).await;
|
||||||
|
let doc = chiara.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let corrections = || {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let doc_id = doc_id.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM outbox WHERE doc_id = ? AND reason = 'correction typographique'",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.expect("outbox")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||||
|
let e1_id = e1["id"].as_str().expect("id").to_owned();
|
||||||
|
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||||
|
let w = chiara.fix(&e1_id, e1["title"].as_str().expect("t"), &fixed).await;
|
||||||
|
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("admin")));
|
||||||
|
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
assert_ne!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str(), "not in force before it is validated");
|
||||||
|
assert_eq!(corrections().await, 0, "nothing republished");
|
||||||
|
// A starting text has no author: an administrator answers, from his messages.
|
||||||
|
assert_eq!(gestures(&yvv.messages().await["messages"][0]), ["validate", "refuse"]);
|
||||||
|
assert_eq!(chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
||||||
|
yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||||
|
assert_eq!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str());
|
||||||
|
assert_eq!(corrections().await, 1, "republished once validated");
|
||||||
|
// In force, an answer is final.
|
||||||
|
assert_eq!(
|
||||||
|
yvv.err("POST", &format!("/api/typos/{tid}/refuse"), None).await,
|
||||||
|
(StatusCode::CONFLICT, "settled".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nobody answers: three days later it applies by itself.
|
||||||
|
let e2 = unit(&doc, "E2")["inForce"].clone();
|
||||||
|
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
||||||
|
let e2_fixed = e2["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||||
|
chiara.fix(&e2_id, e2["title"].as_str().expect("t"), &e2_fixed).await;
|
||||||
|
state.clock.advance(3 * 86_400 - 60);
|
||||||
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
||||||
|
state.clock.advance(120);
|
||||||
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 1);
|
||||||
|
let d = chiara.detail(&e2_id).await;
|
||||||
|
assert_eq!(d["proposal"]["body"], e2_fixed.as_str());
|
||||||
|
assert_eq!(last(&d["history"])["typo"]["answeredBy"], Value::Null);
|
||||||
|
assert_eq!(last(&d["history"])["typo"]["deadline"], true);
|
||||||
|
assert_eq!(corrections().await, 2);
|
||||||
|
let m = yvv.messages().await;
|
||||||
|
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
||||||
|
|
||||||
|
// An administrator's own fix on a starting text applies at once.
|
||||||
|
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||||
|
let i1_id = i1["id"].as_str().expect("id");
|
||||||
|
let body = i1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||||
|
assert_eq!(yvv.fix(i1_id, i1["title"].as_str().expect("t"), &body).await["edit"], "typo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_a_text_in_force_by_an_author_she_answers_and_a_refusal_is_final() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||||
|
// W = 2 in the test group: two voices for make it pass.
|
||||||
|
for c in [&mut chiara, &mut yvv] {
|
||||||
|
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
}
|
||||||
|
assert_eq!(chiara.detail(&p).await["proposal"]["status"], "in_force");
|
||||||
|
let tid = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
||||||
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], VOTED);
|
||||||
|
// In force: no going back on the answer.
|
||||||
|
let back = chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||||
|
assert_eq!(back, (StatusCode::CONFLICT, "settled".into()));
|
||||||
|
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), Vec::<&str>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two fixes waiting on one text: each is judged against the voted text; once one
|
||||||
|
/// applies, the other is written over a text that is gone — stale, « recharge ».
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_pending_typos_on_one_text_the_second_goes_stale() {
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let mut luna = chiara.other();
|
||||||
|
luna.login("Luna", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||||
|
let t1 = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let w2 = luna.fix(&p, TITLE, FIX_2).await;
|
||||||
|
assert_eq!(w2["edit"], "typo_pending", "each one slip from the voted text");
|
||||||
|
let t2 = w2["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
chiara.ok("POST", &format!("/api/typos/{t1}/validate"), None).await;
|
||||||
|
let d = chiara.detail(&p).await;
|
||||||
|
let second = d["typos"].as_array().expect("t").iter().find(|t| t["id"] == t2.as_str()).expect("t2").clone();
|
||||||
|
assert_eq!((second["status"].as_str(), gestures(&second).len()), (Some("stale"), 0));
|
||||||
|
let late = chiara.err("POST", &format!("/api/typos/{t2}/validate"), None).await;
|
||||||
|
assert_eq!(late, (StatusCode::CONFLICT, "changed".into()));
|
||||||
|
let m = chiara.messages().await;
|
||||||
|
let msg =
|
||||||
|
m["messages"].as_array().expect("m").iter().find(|x| x["typo"]["id"] == t2.as_str()).expect("msg").clone();
|
||||||
|
assert_eq!((msg["outcome"].as_str(), gestures(&msg).len()), (Some("stale"), 0));
|
||||||
|
// The deadline never applies it.
|
||||||
|
state.clock.advance(4 * 86_400);
|
||||||
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
||||||
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
||||||
|
// Written again over the text of now, it waits as any other.
|
||||||
|
assert_eq!(luna.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await["edit"], "typo_pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_change_of_substance_or_a_withdrawal_leaves_pending_typos_stale() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||||
|
let t = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||||
|
chiara
|
||||||
|
.fix(&p, TITLE, "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.")
|
||||||
|
.await;
|
||||||
|
assert_eq!(chiara.detail(&p).await["typos"][0]["status"], "stale");
|
||||||
|
assert_eq!(
|
||||||
|
chiara.err("POST", &format!("/api/typos/{t}/validate"), None).await,
|
||||||
|
(StatusCode::CONFLICT, "changed".into())
|
||||||
|
);
|
||||||
|
let q = chiara.propose(&doc, "Autre", VOTED).await;
|
||||||
|
yvv.fix(&q, "Autre", FIX_1).await;
|
||||||
|
chiara.ok("POST", &format!("/api/proposals/{q}/withdraw"), None).await;
|
||||||
|
let stale: i64 = {
|
||||||
|
let d = chiara.detail(&q).await;
|
||||||
|
d["typos"].as_array().expect("t").iter().filter(|t| t["status"] == "stale").count() as i64
|
||||||
|
};
|
||||||
|
assert_eq!(stale, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn translations_stay_editorial() {
|
||||||
|
let (mut chiara, _) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||||
|
yvv.ok(
|
||||||
|
"PUT",
|
||||||
|
&format!("/api/proposals/{p}/translations/en"),
|
||||||
|
Some(json!({ "title": "Reciprocity", "body": "I only certify people who keep these commitments." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let d = chiara.detail(&p).await;
|
||||||
|
assert_eq!(d["proposal"]["translations"]["en"]["title"], "Reciprocity", "without any validation");
|
||||||
|
assert_eq!(d["typos"].as_array().expect("t").len(), 0);
|
||||||
|
assert_eq!(chiara.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text without author: only the administrators who open its group are told,
|
||||||
|
/// and a message shows only while its recipient opens the group.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_administrators_who_open_the_group_are_told() {
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
let mut yvv = chiara.other();
|
||||||
|
yvv.login("Yvv", true).await;
|
||||||
|
let mut luna = chiara.other();
|
||||||
|
luna.login("Luna", true).await;
|
||||||
|
let leave = |who: &'static str| {
|
||||||
|
let db = state.db.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query("DELETE FROM memberships WHERE identity_id = ? AND group_slug = 'essai'")
|
||||||
|
.bind(who)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.expect("leave");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
leave("dev:luna").await;
|
||||||
|
let doc = chiara.doc("essai").await;
|
||||||
|
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||||
|
let body = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||||
|
let w = chiara.fix(e1["id"].as_str().expect("id"), e1["title"].as_str().expect("t"), &body).await;
|
||||||
|
assert_eq!(w["typo"]["answerer"], "admin");
|
||||||
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
||||||
|
assert_eq!(luna.messages().await["messages"].as_array().expect("m").len(), 0, "Luna does not open the test group");
|
||||||
|
leave("dev:yvv").await;
|
||||||
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0, "no longer shown");
|
||||||
|
}
|
||||||
@@ -16,7 +16,14 @@
|
|||||||
//! - where the electorate is the web of trust (Monnaie libre), only its members
|
//! - where the electorate is the web of trust (Monnaie libre), only its members
|
||||||
//! vote (2026-10-21, independent review: one person, one vote) — through the
|
//! vote (2026-10-21, independent review: one person, one vote) — through the
|
||||||
//! Ğ1 chain, one transaction per vote, for the identity that signs it, member
|
//! Ğ1 chain, one transaction per vote, for the identity that signs it, member
|
||||||
//! when the vote is confirmed (2026-10-30, ADR 0008).
|
//! when the vote is confirmed (2026-10-30, ADR 0008);
|
||||||
|
//! - a typo fix to someone's version waits for that author; to an authorless
|
||||||
|
//! version (a seeded text), for an administrator — `answers_typo`;
|
||||||
|
//! - an objection is lifted by the person who raised it, or by an
|
||||||
|
//! administrator; it is reiterated by the person who raised it only —
|
||||||
|
//! `lifts_objection` / `reiterates_objection`;
|
||||||
|
//! - each person reads their own messages only — `reads_message`
|
||||||
|
//! (2026-10-31, lot F, ADR 0010).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -150,6 +157,33 @@ pub fn manages_access(identity: &Identity) -> bool {
|
|||||||
identity.admin
|
identity.admin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who answers a typo fix someone else wrote on a version (2026-10-30, L13):
|
||||||
|
/// its author; for a text without author (a starting text), an administrator of
|
||||||
|
/// trustinWallet. Whoever answers for a version also applies their own fix at once.
|
||||||
|
pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool {
|
||||||
|
match version_author {
|
||||||
|
Some(author) => identity.id == author,
|
||||||
|
None => identity.admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who lifts an objection (2026-10-30, L12): the person who raised it, or an
|
||||||
|
/// administrator of trustinWallet.
|
||||||
|
pub fn lifts_objection(identity: &Identity, objector: &str) -> bool {
|
||||||
|
identity.id == objector || identity.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who reiterates an objection: only the person who raised it — an
|
||||||
|
/// administrator lifts, never speaks for someone else.
|
||||||
|
pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool {
|
||||||
|
identity.id == objector
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message of trustinWallet is read by its recipient only.
|
||||||
|
pub fn reads_message(identity: &Identity, recipient: &str) -> bool {
|
||||||
|
identity.id == recipient
|
||||||
|
}
|
||||||
|
|
||||||
/// The groups a fresh deployment starts with. Ordered as displayed.
|
/// The groups a fresh deployment starts with. Ordered as displayed.
|
||||||
pub fn default_groups() -> Vec<Group> {
|
pub fn default_groups() -> Vec<Group> {
|
||||||
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
|
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
|
||||||
@@ -251,6 +285,37 @@ mod tests {
|
|||||||
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
|
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() {
|
||||||
|
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
||||||
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
||||||
|
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
||||||
|
assert!(answers_typo(&chiara, Some("chiara")));
|
||||||
|
assert!(!answers_typo(&other, Some("chiara")));
|
||||||
|
assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author");
|
||||||
|
assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers");
|
||||||
|
assert!(!answers_typo(&other, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() {
|
||||||
|
let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
||||||
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
||||||
|
let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
||||||
|
assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna"));
|
||||||
|
assert!(lifts_objection(&admin, "luna"));
|
||||||
|
assert!(!reiterates_objection(&admin, "luna"));
|
||||||
|
assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_is_read_by_its_recipient_only() {
|
||||||
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
||||||
|
let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
||||||
|
assert!(reads_message(&luna, "luna"));
|
||||||
|
assert!(!reads_message(&admin, "luna"), "not even an administrator");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_atelier_du_canal() {
|
fn no_atelier_du_canal() {
|
||||||
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 0003 — Corriger n'est pas voter
|
# 0003 — Corriger n'est pas voter
|
||||||
|
|
||||||
|
> Complétée par [0010](0010-objections-et-coquilles-validees.md) (2026-10-31) : la règle de la
|
||||||
|
> coquille ne change pas ; la coquille d'un autre attend son auteur (un administrateur pour un
|
||||||
|
> texte du seed), ou 3 jours, et en vigueur seule une coquille validée change le texte.
|
||||||
|
|
||||||
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
|
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
|
||||||
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout
|
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout
|
||||||
membre peut corriger un texte en vigueur, qui est republié aussitôt.
|
membre peut corriger un texte en vigueur, qui est republié aussitôt.
|
||||||
@@ -24,7 +28,7 @@ ou de fond), jamais contre la correction précédente :
|
|||||||
ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne.
|
ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne.
|
||||||
|
|
||||||
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
|
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
|
||||||
antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ.
|
antérieurs « à reconfirmer », réponse aux objections qui l'attendent (0010) ; refusé sur un texte en vigueur ou de départ.
|
||||||
L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps
|
L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps
|
||||||
donne un conflit. Les brouillons s'éditent librement.
|
donne un conflit. Les brouillons s'éditent librement.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 0004 — Contradictions : signalées par les personnes, bloquantes
|
# 0004 — Contradictions : signalées par les personnes, bloquantes
|
||||||
|
|
||||||
|
> **Remplacée** par [0010](0010-objections-et-coquilles-validees.md) (2026-10-31) : le
|
||||||
|
> signalement devient l'objection « contredit », à côté de « redit » ; elle retient la dernière
|
||||||
|
> arrivée des deux versions, qui ne se vote plus jusqu'à sa levée ; l'auteur répond, la personne
|
||||||
|
> qui objecte lève ou réitère, et sans réponse de sa part sous 3 jours elle est levée. Les
|
||||||
|
> signalements existants sont devenus des « contredit » (migration 0008).
|
||||||
|
|
||||||
**Contexte.** « Il faut que 2 unités modulaires ne puissent pas passer en vigueur si elles se
|
**Contexte.** « Il faut que 2 unités modulaires ne puissent pas passer en vigueur si elles se
|
||||||
contredisent. » La contradiction est une question de sens : aucune règle ne la détecte sans
|
contredisent. » La contradiction est une question de sens : aucune règle ne la détecte sans
|
||||||
se tromper, et l'IA est écartée pour l'instant.
|
se tromper, et l'IA est écartée pour l'instant.
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ s_k = M_k + (1 − M_k)·(1 − p̃^G_k) ⇔ G_k = ln(1 − (s_k − M_k)/(1
|
|||||||
- **C reste un plancher** : à T ≤ C, R = C + B^W ; une seule voix ne fait jamais passer un
|
- **C reste un plancher** : à T ≤ C, R = C + B^W ; une seule voix ne fait jamais passer un
|
||||||
texte dans Monnaie libre ;
|
texte dans Monnaie libre ;
|
||||||
- le groupe d'essai (W = 2, C = 1) : la référence vaut 100 %, ramenée à 50 % ; G reste fini ;
|
- le groupe d'essai (W = 2, C = 1) : la référence vaut 100 %, ramenée à 50 % ; G reste fini ;
|
||||||
il faut les deux voix, à tout cran.
|
il faut les deux voix, à tout cran. Cette garde tient à B^W : dès 16 membres, 0,1^W passe sous
|
||||||
|
la précision du calcul (1 + 10⁻¹⁶ = 1), et une voix seule suffit alors, C valant 1. Monnaie
|
||||||
|
libre n'est pas concernée (C = 50).
|
||||||
|
|
||||||
Les réglages de la formule disent les deux, dans cet ordre (lot D) : la mesure telle quelle — sa
|
Les réglages de la formule disent les deux, dans cet ordre (lot D) : la mesure telle quelle — sa
|
||||||
part est celle de la valeur elle-même, une médiane de 2,5 votants sur 4 fait 62,5 %, jamais
|
part est celle de la valeur elle-même, une médiane de 2,5 votants sur 4 fait 62,5 %, jamais
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
> Complète [0002](0002-vote-permanent.md) (vote permanent) et [0006](0006-acces-connect-trustinwallet.md)
|
> Complète [0002](0002-vote-permanent.md) (vote permanent) et [0006](0006-acces-connect-trustinwallet.md)
|
||||||
> (accès). Décision d'Yvv du 2026-10-30 (revue, L9) ; le format du bulletin, la voie du
|
> (accès). Décision d'Yvv du 2026-10-30 (revue, L9) ; le format du bulletin, la voie du
|
||||||
> portefeuille et les réglages sont les choix par défaut d'orch (Q5, Q6, Q7), isolés pour être
|
> portefeuille et les réglages sont les choix par défaut d'orch (Q5, Q6, Q7), isolés pour être
|
||||||
> changés sans toucher au reste.
|
> changés sans toucher au reste. Corrigé le 2026-10-31 après la relecture indépendante de
|
||||||
|
> `74f96be` (lot B2) : finalité, recherche filtrée et paginée, tâche de fond, plafond, bulletin
|
||||||
|
> Ğecko, bulletins lâchés, reçu.
|
||||||
|
|
||||||
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
|
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
|
||||||
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
|
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
|
||||||
@@ -77,27 +79,55 @@ fait signer par l'extension de portefeuille du navigateur (interface standard `i
|
|||||||
l'offrent), avec **polkadot-api chargé au seul moment de signer**, et l'envoie à un nœud Ğ1
|
l'offrent), avec **polkadot-api chargé au seul moment de signer**, et l'envoie à un nœud Ğ1
|
||||||
(`LD_VOTE_NODES`). Sans portefeuille dans le navigateur : **Ğecko**, par un lien et un QR code
|
(`LD_VOTE_NODES`). Sans portefeuille dans le navigateur : **Ğecko**, par un lien et un QR code
|
||||||
`g1://<compte>?amount=0.1&comment=ld1:<H>` (un virement commenté, ce que Ğecko sait faire) ;
|
`g1://<compte>?amount=0.1&comment=ld1:<H>` (un virement commenté, ce que Ğecko sait faire) ;
|
||||||
ce bulletin-là n'est lié à aucun compte, et la page attend.
|
ce bulletin-là n'est lié à aucun compte : seul un commentaire **payé** le tranche (voir la
|
||||||
|
confirmation), et le serveur le guette, la page ouverte ou non.
|
||||||
|
|
||||||
|
La page joint un nœud **avant** de demander la signature (comme polkadot-api, qui lit le nonce
|
||||||
|
et le runtime) : un nœud muet ne reçoit jamais rien, la page le dit et lâche le bulletin.
|
||||||
|
|
||||||
### La confirmation — `crates/ld-server/src/ballots.rs`, `indexer.rs`
|
### La confirmation — `crates/ld-server/src/ballots.rs`, `indexer.rs`
|
||||||
|
|
||||||
Le serveur lit l'indexeur (duniter-squid, `LD_INDEXERS`) :
|
Le serveur lit l'indexeur (duniter-squid, PostGraphile, `LD_INDEXERS`) :
|
||||||
|
|
||||||
```graphql
|
```graphql
|
||||||
query Ballot($hash: String!) {
|
query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) {
|
||||||
txComments(condition: {hash: $hash}, orderBy: BLOCK_NUMBER_ASC, first: 20) {
|
squidStatus { height }
|
||||||
|
txComments(condition: {hash: $hash}, filter: $filter,
|
||||||
|
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
nodes { id blockNumber remark
|
nodes { id blockNumber remark
|
||||||
author { id identity { index name isMember } }
|
author { id identity { index name isMember } }
|
||||||
transfersByCommentId { nodes { fromId toId amount } } } }
|
transfersByCommentId { nodes { fromId toId amount } } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`hash` = `0x` + blake2b-256 des octets de la remarque. Il garde la première occurrence, dans
|
`hash` = `0x` + blake2b-256 des octets de la remarque. Le `filter` ne demande que ce qui peut
|
||||||
l'ordre de la chaîne, qui ne vient pas d'un autre compte que celui lié, puis exige :
|
trancher : pour un bulletin lié, `{authorId: {equalTo: <compte lié>}}` ; pour un bulletin Ğecko,
|
||||||
|
`{transfersByCommentId: {some: {toId: {equalTo: <compte des votes>}, amount:
|
||||||
|
{greaterThanOrEqualTo: "<montant>"}}}}`. Les commentaires viennent dans l'ordre de la chaîne,
|
||||||
|
**50 par page, page après page** (au plus 20 pages par lecture), jusqu'à celui qui tranche :
|
||||||
|
des copies ne masquent jamais le vrai commentaire (l'ancienne requête s'arrêtait à 20 ; la
|
||||||
|
remarque « Merci » en compte déjà 26 sur la chaîne).
|
||||||
|
|
||||||
|
**La finalité.** L'indexeur sert la meilleure chaîne, blocs pas encore définitifs compris.
|
||||||
|
`squidStatus` n'expose que `name`, `height` et `hash` (pas de `finalizedHeight`) ; `height` est
|
||||||
|
le dernier bloc définitif traité. Relevé le 2026-10-31 sur indexer.duniter.org, par
|
||||||
|
`{ squidStatus { height } blocks(orderBy: HEIGHT_DESC, first: 1) { nodes { height } } }` :
|
||||||
|
`height` reste **3 blocs** derrière le plus récent bloc servi (3 407 340 contre 3 407 343, puis
|
||||||
|
3 407 342 contre 3 407 345…), les 2 à 3 blocs de la finalité de Duniter v2. Seul un commentaire
|
||||||
|
d'un bloc ≤ `height` compte, ou refuse ; au-dessus, le bulletin attend.
|
||||||
|
|
||||||
|
Le moteur (`ld_engine::ballot::judge`) juge à nouveau ce qui revient. Un indexeur qui ignorerait
|
||||||
|
le filtre ne change d'abord que le nombre de pages, mais au-delà de 1 000 copies nues placées
|
||||||
|
avant le vrai commentaire (20 pages de 50), celui-ci n'est plus lu et le bulletin reste en attente
|
||||||
|
(relecture de `7ffa4fd`) : cas théorique, un PostGraphile sans ce filtre répond par une erreur. Il garde le premier commentaire, dans l'ordre de la
|
||||||
|
chaîne, qui n'est pas une copie : pour un bulletin lié, un commentaire d'un autre compte est une
|
||||||
|
copie ; pour un bulletin Ğecko, **un commentaire sans le virement est une copie** (une remarque
|
||||||
|
seule ne coûte rien à envoyer). Puis il exige :
|
||||||
|
|
||||||
1. la remarque exacte ;
|
1. la remarque exacte ;
|
||||||
2. dans la même transaction, un virement **du signataire vers le compte des votes**, d'au moins
|
2. dans la même transaction, un virement **du signataire vers le compte des votes**, d'au moins
|
||||||
le montant ;
|
le montant (`transfer` sinon — seulement pour un bulletin lié : son propre compte s'est trompé) ;
|
||||||
3. une identité Ğ1 au signataire (`no_identity` sinon) ;
|
3. une identité Ğ1 au signataire (`no_identity` sinon) ;
|
||||||
4. que cette identité soit **membre maintenant** (`not_member` sinon) — par la règle de
|
4. que cette identité soit **membre maintenant** (`not_member` sinon) — par la règle de
|
||||||
trustinWallet.
|
trustinWallet.
|
||||||
@@ -107,12 +137,20 @@ avec le bloc et le rang de l'évènement ; le balayage d'adoption tourne aussit
|
|||||||
demandé le bulletin voit ces votes comme « ton vote » — et seulement ceux de ses bulletins : une
|
demandé le bulletin voit ces votes comme « ton vote » — et seulement ceux de ses bulletins : une
|
||||||
autre session ne voit jamais ce qu'a voté la même identité ailleurs.
|
autre session ne voit jamais ce qu'a voté la même identité ailleurs.
|
||||||
|
|
||||||
Le serveur relit l'indexeur pour chaque bulletin en attente toutes les 10 secondes (tâche de
|
**La tâche de fond** passe toutes les 10 secondes et lit **50 bulletins en attente au plus, le
|
||||||
fond) et quand la page demande (au plus une lecture toutes les 3 secondes par bulletin). Un
|
moins récemment lu d'abord** (jamais lu, puis la plus ancienne lecture) : chaque bulletin en
|
||||||
bulletin non vu en **24 heures** est lâché. Cas d'erreur, chacun testé : indexeur muet (le
|
attente est lu au moins toutes les ⌈n / 50⌉ passes, que la page l'attende ou non (un code Ğecko,
|
||||||
bulletin reste en attente), signataire non membre ou sans identité (refusé, et dit), mauvais
|
une fenêtre fermée). Une lecture de l'indexeur prend 0,5 à 2,1 s (mesuré le 31/10), donc une passe
|
||||||
montant ou mauvais destinataire (refusé), déjà confirmé (rien ne change), révision changée entre
|
de 50 bulletins dure 25 à 100 s : il faudrait de l'ordre de 40 000 à 170 000 bulletins en attente
|
||||||
temps (le vote porte sur la révision votée : « à reconfirmer »).
|
pour qu'un seul ne soit pas lu dans ses 24 heures. La page, elle, fait lire son bulletin au plus toutes les 3 secondes.
|
||||||
|
|
||||||
|
Un bulletin non vu en **24 heures** est **lu une dernière fois à son échéance**, puis lâché ;
|
||||||
|
s'aucun indexeur ne répond, ou si son commentaire attend un bloc définitif, il reste en attente
|
||||||
|
et relu, une heure au plus. Cas d'erreur, chacun testé : indexeur muet (le bulletin reste en
|
||||||
|
attente), signataire non membre ou sans identité (refusé, et dit), mauvais montant ou mauvais
|
||||||
|
destinataire (refusé si lié, ignoré si Ğecko), copie nue ou mal payée d'un bulletin Ğecko
|
||||||
|
(ignorée), bloc pas encore définitif (en attente), déjà confirmé (rien ne change), révision
|
||||||
|
changée entre temps (le vote porte sur la révision votée : « à reconfirmer »).
|
||||||
|
|
||||||
### Le dernier fait foi
|
### Le dernier fait foi
|
||||||
|
|
||||||
@@ -122,9 +160,24 @@ un bulletin plus récent sur la chaîne. « Retirer mon vote » est un bulletin
|
|||||||
`none`. Les transactions d'un même compte gardent l'ordre de ses envois (le nonce) ; le désordre
|
`none`. Les transactions d'un même compte gardent l'ordre de ses envois (le nonce) ; le désordre
|
||||||
ne vient que de l'ordre de lecture de l'indexeur, et la règle l'absorbe.
|
ne vient que de l'ordre de lecture de l'indexeur, et la règle l'absorbe.
|
||||||
|
|
||||||
Un bulletin que le portefeuille n'a pas signé (refus, fonds insuffisants, compte absent) est lâché
|
### Lâcher ou garder un bulletin
|
||||||
aussitôt par la page (`DELETE /api/ballots/{id}` : `expired`, raison `dropped`), pour ne pas
|
|
||||||
rester « en attente » une journée. Jamais un bulletin confirmé.
|
**Rien n'est parti** : la page lâche le bulletin aussitôt (`DELETE /api/ballots/{id}` :
|
||||||
|
`expired`, raison `dropped`), pour ne pas le montrer « en attente » une journée. C'est le cas :
|
||||||
|
- d'un refus du portefeuille, d'un compte absent, de fonds insuffisants ;
|
||||||
|
- d'un nœud muet **avant** la signature (la page joint le nœud d'abord) ;
|
||||||
|
- d'une fenêtre fermée avant la signature ; si le portefeuille demande encore à ce moment (sa
|
||||||
|
propre fenêtre peut rester ouverte), le bulletin attend sa réponse : refusé, il est lâché ;
|
||||||
|
- d'un refus de la chaîne : rejet du nœud (transaction invalide), ou transaction incluse mais
|
||||||
|
défaite (le virement a échoué, `batchAll` défait tout : aucune remarque).
|
||||||
|
|
||||||
|
**La transaction a pu partir** — signée, puis le nœud ou le portefeuille se tait — : le bulletin
|
||||||
|
est gardé, et la tâche de fond le guette jusqu'à son échéance. Un **code Ğecko** montré est gardé
|
||||||
|
aussi, fenêtre fermée : on peut payer plus tard, depuis le téléphone. Jamais un bulletin confirmé
|
||||||
|
n'est lâché.
|
||||||
|
|
||||||
|
« Ton vote », sous l'article : un bulletin en attente demandé **avant** un vote confirmé sur la
|
||||||
|
même ligne ne s'y montre plus ; un bulletin demandé après, si.
|
||||||
|
|
||||||
### Le vote groupé
|
### Le vote groupé
|
||||||
|
|
||||||
@@ -132,7 +185,14 @@ Un panier : article par article ; « tout pour » / « tout contre » pour une s
|
|||||||
document — jamais pour un article dont plusieurs versions sont en concurrence : l'interface le
|
document — jamais pour un article dont plusieurs versions sont en concurrence : l'interface le
|
||||||
montre, on choisit sa version. Puis **un geste** : une écriture (groupe géré), ou **une
|
montre, on choisit sa version. Puis **un geste** : une écriture (groupe géré), ou **une
|
||||||
transaction** (chaîne) pour toutes les lignes, au même prix qu'une seule (0,1 Ğ1 par transaction,
|
transaction** (chaîne) pour toutes les lignes, au même prix qu'une seule (0,1 Ğ1 par transaction,
|
||||||
pas par ligne ; 200 lignes au plus, 20 bulletins en attente par session).
|
pas par ligne ; 200 lignes au plus).
|
||||||
|
|
||||||
|
**Le plafond vaut par identité, quelle que soit la session** : 20 bulletins au plus à la fois,
|
||||||
|
en attente **ou lâchés depuis moins d'une heure** — lâcher un bulletin ne libère pas sa place
|
||||||
|
aussitôt (la relecture avait créé 100 bulletins et 3 500 lignes en une session, par `DELETE`).
|
||||||
|
La tâche de fond **purge** les bulletins lâchés, lignes comprises : un jour après qu'un
|
||||||
|
portefeuille ne les a pas signés, une semaine après qu'ils n'ont pas été vus à temps (sous
|
||||||
|
l'article, il le dit d'ici là). Un bulletin lâché n'a jamais compté : rien d'un vote ne se perd.
|
||||||
|
|
||||||
### Ce que voit la chaîne
|
### Ce que voit la chaîne
|
||||||
|
|
||||||
@@ -140,13 +200,39 @@ Qu'un compte (et son identité) a envoyé 0,1 Ğ1 au compte des votes avec une r
|
|||||||
tel bloc : **qui a voté et quand, jamais quoi** — ni le document, ni le nombre de lignes, ni les
|
tel bloc : **qui a voté et quand, jamais quoi** — ni le document, ni le nombre de lignes, ni les
|
||||||
choix. Le solde du compte des votes est public : il dit combien de votes ont été envoyés.
|
choix. Le solde du compte des votes est public : il dit combien de votes ont été envoyés.
|
||||||
|
|
||||||
|
Recoupée avec les totaux affichés en direct, la chaîne peut laisser deviner un choix (un total
|
||||||
|
qui bouge juste après un vote) : une décision d'Yvv attend (secret par paliers, ou totaux en
|
||||||
|
direct). En attendant, les totaux affichés passent par une seule fonction,
|
||||||
|
`DocState::shown_tally` (et `shown_evaluation`), où une publication par paliers se branchera ;
|
||||||
|
l'adoption lit `tally`. Deux lectures n'y passent pas encore et lisent les totaux en direct :
|
||||||
|
l'aperçu des propositions bien cotées (`docs.rs`, `unit_states` et `proposal_states`) et les
|
||||||
|
textes publiés (`publish.rs`). Elles s'y brancheront avec la décision d'Yvv.
|
||||||
|
|
||||||
### Ce que sait le serveur
|
### Ce que sait le serveur
|
||||||
|
|
||||||
Les bulletins, leurs lignes, leurs sels, la session qui les a demandés et l'identité qui les a
|
Les bulletins, leurs lignes, leurs sels, la session qui les a demandés et l'identité qui les a
|
||||||
signés : il sait qui a voté quoi, comme pour un vote direct, et ne montre que les totaux. Il ne
|
signés : il sait qui a voté quoi, comme pour un vote direct, et ne montre que les totaux. Il ne
|
||||||
peut pas inventer un vote d'une identité (il faudrait sa signature sur la chaîne) ; il pourrait en
|
peut pas inventer un vote d'une identité (il faudrait sa signature sur la chaîne) ; il pourrait en
|
||||||
taire un. Chaque vote compté se vérifie contre la chaîne en recalculant H depuis les lignes et le
|
taire un. Chaque vote compté se vérifie contre la chaîne en recalculant H depuis les lignes et le
|
||||||
sel — ce que personne d'autre ne peut faire tant que les sels restent au serveur.
|
sel.
|
||||||
|
|
||||||
|
### Le reçu
|
||||||
|
|
||||||
|
La note de recherche promet que chacun peut vérifier son propre vote avec son sel. Le serveur
|
||||||
|
rend donc le bulletin **à l'identité qui l'a demandé, et à elle seule** (404 pour toute autre) :
|
||||||
|
|
||||||
|
- `GET /api/ballots/{id}/receipt` — le reçu, à télécharger (`recu-vote-<date>-<id>.json`) : le
|
||||||
|
document, le compte lié, les lignes (version, révision, choix, et pour lire : l'article et son
|
||||||
|
titre), le sel, la forme canonique, H, la remarque, son empreinte pour l'indexeur, et où en est
|
||||||
|
le bulletin (état, bloc, évènement, signataire, identité comptée) ;
|
||||||
|
- `GET /api/ballots` — son historique, le plus récent d'abord (sans les bulletins jamais signés).
|
||||||
|
|
||||||
|
L'interface les montre dans la fenêtre de vote (« Télécharger ton reçu », « Tes bulletins ») et
|
||||||
|
sur la page **« Tes bulletins »** (`/bulletins`), qui **vérifie un reçu dans le navigateur**, sans
|
||||||
|
rien demander au serveur : elle recalcule la forme canonique et H (`web/src/lib/receipt.ts`, mêmes
|
||||||
|
vecteurs que le moteur) et dit si le reçu donne bien sa remarque — à chercher dans l'historique
|
||||||
|
de son portefeuille, ou sur la chaîne au bloc indiqué. Garder son reçu pour soi : avec le sel,
|
||||||
|
n'importe qui recalculerait le choix.
|
||||||
|
|
||||||
### Les réglages (Q7) — `crates/ld-server/src/config.rs`
|
### Les réglages (Q7) — `crates/ld-server/src/config.rs`
|
||||||
|
|
||||||
@@ -166,7 +252,9 @@ Migration `0006`, additive : `ballots` (le bulletin, son sel, H, `0x`+blake2b-25
|
|||||||
le compte et le montant du moment, l'état `pending | confirmed | refused | expired`, la raison,
|
le compte et le montant du moment, l'état `pending | confirmed | refused | expired`, la raison,
|
||||||
l'identité qui a voté, le signataire, le bloc, le rang, l'évènement, ce qui est entré en vigueur,
|
l'identité qui a voté, le signataire, le bloc, le rang, l'évènement, ce qui est entré en vigueur,
|
||||||
les dates) et `ballot_lines` (version, révision, choix). `votes` gagne `source` (`server` par
|
les dates) et `ballot_lines` (version, révision, choix). `votes` gagne `source` (`server` par
|
||||||
défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `ballot_id`.
|
défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `ballot_id`. Les
|
||||||
|
corrections du 2026-10-31 n'ajoutent pas de migration ; la purge efface les bulletins lâchés
|
||||||
|
(`expired`) passé leur délai, jamais un bulletin confirmé ni refusé, jamais un vote.
|
||||||
|
|
||||||
## Coût
|
## Coût
|
||||||
|
|
||||||
@@ -178,10 +266,21 @@ défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `b
|
|||||||
indexeurs se configurent ; lire les évènements sur un nœud serait l'étape suivante.
|
indexeurs se configurent ; lire les évènements sur un nœud serait l'étape suivante.
|
||||||
- Le droit de vote se vérifie **au moment de la confirmation sur la chaîne** : un membre sorti
|
- Le droit de vote se vérifie **au moment de la confirmation sur la chaîne** : un membre sorti
|
||||||
de la toile ensuite garde ses voix.
|
de la toile ensuite garde ses voix.
|
||||||
- Par Ğecko, le bulletin n'est lié à aucun compte : un autre compte qui recopierait la remarque
|
- Par Ğecko, le bulletin n'est lié à aucun compte. **Une copie nue de la remarque** (sans
|
||||||
et la ferait inclure avant (en payant 0,1 Ğ1) prendrait ce bulletin — ses lignes compteraient
|
virement, gratuite) **est ignorée** : elle ne refuse plus le bulletin. Seul un commentaire payé
|
||||||
pour lui. Par l'extension, le bulletin est lié au compte et la copie est ignorée.
|
le tranche. **Reste possible, la variante « prise par une copie payée »** : un autre compte qui
|
||||||
- Un paiement Ğecko fait après 24 heures ne compte pas (il est perdu pour le vote).
|
voit la remarque (dans la file des transactions, ou sur l'écran) et fait inclure avant une
|
||||||
|
copie **payée** (0,1 Ğ1 vers le compte des votes) prend ce bulletin — ses lignes comptent pour
|
||||||
|
lui, le paiement de la personne arrive après et ne compte plus. Elle coûte 0,1 Ğ1 à qui la tente
|
||||||
|
et se voit sur la chaîne ; un compte sans identité qui paie la copie fait refuser le bulletin
|
||||||
|
(`no_identity`). Par l'extension, le bulletin est lié au compte et toute copie est ignorée.
|
||||||
|
- Par Ğecko, un virement d'un mauvais montant ou vers un autre compte laisse le bulletin en
|
||||||
|
attente (au lieu d'un refus), jusqu'à son échéance : on ne distingue pas une erreur d'une copie.
|
||||||
|
- Un bulletin est lu une dernière fois à son échéance (24 heures) : ce qui est alors sur la
|
||||||
|
chaîne, en bloc définitif, compte encore ; un paiement Ğecko plus tardif est perdu pour le vote.
|
||||||
|
- Une identité Ğ1 créée par un vote sur la chaîne, jamais connectée ici, ne retient pas son nom
|
||||||
|
face à une personne qui arrive par un lien d'équipe (comme tout membre de la toile qui n'est
|
||||||
|
jamais venu) ; une identité Ğ1 qui s'est connectée le garde.
|
||||||
- polkadot-api : environ 180 Ko (66 Ko compressés) chargés au moment de signer seulement ;
|
- polkadot-api : environ 180 Ko (66 Ko compressés) chargés au moment de signer seulement ;
|
||||||
`node_modules` grossit d'environ 77 Mo (son outillage).
|
`node_modules` grossit d'environ 77 Mo (son outillage).
|
||||||
- La chaîne montre la participation de chacun (qui a voté, quand, combien de fois).
|
- La chaîne montre la participation de chacun (qui a voté, quand, combien de fois).
|
||||||
|
|||||||
@@ -85,6 +85,23 @@ personne traduise le nouveau. Le titre du document dans les autres langues suit
|
|||||||
fraîches ou à revoir (`doc_title::sync`, rejoué aussi à chaque mise en file de publication : une
|
fraîches ou à revoir (`doc_title::sync`, rejoué aussi à chaque mise en file de publication : une
|
||||||
coquille ou une traduction du titre en vigueur s'y reporte).
|
coquille ou une traduction du titre en vigueur s'y reporte).
|
||||||
|
|
||||||
|
### Face aux objections et aux coquilles des autres (ADR 0010)
|
||||||
|
|
||||||
|
- **Une objection** (« redit », « contredit ») porte sur deux versions d'articles, jamais sur un
|
||||||
|
module : un titre ou un ordre ne se répète ni ne se contredit au sens d'un article
|
||||||
|
(`objections::raise` n'admet que des textes ; test
|
||||||
|
`the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected`).
|
||||||
|
- **Un ordre ne se corrige pas** (`409 order_fixed`), ni par son auteur ni par un autre : pas de
|
||||||
|
coquille d'un autre sur un ordre. **Une coquille du titre** suit le chemin de toute coquille :
|
||||||
|
celle d'un autre attend l'auteur du titre (un administrateur pour le titre de départ) ou
|
||||||
|
3 jours ; appliquée, elle republie, et `documents.title` suit.
|
||||||
|
- `DocState::votable` couvre les modules comme les articles ; les totaux d'un module se lisent
|
||||||
|
par `shown_tally`, à l'écran et dans `document.json`.
|
||||||
|
- **« Modules à voter »** ne compte pas une version retenue par une objection : personne ne peut
|
||||||
|
la voter tant qu'elle est retenue (un article dont la seule version ouverte est retenue sort
|
||||||
|
du compte, et y revient à la levée). La liste du groupe la compte toujours parmi les
|
||||||
|
propositions en cours, ouvertes.
|
||||||
|
|
||||||
### Publication
|
### Publication
|
||||||
|
|
||||||
`fr.md` ne change pas de forme : les articles dans leur ordre en vigueur, sous le titre en
|
`fr.md` ne change pas de forme : les articles dans leur ordre en vigueur, sous le titre en
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 0010 — Objections « redit » / « contredit », coquilles validées, messagerie
|
||||||
|
|
||||||
|
> Remplace [0004](0004-contradictions-signalees.md) (contradictions signalées) et complète
|
||||||
|
> [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille
|
||||||
|
> ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la
|
||||||
|
> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch.
|
||||||
|
> Lot F, 2026-10-31.
|
||||||
|
|
||||||
|
**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à
|
||||||
|
qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte
|
||||||
|
d'un autre sans lui demander. Yvv, 2026-10-30 :
|
||||||
|
|
||||||
|
- « Faudrait un "redit" en option avec "contredit" → faut rendre plus explicite que l'objection
|
||||||
|
doit être levée par l'auteur de la dernière arrivée pour être une proposition votable. Pour ce
|
||||||
|
qui constitue une répétition → modifier l'article cible ; pour une contradiction → reformuler.
|
||||||
|
→ ping messagerie TrustinWallet → Lever l'objection ou Réitérer l'objection ; si pas de
|
||||||
|
réponse sous 3j, alors "levée". »
|
||||||
|
- « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais auteur a toujours
|
||||||
|
la main tant que c'est pas le texte en vote. Sinon seule une coquille validée corrige le texte
|
||||||
|
en vote. » (« le texte en vote » : le texte en vigueur.)
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
### Les objections — `crates/ld-engine/src/objection.rs`
|
||||||
|
|
||||||
|
- Une objection dit qu'une version **redit** une autre (elle la répète) ou la **contredit**,
|
||||||
|
motif à l'appui (10 à 600 signes). Elle porte sur deux versions de texte de deux articles
|
||||||
|
différents, ouvertes ou en vigueur — jamais deux textes en vigueur (`both_in_force`).
|
||||||
|
- Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis
|
||||||
|
l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur,
|
||||||
|
c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite.
|
||||||
|
- Tant que l'objection tient, la version retenue **ne se vote plus** (vote direct, vote groupé,
|
||||||
|
bulletin de la chaîne : `409 objection`) et **n'entre pas en vigueur**. Les votes déjà faits
|
||||||
|
restent ; un bulletin scellé avant l'objection et confirmé après compte, sans la faire entrer.
|
||||||
|
- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`).
|
||||||
|
- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé**
|
||||||
|
(une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ;
|
||||||
|
pour une contradiction, il **reformule** (une révision de fond de sa version, ou une
|
||||||
|
proposition liée sur son propre article). Une révision de fond de l'une ou l'autre des deux
|
||||||
|
versions vaut réponse ; une coquille, non. Une version du seed n'a pas d'auteur : tout membre
|
||||||
|
du groupe peut y répondre par une proposition liée.
|
||||||
|
- Une réponse donne la main à **la personne qui objecte** : un message lui demande de **lever**
|
||||||
|
ou de **réitérer** dans les **3 jours** ; sans geste de sa part, l'objection est **levée** à
|
||||||
|
l'échéance. Un seul tour à la fois : une autre réponse pendant son tour ne change ni le
|
||||||
|
message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui).
|
||||||
|
- Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de
|
||||||
|
trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection.
|
||||||
|
Une levée relance l'adoption : la version libérée entre en vigueur si ses voix suffisent.
|
||||||
|
- Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`.
|
||||||
|
|
||||||
|
| Qui | Quoi | Effet | Échéance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — |
|
||||||
|
| l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle |
|
||||||
|
| la personne qui objecte | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — |
|
||||||
|
| la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur |
|
||||||
|
| personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — |
|
||||||
|
| un administrateur | lever | levée « par un administrateur » | — |
|
||||||
|
| l'auteur | retirer sa version | l'objection prend fin | — |
|
||||||
|
|
||||||
|
### Les coquilles validées — `crates/ld-engine/src/correction.rs`
|
||||||
|
|
||||||
|
La règle de la coquille (0003, `edit.rs`) ne change pas ; ce qui change, c'est qui la laisse
|
||||||
|
entrer.
|
||||||
|
|
||||||
|
- Sur **son propre texte** (l'auteur de la version ; un administrateur pour un texte du seed),
|
||||||
|
la correction s'applique tout de suite, comme avant.
|
||||||
|
- Sur **celui d'un autre**, elle **attend** : un message à l'auteur de la version (aux
|
||||||
|
administrateurs qui ouvrent le groupe, pour un texte du seed) avec **valider / refuser** ; sans
|
||||||
|
réponse sous **3 jours**, elle s'applique d'elle-même.
|
||||||
|
- **Tant que le texte n'est pas en vigueur, l'auteur garde la main** : il peut revenir sur une
|
||||||
|
correction appliquée (révision « correction retirée ») ou appliquer finalement une correction
|
||||||
|
refusée, tant que le texte est celui sur lequel elle a été écrite.
|
||||||
|
- **En vigueur, seule une correction validée change le texte** : elle attend la validation (ou
|
||||||
|
l'échéance, qui vaut validation), et un refus est définitif.
|
||||||
|
- Deux corrections en attente sur un même texte : chacune est jugée contre le texte voté ; à
|
||||||
|
l'application, la correction doit tomber sur le texte qu'elle a lu — sinon (une autre
|
||||||
|
correction, une révision de fond ou un retrait est passé entre-temps) elle est **périmée**
|
||||||
|
et l'interface demande de recharger.
|
||||||
|
- Les traductions restent éditoriales : elles ne passent pas par une validation.
|
||||||
|
- L'historique dit qui a écrit la correction et qui l'a validée (ou qu'elle est passée à
|
||||||
|
l'échéance) ; la correction en attente se lit, avec son diff, dans « Détails ».
|
||||||
|
- Règle d'accès : `trustin_access::answers_typo`.
|
||||||
|
|
||||||
|
| Qui | Quoi | Effet | Échéance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| l'auteur de la version (un administrateur pour un texte du seed) | corriger une coquille | appliquée tout de suite, les votes restent | — |
|
||||||
|
| tout autre membre | corriger une coquille | en attente ; message à l'auteur (aux administrateurs pour un texte du seed) | 3 jours, puis appliquée |
|
||||||
|
| l'auteur (ou un administrateur pour le seed) | valider / refuser | appliquée / laissée de côté | — |
|
||||||
|
| l'auteur, texte pas encore en vigueur | revenir au texte d'avant, ou appliquer finalement | la main reste à l'auteur | — |
|
||||||
|
| texte en vigueur | — | seule une correction validée (ou passée à l'échéance) le change ; un refus est définitif | — |
|
||||||
|
|
||||||
|
### La messagerie — `crates/ld-server/src/messages.rs`
|
||||||
|
|
||||||
|
Une boîte **« Messages »** dans l'application, au menu, avec le nombre de non-lus (une pastille
|
||||||
|
sur le bouton du menu en mobile) ; pas de courriel. Un message porte un objet (une objection, une
|
||||||
|
coquille), son texte — dit par l'interface, dans la langue de la personne —, les gestes que
|
||||||
|
l'objet lui demande **maintenant** (répondre, lever ou réitérer, valider ou refuser) et son
|
||||||
|
échéance ; un message traité n'offre plus de geste et dit ce qu'il est devenu. Chacun ne lit
|
||||||
|
que les siens (`trustin_access::reads_message`) ; agir sur l'objet lit ses messages. Un message
|
||||||
|
ne se montre que tant que la personne ouvre le groupe dont il parle.
|
||||||
|
|
||||||
|
### Les échéances — `crates/ld-server/src/deadlines.rs`
|
||||||
|
|
||||||
|
Trois jours (`ANSWER_DAYS`). Le balayage d'adoption (toutes les 5 minutes) passe les
|
||||||
|
échéances, et une fois au démarrage : une échéance passée pendant un arrêt est prise dès la
|
||||||
|
reprise. L'horloge du serveur est injectable (`AppState::clock`) : les tests l'avancent, sans
|
||||||
|
attendre ; la route `/api/dev/clock` n'existe qu'avec la connexion de développement.
|
||||||
|
|
||||||
|
### La migration 0008
|
||||||
|
|
||||||
|
Additive. Les signalements gardent leur table (`signals`) et y gagnent le genre, la version
|
||||||
|
retenue, le tour, l'échéance, la réponse et la cause de la levée ; chaque signalement existant
|
||||||
|
devient un **« contredit »** sans perte : levé, il le reste (par son auteur, un administrateur
|
||||||
|
ou une révision, selon `lifted_by`) ; debout, il retient la dernière arrivée selon la règle
|
||||||
|
ci-dessus (s'il visait deux textes aujourd'hui tous deux en vigueur, il ne retient rien et se
|
||||||
|
lève à la main). Aucun message n'est créé pour le passé. S'y ajoutent les tables `typos` (les
|
||||||
|
corrections des autres) et `messages`, et sur les révisions la correction d'origine et qui l'a
|
||||||
|
validée.
|
||||||
|
|
||||||
|
**Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la
|
||||||
|
dernière arrivée, c'est à elle de s'expliquer. Une personne qui réitère sans fin garde une
|
||||||
|
version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois
|
||||||
|
jours à paraître, et sur un texte du seed elle attend un administrateur.
|
||||||
+2
-1
@@ -8,9 +8,10 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan
|
|||||||
| [0001](0001-rust-svelte.md) | Rust + Svelte, une image, SQLite | 2026-10-21 |
|
| [0001](0001-rust-svelte.md) | Rust + Svelte, une image, SQLite | 2026-10-21 |
|
||||||
| [0002](0002-vote-permanent.md) | Vote permanent : marge du texte en place et plancher C | 2026-10-21 |
|
| [0002](0002-vote-permanent.md) | Vote permanent : marge du texte en place et plancher C | 2026-10-21 |
|
||||||
| [0003](0003-corriger-n-est-pas-voter.md) | Corriger n'est pas voter | 2026-10-21 |
|
| [0003](0003-corriger-n-est-pas-voter.md) | Corriger n'est pas voter | 2026-10-21 |
|
||||||
| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 |
|
| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes (remplacée par 0010) | 2026-10-21 |
|
||||||
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
|
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
|
||||||
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
|
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
|
||||||
| [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 |
|
| [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 |
|
||||||
| [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 |
|
| [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 |
|
||||||
| [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 |
|
| [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 |
|
||||||
|
| [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
La communauté Monnaie libre tient son Acte d'engagement Certification comme un **texte vivant** :
|
La communauté Monnaie libre tient son Acte d'engagement Certification comme un **texte vivant** :
|
||||||
lisible d'abord, modifiable par article, chaque modification passant par un vote dont le seuil
|
lisible d'abord, modifiable par article, chaque modification passant par un vote dont le seuil
|
||||||
suit la formule inertielle de la Ğ1 — sans qu'une coquille demande un vote, sans que deux
|
suit la formule inertielle de la Ğ1 — sans qu'une coquille demande un vote, sans que deux
|
||||||
articles contradictoires soient en vigueur ensemble, et avec une publication automatique que
|
articles qui se répètent ou se contredisent soient en vigueur ensemble, et avec une publication automatique que
|
||||||
les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||||
|
|
||||||
## Ce que voit la personne
|
## Ce que voit la personne
|
||||||
@@ -48,7 +48,7 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
|||||||
(enregistré, inscrit sur la chaîne Ğ1, en attente de la chaîne, à reconfirmer, non compté et
|
(enregistré, inscrit sur la chaîne Ğ1, en attente de la chaîne, à reconfirmer, non compté et
|
||||||
pourquoi), puis l'inertie et la formule. Les autres gestes ouvrent un panneau latéral (une
|
pourquoi), puis l'inertie et la formule. Les autres gestes ouvrent un panneau latéral (une
|
||||||
feuille sur mobile), lui aussi dans l'URL : **Panier**, **Propositions**, **Proposer**,
|
feuille sur mobile), lui aussi dans l'URL : **Panier**, **Propositions**, **Proposer**,
|
||||||
**Détails** d'une version (coquilles, traductions, signalements, historique) et
|
**Détails** d'une version (coquilles, traductions, objections, historique) et
|
||||||
**« Réglages de la formule »** (W, C, B, la participation habituelle du groupe et les cinq
|
**« Réglages de la formule »** (W, C, B, la participation habituelle du groupe et les cinq
|
||||||
crans), atteints depuis l'en-tête du document et depuis chaque vote.
|
crans), atteints depuis l'en-tête du document et depuis chaque vote.
|
||||||
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
|
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
|
||||||
@@ -71,6 +71,11 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
|||||||
7. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
|
7. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
|
||||||
8. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
|
8. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
|
||||||
une traduction devenue ancienne (le français a changé depuis) est signalée.
|
une traduction devenue ancienne (le français a changé depuis) est signalée.
|
||||||
|
9. **Messages** (lot F, ADR 0010) : au menu, une boîte avec le nombre de non-lus (une pastille
|
||||||
|
sur le bouton du menu en mobile) ; ce qui attend un geste de la personne — une objection à
|
||||||
|
laquelle répondre, à lever ou à réitérer, une correction de coquille à valider —, chaque
|
||||||
|
message avec son objet, son texte, ses gestes et son échéance. Une version retenue par une
|
||||||
|
objection le dit sous l'article, à chacun selon son rôle.
|
||||||
|
|
||||||
## Les règles
|
## Les règles
|
||||||
|
|
||||||
@@ -149,7 +154,14 @@ titre d'un article reste voté avec son texte.
|
|||||||
ensuite, par l'ordre de la section.
|
ensuite, par l'ordre de la section.
|
||||||
- **Le titre du document** : à l'entrée d'un autre titre, le document change de titre en
|
- **Le titre du document** : à l'entrée d'un autre titre, le document change de titre en
|
||||||
français ; ses traductions restent éditoriales et passent « à revoir » jusqu'à ce qu'on les
|
français ; ses traductions restent éditoriales et passent « à revoir » jusqu'à ce qu'on les
|
||||||
reprenne. Une coquille du titre se corrige sans vote.
|
reprenne. Une coquille du titre se corrige sans vote, par le chemin de toute coquille : celle
|
||||||
|
d'un autre attend l'auteur du titre (un administrateur pour le titre de départ) ou 3 jours.
|
||||||
|
- **Face aux objections (ADR 0010)** : « redit » et « contredit » portent sur deux articles,
|
||||||
|
jamais sur un module. Un ordre ne se corrige pas, ni par son auteur ni par un autre.
|
||||||
|
- **« Modules à voter »** ne compte pas une version retenue par une objection : personne ne peut
|
||||||
|
la voter tant qu'elle est retenue. Un article dont la seule version ouverte est retenue sort du
|
||||||
|
compte, et y revient à la levée. La liste du groupe la compte toujours parmi les propositions en
|
||||||
|
cours : elle est ouverte.
|
||||||
- Dans une base existante, chaque document reçoit ses modules **une fois**, depuis son état
|
- Dans une base existante, chaque document reçoit ses modules **une fois**, depuis son état
|
||||||
présent (titre actuel, ordre actuel de chaque section), sans toucher un article ni un vote
|
présent (titre actuel, ordre actuel de chaque section), sans toucher un article ni un vote
|
||||||
(`crates/ld-server/src/module_amend.rs`, journal `module_amendments`, migration `0007`).
|
(`crates/ld-server/src/module_amend.rs`, journal `module_amendments`, migration `0007`).
|
||||||
@@ -163,11 +175,19 @@ mot (« certifcation » → « certification »). Tout le reste passe par une pr
|
|||||||
ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement
|
ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement
|
||||||
de sens.
|
de sens.
|
||||||
|
|
||||||
| Geste | Qui | Effet sur les votes |
|
| Geste | Qui | Effet | Échéance |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Corriger une **coquille** | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
|
| Corriger une **coquille** sur son propre texte | l'auteur de la version ; un administrateur sur un texte du seed | appliquée tout de suite : les votes restent, les traductions restent à jour | — |
|
||||||
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
|
| Corriger une **coquille** sur le texte d'un autre | tout membre du groupe | **en attente** : un message à l'auteur (aux administrateurs pour un texte du seed), qui valide ou refuse ; la correction se lit, avec son diff, dans « Détails » | 3 jours sans réponse : appliquée |
|
||||||
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
|
| Revenir sur une correction (ou l'appliquer finalement) | l'auteur, tant que le texte **n'est pas en vigueur** | le texte d'avant revient (ou la correction passe) | — |
|
||||||
|
| Corriger un texte **en vigueur** | tout membre ; l'auteur (un administrateur pour le seed) valide | **seule une correction validée** change le texte ; un refus est définitif | 3 jours sans réponse : appliquée |
|
||||||
|
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les corrections en attente sont périmées ; elle répond aux objections qui attendent l'auteur | — |
|
||||||
|
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version | — |
|
||||||
|
|
||||||
|
Deux corrections en attente sur un même texte : chacune est jugée contre le texte voté ; à
|
||||||
|
l'application, celle qui ne tombe plus sur le texte qu'elle a lu est **périmée** (« recharge,
|
||||||
|
puis recommence »). Les traductions restent éditoriales, sans validation. L'historique dit qui a
|
||||||
|
écrit chaque correction et qui l'a validée, ou qu'elle est passée à l'échéance (ADR 0010).
|
||||||
|
|
||||||
Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre
|
Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre
|
||||||
finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions
|
finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions
|
||||||
@@ -205,14 +225,37 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
|
|||||||
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
|
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
|
||||||
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
|
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
|
||||||
|
|
||||||
Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`.
|
Moteur : `crates/ld-engine/src/edit.rs` (la coquille) et `crates/ld-engine/src/correction.rs` (qui
|
||||||
|
la laisse entrer) ; écriture : `crates/ld-server/src/proposals.rs` et `crates/ld-server/src/typos.rs`.
|
||||||
|
|
||||||
### Deux articles qui se contredisent
|
### Deux articles qui se répètent ou se contredisent
|
||||||
|
|
||||||
Tout membre peut **signaler une contradiction** entre deux propositions, motif à l'appui. Tant
|
Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le
|
||||||
que le signalement tient, celle des deux qui n'est pas en vigueur **ne peut pas entrer en
|
répète) ou elle le **contredit**, motif à l'appui. **Une objection rend la dernière arrivée des
|
||||||
vigueur**. Il se lève par son auteur (ou un administrateur), ou tombe quand la proposition
|
deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière
|
||||||
signalée est révisée sur le fond.
|
arrivée est en vigueur, c'est l'autre qui est retenue. Les votes déjà faits restent ; la version
|
||||||
|
retenue ne reçoit plus de vote (ni sous l'article, ni dans le panier, ni par la chaîne) et
|
||||||
|
n'entre pas en vigueur. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite,
|
||||||
|
il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait
|
||||||
|
l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous
|
||||||
|
**3 jours**, l'objection est levée.
|
||||||
|
|
||||||
|
| Qui | Quoi | Effet | Échéance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — |
|
||||||
|
| l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle |
|
||||||
|
| la personne qui a fait l'objection | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — |
|
||||||
|
| la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur |
|
||||||
|
| personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — |
|
||||||
|
| un administrateur de trustinWallet | lever | levée « par un administrateur » | — |
|
||||||
|
| l'auteur | retirer sa version | l'objection prend fin | — |
|
||||||
|
|
||||||
|
Une révision de fond de l'une ou l'autre des deux versions vaut réponse, une coquille non ; un
|
||||||
|
seul tour à la fois (une autre réponse pendant le tour de la personne qui objecte ne change ni le
|
||||||
|
message ni l'échéance) ; une personne n'a qu'une objection debout sur une même paire. Les
|
||||||
|
signalements de contradiction d'avant sont devenus des « contredit » (migration 0008). Moteur :
|
||||||
|
`crates/ld-engine/src/objection.rs` ; serveur : `crates/ld-server/src/objections.rs`,
|
||||||
|
`messages.rs`, `deadlines.rs` (les échéances, au balayage de 5 minutes et au démarrage) ; ADR 0010.
|
||||||
|
|
||||||
### Une décision par proposition
|
### Une décision par proposition
|
||||||
|
|
||||||
@@ -242,7 +285,7 @@ du dépôt. Le groupe d'essai ne publie jamais. `document.json` dit aussi les mo
|
|||||||
Un document seedé n'est jamais réinstancié : quand le seed change un texte de départ (le
|
Un document seedé n'est jamais réinstancié : quand le seed change un texte de départ (le
|
||||||
Préambule devenu Raison d'être), un **amendement de seed** le remplace dans une base existante,
|
Préambule devenu Raison d'être), un **amendement de seed** le remplace dans une base existante,
|
||||||
une seule fois, et seulement sur un article que personne n'a touché (*seed a minima* d'origine,
|
une seule fois, et seulement sur un article que personne n'a touché (*seed a minima* d'origine,
|
||||||
révision 1, aucun vote, aucune correction, aucune traduction modifiée, aucun signalement, texte
|
révision 1, aucun vote, aucune correction (ni en attente), aucune traduction modifiée, aucune objection, texte
|
||||||
identique à l'empreinte attendue). Chaque décision est consignée (`seed_amendments`, et dans les
|
identique à l'empreinte attendue). Chaque décision est consignée (`seed_amendments`, et dans les
|
||||||
journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'historique de
|
journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'historique de
|
||||||
l'article (« texte de départ remplacé », avec le diff) et le texte est republié
|
l'article (« texte de départ remplacé », avec le diff) et le texte est republié
|
||||||
@@ -251,7 +294,7 @@ l'article (« texte de départ remplacé », avec le diff) et le texte est repub
|
|||||||
### Accès — trustinWallet
|
### Accès — trustinWallet
|
||||||
|
|
||||||
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
|
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
|
||||||
corriger, signaler) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet
|
corriger, faire une objection) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet
|
||||||
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
|
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
|
||||||
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
|
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
|
||||||
|
|
||||||
@@ -265,7 +308,9 @@ du bulletin (`ld1:` + 64 chiffres hexadécimaux). La voix est celle de **l'ident
|
|||||||
qui signe**, membre au moment où le serveur confirme la transaction sur l'indexeur ; quelle que
|
qui signe**, membre au moment où le serveur confirme la transaction sur l'indexeur ; quelle que
|
||||||
soit la session (invitation ou connexion Ğ1), la même identité n'a qu'une voix par version, et
|
soit la session (invitation ou connexion Ğ1), la même identité n'a qu'une voix par version, et
|
||||||
**le dernier vote fait foi**, dans l'ordre de la chaîne. La chaîne montre qui a voté et quand,
|
**le dernier vote fait foi**, dans l'ordre de la chaîne. La chaîne montre qui a voté et quand,
|
||||||
jamais quoi ; chacun ne voit que les totaux. Sans compte des votes configuré
|
jamais quoi ; chacun ne voit que les totaux. Chacun reçoit **le reçu de son bulletin** (ses votes
|
||||||
|
et son sel), le retrouve sur « Tes bulletins » et le vérifie dans son navigateur ; personne
|
||||||
|
d'autre ne le reçoit (ADR 0008, « Le reçu »). Sans compte des votes configuré
|
||||||
(`LD_VOTE_ACCOUNT`), le vote par la chaîne est fermé et l'interface le dit. Dans les groupes
|
(`LD_VOTE_ACCOUNT`), le vote par la chaîne est fermé et l'interface le dit. Dans les groupes
|
||||||
gérés (essai, Axiom…), tout membre vote directement, sans portefeuille.
|
gérés (essai, Axiom…), tout membre vote directement, sans portefeuille.
|
||||||
|
|
||||||
@@ -295,9 +340,11 @@ Les raisons longues sont dans `docs/adr/`.
|
|||||||
de la toile, ou dont l'accès est révoqué, garde les voix déjà confirmées.
|
de la toile, ou dont l'accès est révoqué, garde les voix déjà confirmées.
|
||||||
- Le serveur fait confiance à l'indexeur pour lire la transaction (remarque, virement, identité,
|
- Le serveur fait confiance à l'indexeur pour lire la transaction (remarque, virement, identité,
|
||||||
appartenance à la toile) ; plusieurs indexeurs se configurent (ADR 0008).
|
appartenance à la toile) ; plusieurs indexeurs se configurent (ADR 0008).
|
||||||
- Par Ğecko, le bulletin n'est lié à aucun compte : une copie de son sceau incluse avant lui par
|
- Par Ğecko, le bulletin n'est lié à aucun compte : une copie nue de son sceau est ignorée, mais
|
||||||
un autre compte le prendrait. Par l'extension, il est lié au compte qui signe.
|
une copie **payée** (0,1 Ğ1) incluse avant lui par un autre compte le prendrait. Par
|
||||||
- Un bulletin non vu sur la chaîne en 24 heures est lâché : un paiement plus tardif ne compte pas.
|
l'extension, il est lié au compte qui signe.
|
||||||
|
- Un bulletin non vu sur la chaîne en 24 heures est lu une dernière fois, puis lâché : un
|
||||||
|
paiement plus tardif ne compte pas.
|
||||||
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
|
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
|
||||||
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
|
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
|
||||||
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
|
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
|
||||||
@@ -310,6 +357,10 @@ Les raisons longues sont dans `docs/adr/`.
|
|||||||
dans l'historique de l'article.
|
dans l'historique de l'article.
|
||||||
- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont
|
- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont
|
||||||
republiées — le français fait foi.
|
republiées — le français fait foi.
|
||||||
|
- Une version retenue par une objection attend son auteur aussi longtemps qu'il ne répond pas ;
|
||||||
|
une personne qui réitère sans fin la garde hors du vote : un administrateur peut lever (ADR 0010).
|
||||||
|
- Les messages sont dans l'application, sans courriel : qui ne passe pas ne les voit pas, et la
|
||||||
|
correction ou l'objection qui l'attendait passe à l'échéance de 3 jours.
|
||||||
- Les aperçus (« si les bien cotées passent », « si tout passe ») montrent les articles seuls :
|
- Les aperçus (« si les bien cotées passent », « si tout passe ») montrent les articles seuls :
|
||||||
un ordre ou un titre proposé n'y change pas encore la place des articles ni le titre (ADR 0009).
|
un ordre ou un titre proposé n'y change pas encore la place des articles ni le titre (ADR 0009).
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e
|
|||||||
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
|
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
|
||||||
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
|
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
|
||||||
|
|
||||||
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26) se font dans le **groupe
|
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31) se font dans le **groupe
|
||||||
d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les
|
d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les
|
||||||
votes par la chaîne Ğ1 (C11, C17, C23, C25) ne se jouent, pour l'automate, que sur une **chaîne
|
votes par la chaîne Ğ1 (C11, C17, C23, C25, C32, C33) ne se jouent, pour l'automate, que sur une **chaîne
|
||||||
simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ;
|
simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ;
|
||||||
pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
|
pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
|
|||||||
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » — le nombre réel de ses votes, qui change quand on vote —, sous le titre du document et à la fin de chaque section ses modules (C25, C26) ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » — le nombre réel de ses votes, qui change quand on vote —, sous le titre du document et à la fin de chaque section ses modules (C25, C26) ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
||||||
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie |
|
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie |
|
||||||
| C5 | Entrée en vigueur | groupe d'essai : à deux, déplier le vote de « Les deux garanties réciproques », « Pour » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (2 pour · 0 contre) |
|
| C5 | Entrée en vigueur | groupe d'essai : à deux, déplier le vote de « Les deux garanties réciproques », « Pour » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (2 pour · 0 contre) |
|
||||||
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Coquilles, traductions, signalements, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
| C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) |
|
||||||
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
|
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
|
||||||
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
||||||
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||||
@@ -36,10 +36,16 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
|
|||||||
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
|
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
|
||||||
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
|
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
|
||||||
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
|
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
|
||||||
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction. », pas de bouton, les totaux et le seuil restent ; dans le groupe d'essai, le panier et les boutons sont là |
|
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et le seuil restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là |
|
||||||
| C25 | L'ordre des articles | groupe d'essai : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles · <section> » (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » (1 vote après un « Pour », 0 après le retrait) ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la deuxième voix : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px |
|
| C25 | L'ordre des articles | groupe d'essai : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles · <section> » (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px |
|
||||||
| C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la deuxième voix : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre (2 pour) ; en anglais, l'ancien titre traduit, marqué « translation needs review » |
|
| C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » |
|
||||||
| C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » |
|
| C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » |
|
||||||
|
| C28 | Objection « redit » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera recharge | « Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée. » ; chez Oscar, « « … » redit « Unicité du compte » », « cette version ne se vote plus », « Lever l'objection » ; chez Vera, « Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent. », « Elle redit « Unicité du compte ». », pas de « Pour », la ligne marquée « non votable : objection » ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter |
|
||||||
|
| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste |
|
||||||
|
| C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » |
|
||||||
|
| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » |
|
||||||
|
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
|
||||||
|
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
|
||||||
|
|
||||||
## Au-delà de la liste
|
## Au-delà de la liste
|
||||||
|
|
||||||
@@ -69,4 +75,8 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
|||||||
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
|
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
|
||||||
| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts |
|
| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts |
|
||||||
| 2026-10-31 | automate | local, lot B (le vote déplié, le panier, le vote par la chaîne Ğ1 simulée) | C1–C24, M1 | 25 / 25 verts |
|
| 2026-10-31 | automate | local, lot B (le vote déplié, le panier, le vote par la chaîne Ğ1 simulée) | C1–C24, M1 | 25 / 25 verts |
|
||||||
|
| 2026-10-31 | automate | local, lot B2 (vote par la chaîne : corrections de la relecture de `74f96be`, reçu), `c7d2791` | C1–C24, C32, C33, M1 | 27 / 27 verts |
|
||||||
|
| 2026-10-31 | automate | local, lot F (objections « redit » / « contredit », coquilles validées, messages ; horloge du serveur avancée pour l'échéance) | C1–C24, C28–C31, M1 | 29 / 29 verts |
|
||||||
|
| 2026-10-31 | automate | local, fusion de F avec B2 (`cd7e683`) ; C22 ne suppose plus un groupe d'essai de moins de 16 membres | C1–C24, C28–C33, M1 | 31 / 31 verts |
|
||||||
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
|
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
|
||||||
|
| 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) |
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
Orch seul sur le dépôt (sous-agents en copies de travail). **M1 (L1–L8) en ligne** depuis le
|
Orch seul sur le dépôt (sous-agents en copies de travail). **M1 (L1–L8) en ligne** depuis le
|
||||||
2026-10-30 à 15 h 45 (`8727315`, acte `actes/2026-10-30-m1-mise-en-ligne.md`) ; Raison d'être
|
2026-10-30 à 15 h 45 (`8727315`, acte `actes/2026-10-30-m1-mise-en-ligne.md`) ; Raison d'être
|
||||||
publiée (commit `35864eb` du dépôt des textes). Lot C fusionné et relu, acte M3a préparé (`actes/2026-10-30-m3-inertie-cinq-crans.md`). Lot B lancé
|
publiée (commit `35864eb` du dépôt des textes). Actes M3a et M2 figés, ils attendent « déploie ».
|
||||||
avec les défauts de Q5–Q7 (recherche : `docs/recherche/vote-g1-chaine.md`). D après C, F
|
Sur `chantier/acte-certification` : B2 (`7ffa4fd`, repris en `9c8f4cc`), F (`cd7e683`) et `924a40d`
|
||||||
après B. Restes mineurs de la relecture M1 : focus et flèches des pastilles d'ambiance (lot B).
|
(C22) ; boucle verte, e2e 31 / 31 ; relecture indépendante en cours. Le lot D, fini sur sa branche,
|
||||||
|
fusionne chantier dans `lot/d-modules` (17 fichiers en conflit). Puis : relecture de la fusion D,
|
||||||
|
répétition des migrations 0007 et 0008 sur une copie de la base de production, dossier d'acte.
|
||||||
|
|
||||||
## Critère de livraison (liste fermée)
|
## Critère de livraison (liste fermée)
|
||||||
|
|
||||||
@@ -38,10 +40,11 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| A | L1–L6 : entrée, DA, menu, listes, Décisions | high | **en ligne** 30/10 15 h 45 | `lot/a-entree` |
|
| A | L1–L6 : entrée, DA, menu, listes, Décisions | high | **en ligne** 30/10 15 h 45 | `lot/a-entree` |
|
||||||
| R | L7–L8 : Raison d'être (amendement de seed gardé), versions groupées | high | **en ligne** 30/10 15 h 45 | `lot/r-texte` |
|
| R | L7–L8 : Raison d'être (amendement de seed gardé), versions groupées | high | **en ligne** 30/10 15 h 45 | `lot/r-texte` |
|
||||||
| B | L9 : vote déplié, vote groupé, vote par transaction de 0,1 Ğ1 | xhigh | fusionné (`74f96be`), boucle verte, image construite ; relecture en cours | `lot/b-vote` |
|
| B | L9 : vote déplié, vote groupé, vote par transaction de 0,1 Ğ1 | xhigh | fusionné et relu (`74f96be`) ; acte M2 figé (vote par la chaîne éteint) | `lot/b-vote` |
|
||||||
|
| B2 | Corrections du vote avant de l'ouvrir (relecture de `74f96be`, points 2 à 8) | xhigh | fusionné (`7ffa4fd`) ; relu « à reprendre », repris en `9c8f4cc`, en relecture | `lot/b2-vote-sur` |
|
||||||
| C | L10 : cinq crans, échelle, réglages | xhigh | fusionné et relu (`a576b68`, `71819f9`) ; acte M3a figé | `lot/c-inertie` |
|
| C | L10 : cinq crans, échelle, réglages | xhigh | fusionné et relu (`a576b68`, `71819f9`) ; acte M3a figé | `lot/c-inertie` |
|
||||||
| D | L11 + titre du doc en module (4) + restes mineurs des relectures | high | lancé le 31/10 | `lot/d-modules` |
|
| D | L11 + titre du doc en module (4) + restes mineurs des relectures | high | fini sur sa branche (e2e 29 / 29) ; fusionne chantier dans sa branche | `lot/d-modules` |
|
||||||
| F | L12–L13 : objections, coquilles validées, messages, délai de 3 jours | xhigh | lancé le 31/10 | `lot/f-objections` |
|
| F | L12–L13 : objections, coquilles validées, messages, délai de 3 jours | xhigh | fusionné (`cd7e683`), boucle verte ; en relecture | `lot/f-objections` |
|
||||||
|
|
||||||
## Questions (réponse par défaut appliquée tant qu'Yvv n'a pas tranché)
|
## Questions (réponse par défaut appliquée tant qu'Yvv n'a pas tranché)
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag
|
|||||||
| Q5 | Secret du vote (Q2) et transaction publique (Q1) : le commentaire porte le choix en clair, ou une empreinte scellée ? | défaut : empreinte scellée (la chaîne montre qui a voté et quand, pas quoi) |
|
| Q5 | Secret du vote (Q2) et transaction publique (Q1) : le commentaire porte le choix en clair, ou une empreinte scellée ? | défaut : empreinte scellée (la chaîne montre qui a voté et quand, pas quoi) |
|
||||||
| Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | défaut : duniter-connect maintenant (Ğ1Companion et trustinWallet se brancheront sur la même interface), lien et QR Ğecko sur mobile |
|
| Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | défaut : duniter-connect maintenant (Ğ1Companion et trustinWallet se brancheront sur la même interface), lien et QR Ğecko sur mobile |
|
||||||
| Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | défaut : un compte libreDecision créé par Yvv dans Ğecko, les sommes restent au collectif |
|
| Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | défaut : un compte libreDecision créé par Yvv dans Ğecko, les sommes restent au collectif |
|
||||||
|
| Q8 | Le secret du vote tombe par recoupement : la chaîne montre qui vient de voter, et les totaux bougent aussitôt (relecture de `74f96be`) | à trancher avant d'ouvrir le vote ; pas de défaut posé |
|
||||||
|
|
||||||
Défauts posés par orch, sans question (Yvv peut les corriger) :
|
Défauts posés par orch, sans question (Yvv peut les corriger) :
|
||||||
- Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote
|
- Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote
|
||||||
@@ -69,9 +73,14 @@ Défauts posés par orch, sans question (Yvv peut les corriger) :
|
|||||||
## Actes
|
## Actes
|
||||||
|
|
||||||
- M1 : **exécuté** le 30/10/2026, 15 h 29 – 15 h 52 (`actes/2026-10-30-m1-mise-en-ligne.md`).
|
- M1 : **exécuté** le 30/10/2026, 15 h 29 – 15 h 52 (`actes/2026-10-30-m1-mise-en-ligne.md`).
|
||||||
- M3a (L10) : **figé** sur `71819f9` (`actes/2026-10-30-m3-inertie-cinq-crans.md`), relectures inscrites, attend « déploie ».
|
- M3a (L10) : **figé** sur `71819f9` (`actes/2026-10-30-m3-inertie-cinq-crans.md`), attend « déploie ».
|
||||||
|
- M2 + M3a (L9 et L10) : **figé** sur `74f96be` (`actes/2026-10-31-m2-vote.md`), le vote par la chaîne éteint ; remplace M3a s'il est armé.
|
||||||
|
|
||||||
Restes mineurs pour le lot D (relecture de `71819f9`, captures du lot B) : « seed a minima · 0 vote » affiché à côté des totaux ; pourcentage des réglages calculé sur le nombre arrondi ; liste explicite des statuts comptés dans la médiane ; affichage de la participation ramenée couvert par un e2e ; formulation « les seuils se calculent sur » (mettre le groupe en sujet) ; flèches du sélecteur de crans.
|
Restes mineurs de `71819f9` et du lot B : faits dans le lot D.
|
||||||
|
|
||||||
|
À dire à Yvv : dans le groupe d'essai (C = 1), dès 16 membres une voix seule fait passer un texte,
|
||||||
|
la garde B^W passant sous la précision du calcul (ADR 0007). Monnaie libre (C = 50) n'est pas
|
||||||
|
concernée.
|
||||||
|
|
||||||
Ensuite : Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture
|
Ensuite : Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture
|
||||||
indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne
|
indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers';
|
||||||
|
|
||||||
|
// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for
|
||||||
|
// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms
|
||||||
|
// it without the page; the receipt goes to the one who asked, who checks it here.
|
||||||
|
// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go
|
||||||
|
// to open proposals no other control reads, and only « contre »: nothing enters into
|
||||||
|
// force, nothing becomes well rated, the other controls find the text as they expect.
|
||||||
|
|
||||||
|
type Entry = { id: string; status: string; comment: string };
|
||||||
|
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
|
||||||
|
|
||||||
|
/** Unfolds the vote of an open proposal, from « Propositions en cours ». */
|
||||||
|
async function unfold(page: Page, title: string) {
|
||||||
|
// By its title only: the excerpt of another proposal may name it.
|
||||||
|
await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click();
|
||||||
|
return page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVOCATION = 'Document de révocation';
|
||||||
|
const NEVER_ALONE = 'Ne jamais certifier seul';
|
||||||
|
|
||||||
|
const answer = (page: Page, yes: boolean) =>
|
||||||
|
page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes);
|
||||||
|
|
||||||
|
test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => {
|
||||||
|
// The server reads the indexer every 10 seconds.
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await signIn(page, 'Rosa');
|
||||||
|
await wallet(page, [EVE]);
|
||||||
|
await page.goto(DOC);
|
||||||
|
const { control } = await chain(page);
|
||||||
|
const asked: string[] = [];
|
||||||
|
page.on('request', (r) => {
|
||||||
|
if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) {
|
||||||
|
asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length;
|
||||||
|
const fold = await unfold(page, REVOCATION);
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||||
|
|
||||||
|
// The node does not answer before anything is signed: nothing left, the ballot is let go.
|
||||||
|
await control('node?down=1');
|
||||||
|
try {
|
||||||
|
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible();
|
||||||
|
await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']);
|
||||||
|
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0);
|
||||||
|
await expect.poll(() => mine(page)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
// The next controls need the node.
|
||||||
|
await control('node?down=0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window closed while the wallet still asks: kept until it answers; refused, let go.
|
||||||
|
await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true));
|
||||||
|
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
expect(deleted()).toBe(1);
|
||||||
|
await answer(page, false);
|
||||||
|
await expect.poll(deleted).toBe(2);
|
||||||
|
await expect.poll(() => mine(page)).toEqual([]);
|
||||||
|
|
||||||
|
// Signed once the window is closed: it may land, so it is kept; the server confirms it.
|
||||||
|
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
await answer(page, true);
|
||||||
|
await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||||
|
expect(deleted()).toBe(2);
|
||||||
|
await page.goto(DOC);
|
||||||
|
await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||||
|
|
||||||
|
// Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server.
|
||||||
|
const ugo = await person(browser, 'Ugo');
|
||||||
|
await ugo.goto(DOC);
|
||||||
|
const theirs = await unfold(ugo, REVOCATION);
|
||||||
|
await theirs.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||||
|
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
|
||||||
|
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
|
||||||
|
await sheet.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
|
||||||
|
const { node } = await chain(ugo);
|
||||||
|
const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' };
|
||||||
|
await ugo.request.post(node, {
|
||||||
|
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
|
||||||
|
});
|
||||||
|
await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||||
|
await ugo.goto(DOC);
|
||||||
|
await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await signIn(page, 'Ines');
|
||||||
|
await wallet(page, [ALICE]);
|
||||||
|
await page.goto(DOC);
|
||||||
|
const fold = await unfold(page, NEVER_ALONE);
|
||||||
|
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||||
|
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
|
||||||
|
const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]);
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/);
|
||||||
|
const file = await download.path();
|
||||||
|
const receipt = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address });
|
||||||
|
expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/);
|
||||||
|
expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/);
|
||||||
|
expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]);
|
||||||
|
|
||||||
|
// The same ballot, in her ballots.
|
||||||
|
await dialog.getByRole('link', { name: 'Tes bulletins' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/bulletins$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible();
|
||||||
|
const entry = page.locator('li.ballot');
|
||||||
|
await expect(entry).toHaveCount(1);
|
||||||
|
await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »");
|
||||||
|
await expect(entry).toContainText(receipt.comment);
|
||||||
|
|
||||||
|
// Checked here, in the browser, without the server.
|
||||||
|
await page.getByLabel('Fichier du reçu').setInputFiles(file);
|
||||||
|
await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible();
|
||||||
|
// Only what the seal covers is said verified: the version, its revision and the choice.
|
||||||
|
await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`);
|
||||||
|
// The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd).
|
||||||
|
await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).toBeVisible();
|
||||||
|
// One choice changed: it no longer holds.
|
||||||
|
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) };
|
||||||
|
await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
|
||||||
|
await page.getByRole('button', { name: 'Vérifier', exact: true }).click();
|
||||||
|
await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible();
|
||||||
|
await shot(page, '7-bulletins-recu');
|
||||||
|
|
||||||
|
// Nobody else gets it: neither in their ballots, nor by its address.
|
||||||
|
const other = await person(browser, 'Lou');
|
||||||
|
await other.goto('/bulletins');
|
||||||
|
await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible();
|
||||||
|
expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404);
|
||||||
|
});
|
||||||
+37
-8
@@ -6,11 +6,15 @@
|
|||||||
// each one is included in a new block, as the transfer and the remark of the
|
// each one is included in a new block, as the transfer and the remark of the
|
||||||
// real `utility.batchAll` would be;
|
// real `utility.batchAll` would be;
|
||||||
// - `/v1/graphql`, the « indexer »: answers the server's query
|
// - `/v1/graphql`, the « indexer »: answers the server's query
|
||||||
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
|
// `squidStatus { height } txComments(condition: {hash}, filter, first, after)`
|
||||||
|
// in the shape of indexer.duniter.org: the last final block (here, every
|
||||||
|
// block is final), the comments in the order of the chain, filtered (author,
|
||||||
|
// or a transfer to an account of at least an amount) and served page by page.
|
||||||
//
|
//
|
||||||
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
|
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
|
||||||
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
|
// the indexer, as an indexer behind the chain), `show` (stop hiding), `release`
|
||||||
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
|
// (the indexer catches up), `silent` (the indexer stops answering), `node`
|
||||||
|
// (`?down=1`: the node stops answering, before anything is sent) and `state`.
|
||||||
//
|
//
|
||||||
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
|
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ const IDENTITIES = {
|
|||||||
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
|
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
|
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 };
|
||||||
|
|
||||||
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||||
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
|
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
|
||||||
@@ -49,6 +53,20 @@ async function read(req) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the real indexer's `filter` keeps, for the two filters the server asks. */
|
||||||
|
function kept(c, filter) {
|
||||||
|
if (!filter) return true;
|
||||||
|
const author = filter.authorId?.equalTo;
|
||||||
|
if (author && c.author.id !== author) return false;
|
||||||
|
const some = filter.transfersByCommentId?.some;
|
||||||
|
if (!some) return true;
|
||||||
|
const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0');
|
||||||
|
return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The size of a page: `first: N` in the query, as the server writes it. */
|
||||||
|
const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100);
|
||||||
|
|
||||||
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
|
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
|
||||||
function include(fields) {
|
function include(fields) {
|
||||||
state.block += 1;
|
state.block += 1;
|
||||||
@@ -70,7 +88,9 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
|
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
|
||||||
|
|
||||||
if (url.pathname === '/rpc' && req.method === 'POST') {
|
if (url.pathname === '/rpc' && req.method === 'POST') {
|
||||||
|
if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true);
|
||||||
const body = await read(req);
|
const body = await read(req);
|
||||||
|
if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true);
|
||||||
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
|
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
|
||||||
let fields;
|
let fields;
|
||||||
try {
|
try {
|
||||||
@@ -90,11 +110,19 @@ const server = createServer(async (req, res) => {
|
|||||||
const hash = body.variables?.hash;
|
const hash = body.variables?.hash;
|
||||||
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
|
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
|
||||||
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
|
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
|
||||||
const nodes = state.comments
|
const all = state.comments
|
||||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
|
.filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter))
|
||||||
.sort((a, b) => a.blockNumber - b.blockNumber)
|
.sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id))
|
||||||
.map(({ hidden: _, ...c }) => c);
|
.map(({ hidden: _, ...c }) => c);
|
||||||
return send(res, 200, { data: { txComments: { nodes } } });
|
const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0);
|
||||||
|
const nodes = all.slice(start, start + pageSize(String(body.query)));
|
||||||
|
const end = start + nodes.length;
|
||||||
|
return send(res, 200, {
|
||||||
|
data: {
|
||||||
|
squidStatus: { height: state.block },
|
||||||
|
txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes },
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
|
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
|
||||||
@@ -105,6 +133,7 @@ const server = createServer(async (req, res) => {
|
|||||||
state.hiding = false;
|
state.hiding = false;
|
||||||
for (const c of state.comments) c.hidden = false;
|
for (const c of state.comments) c.hidden = false;
|
||||||
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
|
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
|
||||||
|
else if (what === 'node') state.nodeDown = url.searchParams.get('down') === '1';
|
||||||
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
|
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
|
||||||
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
|
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-3
@@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
|||||||
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
|
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
|
||||||
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
|
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
|
||||||
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
|
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
|
||||||
|
export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' };
|
||||||
|
|
||||||
export async function signIn(page: Page, name: string, admin = false) {
|
export async function signIn(page: Page, name: string, admin = false) {
|
||||||
const r = await page.request.post('/api/auth/dev', {
|
const r = await page.request.post('/api/auth/dev', {
|
||||||
@@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro
|
|||||||
/**
|
/**
|
||||||
* A fake wallet extension, as duniter-connect offers itself to pages
|
* A fake wallet extension, as duniter-connect offers itself to pages
|
||||||
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
|
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
|
||||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
|
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse;
|
||||||
|
* `window.__ldHold = true` makes it wait, as a person who has not answered yet,
|
||||||
|
* until `window.__ldAnswer(true | false)` (sign, or refuse).
|
||||||
* It signs nothing real: the page, in test mode, sends to the simulated node.
|
* It signs nothing real: the page, in test mode, sends to the simulated node.
|
||||||
*/
|
*/
|
||||||
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
|
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
|
||||||
await page.addInitScript((list) => {
|
await page.addInitScript((list) => {
|
||||||
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
|
const w = window as unknown as Record<string, unknown> & {
|
||||||
|
__ldSigned: unknown[];
|
||||||
|
__ldRefuse?: boolean;
|
||||||
|
__ldHold?: boolean;
|
||||||
|
__ldAnswer?: (yes: boolean) => void;
|
||||||
|
};
|
||||||
w.__ldSigned = [];
|
w.__ldSigned = [];
|
||||||
w.injectedWeb3 = {
|
w.injectedWeb3 = {
|
||||||
'duniter-connect': {
|
'duniter-connect': {
|
||||||
@@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri
|
|||||||
accounts: { get: async () => list, subscribe: () => () => undefined },
|
accounts: { get: async () => list, subscribe: () => () => undefined },
|
||||||
signer: {
|
signer: {
|
||||||
signPayload: async (payload: Record<string, unknown>) => {
|
signPayload: async (payload: Record<string, unknown>) => {
|
||||||
|
if (w.__ldHold) {
|
||||||
|
const yes = await new Promise<boolean>((resolve) => (w.__ldAnswer = resolve));
|
||||||
|
if (!yes) throw new Error('Cancelled');
|
||||||
|
}
|
||||||
if (w.__ldRefuse) throw new Error('Cancelled');
|
if (w.__ldRefuse) throw new Error('Cancelled');
|
||||||
w.__ldSigned.push(payload);
|
w.__ldSigned.push(payload);
|
||||||
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
|
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
|
||||||
@@ -69,6 +81,49 @@ export async function shot(page: Page, name: string) {
|
|||||||
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
|
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CSRF = { 'x-requested-with': 'libredecision' };
|
||||||
|
|
||||||
|
/** The document of the test group, as the server gives it. */
|
||||||
|
export async function testDoc(page: Page): Promise<{
|
||||||
|
id: string;
|
||||||
|
units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[];
|
||||||
|
}> {
|
||||||
|
const g = await (await page.request.get('/api/groups/essai')).json();
|
||||||
|
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
|
||||||
|
return { id: d.doc.id, units: d.units };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */
|
||||||
|
export async function propose(page: Page, title: string, body: string): Promise<string> {
|
||||||
|
const doc = await testDoc(page);
|
||||||
|
const r = await page.request.post(`/api/docs/${doc.id}/proposals`, {
|
||||||
|
data: { kind: 'text', unitId: null, title, body, submit: true },
|
||||||
|
headers: CSRF,
|
||||||
|
});
|
||||||
|
if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`);
|
||||||
|
return (await r.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */
|
||||||
|
export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> {
|
||||||
|
const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF });
|
||||||
|
if (!r.ok()) throw new Error(`clock ${r.status()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */
|
||||||
|
export async function shots(page: Page, name: string) {
|
||||||
|
if (!process.env.LD_SHOTS) return;
|
||||||
|
const size = page.viewportSize();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await shot(page, `${name}-bureau`);
|
||||||
|
await page.setViewportSize({ width: 412, height: 915 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await shot(page, `${name}-mobile`);
|
||||||
|
if (size) await page.setViewportSize(size);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
/** The control door of the simulated chain, found where the page finds its node. */
|
/** The control door of the simulated chain, found where the page finds its node. */
|
||||||
export async function chain(page: Page, docPath = DOC) {
|
export async function chain(page: Page, docPath = DOC) {
|
||||||
const [, , group] = docPath.split('/');
|
const [, , group] = docPath.split('/');
|
||||||
@@ -79,6 +134,7 @@ export async function chain(page: Page, docPath = DOC) {
|
|||||||
return {
|
return {
|
||||||
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
|
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
|
||||||
node,
|
node,
|
||||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
|
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') =>
|
||||||
|
(await page.request.post(`${base}/control/${what}`)).json(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-19
@@ -1,4 +1,4 @@
|
|||||||
import { devices, expect, test, type Page } from '@playwright/test';
|
import { devices, expect, test, type Browser, type Page } from '@playwright/test';
|
||||||
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
|
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
|
||||||
|
|
||||||
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
||||||
@@ -17,6 +17,31 @@ async function voteFor(page: Page, row: string) {
|
|||||||
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a version of a module of the test group is in force now. */
|
||||||
|
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
||||||
|
const g = await (await page.request.get('/api/groups/essai')).json();
|
||||||
|
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
|
||||||
|
return [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voices that bring a module version of the test group into force: hers, then a
|
||||||
|
* second one if hers was not enough. Every dev sign-in joins the test group: from 16
|
||||||
|
* members B^W vanishes and, C being 1, one voice may suffice (ADR 0007). Returns the
|
||||||
|
* number of voices for it.
|
||||||
|
*/
|
||||||
|
async function secondVoice(page: Page, browser: Browser, proposalId: string, row: string, news: string): Promise<number> {
|
||||||
|
if (await inForce(page, proposalId)) {
|
||||||
|
await expect(page.getByText(news)).toBeVisible();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const yvv = await person(browser, 'Yvv');
|
||||||
|
await yvv.goto(TEST_DOC);
|
||||||
|
await voteFor(yvv, row);
|
||||||
|
await expect(yvv.getByText(news)).toBeVisible();
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
|
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
@@ -31,17 +56,14 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
|||||||
await order.scrollIntoViewIfNeeded();
|
await order.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c25-1-ordre-lecture');
|
await shot(page, 'c25-1-ordre-lecture');
|
||||||
|
|
||||||
// Its vote unfolds under it, like an article's; the mark tells the real number of votes.
|
// Its vote unfolds under it, like an article's, the list whole (the mark's real number of
|
||||||
|
// votes is seen in Monnaie libre, below: here one voice may confirm a seed).
|
||||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||||
await fond.hover();
|
await fond.hover();
|
||||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||||
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
||||||
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
|
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
|
||||||
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
||||||
await fondVote.getByRole('button', { name: 'Pour', exact: true }).click();
|
|
||||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
|
||||||
await fondVote.getByRole('button', { name: 'Retirer mon vote' }).click();
|
|
||||||
await expect(fond.getByText('seed a minima · 0 vote')).toBeVisible();
|
|
||||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||||
|
|
||||||
// Another order: the article, then its new place.
|
// Another order: the article, then its new place.
|
||||||
@@ -82,25 +104,22 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
|||||||
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
|
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
|
||||||
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
||||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
||||||
|
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||||
await shot(page, 'c25-3-ordre-vote');
|
await shot(page, 'c25-3-ordre-vote');
|
||||||
|
// Into force (her voice, or a second one): the section reads in its new order.
|
||||||
|
const voices = await secondVoice(page, browser, pid, 'Ordre des articles · Conclusion', 'Le nouvel ordre de « Conclusion » est entré en vigueur.');
|
||||||
|
|
||||||
// In the basket too: at the end of its section.
|
// In the basket too: at the end of its section.
|
||||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||||||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||||
|
|
||||||
// A second voice brings it into force: the section reads in its new order.
|
|
||||||
const yvv = await person(browser, 'Yvv');
|
|
||||||
await yvv.goto(TEST_DOC);
|
|
||||||
await voteFor(yvv, 'Ordre des articles · Conclusion');
|
|
||||||
await expect(yvv.getByText('Le nouvel ordre de « Conclusion » est entré en vigueur.')).toBeVisible();
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
||||||
// In force now: its vote stays unfolded, under the module, the list whole.
|
// In force now: its vote stays unfolded, under the module, the list whole.
|
||||||
await expect(order.locator('section.fold')).toContainText('2 pour · 0 contre');
|
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
|
||||||
await order.scrollIntoViewIfNeeded();
|
await order.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||||
await expect(order).toContainText(/adopté le/);
|
await expect(order).toContainText(/adopté le/);
|
||||||
@@ -131,13 +150,10 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
|||||||
const fold = page.locator('ul.deep section.fold');
|
const fold = page.locator('ul.deep section.fold');
|
||||||
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
||||||
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
|
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
|
||||||
|
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||||
|
const voices = await secondVoice(page, browser, pid, next, `Le titre « ${next} » est entré en vigueur.`);
|
||||||
const yvv = await person(browser, 'Yvv');
|
|
||||||
await yvv.goto(TEST_DOC);
|
|
||||||
await voteFor(yvv, next);
|
|
||||||
await expect(yvv.getByText(`Le titre « ${next} » est entré en vigueur.`)).toBeVisible();
|
|
||||||
|
|
||||||
// The document changes title; its translations stay editorial, to review.
|
// The document changes title; its translations stay editorial, to review.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -148,7 +164,7 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
|||||||
const vote = bar.locator('section.fold');
|
const vote = bar.locator('section.fold');
|
||||||
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
||||||
await expect(vote.getByText('2 pour · 0 contre')).toBeVisible();
|
await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible();
|
||||||
await shot(page, 'c26-2-titre-vote');
|
await shot(page, 'c26-2-titre-vote');
|
||||||
await bar.getByRole('button', { name: 'Le vote' }).click();
|
await bar.getByRole('button', { name: 'Le vote' }).click();
|
||||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers';
|
||||||
|
|
||||||
|
// Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit »
|
||||||
|
// holds back the later of two versions until it is lifted; the author answers,
|
||||||
|
// the objector lifts or reiterates from the box « Messages », and with no answer
|
||||||
|
// within 3 days it is lifted. A typo fix to someone else's text waits for its
|
||||||
|
// author (an administrator for a seed text) or for 3 days. Each control has its
|
||||||
|
// own people and its own proposals, in the test group.
|
||||||
|
|
||||||
|
const DAY = 86_400;
|
||||||
|
|
||||||
|
/** The menu's « Messages », with its number of unread messages. */
|
||||||
|
async function openBox(page: Page, unread: number) {
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||||
|
const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` });
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
await expect(link.locator('.unread')).toHaveText(String(unread));
|
||||||
|
await link.click();
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The details of a version, in the panel. */
|
||||||
|
async function details(page: Page, id: string) {
|
||||||
|
await page.goto(`${TEST_DOC}?onglet=details&version=${id}`);
|
||||||
|
const panel = page.locator('aside.panel');
|
||||||
|
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the details of a version: an objection against an article in force. */
|
||||||
|
async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) {
|
||||||
|
const panel = await details(page, id);
|
||||||
|
await panel.getByText('Faire une objection').click();
|
||||||
|
await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click();
|
||||||
|
await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked();
|
||||||
|
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
|
||||||
|
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
|
||||||
|
await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
|
||||||
|
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible();
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
|
||||||
|
const aude = await person(browser, 'Aude');
|
||||||
|
const oscar = await person(browser, 'Oscar');
|
||||||
|
const vera = await person(browser, 'Vera');
|
||||||
|
const title = 'Une seule clé par personne';
|
||||||
|
const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain.");
|
||||||
|
|
||||||
|
// Oscar: it repeats « Unicité du compte ».
|
||||||
|
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
|
||||||
|
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
|
||||||
|
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible();
|
||||||
|
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
|
||||||
|
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
|
||||||
|
|
||||||
|
// Vera, who votes: held back, said so, nothing to press; the list marks it.
|
||||||
|
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||||
|
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
|
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible();
|
||||||
|
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
|
||||||
|
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||||
|
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection');
|
||||||
|
await fold.scrollIntoViewIfNeeded();
|
||||||
|
await shots(vera, 'f2-objection-vue-par-qui-vote');
|
||||||
|
|
||||||
|
// Aude: a message, and the way to answer — modify the article repeated.
|
||||||
|
await aude.goto(TEST_DOC);
|
||||||
|
await openBox(aude, 1);
|
||||||
|
const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' });
|
||||||
|
await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`);
|
||||||
|
await expect(raised).toContainText("Pour répondre, modifie l'article visé.");
|
||||||
|
await shots(aude, 'f3-messages-auteur');
|
||||||
|
await raised.getByRole('button', { name: "Modifier l'article visé" }).click();
|
||||||
|
await expect(aude).toHaveURL(/onglet=proposer/);
|
||||||
|
await expect(aude).toHaveURL(/objection=/);
|
||||||
|
const sheet = aude.locator('aside.panel');
|
||||||
|
await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible();
|
||||||
|
const body = sheet.getByLabel('Texte', { exact: true });
|
||||||
|
await expect(body).toHaveValue(/Je m.engage sur l.honneur/);
|
||||||
|
await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`);
|
||||||
|
await shots(aude, 'f4-reponse-auteur');
|
||||||
|
await sheet.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||||
|
await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible();
|
||||||
|
|
||||||
|
// Oscar: the answer, to lift or reiterate, with its deadline.
|
||||||
|
await oscar.goto(TEST_DOC);
|
||||||
|
await openBox(oscar, 1);
|
||||||
|
const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' });
|
||||||
|
await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`);
|
||||||
|
await expect(answered.getByText(/^échéance : /)).toBeVisible();
|
||||||
|
await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible();
|
||||||
|
await shots(oscar, 'f5-messages-qui-objecte');
|
||||||
|
await answered.getByRole('button', { name: "Lever l'objection" }).click();
|
||||||
|
await expect(oscar.getByText('Objection levée.')).toBeVisible();
|
||||||
|
// Dealt with: no gesture any more, and read.
|
||||||
|
await expect(answered.getByText('objection levée', { exact: true })).toBeVisible();
|
||||||
|
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||||
|
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Vera: it is voted on again.
|
||||||
|
await vera.reload();
|
||||||
|
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||||
|
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => {
|
||||||
|
const aude = await person(browser, 'Aude');
|
||||||
|
const oscar = await person(browser, 'Oscar');
|
||||||
|
const vera = await person(browser, 'Vera');
|
||||||
|
const title = 'Certification à sens unique';
|
||||||
|
const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour.");
|
||||||
|
await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.');
|
||||||
|
|
||||||
|
// Aude, from the vote under the article: reword it.
|
||||||
|
await aude.goto(`${TEST_DOC}?vote=${id}`);
|
||||||
|
const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
|
await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible();
|
||||||
|
await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible();
|
||||||
|
await fold.scrollIntoViewIfNeeded();
|
||||||
|
await shots(aude, 'f8-objection-vue-par-l-auteur');
|
||||||
|
await fold.getByRole('button', { name: 'Reformuler' }).click();
|
||||||
|
await expect(aude).toHaveURL(/modifier=1/);
|
||||||
|
const panel = aude.locator('aside.panel');
|
||||||
|
const body = panel.getByLabel('Texte', { exact: true });
|
||||||
|
await expect(body).toBeVisible();
|
||||||
|
await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité.");
|
||||||
|
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||||
|
await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible();
|
||||||
|
await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible();
|
||||||
|
|
||||||
|
// Oscar says nothing; three days pass on the server's clock.
|
||||||
|
const swept = await advanceClock(oscar, 3 * DAY + 60);
|
||||||
|
expect(swept.lifted).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Vera: votable again; the objection says why it was lifted.
|
||||||
|
const panelV = await details(vera, id);
|
||||||
|
await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite');
|
||||||
|
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||||
|
await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Oscar's message: dealt with, by the deadline.
|
||||||
|
await oscar.goto('/messages');
|
||||||
|
const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` });
|
||||||
|
await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible();
|
||||||
|
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => {
|
||||||
|
const aude = await person(browser, 'Aude');
|
||||||
|
const vera = await person(browser, 'Vera');
|
||||||
|
const title = 'Clé unique et vivante';
|
||||||
|
const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul.");
|
||||||
|
|
||||||
|
// Vera fixes the apostrophes: it waits for Aude, the text stays as it was.
|
||||||
|
const panel = await details(vera, id);
|
||||||
|
await panel.getByText('Corriger une coquille').click();
|
||||||
|
const body = panel.getByLabel('Texte', { exact: true });
|
||||||
|
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||||||
|
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||||
|
await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible();
|
||||||
|
const fix = panel.locator('li.fix');
|
||||||
|
await expect(fix).toContainText('proposée par Vera');
|
||||||
|
await expect(fix).toContainText("en attente : s'applique d'elle-même le");
|
||||||
|
await expect(fix.locator('ins')).toHaveCount(3);
|
||||||
|
await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser");
|
||||||
|
await shots(vera, 'f6-coquille-en-attente');
|
||||||
|
|
||||||
|
// Aude validates it from her box.
|
||||||
|
await aude.goto(TEST_DOC);
|
||||||
|
await openBox(aude, 1);
|
||||||
|
const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` });
|
||||||
|
await expect(msg.locator('ins')).toHaveCount(3);
|
||||||
|
await expect(msg.getByText(/^échéance : /)).toBeVisible();
|
||||||
|
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
await expect(aude.getByText('Correction appliquée.')).toBeVisible();
|
||||||
|
await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible();
|
||||||
|
await expect(msg.locator('.btn')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The text carries the fix; its history says who wrote it and who let it in.
|
||||||
|
const after = await details(vera, id);
|
||||||
|
await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser');
|
||||||
|
await expect(after.locator('.history')).toContainText('coquille corrigée');
|
||||||
|
await expect(after.locator('.history')).toContainText('par Vera');
|
||||||
|
await expect(after.locator('.history')).toContainText('validée par Aude');
|
||||||
|
await expect(after.locator('li.fix')).toContainText('appliquée · par Aude');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => {
|
||||||
|
// An administrator opens the group first: the message goes to them (a seed text has no author).
|
||||||
|
const ada = await person(browser, 'Ada', true);
|
||||||
|
await ada.goto(TEST_DOC);
|
||||||
|
const vera = await person(browser, 'Vera');
|
||||||
|
await vera.goto(TEST_DOC);
|
||||||
|
const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" });
|
||||||
|
await purpose.hover();
|
||||||
|
await purpose.getByRole('button', { name: 'Le vote' }).click();
|
||||||
|
await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||||
|
const panel = vera.locator('aside.panel');
|
||||||
|
await panel.getByText('Corriger une coquille').click();
|
||||||
|
const body = panel.getByLabel('Texte', { exact: true });
|
||||||
|
await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul'));
|
||||||
|
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||||
|
await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible();
|
||||||
|
// In force, only a validated fix changes the text.
|
||||||
|
await expect(purpose).toContainText("C'est le seul et unique propos");
|
||||||
|
await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le");
|
||||||
|
|
||||||
|
await ada.reload();
|
||||||
|
await openBox(ada, 1);
|
||||||
|
const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" });
|
||||||
|
await shots(ada, 'f7-messages-administrateur');
|
||||||
|
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
await expect(ada.getByText('Correction appliquée.')).toBeVisible();
|
||||||
|
|
||||||
|
await vera.reload();
|
||||||
|
await expect(purpose).toContainText('C’est le seul et unique propos');
|
||||||
|
const doc = await testDoc(vera);
|
||||||
|
const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être");
|
||||||
|
const after = await details(vera, unit?.inForce?.id ?? '');
|
||||||
|
await expect(after.locator('.history')).toContainText('validée par Ada');
|
||||||
|
});
|
||||||
@@ -92,13 +92,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
// A seed text has no author: an administrator's fix applies at once (someone
|
||||||
|
// else's waits for an administrator, or for 3 days: C31).
|
||||||
|
await signIn(page, 'Yvv', true);
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||||
await unicity.hover();
|
await unicity.hover();
|
||||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
||||||
// What is not the vote lives in the panel, tab « Détails ».
|
// What is not the vote lives in the panel, tab « Détails ».
|
||||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
|
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||||
const panel = page.locator('aside.panel');
|
const panel = page.locator('aside.panel');
|
||||||
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||||
await panel.getByText('Corriger une coquille').click();
|
await panel.getByText('Corriger une coquille').click();
|
||||||
|
|||||||
+10
-2
@@ -79,8 +79,10 @@ test('C22 — le vote groupé : une section en un geste, et l’article à deux
|
|||||||
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
|
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
|
||||||
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
|
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
|
||||||
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
|
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
|
||||||
// Each counted: her version of E3 has its voice, the other none.
|
// Each counted: her version of E3 has its voice, the other none. Here every
|
||||||
await expect(e3.getByRole('group', { name: /variante 1\.0\.0/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
|
// dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1,
|
||||||
|
// her voice alone may put her version in force (ADR 0007).
|
||||||
|
await expect(e3.getByRole('group', { name: /variante 1\.0\.0|en vigueur/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
|
||||||
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
|
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +202,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
|||||||
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
|
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||||
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
||||||
await shot(page, '6-vote-ferme');
|
await shot(page, '6-vote-ferme');
|
||||||
|
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
||||||
|
await page.goto(`${DOC}?onglet=panier`);
|
||||||
|
const basket = page.locator('aside.panel');
|
||||||
|
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
|
||||||
|
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
|
||||||
|
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
|
||||||
// The test group still votes, directly.
|
// The test group still votes, directly.
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
||||||
|
|||||||
Generated
+1
@@ -10,6 +10,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/literata": "^5.3.0",
|
"@fontsource-variable/literata": "^5.3.0",
|
||||||
"@fontsource-variable/nunito": "^5.3.0",
|
"@fontsource-variable/nunito": "^5.3.0",
|
||||||
|
"@noble/hashes": "2.4.0",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.0",
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.70.0",
|
"@sveltejs/kit": "^2.70.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/literata": "^5.3.0",
|
"@fontsource-variable/literata": "^5.3.0",
|
||||||
"@fontsource-variable/nunito": "^5.3.0",
|
"@fontsource-variable/nunito": "^5.3.0",
|
||||||
|
"@noble/hashes": "2.4.0",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.0",
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.70.0",
|
"@sveltejs/kit": "^2.70.0",
|
||||||
|
|||||||
+15
-4
@@ -17,6 +17,8 @@ export interface Slot {
|
|||||||
versions: Proposal[];
|
versions: Proposal[];
|
||||||
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
||||||
inertia: Proposal[];
|
inertia: Proposal[];
|
||||||
|
/** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */
|
||||||
|
held: Proposal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Section {
|
export interface Section {
|
||||||
@@ -25,15 +27,19 @@ export interface Section {
|
|||||||
slots: Slot[];
|
slots: Slot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a unit offers to vote: its version in force and open versions, its open changes of inertia. */
|
/**
|
||||||
|
* What a unit offers to vote: its version in force and open versions, its open changes
|
||||||
|
* of inertia — and, apart, its versions held back by an objection (ADR 0010).
|
||||||
|
*/
|
||||||
function slotOf(unit: Unit): Slot | null {
|
function slotOf(unit: Unit): Slot | null {
|
||||||
const open = unit.proposals.filter((p) => p.status === 'open');
|
const open = unit.proposals.filter((p) => p.status === 'open');
|
||||||
const versions = [
|
const versions = [
|
||||||
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
||||||
...open.filter((p) => p.kind !== 'inertia'),
|
...open.filter((p) => p.kind !== 'inertia' && p.votable !== false),
|
||||||
];
|
];
|
||||||
const inertia = open.filter((p) => p.kind === 'inertia');
|
const inertia = open.filter((p) => p.kind === 'inertia');
|
||||||
return versions.length || inertia.length ? { unit, versions, inertia } : null;
|
const held = open.filter((p) => p.kind !== 'inertia' && p.votable === false);
|
||||||
|
return versions.length || inertia.length || held.length ? { unit, versions, inertia, held } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,9 +83,14 @@ export function waiting(slots: Slot[], picks: Picks): Slot[] {
|
|||||||
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
|
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The versions one may vote now: those a choice of the basket is sent for. */
|
||||||
|
export function votableIds(all: Section[]): Set<string> {
|
||||||
|
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||||
|
}
|
||||||
|
|
||||||
/** Empties the basket of these articles only. */
|
/** Empties the basket of these articles only. */
|
||||||
export function clearScope(slots: Slot[], picks: Picks): Picks {
|
export function clearScope(slots: Slot[], picks: Picks): Picks {
|
||||||
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)));
|
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia, ...s.held].map((p) => p.id)));
|
||||||
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
|
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
// section entière, ou un article avec son titre, ou séparément »): choices
|
// section entière, ou un article avec son titre, ou séparément »): choices
|
||||||
// article by article, « tout pour » / « tout contre » for a section or the whole
|
// article by article, « tout pour » / « tout contre » for a section or the whole
|
||||||
// document — never for an article whose versions compete —, then one gesture.
|
// document — never for an article whose versions compete —, then one gesture.
|
||||||
|
// A version held back by an objection is shown, with nothing to choose.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
|
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
|
||||||
import type { DocState } from '$lib/doc.svelte';
|
import type { DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
// The modules too (ADR 0009): the title first, each order at the end of its section.
|
// The modules too (ADR 0009): the title first, each order at the end of its section.
|
||||||
const all = $derived(sections(view?.units ?? [], view?.modules));
|
const all = $derived(sections(view?.units ?? [], view?.modules));
|
||||||
const slots = $derived(all.flatMap((s) => s.slots));
|
const slots = $derived(all.flatMap((s) => s.slots));
|
||||||
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
|
const known = $derived(votableIds(all));
|
||||||
const toSend = $derived(lines(basket.picks, known));
|
const toSend = $derived(lines(basket.picks, known));
|
||||||
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
|
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
|
||||||
const left = $derived(waiting(slots, basket.picks));
|
const left = $derived(waiting(slots, basket.picks));
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
|
|
||||||
function headline(s: Slot): string {
|
function headline(s: Slot): string {
|
||||||
if (s.unit.kind) return moduleName(s.unit);
|
if (s.unit.kind) return moduleName(s.unit);
|
||||||
const text = s.versions.find((p) => p.kind === 'text');
|
const text = [...s.versions, ...s.held].find((p) => p.kind === 'text');
|
||||||
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
|
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
{#each s.slots as slot (slot.unit.id)}
|
{#each s.slots as slot (slot.unit.id)}
|
||||||
{@const title = headline(slot)}
|
{@const title = headline(slot)}
|
||||||
{@const several = slot.versions.length > 1}
|
{@const several = slot.versions.length > 1}
|
||||||
{@const first = slot.versions[0] ?? slot.inertia[0]}
|
{@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[0]}
|
||||||
<li class="slot" class:several class:waiting={left.includes(slot)}>
|
<li class="slot" class:several class:waiting={left.includes(slot)}>
|
||||||
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
|
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
|
||||||
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
|
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
|
||||||
@@ -147,6 +148,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each slot.held as v (v.id)}
|
||||||
|
<div class="version held">
|
||||||
|
<span class="about">
|
||||||
|
<span class="label">{labelOf(v, slot)}</span>
|
||||||
|
<span class="faint small">{t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
|
||||||
|
</span>
|
||||||
|
<span class="held-note">{t('basket.held')}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -161,9 +171,11 @@
|
|||||||
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
|
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
|
||||||
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
|
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
|
||||||
</button>
|
</button>
|
||||||
<p class="faint small note-send">
|
{#if open}
|
||||||
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
<p class="faint small note-send">
|
||||||
</p>
|
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
@@ -281,6 +293,17 @@
|
|||||||
.small {
|
.small {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.version.held .label {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.held-note {
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--ochre-wash);
|
||||||
|
color: var(--ochre);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
.send {
|
.send {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: -1.6rem;
|
bottom: -1.6rem;
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
// the ballot, the person's wallet signs one transaction — the amount to the
|
// the ballot, the person's wallet signs one transaction — the amount to the
|
||||||
// vote account and the seal of the ballot —, the page waits for the chain to
|
// vote account and the seal of the ballot —, the page waits for the chain to
|
||||||
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
|
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
|
||||||
|
//
|
||||||
|
// A ballot nothing left for is let go at once (`DELETE`): the wallet refused,
|
||||||
|
// the node did not answer before signing, the window was closed before it.
|
||||||
|
// Once signed, the transaction may land: the ballot is kept, and so is a
|
||||||
|
// Ğecko code — the server reads the indexer for them, the page open or not.
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { DocState } from '$lib/doc.svelte';
|
import type { DocState } from '$lib/doc.svelte';
|
||||||
@@ -10,7 +15,7 @@
|
|||||||
import type { Ballot, ChainSettings } from '$lib/types';
|
import type { Ballot, ChainSettings } from '$lib/types';
|
||||||
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
|
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
|
||||||
import { geckoLink, qrPath } from '$lib/wallet/gecko';
|
import { geckoLink, qrPath } from '$lib/wallet/gecko';
|
||||||
import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
|
import { failure, mayLetGo, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
doc,
|
doc,
|
||||||
@@ -47,11 +52,19 @@
|
|||||||
let ballot = $state<Ballot | null>(null);
|
let ballot = $state<Ballot | null>(null);
|
||||||
let timer: ReturnType<typeof setInterval> | undefined;
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
/** The transaction of the current ballot is signed: it may reach the chain. */
|
||||||
|
let left = false;
|
||||||
|
/** A wallet is asked to sign: its own window may stay open after this one closes. */
|
||||||
|
let asking = false;
|
||||||
|
/** The current ballot was let go: nothing left for it. */
|
||||||
|
let dropped = false;
|
||||||
|
|
||||||
const amount = $derived(g1Amount(chain.amount));
|
const amount = $derived(g1Amount(chain.amount));
|
||||||
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
|
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
|
||||||
/** Ğecko is offered where nothing is being signed nor waited for. */
|
/** Ğecko is offered where nothing is being signed nor waited for. */
|
||||||
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
|
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
|
||||||
|
/** The receipt of a ballot that left, or may still. */
|
||||||
|
const receiptShown = $derived(['waiting', 'gecko', 'confirmed', 'refused'].includes(step.kind));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog?.showModal();
|
dialog?.showModal();
|
||||||
@@ -64,9 +77,19 @@
|
|||||||
timer = undefined;
|
timer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nothing left for this ballot: it is let go, rather than shown as waiting for a day. */
|
||||||
|
async function letGo(b: Ballot) {
|
||||||
|
if (dropped || ballot?.id !== b.id) return;
|
||||||
|
dropped = true;
|
||||||
|
await api.del(`/ballots/${b.id}`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
stop();
|
stop();
|
||||||
|
// Closed before anything left (and no Ğecko code shown): let it go. A wallet still
|
||||||
|
// asking keeps it until it answers — signed, it may land; refused, `go` lets it go.
|
||||||
|
if (ballot?.status === 'pending' && !left && !asking && step.kind !== 'gecko') void letGo(ballot);
|
||||||
dialog?.close();
|
dialog?.close();
|
||||||
onclose();
|
onclose();
|
||||||
}
|
}
|
||||||
@@ -113,6 +136,7 @@
|
|||||||
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
|
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (closed) return;
|
||||||
const usual = accounts.find((a) => a.address === address);
|
const usual = accounts.find((a) => a.address === address);
|
||||||
if (usual) return go(wallet, usual);
|
if (usual) return go(wallet, usual);
|
||||||
const [only] = accounts;
|
const [only] = accounts;
|
||||||
@@ -134,38 +158,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Signed: from now on the transaction may reach the chain. */
|
||||||
|
function onTheWay(b: Ballot) {
|
||||||
|
if (left) return;
|
||||||
|
left = true;
|
||||||
|
onsent();
|
||||||
|
if (!closed) wait(b);
|
||||||
|
}
|
||||||
|
|
||||||
async function go(wallet: Wallet, account: WalletAccount) {
|
async function go(wallet: Wallet, account: WalletAccount) {
|
||||||
remember(wallet, account);
|
remember(wallet, account);
|
||||||
step = { kind: 'opening' };
|
step = { kind: 'opening' };
|
||||||
const b = await seal(account.address);
|
const b = await seal(account.address);
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
ballot = b;
|
ballot = b;
|
||||||
let sent = false;
|
left = false;
|
||||||
|
dropped = false;
|
||||||
|
if (closed) return letGo(b);
|
||||||
|
asking = true;
|
||||||
try {
|
try {
|
||||||
step = { kind: 'signing' };
|
step = { kind: 'signing' };
|
||||||
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
|
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
|
||||||
if (s === 'signing') step = { kind: 'signing' };
|
if (s === 'signing') {
|
||||||
else if (!sent) {
|
if (!closed) step = { kind: 'signing' };
|
||||||
sent = true;
|
} else onTheWay(b);
|
||||||
onsent();
|
|
||||||
wait(b);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!sent) {
|
onTheWay(b);
|
||||||
sent = true;
|
|
||||||
onsent();
|
|
||||||
wait(b);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const f: Failure = failure(e);
|
const f: Failure = failure(e);
|
||||||
// Once on its way, the chain may still take it: keep waiting.
|
// Signed, then the node or the wallet went quiet: it may still land. Keep waiting.
|
||||||
if (sent && f === 'node') return;
|
if (!mayLetGo(f, left)) return;
|
||||||
if (nothingSent(f)) {
|
if (f === 'account') remember(null);
|
||||||
if (f === 'account') remember(null);
|
await letGo(b);
|
||||||
await api.del(`/ballots/${b.id}`).catch(() => undefined);
|
|
||||||
}
|
|
||||||
stop();
|
stop();
|
||||||
step = { kind: 'failed', text: t(`sign.error.${f}`) };
|
if (!closed) step = { kind: 'failed', text: t(`sign.error.${f}`) };
|
||||||
|
} finally {
|
||||||
|
asking = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +202,9 @@
|
|||||||
const b = await seal(null);
|
const b = await seal(null);
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
ballot = b;
|
ballot = b;
|
||||||
|
left = false;
|
||||||
|
dropped = false;
|
||||||
|
if (closed) return letGo(b);
|
||||||
const link = geckoLink(b.account, b.amount, b.comment);
|
const link = geckoLink(b.account, b.amount, b.comment);
|
||||||
step = { kind: 'gecko', link, qr: await qrPath(link) };
|
step = { kind: 'gecko', link, qr: await qrPath(link) };
|
||||||
wait(b);
|
wait(b);
|
||||||
@@ -271,6 +302,13 @@
|
|||||||
{:else if step.kind === 'failed'}
|
{:else if step.kind === 'failed'}
|
||||||
<p class="warn" role="alert">{step.text}</p>
|
<p class="warn" role="alert">{step.text}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if receiptShown && ballot}
|
||||||
|
<p class="receipt small">
|
||||||
|
<a href="/api/ballots/{ballot.id}/receipt" download>{t('sign.receipt')}</a>
|
||||||
|
· <a href="/bulletins">{t('sign.history')}</a>
|
||||||
|
</p>
|
||||||
|
<p class="faint small">{t('sign.receipt.note')}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -393,6 +431,10 @@
|
|||||||
.small {
|
.small {
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
.receipt a {
|
||||||
|
font-weight: 750;
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
|
|
||||||
{#snippet marks(p: Proposal)}
|
{#snippet marks(p: Proposal)}
|
||||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
||||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import type { DocState, PanelState } from '$lib/doc.svelte';
|
import type { DocState, PanelState } from '$lib/doc.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { relationOf, titleOf } from '$lib/labels';
|
import { relationOf, titleOf } from '$lib/labels';
|
||||||
|
import { answerPanel } from '$lib/objections';
|
||||||
import type { BasketState } from '$lib/picks.svelte';
|
import type { BasketState } from '$lib/picks.svelte';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
import type { Lang, Proposal } from '$lib/types';
|
import type { Lang, Proposal } from '$lib/types';
|
||||||
import type { Line } from '$lib/vote';
|
import type { Line } from '$lib/vote';
|
||||||
import BallotPanel from './BallotPanel.svelte';
|
import BallotPanel from './BallotPanel.svelte';
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
<div class="bar">
|
<div class="bar">
|
||||||
<div class="tabs" role="tablist" bind:this={tablist}>
|
<div class="tabs" role="tablist" bind:this={tablist}>
|
||||||
{#each tabs as [key, label] (key)}
|
{#each tabs as [key, label] (key)}
|
||||||
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
|
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
||||||
@@ -98,8 +100,13 @@
|
|||||||
{doc}
|
{doc}
|
||||||
proposalId={voteId}
|
proposalId={voteId}
|
||||||
{lang}
|
{lang}
|
||||||
|
edit={!!panel.edit}
|
||||||
onvote={(id) => onvote(id, true)}
|
onvote={(id) => onvote(id, true)}
|
||||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })}
|
||||||
|
onanswer={(o) => {
|
||||||
|
const next = answerPanel(o, session.me?.id ?? null);
|
||||||
|
if (next) onchange(next);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{:else if panel.tab === 'proposals' && unit}
|
{:else if panel.tab === 'proposals' && unit}
|
||||||
<ul class="versions">
|
<ul class="versions">
|
||||||
@@ -110,7 +117,7 @@
|
|||||||
<span class="text">
|
<span class="text">
|
||||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||||
<span class="title">{nameOfVersion(v)}</span>
|
<span class="title">{nameOfVersion(v)}</span>
|
||||||
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}</span>
|
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -127,6 +134,8 @@
|
|||||||
unitId={panel.unitId}
|
unitId={panel.unitId}
|
||||||
draftId={panel.draftId ?? null}
|
draftId={panel.draftId ?? null}
|
||||||
prefill={panel.prefill ?? null}
|
prefill={panel.prefill ?? null}
|
||||||
|
from={panel.objectionId ? (panel.proposalId ?? null) : null}
|
||||||
|
objectionId={panel.objectionId ?? null}
|
||||||
ondone={(r) =>
|
ondone={(r) =>
|
||||||
r.proposalId && r.submitted
|
r.proposalId && r.submitted
|
||||||
? onvote(r.proposalId, true)
|
? onvote(r.proposalId, true)
|
||||||
@@ -234,6 +243,9 @@
|
|||||||
.small {
|
.small {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.held {
|
||||||
|
color: var(--ochre);
|
||||||
|
}
|
||||||
@keyframes enter {
|
@keyframes enter {
|
||||||
from {
|
from {
|
||||||
transform: translateX(16px);
|
transform: translateX(16px);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A few line icons, drawn here rather than pulled from a library.
|
// A few line icons, drawn here rather than pulled from a library.
|
||||||
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus'; size?: number } = $props();
|
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus' | 'inbox'; size?: number } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
@@ -22,5 +22,7 @@
|
|||||||
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
|
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
|
||||||
{:else if name === 'plus'}
|
{:else if name === 'plus'}
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
{:else if name === 'inbox'}
|
||||||
|
<path d="M4 13l2.2-7.3A1 1 0 0 1 7.2 5h9.6a1 1 0 0 1 1 .7L20 13v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" /><path d="M4 13h4.5l1 2h5l1-2H20" />
|
||||||
{/if}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -17,12 +17,18 @@
|
|||||||
unitId,
|
unitId,
|
||||||
draftId = null,
|
draftId = null,
|
||||||
prefill = null,
|
prefill = null,
|
||||||
|
from = null,
|
||||||
|
objectionId = null,
|
||||||
ondone,
|
ondone,
|
||||||
}: {
|
}: {
|
||||||
doc: DocState;
|
doc: DocState;
|
||||||
unitId: string | null;
|
unitId: string | null;
|
||||||
draftId?: string | null;
|
draftId?: string | null;
|
||||||
prefill?: { title: string; body: string } | null;
|
prefill?: { title: string; body: string } | null;
|
||||||
|
/** The version whose text the new one starts from (an answer to an objection). */
|
||||||
|
from?: string | null;
|
||||||
|
/** The objection this proposal answers (ADR 0010): linked to it when created. */
|
||||||
|
objectionId?: string | null;
|
||||||
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
|
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -44,17 +50,19 @@
|
|||||||
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
||||||
// which would wipe what the person is typing.
|
// which would wipe what the person is typing.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const [u0, d0, pf] = [unitId, draftId, prefill];
|
const [u0, d0, pf, f0] = [unitId, draftId, prefill, from];
|
||||||
|
void objectionId;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
const u = doc.unit(u0);
|
const u = doc.unit(u0);
|
||||||
const d = doc.proposal(d0);
|
const d = doc.proposal(d0);
|
||||||
|
const f = doc.proposal(f0);
|
||||||
target = u0;
|
target = u0;
|
||||||
savedId = d0;
|
savedId = d0;
|
||||||
mode = u0 ? 'replace' : 'new';
|
mode = u0 ? 'replace' : 'new';
|
||||||
// The section of the article one comes from, else the last one.
|
// The section of the article one comes from, else the last one.
|
||||||
section = u?.section ?? doc.view?.units.at(-1)?.section ?? '';
|
section = u?.section ?? doc.view?.units.at(-1)?.section ?? '';
|
||||||
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
title = pf?.title ?? d?.title ?? f?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
||||||
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
|
body = pf?.body ?? d?.body ?? f?.body ?? u?.inForce?.body ?? '';
|
||||||
error = '';
|
error = '';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,6 +104,7 @@
|
|||||||
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
||||||
body: mode === 'remove' ? '' : body,
|
body: mode === 'remove' ? '' : body,
|
||||||
submit: false,
|
submit: false,
|
||||||
|
...(objectionId && mode !== 'new' ? { objectionId } : {}),
|
||||||
});
|
});
|
||||||
savedId = r.id;
|
savedId = r.id;
|
||||||
return r.id;
|
return r.id;
|
||||||
@@ -155,6 +164,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="propose">
|
<div class="propose">
|
||||||
|
{#if objectionId && mode !== 'new' && !draft}<p class="linked">{t('objection.linked')}</p>{/if}
|
||||||
{#if !draft}
|
{#if !draft}
|
||||||
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
|
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
|
||||||
{#if unit}
|
{#if unit}
|
||||||
@@ -252,6 +262,13 @@
|
|||||||
.assist .btn {
|
.assist .btn {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
.linked {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--ochre-wash);
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
.small {
|
.small {
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,17 @@
|
|||||||
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
|
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
|
||||||
<span class="eyebrow">{t('nav.groups')}</span>
|
<span class="eyebrow">{t('nav.groups')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="messages"
|
||||||
|
href="/messages"
|
||||||
|
class:current={path === '/messages'}
|
||||||
|
aria-current={path === '/messages' ? 'page' : undefined}
|
||||||
|
aria-label={session.unread ? i18n.plural('nav.messages.count', session.unread) : undefined}
|
||||||
|
onclick={() => onnavigate?.()}
|
||||||
|
>
|
||||||
|
<Icon name="inbox" size={17} /><span>{t('nav.messages')}</span>
|
||||||
|
{#if session.unread}<span class="unread" aria-hidden="true">{session.unread}</span>{/if}
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ul class="tools">
|
<ul class="tools">
|
||||||
@@ -146,6 +157,37 @@
|
|||||||
.groups.current .eyebrow {
|
.groups.current .eyebrow {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
.messages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
margin-top: -0.9rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.messages:hover,
|
||||||
|
.messages.current {
|
||||||
|
background: var(--water-wash);
|
||||||
|
}
|
||||||
|
.messages.current {
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
|
.unread {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--ochre);
|
||||||
|
color: var(--on-water);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.tools {
|
.tools {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A version in the panel, tab « Détails »: its text and what changed, then
|
// A version in the panel, tab « Détails »: its text and what changed, then
|
||||||
// what is not the vote — typos, translations, flags of contradiction, history.
|
// what is not the vote — typos (and the fixes waiting for an answer),
|
||||||
// The vote itself unfolds under the article (VoteFold).
|
// translations, objections, history. The vote itself unfolds under the
|
||||||
|
// article (VoteFold). Objections and validated typo fixes: ADR 0010.
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api, ApiError } from '$lib/api';
|
||||||
|
import { condense } from '$lib/diff';
|
||||||
import { shown, type DocState } from '$lib/doc.svelte';
|
import { shown, type DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
import { toasts } from '$lib/toast.svelte';
|
import { toasts } from '$lib/toast.svelte';
|
||||||
import { announce } from '$lib/vote';
|
import { announce } from '$lib/vote';
|
||||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
import type { DiffPart, Entered, Inertia, Lang, Objection, ObjectionKind, Proposal, TypoFix } from '$lib/types';
|
||||||
import DiffText from './DiffText.svelte';
|
import DiffText from './DiffText.svelte';
|
||||||
import Prose from './Prose.svelte';
|
import Prose from './Prose.svelte';
|
||||||
|
|
||||||
@@ -16,27 +19,42 @@
|
|||||||
doc,
|
doc,
|
||||||
proposalId,
|
proposalId,
|
||||||
lang,
|
lang,
|
||||||
|
edit = false,
|
||||||
onvote,
|
onvote,
|
||||||
onpropose,
|
onpropose,
|
||||||
|
onanswer,
|
||||||
}: {
|
}: {
|
||||||
doc: DocState;
|
doc: DocState;
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
|
/** Opened to reword one's own version (an answer to an objection). */
|
||||||
|
edit?: boolean;
|
||||||
/** Unfolds the vote of this version under its article. */
|
/** Unfolds the vote of this version under its article. */
|
||||||
onvote: (proposalId: string) => void;
|
onvote: (proposalId: string) => void;
|
||||||
onpropose: (prefill: { title: string; body: string }) => void;
|
onpropose: (prefill: { title: string; body: string }) => void;
|
||||||
|
/** Answers an objection holding a version back: modify the article repeated, or reword. */
|
||||||
|
onanswer: (objection: Objection) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
revision: number;
|
||||||
|
kind: 'initial' | 'typo' | 'substantive' | 'amendment' | 'revert';
|
||||||
|
author: string | null;
|
||||||
|
authorInvited: boolean;
|
||||||
|
at: string;
|
||||||
|
diff: DiffPart[] | null;
|
||||||
|
/** A fix someone else wrote: who let it in, or that it passed at its deadline. */
|
||||||
|
typo: { id: string; answeredBy: string | null; deadline: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Detail {
|
interface Detail {
|
||||||
proposal: Proposal & { author: string | null; authorInvited: boolean };
|
proposal: Proposal & { author: string | null; authorInvited: boolean };
|
||||||
unit: { id: string; code: string; inertia: Inertia };
|
unit: { id: string; code: string; inertia: Inertia };
|
||||||
inForce: { id: string; title: string; body: string } | null;
|
inForce: { id: string; title: string; body: string } | null;
|
||||||
diff: DiffPart[] | null;
|
diff: DiffPart[] | null;
|
||||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
history: HistoryItem[];
|
||||||
signals: {
|
objections: Objection[];
|
||||||
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
typos: TypoFix[];
|
||||||
other: { id: string; title: string; status: string; code: string | null } | null;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
@@ -49,20 +67,23 @@
|
|||||||
let typoSubstantive = $state(false);
|
let typoSubstantive = $state(false);
|
||||||
let trTitle = $state('');
|
let trTitle = $state('');
|
||||||
let trBody = $state('');
|
let trBody = $state('');
|
||||||
let signalTarget = $state('');
|
let objectionKind = $state<ObjectionKind>('contredit');
|
||||||
let signalReason = $state('');
|
let objectionTarget = $state('');
|
||||||
|
let objectionReason = $state('');
|
||||||
|
|
||||||
const view = $derived(doc.view);
|
const view = $derived(doc.view);
|
||||||
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
||||||
const p = $derived(detail?.proposal);
|
const p = $derived(detail?.proposal);
|
||||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||||
const text = $derived(p ? shown(p, lang, source) : null);
|
const text = $derived(p ? shown(p, lang, source) : null);
|
||||||
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
|
/** Open or in force: fixed, translated, objected to — whether held back or not. */
|
||||||
const signalTargets = $derived(
|
const live = $derived(p?.status === 'open' || p?.status === 'in_force');
|
||||||
|
/** The versions an objection may name: another article's, open or in force — never two texts in force. */
|
||||||
|
const objectionTargets = $derived(
|
||||||
(view?.units ?? []).flatMap((u) =>
|
(view?.units ?? []).flatMap((u) =>
|
||||||
[u.inForce, ...u.proposals]
|
[u.inForce, ...u.proposals]
|
||||||
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
|
.filter((x): x is Proposal => !!x && x.id !== p?.id && (x.status === 'open' || x.status === 'in_force') && x.kind === 'text')
|
||||||
.filter((x) => !(u.id === unit?.id))
|
.filter((x) => u.id !== unit?.id && !(x.status === 'in_force' && p?.status === 'in_force'))
|
||||||
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -75,6 +96,7 @@
|
|||||||
typoTitle = d.proposal.title;
|
typoTitle = d.proposal.title;
|
||||||
typoBody = d.proposal.body;
|
typoBody = d.proposal.body;
|
||||||
typoSubstantive = false;
|
typoSubstantive = false;
|
||||||
|
if (edit && d.proposal.mine && d.proposal.status === 'open') typoOpen = true;
|
||||||
const tr = d.proposal.translations[lang];
|
const tr = d.proposal.translations[lang];
|
||||||
trTitle = tr?.title ?? d.proposal.title;
|
trTitle = tr?.title ?? d.proposal.title;
|
||||||
trBody = tr?.body ?? d.proposal.body;
|
trBody = tr?.body ?? d.proposal.body;
|
||||||
@@ -86,6 +108,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void proposalId;
|
void proposalId;
|
||||||
void lang;
|
void lang;
|
||||||
|
void edit;
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,15 +119,36 @@
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
await doc.reload();
|
await doc.reload();
|
||||||
await load();
|
await load();
|
||||||
|
void session.refreshUnread();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<void>) {
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
error = message(err);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTypo(e: SubmitEvent) {
|
async function saveTypo(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
busy = true;
|
busy = true;
|
||||||
|
error = '';
|
||||||
typoSubstantive = false;
|
typoSubstantive = false;
|
||||||
try {
|
try {
|
||||||
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
|
const r = await api.patch<{ edit: string; typo?: { id: string; dueAt: string; answerer: 'author' | 'admin' } }>(
|
||||||
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
`/proposals/${proposalId}`,
|
||||||
|
{ title: typoTitle, body: typoBody },
|
||||||
|
);
|
||||||
|
if (r.edit === 'typo_pending' && r.typo) {
|
||||||
|
toasts.show(t(r.typo.answerer === 'admin' ? 'typo.pending.admin' : 'typo.pending.author', { date: i18n.date(r.typo.dueAt) }), 'info', 7000);
|
||||||
|
} else {
|
||||||
|
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
||||||
|
}
|
||||||
typoOpen = false;
|
typoOpen = false;
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,60 +162,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function answerTypo(fix: TypoFix, gesture: 'validate' | 'refuse') {
|
||||||
|
await run(async () => {
|
||||||
|
await api.post(`/typos/${fix.id}/${gesture}`);
|
||||||
|
toasts.show(t(gesture === 'validate' ? 'typo.validated' : 'typo.refused'));
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTranslation(e: SubmitEvent) {
|
async function saveTranslation(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
busy = true;
|
await run(async () => {
|
||||||
try {
|
|
||||||
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
||||||
toasts.show(t('translate.saved'));
|
toasts.show(t('translate.saved'));
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
});
|
||||||
error = message(err);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendSignal(e: SubmitEvent) {
|
async function raise(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
busy = true;
|
await run(async () => {
|
||||||
try {
|
await api.post(`/proposals/${proposalId}/objections`, { otherId: objectionTarget, kind: objectionKind, reason: objectionReason });
|
||||||
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
|
objectionTarget = '';
|
||||||
signalTarget = '';
|
objectionReason = '';
|
||||||
signalReason = '';
|
toasts.show(t('objection.sent'), 'info', 6000);
|
||||||
toasts.show(t('signal.sent'));
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
});
|
||||||
error = message(err);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lift(id: string) {
|
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
|
||||||
busy = true;
|
await run(async () => {
|
||||||
try {
|
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
|
||||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||||
await refresh();
|
await refresh();
|
||||||
announce(doc, r.entered);
|
announce(doc, r.entered ?? []);
|
||||||
} catch (err) {
|
});
|
||||||
error = message(err);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withdraw() {
|
async function withdraw() {
|
||||||
busy = true;
|
await run(async () => {
|
||||||
try {
|
|
||||||
await api.post(`/proposals/${proposalId}/withdraw`);
|
await api.post(`/proposals/${proposalId}/withdraw`);
|
||||||
toasts.show(t('propose.withdrawn'));
|
toasts.show(t('propose.withdrawn'));
|
||||||
await doc.reload();
|
await doc.reload();
|
||||||
} catch (err) {
|
});
|
||||||
error = message(err);
|
}
|
||||||
} finally {
|
|
||||||
busy = false;
|
/** Where an objection stands, in a few words. */
|
||||||
}
|
function stateOf(o: Objection): string {
|
||||||
|
if (o.phase === 'lifted') return t(`objection.lifted.${o.lift ?? 'objector'}` as Key);
|
||||||
|
if (o.phase === 'objector') return t('objection.phase.objector', { date: i18n.date(o.dueAt) });
|
||||||
|
return t('objection.phase.author');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What became of a fix, or when it applies by itself. */
|
||||||
|
function typoState(fix: TypoFix): string {
|
||||||
|
if (fix.status === 'pending') return t('typo.status.pending', { date: i18n.date(fix.dueAt) });
|
||||||
|
if (fix.status === 'stale') return t('typo.status.stale');
|
||||||
|
const what = t(`typo.status.${fix.status}`);
|
||||||
|
if (fix.settledBy === 'deadline') return `${what} · ${t('typo.settled.deadline')}`;
|
||||||
|
if (fix.settledBy && fix.settledBy !== 'changed') return `${what} · ${t('typo.settled.by', { name: fix.settledBy })}`;
|
||||||
|
return what;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typoGesture(fix: TypoFix, g: 'validate' | 'refuse'): string {
|
||||||
|
if (fix.status === 'applied') return t('typo.undo');
|
||||||
|
if (fix.status === 'refused') return t('typo.redo');
|
||||||
|
return t(g === 'validate' ? 'typo.validate' : 'typo.refuse');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -184,6 +240,7 @@
|
|||||||
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
||||||
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||||
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||||
|
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
||||||
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -211,14 +268,14 @@
|
|||||||
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if votable}
|
{#if live}
|
||||||
<p class="tally faint">
|
<p class="tally faint">
|
||||||
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
||||||
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (p.kind === 'text' || p.kind === 'title') && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
{#if (p.kind === 'text' || p.kind === 'title') && live}
|
||||||
<section class="sub">
|
<section class="sub">
|
||||||
<details bind:open={typoOpen}>
|
<details bind:open={typoOpen}>
|
||||||
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
||||||
@@ -236,6 +293,30 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.typos.length}
|
||||||
|
<section class="sub" aria-labelledby="typos-{p.id}">
|
||||||
|
<h4 id="typos-{p.id}">{t('typo.list')}</h4>
|
||||||
|
<ul class="fixes">
|
||||||
|
{#each detail.typos as fix (fix.id)}
|
||||||
|
<li class="fix {fix.status}">
|
||||||
|
<DiffText parts={condense(fix.diff)} />
|
||||||
|
<p class="faint small">
|
||||||
|
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)}
|
||||||
|
</p>
|
||||||
|
<p class="small state">{typoState(fix)}</p>
|
||||||
|
{#if fix.gestures.length}
|
||||||
|
<div class="gestures">
|
||||||
|
{#each fix.gestures as g (g)}
|
||||||
|
<button class="btn small" class:primary={g === 'validate' && fix.status === 'pending'} type="button" disabled={busy} onclick={() => answerTypo(fix, g)}>{typoGesture(fix, g)}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if lang !== source && (p.kind === 'text' || p.kind === 'title')}
|
{#if lang !== source && (p.kind === 'text' || p.kind === 'title')}
|
||||||
<section class="sub">
|
<section class="sub">
|
||||||
<details>
|
<details>
|
||||||
@@ -256,7 +337,10 @@
|
|||||||
<ol class="history">
|
<ol class="history">
|
||||||
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
||||||
<li>
|
<li>
|
||||||
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
|
<p class="small">
|
||||||
|
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
|
||||||
|
{#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}
|
||||||
|
</p>
|
||||||
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -264,33 +348,69 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if p.kind === 'text' && votable}
|
{#if p.kind === 'text' && (live || detail.objections.length)}
|
||||||
<section class="sub">
|
<section class="sub" aria-labelledby="objections-{p.id}">
|
||||||
<h4>{t('signal.title')}</h4>
|
<h4 id="objections-{p.id}">{t('objection.title')}</h4>
|
||||||
{#each detail.signals as s (s.id)}
|
{#each detail.objections as o (o.id)}
|
||||||
<div class="signal" class:lifted={s.liftedAt}>
|
{@const standing = o.phase !== 'lifted'}
|
||||||
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
|
<div class="objection" class:lifted={!standing}>
|
||||||
<p class="faint small">
|
<p class="small">
|
||||||
{t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
|
<span class="tag" class:ochre={standing}>{t(`objection.kind.${o.kind}`)}</span>
|
||||||
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
|
<b>{t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}</b>
|
||||||
</p>
|
</p>
|
||||||
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
|
<p class="reason">{o.reason}</p>
|
||||||
|
<p class="faint small">
|
||||||
|
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)}
|
||||||
|
· {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if}
|
||||||
|
</p>
|
||||||
|
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
|
||||||
|
{#if standing}
|
||||||
|
<div class="gestures">
|
||||||
|
{#if o.iAnswer && !o.mine && o.phase === 'author'}
|
||||||
|
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
|
||||||
|
{/if}
|
||||||
|
{#if o.canLift}
|
||||||
|
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
|
||||||
|
{/if}
|
||||||
|
{#if o.canReiterate}
|
||||||
|
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="faint small">{t('signal.none')}</p>
|
<p class="faint small">{t('objection.none')}</p>
|
||||||
{/each}
|
{/each}
|
||||||
<details>
|
{#if live && objectionTargets.length}
|
||||||
<summary>{t('signal.add')}</summary>
|
<details>
|
||||||
<form class="form" onsubmit={sendSignal}>
|
<summary>{t('objection.add')}</summary>
|
||||||
<p class="faint small">{t('signal.note')}</p>
|
<form class="form" onsubmit={raise}>
|
||||||
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
|
<p class="faint small">{t('objection.note')}</p>
|
||||||
<option value="" disabled>{t('signal.target')}</option>
|
<div class="kinds" role="radiogroup" aria-label={t('objection.kind')}>
|
||||||
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
{#each ['redit', 'contredit'] as const as k (k)}
|
||||||
</select>
|
<label class:on={objectionKind === k}>
|
||||||
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
|
<input type="radio" bind:group={objectionKind} value={k} />
|
||||||
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
|
<b>{t(`objection.kind.${k}`)}</b><span class="hint">{t(`objection.kind.${k}.hint`)}</span>
|
||||||
</form>
|
</label>
|
||||||
</details>
|
{/each}
|
||||||
|
</div>
|
||||||
|
<select class="select" bind:value={objectionTarget} required aria-label={t('objection.target')}>
|
||||||
|
<option value="" disabled>{t('objection.target')}</option>
|
||||||
|
{#each objectionTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
class="textarea short"
|
||||||
|
bind:value={objectionReason}
|
||||||
|
rows="3"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="600"
|
||||||
|
placeholder={t(`objection.reason.${objectionKind}`)}
|
||||||
|
aria-label={t(`objection.reason.${objectionKind}`)}
|
||||||
|
></textarea>
|
||||||
|
<button class="btn small" type="submit" disabled={busy || !objectionTarget || objectionReason.trim().length < 10}>{t('objection.send')}</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -391,18 +511,88 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
.signal {
|
.fixes {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.fix,
|
||||||
|
.objection {
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.6rem 0.8rem;
|
||||||
border-radius: var(--r-input);
|
border-radius: var(--r-input);
|
||||||
background: var(--ochre-wash);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.signal.lifted {
|
.fix {
|
||||||
|
background: var(--water-wash);
|
||||||
|
}
|
||||||
|
.fix.pending .state {
|
||||||
|
font-weight: 750;
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
|
.fix.refused,
|
||||||
|
.fix.stale {
|
||||||
|
background: var(--surface-2);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.objection {
|
||||||
|
background: var(--ochre-wash);
|
||||||
|
}
|
||||||
|
.objection.lifted {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
.signal .btn {
|
.reason {
|
||||||
justify-self: start;
|
font-family: var(--font-text);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.held {
|
||||||
|
font-weight: 750;
|
||||||
|
color: var(--ochre);
|
||||||
|
}
|
||||||
|
.gestures {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.kinds {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.kinds label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.kinds label.on {
|
||||||
|
background: var(--ochre);
|
||||||
|
color: var(--on-water);
|
||||||
|
}
|
||||||
|
.kinds label.on .hint {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.kinds input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.kinds label:has(input:focus-visible) {
|
||||||
|
outline: 2px solid var(--water);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
|
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
|
||||||
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
|
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
|
||||||
// never who voted what —, how far the threshold is, your vote and where it
|
// never who voted what —, how far the threshold is, your vote and where it
|
||||||
// stands, then the inertia and the formula. The rest of a version (typos,
|
// stands, then the inertia and the formula. A version held back by an
|
||||||
// translations, flags, history) lives in the panel, tab « Détails ».
|
// objection says so, and tells each one what is theirs to do (ADR 0010). The
|
||||||
|
// rest of a version (typos, translations, objections, history) lives in the
|
||||||
|
// panel, tab « Détails ».
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { DocState } from '$lib/doc.svelte';
|
import type { DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||||
|
import { holding } from '$lib/objections';
|
||||||
import { moved } from '$lib/order';
|
import { moved } from '$lib/order';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
import { toasts } from '$lib/toast.svelte';
|
import { toasts } from '$lib/toast.svelte';
|
||||||
import type { LevelInfo, Proposal, Unit } from '$lib/types';
|
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
|
||||||
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||||
import Formula from './Formula.svelte';
|
import Formula from './Formula.svelte';
|
||||||
import InertiaPicker from './InertiaPicker.svelte';
|
import InertiaPicker from './InertiaPicker.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
import WaterLevel from './WaterLevel.svelte';
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
onopen,
|
onopen,
|
||||||
onsettings,
|
onsettings,
|
||||||
ondetails,
|
ondetails,
|
||||||
|
onanswer,
|
||||||
onsign,
|
onsign,
|
||||||
}: {
|
}: {
|
||||||
doc: DocState;
|
doc: DocState;
|
||||||
@@ -34,6 +39,8 @@
|
|||||||
onopen: (proposalId: string) => void;
|
onopen: (proposalId: string) => void;
|
||||||
onsettings: (proposalId: string) => void;
|
onsettings: (proposalId: string) => void;
|
||||||
ondetails: (proposalId: string) => void;
|
ondetails: (proposalId: string) => void;
|
||||||
|
/** The author of a version held back answers its objection: modify the article repeated, or reword. */
|
||||||
|
onanswer: (objection: Objection) => void;
|
||||||
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
|
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
|
||||||
onsign: (lines: Line[]) => void;
|
onsign: (lines: Line[]) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -42,6 +49,7 @@
|
|||||||
electorate: number;
|
electorate: number;
|
||||||
voteFloor: number;
|
voteFloor: number;
|
||||||
levels: LevelInfo[];
|
levels: LevelInfo[];
|
||||||
|
objections: Objection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
@@ -52,7 +60,9 @@
|
|||||||
const view = $derived(doc.view);
|
const view = $derived(doc.view);
|
||||||
const access = $derived(view?.vote);
|
const access = $derived(view?.vote);
|
||||||
const chain = $derived(access?.way === 'chain');
|
const chain = $derived(access?.way === 'chain');
|
||||||
const votable = $derived(p.status === 'open' || p.status === 'in_force');
|
const live = $derived(p.status === 'open' || p.status === 'in_force');
|
||||||
|
/** The objections holding this version back, and what each one asks of whom. */
|
||||||
|
const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []);
|
||||||
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
|
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
|
||||||
const voted = $derived(p.myVote !== null && !p.myVoteStale);
|
const voted = $derived(p.myVote !== null && !p.myVoteStale);
|
||||||
const ballot = $derived(p.myBallot);
|
const ballot = $derived(p.myBallot);
|
||||||
@@ -66,6 +76,9 @@
|
|||||||
const id = p.id;
|
const id = p.id;
|
||||||
void p.tally.for;
|
void p.tally.for;
|
||||||
void p.tally.against;
|
void p.tally.against;
|
||||||
|
void p.blocked;
|
||||||
|
void p.objections;
|
||||||
|
void p.revision;
|
||||||
api
|
api
|
||||||
.get<Detail>(`/proposals/${id}`)
|
.get<Detail>(`/proposals/${id}`)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
@@ -80,6 +93,22 @@
|
|||||||
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
|
||||||
|
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||||
|
await doc.reload();
|
||||||
|
announce(doc, r.entered ?? []);
|
||||||
|
void session.refreshUnread();
|
||||||
|
} catch (e) {
|
||||||
|
error = errorText(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cast(choice: 'for' | 'against' | null) {
|
async function cast(choice: 'for' | 'against' | null) {
|
||||||
error = '';
|
error = '';
|
||||||
const lines: Line[] = [{ proposalId: p.id, choice }];
|
const lines: Line[] = [{ proposalId: p.id, choice }];
|
||||||
@@ -111,7 +140,38 @@
|
|||||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
{#if p.blocked}
|
||||||
|
<div class="held" role="status">
|
||||||
|
<p class="lead">{t('vote.held')}</p>
|
||||||
|
{#each held as o (o.id)}
|
||||||
|
<div class="objection">
|
||||||
|
<p>
|
||||||
|
<b>{t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })}</b>
|
||||||
|
<span class="faint">· {t('objection.by', { name: o.objector ?? '' })}</span>
|
||||||
|
</p>
|
||||||
|
<p class="reason">{o.reason}</p>
|
||||||
|
{#if o.mine}
|
||||||
|
<p>{o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}</p>
|
||||||
|
{:else if o.phase === 'objector'}
|
||||||
|
<p>{t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}</p>
|
||||||
|
{:else if o.iAnswer}
|
||||||
|
<p>{t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="gestures">
|
||||||
|
{#if o.iAnswer && !o.mine && o.phase === 'author'}
|
||||||
|
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
|
||||||
|
{/if}
|
||||||
|
{#if o.canLift}
|
||||||
|
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
|
||||||
|
{/if}
|
||||||
|
{#if o.canReiterate}
|
||||||
|
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if order}
|
{#if order}
|
||||||
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
||||||
{#each order.ids as id (id)}<li class:shifted={shifted.has(id)}>{nameOf(id)}</li>{/each}
|
{#each order.ids as id (id)}<li class:shifted={shifted.has(id)}>{nameOf(id)}</li>{/each}
|
||||||
@@ -135,10 +195,13 @@
|
|||||||
{:else if ballot?.status === 'expired'}
|
{:else if ballot?.status === 'expired'}
|
||||||
<p class="warn">{t('vote.mine.expired')}</p>
|
<p class="warn">{t('vote.mine.expired')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if p.myVoteChain || ballot}<a class="faint small" href="/bulletins">{t('vote.mine.receipts')}</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !votable}
|
{#if !live}
|
||||||
<p class="muted">{t('vote.closed')}</p>
|
<p class="muted">{t('vote.closed')}</p>
|
||||||
|
{:else if p.blocked}
|
||||||
|
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
|
||||||
{:else if access && !access.open}
|
{:else if access && !access.open}
|
||||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||||
{:else if access}
|
{:else if access}
|
||||||
@@ -271,6 +334,34 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
.held {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
background: var(--ochre-wash);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.objection {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.gestures {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.gestures:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.more {
|
.more {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// A typo fix is a sign or two in a long text: shown around its changes only,
|
||||||
|
// a few words on each side, the rest cut between words.
|
||||||
|
|
||||||
|
import type { DiffPart } from './types';
|
||||||
|
|
||||||
|
function head(text: string, n: number): string {
|
||||||
|
if (text.length <= n) return text;
|
||||||
|
let s = text.slice(0, n);
|
||||||
|
if (!/\s/.test(text[n] ?? ' ') && /\s/.test(s)) s = s.slice(0, s.search(/\s\S*$/));
|
||||||
|
return s.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(text: string, n: number): string {
|
||||||
|
if (text.length <= n) return text;
|
||||||
|
let s = text.slice(-n);
|
||||||
|
if (!/\s/.test(text[text.length - n - 1] ?? ' ') && /\s/.test(s)) s = s.slice(s.search(/\s/) + 1);
|
||||||
|
return s.trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of a diff, the unchanged text kept to `context` signs around each change. */
|
||||||
|
export function condense(parts: DiffPart[], context = 40): DiffPart[] {
|
||||||
|
if (!parts.some((p) => p.op !== 'same')) return parts;
|
||||||
|
const last = parts.length - 1;
|
||||||
|
return parts.map((p, i) => {
|
||||||
|
if (p.op !== 'same') return p;
|
||||||
|
if (i === 0) return p.text.length <= context ? p : { op: 'same', text: `… ${tail(p.text, context)}` };
|
||||||
|
if (i === last) return p.text.length <= context ? p : { op: 'same', text: `${head(p.text, context)} …` };
|
||||||
|
if (p.text.length <= 2 * context + 5) return p;
|
||||||
|
return { op: 'same', text: `${head(p.text, context)} … ${tail(p.text, context)}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,30 @@ export interface PanelState {
|
|||||||
/** The basket, opened on one section. */
|
/** The basket, opened on one section. */
|
||||||
section?: string | null;
|
section?: string | null;
|
||||||
prefill?: { title: string; body: string } | null;
|
prefill?: { title: string; body: string } | null;
|
||||||
|
/** A new proposal that answers this objection (ADR 0010). */
|
||||||
|
objectionId?: string | null;
|
||||||
|
/** The details of one's own version, its edit form open: to reword it. */
|
||||||
|
edit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tabs as the URL names them. */
|
||||||
|
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||||
|
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||||
|
|
||||||
|
/** The query of a reader's URL: the unfolded vote and the panel. */
|
||||||
|
export function panelSearch(panel: PanelState | null, vote: string | null): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (vote) q.set('vote', vote);
|
||||||
|
if (panel) {
|
||||||
|
q.set('onglet', TAB_NAMES[panel.tab]);
|
||||||
|
if (panel.unitId) q.set('article', panel.unitId);
|
||||||
|
if (panel.proposalId) q.set('version', panel.proposalId);
|
||||||
|
if (panel.draftId) q.set('brouillon', panel.draftId);
|
||||||
|
if (panel.section) q.set('section', panel.section);
|
||||||
|
if (panel.objectionId) q.set('objection', panel.objectionId);
|
||||||
|
if (panel.edit) q.set('modifier', '1');
|
||||||
|
}
|
||||||
|
return q.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shown {
|
export interface Shown {
|
||||||
|
|||||||
+143
-19
@@ -11,6 +11,9 @@ export const en: Messages = {
|
|||||||
'nav.mandates': 'Mandates',
|
'nav.mandates': 'Mandates',
|
||||||
'nav.protocols': 'Protocols',
|
'nav.protocols': 'Protocols',
|
||||||
'nav.groups': 'My groups',
|
'nav.groups': 'My groups',
|
||||||
|
'nav.messages': 'Messages',
|
||||||
|
'nav.messages.count.one': 'Messages, {n} unread',
|
||||||
|
'nav.messages.count.other': 'Messages, {n} unread',
|
||||||
'nav.docs.count.one': '{label}, {n} document in progress',
|
'nav.docs.count.one': '{label}, {n} document in progress',
|
||||||
'nav.docs.count.other': '{label}, {n} documents in progress',
|
'nav.docs.count.other': '{label}, {n} documents in progress',
|
||||||
'nav.access': 'Access',
|
'nav.access': 'Access',
|
||||||
@@ -139,7 +142,7 @@ export const en: Messages = {
|
|||||||
'deep.addition': 'new article',
|
'deep.addition': 'new article',
|
||||||
'deep.removal': 'removal of “{title}”',
|
'deep.removal': 'removal of “{title}”',
|
||||||
'deep.inertia': 'inertia to {level}',
|
'deep.inertia': 'inertia to {level}',
|
||||||
'deep.blocked': 'blocked',
|
'deep.held': 'not open to vote: objection',
|
||||||
'deep.mine': 'your proposal',
|
'deep.mine': 'your proposal',
|
||||||
'deep.stale': 'needs reconfirming',
|
'deep.stale': 'needs reconfirming',
|
||||||
'deep.wellrated': 'well-rated',
|
'deep.wellrated': 'well-rated',
|
||||||
@@ -228,7 +231,14 @@ export const en: Messages = {
|
|||||||
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
||||||
'vote.reconfirm': 'Reconfirm: {choice}',
|
'vote.reconfirm': 'Reconfirm: {choice}',
|
||||||
'vote.private': 'Nobody sees your vote: only the totals are visible.',
|
'vote.private': 'Nobody sees your vote: only the totals are visible.',
|
||||||
'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.",
|
'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.',
|
||||||
|
'vote.held.redit': 'It repeats “{other}”.',
|
||||||
|
'vote.held.contredit': 'It contradicts “{other}”.',
|
||||||
|
'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
|
||||||
|
'vote.held.author.contredit': 'For it to be voted on again, reword it. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
|
||||||
|
'vote.held.waiting': 'An answer came: {name} lifts or reiterates the objection by {date}; with no answer, it is lifted.',
|
||||||
|
'vote.held.mine': 'You raised this objection: lift it when the answer suits you.',
|
||||||
|
'vote.held.mine.asked': 'An answer came: lift your objection if it suits you, or reiterate it, by {date}. With no answer from you, it is lifted.',
|
||||||
'vote.entered': '“{title}” has come into force.',
|
'vote.entered': '“{title}” has come into force.',
|
||||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||||
@@ -244,14 +254,15 @@ export const en: Messages = {
|
|||||||
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
||||||
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
||||||
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
||||||
|
'vote.mine.receipts': 'Your ballots and receipts',
|
||||||
'vote.choice.for': 'for',
|
'vote.choice.for': 'for',
|
||||||
'vote.choice.against': 'against',
|
'vote.choice.against': 'against',
|
||||||
'vote.choice.none': 'withdrawing your vote',
|
'vote.choice.none': 'withdrawing your vote',
|
||||||
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
|
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
|
||||||
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
|
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
|
||||||
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
|
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.',
|
||||||
'vote.members': 'Only members of this group vote here.',
|
'vote.members': 'Only members of this group vote here.',
|
||||||
'vote.details': 'Typos, translations, flags, history',
|
'vote.details': 'Typos, translations, objections, history',
|
||||||
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
||||||
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
||||||
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
||||||
@@ -278,6 +289,7 @@ export const en: Messages = {
|
|||||||
'basket.sent.other': '{n} votes saved.',
|
'basket.sent.other': '{n} votes saved.',
|
||||||
'basket.section': 'Vote on this section',
|
'basket.section': 'Vote on this section',
|
||||||
'basket.mine': 'your vote: {choice}',
|
'basket.mine': 'your vote: {choice}',
|
||||||
|
'basket.held': 'not open to vote: an objection holds it back',
|
||||||
'sign.title': 'Vote with your Ğ1 wallet',
|
'sign.title': 'Vote with your Ğ1 wallet',
|
||||||
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||||
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||||
@@ -305,6 +317,38 @@ export const en: Messages = {
|
|||||||
'sign.close': 'Close',
|
'sign.close': 'Close',
|
||||||
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
|
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
|
||||||
'sign.retry': 'Try again',
|
'sign.retry': 'Try again',
|
||||||
|
'sign.error.chain': 'The Ğ1 chain refused the transaction: your vote did not leave. Try again whenever you like.',
|
||||||
|
'sign.receipt': 'Download your receipt',
|
||||||
|
'sign.history': 'Your ballots',
|
||||||
|
'sign.receipt.note': 'Your receipt keeps your ballot and its salt: with it, you check for yourself that the chain carries your vote. Keep it to yourself.',
|
||||||
|
|
||||||
|
'ballots.title': 'Your ballots',
|
||||||
|
'ballots.intro': 'Each vote on the Ğ1 chain leaves in a sealed ballot: the chain shows that you voted, never what you voted. Your receipt keeps the ballot and its salt; with it, you check your vote for yourself. Nobody else receives it.',
|
||||||
|
'ballots.mine': 'Your ballots',
|
||||||
|
'ballots.signin': 'Sign in to find your ballots.',
|
||||||
|
'ballots.none': 'You have not sent any ballot on the Ğ1 chain yet.',
|
||||||
|
'ballots.lines.one': '{n} vote',
|
||||||
|
'ballots.lines.other': '{n} votes',
|
||||||
|
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
||||||
|
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
|
||||||
|
'ballots.status.refused': 'Not counted: {reason}',
|
||||||
|
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
||||||
|
'ballots.block': 'block {n}',
|
||||||
|
'ballots.receipt': 'Download the receipt',
|
||||||
|
'ballots.verify.title': 'Check a receipt',
|
||||||
|
'ballots.verify.intro': 'Choose the file of a receipt, or paste its content: the page recomputes the seal of the ballot right here, without asking the server anything.',
|
||||||
|
'ballots.verify.file': 'Receipt file',
|
||||||
|
'ballots.verify.paste': 'Or paste the receipt',
|
||||||
|
'ballots.verify.go': 'Check',
|
||||||
|
'ballots.verify.ok': 'The receipt holds: its votes and its salt give this very comment.',
|
||||||
|
'ballots.verify.line': 'Version {version}, revision {revision}: {choice}',
|
||||||
|
'ballots.verify.line.label': '{label} — written in the receipt, outside the seal',
|
||||||
|
'ballots.verify.where': 'Look for this comment in the history of your wallet: it is your vote, written on the Ğ1 chain.',
|
||||||
|
'ballots.verify.where.block': 'Look for this comment in the history of your wallet, or on the Ğ1 chain (the receipt says block {block}, outside the seal): if it is there, signed by your account, it is your vote.',
|
||||||
|
'ballots.verify.bad': 'The receipt does not hold: its votes and its salt do not give its comment.',
|
||||||
|
'ballots.verify.computed': 'Recomputed:',
|
||||||
|
'ballots.verify.expected': 'In the receipt:',
|
||||||
|
'ballots.verify.unreadable': 'This is not a readable vote receipt.',
|
||||||
|
|
||||||
'inertia.title': 'Inertia',
|
'inertia.title': 'Inertia',
|
||||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||||
@@ -359,27 +403,98 @@ export const en: Messages = {
|
|||||||
'history.typo': 'typo fixed',
|
'history.typo': 'typo fixed',
|
||||||
'history.substantive': 'substantive change (votes to reconfirm)',
|
'history.substantive': 'substantive change (votes to reconfirm)',
|
||||||
'history.amendment': 'starting text replaced',
|
'history.amendment': 'starting text replaced',
|
||||||
|
'history.revert': 'fix taken back by the author',
|
||||||
'history.by': 'by {name}',
|
'history.by': 'by {name}',
|
||||||
|
'history.typo.validated': 'validated by {name}',
|
||||||
|
'history.typo.deadline': 'applied with no answer within 3 days',
|
||||||
|
|
||||||
'typo.title': 'Fix a typo',
|
'typo.title': 'Fix a typo',
|
||||||
'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.',
|
'typo.note': "Accents, capitals, typos, words run together or split: the fix is not voted on. On your own text, it applies at once. On someone else's, its author validates it (an administrator for a seed text); with no answer within 3 days, it applies. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.",
|
||||||
'typo.save': 'Save the fix',
|
'typo.save': 'Save the fix',
|
||||||
'typo.done': 'Typo fixed, votes untouched.',
|
'typo.done': 'Typo fixed, votes untouched.',
|
||||||
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
|
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
|
||||||
'typo.asProposal': 'Make it a proposal',
|
'typo.asProposal': 'Make it a proposal',
|
||||||
|
'typo.pending.author': 'Your fix waits for the author of the text: it applies when they validate it, or by itself on {date}.',
|
||||||
|
'typo.pending.admin': 'Your fix waits for an administrator, as this text comes from the seed: it applies when they validate it, or by itself on {date}.',
|
||||||
|
'typo.list': 'Suggested typo fixes',
|
||||||
|
'typo.by': 'suggested by {name}',
|
||||||
|
'typo.status.pending': 'waiting: applies by itself on {date}',
|
||||||
|
'typo.status.applied': 'applied',
|
||||||
|
'typo.status.refused': 'refused',
|
||||||
|
'typo.status.stale': 'out of date: the text changed meanwhile',
|
||||||
|
'typo.settled.by': 'by {name}',
|
||||||
|
'typo.settled.deadline': 'with no answer within 3 days',
|
||||||
|
'typo.validate': 'Validate',
|
||||||
|
'typo.refuse': 'Refuse',
|
||||||
|
'typo.undo': 'Go back to the text before',
|
||||||
|
'typo.redo': 'Apply after all',
|
||||||
|
'typo.validated': 'Fix applied.',
|
||||||
|
'typo.refused': 'Fix refused.',
|
||||||
|
|
||||||
'signal.title': 'Contradictions',
|
'objection.title': 'Objections',
|
||||||
'signal.none': 'No flags.',
|
'objection.none': 'No objection.',
|
||||||
'signal.add': 'Flag a contradiction',
|
'objection.add': 'Raise an objection',
|
||||||
'signal.target': 'Contradicts',
|
'objection.kind': 'Your objection',
|
||||||
'signal.reason': 'How these two texts contradict each other',
|
'objection.kind.redit': 'Repeats',
|
||||||
'signal.send': 'Flag',
|
'objection.kind.contredit': 'Contradicts',
|
||||||
'signal.sent': 'Flag saved.',
|
'objection.kind.redit.hint': 'this text repeats another article',
|
||||||
'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.",
|
'objection.kind.contredit.hint': 'this text says the opposite of another article',
|
||||||
'signal.lift': 'Lift',
|
'objection.target': 'Which article?',
|
||||||
'signal.lifted': 'lifted',
|
'objection.reason.redit': 'How these two texts say the same thing',
|
||||||
'signal.lifted.revision': 'lapsed: the text changed',
|
'objection.reason.contredit': 'How these two texts contradict each other',
|
||||||
'signal.by': 'by {name}',
|
'objection.send': 'Send the objection',
|
||||||
|
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.',
|
||||||
|
'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.',
|
||||||
|
'objection.says.redit': '“{held}” repeats “{other}”',
|
||||||
|
'objection.says.contredit': '“{held}” contradicts “{other}”',
|
||||||
|
'objection.by': 'objection by {name}',
|
||||||
|
'objection.held.here': 'this version cannot be voted on',
|
||||||
|
'objection.phase.author': "waiting for the author's answer",
|
||||||
|
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
|
||||||
|
'objection.answer.revision': 'answer: the held text was reworded',
|
||||||
|
'objection.answer.other': 'answer: the other text changed',
|
||||||
|
'objection.answer.proposal': 'answer: a linked proposal',
|
||||||
|
'objection.lifted.objector': 'lifted by the person who raised it',
|
||||||
|
'objection.lifted.admin': 'lifted by an administrator',
|
||||||
|
'objection.lifted.deadline': 'lifted: 3 days without follow-up',
|
||||||
|
'objection.lifted.withdrawn': 'lifted: the proposal was withdrawn',
|
||||||
|
'objection.lifted.revision': 'lapsed: the text changed',
|
||||||
|
'objection.lift': 'Lift the objection',
|
||||||
|
'objection.lift.admin': 'Lift the objection (administrator)',
|
||||||
|
'objection.reiterate': 'Reiterate the objection',
|
||||||
|
'objection.done.lifted': 'Objection lifted.',
|
||||||
|
'objection.done.reiterated': 'Objection reiterated: a new answer is awaited.',
|
||||||
|
'objection.respond.redit': 'Change the article it repeats',
|
||||||
|
'objection.respond.contredit': 'Reword',
|
||||||
|
'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.',
|
||||||
|
'messages.title': 'Messages',
|
||||||
|
'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.',
|
||||||
|
'messages.none': 'No messages for now.',
|
||||||
|
'messages.readAll': 'Mark all as read',
|
||||||
|
'messages.read': 'Mark as read',
|
||||||
|
'messages.unread': 'unread',
|
||||||
|
'messages.due': 'deadline: {date}',
|
||||||
|
'messages.open': 'See it in the text',
|
||||||
|
'messages.topic.objection': 'Objection',
|
||||||
|
'messages.topic.typo': 'Typo',
|
||||||
|
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.',
|
||||||
|
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.',
|
||||||
|
'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.',
|
||||||
|
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
|
||||||
|
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
|
||||||
|
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
|
||||||
|
'messages.respond': 'Answer',
|
||||||
|
'messages.outcome.answered': 'answered',
|
||||||
|
'messages.outcome.reiterated': 'objection reiterated',
|
||||||
|
'messages.outcome.lifted': 'objection lifted',
|
||||||
|
'messages.outcome.admin': 'objection lifted by an administrator',
|
||||||
|
'messages.outcome.deadline.objection': 'objection lifted: 3 days without follow-up',
|
||||||
|
'messages.outcome.deadline.typo': 'fix applied: 3 days without an answer',
|
||||||
|
'messages.outcome.withdrawn': 'proposal withdrawn',
|
||||||
|
'messages.outcome.applied': 'fix applied',
|
||||||
|
'messages.outcome.refused': 'fix refused',
|
||||||
|
'messages.outcome.stale': 'fix out of date: the text changed',
|
||||||
|
'messages.outcome.done': 'dealt with',
|
||||||
|
|
||||||
'propose.mode.replace': 'Change this text',
|
'propose.mode.replace': 'Change this text',
|
||||||
'propose.mode.remove': 'Remove this article',
|
'propose.mode.remove': 'Remove this article',
|
||||||
@@ -490,7 +605,7 @@ export const en: Messages = {
|
|||||||
'error.internal': 'Something failed on our side. Try again in a moment.',
|
'error.internal': 'Something failed on our side. Try again in a moment.',
|
||||||
'error.title': 'The title is empty or too long (120 characters max).',
|
'error.title': 'The title is empty or too long (120 characters max).',
|
||||||
'error.body': 'The text is empty or too long (4,000 characters max).',
|
'error.body': 'The text is empty or too long (4,000 characters max).',
|
||||||
'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).',
|
'error.reason': 'Say in a few words what your objection rests on (10 to 600 characters).',
|
||||||
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
|
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
|
||||||
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
|
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
|
||||||
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
|
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
|
||||||
@@ -509,7 +624,7 @@ export const en: Messages = {
|
|||||||
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
|
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
|
||||||
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
|
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
|
||||||
'error.server_vote': 'Here, voting is direct, without a wallet.',
|
'error.server_vote': 'Here, voting is direct, without a wallet.',
|
||||||
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
|
'error.too_many_ballots': 'You already have many ballots waiting, or left on the way within the hour: wait for them to be confirmed, or try again a little later.',
|
||||||
'error.lines': 'This vote no longer holds: reload the page, then start again.',
|
'error.lines': 'This vote no longer holds: reload the page, then start again.',
|
||||||
'error.choice': 'This choice does not exist.',
|
'error.choice': 'This choice does not exist.',
|
||||||
'error.author': 'This account is not a Ğ1 address.',
|
'error.author': 'This account is not a Ğ1 address.',
|
||||||
@@ -523,6 +638,15 @@ export const en: Messages = {
|
|||||||
'error.not_a_title': 'This module is not the title of the document.',
|
'error.not_a_title': 'This module is not the title of the document.',
|
||||||
'error.same_title': 'This is already the title in force.',
|
'error.same_title': 'This is already the title in force.',
|
||||||
'error.section': 'This section does not exist in this document.',
|
'error.section': 'This section does not exist in this document.',
|
||||||
|
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.',
|
||||||
|
'error.kind': 'Say whether this text repeats the other or contradicts it.',
|
||||||
|
'error.other': 'Choose another article, in force or proposed.',
|
||||||
|
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
|
||||||
|
'error.objection_exists': 'You already have an objection standing on these two texts.',
|
||||||
|
'error.nothing_to_reiterate': 'Nothing to reiterate: no answer has come yet.',
|
||||||
|
'error.lifted': 'This objection is already lifted.',
|
||||||
|
'error.settled': 'This fix is already decided.',
|
||||||
|
'error.link': 'This proposal cannot answer this objection: it targets another article, or the objection is lifted.',
|
||||||
'error.retry': 'Try again',
|
'error.retry': 'Try again',
|
||||||
|
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
|
|||||||
+143
-19
@@ -11,6 +11,9 @@ export const es: Messages = {
|
|||||||
'nav.mandates': 'Mandatos',
|
'nav.mandates': 'Mandatos',
|
||||||
'nav.protocols': 'Protocolos',
|
'nav.protocols': 'Protocolos',
|
||||||
'nav.groups': 'Mis grupos',
|
'nav.groups': 'Mis grupos',
|
||||||
|
'nav.messages': 'Mensajes',
|
||||||
|
'nav.messages.count.one': 'Mensajes, {n} sin leer',
|
||||||
|
'nav.messages.count.other': 'Mensajes, {n} sin leer',
|
||||||
'nav.docs.count.one': '{label}, {n} documento en curso',
|
'nav.docs.count.one': '{label}, {n} documento en curso',
|
||||||
'nav.docs.count.other': '{label}, {n} documentos en curso',
|
'nav.docs.count.other': '{label}, {n} documentos en curso',
|
||||||
'nav.access': 'Accesos',
|
'nav.access': 'Accesos',
|
||||||
@@ -139,7 +142,7 @@ export const es: Messages = {
|
|||||||
'deep.addition': 'artículo nuevo',
|
'deep.addition': 'artículo nuevo',
|
||||||
'deep.removal': 'retirada de «{title}»',
|
'deep.removal': 'retirada de «{title}»',
|
||||||
'deep.inertia': 'inercia a {level}',
|
'deep.inertia': 'inercia a {level}',
|
||||||
'deep.blocked': 'bloqueada',
|
'deep.held': 'no votable: objeción',
|
||||||
'deep.mine': 'tu propuesta',
|
'deep.mine': 'tu propuesta',
|
||||||
'deep.stale': 'por reconfirmar',
|
'deep.stale': 'por reconfirmar',
|
||||||
'deep.wellrated': 'bien valorada',
|
'deep.wellrated': 'bien valorada',
|
||||||
@@ -228,7 +231,14 @@ export const es: Messages = {
|
|||||||
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
||||||
'vote.reconfirm': 'Reconfirmar: {choice}',
|
'vote.reconfirm': 'Reconfirmar: {choice}',
|
||||||
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
|
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
|
||||||
'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.',
|
'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.',
|
||||||
|
'vote.held.redit': 'Repite «{other}».',
|
||||||
|
'vote.held.contredit': 'Contradice «{other}».',
|
||||||
|
'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
|
||||||
|
'vote.held.author.contredit': 'Para que vuelva a votarse, reformúlala. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
|
||||||
|
'vote.held.waiting': 'Llegó una respuesta: {name} levanta o reitera la objeción antes del {date}; sin respuesta, se levanta.',
|
||||||
|
'vote.held.mine': 'Hiciste esta objeción: levántala cuando la respuesta te convenga.',
|
||||||
|
'vote.held.mine.asked': 'Llegó una respuesta: levanta tu objeción si te convence, o reitérala, antes del {date}. Sin respuesta tuya, se levanta.',
|
||||||
'vote.entered': '«{title}» ha entrado en vigor.',
|
'vote.entered': '«{title}» ha entrado en vigor.',
|
||||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||||
@@ -244,14 +254,15 @@ export const es: Messages = {
|
|||||||
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
||||||
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
||||||
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
||||||
|
'vote.mine.receipts': 'Tus papeletas y recibos',
|
||||||
'vote.choice.for': 'a favor',
|
'vote.choice.for': 'a favor',
|
||||||
'vote.choice.against': 'en contra',
|
'vote.choice.against': 'en contra',
|
||||||
'vote.choice.none': 'retirar tu voto',
|
'vote.choice.none': 'retirar tu voto',
|
||||||
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
|
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
|
||||||
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
|
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
|
||||||
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
|
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.',
|
||||||
'vote.members': 'Aquí solo votan los miembros de este grupo.',
|
'vote.members': 'Aquí solo votan los miembros de este grupo.',
|
||||||
'vote.details': 'Erratas, traducciones, señalamientos, historial',
|
'vote.details': 'Erratas, traducciones, objeciones, historial',
|
||||||
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
||||||
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
||||||
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
||||||
@@ -278,6 +289,7 @@ export const es: Messages = {
|
|||||||
'basket.sent.other': '{n} votos registrados.',
|
'basket.sent.other': '{n} votos registrados.',
|
||||||
'basket.section': 'Votar esta sección',
|
'basket.section': 'Votar esta sección',
|
||||||
'basket.mine': 'tu voto: {choice}',
|
'basket.mine': 'tu voto: {choice}',
|
||||||
|
'basket.held': 'no votable: una objeción la retiene',
|
||||||
'sign.title': 'Votar con tu monedero Ğ1',
|
'sign.title': 'Votar con tu monedero Ğ1',
|
||||||
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||||
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||||
@@ -305,6 +317,38 @@ export const es: Messages = {
|
|||||||
'sign.close': 'Cerrar',
|
'sign.close': 'Cerrar',
|
||||||
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
|
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
|
||||||
'sign.retry': 'Reintentar',
|
'sign.retry': 'Reintentar',
|
||||||
|
'sign.error.chain': 'La cadena Ğ1 rechazó la transacción: tu voto no salió. Vuelve a intentarlo cuando quieras.',
|
||||||
|
'sign.receipt': 'Descargar tu recibo',
|
||||||
|
'sign.history': 'Tus papeletas',
|
||||||
|
'sign.receipt.note': 'Tu recibo guarda tu papeleta y su sal: con él, compruebas por tu cuenta que la cadena lleva tu voto. Guárdalo para ti.',
|
||||||
|
|
||||||
|
'ballots.title': 'Tus papeletas de voto',
|
||||||
|
'ballots.intro': 'Cada voto por la cadena Ğ1 sale en una papeleta sellada: la cadena muestra que votaste, nunca lo que votaste. Tu recibo guarda la papeleta y su sal; con él, compruebas tu voto por tu cuenta. Nadie más lo recibe.',
|
||||||
|
'ballots.mine': 'Tus papeletas',
|
||||||
|
'ballots.signin': 'Conéctate para encontrar tus papeletas.',
|
||||||
|
'ballots.none': 'Aún no has enviado ninguna papeleta por la cadena Ğ1.',
|
||||||
|
'ballots.lines.one': '{n} voto',
|
||||||
|
'ballots.lines.other': '{n} votos',
|
||||||
|
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
||||||
|
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
|
||||||
|
'ballots.status.refused': 'No contado: {reason}',
|
||||||
|
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
||||||
|
'ballots.block': 'bloque {n}',
|
||||||
|
'ballots.receipt': 'Descargar el recibo',
|
||||||
|
'ballots.verify.title': 'Comprobar un recibo',
|
||||||
|
'ballots.verify.intro': 'Elige el archivo de un recibo, o pega su contenido: la página recalcula el sello de la papeleta aquí mismo, sin pedir nada al servidor.',
|
||||||
|
'ballots.verify.file': 'Archivo del recibo',
|
||||||
|
'ballots.verify.paste': 'O pega el recibo',
|
||||||
|
'ballots.verify.go': 'Comprobar',
|
||||||
|
'ballots.verify.ok': 'El recibo es válido: sus votos y su sal dan exactamente este comentario.',
|
||||||
|
'ballots.verify.line': 'Versión {version}, revisión {revision}: {choice}',
|
||||||
|
'ballots.verify.line.label': '{label} — anotado en el recibo, fuera del sello',
|
||||||
|
'ballots.verify.where': 'Busca este comentario en el historial de tu monedero: es tu voto, inscrito en la cadena Ğ1.',
|
||||||
|
'ballots.verify.where.block': 'Busca este comentario en el historial de tu monedero, o en la cadena Ğ1 (el recibo indica el bloque {block}, fuera del sello): si figura allí, firmado por tu cuenta, es tu voto.',
|
||||||
|
'ballots.verify.bad': 'El recibo no es válido: sus votos y su sal no dan su comentario.',
|
||||||
|
'ballots.verify.computed': 'Recalculado:',
|
||||||
|
'ballots.verify.expected': 'En el recibo:',
|
||||||
|
'ballots.verify.unreadable': 'No es un recibo de voto legible.',
|
||||||
|
|
||||||
'inertia.title': 'Inercia',
|
'inertia.title': 'Inercia',
|
||||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
||||||
@@ -359,27 +403,98 @@ export const es: Messages = {
|
|||||||
'history.typo': 'errata corregida',
|
'history.typo': 'errata corregida',
|
||||||
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
||||||
'history.amendment': 'texto de partida sustituido',
|
'history.amendment': 'texto de partida sustituido',
|
||||||
|
'history.revert': 'corrección retirada por el autor',
|
||||||
'history.by': 'por {name}',
|
'history.by': 'por {name}',
|
||||||
|
'history.typo.validated': 'validada por {name}',
|
||||||
|
'history.typo.deadline': 'aplicada sin respuesta en 3 días',
|
||||||
|
|
||||||
'typo.title': 'Corregir una errata',
|
'typo.title': 'Corregir una errata',
|
||||||
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
|
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección no se vota. En tu texto, se aplica enseguida. En el de otra persona, su autor la valida (un administrador para un texto del seed); sin respuesta en 3 días, se aplica. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
|
||||||
'typo.save': 'Guardar la corrección',
|
'typo.save': 'Guardar la corrección',
|
||||||
'typo.done': 'Errata corregida, sin tocar los votos.',
|
'typo.done': 'Errata corregida, sin tocar los votos.',
|
||||||
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
|
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||||
'typo.asProposal': 'Convertirlo en propuesta',
|
'typo.asProposal': 'Convertirlo en propuesta',
|
||||||
|
'typo.pending.author': 'Tu corrección espera al autor del texto: se aplica cuando la valida, o sola el {date}.',
|
||||||
|
'typo.pending.admin': 'Tu corrección espera a un administrador, porque este texto viene del seed: se aplica cuando la valida, o sola el {date}.',
|
||||||
|
'typo.list': 'Erratas propuestas',
|
||||||
|
'typo.by': 'propuesta por {name}',
|
||||||
|
'typo.status.pending': 'en espera: se aplica sola el {date}',
|
||||||
|
'typo.status.applied': 'aplicada',
|
||||||
|
'typo.status.refused': 'rechazada',
|
||||||
|
'typo.status.stale': 'caducada: el texto cambió entretanto',
|
||||||
|
'typo.settled.by': 'por {name}',
|
||||||
|
'typo.settled.deadline': 'sin respuesta en 3 días',
|
||||||
|
'typo.validate': 'Validar',
|
||||||
|
'typo.refuse': 'Rechazar',
|
||||||
|
'typo.undo': 'Volver al texto anterior',
|
||||||
|
'typo.redo': 'Aplicar al final',
|
||||||
|
'typo.validated': 'Corrección aplicada.',
|
||||||
|
'typo.refused': 'Corrección rechazada.',
|
||||||
|
|
||||||
'signal.title': 'Contradicciones',
|
'objection.title': 'Objeciones',
|
||||||
'signal.none': 'Ningún aviso.',
|
'objection.none': 'Ninguna objeción.',
|
||||||
'signal.add': 'Señalar una contradicción',
|
'objection.add': 'Hacer una objeción',
|
||||||
'signal.target': 'Contradice',
|
'objection.kind': 'Tu objeción',
|
||||||
'signal.reason': 'En qué se contradicen estos dos textos',
|
'objection.kind.redit': 'Repite',
|
||||||
'signal.send': 'Señalar',
|
'objection.kind.contredit': 'Contradice',
|
||||||
'signal.sent': 'Aviso registrado.',
|
'objection.kind.redit.hint': 'este texto repite otro artículo',
|
||||||
'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.',
|
'objection.kind.contredit.hint': 'este texto dice lo contrario de otro artículo',
|
||||||
'signal.lift': 'Levantar',
|
'objection.target': '¿Qué artículo?',
|
||||||
'signal.lifted': 'levantado',
|
'objection.reason.redit': 'En qué dicen lo mismo estos dos textos',
|
||||||
'signal.lifted.revision': 'sin efecto: el texto ha cambiado',
|
'objection.reason.contredit': 'En qué se contradicen estos dos textos',
|
||||||
'signal.by': 'por {name}',
|
'objection.send': 'Enviar la objeción',
|
||||||
|
'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.',
|
||||||
|
'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.',
|
||||||
|
'objection.says.redit': '«{held}» repite «{other}»',
|
||||||
|
'objection.says.contredit': '«{held}» contradice «{other}»',
|
||||||
|
'objection.by': 'objeción de {name}',
|
||||||
|
'objection.held.here': 'esta versión no se vota',
|
||||||
|
'objection.phase.author': 'en espera de la respuesta del autor',
|
||||||
|
'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
|
||||||
|
'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
|
||||||
|
'objection.answer.other': 'respuesta: el otro texto cambió',
|
||||||
|
'objection.answer.proposal': 'respuesta: una propuesta vinculada',
|
||||||
|
'objection.lifted.objector': 'levantada por la persona que la hizo',
|
||||||
|
'objection.lifted.admin': 'levantada por un administrador',
|
||||||
|
'objection.lifted.deadline': 'levantada: 3 días sin continuación',
|
||||||
|
'objection.lifted.withdrawn': 'levantada: la propuesta se retiró',
|
||||||
|
'objection.lifted.revision': 'caída: el texto cambió',
|
||||||
|
'objection.lift': 'Levantar la objeción',
|
||||||
|
'objection.lift.admin': 'Levantar la objeción (administrador)',
|
||||||
|
'objection.reiterate': 'Reiterar la objeción',
|
||||||
|
'objection.done.lifted': 'Objeción levantada.',
|
||||||
|
'objection.done.reiterated': 'Objeción reiterada: se espera una nueva respuesta.',
|
||||||
|
'objection.respond.redit': 'Modificar el artículo que repite',
|
||||||
|
'objection.respond.contredit': 'Reformular',
|
||||||
|
'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.',
|
||||||
|
'messages.title': 'Mensajes',
|
||||||
|
'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.',
|
||||||
|
'messages.none': 'Ningún mensaje por ahora.',
|
||||||
|
'messages.readAll': 'Marcar todo como leído',
|
||||||
|
'messages.read': 'Marcar como leído',
|
||||||
|
'messages.unread': 'sin leer',
|
||||||
|
'messages.due': 'plazo: {date}',
|
||||||
|
'messages.open': 'Verlo en el texto',
|
||||||
|
'messages.topic.objection': 'Objeción',
|
||||||
|
'messages.topic.typo': 'Errata',
|
||||||
|
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.',
|
||||||
|
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.',
|
||||||
|
'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.',
|
||||||
|
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
|
||||||
|
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
|
||||||
|
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
|
||||||
|
'messages.respond': 'Responder',
|
||||||
|
'messages.outcome.answered': 'respuesta dada',
|
||||||
|
'messages.outcome.reiterated': 'objeción reiterada',
|
||||||
|
'messages.outcome.lifted': 'objeción levantada',
|
||||||
|
'messages.outcome.admin': 'objeción levantada por un administrador',
|
||||||
|
'messages.outcome.deadline.objection': 'objeción levantada: 3 días sin continuación',
|
||||||
|
'messages.outcome.deadline.typo': 'corrección aplicada: 3 días sin respuesta',
|
||||||
|
'messages.outcome.withdrawn': 'propuesta retirada',
|
||||||
|
'messages.outcome.applied': 'corrección aplicada',
|
||||||
|
'messages.outcome.refused': 'corrección rechazada',
|
||||||
|
'messages.outcome.stale': 'corrección caducada: el texto cambió',
|
||||||
|
'messages.outcome.done': 'tratado',
|
||||||
|
|
||||||
'propose.mode.replace': 'Modificar este texto',
|
'propose.mode.replace': 'Modificar este texto',
|
||||||
'propose.mode.remove': 'Retirar este artículo',
|
'propose.mode.remove': 'Retirar este artículo',
|
||||||
@@ -490,7 +605,7 @@ export const es: Messages = {
|
|||||||
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
|
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
|
||||||
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
|
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
|
||||||
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
|
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
|
||||||
'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).',
|
'error.reason': 'Di en pocas palabras en qué se basa tu objeción (de 10 a 600 caracteres).',
|
||||||
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
|
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
|
||||||
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
|
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||||
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
|
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
|
||||||
@@ -509,7 +624,7 @@ export const es: Messages = {
|
|||||||
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
|
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
|
||||||
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
|
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
|
||||||
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
|
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
|
||||||
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
|
'error.too_many_ballots': 'Ya tienes muchas papeletas esperando, o abandonadas en la última hora: espera a que se confirmen, o vuelve a intentarlo un poco más tarde.',
|
||||||
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
|
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
|
||||||
'error.choice': 'Esta elección no existe.',
|
'error.choice': 'Esta elección no existe.',
|
||||||
'error.author': 'Esta cuenta no es una dirección Ğ1.',
|
'error.author': 'Esta cuenta no es una dirección Ğ1.',
|
||||||
@@ -523,6 +638,15 @@ export const es: Messages = {
|
|||||||
'error.not_a_title': 'Este módulo no es el título del documento.',
|
'error.not_a_title': 'Este módulo no es el título del documento.',
|
||||||
'error.same_title': 'Ya es el título en vigor.',
|
'error.same_title': 'Ya es el título en vigor.',
|
||||||
'error.section': 'Esta sección no existe en este documento.',
|
'error.section': 'Esta sección no existe en este documento.',
|
||||||
|
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.',
|
||||||
|
'error.kind': 'Di si este texto repite el otro o lo contradice.',
|
||||||
|
'error.other': 'Elige otro artículo, en vigor o propuesto.',
|
||||||
|
'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.',
|
||||||
|
'error.objection_exists': 'Ya tienes una objeción en curso sobre estos dos textos.',
|
||||||
|
'error.nothing_to_reiterate': 'Nada que reiterar: aún no ha llegado ninguna respuesta.',
|
||||||
|
'error.lifted': 'Esta objeción ya está levantada.',
|
||||||
|
'error.settled': 'Esta corrección ya está decidida.',
|
||||||
|
'error.link': 'Esta propuesta no puede responder a esta objeción: apunta a otro artículo, o la objeción está levantada.',
|
||||||
'error.retry': 'Reintentar',
|
'error.retry': 'Reintentar',
|
||||||
|
|
||||||
'common.cancel': 'Cancelar',
|
'common.cancel': 'Cancelar',
|
||||||
|
|||||||
+143
-19
@@ -14,6 +14,9 @@ export const fr = {
|
|||||||
'nav.mandates': 'Mandats',
|
'nav.mandates': 'Mandats',
|
||||||
'nav.protocols': 'Protocoles',
|
'nav.protocols': 'Protocoles',
|
||||||
'nav.groups': 'Mes groupes',
|
'nav.groups': 'Mes groupes',
|
||||||
|
'nav.messages': 'Messages',
|
||||||
|
'nav.messages.count.one': 'Messages, {n} non lu',
|
||||||
|
'nav.messages.count.other': 'Messages, {n} non lus',
|
||||||
'nav.docs.count.one': '{label}, {n} doc en cours',
|
'nav.docs.count.one': '{label}, {n} doc en cours',
|
||||||
'nav.docs.count.other': '{label}, {n} docs en cours',
|
'nav.docs.count.other': '{label}, {n} docs en cours',
|
||||||
'nav.access': 'Accès',
|
'nav.access': 'Accès',
|
||||||
@@ -142,7 +145,7 @@ export const fr = {
|
|||||||
'deep.addition': 'nouvel article',
|
'deep.addition': 'nouvel article',
|
||||||
'deep.removal': 'retrait de « {title} »',
|
'deep.removal': 'retrait de « {title} »',
|
||||||
'deep.inertia': 'inertie vers {level}',
|
'deep.inertia': 'inertie vers {level}',
|
||||||
'deep.blocked': 'bloquée',
|
'deep.held': 'non votable : objection',
|
||||||
'deep.mine': 'ta proposition',
|
'deep.mine': 'ta proposition',
|
||||||
'deep.stale': 'à reconfirmer',
|
'deep.stale': 'à reconfirmer',
|
||||||
'deep.wellrated': 'bien cotée',
|
'deep.wellrated': 'bien cotée',
|
||||||
@@ -231,7 +234,14 @@ export const fr = {
|
|||||||
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
|
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
|
||||||
'vote.reconfirm': 'Reconfirmer : {choice}',
|
'vote.reconfirm': 'Reconfirmer : {choice}',
|
||||||
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
|
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
|
||||||
'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.",
|
'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.",
|
||||||
|
'vote.held.redit': 'Elle redit « {other} ».',
|
||||||
|
'vote.held.contredit': 'Elle contredit « {other} ».',
|
||||||
|
'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
|
||||||
|
'vote.held.author.contredit': "Pour qu'elle se vote à nouveau, reformule-la. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
|
||||||
|
'vote.held.waiting': "Une réponse est arrivée : {name} lève ou réitère l'objection d'ici le {date} ; sans réponse, elle est levée.",
|
||||||
|
'vote.held.mine': 'Tu as fait cette objection : lève-la quand la réponse te convient.',
|
||||||
|
'vote.held.mine.asked': "Une réponse est arrivée : lève ton objection si elle te convient, ou réitère-la, d'ici le {date}. Sans réponse de ta part, elle est levée.",
|
||||||
'vote.entered': '« {title} » est entré en vigueur.',
|
'vote.entered': '« {title} » est entré en vigueur.',
|
||||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||||
@@ -247,14 +257,15 @@ export const fr = {
|
|||||||
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
||||||
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
||||||
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
||||||
|
'vote.mine.receipts': 'Tes bulletins et reçus',
|
||||||
'vote.choice.for': 'pour',
|
'vote.choice.for': 'pour',
|
||||||
'vote.choice.against': 'contre',
|
'vote.choice.against': 'contre',
|
||||||
'vote.choice.none': 'retrait de ton vote',
|
'vote.choice.none': 'retrait de ton vote',
|
||||||
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
|
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
|
||||||
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
|
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
|
||||||
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
|
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection.",
|
||||||
'vote.members': 'Seuls les membres de ce groupe votent ici.',
|
'vote.members': 'Seuls les membres de ce groupe votent ici.',
|
||||||
'vote.details': 'Coquilles, traductions, signalements, historique',
|
'vote.details': 'Coquilles, traductions, objections, historique',
|
||||||
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
||||||
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
||||||
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
||||||
@@ -281,6 +292,7 @@ export const fr = {
|
|||||||
'basket.sent.other': '{n} votes enregistrés.',
|
'basket.sent.other': '{n} votes enregistrés.',
|
||||||
'basket.section': 'Voter cette section',
|
'basket.section': 'Voter cette section',
|
||||||
'basket.mine': 'ton vote : {choice}',
|
'basket.mine': 'ton vote : {choice}',
|
||||||
|
'basket.held': 'non votable : une objection la retient',
|
||||||
'sign.title': 'Voter avec ton portefeuille Ğ1',
|
'sign.title': 'Voter avec ton portefeuille Ğ1',
|
||||||
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||||
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||||
@@ -308,6 +320,38 @@ export const fr = {
|
|||||||
'sign.close': 'Fermer',
|
'sign.close': 'Fermer',
|
||||||
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
|
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
|
||||||
'sign.retry': 'Réessayer',
|
'sign.retry': 'Réessayer',
|
||||||
|
'sign.error.chain': "La chaîne Ğ1 a refusé la transaction : ton vote n'est pas parti. Réessaie quand tu veux.",
|
||||||
|
'sign.receipt': 'Télécharger ton reçu',
|
||||||
|
'sign.history': 'Tes bulletins',
|
||||||
|
'sign.receipt.note': 'Ton reçu garde ton bulletin et son sel : avec lui, tu vérifies toi-même que la chaîne porte ton vote. Garde-le pour toi.',
|
||||||
|
|
||||||
|
'ballots.title': 'Tes bulletins de vote',
|
||||||
|
'ballots.intro': "Chaque vote par la chaîne Ğ1 part dans un bulletin scellé : la chaîne montre que tu as voté, jamais ce que tu as voté. Ton reçu garde le bulletin et son sel ; avec lui, tu vérifies toi-même ton vote. Personne d'autre ne le reçoit.",
|
||||||
|
'ballots.mine': 'Tes bulletins',
|
||||||
|
'ballots.signin': 'Connecte-toi pour retrouver tes bulletins.',
|
||||||
|
'ballots.none': "Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.",
|
||||||
|
'ballots.lines.one': '{n} vote',
|
||||||
|
'ballots.lines.other': '{n} votes',
|
||||||
|
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
||||||
|
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
|
||||||
|
'ballots.status.refused': 'Pas compté : {reason}',
|
||||||
|
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
||||||
|
'ballots.block': 'bloc {n}',
|
||||||
|
'ballots.receipt': 'Télécharger le reçu',
|
||||||
|
'ballots.verify.title': 'Vérifier un reçu',
|
||||||
|
'ballots.verify.intro': "Choisis le fichier d'un reçu, ou colle son contenu : la page recalcule l'empreinte du bulletin ici même, sans rien demander au serveur.",
|
||||||
|
'ballots.verify.file': 'Fichier du reçu',
|
||||||
|
'ballots.verify.paste': 'Ou colle le reçu',
|
||||||
|
'ballots.verify.go': 'Vérifier',
|
||||||
|
'ballots.verify.ok': 'Le reçu tient : ses votes et son sel donnent bien ce commentaire.',
|
||||||
|
'ballots.verify.line': 'Version {version}, révision {revision} : {choice}',
|
||||||
|
'ballots.verify.line.label': '{label} — noté dans le reçu, hors du sceau',
|
||||||
|
'ballots.verify.where': "Cherche ce commentaire dans l'historique de ton portefeuille : c'est ton vote, inscrit sur la chaîne Ğ1.",
|
||||||
|
'ballots.verify.where.block': "Cherche ce commentaire dans l'historique de ton portefeuille, ou sur la chaîne Ğ1 (le reçu indique le bloc {block}, hors du sceau) : s'il y figure, signé de ton compte, c'est ton vote.",
|
||||||
|
'ballots.verify.bad': 'Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.',
|
||||||
|
'ballots.verify.computed': 'Recalculé :',
|
||||||
|
'ballots.verify.expected': 'Dans le reçu :',
|
||||||
|
'ballots.verify.unreadable': "Ce n'est pas un reçu de vote lisible.",
|
||||||
|
|
||||||
'inertia.title': 'Inertie',
|
'inertia.title': 'Inertie',
|
||||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||||
@@ -362,27 +406,98 @@ export const fr = {
|
|||||||
'history.typo': 'coquille corrigée',
|
'history.typo': 'coquille corrigée',
|
||||||
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
||||||
'history.amendment': 'texte de départ remplacé',
|
'history.amendment': 'texte de départ remplacé',
|
||||||
|
'history.revert': "correction retirée par l'auteur",
|
||||||
'history.by': 'par {name}',
|
'history.by': 'par {name}',
|
||||||
|
'history.typo.validated': 'validée par {name}',
|
||||||
|
'history.typo.deadline': 'appliquée sans réponse sous 3 jours',
|
||||||
|
|
||||||
'typo.title': 'Corriger une coquille',
|
'typo.title': 'Corriger une coquille',
|
||||||
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
|
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction ne se vote pas. Sur ton texte, elle s'applique tout de suite. Sur celui d'une autre personne, son auteur la valide (un administrateur pour un texte du seed) ; sans réponse sous 3 jours, elle s'applique. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
|
||||||
'typo.save': 'Enregistrer la correction',
|
'typo.save': 'Enregistrer la correction',
|
||||||
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
|
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
|
||||||
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
|
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
|
||||||
'typo.asProposal': 'En faire une proposition',
|
'typo.asProposal': 'En faire une proposition',
|
||||||
|
'typo.pending.author': "Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le {date}.",
|
||||||
|
'typo.pending.admin': "Ta correction attend un administrateur, ce texte venant du seed : elle s'applique quand il la valide, ou d'elle-même le {date}.",
|
||||||
|
'typo.list': 'Coquilles proposées',
|
||||||
|
'typo.by': 'proposée par {name}',
|
||||||
|
'typo.status.pending': "en attente : s'applique d'elle-même le {date}",
|
||||||
|
'typo.status.applied': 'appliquée',
|
||||||
|
'typo.status.refused': 'refusée',
|
||||||
|
'typo.status.stale': 'périmée : le texte a changé entre-temps',
|
||||||
|
'typo.settled.by': 'par {name}',
|
||||||
|
'typo.settled.deadline': 'sans réponse sous 3 jours',
|
||||||
|
'typo.validate': 'Valider',
|
||||||
|
'typo.refuse': 'Refuser',
|
||||||
|
'typo.undo': "Revenir au texte d'avant",
|
||||||
|
'typo.redo': 'Appliquer finalement',
|
||||||
|
'typo.validated': 'Correction appliquée.',
|
||||||
|
'typo.refused': 'Correction refusée.',
|
||||||
|
|
||||||
'signal.title': 'Contradictions',
|
'objection.title': 'Objections',
|
||||||
'signal.none': 'Aucun signalement.',
|
'objection.none': 'Aucune objection.',
|
||||||
'signal.add': 'Signaler une contradiction',
|
'objection.add': 'Faire une objection',
|
||||||
'signal.target': 'Contredit',
|
'objection.kind': 'Ton objection',
|
||||||
'signal.reason': 'En quoi ces deux textes se contredisent',
|
'objection.kind.redit': 'Redit',
|
||||||
'signal.send': 'Signaler',
|
'objection.kind.contredit': 'Contredit',
|
||||||
'signal.sent': 'Signalement enregistré.',
|
'objection.kind.redit.hint': 'ce texte répète un autre article',
|
||||||
'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.",
|
'objection.kind.contredit.hint': "ce texte dit le contraire d'un autre article",
|
||||||
'signal.lift': 'Lever',
|
'objection.target': 'Quel article ?',
|
||||||
'signal.lifted': 'levé',
|
'objection.reason.redit': 'En quoi ces deux textes disent la même chose',
|
||||||
'signal.lifted.revision': 'tombé : le texte a changé',
|
'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
|
||||||
'signal.by': 'par {name}',
|
'objection.send': "Envoyer l'objection",
|
||||||
|
'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.",
|
||||||
|
'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.",
|
||||||
|
'objection.says.redit': '« {held} » redit « {other} »',
|
||||||
|
'objection.says.contredit': '« {held} » contredit « {other} »',
|
||||||
|
'objection.by': 'objection de {name}',
|
||||||
|
'objection.held.here': 'cette version ne se vote plus',
|
||||||
|
'objection.phase.author': "en attente d'une réponse de l'auteur",
|
||||||
|
'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même",
|
||||||
|
'objection.answer.revision': 'réponse : le texte retenu a été reformulé',
|
||||||
|
'objection.answer.other': "réponse : l'autre texte a changé",
|
||||||
|
'objection.answer.proposal': 'réponse : une proposition liée',
|
||||||
|
'objection.lifted.objector': "levée par la personne qui l'a faite",
|
||||||
|
'objection.lifted.admin': 'levée par un administrateur',
|
||||||
|
'objection.lifted.deadline': 'levée : 3 jours sans suite',
|
||||||
|
'objection.lifted.withdrawn': 'levée : la proposition a été retirée',
|
||||||
|
'objection.lifted.revision': 'tombée : le texte a changé',
|
||||||
|
'objection.lift': "Lever l'objection",
|
||||||
|
'objection.lift.admin': "Lever l'objection (administrateur)",
|
||||||
|
'objection.reiterate': "Réitérer l'objection",
|
||||||
|
'objection.done.lifted': 'Objection levée.',
|
||||||
|
'objection.done.reiterated': 'Objection réitérée : une nouvelle réponse est attendue.',
|
||||||
|
'objection.respond.redit': "Modifier l'article visé",
|
||||||
|
'objection.respond.contredit': 'Reformuler',
|
||||||
|
'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.",
|
||||||
|
'messages.title': 'Messages',
|
||||||
|
'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.',
|
||||||
|
'messages.none': "Aucun message pour l'instant.",
|
||||||
|
'messages.readAll': 'Tout marquer comme lu',
|
||||||
|
'messages.read': 'Marquer comme lu',
|
||||||
|
'messages.unread': 'non lu',
|
||||||
|
'messages.due': 'échéance : {date}',
|
||||||
|
'messages.open': 'Voir dans le texte',
|
||||||
|
'messages.topic.objection': 'Objection',
|
||||||
|
'messages.topic.typo': 'Coquille',
|
||||||
|
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.",
|
||||||
|
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.",
|
||||||
|
'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.',
|
||||||
|
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
|
||||||
|
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
|
||||||
|
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
|
||||||
|
'messages.respond': 'Répondre',
|
||||||
|
'messages.outcome.answered': 'réponse donnée',
|
||||||
|
'messages.outcome.reiterated': 'objection réitérée',
|
||||||
|
'messages.outcome.lifted': 'objection levée',
|
||||||
|
'messages.outcome.admin': 'objection levée par un administrateur',
|
||||||
|
'messages.outcome.deadline.objection': 'objection levée : 3 jours sans suite',
|
||||||
|
'messages.outcome.deadline.typo': 'correction appliquée : 3 jours sans réponse',
|
||||||
|
'messages.outcome.withdrawn': 'proposition retirée',
|
||||||
|
'messages.outcome.applied': 'correction appliquée',
|
||||||
|
'messages.outcome.refused': 'correction refusée',
|
||||||
|
'messages.outcome.stale': 'correction périmée : le texte a changé',
|
||||||
|
'messages.outcome.done': 'traité',
|
||||||
|
|
||||||
'propose.mode.replace': 'Modifier ce texte',
|
'propose.mode.replace': 'Modifier ce texte',
|
||||||
'propose.mode.remove': 'Retirer cet article',
|
'propose.mode.remove': 'Retirer cet article',
|
||||||
@@ -493,7 +608,7 @@ export const fr = {
|
|||||||
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
|
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
|
||||||
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
|
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
|
||||||
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
|
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
|
||||||
'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).',
|
'error.reason': 'Dis en quelques mots ce qui fonde ton objection (10 à 600 signes).',
|
||||||
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
|
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
|
||||||
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
|
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
|
||||||
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
|
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
|
||||||
@@ -512,7 +627,7 @@ export const fr = {
|
|||||||
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
|
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
|
||||||
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
|
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
|
||||||
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
|
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
|
||||||
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
|
'error.too_many_ballots': 'Tu as déjà beaucoup de bulletins en attente, ou laissés en route depuis une heure : attends leur confirmation, ou réessaie un peu plus tard.',
|
||||||
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
|
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
|
||||||
'error.choice': "Ce choix n'existe pas.",
|
'error.choice': "Ce choix n'existe pas.",
|
||||||
'error.author': "Ce compte n'est pas une adresse Ğ1.",
|
'error.author': "Ce compte n'est pas une adresse Ğ1.",
|
||||||
@@ -526,6 +641,15 @@ export const fr = {
|
|||||||
'error.not_a_title': "Ce module n'est pas le titre du document.",
|
'error.not_a_title': "Ce module n'est pas le titre du document.",
|
||||||
'error.same_title': "C'est déjà le titre en vigueur.",
|
'error.same_title': "C'est déjà le titre en vigueur.",
|
||||||
'error.section': "Cette section n'existe pas dans ce document.",
|
'error.section': "Cette section n'existe pas dans ce document.",
|
||||||
|
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.",
|
||||||
|
'error.kind': "Dis si ce texte redit l'autre ou le contredit.",
|
||||||
|
'error.other': 'Choisis un autre article, en vigueur ou proposé.',
|
||||||
|
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
|
||||||
|
'error.objection_exists': 'Tu as déjà une objection en cours sur ces deux textes.',
|
||||||
|
'error.nothing_to_reiterate': "Rien à réitérer : aucune réponse n'est encore arrivée.",
|
||||||
|
'error.lifted': 'Cette objection est déjà levée.',
|
||||||
|
'error.settled': 'Cette correction est déjà tranchée.',
|
||||||
|
'error.link': 'Cette proposition ne peut pas répondre à cette objection : elle vise un autre article, ou l’objection est levée.',
|
||||||
'error.retry': 'Réessayer',
|
'error.retry': 'Réessayer',
|
||||||
|
|
||||||
'common.cancel': 'Annuler',
|
'common.cancel': 'Annuler',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Answering an objection (Yvv, 2026-10-30: « Pour ce qui constitue une
|
||||||
|
// répétition → modifier l'article cible ; pour une contradiction →
|
||||||
|
// reformuler »): where the author of the version held back is taken.
|
||||||
|
|
||||||
|
import type { PanelState } from './doc.svelte';
|
||||||
|
import type { Objection } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repetition: a proposal that changes the article repeated, linked to the
|
||||||
|
* objection, starting from its text. A contradiction: one's own open version is
|
||||||
|
* reworded in place; a seeded one, by a linked proposal on its article.
|
||||||
|
*/
|
||||||
|
export function answerPanel(o: Objection, me: string | null): PanelState | null {
|
||||||
|
const held = o.heldVersion;
|
||||||
|
if (!held) return null;
|
||||||
|
if (o.kind === 'redit') {
|
||||||
|
return { tab: 'propose', unitId: o.other.unitId, proposalId: o.other.id, objectionId: o.id };
|
||||||
|
}
|
||||||
|
if (me && held.authorId === me && held.status === 'open') {
|
||||||
|
return { tab: 'details', unitId: held.unitId, proposalId: held.id, edit: true };
|
||||||
|
}
|
||||||
|
return { tab: 'propose', unitId: held.unitId, proposalId: held.id, objectionId: o.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The objections standing against a version, held back by them. */
|
||||||
|
export function holding(objections: Objection[]): Objection[] {
|
||||||
|
return objections.filter((o) => o.held && o.phase !== 'lifted');
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its
|
||||||
|
// lines and its salt — as the person who asked for it downloads it
|
||||||
|
// (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking
|
||||||
|
// the server anything: the canonical text, its blake2b-256 seal, and the comment
|
||||||
|
// `ld1:` + seal that the chain carries. The same rules as the engine
|
||||||
|
// (crates/ld-engine/src/ballot.rs, `canonical` and `seal`).
|
||||||
|
|
||||||
|
import { blake2b } from '@noble/hashes/blake2.js';
|
||||||
|
|
||||||
|
export type ReceiptChoice = 'for' | 'against' | 'none';
|
||||||
|
|
||||||
|
export interface ReceiptLine {
|
||||||
|
proposalId: string;
|
||||||
|
revision: number;
|
||||||
|
choice: ReceiptChoice;
|
||||||
|
/** For reading only: the article and the title of the version voted. */
|
||||||
|
article?: string;
|
||||||
|
title?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sealed {
|
||||||
|
canonical: string;
|
||||||
|
digest: string;
|
||||||
|
comment: string;
|
||||||
|
/** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */
|
||||||
|
remarkHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReceiptCheck =
|
||||||
|
| { kind: 'unreadable' }
|
||||||
|
| { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] }
|
||||||
|
| { kind: 'mismatch'; comment: string; computed: string };
|
||||||
|
|
||||||
|
const CHOICES: readonly string[] = ['for', 'against', 'none'];
|
||||||
|
/** Printable ASCII without spaces: what reads back, one field per line. */
|
||||||
|
const PLAIN = /^[\x21-\x7e]+$/;
|
||||||
|
const SALT = /^[0-9a-f]{32}$/;
|
||||||
|
|
||||||
|
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
export function blake2_256(text: string): string {
|
||||||
|
return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ballot as it is hashed; `null` if it cannot be one. */
|
||||||
|
export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null {
|
||||||
|
if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null;
|
||||||
|
const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0));
|
||||||
|
if (!sorted.length) return null;
|
||||||
|
for (const [i, l] of sorted.entries()) {
|
||||||
|
if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null;
|
||||||
|
if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null;
|
||||||
|
}
|
||||||
|
let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`;
|
||||||
|
for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`;
|
||||||
|
return `${out}salt ${salt}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null {
|
||||||
|
const text = canonical(doc, author, lines, salt);
|
||||||
|
if (text === null) return null;
|
||||||
|
const digest = blake2_256(text);
|
||||||
|
const comment = `ld1:${digest}`;
|
||||||
|
return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLine = (l: unknown): l is ReceiptLine => {
|
||||||
|
const v = l as Partial<ReceiptLine> | null;
|
||||||
|
return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reads a receipt (its text, as downloaded) and recomputes its seal. */
|
||||||
|
export function checkReceipt(text: string): ReceiptCheck {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { kind: 'unreadable' };
|
||||||
|
}
|
||||||
|
const r = raw as Record<string, unknown> | null;
|
||||||
|
if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' };
|
||||||
|
const { doc, author, lines, salt, comment, block } = r;
|
||||||
|
if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' };
|
||||||
|
if (author !== null && typeof author !== 'string') return { kind: 'unreadable' };
|
||||||
|
if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' };
|
||||||
|
const s = seal(doc, author, lines, salt);
|
||||||
|
if (!s) return { kind: 'unreadable' };
|
||||||
|
if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment };
|
||||||
|
return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines };
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { Me } from './types';
|
import type { Me } from './types';
|
||||||
|
|
||||||
|
/** How often the number of unread messages is looked at again, while signed in. */
|
||||||
|
const UNREAD_EVERY_MS = 60_000;
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
me = $state<Me | null>(null);
|
me = $state<Me | null>(null);
|
||||||
ready = $state(false);
|
ready = $state(false);
|
||||||
g1 = $state(false);
|
g1 = $state(false);
|
||||||
dev = $state(false);
|
dev = $state(false);
|
||||||
|
/** Unread messages of one's box, next to « Messages » in the menu (ADR 0010). */
|
||||||
|
unread = $state(0);
|
||||||
|
#timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const [me, cfg] = await Promise.all([
|
const [me, cfg] = await Promise.all([
|
||||||
@@ -16,11 +22,33 @@ class Session {
|
|||||||
this.g1 = cfg.g1;
|
this.g1 = cfg.g1;
|
||||||
this.dev = cfg.dev;
|
this.dev = cfg.dev;
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
|
this.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks at the box again: on a navigation, after a gesture, every minute. */
|
||||||
|
async refreshUnread() {
|
||||||
|
if (!this.me) {
|
||||||
|
this.unread = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.unread = (await api.get<{ unread: number }>('/messages/unread')).unread;
|
||||||
|
} catch {
|
||||||
|
/* the count waits for the next look */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sign-in on another page sets `me` directly: the count follows on the next look. */
|
||||||
|
private watch() {
|
||||||
|
clearInterval(this.#timer);
|
||||||
|
void this.refreshUnread();
|
||||||
|
this.#timer = setInterval(() => void this.refreshUnread(), UNREAD_EVERY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
await api.post('/auth/logout');
|
await api.post('/auth/logout');
|
||||||
this.me = null;
|
this.me = null;
|
||||||
|
this.unread = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -110,8 +110,12 @@ export interface Proposal {
|
|||||||
tally: Tally;
|
tally: Tally;
|
||||||
evaluation: Evaluation | null;
|
evaluation: Evaluation | null;
|
||||||
votedAtInertia: Inertia;
|
votedAtInertia: Inertia;
|
||||||
|
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
signals: number;
|
/** May be voted now: open or in force, and not held back (ADR 0010). */
|
||||||
|
votable: boolean;
|
||||||
|
/** Standing objections around this version. */
|
||||||
|
objections: number;
|
||||||
myVote: 'for' | 'against' | null;
|
myVote: 'for' | 'against' | null;
|
||||||
myVoteStale: boolean;
|
myVoteStale: boolean;
|
||||||
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
||||||
@@ -169,6 +173,20 @@ export interface Ballot {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
|
||||||
|
export interface BallotEntry {
|
||||||
|
id: string;
|
||||||
|
doc: { id: string; group: string; slug: string; title: LangMap | string };
|
||||||
|
comment: string;
|
||||||
|
status: Ballot['status'];
|
||||||
|
reason: Ballot['reason'];
|
||||||
|
block: number | null;
|
||||||
|
voter: string | null;
|
||||||
|
lines: number;
|
||||||
|
createdAt: string;
|
||||||
|
settledAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -244,3 +262,83 @@ export interface Entered {
|
|||||||
code: string;
|
code: string;
|
||||||
kind: 'text' | 'remove' | 'inertia' | 'title' | 'order';
|
kind: 'text' | 'remove' | 'inertia' | 'title' | 'order';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ObjectionKind = 'redit' | 'contredit';
|
||||||
|
|
||||||
|
/** A version as an objection names it. */
|
||||||
|
export interface VersionRef {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
status: Proposal['status'] | null;
|
||||||
|
unitId: string | null;
|
||||||
|
code: string | null;
|
||||||
|
author: string | null;
|
||||||
|
authorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An objection, as the session sees it (ADR 0010). */
|
||||||
|
export interface Objection {
|
||||||
|
id: string;
|
||||||
|
kind: ObjectionKind;
|
||||||
|
reason: string;
|
||||||
|
objector: string | null;
|
||||||
|
objectorInvited: boolean;
|
||||||
|
/** This session raised it. */
|
||||||
|
mine: boolean;
|
||||||
|
at: string;
|
||||||
|
/** The author answers, then the objector lifts or reiterates; or it is lifted. */
|
||||||
|
phase: 'author' | 'objector' | 'lifted';
|
||||||
|
cycle: number;
|
||||||
|
/** While the objector is asked: past this, it is lifted. */
|
||||||
|
dueAt: string | null;
|
||||||
|
answer: 'revision' | 'other' | 'proposal' | null;
|
||||||
|
answerId: string | null;
|
||||||
|
answeredAt: string | null;
|
||||||
|
lift: 'objector' | 'admin' | 'deadline' | 'withdrawn' | 'revision' | null;
|
||||||
|
liftedAt: string | null;
|
||||||
|
/** The latest arrival of the two, not votable while it stands. */
|
||||||
|
heldVersion: VersionRef | null;
|
||||||
|
other: VersionRef;
|
||||||
|
/** This session may answer by a proposal linked to it (the author of the held version, or anyone for a seeded one). */
|
||||||
|
iAnswer: boolean;
|
||||||
|
canLift: boolean;
|
||||||
|
canReiterate: boolean;
|
||||||
|
/** Around one version: whether that version is the one held back. */
|
||||||
|
held?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A typo fix written by someone else than the author (ADR 0010). */
|
||||||
|
export interface TypoFix {
|
||||||
|
id: string;
|
||||||
|
status: 'pending' | 'applied' | 'refused' | 'stale';
|
||||||
|
writer: string | null;
|
||||||
|
writerInvited: boolean;
|
||||||
|
at: string;
|
||||||
|
dueAt: string;
|
||||||
|
settledAt: string | null;
|
||||||
|
/** A name, or `deadline` (passed without an answer), or `changed` (the text moved on). */
|
||||||
|
settledBy: string | null;
|
||||||
|
/** Who answers: the author of the version, or an administrator for a seeded text. */
|
||||||
|
answerer: 'author' | 'admin';
|
||||||
|
diff: DiffPart[];
|
||||||
|
gestures: ('validate' | 'refuse')[];
|
||||||
|
version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message of one's box: about an objection or a typo fix, with its gestures now. */
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
topic: 'objection' | 'typo';
|
||||||
|
kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending';
|
||||||
|
cycle: number;
|
||||||
|
createdAt: string;
|
||||||
|
dueAt: string | null;
|
||||||
|
read: boolean;
|
||||||
|
/** Dealt with: no gesture any more. */
|
||||||
|
done: boolean;
|
||||||
|
outcome: string | null;
|
||||||
|
gestures: ('respond' | 'lift' | 'reiterate' | 'validate' | 'refuse')[];
|
||||||
|
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
|
||||||
|
objection?: Objection;
|
||||||
|
typo?: TypoFix;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function level(p: Proposal): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
|
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
|
||||||
* against. A blocked version comes after the others: it cannot enter into force.
|
* against. A version held back by an objection comes after the others: it is not
|
||||||
|
* votable, and cannot enter into force, until the objection is lifted.
|
||||||
*/
|
*/
|
||||||
export function compare(a: Proposal, b: Proposal): number {
|
export function compare(a: Proposal, b: Proposal): number {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ export interface VoteTransfer {
|
|||||||
remark: string;
|
remark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
|
/**
|
||||||
export type SendStatus = 'signing' | 'sent' | 'included';
|
* signing: the wallet asks the person; signed: signed and handed to the node —
|
||||||
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
|
* from now on the transaction may reach the chain; sent: on its way; included: in a block.
|
||||||
|
*/
|
||||||
|
export type SendStatus = 'signing' | 'signed' | 'sent' | 'included';
|
||||||
|
/** chain: included in a block, but the chain undid it (the transfer failed): no vote will come. */
|
||||||
|
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet' | 'chain';
|
||||||
|
|
||||||
export class WalletError extends Error {
|
export class WalletError extends Error {
|
||||||
constructor(public code: Failure) {
|
constructor(public code: Failure) {
|
||||||
@@ -69,6 +73,12 @@ export function injectedNames(page: Page): string[] {
|
|||||||
/** What went wrong, in one of a few words the interface translates. */
|
/** What went wrong, in one of a few words the interface translates. */
|
||||||
export function failure(e: unknown): Failure {
|
export function failure(e: unknown): Failure {
|
||||||
if (e instanceof WalletError) return e.code;
|
if (e instanceof WalletError) return e.code;
|
||||||
|
// polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent.
|
||||||
|
// Its message is indented JSON, so the reason is read from the error itself.
|
||||||
|
if (e instanceof Error && e.name === 'InvalidTxError') {
|
||||||
|
const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();
|
||||||
|
return /payment|funds|balance|existential/.test(reason) ? 'funds' : 'chain';
|
||||||
|
}
|
||||||
let text: string;
|
let text: string;
|
||||||
if (e instanceof Error) text = e.message;
|
if (e instanceof Error) text = e.message;
|
||||||
else {
|
else {
|
||||||
@@ -81,14 +91,29 @@ export function failure(e: unknown): Failure {
|
|||||||
text = text.toLowerCase();
|
text = text.toLowerCase();
|
||||||
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
|
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
|
||||||
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
|
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
|
||||||
|
// Refused by the node's pool (stale, bad proof…): it will never be included.
|
||||||
|
if (/"type":\s*"invalid"|invalid transaction/.test(text)) return 'chain';
|
||||||
return 'wallet';
|
return 'wallet';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
|
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
|
||||||
export function nothingSent(f: Failure): boolean {
|
export function nothingSent(f: Failure): boolean {
|
||||||
return f === 'cancelled' || f === 'funds' || f === 'account';
|
return f === 'cancelled' || f === 'funds' || f === 'account' || f === 'chain';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the page may let a ballot go after a failure: nothing was signed yet
|
||||||
|
* (`left` false — a node down before sending, a refusal, the window closed), or
|
||||||
|
* the chain said no. Otherwise the transaction may still land: the ballot is
|
||||||
|
* kept, and the server reads the indexer for it until its end.
|
||||||
|
*/
|
||||||
|
export function mayLetGo(f: Failure, left: boolean): boolean {
|
||||||
|
return !left || nothingSent(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a node may take to answer before the wallet is asked to sign. */
|
||||||
|
const REACH_MS = 20_000;
|
||||||
|
|
||||||
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
|
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
|
||||||
@@ -106,10 +131,23 @@ async function enable(name: string): Promise<Enabled> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tests: one call to the fake node; `null` if it does not answer. */
|
||||||
|
async function rpc(node: string, method: string, params: unknown[] = []): Promise<{ error?: unknown } | null> {
|
||||||
|
const body = { jsonrpc: '2.0', id: 1, method, params };
|
||||||
|
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
if (!res?.ok) return null;
|
||||||
|
return (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
|
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
|
||||||
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
|
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
|
||||||
const ext = await enable(name);
|
const ext = await enable(name);
|
||||||
if (!ext.signer.signPayload) throw new WalletError('wallet');
|
if (!ext.signer.signPayload) throw new WalletError('wallet');
|
||||||
|
// As polkadot-api does, the node is reached before the wallet is asked to sign.
|
||||||
|
const health = await rpc(node, 'system_health');
|
||||||
|
if (!health || health.error) throw new WalletError('node');
|
||||||
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
|
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
|
||||||
on('signing');
|
on('signing');
|
||||||
let signature: string;
|
let signature: string;
|
||||||
@@ -118,11 +156,10 @@ async function sendTest(name: string, node: string, account: WalletAccount, tx:
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new WalletError(failure(e));
|
throw new WalletError(failure(e));
|
||||||
}
|
}
|
||||||
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
|
on('signed');
|
||||||
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
|
const submitted = await rpc(node, 'author_submitExtrinsic', [testExtrinsic({ ...fields, signature })]);
|
||||||
() => null,
|
if (!submitted) throw new WalletError('node');
|
||||||
);
|
if (submitted.error) throw new WalletError(failure(submitted.error));
|
||||||
if (!res?.ok) throw new WalletError('node');
|
|
||||||
on('sent');
|
on('sent');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +177,16 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
|||||||
try {
|
try {
|
||||||
const signer = ext.getAccounts().find((a) => a.address === account.address);
|
const signer = ext.getAccounts().find((a) => a.address === account.address);
|
||||||
if (!signer) throw new WalletError('account');
|
if (!signer) throw new WalletError('account');
|
||||||
|
// A node must answer before the wallet is asked to sign: otherwise nothing leaves, and the page says so.
|
||||||
|
let wait: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
await Promise.race([
|
||||||
|
client.getFinalizedBlock(),
|
||||||
|
new Promise((_, reject) => (wait = setTimeout(() => reject(new WalletError('node')), REACH_MS))),
|
||||||
|
])
|
||||||
|
.catch(() => {
|
||||||
|
throw new WalletError('node');
|
||||||
|
})
|
||||||
|
.finally(() => clearTimeout(wait));
|
||||||
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
|
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
|
||||||
const { tx: calls } = client.getUnsafeApi();
|
const { tx: calls } = client.getUnsafeApi();
|
||||||
const call = calls.Utility!.batch_all!({
|
const call = calls.Utility!.batch_all!({
|
||||||
@@ -153,6 +200,8 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
|||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
|
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
|
// Signed: from now on, the transaction may reach the chain.
|
||||||
|
if (ev.type === 'created') on('signed');
|
||||||
if (ev.type === 'broadcasted') {
|
if (ev.type === 'broadcasted') {
|
||||||
on('sent');
|
on('sent');
|
||||||
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
|
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
|
||||||
@@ -167,7 +216,7 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
|||||||
if (ev.ok) {
|
if (ev.ok) {
|
||||||
on('included');
|
on('included');
|
||||||
resolve();
|
resolve();
|
||||||
} else reject(new WalletError(failure(ev.dispatchError)));
|
} else reject(new WalletError(failure(ev.dispatchError) === 'funds' ? 'funds' : 'chain'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (e: unknown) => {
|
error: (e: unknown) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
drawer = false;
|
drawer = false;
|
||||||
|
if (session.ready) void session.refreshUnread();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><Logo size={1.2} /></a>
|
<a class="brand" href="/"><Logo size={1.2} /></a>
|
||||||
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
||||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
|
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
|
||||||
|
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -157,6 +159,7 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
@@ -167,5 +170,21 @@
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
/* Unread messages, seen without opening the menu. */
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.3rem;
|
||||||
|
right: -0.3rem;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--ochre);
|
||||||
|
color: var(--on-water);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
||||||
|
// note promises everyone can check their own vote with its salt: the receipt is
|
||||||
|
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
||||||
|
// it is checked here, in the browser, without asking the server anything.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
|
import { Remote } from '$lib/load.svelte';
|
||||||
|
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
|
import type { BallotEntry, LangMap } from '$lib/types';
|
||||||
|
|
||||||
|
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
||||||
|
const MAX_FILE = 1_000_000;
|
||||||
|
|
||||||
|
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
||||||
|
onMount(() => {
|
||||||
|
if (session.me) void data.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
let pasted = $state('');
|
||||||
|
let check = $state<ReceiptCheck | null>(null);
|
||||||
|
|
||||||
|
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
||||||
|
|
||||||
|
function standing(b: BallotEntry): string {
|
||||||
|
if (b.status === 'pending') return t('ballots.status.pending');
|
||||||
|
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||||
|
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||||
|
return t('ballots.status.expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fromFile(e: Event) {
|
||||||
|
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
pasted = '';
|
||||||
|
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromText(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
check = checkReceipt(pasted);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1>{t('ballots.title')}</h1>
|
||||||
|
<p class="lead">{t('ballots.intro')}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="ballots-mine">
|
||||||
|
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
||||||
|
{#if !session.me}
|
||||||
|
<p class="muted">
|
||||||
|
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
||||||
|
</p>
|
||||||
|
{:else if data.error}
|
||||||
|
<Guard code={data.error} onretry={() => data.load()} />
|
||||||
|
{:else if data.value}
|
||||||
|
<ul class="list">
|
||||||
|
{#each data.value.ballots as b (b.id)}
|
||||||
|
<li class="card block ballot">
|
||||||
|
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||||
|
<p class="state {b.status}">
|
||||||
|
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||||
|
</p>
|
||||||
|
<code class="comment">{b.comment}</code>
|
||||||
|
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="faint">{t('ballots.none')}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="ballots-verify">
|
||||||
|
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
||||||
|
<div class="card block">
|
||||||
|
<p class="muted">{t('ballots.verify.intro')}</p>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">{t('ballots.verify.file')}</span>
|
||||||
|
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
||||||
|
</label>
|
||||||
|
<form class="paste" onsubmit={fromText}>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">{t('ballots.verify.paste')}</span>
|
||||||
|
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
||||||
|
</form>
|
||||||
|
<div class="result" aria-live="polite">
|
||||||
|
{#if check?.kind === 'ok'}
|
||||||
|
<p class="ok">{t('ballots.verify.ok')}</p>
|
||||||
|
<code class="comment">{check.comment}</code>
|
||||||
|
<ul class="lines">
|
||||||
|
{#each check.lines as l (l.proposalId)}
|
||||||
|
<li>
|
||||||
|
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
|
||||||
|
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="faint small">
|
||||||
|
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
||||||
|
</p>
|
||||||
|
{:else if check?.kind === 'mismatch'}
|
||||||
|
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
||||||
|
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
||||||
|
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
||||||
|
{:else if check?.kind === 'unreadable'}
|
||||||
|
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.list,
|
||||||
|
.lines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
.ballot {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.state.confirmed {
|
||||||
|
color: var(--moss);
|
||||||
|
}
|
||||||
|
.state.pending {
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
|
.state.refused,
|
||||||
|
.state.expired {
|
||||||
|
color: var(--ochre);
|
||||||
|
}
|
||||||
|
.comment {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.paste {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.paste .field,
|
||||||
|
.paste textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.lines {
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.unsealed {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--moss);
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
color: var(--ochre);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,10 +11,12 @@
|
|||||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||||
import { sections } from '$lib/basket';
|
import { sections, votableIds } from '$lib/basket';
|
||||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
|
||||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||||
|
import { answerPanel } from '$lib/objections';
|
||||||
import { BasketState } from '$lib/picks.svelte';
|
import { BasketState } from '$lib/picks.svelte';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||||
import type { Line } from '$lib/vote';
|
import type { Line } from '$lib/vote';
|
||||||
import { articles, type Filter } from '$lib/versions';
|
import { articles, type Filter } from '$lib/versions';
|
||||||
@@ -26,23 +28,30 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void doc.load(group, slug);
|
void doc.load(group, slug);
|
||||||
});
|
});
|
||||||
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
// The basket of this document, kept for the tab; versions no longer open to vote
|
||||||
|
// (or held back by an objection) leave it.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = doc.view;
|
const v = doc.view;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
const known = new Set(sections(v.units, v.modules).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
basket.load(v.doc.id, votableIds(sections(v.units, v.modules)));
|
||||||
basket.load(v.doc.id, known);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||||
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
|
||||||
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
|
||||||
let prefill = $state<PanelState['prefill']>(null);
|
let prefill = $state<PanelState['prefill']>(null);
|
||||||
const panel = $derived.by((): PanelState | null => {
|
const panel = $derived.by((): PanelState | null => {
|
||||||
const q = page.url.searchParams;
|
const q = page.url.searchParams;
|
||||||
const tab = TABS[q.get('onglet') ?? ''];
|
const tab = TABS[q.get('onglet') ?? ''];
|
||||||
if (!tab) return null;
|
if (!tab) return null;
|
||||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
|
return {
|
||||||
|
unitId: q.get('article'),
|
||||||
|
proposalId: q.get('version'),
|
||||||
|
tab,
|
||||||
|
draftId: q.get('brouillon'),
|
||||||
|
section: q.get('section'),
|
||||||
|
prefill,
|
||||||
|
objectionId: q.get('objection'),
|
||||||
|
edit: q.get('modifier') === '1',
|
||||||
|
};
|
||||||
});
|
});
|
||||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||||
const vote = $derived.by(() => {
|
const vote = $derived.by(() => {
|
||||||
@@ -52,16 +61,7 @@
|
|||||||
|
|
||||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||||
prefill = nextPanel?.prefill ?? null;
|
prefill = nextPanel?.prefill ?? null;
|
||||||
const q = new URLSearchParams();
|
const search = panelSearch(nextPanel, nextVote);
|
||||||
if (nextVote) q.set('vote', nextVote);
|
|
||||||
if (nextPanel) {
|
|
||||||
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
|
||||||
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
|
||||||
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
|
||||||
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
|
||||||
if (nextPanel.section) q.set('section', nextPanel.section);
|
|
||||||
}
|
|
||||||
const search = q.toString();
|
|
||||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
||||||
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
||||||
noScroll: true,
|
noScroll: true,
|
||||||
@@ -130,6 +130,7 @@
|
|||||||
onopen={(id) => go(panel, id)}
|
onopen={(id) => go(panel, id)}
|
||||||
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
||||||
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
||||||
|
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
|
||||||
onsign={sign}
|
onsign={sign}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
|
||||||
|
// ADR 0010): what waits for a gesture of the person — an objection to answer,
|
||||||
|
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
|
||||||
|
// deadline. A message dealt with offers no gesture any more. No mail.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import DiffText from '$lib/components/DiffText.svelte';
|
||||||
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
|
import { condense } from '$lib/diff';
|
||||||
|
import { panelSearch } from '$lib/doc.svelte';
|
||||||
|
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||||
|
import { Remote } from '$lib/load.svelte';
|
||||||
|
import { answerPanel } from '$lib/objections';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
|
import { toasts } from '$lib/toast.svelte';
|
||||||
|
import type { Message } from '$lib/types';
|
||||||
|
import { errorText } from '$lib/vote';
|
||||||
|
|
||||||
|
const box = new Remote(() => api.get<{ unread: number; messages: Message[] }>('/messages'));
|
||||||
|
onMount(() => {
|
||||||
|
if (session.me) void box.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
const unread = $derived(box.value?.unread ?? 0);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
await box.load();
|
||||||
|
session.unread = box.value?.unread ?? session.unread;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(m: Message) {
|
||||||
|
if (m.read) return;
|
||||||
|
const r = await api.post<{ unread: number }>(`/messages/${m.id}/read`).catch(() => null);
|
||||||
|
if (r) session.unread = r.unread;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll() {
|
||||||
|
await api.post('/messages/read').catch(() => null);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text of a message, in the language of the reader, from its subject. */
|
||||||
|
function textOf(m: Message): string {
|
||||||
|
const o = m.objection;
|
||||||
|
const fix = m.typo;
|
||||||
|
if (o) {
|
||||||
|
const held = o.heldVersion?.title ?? '';
|
||||||
|
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
|
||||||
|
if (m.kind === 'objection_reiterated') return t('messages.objection_reiterated', { name: o.objector ?? '', held });
|
||||||
|
return t('messages.objection_answered', { held });
|
||||||
|
}
|
||||||
|
if (fix) return t(fix.answerer === 'admin' ? 'messages.typo_pending.admin' : 'messages.typo_pending.author', { name: fix.writer ?? '', title: fix.version?.title ?? '' });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeOf(m: Message): string {
|
||||||
|
const o = m.outcome;
|
||||||
|
if (!o) return t('messages.outcome.done');
|
||||||
|
if (o === 'deadline') return t(m.topic === 'typo' ? 'messages.outcome.deadline.typo' : 'messages.outcome.deadline.objection');
|
||||||
|
const key = `messages.outcome.${o}`;
|
||||||
|
return key in OUTCOMES ? t(key as Key) : t('messages.outcome.done');
|
||||||
|
}
|
||||||
|
const OUTCOMES: Record<string, true> = Object.fromEntries(
|
||||||
|
['answered', 'reiterated', 'lifted', 'admin', 'withdrawn', 'applied', 'refused', 'stale'].map((k) => [`messages.outcome.${k}`, true]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The version a message is about, in the reader: its details. */
|
||||||
|
function hrefOf(m: Message): string {
|
||||||
|
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
|
||||||
|
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
|
||||||
|
if (!v) return base;
|
||||||
|
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gesture(m: Message, g: Message['gestures'][number]) {
|
||||||
|
error = '';
|
||||||
|
if (g === 'respond' && m.objection) {
|
||||||
|
const panel = answerPanel(m.objection, session.me?.id ?? null);
|
||||||
|
void read(m);
|
||||||
|
await goto(`/g/${m.doc.groupSlug}/docs/${m.doc.slug}?${panelSearch(panel, null)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if ((g === 'lift' || g === 'reiterate') && m.objection) {
|
||||||
|
await api.post(`/objections/${m.objection.id}/${g}`);
|
||||||
|
toasts.show(t(g === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||||
|
} else if ((g === 'validate' || g === 'refuse') && m.typo) {
|
||||||
|
await api.post(`/typos/${m.typo.id}/${g}`);
|
||||||
|
toasts.show(t(g === 'validate' ? 'typo.validated' : 'typo.refused'));
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
error = errorText(e);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(m: Message, g: Message['gestures'][number]): string {
|
||||||
|
switch (g) {
|
||||||
|
case 'respond':
|
||||||
|
return m.objection ? t(m.objection.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit') : t('messages.respond');
|
||||||
|
case 'lift':
|
||||||
|
return t('objection.lift');
|
||||||
|
case 'reiterate':
|
||||||
|
return t('objection.reiterate');
|
||||||
|
case 'validate':
|
||||||
|
return t('typo.validate');
|
||||||
|
case 'refuse':
|
||||||
|
return t('typo.refuse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{t('messages.title')} · libreDecision</title></svelte:head>
|
||||||
|
|
||||||
|
{#if !session.me}
|
||||||
|
<Guard code="unauthenticated" />
|
||||||
|
{:else if box.error}
|
||||||
|
<Guard code={box.error} onretry={() => box.load()} />
|
||||||
|
{:else}
|
||||||
|
<div class="page">
|
||||||
|
<header class="head">
|
||||||
|
<h1>{t('messages.title')}</h1>
|
||||||
|
<p class="muted">{t('messages.intro')}</p>
|
||||||
|
{#if unread}
|
||||||
|
<button class="btn small" type="button" onclick={readAll}>{t('messages.readAll')}</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
|
|
||||||
|
{#if box.value}
|
||||||
|
<ul class="box">
|
||||||
|
{#each box.value.messages as m (m.id)}
|
||||||
|
<li class="message" class:unread={!m.read} class:done={m.done}>
|
||||||
|
<p class="top">
|
||||||
|
<span class="tag" class:ochre={m.topic === 'objection'} class:water={m.topic === 'typo'}>{t(`messages.topic.${m.topic}`)}</span>
|
||||||
|
<span class="doc">{m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''}</span>
|
||||||
|
<span class="faint">{i18n.date(m.createdAt)}</span>
|
||||||
|
{#if !m.read}<span class="new">{t('messages.unread')}</span>{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text">{textOf(m)}</p>
|
||||||
|
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
|
||||||
|
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
|
||||||
|
{#if m.done}
|
||||||
|
<p class="outcome">{outcomeOf(m)}</p>
|
||||||
|
{:else if m.dueAt}
|
||||||
|
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if !m.done}
|
||||||
|
{#each m.gestures as g (g)}
|
||||||
|
<button class="btn small" class:primary={g === 'respond' || g === 'lift' || g === 'validate'} type="button" disabled={busy} onclick={() => gesture(m, g)}>{label(m, g)}</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<a class="linkish" href={hrefOf(m)} onclick={() => void read(m)}>{t('messages.open')}</a>
|
||||||
|
{#if !m.read}<button class="linkish" type="button" onclick={() => read(m)}>{t('messages.read')}</button>{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="empty muted">{t('messages.none')}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="faint">{t('common.loading')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.head .btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.5rem);
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.message.unread {
|
||||||
|
background: color-mix(in srgb, var(--surface) 88%, var(--water));
|
||||||
|
}
|
||||||
|
.message.done {
|
||||||
|
opacity: 0.78;
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
.doc {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.new {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--water);
|
||||||
|
color: var(--on-water);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.due {
|
||||||
|
font-weight: 750;
|
||||||
|
color: var(--ochre);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.outcome {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem 0.8rem;
|
||||||
|
}
|
||||||
|
.linkish {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--water);
|
||||||
|
font-weight: 750;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
color: var(--ochre);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
|
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket';
|
||||||
import type { Proposal, Unit } from '../../src/lib/types';
|
import type { Proposal, Unit } from '../../src/lib/types';
|
||||||
|
|
||||||
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||||
@@ -7,7 +7,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
|||||||
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
||||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
|
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
|
||||||
blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,4 +92,26 @@ describe('the basket of a vote', () => {
|
|||||||
]);
|
]);
|
||||||
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
|
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => {
|
||||||
|
// E5 has the text in force and a rival held back; E6 is a new article, held back.
|
||||||
|
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
|
||||||
|
prop('e5r', 'E5', { blocked: true, votable: false }),
|
||||||
|
]);
|
||||||
|
const e6 = unit('E6', 'technique', null, [prop('e6', 'E6', { blocked: true, votable: false })]);
|
||||||
|
const s = sections([e5, e6]);
|
||||||
|
const [slotE5, slotE6] = s[0]?.slots ?? [];
|
||||||
|
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
|
||||||
|
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
|
||||||
|
// A held article stays in sight, with nothing to choose.
|
||||||
|
expect(slotE6?.versions).toEqual([]);
|
||||||
|
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
|
||||||
|
const all = s.flatMap((x) => x.slots);
|
||||||
|
// The held rival does not compete now: the text in force is the only choice.
|
||||||
|
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
|
||||||
|
expect(waiting(all, {})).toEqual([]);
|
||||||
|
// A choice kept from before the objection is not sent.
|
||||||
|
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]);
|
||||||
|
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// What the end-to-end tests import is declared: a package found only as the
|
||||||
|
// dependency of another (as @noble/hashes under polkadot-api) may vanish with it.
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { builtinModules } from 'node:module';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const root = join(__dirname, '../..');
|
||||||
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
|
||||||
|
|
||||||
|
/** `@scope/name/sub.js` → `@scope/name`; `name/sub` → `name`. */
|
||||||
|
const packageOf = (spec: string) => spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/');
|
||||||
|
|
||||||
|
describe('the end-to-end tests', () => {
|
||||||
|
it('import only declared packages', () => {
|
||||||
|
const dir = join(root, 'e2e');
|
||||||
|
for (const file of readdirSync(dir).filter((f) => /\.(mjs|ts)$/.test(f))) {
|
||||||
|
const text = readFileSync(join(dir, file), 'utf8');
|
||||||
|
for (const [, spec] of text.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||||
|
if (!spec || spec.startsWith('.') || spec.startsWith('node:') || builtinModules.includes(spec)) continue;
|
||||||
|
expect(declared.has(packageOf(spec)), `${file}: ${spec}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { condense } from '../../src/lib/diff';
|
||||||
|
import type { DiffPart } from '../../src/lib/types';
|
||||||
|
|
||||||
|
const text = (parts: DiffPart[]) => parts.map((p) => (p.op === 'same' ? p.text : `[${p.op === 'added' ? '+' : '-'}${p.text}]`)).join('');
|
||||||
|
|
||||||
|
describe('a typo fix, told around its change', () => {
|
||||||
|
it('keeps a few words on each side of a change, cut between words', () => {
|
||||||
|
const parts: DiffPart[] = [
|
||||||
|
{ op: 'same', text: "Raison d'être\nDerrière une clé il n'y a qu'un seul être humain vivant. C" },
|
||||||
|
{ op: 'removed', text: "'" },
|
||||||
|
{ op: 'added', text: '’' },
|
||||||
|
{ op: 'same', text: "est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur." },
|
||||||
|
];
|
||||||
|
expect(text(condense(parts, 24))).toBe("… être humain vivant. C[-'][+’]est le seul et unique …");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two changes far apart: the middle is cut; close ones stay together', () => {
|
||||||
|
const far: DiffPart[] = [
|
||||||
|
{ op: 'same', text: 'Je m' },
|
||||||
|
{ op: 'removed', text: "'" },
|
||||||
|
{ op: 'added', text: '’' },
|
||||||
|
{ op: 'same', text: 'engage à vérifier la clé publique avec la personne, en sa présence, avant toute certification de son compte, et à n' },
|
||||||
|
{ op: 'removed', text: "'" },
|
||||||
|
{ op: 'added', text: '’' },
|
||||||
|
{ op: 'same', text: 'en rien faire sinon.' },
|
||||||
|
];
|
||||||
|
expect(text(condense(far, 20))).toBe("Je m[-'][+’]engage à vérifier la … son compte, et à n[-'][+’]en rien faire sinon.");
|
||||||
|
const near: DiffPart[] = [
|
||||||
|
{ op: 'same', text: 'Je m' },
|
||||||
|
{ op: 'removed', text: "'" },
|
||||||
|
{ op: 'added', text: '’' },
|
||||||
|
{ op: 'same', text: 'engage à n' },
|
||||||
|
{ op: 'removed', text: "'" },
|
||||||
|
{ op: 'added', text: '’' },
|
||||||
|
{ op: 'same', text: 'utiliser qu' },
|
||||||
|
];
|
||||||
|
expect(condense(near, 20)).toEqual(near);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nothing changed: the text as it is', () => {
|
||||||
|
const same: DiffPart[] = [{ op: 'same', text: 'Un texte.' }];
|
||||||
|
expect(condense(same)).toEqual(same);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { answerPanel, holding } from '../../src/lib/objections';
|
||||||
|
import type { Objection, VersionRef } from '../../src/lib/types';
|
||||||
|
|
||||||
|
const ref = (id: string, over: Partial<VersionRef> = {}): VersionRef => ({
|
||||||
|
id, title: id, status: 'open', unitId: `u-${id}`, code: id.toUpperCase(), author: null, authorId: null, ...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function objection(over: Partial<Objection> = {}): Objection {
|
||||||
|
return {
|
||||||
|
id: 'o1', kind: 'redit', reason: 'Ces deux textes disent la même chose.', objector: 'Yvv', objectorInvited: false,
|
||||||
|
mine: false, at: '', phase: 'author', cycle: 1, dueAt: null, answer: null, answerId: null, answeredAt: null,
|
||||||
|
lift: null, liftedAt: null, heldVersion: ref('k1', { author: 'Chiara', authorId: 'dev:chiara' }),
|
||||||
|
other: ref('e2', { status: 'in_force' }), iAnswer: true, canLift: false, canReiterate: false, held: true, ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('answering an objection', () => {
|
||||||
|
it('a repetition: a proposal linked to it, on the article repeated, from its text', () => {
|
||||||
|
expect(answerPanel(objection(), 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-e2', proposalId: 'e2', objectionId: 'o1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a contradiction: one rewords one’s own version in place', () => {
|
||||||
|
expect(answerPanel(objection({ kind: 'contredit' }), 'dev:chiara')).toEqual({ tab: 'details', unitId: 'u-k1', proposalId: 'k1', edit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a contradiction on a seeded version: a linked proposal on its article', () => {
|
||||||
|
const o = objection({ kind: 'contredit', heldVersion: ref('k1') });
|
||||||
|
expect(answerPanel(o, 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-k1', proposalId: 'k1', objectionId: 'o1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nothing held back, nothing to answer; a lifted objection holds nothing', () => {
|
||||||
|
expect(answerPanel(objection({ heldVersion: null }), 'dev:chiara')).toBeNull();
|
||||||
|
const list = [objection(), objection({ id: 'o2', phase: 'lifted' }), objection({ id: 'o3', held: false })];
|
||||||
|
expect(holding(list).map((o) => o.id)).toEqual(['o1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// The receipt of a vote on the Ğ1 chain, checked in the browser: the same
|
||||||
|
// canonical text and the same seal as the engine (crates/ld-engine/src/ballot.rs,
|
||||||
|
// whose vectors were computed apart with Python's hashlib.blake2b).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { canonical, checkReceipt, seal } from '../../src/lib/receipt';
|
||||||
|
|
||||||
|
const SALT = '00112233445566778899aabbccddeeff';
|
||||||
|
const LINES = [
|
||||||
|
{ proposalId: 'p2', revision: 1, choice: 'against' as const },
|
||||||
|
{ proposalId: 'p1', revision: 3, choice: 'for' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('the sealed ballot', () => {
|
||||||
|
it('reads as the engine writes it, the lines in the order of their versions', () => {
|
||||||
|
expect(canonical('d0c', null, LINES, SALT)).toBe(
|
||||||
|
'ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seals to the remark of the engine', () => {
|
||||||
|
const s = seal('d0c', null, LINES, SALT);
|
||||||
|
expect(s?.digest).toBe('20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
|
||||||
|
expect(s?.comment).toBe('ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
|
||||||
|
expect(s?.remarkHash).toBe('0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3');
|
||||||
|
const bound = seal('d0c', 'g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv', [{ proposalId: 'p1', revision: 3, choice: 'none' }], 'ffeeddccbbaa99887766554433221100');
|
||||||
|
expect(bound?.digest).toBe('6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e');
|
||||||
|
expect(bound?.remarkHash).toBe('0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('what cannot be sealed', () => {
|
||||||
|
expect(seal('d0c', null, [], SALT)).toBeNull();
|
||||||
|
expect(seal('d0c', null, [LINES[0]!, { ...LINES[0]!, revision: 2 }], SALT)).toBeNull();
|
||||||
|
expect(seal('d 0c', null, LINES, SALT)).toBeNull();
|
||||||
|
expect(seal('d0c', null, LINES, SALT.toUpperCase())).toBeNull();
|
||||||
|
expect(seal('d0c', null, LINES, '0011')).toBeNull();
|
||||||
|
expect(seal('d0c', null, [{ proposalId: 'p1', revision: 1.5, choice: 'for' }], SALT)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a receipt', () => {
|
||||||
|
const receipt = {
|
||||||
|
format: 'ld1',
|
||||||
|
doc: 'd0c',
|
||||||
|
author: null,
|
||||||
|
lines: LINES.map((l) => ({ ...l, article: 'E1', title: 'Unicité du compte' })),
|
||||||
|
salt: SALT,
|
||||||
|
comment: 'ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340',
|
||||||
|
block: 3_400_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('holds when its lines and its salt give its comment', () => {
|
||||||
|
expect(checkReceipt(JSON.stringify(receipt))).toEqual({
|
||||||
|
kind: 'ok',
|
||||||
|
comment: receipt.comment,
|
||||||
|
remarkHash: '0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3',
|
||||||
|
block: 3_400_000,
|
||||||
|
lines: receipt.lines,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hold once a choice is changed', () => {
|
||||||
|
const changed = { ...receipt, lines: [{ ...receipt.lines[0]!, choice: 'for' }, receipt.lines[1]!] };
|
||||||
|
const check = checkReceipt(JSON.stringify(changed));
|
||||||
|
expect(check.kind).toBe('mismatch');
|
||||||
|
if (check.kind === 'mismatch') expect(check.computed).not.toBe(receipt.comment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('what is not a receipt', () => {
|
||||||
|
for (const text of ['', 'nope', '[]', '{}', JSON.stringify({ ...receipt, format: 'ld2' }), JSON.stringify({ ...receipt, lines: 'x' })]) {
|
||||||
|
expect(checkReceipt(text).kind, text).toBe('unreadable');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
|||||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
||||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
||||||
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { InvalidTxError } from 'polkadot-api';
|
||||||
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
|
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
|
||||||
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
|
import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected';
|
||||||
|
|
||||||
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
||||||
const COMMENT = `ld1:${'ab'.repeat(32)}`;
|
const COMMENT = `ld1:${'ab'.repeat(32)}`;
|
||||||
@@ -30,6 +31,28 @@ describe('wallets of the page', () => {
|
|||||||
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
|
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
|
||||||
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
|
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
|
||||||
expect(failure(new Error('socket hang up'))).toBe('wallet');
|
expect(failure(new Error('socket hang up'))).toBe('wallet');
|
||||||
|
// Refused by the pool of the node: it will never be included.
|
||||||
|
expect(failure({ type: 'Invalid', value: { type: 'Stale' } })).toBe('chain');
|
||||||
|
expect(failure({ code: 1010, message: 'Invalid Transaction' })).toBe('chain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a refusal of the node, as polkadot-api raises it, is the chain saying no (relecture of 7ffa4fd)', () => {
|
||||||
|
// The real class: its message is indented JSON (`"type": "Invalid"`, with a space).
|
||||||
|
for (const reason of ['Stale', 'AncientBirthBlock', 'BadProof', 'ExhaustsResources', 'Future']) {
|
||||||
|
const e = new InvalidTxError({ type: 'Invalid', value: { type: reason } });
|
||||||
|
expect(failure(e), reason).toBe('chain');
|
||||||
|
expect(mayLetGo(failure(e), true), reason).toBe(true);
|
||||||
|
}
|
||||||
|
expect(failure(new InvalidTxError({ type: 'Invalid', value: { type: 'Payment' } }))).toBe('funds');
|
||||||
|
expect(failure(new InvalidTxError({ type: 'Unknown', value: { type: 'CannotLookup' } }))).toBe('chain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a ballot is let go when nothing left the page, or when the chain said no — kept when it may still land', () => {
|
||||||
|
// Before the signature (the node down before sending, a refusal, the window closed): nothing left.
|
||||||
|
for (const f of ['node', 'wallet', 'cancelled', 'funds', 'account', 'chain'] as const) expect(mayLetGo(f, false), f).toBe(true);
|
||||||
|
// Signed and handed to the node: it may reach the chain, unless the chain refused it.
|
||||||
|
for (const f of ['funds', 'account', 'chain'] as const) expect(mayLetGo(f, true), f).toBe(true);
|
||||||
|
for (const f of ['node', 'wallet'] as const) expect(mayLetGo(f, true), f).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('in tests, the fake node receives what was signed, readable', () => {
|
it('in tests, the fake node receives what was signed, readable', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user