//! The four rule articles of the seed rewritten after the rules applied (Yvv, //! 2026-11-02): F1.2, N1, N1.1 and O1 are open proposals at zero votes, not texts //! in force; a guarded seed amendment replaces them where nobody touched them. //! //! Played with the seed M5 shipped (3f10a0e, the fixture of production): since lot T //! (2026-11-03), today's seed gives F1.2 and N1.1 the texts of the quorum, and their //! amendments of 02/11 are superseded (`tests/seed_quorum_texts.rs`). Since lot T4 (the same //! day, ADR 0014), the four leave the seed: the seed of M8 (`tests/fixtures/seed-m8/`), the last //! that holds them, stands for « today » here, and their last amendments are superseded by //! their retirement (`retire`). use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::{AppState, Config, open_db, seed}; use serde_json::Value; const CODES: [&str; 4] = ["F1.2", "N1", "N1.1", "O1"]; fn read(path: std::path::PathBuf) -> Value { serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") } /// The four articles as production holds them (the seed of 21/10, unchanged until 02/11). fn before() -> Value { read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-regles-avant.json")) } /// The seed of M8 (de1b7eb): the last that holds the rule articles. fn m8_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8") } fn today_seed() -> Value { read(m8_dir().join("acte-engagement-certification.json")) } /// The seed M5 shipped: production's since 2026-11-02. fn m5_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02") } fn m5_seed() -> Value { read(m5_dir().join("acte-engagement-certification.json")) } fn version<'a>(seed: &'a Value, code: &str) -> &'a Value { let u = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit"); &u["versions"][0] } fn fr(v: &Value) -> (&str, &str) { (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body")) } /// The seed of M5 with the four rule articles of before. fn seed_dir_before(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("ld-seed-rules-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("dir"); let mut seed = m5_seed(); let old = before(); for u in seed["units"].as_array_mut().expect("units") { let code = u["code"].as_str().expect("code").to_owned(); if let Some(o) = old["units"].get(&code) { u["versions"][0]["title"] = o["title"].clone(); u["versions"][0]["body"] = o["body"].clone(); } } std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); dir } /// A base seeded with the rule articles of before, as production was, by the code of /// the time (no amendment). async fn base_before(tag: &str) -> AppState { let mut cfg = Config::for_tests(); cfg.seed_dir = seed_dir_before(tag); let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); seed::documents(&state).await.expect("seed of before"); state } /// The same base, started again with the seed of M5 and its amendments. async fn restart(before: &AppState) -> AppState { let mut cfg = Config::for_tests(); cfg.seed_dir = m5_dir(); let state = AppState::new(cfg, before.db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } async fn proposal(state: &AppState, group: &str, code: &str) -> (String, String, String, i64) { sqlx::query_as( "SELECT p.id, p.title, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = '1.0.0'", ) .bind(group) .bind(code) .fetch_one(&state.db) .await .expect("seed proposal") } /// Each decision on the four rule amendments: (group, unit, outcome, reason). async fn decisions(state: &AppState) -> Vec<(String, String, String, Option)> { 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-02-%' ORDER BY d.group_slug, s.unit_code", ) .fetch_all(&state.db) .await .expect("decisions") } #[test] fn the_shipped_amendments_go_from_the_texts_of_before_to_the_seed_of_m5() { let (old, m5, today) = (before(), m5_seed(), today_seed()); for code in CODES { let a = AMENDMENTS.iter().find(|a| a.unit == code).unwrap_or_else(|| panic!("no amendment for {code}")); assert_eq!(a.doc, "acte-engagement-certification"); assert_eq!(a.version, Version::Open("1.0.0"), "{code}: the open proposal of the seed"); let (t, b) = fr(&old["units"][code]); assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds"); let v = version(&m5, code); assert_eq!((v["status"].as_str(), v["label"].as_str()), (Some("open"), Some("1.0.0")), "{code}"); let (t, b) = fr(v); assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed of M5"); assert_ne!(a.from, a.to, "{code}"); // Today's seed gives it, or the text of the last of the amendments that follow it, // each replacing the text of the one before (lot T, then lot T2, on 03/11). let (t, b) = fr(version(&today, code)); let now = seed_amend::fingerprint(t, b); let mut last = a; while let Some(later) = last.superseded_by.filter(|l| *l != ld_server::retire::RETIREMENT) { let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment"); assert_eq!((l.unit, l.follows, l.from), (code, Some(last.id), last.to), "{code}: {}", l.id); last = l; } assert_eq!(now, last.to, "{code}: the text of today"); // Lot T4 takes the article out of the seed: the last amendment of its text decides nothing more. assert_eq!(last.superseded_by, Some(ld_server::retire::RETIREMENT), "{code}"); } } #[tokio::test] async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() { let base = base_before("replace").await; // In the test group someone voted on N1: there it keeps its text. let (n1, ..) = proposal(&base, "essai", "N1").await; sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:vera', 'dev', 'Vera', '2026-11-01T00:00:00Z')") .execute(&base.db) .await .expect("identity"); sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, 'dev:vera', 'against', 1, '2026-11-01T00:00:00Z')") .bind(&n1) .execute(&base.db) .await .expect("vote"); let state = restart(&base).await; let (old, today) = (before(), m5_seed()); for group in ["essai", "monnaie-libre"] { for code in CODES { let (id, title, body, revision) = proposal(&state, group, code).await; let kept = group == "essai" && code == "N1"; let expected = if kept { fr(&old["units"][code]) } else { fr(version(&today, code)) }; assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}"); assert_eq!(revision, if kept { 1 } else { 2 }, "{group} {code}"); if kept { continue; } let kinds: Vec = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id") .bind(&id) .fetch_all(&state.db) .await .expect("revisions"); assert_eq!(kinds, ["initial", "amendment"], "{group} {code}"); for lang in ["en", "es"] { let (t, b, hash): (String, String, String) = sqlx::query_as( "SELECT title, body, source_hash FROM translations WHERE proposal_id = ? AND lang = ?", ) .bind(&id) .bind(lang) .fetch_one(&state.db) .await .expect("translation"); let v = version(&today, code); assert_eq!( (t.as_str(), b.as_str()), (v["title"][lang].as_str().expect("t"), v["body"][lang].as_str().expect("b")) ); assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date"); } } } let expected: Vec<(String, String, String, Option)> = ["essai", "monnaie-libre"] .iter() .flat_map(|g| { CODES.iter().map(move |c| { let kept = *g == "essai" && *c == "N1"; ( (*g).to_owned(), (*c).to_owned(), if kept { "refused" } else { "applied" }.to_owned(), kept.then(|| "votes".to_owned()), ) }) }) .collect(); assert_eq!(decisions(&state).await, expected); // Monnaie libre republishes, once per article; the test group never. 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 '%F1.2%' OR o.reason LIKE '%N1%' OR o.reason LIKE '%O1%' ORDER BY o.id", ) .fetch_all(&state.db) .await .expect("outbox"); assert_eq!(queued.len(), 4, "{queued:?}"); assert!(queued.iter().all(|(g, _)| g == "monnaie-libre"), "{queued:?}"); // Decided once: a second start changes nothing. let again = restart(&state).await; assert_eq!(decisions(&again).await, expected); let (_, _, _, revision) = proposal(&again, "monnaie-libre", "N1.1").await; assert_eq!(revision, 2); } #[tokio::test] async fn a_base_born_on_the_seed_of_m8_holds_its_rules() { let db = open_db("sqlite::memory:").await.expect("db"); let mut cfg = Config::for_tests(); cfg.seed_dir = m8_dir(); let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); let today = today_seed(); for group in ["essai", "monnaie-libre"] { for code in CODES { let (_, title, body, revision) = proposal(&state, group, code).await; assert_eq!(((title.as_str(), body.as_str()), revision), (fr(version(&today, code)), 1), "{group} {code}"); } } // N1 and O1: present. F1.2 and N1.1: their amendments, superseded, decide nothing. let got = decisions(&state).await; assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); let units: Vec<&str> = got.iter().map(|(_, u, _, _)| u.as_str()).collect(); assert_eq!(units, ["N1", "O1", "N1", "O1"]); }