forked from yvv/decision
Fusion du lot G : sur une version retenue par une objection, « contre » et retrait oui, « pour » non (Yvv, 01/11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) ;
|
- **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
|
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
|
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
|
change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en
|
||||||
texte en vigueur ; ADR 0010) ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
|
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
|
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**
|
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).
|
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
//! is changed by a proposal. The target is fixed when the objection is raised;
|
//! 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
|
//! - the votes already cast on it stay, but it cannot enter into force
|
||||||
//! (`votable` and the adoption: a non-votable version is blocked);
|
//! (`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
|
//! - 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
|
//! to the objection (on the article it repeats, for a « redit »; a reworded
|
||||||
//! version, for a version without author). A substantive revision of **the
|
//! 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
|
//! lift too (never reiterate: the objection is not theirs); withdrawing either
|
||||||
//! version ends it.
|
//! version ends it.
|
||||||
|
|
||||||
|
use crate::ballot::Choice;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
@@ -296,6 +301,16 @@ pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
|
|||||||
in_force || !held.contains(&id)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -357,6 +372,17 @@ mod tests {
|
|||||||
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
|
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]
|
#[test]
|
||||||
fn the_full_cycle_answer_reiterate_answer_lift() {
|
fn the_full_cycle_answer_reiterate_answer_lift() {
|
||||||
let start = Phase::Author { cycle: 1 };
|
let start = Phase::Author { cycle: 1 };
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ pub struct BallotBody {
|
|||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(crate) struct Cast {
|
||||||
pub proposal_id: String,
|
pub proposal_id: String,
|
||||||
pub revision: i64,
|
pub revision: i64,
|
||||||
@@ -105,7 +105,9 @@ fn parse_choice(raw: Option<&str>) -> AppResult<Choice> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The lines of a vote, read against the document: at least one, each version
|
/// 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<Vec<Cast>> {
|
fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
||||||
if lines.is_empty() || lines.len() > MAX_LINES {
|
if lines.is_empty() || lines.len() > MAX_LINES {
|
||||||
return Err(AppError::Invalid("lines"));
|
return Err(AppError::Invalid("lines"));
|
||||||
@@ -120,11 +122,13 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
|||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
return Err(AppError::Conflict("not_votable"));
|
||||||
}
|
}
|
||||||
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010).
|
let choice = parse_choice(l.choice.as_deref())?;
|
||||||
if !st.votable(p) {
|
// 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"));
|
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)
|
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,
|
/// 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<()> {
|
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
|
||||||
let voter = format!("idty:{index}");
|
let voter = format!("idty:{index}");
|
||||||
let position = found.comment.position;
|
let position = found.comment.position;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::participation;
|
|||||||
use crate::util::{self, LangMap};
|
use crate::util::{self, LangMap};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
|
use ld_engine::ballot::Choice;
|
||||||
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
||||||
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
||||||
use ld_engine::threshold::{Evaluation, Tally};
|
use ld_engine::threshold::{Evaluation, Tally};
|
||||||
@@ -265,7 +266,8 @@ pub struct DocState {
|
|||||||
pub module_kinds: HashMap<String, ModuleKind>,
|
pub module_kinds: HashMap<String, ModuleKind>,
|
||||||
pub proposals: Vec<ProposalRow>,
|
pub proposals: Vec<ProposalRow>,
|
||||||
pub tallies: HashMap<String, Tally>,
|
pub tallies: HashMap<String, Tally>,
|
||||||
/// 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<String>,
|
pub blocked: HashSet<String>,
|
||||||
/// Standing objections touching each version, either side.
|
/// Standing objections touching each version, either side.
|
||||||
pub signal_counts: HashMap<String, i64>,
|
pub signal_counts: HashMap<String, i64>,
|
||||||
@@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A standing objection holds back the latest arrival of its pair (never a version
|
// 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 (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
|
||||||
let (electorate, electorate_source) = electorate(state, &group).await?;
|
let (electorate, electorate_source) = electorate(state, &group).await?;
|
||||||
let measure = participation::stored(state, &group.slug).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)
|
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.
|
/// The module a unit is, if it is one.
|
||||||
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
||||||
self.module_kinds.get(unit_id).copied()
|
self.module_kinds.get(unit_id).copied()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
|
//! 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 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
|
//! the pair, never a version in force, takes no new *for* while the objection
|
||||||
//! stands; its author answers (a substantive revision, or a proposal linked to
|
//! 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
|
//! 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
|
//! 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).
|
//! days the objection is lifted (`due`, run by the sweeper and at start).
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ pub async fn decisions(
|
|||||||
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
/// 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
|
/// 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
|
/// 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<Vec<Module>> {
|
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct VersionRow {
|
struct VersionRow {
|
||||||
|
|||||||
@@ -648,19 +648,20 @@ pub async fn vote(
|
|||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
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() {
|
let choice = match b.choice.as_deref() {
|
||||||
Some("for") => Choice::For,
|
Some("for") => Choice::For,
|
||||||
Some("against") => Choice::Against,
|
Some("against") => Choice::Against,
|
||||||
None => Choice::None,
|
None => Choice::None,
|
||||||
Some(_) => return Err(AppError::Invalid("choice")),
|
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 };
|
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
|
||||||
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
|
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
|
||||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||||
|
|||||||
@@ -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");
|
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
|
// After the independent review of 74f96be (lot B2): its probes, kept as tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)["votable"], true, "a version in force is never made non-votable");
|
||||||
assert_eq!(find(&d, &e1_id)["objections"], 1);
|
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;
|
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
|
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
|
||||||
let many = yvv
|
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");
|
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
|
/// 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).
|
/// enters then, not at the next pass of the sweeper (relecture of cd7e683).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
> [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille
|
> [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
|
> 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.
|
> 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 à
|
**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
|
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
|
- 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,
|
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.
|
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é,
|
- Tant que l'objection tient, la version retenue **ne prend plus de « pour »** et **n'entre pas
|
||||||
bulletin de la chaîne : `409 objection`) et **n'entre pas en vigueur**. Les votes déjà faits
|
en vigueur**, quelles que soient ses voix. Yvv, 2026-11-01, à la question « pendant qu'une
|
||||||
restent ; un bulletin scellé avant l'objection et confirmé après compte, sans la faire entrer.
|
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`).
|
- 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é**
|
- **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) ;
|
(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).
|
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
|
- 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.
|
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`.
|
- Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`.
|
||||||
|
|
||||||
| Qui | Quoi | Effet | Échéance |
|
| 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 |
|
| 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 |
|
| 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 » | — |
|
| personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — |
|
||||||
| un administrateur | lever | levée « par un administrateur » | — |
|
| 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.
|
validée.
|
||||||
|
|
||||||
**Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la
|
**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
|
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.
|
jours à paraître, et sur un texte du seed elle attend un administrateur.
|
||||||
|
|||||||
@@ -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
|
### 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
|
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
|
répète) ou elle le **contredit**, motif à l'appui. **Une objection retient la dernière arrivée
|
||||||
deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière
|
des deux versions jusqu'à sa levée** — jamais un texte en vigueur : si la dernière arrivée est
|
||||||
arrivée est en vigueur, c'est l'autre qui est retenue. Les votes déjà faits restent ; la version
|
en vigueur, c'est l'autre qui est retenue. La version retenue n'entre pas en vigueur, quelles que
|
||||||
retenue ne reçoit plus de vote (ni sous l'article, ni dans le panier, ni par la chaîne) et
|
soient ses voix. Pendant l'objection, **on peut retirer son vote ou passer à « contre », jamais
|
||||||
n'entre pas en vigueur. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite,
|
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
|
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
|
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.
|
**3 jours**, l'objection est levée.
|
||||||
|
|
||||||
| Qui | Quoi | Effet | Échéance |
|
| 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 |
|
| 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 |
|
| 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 » | — |
|
| 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 » | — |
|
| un administrateur de trustinWallet | lever | levée « par un administrateur » | — |
|
||||||
|
|||||||
@@ -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 · <section> » (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 |
|
| 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 · <section> » (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 » |
|
| 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. » |
|
| 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 |
|
| 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 » |
|
| 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 » |
|
| 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, 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, 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-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) |
|
||||||
|
|||||||
@@ -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('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.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 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;
|
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 aude = await person(browser, 'Aude');
|
||||||
const oscar = await person(browser, 'Oscar');
|
const oscar = await person(browser, 'Oscar');
|
||||||
const vera = await person(browser, 'Vera');
|
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 ».
|
// Oscar: it repeats « Unicité du compte ».
|
||||||
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
|
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.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 expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
|
||||||
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
|
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}`);
|
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||||
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
|
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();
|
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(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 fold.scrollIntoViewIfNeeded();
|
||||||
await shots(vera, 'f2-objection-vue-par-qui-vote');
|
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.
|
// Aude: a message, and the way to answer — modify the article repeated.
|
||||||
await aude.goto(TEST_DOC);
|
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(answered.locator('.btn')).toHaveCount(0);
|
||||||
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
|
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
|
||||||
|
|
||||||
// Vera: it is voted on again.
|
// Vera: it is voted on again, « Pour » too.
|
||||||
await vera.reload();
|
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||||
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
|
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
+45
-9
@@ -4,6 +4,10 @@
|
|||||||
// whole document — but never an article whose versions compete: there, one
|
// 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
|
// chooses one's version. Then one gesture sends it all: one write on the
|
||||||
// server, or one transaction on the Ğ1 chain.
|
// 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';
|
import type { Choice, Modules, Proposal, Unit } from './types';
|
||||||
|
|
||||||
@@ -17,7 +21,10 @@ export interface Slot {
|
|||||||
versions: Proposal[];
|
versions: Proposal[];
|
||||||
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
||||||
inertia: Proposal[];
|
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[];
|
held: Proposal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,24 +75,55 @@ export function sections(units: Unit[], modules: Modules = { title: null, orders
|
|||||||
return out;
|
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 {
|
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
|
||||||
const next = { ...picks };
|
const next = { ...picks };
|
||||||
for (const s of slots) {
|
for (const s of slots) {
|
||||||
const [only] = s.versions;
|
const [only] = s.versions;
|
||||||
if (only && s.versions.length === 1) next[only.id] = choice;
|
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;
|
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. */
|
/** Articles of several versions where no version is chosen yet: the interface shows them. */
|
||||||
export function waiting(slots: Slot[], picks: Picks): Slot[] {
|
export function waiting(slots: Slot[], picks: Picks): Slot[] {
|
||||||
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
|
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<string, 'free' | 'held'>;
|
||||||
|
|
||||||
/** The versions one may vote now: those a choice of the basket is sent for. */
|
/** The versions one may vote now: those a choice of the basket is sent for. */
|
||||||
export function votableIds(all: Section[]): Set<string> {
|
export function votableIds(all: Section[]): Votable {
|
||||||
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
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. */
|
/** 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)));
|
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. */
|
/** What is sent: one line per choice that may be sent now (`kept`); withdrawing is a null choice. */
|
||||||
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] {
|
export function lines(picks: Picks, known: Votable): { proposalId: string; choice: 'for' | 'against' | null }[] {
|
||||||
return Object.entries(picks)
|
return Object.entries(kept(picks, known)).map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
|
||||||
.filter(([id]) => known.has(id))
|
|
||||||
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
|
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
// section entière, ou un article avec son titre, ou séparément »): choices
|
// 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
|
// article by article, « tout pour » / « tout contre » for a section or the whole
|
||||||
// document — never for an article whose versions compete —, then one gesture.
|
// 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 { 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 type { DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
||||||
@@ -38,12 +40,14 @@
|
|||||||
const slots = $derived(all.flatMap((s) => s.slots));
|
const slots = $derived(all.flatMap((s) => s.slots));
|
||||||
const known = $derived(votableIds(all));
|
const known = $derived(votableIds(all));
|
||||||
const toSend = $derived(lines(basket.picks, known));
|
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 left = $derived(waiting(slots, basket.picks));
|
||||||
const open = $derived(!!access?.open);
|
const open = $derived(!!access?.open);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let list: HTMLElement | undefined = $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(() => {
|
onMount(() => {
|
||||||
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
|
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 ?? []));
|
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() {
|
async function send() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!toSend.length) return;
|
if (!toSend.length) return;
|
||||||
@@ -91,12 +103,18 @@
|
|||||||
|
|
||||||
{#snippet bulk(key: string | null, label: string)}
|
{#snippet bulk(key: string | null, label: string)}
|
||||||
<div class="bulk" role="group" aria-label={label}>
|
<div class="bulk" role="group" aria-label={label}>
|
||||||
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button>
|
<button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'for')}>{t('basket.for')}</button>
|
||||||
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button>
|
<button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'against')}>{t('basket.against')}</button>
|
||||||
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button>
|
<button class="btn quiet small" type="button" onclick={() => gesture(key, 'clear')}>{t('basket.clear')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet skippedNote(key: string | null)}
|
||||||
|
{#if skipped && skipped.key === key}
|
||||||
|
<p class="note" role="status">{i18n.plural('basket.held.skipped', skipped.n)}</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<section class="basket" aria-labelledby="basket-title">
|
<section class="basket" aria-labelledby="basket-title">
|
||||||
<h3 id="basket-title">{t('basket.title')}</h3>
|
<h3 id="basket-title">{t('basket.title')}</h3>
|
||||||
<p class="muted">{t('basket.intro')}</p>
|
<p class="muted">{t('basket.intro')}</p>
|
||||||
@@ -108,6 +126,7 @@
|
|||||||
<span class="eyebrow">{t('basket.doc')}</span>
|
<span class="eyebrow">{t('basket.doc')}</span>
|
||||||
{@render bulk('*', t('basket.doc'))}
|
{@render bulk('*', t('basket.doc'))}
|
||||||
</div>
|
</div>
|
||||||
|
{@render skippedNote('*')}
|
||||||
{#if left.length}
|
{#if left.length}
|
||||||
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
|
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -121,6 +140,7 @@
|
|||||||
<h4 class="eyebrow">{label}</h4>
|
<h4 class="eyebrow">{label}</h4>
|
||||||
{@render bulk(s.key, label)}
|
{@render bulk(s.key, label)}
|
||||||
</header>
|
</header>
|
||||||
|
{@render skippedNote(s.key)}
|
||||||
{/if}
|
{/if}
|
||||||
<ul>
|
<ul>
|
||||||
{#each s.slots as slot (slot.unit.id)}
|
{#each s.slots as slot (slot.unit.id)}
|
||||||
@@ -152,9 +172,20 @@
|
|||||||
<div class="version held">
|
<div class="version held">
|
||||||
<span class="about">
|
<span class="about">
|
||||||
<span class="label">{labelOf(v, slot)}</span>
|
<span class="label">{labelOf(v, slot)}</span>
|
||||||
<span class="faint small">{t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
|
<span class="faint small">
|
||||||
|
{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}
|
||||||
|
</span>
|
||||||
|
<span class="held-note">{t('basket.held')}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="held-note">{t('basket.held')}</span>
|
<ChoicePicker
|
||||||
|
held
|
||||||
|
value={basket.picks[v.id]}
|
||||||
|
voted={!!v.myVote}
|
||||||
|
label={t('basket.pick', { title: slot.versions.length || slot.held.length > 1 ? `${title} — ${labelOf(v, slot)}` : title })}
|
||||||
|
disabled={!open}
|
||||||
|
onpick={(c) => basket.pick(v.id, c)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</li>
|
</li>
|
||||||
@@ -297,6 +328,8 @@
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.held-note {
|
.held-note {
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: 0.2rem;
|
||||||
padding: 0.25rem 0.7rem;
|
padding: 0.25rem 0.7rem;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
background: var(--ochre-wash);
|
background: var(--ochre-wash);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// One choice of the basket: for, against, or withdraw one's vote (when there is
|
// One choice of the basket: for, against, or withdraw one's vote (when there is
|
||||||
// one). Pressing the chosen one again takes the version out of the basket.
|
// one). Pressing the chosen one again takes the version out of the basket. A
|
||||||
|
// version held back by an objection offers no « for » (Yvv, 2026-11-01).
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import type { Choice } from '$lib/types';
|
import type { Choice } from '$lib/types';
|
||||||
|
|
||||||
@@ -8,13 +9,24 @@
|
|||||||
value,
|
value,
|
||||||
voted,
|
voted,
|
||||||
label,
|
label,
|
||||||
|
held = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onpick,
|
onpick,
|
||||||
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props();
|
}: {
|
||||||
|
value: Choice | undefined;
|
||||||
|
voted: boolean;
|
||||||
|
label: string;
|
||||||
|
/** Held back by an objection: against or a withdrawal only. */
|
||||||
|
held?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onpick: (c: Choice) => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="picker" role="group" aria-label={label}>
|
<span class="picker" role="group" aria-label={label}>
|
||||||
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
|
{#if !held}
|
||||||
|
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
|
||||||
|
{/if}
|
||||||
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
|
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
|
||||||
{#if voted || value === 'none'}
|
{#if voted || value === 'none'}
|
||||||
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
|
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
|
// 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
|
// 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
|
// 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
|
// rest of a version (typos, translations, objections, history) lives in the
|
||||||
// panel, tab « Détails ».
|
// panel, tab « Détails ».
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -200,13 +201,14 @@
|
|||||||
|
|
||||||
{#if !live}
|
{#if !live}
|
||||||
<p class="muted">{t('vote.closed')}</p>
|
<p class="muted">{t('vote.closed')}</p>
|
||||||
{:else if p.blocked}
|
|
||||||
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
|
|
||||||
{:else if access && !access.open}
|
{:else if access && !access.open}
|
||||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||||
{:else if access}
|
{:else if access}
|
||||||
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
||||||
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
|
<!-- Held back by an objection: against or a withdrawal, never a « for » (Yvv, 2026-11-01). -->
|
||||||
|
{#if !p.blocked}
|
||||||
|
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
|
||||||
|
{/if}
|
||||||
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
|
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
|
||||||
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
|
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-10
@@ -142,7 +142,7 @@ export const en: Messages = {
|
|||||||
'deep.addition': 'new article',
|
'deep.addition': 'new article',
|
||||||
'deep.removal': 'removal of “{title}”',
|
'deep.removal': 'removal of “{title}”',
|
||||||
'deep.inertia': 'inertia to {level}',
|
'deep.inertia': 'inertia to {level}',
|
||||||
'deep.held': 'not open to vote: objection',
|
'deep.held': 'held back by an objection',
|
||||||
'deep.mine': 'your proposal',
|
'deep.mine': 'your proposal',
|
||||||
'deep.stale': 'needs reconfirming',
|
'deep.stale': 'needs reconfirming',
|
||||||
'deep.wellrated': 'well-rated',
|
'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.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
||||||
'vote.reconfirm': 'Reconfirm: {choice}',
|
'vote.reconfirm': 'Reconfirm: {choice}',
|
||||||
'vote.private': 'Nobody sees your vote: only the totals are visible.',
|
'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.redit': 'It repeats “{other}”.',
|
||||||
'vote.held.contredit': 'It contradicts “{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.',
|
'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.sent.other': '{n} votes saved.',
|
||||||
'basket.section': 'Vote on this section',
|
'basket.section': 'Vote on this section',
|
||||||
'basket.mine': 'your vote: {choice}',
|
'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.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.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.',
|
'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.redit': 'How these two texts say the same thing',
|
||||||
'objection.reason.contredit': 'How these two texts contradict each other',
|
'objection.reason.contredit': 'How these two texts contradict each other',
|
||||||
'objection.send': 'Send the objection',
|
'objection.send': 'Send the objection',
|
||||||
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until 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 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.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.redit': '“{held}” repeats “{other}”',
|
||||||
'objection.says.contredit': '“{held}” contradicts “{other}”',
|
'objection.says.contredit': '“{held}” contradicts “{other}”',
|
||||||
'objection.by': 'objection by {name}',
|
'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.author': "waiting for the author's answer",
|
||||||
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
|
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
|
||||||
'objection.answer.revision': 'answer: the held text was reworded',
|
'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.open': 'See it in the text',
|
||||||
'messages.topic.objection': 'Objection',
|
'messages.topic.objection': 'Objection',
|
||||||
'messages.topic.typo': 'Typo',
|
'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.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}”. It cannot be voted on until the objection is lifted. To answer, reword it.',
|
'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: “{held}” still cannot be voted on. Answer again.',
|
'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.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.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.',
|
'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.not_a_title': 'This module is not the title of the document.',
|
||||||
'error.same_title': 'This is already the title in force.',
|
'error.same_title': 'This is already the title in force.',
|
||||||
'error.section': 'This section does not exist in this document.',
|
'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.kind': 'Say whether this text repeats the other or contradicts it.',
|
||||||
'error.other': 'Choose another article, in force or proposed.',
|
'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.',
|
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
|
||||||
|
|||||||
+12
-10
@@ -142,7 +142,7 @@ export const es: Messages = {
|
|||||||
'deep.addition': 'artículo nuevo',
|
'deep.addition': 'artículo nuevo',
|
||||||
'deep.removal': 'retirada de «{title}»',
|
'deep.removal': 'retirada de «{title}»',
|
||||||
'deep.inertia': 'inercia a {level}',
|
'deep.inertia': 'inercia a {level}',
|
||||||
'deep.held': 'no votable: objeción',
|
'deep.held': 'retenida por una objeción',
|
||||||
'deep.mine': 'tu propuesta',
|
'deep.mine': 'tu propuesta',
|
||||||
'deep.stale': 'por reconfirmar',
|
'deep.stale': 'por reconfirmar',
|
||||||
'deep.wellrated': 'bien valorada',
|
'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.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
||||||
'vote.reconfirm': 'Reconfirmar: {choice}',
|
'vote.reconfirm': 'Reconfirmar: {choice}',
|
||||||
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
|
'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.redit': 'Repite «{other}».',
|
||||||
'vote.held.contredit': 'Contradice «{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.',
|
'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.sent.other': '{n} votos registrados.',
|
||||||
'basket.section': 'Votar esta sección',
|
'basket.section': 'Votar esta sección',
|
||||||
'basket.mine': 'tu voto: {choice}',
|
'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.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.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.',
|
'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.redit': 'En qué dicen lo mismo estos dos textos',
|
||||||
'objection.reason.contredit': 'En qué se contradicen estos dos textos',
|
'objection.reason.contredit': 'En qué se contradicen estos dos textos',
|
||||||
'objection.send': 'Enviar la objeción',
|
'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.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 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.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.redit': '«{held}» repite «{other}»',
|
||||||
'objection.says.contredit': '«{held}» contradice «{other}»',
|
'objection.says.contredit': '«{held}» contradice «{other}»',
|
||||||
'objection.by': 'objeción de {name}',
|
'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.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.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
|
||||||
'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
|
'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
|
||||||
@@ -477,9 +479,9 @@ export const es: Messages = {
|
|||||||
'messages.open': 'Verlo en el texto',
|
'messages.open': 'Verlo en el texto',
|
||||||
'messages.topic.objection': 'Objeción',
|
'messages.topic.objection': 'Objeción',
|
||||||
'messages.topic.typo': 'Errata',
|
'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.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}». No se vota hasta que se levante. Para responder, reformúlala.',
|
'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: «{held}» sigue sin votarse. Responde de nuevo.',
|
'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.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.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.',
|
'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.not_a_title': 'Este módulo no es el título del documento.',
|
||||||
'error.same_title': 'Ya es el título en vigor.',
|
'error.same_title': 'Ya es el título en vigor.',
|
||||||
'error.section': 'Esta sección no existe en este documento.',
|
'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.kind': 'Di si este texto repite el otro o lo contradice.',
|
||||||
'error.other': 'Elige otro artículo, en vigor o propuesto.',
|
'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á.',
|
'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á.',
|
||||||
|
|||||||
+12
-10
@@ -145,7 +145,7 @@ export const fr = {
|
|||||||
'deep.addition': 'nouvel article',
|
'deep.addition': 'nouvel article',
|
||||||
'deep.removal': 'retrait de « {title} »',
|
'deep.removal': 'retrait de « {title} »',
|
||||||
'deep.inertia': 'inertie vers {level}',
|
'deep.inertia': 'inertie vers {level}',
|
||||||
'deep.held': 'non votable : objection',
|
'deep.held': 'retenue par une objection',
|
||||||
'deep.mine': 'ta proposition',
|
'deep.mine': 'ta proposition',
|
||||||
'deep.stale': 'à reconfirmer',
|
'deep.stale': 'à reconfirmer',
|
||||||
'deep.wellrated': 'bien cotée',
|
'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.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.reconfirm': 'Reconfirmer : {choice}',
|
||||||
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
|
'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.redit': 'Elle redit « {other} ».',
|
||||||
'vote.held.contredit': 'Elle contredit « {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.",
|
'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.sent.other': '{n} votes enregistrés.',
|
||||||
'basket.section': 'Voter cette section',
|
'basket.section': 'Voter cette section',
|
||||||
'basket.mine': 'ton vote : {choice}',
|
'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.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.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é.',
|
'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.redit': 'En quoi ces deux textes disent la même chose',
|
||||||
'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
|
'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
|
||||||
'objection.send': "Envoyer l'objection",
|
'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.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 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.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.redit': '« {held} » redit « {other} »',
|
||||||
'objection.says.contredit': '« {held} » contredit « {other} »',
|
'objection.says.contredit': '« {held} » contredit « {other} »',
|
||||||
'objection.by': 'objection de {name}',
|
'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.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.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é',
|
'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.open': 'Voir dans le texte',
|
||||||
'messages.topic.objection': 'Objection',
|
'messages.topic.objection': 'Objection',
|
||||||
'messages.topic.typo': 'Coquille',
|
'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.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} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.",
|
'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 : « {held} » ne se vote toujours pas. Réponds à nouveau.',
|
'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.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.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.',
|
'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.not_a_title': "Ce module n'est pas le titre du document.",
|
||||||
'error.same_title': "C'est déjà le titre en vigueur.",
|
'error.same_title': "C'est déjà le titre en vigueur.",
|
||||||
'error.section': "Cette section n'existe pas dans ce document.",
|
'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.kind': "Dis si ce texte redit l'autre ou le contredit.",
|
||||||
'error.other': 'Choisis un autre article, en vigueur ou proposé.',
|
'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.',
|
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// The basket of the reader: the choices not sent yet, kept for the tab (a reload
|
// 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.
|
// 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';
|
import type { Choice } from './types';
|
||||||
|
|
||||||
export class BasketState {
|
export class BasketState {
|
||||||
picks = $state<Picks>({});
|
picks = $state<Picks>({});
|
||||||
#key = '';
|
#key = '';
|
||||||
|
|
||||||
/** Takes back the basket of this document, keeping only the versions still open to vote. */
|
/**
|
||||||
load(docId: string, known: Set<string>) {
|
* 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}`;
|
const key = `ld:basket:${docId}`;
|
||||||
if (key === this.#key) {
|
if (key === this.#key) {
|
||||||
this.#keep(known);
|
this.#keep(known);
|
||||||
@@ -27,9 +30,9 @@ export class BasketState {
|
|||||||
this.#keep(known);
|
this.#keep(known);
|
||||||
}
|
}
|
||||||
|
|
||||||
#keep(known: Set<string>) {
|
#keep(known: Votable) {
|
||||||
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id)));
|
const still = kept(this.picks, known);
|
||||||
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept);
|
if (Object.keys(still).length !== Object.keys(this.picks).length) this.set(still);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(picks: Picks) {
|
set(picks: Picks) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
void doc.load(group, slug);
|
void doc.load(group, slug);
|
||||||
});
|
});
|
||||||
// The basket of this document, kept for the tab; versions no longer open to vote
|
// 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(() => {
|
$effect(() => {
|
||||||
const v = doc.view;
|
const v = doc.view;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket';
|
import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket';
|
||||||
import type { Proposal, Unit } from '../../src/lib/types';
|
import type { Choice, Proposal, Unit } from '../../src/lib/types';
|
||||||
|
|
||||||
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||||
return {
|
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', () => {
|
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([
|
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
|
||||||
{ proposalId: 'i1', choice: 'for' },
|
{ proposalId: 'i1', choice: 'for' },
|
||||||
{ proposalId: 'e2', choice: null },
|
{ 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 });
|
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', () => {
|
it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => {
|
||||||
// E5 has the text in force and a rival held back; E6 is a new article, held back.
|
// 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' }), [
|
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
|
||||||
prop('e5r', 'E5', { blocked: true, votable: false }),
|
prop('e5r', 'E5', { blocked: true, votable: false }),
|
||||||
]);
|
]);
|
||||||
@@ -103,15 +104,28 @@ describe('the basket of a vote', () => {
|
|||||||
const [slotE5, slotE6] = s[0]?.slots ?? [];
|
const [slotE5, slotE6] = s[0]?.slots ?? [];
|
||||||
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
|
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
|
||||||
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
|
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?.versions).toEqual([]);
|
||||||
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
|
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
|
||||||
const all = s.flatMap((x) => x.slots);
|
const all = s.flatMap((x) => x.slots);
|
||||||
// The held rival does not compete now: the text in force is the only choice.
|
// The held rival does not compete now: the text in force is the only version to choose.
|
||||||
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
|
|
||||||
expect(waiting(all, {})).toEqual([]);
|
expect(waiting(all, {})).toEqual([]);
|
||||||
// A choice kept from before the objection is not sent.
|
// « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them.
|
||||||
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]);
|
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' });
|
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user