Files
decision/crates/ld-server/tests/seed_rules.rs
T
YvvandClaude Opus 5.5 92ba485f71 Lot T : F1.1, F1.2 et N1.1 disent le quorum par cran (seed, trois langues) ; un second amendement de seed gardé les remplace en production
- 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>
2026-11-03 07:16:57 +01:00

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"]);
}