forked from yvv/decision
Lot V : le quorum jugé sur le décompte affiché, l'adoption jamais sur une intention ; « un vote existe » pour chaque état du tampon (en attente, prouvé quel que soit son sort, retrait, simulé) — tests vus rouges par mutation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,21 +27,32 @@ use std::collections::{HashMap, HashSet};
|
||||
///
|
||||
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||
/// of its inertia; any revision) holds
|
||||
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
|
||||
/// of the chain once confirmed, written there by the confirmation);
|
||||
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
|
||||
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
|
||||
/// - a vote that counts (`votes`): a vote of a managed group, a vote proven by the
|
||||
/// chain, a **simulated** vote (ADR 0012);
|
||||
/// - a line of a ballot — the buffer of the votes keeps their history (ADR 0012) —
|
||||
/// of the chain or of the simulation: **an intention still pending** (signed, not yet
|
||||
/// read on the chain: Yvv, 2026-11-03), or a ballot proven, **whatever became of its
|
||||
/// line**: the voice that counts, a vote overtaken by a more recent one, a *for* set
|
||||
/// aside by an objection, **a withdrawal** (`none`). A vote withdrawn on the chain or
|
||||
/// in simulation, before any adoption, thus still says that someone voted. A ballot
|
||||
/// the wallet never signed (let go at once), never seen on the chain in time, or
|
||||
/// refused by the chain, is no vote;
|
||||
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||
/// its votes are withdrawn.
|
||||
///
|
||||
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
|
||||
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
|
||||
/// Two limits, known. In a managed group there is no buffer: a vote of the server
|
||||
/// withdrawn before any adoption leaves no row (`votes` keeps the last choice only),
|
||||
/// and from then on no vote exists (ADR 0013). The act « supprime » that ends the
|
||||
/// simulation (`simulation::discard`) takes the simulated ballots away with their
|
||||
/// trace: an article voted in simulation only has no vote any more, unless an adoption
|
||||
/// that holds on the proven votes stays.
|
||||
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||
let ids: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT p.unit_id FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1
|
||||
UNION
|
||||
-- Every ballot of the buffer, chain or simulated, pending or proven: its history.
|
||||
SELECT p.unit_id FROM ballot_lines l
|
||||
JOIN ballots b ON b.id = l.ballot_id
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
|
||||
@@ -1934,3 +1934,144 @@ async fn a_waiting_intention_counts_as_my_vote_in_decisions() {
|
||||
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
||||
assert_eq!(after, before.map(|n| n - 1));
|
||||
}
|
||||
|
||||
/// The evaluation as the engine gives it for these totals, on this version's letters.
|
||||
async fn judged(state: &AppState, doc: &Value, id: &str, shown: (u32, u32)) -> Value {
|
||||
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
||||
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
||||
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
||||
let tally = ld_engine::threshold::Tally { for_votes: shown.0, against: shown.1 };
|
||||
let e = ld_engine::threshold::evaluate(tally, st.electorate, &st.params(u, p)).expect("evaluation");
|
||||
serde_json::to_value(e).expect("json")
|
||||
}
|
||||
|
||||
/// What adoption judges: never an intention, simulated votes by default (ADR 0012).
|
||||
async fn adopted_evaluation(state: &AppState, doc: &Value, id: &str) -> Value {
|
||||
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
||||
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
||||
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
||||
serde_json::to_value(st.evaluation(u, p).expect("evaluation")).expect("json")
|
||||
}
|
||||
|
||||
/// The quorum (ADR 0013) is judged on the totals shown — votes recorded, pending and
|
||||
/// simulated, the vote one sees as one's own replaced by one's intention: `quorum`,
|
||||
/// `yesNeeded`, `noNeeded` and `missing` are those of the totals shown. Adoption counts
|
||||
/// the proven votes and the simulated ones (`SIMULATED_ADOPTS`), never an intention.
|
||||
#[tokio::test]
|
||||
async fn the_quorum_is_judged_on_the_totals_shown_and_adoption_never_on_an_intention() {
|
||||
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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
// I1, a seed proposal at level 2: the quorum 12, 9 « pour » at no vote.
|
||||
let i1 = version(&doc, "I1", "0");
|
||||
assert_eq!(find(&doc, &i1)["evaluation"], judged(&state, &doc, &i1, (0, 0)).await);
|
||||
assert_eq!(find(&doc, &i1)["evaluation"]["yesNeeded"], 9);
|
||||
// Proven: « contre ». Then « pour », sealed, not yet proven: it takes the place of
|
||||
// « contre » in the totals shown, and in the evaluation shown.
|
||||
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||
indexer.pay(100, comment_of(&first), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
|
||||
let shown = find(&d, &i1)["evaluation"].clone();
|
||||
assert_eq!(shown, judged(&state, &d, &i1, (1, 0)).await, "on the totals shown");
|
||||
assert_eq!((shown["cast"].as_u64(), shown["missing"].as_u64()), (Some(1), Some(8)));
|
||||
let adoption = adopted_evaluation(&state, &d, &i1).await;
|
||||
assert_eq!(adoption, judged(&state, &d, &i1, (0, 1)).await, "adoption: the proven « contre » only");
|
||||
assert!(adoption["missing"].as_u64() > Some(8), "the intention never brings adoption closer: {adoption}");
|
||||
// The detail says the same, and its five levels are asked at the votes shown.
|
||||
let detail = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
||||
assert_eq!(detail["proposal"]["evaluation"], shown);
|
||||
assert!(detail["levels"].as_array().expect("levels").iter().all(|l| l["cast"] == 1), "{detail}");
|
||||
// The chain closed: a simulated « pour » counts in both, like a proven vote.
|
||||
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||
let mut luna = c.other();
|
||||
luna.login("Luna").await;
|
||||
luna.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/votes"),
|
||||
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
|
||||
)
|
||||
.await;
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((2, 0), (1, 0), (1, 1)));
|
||||
assert_eq!(find(&d, &i1)["evaluation"], judged(&state, &d, &i1, (2, 0)).await);
|
||||
assert_eq!(adopted_evaluation(&state, &d, &i1).await, judged(&state, &d, &i1, (1, 1)).await);
|
||||
}
|
||||
|
||||
/// « Un vote existe » (ADR 0013, `quorum::voted_units`), for every state of the buffer
|
||||
/// (ADR 0012): an intention still pending, a proven line whatever became of it (a
|
||||
/// withdrawal, a vote overtaken, a *for* set aside by an objection), a simulated vote,
|
||||
/// a simulated withdrawal — the history of the ballots keeps their trace. A ballot the
|
||||
/// wallet never signed, one never seen on the chain in time, one the chain refused, is
|
||||
/// no vote.
|
||||
#[tokio::test]
|
||||
async fn a_vote_exists_for_every_state_of_the_buffer() {
|
||||
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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let unit_id = |code: &str| unit(&doc, code)["id"].as_str().expect("unit").to_owned();
|
||||
let exists = |u: String| {
|
||||
let state = state.clone();
|
||||
async move { ld_server::quorum::a_vote_exists(&state, &u).await.expect("query") }
|
||||
};
|
||||
let (i1, e1, e2, e3, e4) = (
|
||||
version(&doc, "I1", "0"),
|
||||
version(&doc, "E1", "0"),
|
||||
version(&doc, "E2", "0"),
|
||||
version(&doc, "E3", "0"),
|
||||
version(&doc, "E4", "0"),
|
||||
);
|
||||
for code in ["I1", "E1", "E2", "E3", "E4"] {
|
||||
assert!(!exists(unit_id(code)).await, "{code}: nobody voted yet");
|
||||
}
|
||||
// A ballot the wallet never signed: nothing.
|
||||
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||
assert!(exists(unit_id("I1")).await, "an intention pending is a vote already");
|
||||
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
|
||||
assert!(!exists(unit_id("I1")).await, "let go at once: no vote");
|
||||
// Never seen on the chain in time: no vote.
|
||||
let late = c.ballot(&doc, &[(&e2, Some("for"))], None).await;
|
||||
age(&state, "expires_at", -2 * 3600, id_of(&late)).await;
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
||||
assert!(!exists(unit_id("E2")).await);
|
||||
// Proven, then withdrawn on the chain: no row in `votes`, the trace stays.
|
||||
let a = c.ballot(&doc, &[(&e1, Some("for"))], None).await;
|
||||
indexer.pay(100, comment_of(&a), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let w = c.ballot(&doc, &[(&e1, None)], None).await;
|
||||
indexer.pay(101, comment_of(&w), alice());
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e1).await, (0, 0));
|
||||
assert!(exists(unit_id("E1")).await, "a vote withdrawn leaves its trace");
|
||||
// The chain closed: a simulated vote; a simulated vote, then withdrawn.
|
||||
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||
c.ok("POST", &format!("/api/proposals/{e3}/vote"), Some(json!({ "choice": "against" }))).await;
|
||||
assert!(exists(unit_id("E3")).await, "a simulated vote");
|
||||
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
|
||||
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e4).await, (0, 0));
|
||||
assert!(exists(unit_id("E4")).await, "a simulated vote withdrawn leaves its trace");
|
||||
// The quorum is held: the author (an administrator, for a text of the seed) may not set it.
|
||||
let mut admin = c.other();
|
||||
admin.login_admin("Yvv").await;
|
||||
for code in ["E1", "E3", "E4"] {
|
||||
let (s, v) =
|
||||
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "{code}");
|
||||
}
|
||||
let (s, _) =
|
||||
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E2")), Some(json!({ "quorum": 20 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "E2: no vote");
|
||||
// The act « supprime » sets every simulated vote aside: with them, their trace.
|
||||
ld_server::simulation::discard(&state, true).await.expect("act");
|
||||
assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await);
|
||||
assert!(exists(unit_id("E1")).await, "a proven trace stays");
|
||||
let _ = doc_id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user