forked from yvv/decision
- Le seed : « Formule du seuil WoT », « Paramètres par défaut », « Niveaux d'inertie » réécrits d'après l'ADR 0013 (sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran) et l'ADR 0011 (aucun article en vigueur au départ, les trois Propositions seed au cran 2, le titre au cran 4) ; le _source le dit. - Les nombres : chacun, en fr, en et es, calculé par le moteur ou lu dans le seed (tests/seed_quorum_texts.rs) ; les trois formules évaluées telles qu'écrites contre threshold et inertia ; les règles de forme vérifiées. - seed_amend : 2026-11-03-f1-1-quorum, -f1-2-quorum, -n1-1-quorum. Un amendement qui « follows » un autre admet la révision 2 « initial puis amendment » écrite par lui seul, rien d'autre ; ceux de M5 sur F1.2 et N1.1 sont « superseded_by » (plus rien décidé, sans erreur). La garde compte aussi les lignes de bulletin du tampon (« ballots »). - Tests vus rouges : sept du lot T, deux unitaires de la garde ; les tests de M5 jouent sur le seed de M5 (la fixture de production). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
10 KiB
Rust
236 lines
10 KiB
Rust
//! 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`).
|
|
|
|
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"))
|
|
}
|
|
|
|
fn today_seed() -> Value {
|
|
read(Config::for_tests().seed_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<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-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 another text whose amendment follows this one.
|
|
let (t, b) = fr(version(&today, code));
|
|
let now = seed_amend::fingerprint(t, b);
|
|
match a.superseded_by {
|
|
None => assert_eq!(now, a.to, "{code}: still the text of today"),
|
|
Some(later) => {
|
|
let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment");
|
|
assert_eq!((l.unit, l.follows, l.from, l.to), (code, Some(a.id), a.to, now.as_str()), "{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<String> = 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<String>)> = ["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_new_base_is_born_with_the_rules_of_today() {
|
|
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");
|
|
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"]);
|
|
}
|