diff --git a/crates/ld-server/src/quorum.rs b/crates/ld-server/src/quorum.rs index 50a7e86..3b0c766 100644 --- a/crates/ld-server/src/quorum.rs +++ b/crates/ld-server/src/quorum.rs @@ -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> { let ids: Vec = 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 diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index d6eff22..cec3b1f 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -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; +}