//! 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 raises E1 and E2 to level 4 in an existing base. 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::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, } 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) -> (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") } fn numbers(levels: &Value, key: &str) -> Vec { 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, 4 for the two structural commitments. let raised: 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!(raised, ["E1", "E2"]); assert!(unit(&doc, "E1")["inertia"] == 4 && unit(&doc, "E2")["inertia"] == 4); // 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]); let scale = Scale { electorate: 5745, floor: 50.0, participation: ratio }; 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, C plus s_k of the votes beyond C. assert_eq!(l["cast"], 100); assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k).expect("valid")))); let at_usual = 50.0 + k.share_pct() / 100.0 * 50.0; assert_eq!(l["required"].as_f64(), Some(at_usual.ceil())); } // A version: the five levels at its own participation, and the level it is voted at. let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned(); let d = c.get(&format!("/api/proposals/{e1}")).await; assert_eq!(d["proposal"]["votedAtInertia"], 4); assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]); assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under 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!(4)))).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!(1)))).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(1), Some(4))); // W(essai) = 2: both vote for, the article goes to level 1. a.vote(&p, "for").await; let v = b.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"], 1); 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(4), Some(1), 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(¶ms).expect("json"); assert_eq!( (params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()), (Some(4), Some(60.0), Some(83.0)) ); 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(4), 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 = ["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, floor: 1.0, participation: 0.3 }; let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio"); assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).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 = ["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))); } // --------------------------------------------------------------------------- // The base as production holds it on 2026-10-30, then the new version // --------------------------------------------------------------------------- /// 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())); 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 } async fn restart(pool: &SqlitePool) -> AppState { let state = AppState::new(Config::for_tests(), 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); async fn inertia_decisions(pool: &SqlitePool) -> Vec { 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() } #[tokio::test] async fn a_new_base_starts_with_e1_and_e2_at_level_4() { let (_, state) = Client::new().await; assert_eq!( decided(&inertia_decisions(&state.db).await), [ ("essai", "E1", "present", None), ("essai", "E2", "present", None), ("monnaie-libre", "E1", "present", None), ("monnaie-libre", "E2", "present", None), ] ); let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n"); assert_eq!(changes, 0, "born at 4: nothing changed"); } #[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)> = 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, Option) = 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"); let scale = Scale { electorate: 5745, floor: 50.0, participation: p }; assert!((ratio - required_ratio(5745, 1, &scale.params(level(4)))).abs() < 1e-12); assert!((ratio - required_ratio(5745, 1, &scale.params(level(3)))).abs() > 1e-6, "not at the old level"); assert_eq!(d["proposal"]["evaluation"]["required"], 50); // 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(); a.vote(&p, "for").await; assert_eq!(b.vote(&p, "for").await["entered"][0]["kind"], "inertia"); 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"); }