diff --git a/CLAUDE.md b/CLAUDE.md index 85d4a00..81adca2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,8 +117,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la - **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 ; la coquille d'un autre attend 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) ; + change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en + vigueur ; ADR 0010) : elle n'entre pas en vigueur, et on peut y retirer son vote ou passer à + « contre », jamais ajouter un « pour » (Yvv, 01/11/2026 : « Retirer ou "contre" ») ; 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 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). diff --git a/crates/ld-engine/src/objection.rs b/crates/ld-engine/src/objection.rs index bc36290..8e9f82c 100644 --- a/crates/ld-engine/src/objection.rs +++ b/crates/ld-engine/src/objection.rs @@ -17,6 +17,10 @@ //! 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); +//! - while it is held, one may still **vote against it or take one's vote +//! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou +//! "contre" »): it can only lose votes, and once lifted it enters with the +//! votes still there; //! - 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 @@ -31,6 +35,7 @@ //! lift too (never reiterate: the objection is not theirs); withdrawing either //! version ends it. +use crate::ballot::Choice; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -296,6 +301,16 @@ pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool { in_force || !held.contains(&id) } +/// May this choice be cast on a version? `held` = a standing objection holds it +/// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant +/// qu'une objection retient une version, que permet-on ? »: « Retirer ou +/// "contre" ». On a held version one may take one's vote back or vote against, +/// never add a *for*: it can only lose votes while it is held, and once lifted it +/// enters with the votes still there. A version not held takes any choice. +pub fn may_cast(held: bool, choice: Choice) -> bool { + !held || choice != Choice::For +} + #[cfg(test)] mod tests { use super::*; @@ -357,6 +372,17 @@ mod tests { assert!(votable("x", true, &["x"]), "a version in force is never made non-votable"); } + #[test] + fn a_held_version_takes_against_and_withdrawal_never_for() { + // Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held. + assert!(!may_cast(true, Choice::For), "never a new *for* while held"); + assert!(may_cast(true, Choice::Against), "against, yes"); + assert!(may_cast(true, Choice::None), "taking one's vote back, yes"); + for c in [Choice::For, Choice::Against, Choice::None] { + assert!(may_cast(false, c), "a version not held takes any choice: {c:?}"); + } + } + #[test] fn the_full_cycle_answer_reiterate_answer_lift() { let start = Phase::Author { cycle: 1 }; diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs index f673347..818dafd 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -90,7 +90,7 @@ pub struct BallotBody { author: Option, } -/// A line checked against the document: a votable version, its revision now. +/// A line checked against the document: a version open to its choice, its revision now. pub(crate) struct Cast { pub proposal_id: String, pub revision: i64, @@ -105,7 +105,9 @@ fn parse_choice(raw: Option<&str>) -> AppResult { } /// The lines of a vote, read against the document: at least one, each version -/// once, each one of this document and votable. +/// once, each one of this document and open to its choice — a version held back +/// by an objection takes against or a withdrawal, never a *for* (`DocState::admits`). +/// One line refused refuses them all. fn casts(st: &DocState, lines: &[LineBody]) -> AppResult> { if lines.is_empty() || lines.len() > MAX_LINES { return Err(AppError::Invalid("lines")); @@ -120,11 +122,13 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult> { if !matches!(p.status.as_str(), "open" | "in_force") { 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) { + let choice = parse_choice(l.choice.as_deref())?; + // Held back by an objection: no new *for*, on the server or on the chain + // (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »). + if !st.admits(p, choice) { 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 }); } Ok(out) } @@ -600,7 +604,9 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str) } /// The ballot counts: written for the identity of the signer, line by line, -/// where no more recent ballot of that identity decided already. +/// where no more recent ballot of that identity decided already. A ballot sealed +/// before an objection counts as it was signed — a *for* too, as a vote already +/// cast —, and the version held back still cannot enter (ADR 0010). async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> { let voter = format!("idty:{index}"); let position = found.comment.position; diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 96ebfda..af2d971 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -12,6 +12,7 @@ use crate::participation; use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, Query, State}; +use ld_engine::ballot::Choice; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::inertia::{InertiaLevel, Participation, Scale}; use ld_engine::threshold::{Evaluation, Tally}; @@ -265,7 +266,8 @@ pub struct DocState { pub module_kinds: HashMap, pub proposals: Vec, pub tallies: HashMap, - /// Versions held back by a standing objection: not votable, never entering into force. + /// Versions held back by a standing objection: against or a withdrawal only, never + /// a new *for* (`admits`), and never entering into force. pub blocked: HashSet, /// Standing objections touching each version, either side. pub signal_counts: HashMap, @@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { } // A standing objection holds back the latest arrival of its pair (never a version - // in force): not votable, and it cannot enter into force (ADR 0010). + // in force): no new *for*, and it cannot enter into force (ADR 0010). let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; let (electorate, electorate_source) = electorate(state, &group).await?; let measure = participation::stored(state, &group.slug).await?; @@ -414,6 +416,14 @@ impl DocState { matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id) } + /// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the + /// chain? Open or in force; held back by an objection, against or a withdrawal + /// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01). + pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool { + matches!(p.status.as_str(), "open" | "in_force") + && ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice) + } + /// The module a unit is, if it is one. pub fn module_of(&self, unit_id: &str) -> Option { self.module_kinds.get(unit_id).copied() diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs index 1634211..e7dc934 100644 --- a/crates/ld-server/src/objections.rs +++ b/crates/ld-server/src/objections.rs @@ -1,8 +1,9 @@ //! 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 pair, never a version in force, takes no new *for* while the objection +//! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and +//! cannot enter into force; 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). diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index 7ed6f21..20ffe8b 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -138,7 +138,8 @@ pub async fn decisions( /// 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 /// 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). +/// by an objection is not counted as one to vote while it is held: it takes no +/// new *for*, only against or a withdrawal (ADR 0010). async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct VersionRow { diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 153ba54..62b24f6 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -648,19 +648,20 @@ pub async fn vote( if !matches!(p.status.as_str(), "open" | "in_force") { 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? { - return Err(AppError::Conflict("members_only")); - } let choice = match b.choice.as_deref() { Some("for") => Choice::For, Some("against") => Choice::Against, None => Choice::None, Some(_) => return Err(AppError::Invalid("choice")), }; + // Held back by an objection: its votes stay; against or a withdrawal, never a + // new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »). + if !st.admits(&p, choice) { + return Err(AppError::Conflict("objection")); + } + if !docs::may_vote(&state, &user, &st.group).await? { + return Err(AppError::Conflict("members_only")); + } let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; ballots::write_server_votes(&state, &user.id, &[cast]).await?; let entered = adoption::run(&state, &st.doc.id).await?; diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index f872710..ceb714b 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -776,6 +776,52 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); } +/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a +/// ballot against it or a withdrawal, never a *for* — alone or among free lines. +#[tokio::test] +async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() { + 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 (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0")); + let o = c + .ok( + "POST", + &format!("/api/proposals/{e5}/objections"), + Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })), + ) + .await; + assert_eq!(o["heldId"], e5.as_str()); + let uri = format!("/api/docs/{doc_id}/ballots"); + let refused = (StatusCode::CONFLICT, Some("objection")); + for lines in [ + json!([{ "proposalId": e5, "choice": "for" }]), + json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]), + ] { + let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await; + assert_eq!((s, v["error"].as_str()), refused, "{lines}"); + } + // Against the held one, for a free one: sealed, then confirmed by the chain. + let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await; + indexer.pay(200, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(c.status(&b).await["status"], "confirmed"); + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1))); + assert_eq!(find(&d, &e5)["myVote"], "against"); + // A withdrawal: the last ballot stands. + let w = c.ballot(&doc, &[(&e5, None)], None).await; + indexer.pay(201, comment_of(&w), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(c.status(&w).await["status"], "confirmed"); + let d = c.doc("monnaie-libre").await; + assert_eq!(tally(&d, &e5), (0, 0)); + assert!(find(&d, &e5)["myVote"].is_null()); + assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters"); +} + // --------------------------------------------------------------------------- // After the independent review of 74f96be (lot B2): its probes, kept as tests // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index f6ca5dd..082e005 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -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 = 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] diff --git a/docs/adr/0010-objections-et-coquilles-validees.md b/docs/adr/0010-objections-et-coquilles-validees.md index 7acd3d4..27794ef 100644 --- a/docs/adr/0010-objections-et-coquilles-validees.md +++ b/docs/adr/0010-objections-et-coquilles-validees.md @@ -4,7 +4,8 @@ > [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. +> Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version +> retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ». **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 @@ -29,9 +30,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 : - 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. +- Tant que l'objection tient, la version retenue **ne prend plus de « pour »** et **n'entre pas + en vigueur**, quelles que soient ses voix. Yvv, 2026-11-01, à la question « pendant qu'une + objection retient une version, que permet-on ? » : **« Retirer ou "contre" »**. Chacun peut + **retirer son vote** ou **passer à « contre »** ; personne ne peut **ajouter un « pour »** (vote + direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le + vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle + n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant + l'objection et confirmé après compte tel qu'il est signé, sans la faire entrer. Règle : + `ld_engine::objection::may_cast`, appliquée par `DocState::admits`. - 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) ; @@ -45,14 +52,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 : 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. + Une levée relance l'adoption : la version libérée entre en vigueur si les voix encore là + 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 | — | +| tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; 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 | lever | la version reprend des « pour », et entre en vigueur si les voix encore là 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 » | — | @@ -119,6 +127,8 @@ corrections des autres) et `messages`, et sur les révisions la correction d'ori 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 +dernière arrivée, c'est à elle de s'expliquer. Pendant ce temps, ceux qui la soutiennent ne +peuvent pas la renforcer : un nouveau « pour » attend la levée, alors qu'un « contre » ou un +retrait compte tout de suite. 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. diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 7e418d0..02cea40 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -231,20 +231,23 @@ la laisse entrer) ; écriture : `crates/ld-server/src/proposals.rs` et `crates/l ### Deux articles qui se répètent ou se contredisent Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le -répète) ou elle le **contredit**, motif à l'appui. **Une objection rend la dernière arrivée des -deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière -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, +répète) ou elle le **contredit**, motif à l'appui. **Une objection retient la dernière arrivée +des deux versions jusqu'à sa levée** — jamais un texte en vigueur : si la dernière arrivée est +en vigueur, c'est l'autre qui est retenue. La version retenue n'entre pas en vigueur, quelles que +soient ses voix. Pendant l'objection, **on peut retirer son vote ou passer à « contre », jamais +ajouter un « pour »** (Yvv, 01/11/2026 : « Retirer ou "contre" ») — sous l'article, dans le +panier comme par la chaîne : « Tout pour » la saute et le dit, « Tout contre » la prend. Elle ne +peut que perdre des voix tant qu'elle est retenue ; à la levée, elle n'entre qu'avec les voix +encore là. 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 | — | +| 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 prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; 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 | lever | la version reprend des « pour », et entre en vigueur si les voix encore là 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 » | — | diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 1472cb0..f54e746 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -40,7 +40,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | 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 ·
» (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 » 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. » | -| 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 | +| C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | 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 → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; 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, « Pour » compris | | 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 » | @@ -81,3 +81,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 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) | | 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) | +| 2026-11-02 | automate | local, lot G sur `5d1f865` (décision d'Yvv du 01/11 : sur une version retenue, « contre » et retrait, jamais « pour » ; C28 ajusté : « Contre » et le retrait sous l'article, le panier) | C1–C33, M1 | 34 / 34 verts (35 tests) | diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index b940ae7..1b7f31c 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -37,11 +37,11 @@ async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target 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(); + await expect(page.getByText("Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.")).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 }) => { +test('C28 — « redit » : plus de « pour » sur la dernière arrivée (contre ou retrait), 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'); @@ -51,19 +51,57 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo // 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.getByText('personne ne peut plus voter pour cette version')).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. + // Vera, who votes (Yvv, 2026-11-01: « Retirer ou "contre" »): held back, said so; + // « Contre » and the withdrawal, never « Pour »; 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("Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là."), + ).toBeVisible(); await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); + const choice = fold.getByRole('group', { name: 'Ton vote', exact: true }); + await expect(choice.getByRole('button', { name: 'Contre', exact: true })).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 expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('retenue par une objection'); await fold.scrollIntoViewIfNeeded(); await shots(vera, 'f2-objection-vue-par-qui-vote'); + // « Contre », then the withdrawal: the version can only lose votes; « Pour » never comes back. + await choice.getByRole('button', { name: 'Contre', exact: true }).click(); + await expect(vera.getByText('Vote enregistré.')).toBeVisible(); + await expect(fold.getByText('Ton vote : contre')).toBeVisible(); + await expect(fold.locator('.tally')).toHaveText('0 pour · 1 contre'); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + await choice.getByRole('button', { name: 'Retirer mon vote' }).click(); + await expect(vera.getByText('Vote retiré.')).toBeVisible(); + await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); + await expect(fold.locator('.tally')).toHaveText('0 pour · 0 contre'); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + + // In the basket: « Contre » or the withdrawal, never « Pour »; « Tout pour » skips it + // and says so, « Tout contre » takes it. + await vera.goto(`${TEST_DOC}?onglet=panier`); + const basket = vera.locator('aside.panel'); + await expect(basket.getByRole('heading', { name: 'Panier de vote' })).toBeVisible(); + const pick = basket.getByRole('group', { name: `Ton choix pour « ${title} »` }); + await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toBeVisible(); + await expect(pick.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + const heldRow = basket.locator('.version.held').filter({ has: vera.getByRole('group', { name: `Ton choix pour « ${title} »` }) }); + await expect(heldRow).toContainText('une objection la retient : contre ou retrait, pas de « pour »'); + const whole = basket.getByRole('group', { name: 'Tout le document', exact: true }); + await whole.getByRole('button', { name: 'Tout pour' }).click(); + await expect(basket.getByText(/^Tu as tout mis pour, sauf \d+ versions? qu'une objection retient : sur elles?, tu peux voter contre ou retirer ton vote\.$/)).toBeVisible(); + await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'false'); + await shots(vera, 'g1-panier-tout-pour-la-saute'); + await whole.getByRole('button', { name: 'Tout contre' }).click(); + await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await heldRow.scrollIntoViewIfNeeded(); + await shots(vera, 'g2-panier-tout-contre-la-prend'); + await whole.getByRole('button', { name: 'Vider' }).click(); + await expect(basket.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); // Aude: a message, and the way to answer — modify the article repeated. await aude.goto(TEST_DOC); @@ -99,8 +137,8 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo 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(); + // Vera: it is voted on again, « Pour » too. + await vera.goto(`${TEST_DOC}?vote=${id}`); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); }); diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts index e1b1d8d..6761881 100644 --- a/web/src/lib/basket.ts +++ b/web/src/lib/basket.ts @@ -4,6 +4,10 @@ // whole document — but never an article whose versions compete: there, one // chooses one's version. Then one gesture sends it all: one write on the // server, or one transaction on the Ğ1 chain. +// +// A version held back by an objection (ADR 0010) takes against or a withdrawal, +// never a « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour » +// skips it, « tout contre » takes it. import type { Choice, Modules, Proposal, Unit } from './types'; @@ -17,7 +21,10 @@ export interface Slot { versions: Proposal[]; /** Open changes of its inertia: another question, never filled by a grouped gesture. */ inertia: Proposal[]; - /** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */ + /** + * Versions held back by an objection (ADR 0010): apart from those in competition, + * against or a withdrawal only until it is lifted — never a « for ». + */ held: Proposal[]; } @@ -68,24 +75,55 @@ export function sections(units: Unit[], modules: Modules = { title: null, orders return out; } -/** « Tout pour » / « tout contre » over some articles: only those with a single version. */ +/** + * « Tout pour » / « tout contre » over some articles: only those with a single version. + * A version held back by an objection: « tout contre » takes it, « tout pour » skips it. + */ export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks { const next = { ...picks }; for (const s of slots) { const [only] = s.versions; if (only && s.versions.length === 1) next[only.id] = choice; + if (choice === 'against') for (const p of s.held) next[p.id] = choice; } return next; } +/** The versions held back by an objection among these articles: those « tout pour » skips. */ +export function heldIn(slots: Slot[]): Proposal[] { + return slots.flatMap((s) => s.held); +} + /** Articles of several versions where no version is chosen yet: the interface shows them. */ export function waiting(slots: Slot[], picks: Picks): Slot[] { return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks)); } +/** + * The versions one may vote now, and how: `free`, any choice; `held`, held back by an + * objection — against or a withdrawal only. + */ +export type Votable = Map; + /** The versions one may vote now: those a choice of the basket is sent for. */ -export function votableIds(all: Section[]): Set { - return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); +export function votableIds(all: Section[]): Votable { + const out: Votable = new Map(); + for (const x of all.flatMap((s) => s.slots)) { + for (const p of [...x.versions, ...x.inertia]) out.set(p.id, 'free'); + for (const p of x.held) out.set(p.id, 'held'); + } + return out; +} + +/** May this choice be sent for this version now? Never a « for » on a held one. */ +export function admits(known: Votable, id: string, choice: Choice): boolean { + const way = known.get(id); + return way === 'free' || (way === 'held' && choice !== 'for'); +} + +/** The choices of the basket that may be sent now: a version gone, or a « for » on a held one, is left out. */ +export function kept(picks: Picks, known: Votable): Picks { + return Object.fromEntries(Object.entries(picks).filter(([id, c]) => admits(known, id, c))); } /** Empties the basket of these articles only. */ @@ -94,11 +132,9 @@ export function clearScope(slots: Slot[], picks: Picks): Picks { return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id))); } -/** What is sent: one line per version still in the document; withdrawing is a null choice. */ -export function lines(picks: Picks, known: Set): { proposalId: string; choice: 'for' | 'against' | null }[] { - return Object.entries(picks) - .filter(([id]) => known.has(id)) - .map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c })); +/** What is sent: one line per choice that may be sent now (`kept`); withdrawing is a null choice. */ +export function lines(picks: Picks, known: Votable): { proposalId: string; choice: 'for' | 'against' | null }[] { + return Object.entries(kept(picks, known)).map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c })); } export function counts(picks: Picks): { for: number; against: number; none: number; total: number } { diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index cec0b73..e15affd 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -3,9 +3,11 @@ // 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 // document — never for an article whose versions compete —, then one gesture. - // A version held back by an objection is shown, with nothing to choose. + // A version held back by an objection takes against or a withdrawal, never a + // « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour » skips it + // and says so, « tout contre » takes it. import { onMount } from 'svelte'; - import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; + import { clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t } from '$lib/i18n/index.svelte'; import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels'; @@ -38,12 +40,14 @@ const slots = $derived(all.flatMap((s) => s.slots)); const known = $derived(votableIds(all)); 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(kept(basket.picks, known))); const left = $derived(waiting(slots, basket.picks)); const open = $derived(!!access?.open); let busy = $state(false); let error = $state(''); let list: HTMLElement | undefined = $state(); + /** After « tout pour »: where, and how many versions held back by an objection it left aside. */ + let skipped = $state<{ key: string | null; n: number } | null>(null); onMount(() => { if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' }); @@ -69,6 +73,14 @@ const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? [])); + /** « Tout pour » / « tout contre » / « vider » over a scope; « tout pour » says what it skipped. */ + function gesture(key: string | null, what: 'for' | 'against' | 'clear') { + const within = scope(key); + basket.set(what === 'clear' ? clearScope(within, basket.picks) : fill(within, what, basket.picks)); + const n = what === 'for' ? heldIn(within).length : 0; + skipped = n ? { key, n } : null; + } + async function send() { error = ''; if (!toSend.length) return; @@ -91,12 +103,18 @@ {#snippet bulk(key: string | null, label: string)}
- - - + + +
{/snippet} +{#snippet skippedNote(key: string | null)} + {#if skipped && skipped.key === key} +

{i18n.plural('basket.held.skipped', skipped.n)}

+ {/if} +{/snippet} +

{t('basket.title')}

{t('basket.intro')}

@@ -108,6 +126,7 @@ {t('basket.doc')} {@render bulk('*', t('basket.doc'))} + {@render skippedNote('*')} {#if left.length}

{i18n.plural('basket.waiting', left.length)}

{/if} @@ -121,6 +140,7 @@

{label}

{@render bulk(s.key, label)} + {@render skippedNote(s.key)} {/if}
    {#each s.slots as slot (slot.unit.id)} @@ -152,9 +172,20 @@
    {labelOf(v, slot)} - {t('tally.short', { for: v.tally.for, against: v.tally.against })} + + {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} + + {t('basket.held')} - {t('basket.held')} + 1 ? `${title} — ${labelOf(v, slot)}` : title })} + disabled={!open} + onpick={(c) => basket.pick(v.id, c)} + />
    {/each} @@ -297,6 +328,8 @@ color: var(--ink-soft); } .held-note { + justify-self: start; + margin-top: 0.2rem; padding: 0.25rem 0.7rem; border-radius: var(--r-pill); background: var(--ochre-wash); diff --git a/web/src/lib/components/ChoicePicker.svelte b/web/src/lib/components/ChoicePicker.svelte index f6d8971..0c36b64 100644 --- a/web/src/lib/components/ChoicePicker.svelte +++ b/web/src/lib/components/ChoicePicker.svelte @@ -1,6 +1,7 @@ - + {#if !held} + + {/if} {#if voted || value === 'none'} diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index a52586b..92bbd7d 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -4,7 +4,8 @@ // 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 // stands, then the inertia and the formula. A version held back by an - // objection says so, and tells each one what is theirs to do (ADR 0010). The + // objection says so, and tells each one what is theirs to do (ADR 0010); it + // offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The // rest of a version (typos, translations, objections, history) lives in the // panel, tab « Détails ». import { onMount } from 'svelte'; @@ -200,13 +201,14 @@ {#if !live}

    {t('vote.closed')}

    - {:else if p.blocked} - {:else if access && !access.open}

    {t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

    {:else if access}
    - + + {#if !p.blocked} + + {/if} {#if p.myVote}{/if}
    diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f55fc51..53f7543 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -142,7 +142,7 @@ export const en: Messages = { 'deep.addition': 'new article', 'deep.removal': 'removal of “{title}”', 'deep.inertia': 'inertia to {level}', - 'deep.held': 'not open to vote: objection', + 'deep.held': 'held back by an objection', 'deep.mine': 'your proposal', 'deep.stale': 'needs reconfirming', 'deep.wellrated': 'well-rated', @@ -231,7 +231,7 @@ 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.reconfirm': 'Reconfirm: {choice}', 'vote.private': 'Nobody sees your vote: only the totals are visible.', - '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': 'An objection is holding this version back: until it is lifted, nobody can vote for it any more. You can vote against it or take your vote back; once lifted, it enters into force only with the votes still there.', '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.', @@ -289,7 +289,9 @@ export const en: Messages = { 'basket.sent.other': '{n} votes saved.', 'basket.section': 'Vote on this section', 'basket.mine': 'your vote: {choice}', - 'basket.held': 'not open to vote: an objection holds it back', + 'basket.held': 'an objection holds it back: against or withdraw, no “for”', + 'basket.held.skipped.one': 'You put everything for, except {n} version an objection holds back: on it, you can vote against or take your vote back.', + 'basket.held.skipped.other': 'You put everything for, except {n} versions an objection holds back: on them, you can vote against or take your vote back.', '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.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', @@ -443,12 +445,12 @@ export const en: Messages = { 'objection.reason.redit': 'How these two texts say the same thing', 'objection.reason.contredit': 'How these two texts contradict each other', '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.sent': 'Objection saved: until it is lifted, nobody can vote for the later of the two versions any more.', + 'objection.note': 'An objection holds back the later of the two versions until it is lifted — never a text in force: nobody can vote for it any more, anyone can vote against it or take their vote back. 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.held.here': 'nobody can vote for this version any more', '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', @@ -477,9 +479,9 @@ export const en: Messages = { '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_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.', + 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.', + 'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. 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.', @@ -638,7 +640,7 @@ export const en: Messages = { 'error.not_a_title': 'This module is not the title of the document.', 'error.same_title': 'This is already the title in force.', '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.objection': 'An objection is holding this version back: until it is lifted, you can vote against it or take your vote back, not vote for it.', '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.', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 8736461..b5a606f 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -142,7 +142,7 @@ export const es: Messages = { 'deep.addition': 'artículo nuevo', 'deep.removal': 'retirada de «{title}»', 'deep.inertia': 'inercia a {level}', - 'deep.held': 'no votable: objeción', + 'deep.held': 'retenida por una objeción', 'deep.mine': 'tu propuesta', 'deep.stale': 'por reconfirmar', 'deep.wellrated': 'bien valorada', @@ -231,7 +231,7 @@ 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.reconfirm': 'Reconfirmar: {choice}', 'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.', - 'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.', + 'vote.held': 'Una objeción retiene esta versión: hasta que se levante, nadie puede votar a favor. Puedes votar en contra o retirar tu voto; al levantarse, solo entra en vigor con los votos que sigan ahí.', '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.', @@ -289,7 +289,9 @@ export const es: Messages = { 'basket.sent.other': '{n} votos registrados.', 'basket.section': 'Votar esta sección', 'basket.mine': 'tu voto: {choice}', - 'basket.held': 'no votable: una objeción la retiene', + 'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»', + 'basket.held.skipped.one': 'Lo pusiste todo a favor, salvo {n} versión que una objeción retiene: en ella, puedes votar en contra o retirar tu voto.', + 'basket.held.skipped.other': 'Lo pusiste todo a favor, salvo {n} versiones que una objeción retiene: en ellas, puedes votar en contra o retirar tu voto.', '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.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', @@ -443,12 +445,12 @@ export const es: Messages = { 'objection.reason.redit': 'En qué dicen lo mismo estos dos textos', 'objection.reason.contredit': 'En qué se contradicen estos dos textos', '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.sent': 'Objeción registrada: hasta que se levante, nadie puede votar a favor de la más reciente de las dos versiones.', + 'objection.note': 'Una objeción retiene la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor: nadie puede votar a favor, cada cual puede votar en contra o retirar su voto. 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.held.here': 'nadie puede votar a favor de esta versión', '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ó', @@ -477,9 +479,9 @@ export const es: Messages = { '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_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.', + 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.', + 'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». 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.', @@ -638,7 +640,7 @@ export const es: Messages = { '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.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.objection': 'Una objeción retiene esta versión: hasta que se levante, puedes votar en contra o retirar tu voto, no votar a favor.', '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á.', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 96b8fa5..ed9159e 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -145,7 +145,7 @@ export const fr = { 'deep.addition': 'nouvel article', 'deep.removal': 'retrait de « {title} »', 'deep.inertia': 'inertie vers {level}', - 'deep.held': 'non votable : objection', + 'deep.held': 'retenue par une objection', 'deep.mine': 'ta proposition', 'deep.stale': 'à reconfirmer', 'deep.wellrated': 'bien cotée', @@ -234,7 +234,7 @@ 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.reconfirm': 'Reconfirmer : {choice}', 'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.', - 'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.", + 'vote.held': "Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là.", '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.", @@ -292,7 +292,9 @@ export const fr = { 'basket.sent.other': '{n} votes enregistrés.', 'basket.section': 'Voter cette section', 'basket.mine': 'ton vote : {choice}', - 'basket.held': 'non votable : une objection la retient', + 'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »', + 'basket.held.skipped.one': "Tu as tout mis pour, sauf {n} version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote.", + 'basket.held.skipped.other': "Tu as tout mis pour, sauf {n} versions qu'une objection retient : sur elles, tu peux voter contre ou retirer ton vote.", '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.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', @@ -446,12 +448,12 @@ export const fr = { 'objection.reason.redit': 'En quoi ces deux textes disent la même chose', 'objection.reason.contredit': 'En quoi ces deux textes se contredisent', '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.sent': "Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.", + 'objection.note': "Une objection retient la dernière arrivée des deux versions jusqu'à sa levée — jamais un texte en vigueur : personne ne peut plus voter pour elle, chacun peut voter contre ou retirer son vote. 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.held.here': 'personne ne peut plus voter pour cette version', '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é', @@ -480,9 +482,9 @@ export const fr = { '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_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.", + 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.", + 'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». 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.', @@ -641,7 +643,7 @@ export const fr = { '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.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.objection': "Une objection retient cette version : jusqu'à sa levée, tu peux voter contre ou retirer ton vote, pas voter pour.", '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.', diff --git a/web/src/lib/picks.svelte.ts b/web/src/lib/picks.svelte.ts index 6d2ea27..c00e512 100644 --- a/web/src/lib/picks.svelte.ts +++ b/web/src/lib/picks.svelte.ts @@ -1,15 +1,18 @@ // The basket of the reader: the choices not sent yet, kept for the tab (a reload // or a look at another article does not lose them), one basket per document. -import type { Picks } from './basket'; +import { kept, type Picks, type Votable } from './basket'; import type { Choice } from './types'; export class BasketState { picks = $state({}); #key = ''; - /** Takes back the basket of this document, keeping only the versions still open to vote. */ - load(docId: string, known: Set) { + /** + * Takes back the basket of this document, keeping only the choices still open: a + * version gone leaves it, and so does a « for » on a version an objection holds back. + */ + load(docId: string, known: Votable) { const key = `ld:basket:${docId}`; if (key === this.#key) { this.#keep(known); @@ -27,9 +30,9 @@ export class BasketState { this.#keep(known); } - #keep(known: Set) { - const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id))); - if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept); + #keep(known: Votable) { + const still = kept(this.picks, known); + if (Object.keys(still).length !== Object.keys(this.picks).length) this.set(still); } set(picks: Picks) { diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 620ef53..9481bc9 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -29,7 +29,7 @@ void doc.load(group, slug); }); // The basket of this document, kept for the tab; versions no longer open to vote - // (or held back by an objection) leave it. + // leave it, and so does a « for » on a version held back by an objection. $effect(() => { const v = doc.view; if (!v) return; diff --git a/web/tests/unit/basket.test.ts b/web/tests/unit/basket.test.ts index c2bec14..f1abf4a 100644 --- a/web/tests/unit/basket.test.ts +++ b/web/tests/unit/basket.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket'; -import type { Proposal, Unit } from '../../src/lib/types'; +import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket'; +import type { Choice, Proposal, Unit } from '../../src/lib/types'; function prop(id: string, unitId: string, over: Partial = {}): Proposal { return { @@ -84,7 +84,7 @@ describe('the basket of a vote', () => { }); it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => { - const known = new Set(['i1', 'e2', 'e4']); + const known = votableIds(sections([I1, E2, E4])); expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([ { proposalId: 'i1', choice: 'for' }, { proposalId: 'e2', choice: null }, @@ -93,8 +93,9 @@ 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 }); }); - 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. + it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => { + // Yvv, 2026-11-01: « Retirer ou "contre" ». 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 }), ]); @@ -103,15 +104,28 @@ describe('the basket of a vote', () => { 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. + // A held article stays in sight, apart from the versions in competition. 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' }); + // The held rival does not compete now: the text in force is the only version to choose. 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' }]); + // « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them. + expect(fill(all, 'for', {})).toEqual({ e5: 'for' }); + expect(heldIn(all).map((p) => p.id)).toEqual(['e5r', 'e6']); + expect(fill(all, 'against', {})).toEqual({ e5: 'against', e5r: 'against', e6: 'against' }); + // What may be chosen: anything on a free version; against or withdrawing on a held one. + const known = votableIds(s); + expect(['for', 'against', 'none'].map((c) => admits(known, 'e5', c as Choice))).toEqual([true, true, true]); + expect(['for', 'against', 'none'].map((c) => admits(known, 'e5r', c as Choice))).toEqual([false, true, true]); + expect(admits(known, 'gone', 'against')).toBe(false); + // A « for » kept from before the objection is neither kept nor sent; against and withdrawing are. + expect(kept({ e5: 'for', e5r: 'for', e6: 'against', gone: 'none' }, known)).toEqual({ e5: 'for', e6: 'against' }); + expect(lines({ e5: 'for', e5r: 'for', e6: 'none' }, known)).toEqual([ + { proposalId: 'e5', choice: 'for' }, + { proposalId: 'e6', choice: null }, + ]); + expect(lines({ e5r: 'against' }, known)).toEqual([{ proposalId: 'e5r', choice: 'against' }]); expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); }); });