Lot T2 : les trois phrases de F1.1, F1.2 et N1.1 écrites justes (la part du cran, la plus haute au quorum, baisse quand la participation monte ; le quorum se fixe tant que l'article ne garde la trace d'aucun vote ; le quorum n'est pas un nombre minimal de votants), dans le seed et par un amendement gardé qui suit celui de M6b
- seed_amend : 2026-11-03-f1-1-phrases, -f1-2-phrases, -n1-1-phrases ; la garde admet la révision écrite par la chaîne des amendements suivis (earlier), dans leur ordre, le dernier étant celui de M6b (F1.1 en révision 2, F1.2 et N1.1 en révision 3) ; les amendements de M6b dépassés ; le commentaire dit les deux votes qui ne laissent pas de trace (vote direct retiré dans le groupe d'essai, votes simulés après « supprime ») - tests : base faite comme la production après M6b (fixture seed-quorum-m6b.json), appliqué une fois, refus votes et ballots, second démarrage sans rejeu, base née le 02/11 ; les phrases et leur preuve par le moteur ; le quorum de nouveau réglable après un vote direct retiré et après « supprime » - moteur : la part exigée au quorum en production (W 5 707) : 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % (la relecture disait 74 au cran 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,15 @@
|
||||
//! the rules of the texts (`docs/chantiers/acte-certification/textes-quorum-seed.md`).
|
||||
//! A guarded seed amendment replaces them in production, on a base made as production
|
||||
//! is after M6, only where nobody touched them.
|
||||
//!
|
||||
//! Lot T2 (2026-11-03, after the review of M6b): three phrases of these texts said more than
|
||||
//! the rules do — F1.1 « presque l'unanimité quand peu de gens votent », F1.2 « ensuite, il
|
||||
//! ne change plus », N1.1 « le groupe demande un quorum de votants ». A second guarded
|
||||
//! amendment, which follows lot T's, writes them true on a base made as production is
|
||||
//! after M6b; the texts of M6b are kept in `tests/fixtures/seed-quorum-m6b.json`.
|
||||
|
||||
use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds};
|
||||
use ld_engine::threshold::{FormulaParams, judged_voters, no_needed, required_ratio, yes_needed};
|
||||
use ld_engine::threshold::{FormulaParams, Tally, judged_voters, no_needed, passes, required_ratio, yes_needed};
|
||||
use ld_server::seed_amend::{self, AMENDMENTS, Version};
|
||||
use ld_server::{AppState, Config, open_db, seed};
|
||||
use serde_json::Value;
|
||||
@@ -50,6 +56,16 @@ fn rules_before() -> Value {
|
||||
read(fixtures().join("seed-regles-avant.json"))
|
||||
}
|
||||
|
||||
/// F1.1, F1.2 and N1.1 as lot T wrote them: production's since M6b (bb4bdf5), unchanged by M7.
|
||||
fn m6b_texts() -> Value {
|
||||
read(fixtures().join("seed-quorum-m6b.json"))
|
||||
}
|
||||
|
||||
/// The seed production runs since M6b: today's, with the texts of lot T.
|
||||
fn m6b_seed() -> Value {
|
||||
with_texts(&today_seed(), &m6b_texts(), &CODES)
|
||||
}
|
||||
|
||||
fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value {
|
||||
seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
@@ -470,6 +486,122 @@ fn every_number_of_the_quorum_texts_is_the_engine_s() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lot T2: the three phrases the review of M6b left inexact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// (article, language, the words of M6b, the words of today): the only change of lot T2.
|
||||
const PHRASES: [(&str, &str, &str, &str); 9] = [
|
||||
(
|
||||
"F1.1",
|
||||
"fr",
|
||||
"parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.",
|
||||
"parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.",
|
||||
),
|
||||
(
|
||||
"F1.1",
|
||||
"en",
|
||||
"among all the voters: near-unanimity when few people vote, the majority M when everyone votes.",
|
||||
"among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.",
|
||||
),
|
||||
(
|
||||
"F1.1",
|
||||
"es",
|
||||
"entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.",
|
||||
"entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.",
|
||||
),
|
||||
(
|
||||
"F1.2",
|
||||
"fr",
|
||||
"peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus.",
|
||||
"peut en fixer un autre tant que l'article ne garde la trace d'aucun vote.",
|
||||
),
|
||||
(
|
||||
"F1.2",
|
||||
"en",
|
||||
"can set another one as long as nobody has voted on the article; after that, it no longer changes.",
|
||||
"can set another one as long as the article keeps no trace of any vote.",
|
||||
),
|
||||
(
|
||||
"F1.2",
|
||||
"es",
|
||||
"puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia.",
|
||||
"puede fijar otro mientras el artículo no guarde rastro de ningún voto.",
|
||||
),
|
||||
(
|
||||
"N1.1",
|
||||
"fr",
|
||||
"À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse,",
|
||||
"À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants, plus elle baisse,",
|
||||
),
|
||||
(
|
||||
"N1.1",
|
||||
"en",
|
||||
"At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets,",
|
||||
"At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets,",
|
||||
),
|
||||
(
|
||||
"N1.1",
|
||||
"es",
|
||||
"En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja,",
|
||||
"En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja,",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn the_three_phrases_of_the_review_of_m6b_are_written_true() {
|
||||
let (m6b, today) = (m6b_seed(), today_seed());
|
||||
for (code, lang, old, new) in PHRASES {
|
||||
assert!(text(&m6b, code, lang).1.contains(old), "{code} {lang}: the words of M6b");
|
||||
let (_, body) = text(&today, code, lang);
|
||||
assert!(!body.contains(old), "{code} {lang}: still the words of M6b\n{body}");
|
||||
assert!(body.contains(new), "{code} {lang}: missing « {new} »\n{body}");
|
||||
}
|
||||
// F1.1 and N1.1, by the engine: the share of a level is that of the quorum while fewer
|
||||
// vote, its highest; beyond, it falls as turnout rises, down to M when everyone votes —
|
||||
// never near-unanimity because few vote. Every level, quorum and electorate of the grid.
|
||||
for (w, median) in [(5707u32, None), (7224, Some(120.0)), (40, Some(9.0)), (2, Some(1.0))] {
|
||||
let p = Participation::of(w, 50.0, median, 9).ratio;
|
||||
let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] };
|
||||
for k in InertiaLevel::ALL {
|
||||
for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) {
|
||||
let params = scale.params(k, q);
|
||||
let share = |t: u32| required_ratio(w, judged_voters(t, q), ¶ms);
|
||||
let top = share(0);
|
||||
let mut last = top;
|
||||
for t in 0..=w {
|
||||
let s = share(t);
|
||||
assert!(s <= last, "W {w} k {} Q {q}: the share rises at {t} voters", k.get());
|
||||
if t <= q {
|
||||
assert_eq!(s, top, "W {w} k {} Q {q}: under the quorum, the share of the quorum", k.get());
|
||||
}
|
||||
last = s;
|
||||
}
|
||||
let m = k.majority_pct() / 100.0;
|
||||
assert!((share(w) - m).abs() < 1e-12, "W {w} k {} Q {q}: M when everyone votes", k.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
// N1.1: the quorum is no minimum of voters. In Monnaie libre (W 5 707 on 02/11, the
|
||||
// reference turnout), at every level, fewer voters than its quorum pass when they all
|
||||
// vote « pour »: 8 of 12, 9 of 12, 39 of 50, 43 of 50, 46 of 50.
|
||||
let w = 5707;
|
||||
let f = figures(&today);
|
||||
let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: f.quorums };
|
||||
let fewer: Vec<(u32, u32)> = InertiaLevel::ALL
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let q = scale.quorum(*k, None);
|
||||
let p = scale.params(*k, q);
|
||||
let yes = yes_needed(0, w, &p).expect("valid");
|
||||
assert!(passes(Tally { for_votes: yes, against: 0 }, w, &p).expect("valid"));
|
||||
(yes, q)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(fewer, [(8, 12), (9, 12), (39, 50), (43, 50), (46, 50)]);
|
||||
assert!(fewer.iter().all(|(yes, q)| yes < q));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The formulas: evaluated as written, against the engine
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -730,28 +862,27 @@ fn the_quorum_texts_follow_the_rules_of_form() {
|
||||
// Production: a guarded amendment, on a base made as production is after M6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The amendment of an article, by lot T.
|
||||
fn amendment_t(code: &str) -> &'static seed_amend::Amendment {
|
||||
/// The amendment of an article by lot T (`quorum`) or lot T2 (`phrases`).
|
||||
fn amendment(code: &str, lot: &str) -> &'static seed_amend::Amendment {
|
||||
AMENDMENTS
|
||||
.iter()
|
||||
.find(|a| a.unit == code && a.id.starts_with("2026-11-03-"))
|
||||
.unwrap_or_else(|| panic!("no amendment of lot T for {code}"))
|
||||
.find(|a| a.unit == code && a.id.starts_with("2026-11-03-") && a.id.ends_with(lot))
|
||||
.unwrap_or_else(|| panic!("no amendment « {lot} » for {code}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_amendments_go_from_production_to_the_seed_of_today() {
|
||||
let (prod, today) = (production_seed(), today_seed());
|
||||
fn the_amendments_of_lot_t_went_from_m5_to_the_texts_of_m6b() {
|
||||
let (prod, m6b) = (production_seed(), m6b_seed());
|
||||
for code in CODES {
|
||||
let a = amendment_t(code);
|
||||
let a = amendment(code, "-quorum");
|
||||
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
|
||||
let (t, b) = text(&prod, code, "fr");
|
||||
assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds");
|
||||
let (t, b) = text(&today, code, "fr");
|
||||
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today");
|
||||
assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production held before M6b");
|
||||
let (t, b) = text(&m6b, code, "fr");
|
||||
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text production holds since M6b");
|
||||
assert_ne!(a.from, a.to, "{code}");
|
||||
assert_eq!(a.superseded_by, None, "{code}");
|
||||
// F1.1 was never amended; F1.2 and N1.1 follow the amendment of M5, which wrote the
|
||||
// text they replace and which the seed no longer gives: it is superseded.
|
||||
// F1.1 was never amended before; F1.2 and N1.1 follow the amendment of M5, which wrote
|
||||
// the text they replace and which the seed no longer gives: it is superseded.
|
||||
match a.follows {
|
||||
None => assert_eq!(code, "F1.1"),
|
||||
Some(earlier) => {
|
||||
@@ -759,6 +890,41 @@ fn the_shipped_amendments_go_from_production_to_the_seed_of_today() {
|
||||
assert_eq!((m5.unit, m5.to, m5.superseded_by), (code, a.from, Some(a.id)), "{code}");
|
||||
}
|
||||
}
|
||||
// Lot T2 replaces its text: it is superseded in turn.
|
||||
assert_eq!(a.superseded_by, Some(amendment(code, "-phrases").id), "{code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amendments_of_lot_t2_go_from_the_texts_of_m6b_to_the_seed_of_today() {
|
||||
let (m6b, today) = (m6b_seed(), today_seed());
|
||||
for code in CODES {
|
||||
let a = amendment(code, "-phrases");
|
||||
let t = amendment(code, "-quorum");
|
||||
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
|
||||
let (title, body) = text(&m6b, code, "fr");
|
||||
assert_eq!(seed_amend::fingerprint(title, body), a.from, "{code}: the text production holds");
|
||||
let (title, body) = text(&today, code, "fr");
|
||||
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text of the seed today");
|
||||
assert_ne!(a.from, a.to, "{code}");
|
||||
// It follows lot T's, which wrote the text it replaces; nothing replaces it yet.
|
||||
assert_eq!(
|
||||
(a.follows, t.to, t.superseded_by, a.superseded_by),
|
||||
(Some(t.id), a.from, Some(a.id), None),
|
||||
"{code}"
|
||||
);
|
||||
// The titles stay, and only the phrases of the review change (`PHRASES`).
|
||||
for lang in LANGS {
|
||||
let (before, now) = (text(&m6b, code, lang), text(&today, code, lang));
|
||||
assert_eq!(before.0, now.0, "{code} {lang}: the title is kept");
|
||||
let mut b = before.1.to_owned();
|
||||
for (c, l, old, new) in PHRASES {
|
||||
if (c, l) == (code, lang) {
|
||||
b = b.replacen(old, new, 1);
|
||||
}
|
||||
}
|
||||
assert_eq!(b, now.1, "{code} {lang}: nothing else changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,32 +993,40 @@ async fn kinds(state: &AppState, id: &str) -> Vec<String> {
|
||||
.expect("revisions")
|
||||
}
|
||||
|
||||
/// Each decision of lot T: (group, unit, outcome, reason).
|
||||
async fn decisions(state: &AppState) -> Vec<(String, String, String, Option<String>)> {
|
||||
/// Each decision of lot T (`-quorum`) or lot T2 (`-phrases`): (group, unit, outcome, reason).
|
||||
async fn decisions(state: &AppState, lot: &str) -> Vec<(String, String, String, Option<String>)> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.amendment_id LIKE '2026-11-03-%' ORDER BY d.group_slug, s.unit_code",
|
||||
WHERE s.amendment_id LIKE ? ORDER BY d.group_slug, s.unit_code",
|
||||
)
|
||||
.bind(format!("2026-11-03-%{lot}"))
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
async fn queued(state: &AppState) -> Vec<(String, String)> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
|
||||
WHERE o.reason LIKE '%quorum par cran%' ORDER BY o.id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox")
|
||||
/// The rows the amendments of a lot added to the publication queue: (group, reason).
|
||||
async fn queued(state: &AppState, lot: &str) -> Vec<(String, String)> {
|
||||
let reasons: Vec<&str> = CODES.iter().map(|c| amendment(c, lot).reason).collect();
|
||||
let rows: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id ORDER BY o.id")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
rows.into_iter().filter(|(_, r)| reasons.contains(&r.as_str())).collect()
|
||||
}
|
||||
|
||||
/// A start with the seed of M6b (bb4bdf5, and M7 since): lot T decides, lot T2 does not yet.
|
||||
fn m6b_config(tag: &str) -> Config {
|
||||
config(seed_dir(&format!("{tag}-m6b"), &m6b_seed()))
|
||||
}
|
||||
|
||||
/// Lot T, as M6b played it on production (03/11, 08 h 02): started with the seed of M6b.
|
||||
#[tokio::test]
|
||||
async fn production_after_m6_gets_the_quorum_texts_once() {
|
||||
let base = production_after_m6("apply").await;
|
||||
let (prod, today) = (production_seed(), today_seed());
|
||||
let (prod, today) = (production_seed(), m6b_seed());
|
||||
// The base is as production after M6: F1.1 as born, F1.2 and N1.1 as M5 wrote them.
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
@@ -873,11 +1047,11 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!((seed_proposals, quorums), (6, 2), "M6: seed proposals and quorums written");
|
||||
assert!(decisions(&base).await.is_empty(), "the seed of M6 decides nothing of lot T");
|
||||
assert!(decisions(&base, "-quorum").await.is_empty(), "the seed of M6 decides nothing of lot T");
|
||||
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
|
||||
|
||||
// Started with today's seed.
|
||||
let state = start(&base.db, Config::for_tests()).await;
|
||||
// Started with the seed of M6b.
|
||||
let state = start(&base.db, m6b_config("apply")).await;
|
||||
let mut touched = Vec::new();
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
@@ -916,7 +1090,8 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
|
||||
.iter()
|
||||
.flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None)))
|
||||
.collect();
|
||||
assert_eq!(decisions(&state).await, expected);
|
||||
assert_eq!(decisions(&state, "-quorum").await, expected);
|
||||
assert!(decisions(&state, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
|
||||
// Nothing else is touched: no other proposal, no other translation, no vote.
|
||||
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
|
||||
assert_eq!(keep(proposals(&state).await), keep(before.clone()));
|
||||
@@ -927,14 +1102,14 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
|
||||
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
|
||||
assert_eq!(votes, 0);
|
||||
// Monnaie libre republishes, once per article; the test group never.
|
||||
let rows = queued(&state).await;
|
||||
let rows = queued(&state, "-quorum").await;
|
||||
assert_eq!(rows.len(), 3, "{rows:?}");
|
||||
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
|
||||
// Decided once: a second start changes nothing.
|
||||
let again = start(&state.db, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, expected);
|
||||
let again = start(&state.db, m6b_config("apply-again")).await;
|
||||
assert_eq!(decisions(&again, "-quorum").await, expected);
|
||||
assert_eq!(proposals(&again).await, proposals(&state).await);
|
||||
assert_eq!(queued(&again).await.len(), 3);
|
||||
assert_eq!(queued(&again, "-quorum").await.len(), 3);
|
||||
}
|
||||
|
||||
async fn identity(state: &AppState, id: &str) {
|
||||
@@ -948,6 +1123,7 @@ async fn identity(state: &AppState, id: &str) {
|
||||
.expect("identity");
|
||||
}
|
||||
|
||||
/// Lot T's guard, as M6b played it: started with the seed of M6b.
|
||||
#[tokio::test]
|
||||
async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
|
||||
let base = production_after_m6("refuse").await;
|
||||
@@ -993,12 +1169,12 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
|
||||
.await
|
||||
.expect("translation");
|
||||
|
||||
let state = start(&base.db, Config::for_tests()).await;
|
||||
let state = start(&base.db, m6b_config("refuse")).await;
|
||||
let refused =
|
||||
|g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned()));
|
||||
let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None);
|
||||
assert_eq!(
|
||||
decisions(&state).await,
|
||||
decisions(&state, "-quorum").await,
|
||||
[
|
||||
refused("essai", "F1.1", "translation_edited"),
|
||||
applied("essai", "F1.2"),
|
||||
@@ -1008,7 +1184,7 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
|
||||
refused("monnaie-libre", "N1.1", "ballots"),
|
||||
]
|
||||
);
|
||||
let (prod, today) = (production_seed(), today_seed());
|
||||
let (prod, today) = (production_seed(), m6b_seed());
|
||||
for (group, code, kept) in [
|
||||
("essai", "F1.1", true),
|
||||
("essai", "F1.2", false),
|
||||
@@ -1023,14 +1199,14 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
|
||||
assert_eq!(revision, 1 + m5 + i64::from(!kept), "{group} {code}");
|
||||
}
|
||||
// Republished for the one article applied in Monnaie libre only.
|
||||
let rows = queued(&state).await;
|
||||
let rows = queued(&state, "-quorum").await;
|
||||
assert_eq!(
|
||||
rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::<Vec<_>>(),
|
||||
[("monnaie-libre", true)]
|
||||
);
|
||||
// The refusal stands: decided once, it is not tried again.
|
||||
let again = start(&state.db, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, decisions(&state).await);
|
||||
let again = start(&state.db, m6b_config("refuse-again")).await;
|
||||
assert_eq!(decisions(&again, "-quorum").await, decisions(&state, "-quorum").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1044,15 +1220,308 @@ async fn a_new_base_is_born_with_the_quorum_texts() {
|
||||
assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 1), "{group} {code}");
|
||||
}
|
||||
}
|
||||
let got = decisions(&state).await;
|
||||
// Lot T2's amendments find their text there: present.
|
||||
let got = decisions(&state, "-phrases").await;
|
||||
assert_eq!(got.len(), 6);
|
||||
assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}");
|
||||
// The amendments of M5 on F1.2 and N1.1, superseded, decide nothing on a new base.
|
||||
// The amendments of M5 and of lot T on these articles, superseded, decide nothing on a
|
||||
// new base.
|
||||
let m5: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT unit_code FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles')",
|
||||
"SELECT amendment_id FROM seed_amendments
|
||||
WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles') OR amendment_id LIKE '2026-11-03-%-quorum'",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("m5");
|
||||
assert!(m5.is_empty(), "{m5:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lot T2 in production: on a base made as production is after M6b
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Production's history, for these articles, after M6b (03/11, 08 h 02): F1.1 at its
|
||||
/// revision 2, F1.2 and N1.1 at their revision 3, each written by an amendment of the seed.
|
||||
async fn production_after_m6b(tag: &str) -> AppState {
|
||||
let base = production_after_m6(tag).await;
|
||||
start(&base.db, m6b_config(tag)).await
|
||||
}
|
||||
|
||||
async fn count(state: &AppState, sql: &str) -> i64 {
|
||||
sqlx::query_scalar(sql).fetch_one(&state.db).await.expect("count")
|
||||
}
|
||||
|
||||
/// The last revision of a proposal: (revision, kind, title, body, author).
|
||||
async fn last_revision(state: &AppState, id: &str) -> (i64, String, String, String, Option<String>) {
|
||||
sqlx::query_as(
|
||||
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("revision")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_after_m6b_gets_the_three_phrases_once() {
|
||||
let base = production_after_m6b("t2-apply").await;
|
||||
let (m6b, today) = (m6b_seed(), today_seed());
|
||||
// The base is as production after M6b: the texts of lot T, at revision 2 and 3.
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
let (id, title, body, revision) = proposal(&base, group, code).await;
|
||||
assert_eq!((title.as_str(), body.as_str()), text(&m6b, code, "fr"), "{group} {code}");
|
||||
let m5 = code != "F1.1";
|
||||
assert_eq!(revision, if m5 { 3 } else { 2 }, "{group} {code}");
|
||||
let expected: &[&str] = if m5 { &["initial", "amendment", "amendment"] } else { &["initial", "amendment"] };
|
||||
assert_eq!(kinds(&base, &id).await, expected, "{group} {code}");
|
||||
}
|
||||
}
|
||||
let lot_t = decisions(&base, "-quorum").await;
|
||||
assert_eq!(lot_t.len(), 6);
|
||||
assert!(lot_t.iter().all(|(_, _, o, _)| o == "applied"), "{lot_t:?}");
|
||||
assert!(decisions(&base, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
|
||||
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
|
||||
let outbox = count(&base, "SELECT COUNT(*) FROM outbox").await;
|
||||
let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await;
|
||||
|
||||
// Started with today's seed.
|
||||
let state = start(&base.db, Config::for_tests()).await;
|
||||
let mut touched = Vec::new();
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
let (id, title, body, revision) = proposal(&state, group, code).await;
|
||||
assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}");
|
||||
let m5 = code != "F1.1";
|
||||
assert_eq!(revision, if m5 { 4 } else { 3 }, "{group} {code}");
|
||||
let mut expected = vec!["initial", "amendment", "amendment"];
|
||||
if m5 {
|
||||
expected.push("amendment");
|
||||
}
|
||||
assert_eq!(kinds(&state, &id).await, expected, "{group} {code}");
|
||||
// The history keeps the text of lot T, and says the new one, without author.
|
||||
assert_eq!(
|
||||
last_revision(&state, &id).await,
|
||||
(revision, "amendment".to_owned(), title.clone(), body.clone(), None),
|
||||
"{group} {code}"
|
||||
);
|
||||
let (_, kind, t, b, _) = sqlx::query_as::<_, (i64, String, String, String, Option<String>)>(
|
||||
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? AND revision = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(revision - 1)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("revision of lot T");
|
||||
assert_eq!(
|
||||
(kind.as_str(), (t.as_str(), b.as_str())),
|
||||
("amendment", text(&m6b, code, "fr")),
|
||||
"{group} {code}"
|
||||
);
|
||||
for lang in ["en", "es"] {
|
||||
let (t, b, hash, origin, by): (String, String, String, String, Option<String>) = sqlx::query_as(
|
||||
"SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(lang)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("translation");
|
||||
assert_eq!((t.as_str(), b.as_str()), text(&today, code, lang), "{group} {code} {lang}");
|
||||
assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date");
|
||||
assert_eq!((origin.as_str(), by), ("seed", None));
|
||||
}
|
||||
touched.push(id);
|
||||
}
|
||||
}
|
||||
let expected: Vec<(String, String, String, Option<String>)> = GROUPS
|
||||
.iter()
|
||||
.flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None)))
|
||||
.collect();
|
||||
assert_eq!(decisions(&state, "-phrases").await, expected);
|
||||
// Lot T's decisions stay as they were taken; superseded, it decides nothing more.
|
||||
assert_eq!(decisions(&state, "-quorum").await, lot_t);
|
||||
// Nothing else is touched: no other proposal, no other translation, no vote, six revisions.
|
||||
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
|
||||
assert_eq!(keep(proposals(&state).await), keep(before));
|
||||
let others = |rows: Vec<(String, String, String, String, String)>| {
|
||||
rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(others(translations(&state).await), others(before_tr));
|
||||
assert_eq!(count(&state, "SELECT COUNT(*) FROM votes").await, 0);
|
||||
assert_eq!(count(&state, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
|
||||
// Monnaie libre republishes, once per article; the test group never.
|
||||
let rows = queued(&state, "-phrases").await;
|
||||
assert_eq!(rows.len(), 3, "{rows:?}");
|
||||
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
|
||||
assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
|
||||
// Decided once: a second start replays nothing.
|
||||
let again = start(&state.db, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again, "-phrases").await, expected);
|
||||
assert_eq!(proposals(&again).await, proposals(&state).await);
|
||||
assert_eq!(translations(&again).await, translations(&state).await);
|
||||
assert_eq!(count(&again, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
|
||||
assert_eq!(count(&again, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
|
||||
}
|
||||
|
||||
/// A person, voting through the interface's own routes (no chain in the test group).
|
||||
struct Voter {
|
||||
router: axum::Router,
|
||||
cookie: String,
|
||||
}
|
||||
|
||||
impl Voter {
|
||||
async fn call(&self, method: &str, uri: &str, body: Value, cookie: Option<&str>) -> axum::response::Response {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(ld_server::CSRF_HEADER, ld_server::CSRF_VALUE)
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json");
|
||||
if let Some(c) = cookie {
|
||||
req = req.header(axum::http::header::COOKIE, c);
|
||||
}
|
||||
let req = req.body(axum::body::Body::from(body.to_string())).expect("request");
|
||||
self.router.clone().oneshot(req).await.expect("response")
|
||||
}
|
||||
|
||||
async fn login(state: &AppState, name: &str) -> Self {
|
||||
let mut v = Self { router: ld_server::app(state.clone()), cookie: String::new() };
|
||||
let res = v.call("POST", "/api/auth/dev", serde_json::json!({ "name": name }), None).await;
|
||||
assert_eq!(res.status(), axum::http::StatusCode::OK);
|
||||
let set = res.headers().get(axum::http::header::SET_COOKIE).expect("cookie").to_str().expect("cookie");
|
||||
v.cookie = set.split(';').next().expect("cookie").to_owned();
|
||||
v
|
||||
}
|
||||
|
||||
async fn vote(&self, proposal: &str, choice: Option<&str>) {
|
||||
let uri = format!("/api/proposals/{proposal}/vote");
|
||||
let res = self.call("POST", &uri, serde_json::json!({ "choice": choice }), Some(&self.cookie)).await;
|
||||
assert_eq!(res.status(), axum::http::StatusCode::OK, "{uri} {choice:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_article_voted_on_keeps_the_text_of_m6b_and_says_why() {
|
||||
let base = production_after_m6b("t2-refuse").await;
|
||||
let doc: String = sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = 'monnaie-libre'")
|
||||
.fetch_one(&base.db)
|
||||
.await
|
||||
.expect("doc");
|
||||
identity(&base, "dev:vera").await;
|
||||
// In Monnaie libre, a simulated vote on F1.1, at its revision 2…
|
||||
let (f11, ..) = proposal(&base, "monnaie-libre", "F1.1").await;
|
||||
sqlx::query(
|
||||
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source)
|
||||
VALUES (?, 'dev:vera', 'against', 2, '2026-11-03T09:00:00Z', 'simulated')",
|
||||
)
|
||||
.bind(&f11)
|
||||
.execute(&base.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
// …and a ballot still pending on F1.2, at its revision 3: cast, not proven yet.
|
||||
let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await;
|
||||
sqlx::query(
|
||||
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode,
|
||||
created_at, expires_at)
|
||||
VALUES ('b2', ?, 'dev:vera', 'salt', 'digest-b2', 'remark-b2', '', 0, 'pending', 'simulated',
|
||||
'2026-11-03T09:00:00Z', '2026-11-04T09:00:00Z')",
|
||||
)
|
||||
.bind(&doc)
|
||||
.execute(&base.db)
|
||||
.await
|
||||
.expect("ballot");
|
||||
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', ?, 3, 'against')")
|
||||
.bind(&f12)
|
||||
.execute(&base.db)
|
||||
.await
|
||||
.expect("line");
|
||||
// In the test group, Luna votes « contre » N1.1 and keeps her vote (a « pour » would pass
|
||||
// there, quorum 1); she votes on F1.2, then withdraws: a direct vote withdrawn leaves no
|
||||
// trace (the guard's known limit, `seed_amend`).
|
||||
let luna = Voter::login(&base, "Luna").await;
|
||||
let (n11, ..) = proposal(&base, "essai", "N1.1").await;
|
||||
luna.vote(&n11, Some("against")).await;
|
||||
let (f12_essai, ..) = proposal(&base, "essai", "F1.2").await;
|
||||
luna.vote(&f12_essai, Some("against")).await;
|
||||
luna.vote(&f12_essai, None).await;
|
||||
let traces = |id: String| {
|
||||
let state = base.clone();
|
||||
async move {
|
||||
let sql = "SELECT (SELECT COUNT(*) FROM votes WHERE proposal_id = ?1)
|
||||
+ (SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?1)";
|
||||
sqlx::query_scalar::<_, i64>(sql).bind(id).fetch_one(&state.db).await.expect("traces")
|
||||
}
|
||||
};
|
||||
assert_eq!((traces(n11.clone()).await, traces(f12_essai.clone()).await), (1, 0));
|
||||
|
||||
let state = start(&base.db, Config::for_tests()).await;
|
||||
let refused =
|
||||
|g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned()));
|
||||
let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None);
|
||||
assert_eq!(
|
||||
decisions(&state, "-phrases").await,
|
||||
[
|
||||
applied("essai", "F1.1"),
|
||||
applied("essai", "F1.2"),
|
||||
refused("essai", "N1.1", "votes"),
|
||||
refused("monnaie-libre", "F1.1", "votes"),
|
||||
refused("monnaie-libre", "F1.2", "ballots"),
|
||||
applied("monnaie-libre", "N1.1"),
|
||||
]
|
||||
);
|
||||
let (m6b, today) = (m6b_seed(), today_seed());
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
let kept = matches!((group, code), ("essai", "N1.1") | ("monnaie-libre", "F1.1" | "F1.2"));
|
||||
let (_, title, body, revision) = proposal(&state, group, code).await;
|
||||
let expected = if kept { text(&m6b, code, "fr") } else { text(&today, code, "fr") };
|
||||
assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}");
|
||||
let lot_t = if code == "F1.1" { 2 } else { 3 };
|
||||
assert_eq!(revision, lot_t + i64::from(!kept), "{group} {code}");
|
||||
}
|
||||
}
|
||||
// Republished for the one article applied in Monnaie libre only.
|
||||
let rows = queued(&state, "-phrases").await;
|
||||
assert_eq!(
|
||||
rows.iter().map(|(g, r)| (g.as_str(), r.contains("N1.1"))).collect::<Vec<_>>(),
|
||||
[("monnaie-libre", true)]
|
||||
);
|
||||
// The refusals stand: decided once, never tried again, even once the vote is gone.
|
||||
sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&f11).execute(&state.db).await.expect("delete");
|
||||
let again = start(&state.db, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again, "-phrases").await, decisions(&state, "-phrases").await);
|
||||
let (_, title, body, _) = proposal(&again, "monnaie-libre", "F1.1").await;
|
||||
assert_eq!((title.as_str(), body.as_str()), text(&m6b, "F1.1", "fr"));
|
||||
}
|
||||
|
||||
/// A base born on 02/11 with the seed of M5 (F1.2 and N1.1 at their revision 1, in M5's
|
||||
/// words): lot T wrote their revision 2, and lot T2 follows it there too.
|
||||
#[tokio::test]
|
||||
async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() {
|
||||
let prod = production_seed();
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let first = AppState::new(config(seed_dir("t2-born-m5", &prod)), db.clone()).await.expect("state");
|
||||
seed::documents(&first).await.expect("born");
|
||||
start(&db, config(seed_dir("t2-born-m5-m5", &prod))).await;
|
||||
start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await;
|
||||
let base = start(&db, m6b_config("t2-born-m5")).await;
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
let (id, ..) = proposal(&base, group, code).await;
|
||||
assert_eq!(kinds(&base, &id).await, ["initial", "amendment"], "{group} {code}: lot T, alone");
|
||||
}
|
||||
}
|
||||
let state = start(&db, Config::for_tests()).await;
|
||||
let today = today_seed();
|
||||
for group in GROUPS {
|
||||
for code in CODES {
|
||||
let (id, title, body, revision) = proposal(&state, group, code).await;
|
||||
assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 3), "{group} {code}");
|
||||
assert_eq!(kinds(&state, &id).await, ["initial", "amendment", "amendment"], "{group} {code}");
|
||||
}
|
||||
}
|
||||
let got = decisions(&state, "-phrases").await;
|
||||
assert_eq!(got.len(), 6);
|
||||
assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user