v3 : sur une version retenue, « contre » et retrait oui, « pour » non (moteur, serveur, chaîne)
Décision d'Yvv du 01/11/2026 : « Retirer ou "contre" ». Le moteur dit la règle (objection::may_cast) ; le vote direct, le vote groupé et le bulletin de la chaîne l'appliquent par DocState::admits : « pour » 409 objection, « contre » et retrait acceptés, une ligne refusée refuse tout le vote. Un bulletin scellé avant l'objection compte tel qu'il est signé ; une version retenue n'entre jamais. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -142,7 +142,8 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector
|
||||
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.
|
||||
// Nobody votes *for* it any more: neither one vote nor a grouped one (against and a
|
||||
// withdrawal still pass: `a_held_version_takes_against_and_withdrawal_never_for`).
|
||||
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
|
||||
@@ -476,6 +477,117 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
|
||||
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
|
||||
}
|
||||
|
||||
/// Yvv, 2026-11-01, « pendant qu'une objection retient une version, que permet-on ? » —
|
||||
/// « Retirer ou "contre" »: on a held version, against and a withdrawal pass, by one vote
|
||||
/// or a grouped one; a *for* never does, alone or among free lines (all or none).
|
||||
#[tokio::test]
|
||||
async fn a_held_version_takes_against_and_withdrawal_never_for() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut zoe = chiara.other();
|
||||
zoe.login("Zoé", 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 e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
zoe.object(&b, &e1, "redit", REPEATS).await;
|
||||
// Yvv voted for it before the objection (written as it would have been then).
|
||||
sqlx::query(
|
||||
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at)
|
||||
VALUES (?, 'dev:yvv', 'for', 1, '2026-10-31T00:00:00Z')",
|
||||
)
|
||||
.bind(&b)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
let tally = |d: &Value| (find(d, &b)["tally"]["for"].as_u64(), find(d, &b)["tally"]["against"].as_u64());
|
||||
let vote = |c: Option<&str>| Some(json!({ "choice": c }));
|
||||
let uri = format!("/api/proposals/{b}/vote");
|
||||
let refused = (StatusCode::CONFLICT, "objection".to_owned());
|
||||
|
||||
// One vote: never for — not even the one he already holds.
|
||||
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
assert_eq!(chiara.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
// Against: his for becomes against.
|
||||
assert_eq!(yvv.ok("POST", &uri, vote(Some("against"))).await["entered"], json!([]));
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!(tally(&d), (Some(0), Some(1)));
|
||||
assert_eq!(find(&d, &b)["myVote"], "against");
|
||||
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
|
||||
// And back to for: still not.
|
||||
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
// A withdrawal.
|
||||
yvv.ok("POST", &uri, vote(None)).await;
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
|
||||
|
||||
// Grouped: a for on the held version refuses the whole vote, the free line too.
|
||||
let votes = format!("/api/docs/{doc_id}/votes");
|
||||
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "for" }] });
|
||||
assert_eq!(chiara.err("POST", &votes, Some(mixed)).await, refused);
|
||||
let d = chiara.doc("essai").await;
|
||||
assert!(find(&d, &e2)["myVote"].is_null(), "all or none: nothing written");
|
||||
// Against the held one, for the free one: both written.
|
||||
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "against" }] });
|
||||
assert_eq!(chiara.ok("POST", &votes, Some(mixed)).await["count"], 2);
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &e2)["myVote"].as_str(), find(&d, &b)["myVote"].as_str()), (Some("for"), Some("against")));
|
||||
assert_eq!(tally(&d), (Some(0), Some(1)));
|
||||
// A grouped withdrawal on the held one.
|
||||
chiara.ok("POST", &votes, Some(json!({ "lines": [{ "proposalId": b, "choice": null }] }))).await;
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
|
||||
}
|
||||
|
||||
/// While held, a version can only lose votes; lifted, it enters with the votes still
|
||||
/// there — not with those it had when the objection was raised.
|
||||
#[tokio::test]
|
||||
async fn lifted_a_version_enters_only_with_the_votes_still_there() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut zoe = chiara.other();
|
||||
zoe.login("Zoé", 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 = zoe.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
// Every member of the group for it, as before the objection: enough to enter.
|
||||
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("members");
|
||||
assert_eq!(members.len(), 3);
|
||||
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");
|
||||
// While it is held, each one takes back or turns against.
|
||||
let uri = format!("/api/proposals/{b}/vote");
|
||||
chiara.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
|
||||
yvv.ok("POST", &uri, Some(json!({ "choice": null }))).await;
|
||||
zoe.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
|
||||
let lifted = zoe.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(lifted["entered"], json!([]), "lifted: no for left, it does not enter");
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &b)["status"].as_str(), find(&d, &b)["votable"].as_bool()), (Some("open"), Some(true)));
|
||||
assert_eq!(find(&d, &b)["tally"], json!({ "for": 0, "against": 2 }));
|
||||
}
|
||||
|
||||
/// Withdrawing the other side frees the held version at once: with its votes, it
|
||||
/// enters then, not at the next pass of the sweeper (relecture of cd7e683).
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user