Files
decision/crates/ld-server/tests/inertia.rs
T
YvvandClaude Opus 5.5 01b7841472 Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
  NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
  cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
  les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
  (l'auteur de la première version, un administrateur pour le seed), refusée dès
  qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
  en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
  il n'y en a pas, une fois par document, consigné, file de publication là où il
  publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
  quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
  publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
  d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
  auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
  cran 3, 43 au cran 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:00:41 +01:00

701 lines
30 KiB
Rust

//! Inertia in five levels (ADR 0007): the levels and the scale served, the
//! usual participation of a group, the change of inertia voted at the level
//! of its article, the migration of the four words of before, and the
//! amendment that raised E1 and E2 to level 4 in an existing base (30/10; since
//! 2026-11-02 the seed puts them forward at a low level, ADR 0011: the scenarios
//! of 30/10 are replayed on the seed production ran then).
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_engine::inertia::{InertiaLevel, Scale, gradient};
use ld_engine::threshold::{judged_voters, required_ratio};
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use std::borrow::Cow;
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self::of(&state), state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.get(&format!("/api/groups/{group}")).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.get(&format!("/api/docs/{id}")).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
/// The level seed proposals start at (ADR 0011).
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
/// The quorums of Monnaie libre by level (ADR 0013, Yvv, 2026-11-02).
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1, E2 in force, E1 and E2 at 4.
fn production_seed_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
}
fn numbers(levels: &Value, key: &str) -> Vec<f64> {
levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect()
}
#[tokio::test]
async fn the_five_levels_and_the_scale_are_served() {
let (mut c, _) = Client::new().await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// 3 everywhere, low for the three seed proposals (ADR 0011).
let other: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| u["inertia"] != 3)
.map(|u| u["code"].as_str().expect("code"))
.collect();
assert_eq!(other, ["I1", "E1", "E2"]);
assert!(["I1", "E1", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW));
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["voters"].as_u64()),
(Some("reference"), Some(0), Some(100))
);
assert_eq!(
(p["minSample"].as_u64(), p["windowDays"].as_u64(), p["everyHours"].as_u64()),
(Some(5), Some(365), Some(24))
);
let ratio = p["ratio"].as_f64().expect("ratio");
assert!((ratio - 100.0 / 5745.0).abs() < 1e-12);
let levels = &doc["levels"];
assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]);
assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]);
// The quorums by level (ADR 0013, Yvv: « 12 · 12 · 50 · 50 · 50 »).
assert_eq!(numbers(levels, "quorum"), [12.0, 12.0, 50.0, 50.0, 50.0]);
let scale = Scale { electorate: 5745, participation: ratio, quorums: QUORUMS };
for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12);
// At the usual participation, above every quorum: s_k of the voters, nothing in front.
assert_eq!(l["cast"], 100);
let q = scale.quorum(k, None);
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k, q).expect("valid"))));
assert_eq!(l["required"].as_f64(), Some(k.share_pct().ceil()));
}
assert_eq!(numbers(levels, "required"), [60.0, 67.0, 75.0, 83.0, 90.0]);
// A version: the five levels at its own participation, and the level it is voted at.
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
let d = c.get(&format!("/api/proposals/{e1}")).await;
assert_eq!(d["proposal"]["votedAtInertia"], LOW);
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
// No vote: judged on the quorum, the voters missing counted as against (ADR 0013).
assert_eq!(numbers(&d["levels"], "required"), [8.0, 9.0, 39.0, 43.0, 46.0], "the quorum, not C");
assert_eq!(d["participation"]["source"], "reference");
assert_eq!(d["electorateSource"], "wot");
}
#[tokio::test]
async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled() {
let (mut a, state) = Client::new().await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let doc = a.doc("essai").await;
let e2 = unit(&doc, "E2")["id"].as_str().expect("e2").to_owned();
let to = |t: Value| json!({ "target": t });
for bad in [json!(0), json!(6), json!("haute"), json!(null)] {
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}");
}
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(LOW)))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia")));
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(5)))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let d = a.get(&format!("/api/proposals/{p}")).await;
assert_eq!(
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
(Some(5), Some(u64::from(LOW)))
);
// W(essai) = 2, the quorum 1 everywhere in the test group (ADR 0013): one voice
// passes, the article goes to level 5.
let v = a.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "inertia");
let doc = a.doc("essai").await;
let e2u = unit(&doc, "E2");
assert_eq!(e2u["inertia"], 5);
let changes = e2u["inertiaChanges"].as_array().expect("changes");
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(u64::from(LOW)), Some(5), Some("vote"))
);
// The adoption keeps the level it was judged at, and the scale.
let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?")
.bind(&p)
.fetch_one(&state.db)
.await
.expect("adoption");
let params: Value = serde_json::from_str(&params).expect("json");
let judged = Scale { electorate: 2, participation: 0.5, quorums: [1; 5] }.params(level(LOW), 1);
assert_eq!(
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
(Some(u64::from(LOW)), Some(judged.majority_pct), Some(level(LOW).share_pct()))
);
assert_eq!((params["quorum"].as_u64(), params.get("constant")), (Some(1), None), "the quorum, no C");
assert_eq!(
(params["participation"].as_f64(), params["participation_source"].as_str()),
(Some(0.5), Some("reference"))
);
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(LOW), 0.5)).abs() < 1e-12));
}
#[tokio::test]
async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
let (mut first, state) = Client::new().await;
// Ten people, all members of the test group: W = 10, C = 1.
let mut people = Vec::new();
for name in ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("P1").await;
let doc = first.doc("essai").await;
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6"]
.iter()
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
.collect();
// 1, 2, 3, 3 and 4 voters (against: nothing enters into force, the count is what matters).
for (p, n) in open.iter().zip([1, 2, 3, 3, 4]) {
for c in people.iter_mut().take(n) {
c.vote(p, "against").await;
}
}
// Measured once a day, not at each vote: until then, the reference.
let doc = first.doc("essai").await;
assert_eq!(doc["doc"]["participation"]["source"], "reference");
assert_eq!(doc["doc"]["electorate"], 10);
let measured = ld_server::participation::refresh(&state, false).await.expect("refresh");
assert!(measured.contains(&"essai".to_owned()));
let doc = first.doc("essai").await;
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
(Some("median"), Some(5), Some(3.0))
);
assert_eq!((p["ratio"].as_f64(), p["voters"].as_u64(), p["bounded"].as_bool()), (Some(0.3), Some(3), Some(false)));
assert!(p["measuredAt"].is_string());
for (l, k) in doc["levels"].as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, 0.3)).abs() < 1e-12);
}
// Thresholds follow: the proposal with 4 voters is judged on this scale.
let d = first.get(&format!("/api/proposals/{}", open[4])).await;
let scale = Scale { electorate: 10, participation: 0.3, quorums: [1; 5] };
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
assert!((ratio - required_ratio(10, 4, &scale.params(level(3), 1))).abs() < 1e-12);
// A new vote does not move it within the day…
people[4].vote(&open[0], "against").await;
assert!(ld_server::participation::refresh(&state, false).await.expect("refresh").is_empty());
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// …the next measure sees it: 2, 2, 3, 3, 4.
ld_server::participation::refresh(&state, true).await.expect("refresh");
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// A proposal last voted on more than a year ago leaves the sample: four are too few.
sqlx::query("UPDATE votes SET cast_at = '2025-01-01T00:00:00Z' WHERE proposal_id = ?")
.bind(&open[2])
.execute(&state.db)
.await
.expect("age");
ld_server::participation::refresh(&state, true).await.expect("refresh");
let p = first.doc("essai").await["doc"]["participation"].clone();
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
(Some("reference"), Some(4), Some(2.5))
);
// The reference of the test group: 2C/W = 20 %.
assert_eq!(p["ratio"].as_f64(), Some(0.2));
// Monnaie libre is measured apart, from its own votes (none).
let ml = first.doc("monnaie-libre").await;
assert_eq!(
(ml["doc"]["participation"]["sample"].as_u64(), ml["doc"]["participation"]["source"].as_str()),
(Some(0), Some("reference"))
);
}
#[tokio::test]
async fn a_withdrawn_proposal_leaves_the_usual_participation() {
// Relecture of a576b68: withdrawn proposals vanish from the document and their
// votes can no longer be taken back, so they must not weigh on the median.
let (mut first, state) = Client::new().await;
let mut people = Vec::new();
for name in ["W1", "W2", "W3", "W4"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("W1").await;
let doc = first.doc("essai").await;
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6"]
.iter()
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
.collect();
for p in &open {
for c in people.iter_mut().take(2) {
c.vote(p, "against").await;
}
}
ld_server::participation::refresh(&state, true).await.expect("refresh");
let p = first.doc("essai").await["doc"]["participation"].clone();
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("median"), Some(5)));
sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?")
.bind(&open[0])
.execute(&state.db)
.await
.expect("withdraw");
ld_server::participation::refresh(&state, true).await.expect("refresh");
let p = first.doc("essai").await["doc"]["participation"].clone();
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4)));
}
#[tokio::test]
async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() {
// Relecture of 71819f9: the list is explicit — open, in force, applied. A decision
// taken (a change of inertia applied) keeps its voters; a status not named stays out.
let (mut first, state) = Client::new().await;
let mut people = Vec::new();
for name in ["S1", "S2"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("S1").await;
let doc = first.doc("essai").await;
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6", "E8"]
.iter()
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
.collect();
for p in &open {
for c in &mut people {
c.vote(p, "against").await;
}
}
let sample = |state: AppState| async move {
ld_server::participation::refresh(&state, true).await.expect("refresh");
ld_server::participation::stored(&state, "essai").await.expect("stored").expect("measure").sample
};
assert_eq!(sample(state.clone()).await, 6);
for (id, status) in [(&open[0], "applied"), (&open[1], "in_force")] {
sqlx::query("UPDATE proposals SET status = ? WHERE id = ?")
.bind(status)
.bind(id)
.execute(&state.db)
.await
.expect("status");
}
assert_eq!(sample(state.clone()).await, 6, "applied and in force stay counted");
sqlx::query("UPDATE proposals SET status = 'superseded' WHERE id = ?")
.bind(&open[2])
.execute(&state.db)
.await
.expect("status");
assert_eq!(sample(state.clone()).await, 5, "a status not named is out");
}
// ---------------------------------------------------------------------------
// The base as production holds it on 2026-10-30, then the new version
// ---------------------------------------------------------------------------
/// The seed as it was then: no modules yet (ADR 0009 came after), I1, E1 and E2 in force.
fn seed_of_the_time(tag: &str) -> std::path::PathBuf {
let today = production_seed_dir().join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// Migrations up to 0004: the schema production runs before this version.
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("db");
let mut before = sqlx::migrate!("./migrations");
before.migrations = Cow::Owned(before.migrations.iter().filter(|m| m.version <= 4).cloned().collect());
before.run(&pool).await.expect("migrations 1 to 4");
let mut cfg = Config::for_tests();
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
cfg.seed_dir = seed_of_the_time(tag);
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::documents(&state).await.expect("documents");
// The words of the time: every article `standard`.
sqlx::query("UPDATE units SET inertia = 'standard'").execute(&pool).await.expect("words");
(pool, state)
}
/// The new version starts: migration 0005, then the seed and its amendments.
async fn start_new_version(pool: &SqlitePool) -> AppState {
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(pool).await.expect("migrations");
restart(pool).await
}
/// Started again with the seed production ran after 30/10 (E1 and E2 at 4).
async fn restart(pool: &SqlitePool) -> AppState {
let mut cfg = Config::for_tests();
cfg.seed_dir = production_seed_dir();
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
async fn unit_id(pool: &SqlitePool, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(pool)
.await
.expect("unit")
}
/// An open change of inertia written as production holds it (a word, no vote).
async fn legacy_inertia_proposal(pool: &SqlitePool, unit: &str, word: &str) -> String {
let id = format!("legacy-{unit}-{word}");
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, 'inertia', ?, ?, '', 'open', 'member', NULL, 1, '2026-10-29T21:54:00Z', '2026-10-29T21:54:00Z',
'2026-10-29T21:54:00Z')",
)
.bind(&id)
.bind(unit)
.bind(word)
.bind(format!("inertia:{word}"))
.execute(pool)
.await
.expect("proposal");
id
}
type Decision = (String, String, String, Option<String>);
async fn inertia_decisions(pool: &SqlitePool) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, a.unit_code, a.outcome, a.reason FROM inertia_amendments a
JOIN documents d ON d.id = a.doc_id ORDER BY d.group_slug, a.unit_code",
)
.fetch_all(pool)
.await
.expect("decisions")
}
fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> {
rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect()
}
/// Since 2026-11-02 the seed gives E1 and E2 the low level of seed proposals: the
/// raise of 30/10 is superseded, decided in production and nowhere else.
#[tokio::test]
async fn a_new_base_starts_low_and_the_raise_of_30_10_decides_nothing() {
let (mut c, state) = Client::new().await;
assert_eq!(decided(&inertia_decisions(&state.db).await), []);
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
assert_eq!(changes, 0, "born low: nothing changed");
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
assert!(["E1", "E2"].iter().all(|code| unit(&doc, code)["inertia"] == LOW));
// On the seed of production, the raise is still there to decide: present at birth.
let mut cfg = Config::for_tests();
cfg.seed_dir = production_seed_dir();
let db = open_db("sqlite::memory:").await.expect("db");
let born = AppState::new(cfg, db).await.expect("state");
seed::ensure(&born).await.expect("seed");
assert_eq!(
decided(&inertia_decisions(&born.db).await),
[
("essai", "E1", "present", None),
("essai", "E2", "present", None),
("monnaie-libre", "E1", "present", None),
("monnaie-libre", "E2", "present", None),
]
);
}
#[tokio::test]
async fn the_migration_turns_the_four_words_into_levels_and_keeps_them() {
let (pool, _) = base_before_levels("migration").await;
let i1 = unit_id(&pool, "monnaie-libre", "I1").await;
let e3 = unit_id(&pool, "monnaie-libre", "E3").await;
for (unit, word) in [(&i1, "low"), (&e3, "high")] {
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(word)
.bind(unit)
.execute(&pool)
.await
.expect("word");
}
let p = legacy_inertia_proposal(&pool, &e3, "max").await;
// An adoption of before: its params are the letters it was judged by.
let old_params = r#"{"majority_pct":50.0,"gradient":0.2,"base":0.1,"constant":50.0}"#;
sqlx::query(
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
SELECT 'past', ?, doc_id, 'text', 60, 0, 7224, 58, ?, '2026-10-25T00:00:00Z' FROM units WHERE id = ?",
)
.bind(&e3)
.bind(old_params)
.bind(&e3)
.execute(&pool)
.await
.expect("adoption");
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migration 0005");
let words: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT code, inertia, inertia_legacy FROM units WHERE doc_id = (SELECT doc_id FROM units WHERE id = ?) AND code IN ('I1', 'E1', 'E3') ORDER BY code")
.bind(&i1)
.fetch_all(&pool)
.await
.expect("units");
assert_eq!(
words,
[
("E1".into(), "3".into(), Some("standard".into())),
("E3".into(), "4".into(), Some("high".into())),
("I1".into(), "2".into(), Some("low".into())),
]
);
let target: (Option<String>, Option<String>) =
sqlx::query_as("SELECT inertia_target, inertia_target_legacy FROM proposals WHERE id = ?")
.bind(&p)
.fetch_one(&pool)
.await
.expect("proposal");
assert_eq!(target, (Some("5".into()), Some("max".into())));
let kept: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = 'past'")
.fetch_one(&pool)
.await
.expect("params");
assert_eq!(kept, old_params, "the history is never rewritten");
}
/// Production on 2026-10-30 (orch): every article `standard`, no vote, and two open
/// changes of inertia at zero votes, I1 → max and E1 → max.
#[tokio::test]
async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() {
let (pool, _) = base_before_levels("production").await;
let mut proposals = Vec::new();
for group in ["monnaie-libre", "essai"] {
for code in ["I1", "E1"] {
let u = unit_id(&pool, group, code).await;
proposals.push((group, code, legacy_inertia_proposal(&pool, &u, "max").await));
}
}
let state = start_new_version(&pool).await;
let first = inertia_decisions(&pool).await;
assert_eq!(
decided(&first),
[
("essai", "E1", "applied", None),
("essai", "E2", "applied", None),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
let levels: Vec<(&str, u64)> = ["I1", "E1", "E2", "E3"]
.iter()
.map(|code| (*code, unit(&doc, code)["inertia"].as_u64().expect("level")))
.collect();
assert_eq!(levels, [("I1", 3), ("E1", 4), ("E2", 4), ("E3", 3)], "{group}");
// The article says so.
let changes = unit(&doc, "E1")["inertiaChanges"].as_array().expect("changes").clone();
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(3), Some(4), Some("amendment"))
);
}
// The open changes stay open, their target read as 5, voted at the article's level.
for (group, code, id) in &proposals {
let d = c.get(&format!("/api/proposals/{id}")).await;
assert_eq!(d["proposal"]["status"], "open", "{group} {code}");
assert_eq!(d["proposal"]["inertiaTarget"], 5, "{group} {code}");
let at = if *code == "E1" { 4 } else { 3 };
assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}");
}
// And votable: in Monnaie libre, judged at level 4. A vote there is confirmed on the
// chain (tests/ballots.rs); here, one such vote is written as the confirmation writes it.
let (_, _, e1_ml) = &proposals[1];
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at)
VALUES ('idty:9001', 'g1', 'Alice', 1, 0, '2026-10-30T00:00:00Z')",
)
.execute(&pool)
.await
.expect("identity");
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index)
VALUES (?, 'idty:9001', 'for', 1, '2026-10-30T00:00:00Z', 'chain', 100, 1)",
)
.bind(e1_ml)
.execute(&pool)
.await
.expect("vote");
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
let p = d["participation"]["ratio"].as_f64().expect("p");
// One voter, under the quorum of level 4 (50): judged on 50 voters, 49 counted against.
let scale = Scale { electorate: 5745, participation: p, quorums: QUORUMS };
let voters = judged_voters(1, 50);
assert!((ratio - required_ratio(5745, voters, &scale.params(level(4), 50))).abs() < 1e-12);
assert!((ratio - required_ratio(5745, voters, &scale.params(level(3), 50))).abs() > 1e-6, "not at the old level");
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
assert_eq!(d["proposal"]["evaluation"]["required"], 43);
// Republished where the document publishes; never from the test group.
let queued: 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 'inertie%' ORDER BY o.id",
)
.fetch_all(&pool)
.await
.expect("outbox");
assert_eq!(
queued,
[
("monnaie-libre".to_owned(), "inertie de départ relevée : E1, 3 → 4".to_owned()),
("monnaie-libre".to_owned(), "inertie de départ relevée : E2, 3 → 4".to_owned()),
]
);
// Once only: the next start changes nothing.
let again = restart(&pool).await;
assert_eq!(inertia_decisions(&again.db).await, first);
let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&pool).await.expect("n");
assert_eq!(changes, 4);
}
#[tokio::test]
async fn an_inertia_changed_by_a_vote_keeps_its_level() {
let (pool, _) = base_before_levels("voted").await;
// The migration runs; a vote changes E2's inertia in the test group, before the
// version that knows the amendment starts.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migrations");
let between = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut a = Client::of(&between);
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let e2 = unit_id(&pool, "essai", "E2").await;
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
// One voice of the two: before the quorum amendment, the C = 1 of the test group is
// read as the quorum of every level (ADR 0013).
assert_eq!(a.vote(&p, "for").await["entered"][0]["kind"], "inertia");
let (s, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("not_votable")), "already decided");
let state = restart(&pool).await;
assert_eq!(
decided(&inertia_decisions(&state.db).await),
[
("essai", "E1", "applied", None),
("essai", "E2", "refused", Some("changed_by_vote")),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
let doc = c.doc("essai").await;
assert_eq!(unit(&doc, "E2")["inertia"], 5, "the vote holds");
}