Lot T4 : les tests d'avant suivent le seed de T4 sans rien perdre de ce qu'ils prouvent

Les tests qui lisaient F, N ou O rejouent leurs transitions sur le seed de M8
(tests/fixtures/seed-m8) : seed_quorum_texts, seed_rules, seed_proposals, api,
quorum ; ils disent en plus que ces amendements sont dépassés par le retrait
(superseded_by). Les comptes suivent le document de T4 : 27 articles, quatre
Propositions seed (I1, E1, E12, E2), 33 modules à voter (27 articles, le titre,
l'ordre de 5 sections). modules.rs : l'ordre sous vote se rejoue sur
« conclusion » (K1, K2) ; « un ordre d'un seul article n'a rien à décider » sur
une base née du seed de M8 (O1). seed_amend : tests unitaires de
Version::SeedProposal et de la ligne seed_proposal (put_forward). order.rs ne lit
que les colonnes d'avant 0012 : sections_now ne sert qu'à la naissance des
modules, et article_amend n'en garde que les articles vivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 21:21:44 +01:00
co-authored by Claude Opus 5.5
parent dda1ca85dd
commit 093d1ca471
14 changed files with 384 additions and 189 deletions
+1 -3
View File
@@ -232,9 +232,7 @@ async fn where_to(
.into_iter()
.find(|(s, _)| s == section)
.map(|(_, ids)| {
ids.iter()
.filter_map(|id| articles.iter().find(|x| &x.0 == id).map(|x| (x.1.clone(), x.3)))
.collect()
ids.iter().filter_map(|id| articles.iter().find(|x| &x.0 == id).map(|x| (x.1.clone(), x.3))).collect()
})
.unwrap_or_default();
let positions: Vec<f64> = articles.iter().map(|x| x.3).collect();
+4 -4
View File
@@ -37,14 +37,14 @@ pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Re
position: f64,
visible: bool,
}
// Seen: a text in force, or an open version (`DocState::members` says the same); never a
// unit out of the document (ADR 0014).
// Seen: a text in force, or an open version (`DocState::members` says the same). It reads
// only the columns of before 0012: a document born now, or given its modules by an
// amendment, holds no retired unit (ADR 0014); `article_amend` keeps the live ones.
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.section, u.position,
(u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p
WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible
FROM units u WHERE u.doc_id = ? AND u.kind = 'article' AND u.retired_at IS NULL
ORDER BY u.position, u.id",
FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id",
)
.bind(doc_id)
.fetch_all(&mut *conn)
+23 -15
View File
@@ -18,7 +18,7 @@
//! quorum or an order (the unit is not found: 404), nor counted (`docs::group`,
//! `overview::modules`), nor published (`publish`: `document.json` and the Markdown read the
//! document's state; `waits_for_a_text` counts the articles in it). The order of a section
//! reads its articles in it (`order::sections_now`, `DocState::members`). An older image
//! reads its articles in it (`DocState::members`). An older image
//! (rollback) ignores the column and shows them again, exactly as they were.
//!
//! **The guard**, decided once per document, all of them or none: they leave only if no
@@ -147,8 +147,12 @@ pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Retirement]) ->
for (doc_id, group, publish_path) in docs {
let Some((v, retired)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
match &v {
Verdict::Apply => tracing::info!(amendment = a.id, %group, retired = ?retired, "units out of the document"),
Verdict::Present => tracing::info!(amendment = a.id, %group, "retirement: none of them in the document"),
Verdict::Apply => {
tracing::info!(amendment = a.id, %group, retired = ?retired, "units out of the document")
}
Verdict::Present => {
tracing::info!(amendment = a.id, %group, "retirement: none of them in the document")
}
Verdict::Refuse(why, unit) => tracing::warn!(
amendment = a.id, %group, reason = why, %unit,
"retirement refused: a person left a trace, they stay in the document"
@@ -203,11 +207,13 @@ async fn unit_facts(conn: &mut SqliteConnection, unit_id: &str, code: &str) -> s
)
.fetch_one(&mut *conn)
.await?,
corrected: n("SELECT (SELECT COUNT(*) FROM typos
corrected: n(
"SELECT (SELECT COUNT(*) FROM typos
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1))
+ (SELECT COUNT(*) FROM revisions
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)
AND (author_id IS NOT NULL OR kind NOT IN ('initial', 'amendment', 'seed_proposal')))")
AND (author_id IS NOT NULL OR kind NOT IN ('initial', 'amendment', 'seed_proposal')))",
)
.fetch_one(&mut *conn)
.await?,
member_translations: n("SELECT COUNT(*) FROM translations
@@ -267,12 +273,13 @@ async fn decide(
for (id, code) in &units {
// Only the two columns of the retirement; nothing else of the unit, nothing of its
// versions, revisions or translations.
let done = sqlx::query("UPDATE units SET retired_at = ?, retired_reason = ? WHERE id = ? AND retired_at IS NULL")
.bind(&now)
.bind(a.id)
.bind(id)
.execute(&mut *tx)
.await?;
let done =
sqlx::query("UPDATE units SET retired_at = ?, retired_reason = ? WHERE id = ? AND retired_at IS NULL")
.bind(&now)
.bind(a.id)
.bind(id)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
retired.push(code.clone());
}
@@ -350,10 +357,11 @@ mod tests {
assert_eq!(verdict(&all), Verdict::Refuse(why, "N1".into()), "{facts:?}");
}
// The first trace, in the order of the units, then of the kinds.
let two = [untouched("F1"), UnitFacts { signals: 1, votes: 2, ..untouched("F1.1") }, UnitFacts {
quorum_set: 1,
..untouched("N1")
}];
let two = [
untouched("F1"),
UnitFacts { signals: 1, votes: 2, ..untouched("F1.1") },
UnitFacts { quorum_set: 1, ..untouched("N1") },
];
assert_eq!(verdict(&two), Verdict::Refuse("votes", "F1.1".into()));
}
+55
View File
@@ -944,6 +944,57 @@ mod tests {
assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &third, &["m5", "t"]), Verdict::Refuse("revised"));
}
/// A version the seed proposal amendment put forward (ADR 0011), as production holds I1,
/// E1 and E2 since M6: over the texts `applied` wrote, one line `seed_proposal` that
/// changes no text, at the same revision.
fn put_forward(applied: &[&str]) -> Facts {
let mut f = written_by(applied);
f.revisions.push("seed_proposal".into());
f.put_forward = 1;
f
}
#[test]
fn a_seed_proposal_is_rewritten_over_the_line_that_put_it_forward_and_nothing_else() {
// Lot T4 (2026-11-03): I1 follows the amendment of 30/10, E1 and E2 follow none.
let i1 = Amendment { version: Version::SeedProposal, follows: Some("r"), ..A };
let e1 = Amendment { version: Version::SeedProposal, ..A };
// Production since M6: the text of 30/10 (I1) or the seed's (E1), then put forward.
assert_eq!(verdict(Some(&put_forward(&["r"])), &i1, &["r"]), Verdict::Apply);
assert_eq!(verdict(Some(&put_forward(&[])), &e1, &[]), Verdict::Apply);
// A base born with the seed proposals: nothing put them forward, revision 1.
assert_eq!(verdict(Some(&intact()), &i1, &["r"]), Verdict::Apply);
assert_eq!(verdict(Some(&intact()), &e1, &[]), Verdict::Apply);
// The new text already there.
let present = Facts { fingerprint: "new".into(), ..put_forward(&[]) };
assert_eq!(verdict(Some(&present), &e1, &[]), Verdict::Present);
// No seed proposal: a base older than M6, or a version voted into force or withdrawn.
assert_eq!(verdict(None, &e1, &[]), Verdict::Refuse("no_seed_proposal"));
// A line `seed_proposal` that no amendment wrote, or one more than they wrote.
let unwritten = Facts { put_forward: 0, ..put_forward(&[]) };
assert_eq!(verdict(Some(&unwritten), &e1, &[]), Verdict::Refuse("corrected"));
let mut twice = put_forward(&[]);
twice.revisions.push("seed_proposal".into());
assert_eq!(verdict(Some(&twice), &e1, &[]), Verdict::Refuse("corrected"));
// A typo once put forward: a revision no amendment wrote.
let mut typo = put_forward(&["r"]);
typo.revision = 3;
typo.revisions.push("typo".into());
assert_eq!(verdict(Some(&typo), &i1, &["r"]), Verdict::Refuse("revised"));
// Any trace of a person keeps the text, with its reason.
for (f, why) in [
(Facts { votes: 1, ..put_forward(&[]) }, "votes"),
(Facts { ballots: 1, ..put_forward(&[]) }, "ballots"),
(Facts { typos: 1, ..put_forward(&[]) }, "corrected"),
(Facts { member_translations: 1, ..put_forward(&[]) }, "translation_edited"),
(Facts { signals: 1, ..put_forward(&[]) }, "signalled"),
(Facts { fingerprint: "other".into(), ..put_forward(&[]) }, "text_differs"),
(Facts { origin: "member".into(), ..put_forward(&[]) }, "not_a_seed"),
] {
assert_eq!(verdict(Some(&f), &e1, &[]), Verdict::Refuse(why), "{f:?}");
}
}
#[test]
fn the_amendments_followed_are_read_through_the_list_oldest_first() {
let by_id = |id: &str| AMENDMENTS.iter().find(|a| a.id == id).expect("amendment");
@@ -965,6 +1016,10 @@ mod tests {
["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum", "2026-11-03-n1-1-phrases"]
);
assert!(earlier(AMENDMENTS, by_id("2026-10-30-i1-raison-d-etre")).is_empty());
// Lot T4: I1 follows the text of 30/10; E1 and E2, the seed's.
assert_eq!(earlier(AMENDMENTS, by_id("2026-11-03-i1-amorce")), ["2026-10-30-i1-raison-d-etre"]);
assert!(earlier(AMENDMENTS, by_id("2026-11-03-e1-amorce")).is_empty());
assert!(earlier(AMENDMENTS, by_id("2026-11-03-e2-amorce")).is_empty());
// Each one followed wrote the text the next replaces, and is superseded by it.
for a in AMENDMENTS.iter().filter(|a| a.follows.is_some()) {
let e = by_id(a.follows.expect("follows"));
+73 -33
View File
@@ -42,8 +42,7 @@ const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
/// he wrote « il n'y a une et une seule » (read as a slip, the parallel of the first half);
/// the quotation keeps the non-breaking spaces of the one it replaces.
const I1_FR: &str = "*«\u{a0}Derrière une clé il y a un et un seul être humain vivant\u{a0}; derrière un être humain, il y a une et une seule clé certifiante.\u{a0}»*\n\nC'est l'unique enjeu de la certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé par cette clé.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle, qui fonde l'usage de la première monnaie libre.";
const E1_FR: &str =
"Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant.";
const E1_FR: &str = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant.";
const E12_TITLE: [(&str, &str); 3] =
[("fr", "Responsabilité civique"), ("en", "Civic responsibility"), ("es", "Responsabilidad cívica")];
const E12_FR: &str = "Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la responsabilité civique, pour certifier à mon tour.";
@@ -364,22 +363,28 @@ async fn rows(state: &AppState, table: &str, skip: &[&str], filter: &str) -> Vec
.fetch_all(&state.db)
.await
.expect("columns");
let cols: Vec<String> = columns.iter().filter(|c| !skip.contains(&c.as_str())).map(|c| format!("quote(\"{c}\")")).collect();
let cols: Vec<String> =
columns.iter().filter(|c| !skip.contains(&c.as_str())).map(|c| format!("quote(\"{c}\")")).collect();
let sql = format!("SELECT {} FROM \"{table}\" WHERE {filter} ORDER BY rowid", cols.join(" || '|' || "));
sqlx::query_scalar(&sql).fetch_all(&state.db).await.expect("rows")
}
/// The units that leave, by section (their articles and their orders).
const OF_THE_OUT: &str = "section IN ('formule', 'inertie', 'ordonnancement')";
const OF_THEIR_VERSIONS: &str =
"proposal_id IN (SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.section IN ('formule', 'inertie', 'ordonnancement'))";
const OF_THEIR_VERSIONS: &str = "proposal_id IN (SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.section IN ('formule', 'inertie', 'ordonnancement'))";
/// What the articles of operation hold: units (without the columns of the retirement),
/// their versions, revisions, translations.
async fn the_rules_of_operation(state: &AppState) -> [Vec<String>; 4] {
[
rows(state, "units", &["retired_at", "retired_reason"], OF_THE_OUT).await,
rows(state, "proposals", &[], "unit_id IN (SELECT id FROM units WHERE section IN ('formule', 'inertie', 'ordonnancement'))").await,
rows(
state,
"proposals",
&[],
"unit_id IN (SELECT id FROM units WHERE section IN ('formule', 'inertie', 'ordonnancement'))",
)
.await,
rows(state, "revisions", &[], OF_THEIR_VERSIONS).await,
rows(state, "translations", &[], OF_THEIR_VERSIONS).await,
]
@@ -461,7 +466,11 @@ async fn a_new_base_is_born_with_the_texts_of_t4_and_without_the_rules_of_operat
let u = unit(&doc, code).expect(code);
let p = &u["proposals"][0];
assert_eq!(p["body"], new_text(code), "{group} {code}");
assert_eq!((p["status"].as_str(), p["seedProposal"].as_bool()), (Some("open"), Some(true)), "{group} {code}");
assert_eq!(
(p["status"].as_str(), p["seedProposal"].as_bool()),
(Some("open"), Some(true)),
"{group} {code}"
);
assert_eq!(p["revision"], 1, "{group} {code}");
assert_eq!(u["inertia"], LOW, "{group} {code}");
assert!(u["inForce"].is_null(), "{group} {code}");
@@ -489,7 +498,10 @@ async fn a_new_base_is_born_with_the_texts_of_t4_and_without_the_rules_of_operat
let present = |g: &str| (g.to_owned(), "present".to_owned(), None, None);
assert_eq!(retirements(&state).await, [present("essai"), present("monnaie-libre")]);
let added = additions(&state).await;
assert_eq!(added.iter().map(|a| (a.0.as_str(), a.1.as_str())).collect::<Vec<_>>(), [("essai", "present"), ("monnaie-libre", "present")]);
assert_eq!(
added.iter().map(|a| (a.0.as_str(), a.1.as_str())).collect::<Vec<_>>(),
[("essai", "present"), ("monnaie-libre", "present")]
);
let rw = rewrites(&state).await;
assert_eq!(rw.len(), 6, "{rw:?}");
assert!(rw.iter().all(|(_, _, o, _)| o == "present"), "{rw:?}");
@@ -507,11 +519,8 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
for code in AMORCE {
let (id, status, revision, _, _, featured) = seed_version(&base, group, code).await;
assert_eq!((status.as_str(), featured), ("open", true), "{group} {code}");
let expected: &[&str] = if code == "I1" {
&["initial", "amendment", "seed_proposal"]
} else {
&["initial", "seed_proposal"]
};
let expected: &[&str] =
if code == "I1" { &["initial", "amendment", "seed_proposal"] } else { &["initial", "seed_proposal"] };
assert_eq!(kinds(&base, &id).await, expected, "{group} {code}");
assert_eq!(revision, if code == "I1" { 2 } else { 1 }, "{group} {code}");
}
@@ -523,7 +532,9 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
let before = tables(&base).await;
let before_rows = rowids(&base).await;
let kept = the_rules_of_operation(&base).await;
let untouched_versions = rows(&base, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2'))").await;
let untouched_versions =
rows(&base, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2'))")
.await;
let state = t4(&base).await;
// Taken at once, before anyone signs in (a session writes its identity).
@@ -547,7 +558,11 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
let v = &seed_unit(&today, code)["versions"][0];
for lang in ["en", "es"] {
let (t, b, hash, origin, by) = translation(&state, &id, lang).await;
assert_eq!((json!(t), json!(b)), (v["title"][lang].clone(), v["body"][lang].clone()), "{group} {code} {lang}");
assert_eq!(
(json!(t), json!(b)),
(v["title"][lang].clone(), v["body"][lang].clone()),
"{group} {code} {lang}"
);
assert_eq!(hash, ld_server::seed_amend::fingerprint(&title, &body), "{group} {code} {lang}");
assert_eq!((origin.as_str(), by), ("seed", None));
}
@@ -565,14 +580,17 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
assert_eq!((title.as_str(), body.as_str()), ("Responsabilité civique", E12_FR), "{group}");
assert_eq!((status.as_str(), revision, featured), ("open", 1, true), "{group}");
assert_eq!(kinds(&state, &id).await, ["initial"], "{group}");
let (section, position, inertia, in_force): (String, f64, String, Option<String>) = sqlx::query_as(
"SELECT section, position, inertia, in_force_id FROM units WHERE id = ?",
)
.bind(unit_id(&state, group, NEW).await)
.fetch_one(&state.db)
.await
.expect("unit");
assert_eq!((section.as_str(), position, inertia, in_force), ("fondamental", 4.5, LOW.to_string(), None), "{group}");
let (section, position, inertia, in_force): (String, f64, String, Option<String>) =
sqlx::query_as("SELECT section, position, inertia, in_force_id FROM units WHERE id = ?")
.bind(unit_id(&state, group, NEW).await)
.fetch_one(&state.db)
.await
.expect("unit");
assert_eq!(
(section.as_str(), position, inertia, in_force),
("fondamental", 4.5, LOW.to_string(), None),
"{group}"
);
for (lang, t) in E12_TITLE.iter().skip(1) {
let (title_tr, _, hash, origin, _) = translation(&state, &id, lang).await;
assert_eq!((title_tr.as_str(), origin.as_str()), (*t, "seed"), "{group} {lang}");
@@ -586,10 +604,11 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
for group in GROUPS {
assert_eq!(retired(&state, group).await, all_out(), "{group}");
}
let reasons: Vec<String> = sqlx::query_scalar(&format!("SELECT DISTINCT retired_reason FROM units WHERE {OF_THE_OUT}"))
.fetch_all(&state.db)
.await
.expect("reasons");
let reasons: Vec<String> =
sqlx::query_scalar(&format!("SELECT DISTINCT retired_reason FROM units WHERE {OF_THE_OUT}"))
.fetch_all(&state.db)
.await
.expect("reasons");
assert_eq!(reasons, [RETIRE]);
assert_eq!(the_rules_of_operation(&state).await, kept, "their rows, as they were");
@@ -636,9 +655,22 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
let was = before.get(t).copied().unwrap_or(0);
assert_eq!(n - was, added.get(t.as_str()).copied().unwrap_or(0), "{t}: {was} → {n}");
}
eprintln!("lot T4, rows by table (before → after): {}", after.iter().map(|(t, n)| format!("{t} {} → {n}", before.get(t).copied().unwrap_or(0))).collect::<Vec<_>>().join(", "));
eprintln!(
"lot T4, rows by table (before → after): {}",
after
.iter()
.map(|(t, n)| format!("{t} {} → {n}", before.get(t).copied().unwrap_or(0)))
.collect::<Vec<_>>()
.join(", ")
);
// Every other version untouched.
let others = rows(&state, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2', 'E12'))").await;
let others = rows(
&state,
"proposals",
&[],
"unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2', 'E12'))",
)
.await;
assert_eq!(others, untouched_versions);
// The queue of Monnaie libre: a row for each change, waiting for the first text.
let queued: Vec<(String, String)> = sqlx::query_as(
@@ -657,7 +689,10 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
let again = t4(&state).await;
assert_eq!(tables(&again).await, settled);
let unchanged = |t: &BTreeMap<String, i64>| {
t.iter().filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships")).map(|(k, n)| (k.clone(), *n)).collect::<Vec<_>>()
t.iter()
.filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships"))
.map(|(k, n)| (k.clone(), *n))
.collect::<Vec<_>>()
};
assert_eq!(unchanged(&settled), unchanged(&after));
assert_eq!(retirements(&again).await, [mark("essai"), mark("monnaie-libre")]);
@@ -843,7 +878,8 @@ async fn leave(state: &AppState, trace: Trace) {
Trace::Typo => {
let (p, _, _, title, body, _) = id("F1.1").await;
let fixed = body.replacen('\'', "\u{2019}", 1);
let v = vera.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": title, "body": fixed }))).await;
let v =
vera.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": title, "body": fixed }))).await;
assert_eq!(v["edit"], "typo_pending", "{v}");
}
Trace::Translation => {
@@ -1002,7 +1038,8 @@ async fn an_image_that_ignores_the_column_shows_the_rules_of_operation_as_they_w
let base = production_after_m8("rollback").await;
let mut c = Client::as_(&base, "Chiara", false).await;
let view = |doc: &Value| -> Vec<Value> {
let units = doc["units"].as_array().expect("units").iter().filter(|u| OUT.contains(&u["code"].as_str().unwrap_or("")));
let units =
doc["units"].as_array().expect("units").iter().filter(|u| OUT.contains(&u["code"].as_str().unwrap_or("")));
let orders = doc["modules"]["orders"]
.as_array()
.expect("orders")
@@ -1021,7 +1058,10 @@ async fn an_image_that_ignores_the_column_shows_the_rules_of_operation_as_they_w
assert!(view(&c.doc(group).await).is_empty(), "{group}: retired");
}
// The image of before (de1b7eb) reads `units` without the column: as here with it empty.
sqlx::query("UPDATE units SET retired_at = NULL").execute(&state.db).await.expect("as the image of before reads it");
sqlx::query("UPDATE units SET retired_at = NULL")
.execute(&state.db)
.await
.expect("as the image of before reads it");
let mut c = Client::as_(&state, "Chiara", false).await;
for (i, group) in GROUPS.iter().enumerate() {
assert_eq!(view(&c.doc(group).await), before[i], "{group}: the same units, versions, texts, translations");
+38 -10
View File
@@ -442,7 +442,8 @@ async fn previews_and_assistant() {
a.login("Yvv", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 33);
// 27 articles since lot T4 (the articles of operation out of the document, ADR 0014; E12 in).
assert_eq!(v["units"].as_array().expect("u").len(), 27);
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes");
let (_, v) = a
@@ -951,11 +952,21 @@ const PREAMBULE: [(&str, &str, &str); 3] = [
const RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification";
/// The Raison d'être since lot T4 (Yvv, 03/11): its beginning.
const RAISON_D_ETRE_T4: &str = "*«\u{a0}Derrière une clé il y a un et un seul être humain vivant\u{a0}; derrière un être humain, il y a une et une seule clé certifiante.\u{a0}»*\n\nC'est l'unique enjeu de la certification";
fn today_seed() -> Value {
let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed of M8 (de1b7eb): the last that gave I1 the Raison d'être of 30/10.
fn m8_seed() -> Value {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/seed-m8/acte-engagement-certification.json");
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed production runs, I1 in force: the amendment of 30/10 is replayed on it.
fn production_seed() -> Value {
let path = production_seed_dir().join("acte-engagement-certification.json");
@@ -1026,15 +1037,24 @@ async fn a_new_base_starts_with_the_raison_d_etre() {
for group in ["monnaie-libre", "essai"] {
let i1 = i1_of(&mut c, group).await;
assert_eq!(i1["title"], "Raison d'être");
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE_T4), "{i1}");
assert_eq!((i1["revision"].as_i64(), i1["seedProposal"].as_bool()), (Some(1), Some(true)));
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
let tr = &i1["translations"][lang];
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
}
}
// Born with the text, put forward: nothing to replace, and it is said once.
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
// Born with the text of lot T4, put forward: nothing to replace, and it is said once; the
// amendment of 30/10, superseded, decides nothing on a new base.
assert!(decisions(&state).await.is_empty());
let t4: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, s.outcome FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
WHERE s.amendment_id = '2026-11-03-i1-amorce' ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions");
assert_eq!(t4, [("essai".to_owned(), "present".to_owned()), ("monnaie-libre".to_owned(), "present".to_owned())]);
}
#[tokio::test]
@@ -1043,13 +1063,21 @@ async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
assert_eq!(a.doc, "acte-engagement-certification");
let (_, title, body) = PREAMBULE[0];
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold");
let seed = today_seed();
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
let v = &i1["versions"][0];
// Today the starting text is a seed proposal (ADR 0011); the text is the same.
let i1_of = |seed: &Value| -> (String, String, Value) {
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
let v = &i1["versions"][0];
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
(title.to_owned(), body.to_owned(), v.clone())
};
// Until M8 the starting text is a seed proposal (ADR 0011); the text is the same.
let (title, body, v) = i1_of(&m8_seed());
assert_eq!((v["status"].as_str(), v["seedProposal"].as_bool()), (Some("open"), Some(true)));
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), a.to, "the text of the seed of M8");
// Lot T4 replaces it, after it only (Yvv's text of 03/11): this one decides nothing more.
let t4 = ld_server::seed_amend::AMENDMENTS.iter().find(|x| x.id == "2026-11-03-i1-amorce").expect("lot T4");
assert_eq!((t4.from, t4.follows, a.superseded_by), (a.to, Some(a.id), Some(t4.id)));
let (title, body, _) = i1_of(&today_seed());
assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), t4.to, "the text of the seed today");
}
#[tokio::test]
+4 -4
View File
@@ -794,10 +794,10 @@ async fn a_session_sees_its_own_ballots_only() {
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
let theirs = other.doc("monnaie-libre").await;
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
// « Décisions » counts the same way (27 articles, the title, 5 orders; lot T4, ADR 0014).
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
assert_eq!(d["canVote"], true);
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32)));
}
#[tokio::test]
@@ -2271,12 +2271,12 @@ async fn on_a_base_like_production_the_three_migrations_pass_in_order_and_0010_r
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("0009 to 0011");
all.run(&pool).await.expect("0009 to 0012");
let order: Vec<i64> = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid")
.fetch_all(&pool)
.await
.expect("order");
assert_eq!(order, (1..=11).collect::<Vec<i64>>(), "0009, then 0010, then 0011");
assert_eq!(order, (1..=12).collect::<Vec<i64>>(), "0009, then 0010, then 0011 (and 0012 since lot T4)");
assert_eq!(tables(pool.clone()).await, written, "no row rewritten");
let added: Vec<(String, String, Option<String>)> = sqlx::query_as(
"SELECT b.id, b.mode, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id ORDER BY b.id",
+3 -3
View File
@@ -112,7 +112,7 @@ 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, low for the three seed proposals (ADR 0011).
// 3 everywhere, low for the four seed proposals (ADR 0011; E12 since lot T4).
let other: Vec<&str> = doc["units"]
.as_array()
.expect("units")
@@ -120,8 +120,8 @@ async fn the_five_levels_and_the_scale_are_served() {
.filter(|u| u["inertia"] != 3)
.map(|u| u["code"].as_str().expect("code"))
.collect();
assert_eq!(other, ["I1", "E1", "E2"]);
assert!(["I1", "E1", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW));
assert_eq!(other, ["I1", "E1", "E12", "E2"]);
assert!(["I1", "E1", "E12", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW));
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
let p = &doc["doc"]["participation"];
+58 -43
View File
@@ -146,8 +146,9 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() {
c.login("Chiara").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
// The articles stay the articles: 33 of them, none of the modules.
assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}");
// The articles stay the articles: 27 of them, none of the modules (since lot T4, the
// rules of operation are out of the document, ADR 0014).
assert_eq!(doc["units"].as_array().expect("units").len(), 27, "{group}");
let title = &doc["modules"]["title"];
assert_eq!(
(title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()),
@@ -163,27 +164,15 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() {
// One order per section, in the order of the document, each at level 2.
let orders = doc["modules"]["orders"].as_array().expect("orders");
let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect();
assert_eq!(
sections,
[
"introduction",
"fondamental",
"technique",
"conclusion",
"annexe",
"formule",
"inertie",
"ordonnancement"
]
);
assert_eq!(sections, ["introduction", "fondamental", "technique", "conclusion", "annexe"]);
assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true));
// Its version in force is the order the section reads in now.
let fondamental = order_of(&doc, "fondamental");
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]);
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E12", "E2"]);
assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]);
assert_eq!(fondamental["moot"], false);
// « Ordonnancement » holds one article: nothing to decide.
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true);
// Every section holds two articles or more: each order has something to decide
// (one of a single article: `an_order_of_one_article_has_nothing_to_decide`).
assert!(orders.iter().all(|o| o["moot"] == false), "{group}");
}
// Born with them: the addition says so, once per document.
let rows: Vec<(String, String)> = sqlx::query_as(
@@ -197,11 +186,35 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() {
let doc = c.doc("monnaie-libre").await;
let id = doc["doc"]["id"].as_str().expect("id");
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(all["units"].as_array().expect("units").len(), 33);
// The list of the group: the articles in force — none, I1, E1 and E2 are seed proposals
// (ADR 0011), three more open versions —; no module has an open proposal yet.
assert_eq!(all["units"].as_array().expect("units").len(), 27);
// The list of the group: the articles in force — none, I1, E1, E12 and E2 are seed
// proposals (ADR 0011) —, one open version per article and two more (E3, E7); no module
// has an open proposal yet.
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)));
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29)));
}
/// The seed of M8 (tests/fixtures/seed-m8): « Ordonnancement » held one article, O1. Lot T4
/// takes that section out of the document (ADR 0014); a base born on that seed still has it.
fn m8_config() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8");
cfg
}
#[tokio::test]
async fn an_order_of_one_article_has_nothing_to_decide() {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(m8_config(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut c = Client::of(&state);
c.login("Chiara").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
assert_eq!(codes(&doc, &order_of(&doc, "ordonnancement")["members"]), ["O1"], "{group}");
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true, "{group}");
assert_eq!(order_of(&doc, "conclusion")["moot"], false, "{group}");
}
}
#[tokio::test]
@@ -251,7 +264,7 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f
assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed")));
assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]);
// Nothing else moved: the other sections read as before, the texts are the same.
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]);
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2"]);
assert_eq!(read_order(&doc, "technique")[0], "T2");
}
@@ -259,12 +272,12 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
let (mut yvv, mut chiara, state) = two().await;
// The order stays under vote with one voice.
two_voices(&state, "ordre.inertie").await;
two_voices(&state, "ordre.conclusion").await;
let doc = yvv.doc("essai").await;
let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned();
let module = order_of(&doc, "conclusion")["id"].as_str().expect("id").to_owned();
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1"));
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"]
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [k2, k1] }))).await["id"]
.as_str()
.expect("id")
.to_owned();
@@ -286,20 +299,22 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })),
Some(
json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel pour finir.", "submit": true }),
),
)
.await;
let doc = yvv.doc("essai").await;
let fresh = unit(&doc, "N1")["code"]
let fresh = unit(&doc, "K1")["code"]
.as_str()
.map(|_| ())
.and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned());
let fresh = fresh.expect("the new article");
let new_code = fresh["code"].as_str().expect("code").to_owned();
assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end");
let module_now = order_of(&doc, "inertie");
assert_eq!(read_order(&doc, "conclusion"), ["K1", "K2", new_code.as_str()], "a new article goes at the end");
let module_now = order_of(&doc, "conclusion");
let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]);
assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1", new_code.as_str()]);
assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]);
// Its vote stays: completing an order never asks the voters again.
assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for")));
@@ -307,47 +322,47 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
let added = created["id"].as_str().expect("id");
chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await;
let doc = yvv.doc("essai").await;
let open = order_of(&doc, "inertie")["proposals"]
let open = order_of(&doc, "conclusion")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == p.as_str())
.cloned()
.expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]);
assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1"]);
assert!(open["order"]["added"].as_array().expect("a").is_empty());
// An order proposed with three articles, one of which then leaves: dropped from it.
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
Some(json!({ "section": "conclusion", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
)
.await;
let other = created["unitId"].as_str().expect("unit").to_owned();
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, k2, k1] }))).await
["id"]
.as_str()
.expect("id")
.to_owned();
chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await;
let doc = yvv.doc("essai").await;
let o = order_of(&doc, "inertie")["proposals"]
let o = order_of(&doc, "conclusion")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == three.as_str())
.cloned()
.expect("o");
assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]);
assert_eq!(codes(&doc, &o["order"]["ids"]), ["K2", "K1"]);
assert_eq!(o["order"]["dropped"], json!([other]));
// Completed, it reads like the first one: the second voice brings that order in.
let v = chiara.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "order");
let doc = yvv.doc("essai").await;
assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]);
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
// Now the order in force: the other one changes nothing more and cannot enter.
let o = order_of(&doc, "inertie")["proposals"]
let o = order_of(&doc, "conclusion")["proposals"]
.as_array()
.expect("p")
.iter()
@@ -503,10 +518,10 @@ async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
.to_owned()
};
let (ca, cb) = (code(&a), code(&b));
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]);
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2", ca.as_str()]);
assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]);
// It joins the order of its section, at its end.
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]);
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E12", "E2", ca.as_str()]);
}
// ---------------------------------------------------------------------------
+15 -15
View File
@@ -169,11 +169,11 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
// 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title
// of the document and the order of 7 sections (« ordonnancement » holds one article: nothing
// to decide).
assert_eq!(docs[0]["toVote"], 41);
assert_eq!(docs[0]["withoutMyVote"], 41);
// 4 seed proposals (ADR 0011) + 23 open articles, the two variants counting once; the title
// of the document and the order of its 5 sections (since lot T4, the rules of operation are
// out of the document, ADR 0014).
assert_eq!(docs[0]["toVote"], 33);
assert_eq!(docs[0]["withoutMyVote"], 33);
let doc = the_doc(&mut c, "essai").await;
// Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two.
@@ -183,9 +183,9 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
c.vote(&version(&doc, "E3", "1"), "against").await;
c.vote(&version(&doc, "I1", "0"), "for").await;
let d = decisions(&mut c, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31)));
// Someone else has not voted yet.
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
}
#[tokio::test]
@@ -201,7 +201,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
let v = yvv.vote(&e3, "for").await;
assert_eq!(v["entered"][0]["code"], "E3");
let d = decisions(&mut yvv, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32)));
// A new article by Chiara: one more module to vote.
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
@@ -216,9 +216,9 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let added = v["id"].as_str().expect("id").to_owned();
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33);
yvv.vote(&added, "against").await;
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32);
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
let (s, v) = chiara
.call(
@@ -231,7 +231,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "substantive");
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
}
#[tokio::test]
@@ -251,7 +251,7 @@ async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
assert_eq!(s, StatusCode::OK, "{v}");
// Nobody can vote on it while it is held: one module fewer to vote, for everyone.
let d = decisions(&mut yvv, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32)));
// The list of the group still counts it among the proposals in progress: it is open.
let g = yvv.get("/api/groups/essai").await;
let open = doc["units"]
@@ -271,10 +271,10 @@ async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_o
// No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012).
let d = decisions(&mut guest, "monnaie-libre").await;
assert_eq!(d["canVote"], true);
assert_eq!(d["docs"][0]["toVote"], 41);
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
assert_eq!(d["docs"][0]["toVote"], 33);
assert_eq!(d["docs"][0]["withoutMyVote"], 33);
// In the test group, the same person votes.
let d = decisions(&mut guest, "essai").await;
assert_eq!(d["canVote"], true);
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
assert_eq!(d["docs"][0]["withoutMyVote"], 33);
}
+10 -2
View File
@@ -329,6 +329,14 @@ fn production_config() -> Config {
cfg
}
/// The seed of M6 to M8 (de1b7eb), which wrote the quorums and put I1 forward: since lot T4 the
/// seed of today gives I1 another text, and a base of 02/11 keeps it in force under it.
fn m8_config() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8");
cfg
}
async fn restart(before: &AppState, cfg: Config) -> AppState {
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
@@ -374,8 +382,8 @@ async fn the_quorum_amendment_writes_the_quorums_by_level_once() {
assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50]));
assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
// Started with today's seed: written, where none was.
let state = restart(&born, Config::for_tests()).await;
// Started with the seed of M6 (and of M8): written, where none was.
let state = restart(&born, m8_config()).await;
let first = decisions(&state).await;
assert_eq!(
first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
+32 -17
View File
@@ -4,6 +4,10 @@
//! base is born with them open; an existing base gets them through a guarded
//! amendment, only where nobody has touched the text. Monnaie libre publishes no
//! document without any article in force: the queue waits for the first text.
//!
//! Lot T4 (2026-11-03): a fourth, E12 « Responsabilité civique », and Yvv's texts for the three
//! (`tests/amorce.rs`). A new base is born with the four; the bases made as production are
//! started here with the seed of M8, the last that gave the three their texts of before.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
@@ -107,7 +111,7 @@ async fn fresh_with(cfg: Config) -> AppState {
}
#[tokio::test]
async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() {
async fn a_new_base_is_born_with_four_seed_proposals_and_no_article_in_force() {
let state = fresh().await;
let mut c = Client::of(&state);
c.login("Chiara", false).await;
@@ -121,7 +125,8 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force()
.filter_map(|u| u["code"].as_str())
.collect();
assert!(in_force.is_empty(), "{group}: no article in force, {in_force:?}");
for code in CODES {
for code in CODES.iter().chain(&["E12"]) {
let code = *code;
let u = unit(&doc, code);
let p = &u["proposals"][0];
assert_eq!(
@@ -135,19 +140,20 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force()
assert!(p["translations"]["en"]["title"].is_string(), "{group} {code}: translated");
}
assert_eq!(unit(&doc, "I1")["proposals"][0]["title"], "Raison d'être");
// Only those three; the title and the orders stay seeds a minima, in force.
// Only those four; the title and the orders stay seeds a minima, in force.
let marked: Vec<&Value> = versions(&doc).into_iter().filter(|v| v["seedProposal"] == true).collect();
assert_eq!(marked.len(), 3, "{group}");
assert_eq!(marked.len(), 4, "{group}");
let title = &doc["modules"]["title"]["inForce"];
assert_eq!((title["seedMinimal"].as_bool(), title["seedProposal"].as_bool()), (Some(true), Some(false)));
assert!(
doc["modules"]["orders"].as_array().expect("orders").iter().all(|o| o["inForce"]["seedMinimal"] == true)
);
// The counts: no article in force, three more open versions; still 41 modules to vote.
// The counts: no article in force, 29 open versions (27 articles, E3 and E7 two each);
// 33 modules to vote (27 articles, the title, the order of 5 sections; lot T4).
let g = c.get(&format!("/api/groups/{group}")).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)), "{group}");
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29)), "{group}");
let d = c.get(&format!("/api/groups/{group}/decisions")).await;
assert_eq!(d["docs"][0]["toVote"], 41, "{group}");
assert_eq!(d["docs"][0]["toVote"], 33, "{group}");
// The preview of the text in force is empty; « if everything passes » shows them.
let id = doc["doc"]["id"].as_str().expect("id");
let p = c.get(&format!("/api/docs/{id}/preview")).await;
@@ -155,7 +161,8 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force()
let all = c.get(&format!("/api/docs/{id}/preview?mode=all")).await;
assert!(all["units"].as_array().expect("units").iter().any(|u| u["title"] == "Raison d'être"));
}
// Born with them: nothing to amend, and it is said once per article and group.
// Born with them: the seed proposal amendments, superseded since lot T4 (the seed puts
// forward Yvv's texts, not theirs), decide nothing; the texts of lot T4 are said present.
let decided: Vec<(String, String, String)> = sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome FROM seed_proposal_amendments s
JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code",
@@ -163,14 +170,13 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force()
.fetch_all(&state.db)
.await
.expect("decisions");
assert_eq!(decided.len(), 6, "{decided:?}");
assert!(decided.iter().all(|(_, _, o)| o == "present"), "{decided:?}");
assert!(decided.is_empty(), "{decided:?}");
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("changes");
assert_eq!(changes, 0, "born at the low level: nothing changed");
// The Raison d'être of a new base is still the text its own amendment names.
// The Raison d'être of a new base is the text its own amendment of lot T4 names.
let i1: Vec<String> = sqlx::query_scalar(
"SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-10-30-i1-raison-d-etre' ORDER BY doc_id",
"SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-11-03-i1-amorce' ORDER BY doc_id",
)
.fetch_all(&state.db)
.await
@@ -242,7 +248,16 @@ async fn production(tag: &str, cfg: Config) -> AppState {
state
}
/// The same base, started again with today's seed (and its amendments).
/// The seed of M6 for these three texts, unchanged until M8 (de1b7eb): since lot T4 (03/11) the
/// seed of today gives them Yvv's texts, and their seed proposal amendments are superseded
/// (`tests/amorce.rs`).
fn m8() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8");
cfg
}
/// The same base, started again with a later seed (and its amendments).
async fn restart(before: &AppState, cfg: Config) -> AppState {
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
@@ -367,7 +382,7 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once(
};
let translated = translations(before.clone()).await;
let state = restart(&before, Config::for_tests()).await;
let state = restart(&before, m8()).await;
let first = decisions(&state).await;
// Every text becomes a seed proposal; the level goes low where nobody touched it:
// an open change of inertia by a member keeps it (I1 and E1 in Monnaie libre).
@@ -480,7 +495,7 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once(
);
// Decided once: a second start changes nothing.
let again = restart(&state, Config::for_tests()).await;
let again = restart(&state, m8()).await;
assert_eq!(decisions(&again).await, first);
for group in GROUPS {
for code in CODES {
@@ -508,7 +523,7 @@ async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() {
let v = c.vote(&e1.id, "against").await;
assert!(v["entered"].as_array().expect("entered").is_empty());
let state = restart(&before, Config::for_tests()).await;
let state = restart(&before, m8()).await;
let first = decisions(&state).await;
assert_eq!(
outcomes(&first),
@@ -533,7 +548,7 @@ async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() {
// Decided once: the vote withdrawn, a later start does not look again.
let (s, v) = c.call("POST", &format!("/api/proposals/{}/vote", kept.id), Some(json!({ "choice": null }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let again = restart(&state, Config::for_tests()).await;
let again = restart(&state, m8()).await;
assert_eq!(decisions(&again).await, first);
assert!(version(&again, "essai", "E1").await.in_force);
}
+51 -35
View File
@@ -39,9 +39,17 @@
//! engine on one grid (`grid`): W from 100, medians under 1 % and above 50 % of W, every quorum an
//! author may set, the five levels — the closed list given to the reviewer is
//! `docs/chantiers/acte-certification/t3c-liste-fermee.md`.
//!
//! Lot T4 (2026-11-03, Yvv: « ordonnancement, inertie, et formule de vote sont des infos de
//! fonctionnement »): F1.1, F1.2 and N1.1 leave the document (ADR 0014); they are no texts to
//! vote any more. Their last texts — the seed of M8 (de1b7eb), kept in
//! `tests/fixtures/seed-m8/` — stay the reference of what the interface will say of the rules
//! (lot U3), and every truth this file checks of them against the engine still holds there. The
//! amendments that wrote them are replayed on that seed, as production played them.
use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds};
use ld_engine::threshold::{FormulaParams, Tally, judged_voters, passes, required_ratio, wot_threshold, yes_needed};
use ld_server::retire::RETIREMENT;
use ld_server::seed_amend::{self, AMENDMENTS, Version};
use ld_server::wot::PLAUSIBLE;
use ld_server::{AppState, Config, open_db, seed};
@@ -64,8 +72,15 @@ fn read(path: PathBuf) -> Value {
serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json")
}
fn today_seed() -> Value {
read(Config::for_tests().seed_dir.join("acte-engagement-certification.json"))
/// The seed production runs since M8 (de1b7eb): the last that holds F1.1, F1.2 and N1.1, and
/// the reference of their texts since lot T4 took them out of the document.
fn m8_seed() -> Value {
read(fixtures().join("seed-m8/acte-engagement-certification.json"))
}
/// A start with the seed of M8: lot T3 decides.
fn m8_config(tag: &str) -> Config {
config(seed_dir(&format!("{tag}-m8"), &m8_seed()))
}
fn fixtures() -> PathBuf {
@@ -89,7 +104,7 @@ fn m6b_texts() -> Value {
/// The seed production runs since M6b: today's, with the texts of lot T.
fn m6b_seed() -> Value {
with_texts(&today_seed(), &m6b_texts(), &CODES)
with_texts(&m8_seed(), &m6b_texts(), &CODES)
}
/// F1.1, F1.2 and N1.1 as lot T2 wrote them: production's since M6c (84f015a), unchanged by M7b.
@@ -99,7 +114,7 @@ fn t2_texts() -> Value {
/// The seed production runs since M6c: today's, with the texts of lot T2.
fn t2_seed() -> Value {
with_texts(&today_seed(), &t2_texts(), &CODES)
with_texts(&m8_seed(), &t2_texts(), &CODES)
}
fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value {
@@ -481,7 +496,7 @@ fn formulas(code: &str, lang: &str) -> Vec<String> {
/// article and language listed at once.
#[test]
fn every_number_of_the_quorum_texts_is_the_engine_s() {
let seed = today_seed();
let seed = m8_seed();
let f = figures(&seed);
let mut wrong = Vec::new();
for code in CODES {
@@ -821,7 +836,7 @@ fn t3_from_t2(code: &str, lang: &str, t2: &str) -> String {
#[test]
fn the_texts_of_lot_t3_change_these_phrases_only() {
let (t2, today) = (t2_seed(), today_seed());
let (t2, today) = (t2_seed(), m8_seed());
for code in CODES {
for lang in LANGS {
let (before, now) = (text(&t2, code, lang), text(&today, code, lang));
@@ -1006,7 +1021,7 @@ fn share_at(w: u32, voters: f64, params: &FormulaParams) -> f64 {
/// quorum of 2, two « pour » are asked, at every level, on every W.
#[test]
fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() {
let today = today_seed();
let today = m8_seed();
let said = [
(
"fr",
@@ -1104,7 +1119,7 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() {
/// quorum 12), while the share asked falls from 73,5 % to 73,2 %.
#[test]
fn the_share_asked_falls_beyond_the_article_s_quorum_down_to_m() {
let today = today_seed();
let today = m8_seed();
for (lang, phrase) in [
(
"fr",
@@ -1175,7 +1190,7 @@ fn the_share_asked_falls_beyond_the_article_s_quorum_down_to_m() {
/// asked of more voters than came; when everyone votes, M_k, at every quorum.
#[test]
fn the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quorum() {
let today = today_seed();
let today = m8_seed();
for (lang, intro) in [
(
"fr",
@@ -1247,7 +1262,7 @@ fn the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quoru
/// share asked there is s beyond the article's quorum, and lower, judged as the quorum, under it.
#[test]
fn g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quorum() {
let today = today_seed();
let today = m8_seed();
// The sentence of G closes N1.1, in each language; nothing of the sentence of T2 is left.
for lang in LANGS {
let (_, body) = text(&today, "N1.1", lang);
@@ -1324,7 +1339,7 @@ fn g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quor
/// reste entre » read as a fact, not as the rule that brings the median back.
#[test]
fn the_participation_habituelle_is_the_median_brought_back_within_its_bounds() {
let today = today_seed();
let today = m8_seed();
// The texts write the defined term, never the paraphrase: in N1.1, the list, its five lines and
// G (and, in French and Spanish, « comparé à la participation habituelle du groupe », already
// there); in F1.2, « Par défaut ».
@@ -1421,7 +1436,7 @@ fn the_participation_habituelle_is_the_median_brought_back_within_its_bounds() {
/// paragraph says after it bring it back. The voted text is not changed for this.
#[test]
fn the_reference_turnout_is_a_parameter_the_bounds_bring_back() {
let seed = today_seed();
let seed = m8_seed();
let f = figures(&seed);
let vote_floor = f64::from(f.reference) / 2.0;
for w in ELECTORATES.into_iter().chain([199, 200, 10_000, 10_001]) {
@@ -1629,7 +1644,7 @@ fn close(a: f64, b: f64) -> bool {
#[test]
fn the_formulas_of_the_texts_are_those_the_engine_computes() {
let seed = today_seed();
let seed = m8_seed();
for lang in LANGS {
let (_, f11) = text(&seed, "F1.1", lang);
let (_, n11) = text(&seed, "N1.1", lang);
@@ -1693,7 +1708,7 @@ fn the_formulas_of_the_texts_are_those_the_engine_computes() {
#[test]
fn the_quorum_texts_follow_the_rules_of_form() {
let seed = today_seed();
let seed = m8_seed();
let titles: HashMap<&str, Vec<&str>> = LANGS
.iter()
.map(|l| {
@@ -1812,8 +1827,8 @@ fn the_amendments_of_lot_t2_went_from_the_texts_of_m6b_to_those_of_t2() {
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text production holds since M6c");
assert_ne!(a.from, a.to, "{code}");
// It follows lot T's, which wrote the text it replaces; lot T3's replaces it in turn,
// where it changes the text (F1.2, N1.1).
let later = AMENDMENTS.iter().find(|l| l.follows == Some(a.id)).map(|l| l.id);
// where it changes the text (F1.2, N1.1); F1.1 leaves the seed with lot T4 (ADR 0014).
let later = AMENDMENTS.iter().find(|l| l.follows == Some(a.id)).map(|l| l.id).or(Some(RETIREMENT));
assert_eq!(
(a.follows, t.to, t.superseded_by, a.superseded_by),
(Some(t.id), a.from, Some(a.id), later),
@@ -1873,7 +1888,7 @@ async fn production_after_m6(tag: &str) -> AppState {
let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await;
let m6 = with_texts(&today_seed(), &prod, &CODES);
let m6 = with_texts(&m8_seed(), &prod, &CODES);
start(&db, config(seed_dir(&format!("{tag}-m6"), &m6))).await
}
@@ -2128,8 +2143,8 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
#[tokio::test]
async fn a_new_base_is_born_with_the_quorum_texts() {
let db = open_db("sqlite::memory:").await.expect("db");
let state = start(&db, Config::for_tests()).await;
let today = today_seed();
let state = start(&db, m8_config("new")).await;
let today = m8_seed();
for group in GROUPS {
for code in CODES {
let (_, title, body, revision) = proposal(&state, group, code).await;
@@ -2430,7 +2445,7 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() {
let first = AppState::new(config(seed_dir("t2-born-m5", &prod)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir("t2-born-m5-m5", &prod))).await;
start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await;
start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await;
let base = start(&db, m6b_config("t2-born-m5")).await;
for group in GROUPS {
for code in CODES {
@@ -2461,7 +2476,7 @@ const T3_CODES: [&str; 2] = ["F1.2", "N1.1"];
#[test]
fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() {
let (t2, today) = (t2_seed(), today_seed());
let (t2, today) = (t2_seed(), m8_seed());
for code in CODES {
let t2a = amendment(code, "-phrases");
let found = AMENDMENTS.iter().find(|a| a.unit == code && a.id.ends_with("-chiffres"));
@@ -2470,7 +2485,7 @@ fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() {
for lang in LANGS {
assert_eq!(text(&t2, code, lang), text(&today, code, lang), "{code} {lang}");
}
assert_eq!(t2a.superseded_by, None, "{code}");
assert_eq!(t2a.superseded_by, Some(RETIREMENT), "{code}: out of the seed since lot T4");
continue;
}
let a = found.unwrap_or_else(|| panic!("{code}: the amendment of lot T3"));
@@ -2481,10 +2496,11 @@ fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() {
let (title, body) = text(&today, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text of the seed today");
assert_ne!(a.from, a.to, "{code}");
// It follows lot T2's, which wrote the text it replaces; nothing replaces it yet.
// It follows lot T2's, which wrote the text it replaces; lot T4 takes the article out of
// the seed (ADR 0014), and with it this amendment.
assert_eq!(
(a.follows, t2a.to, t2a.superseded_by, a.superseded_by),
(Some(t2a.id), a.from, Some(a.id), None),
(Some(t2a.id), a.from, Some(a.id), Some(RETIREMENT)),
"{code}"
);
let t = amendment(code, "-quorum");
@@ -2513,7 +2529,7 @@ fn history(revision: i64) -> Vec<&'static str> {
#[tokio::test]
async fn production_after_m6c_gets_the_texts_without_dated_figures_once() {
let base = production_after_m6c("t3-apply").await;
let (t2, today) = (t2_seed(), today_seed());
let (t2, today) = (t2_seed(), m8_seed());
// The base is as production after M6c: the texts of lot T2, F1.1 at revision 3, F1.2 and
// N1.1 at revision 4.
for group in GROUPS {
@@ -2533,7 +2549,7 @@ async fn production_after_m6c_gets_the_texts_without_dated_figures_once() {
let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await;
// Started with today's seed.
let state = start(&base.db, Config::for_tests()).await;
let state = start(&base.db, m8_config("t3-apply")).await;
let mut touched = Vec::new();
for group in GROUPS {
for code in T3_CODES {
@@ -2597,7 +2613,7 @@ async fn production_after_m6c_gets_the_texts_without_dated_figures_once() {
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 2);
// Decided once: a second start replays nothing.
let again = start(&state.db, Config::for_tests()).await;
let again = start(&state.db, m8_config("t3-apply-again")).await;
assert_eq!(decisions(&again, "-chiffres").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(translations(&again).await, translations(&state).await);
@@ -2649,7 +2665,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() {
luna.vote(&f12_essai, Some("against")).await;
luna.vote(&f12_essai, None).await;
let state = start(&base.db, Config::for_tests()).await;
let state = start(&base.db, m8_config("t3-refuse")).await;
let refused =
|g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned()));
let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None);
@@ -2662,7 +2678,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() {
refused("monnaie-libre", "N1.1", "votes"),
]
);
let (t2, today) = (t2_seed(), today_seed());
let (t2, today) = (t2_seed(), m8_seed());
for group in GROUPS {
for code in T3_CODES {
let kept = (group, code) != ("essai", "F1.2");
@@ -2676,7 +2692,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() {
assert!(queued(&state, "-chiffres").await.is_empty());
// The refusals stand: decided once, never tried again, even once the vote is gone.
sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&n11).execute(&state.db).await.expect("delete");
let again = start(&state.db, Config::for_tests()).await;
let again = start(&state.db, m8_config("t3-refuse-again")).await;
assert_eq!(decisions(&again, "-chiffres").await, decisions(&state, "-chiffres").await);
let (_, title, body, _) = proposal(&again, "monnaie-libre", "N1.1").await;
assert_eq!((title.as_str(), body.as_str()), text(&t2, "N1.1", "fr"));
@@ -2691,7 +2707,7 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() {
let first = AppState::new(config(seed_dir("t3-born-m5", &prod)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir("t3-born-m5-m5", &prod))).await;
start(&db, config(seed_dir("t3-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await;
start(&db, config(seed_dir("t3-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await;
start(&db, m6b_config("t3-born-m5")).await;
let base = start(&db, t2_config("t3-born-m5")).await;
for group in GROUPS {
@@ -2700,8 +2716,8 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() {
assert_eq!(kinds(&base, &id).await, history(3), "{group} {code}: lots T and T2");
}
}
let state = start(&db, Config::for_tests()).await;
let today = today_seed();
let state = start(&db, m8_config("t3-born-m5")).await;
let today = m8_seed();
for group in GROUPS {
for code in T3_CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
@@ -2720,8 +2736,8 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() {
async fn a_base_born_with_the_texts_of_t2_gets_the_texts_of_t3() {
let db = open_db("sqlite::memory:").await.expect("db");
start(&db, t2_config("t3-born-t2")).await;
let state = start(&db, Config::for_tests()).await;
let today = today_seed();
let state = start(&db, m8_config("t3-born-t2")).await;
let today = m8_seed();
for group in GROUPS {
for code in CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
+17 -5
View File
@@ -4,7 +4,10 @@
//!
//! 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`).
//! 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};
@@ -21,8 +24,13 @@ 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(Config::for_tests().seed_dir.join("acte-engagement-certification.json"))
read(m8_dir().join("acte-engagement-certification.json"))
}
/// The seed M5 shipped: production's since 2026-11-02.
@@ -125,12 +133,14 @@ fn the_shipped_amendments_go_from_the_texts_of_before_to_the_seed_of_m5() {
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 {
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}");
}
}
@@ -217,9 +227,11 @@ async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() {
}
#[tokio::test]
async fn a_new_base_is_born_with_the_rules_of_today() {
async fn a_base_born_on_the_seed_of_m8_holds_its_rules() {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
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"] {