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
|
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||||
/// of its inertia; any revision) holds
|
/// of its inertia; any revision) holds
|
||||||
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
|
/// - a vote that counts (`votes`): a vote of a managed group, a vote proven by the
|
||||||
/// of the chain once confirmed, written there by the confirmation);
|
/// chain, a **simulated** vote (ADR 0012);
|
||||||
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
|
/// - a line of a ballot — the buffer of the votes keeps their history (ADR 0012) —
|
||||||
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
|
/// 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
|
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||||
/// its votes are withdrawn.
|
/// its votes are withdrawn.
|
||||||
///
|
///
|
||||||
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
|
/// Two limits, known. In a managed group there is no buffer: a vote of the server
|
||||||
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
|
/// 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>> {
|
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||||
let ids: Vec<String> = sqlx::query_scalar(
|
let ids: Vec<String> = sqlx::query_scalar(
|
||||||
"SELECT p.unit_id FROM votes v
|
"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
|
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||||
WHERE u.doc_id = ?1
|
WHERE u.doc_id = ?1
|
||||||
UNION
|
UNION
|
||||||
|
-- Every ballot of the buffer, chain or simulated, pending or proven: its history.
|
||||||
SELECT p.unit_id FROM ballot_lines l
|
SELECT p.unit_id FROM ballot_lines l
|
||||||
JOIN ballots b ON b.id = l.ballot_id
|
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
|
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);
|
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
||||||
assert_eq!(after, before.map(|n| n - 1));
|
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