forked from yvv/decision
- seed_amend : 2026-11-03-f1-1-phrases, -f1-2-phrases, -n1-1-phrases ; la garde admet la révision écrite par la chaîne des amendements suivis (earlier), dans leur ordre, le dernier étant celui de M6b (F1.1 en révision 2, F1.2 et N1.1 en révision 3) ; les amendements de M6b dépassés ; le commentaire dit les deux votes qui ne laissent pas de trace (vote direct retiré dans le groupe d'essai, votes simulés après « supprime ») - tests : base faite comme la production après M6b (fixture seed-quorum-m6b.json), appliqué une fois, refus votes et ballots, second démarrage sans rejeu, base née le 02/11 ; les phrases et leur preuve par le moteur ; le quorum de nouveau réglable après un vote direct retiré et après « supprime » - moteur : la part exigée au quorum en production (W 5 707) : 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % (la relecture disait 74 au cran 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
10 KiB
Rust
237 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 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 {
|
|
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");
|
|
}
|
|
}
|
|
|
|
#[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"]);
|
|
}
|