From 093d1ca471d716c372613b09fc222ecb401dbf7d Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 21:21:44 +0100 Subject: [PATCH] Lot T4 : les tests d'avant suivent le seed de T4 sans rien perdre de ce qu'ils prouvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ld-server/src/article_amend.rs | 4 +- crates/ld-server/src/order.rs | 8 +- crates/ld-server/src/retire.rs | 38 ++++--- crates/ld-server/src/seed_amend.rs | 55 ++++++++++ crates/ld-server/tests/amorce.rs | 106 ++++++++++++++------ crates/ld-server/tests/api.rs | 48 +++++++-- crates/ld-server/tests/ballots.rs | 8 +- crates/ld-server/tests/inertia.rs | 6 +- crates/ld-server/tests/modules.rs | 101 +++++++++++-------- crates/ld-server/tests/overview.rs | 30 +++--- crates/ld-server/tests/quorum.rs | 12 ++- crates/ld-server/tests/seed_proposals.rs | 49 +++++---- crates/ld-server/tests/seed_quorum_texts.rs | 86 +++++++++------- crates/ld-server/tests/seed_rules.rs | 22 +++- 14 files changed, 384 insertions(+), 189 deletions(-) diff --git a/crates/ld-server/src/article_amend.rs b/crates/ld-server/src/article_amend.rs index ccf0348..15ee9ec 100644 --- a/crates/ld-server/src/article_amend.rs +++ b/crates/ld-server/src/article_amend.rs @@ -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 = articles.iter().map(|x| x.3).collect(); diff --git a/crates/ld-server/src/order.rs b/crates/ld-server/src/order.rs index f3d941e..1209628 100644 --- a/crates/ld-server/src/order.rs +++ b/crates/ld-server/src/order.rs @@ -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) diff --git a/crates/ld-server/src/retire.rs b/crates/ld-server/src/retire.rs index 4829360..7c638c9 100644 --- a/crates/ld-server/src/retire.rs +++ b/crates/ld-server/src/retire.rs @@ -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())); } diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index 19cd53b..0292416 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -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")); diff --git a/crates/ld-server/tests/amorce.rs b/crates/ld-server/tests/amorce.rs index fad03da..9b11be1 100644 --- a/crates/ld-server/tests/amorce.rs +++ b/crates/ld-server/tests/amorce.rs @@ -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 = columns.iter().filter(|c| !skip.contains(&c.as_str())).map(|c| format!("quote(\"{c}\")")).collect(); + let cols: Vec = + 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; 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::>(), [("essai", "present"), ("monnaie-libre", "present")]); + assert_eq!( + added.iter().map(|a| (a.0.as_str(), a.1.as_str())).collect::>(), + [("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) = 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) = + 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 = sqlx::query_scalar(&format!("SELECT DISTINCT retired_reason FROM units WHERE {OF_THE_OUT}")) - .fetch_all(&state.db) - .await - .expect("reasons"); + let reasons: Vec = + 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::>().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::>() + .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| { - t.iter().filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships")).map(|(k, n)| (k.clone(), *n)).collect::>() + t.iter() + .filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships")) + .map(|(k, n)| (k.clone(), *n)) + .collect::>() }; 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 { - 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"); diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index e83a155..1be41ba 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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] diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index 3ca56fb..a5b8152 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -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 = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid") .fetch_all(&pool) .await .expect("order"); - assert_eq!(order, (1..=11).collect::>(), "0009, then 0010, then 0011"); + assert_eq!(order, (1..=12).collect::>(), "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)> = 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", diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 06b1ef1..afef4e6 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -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"]; diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index e853df6..c66be72 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -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()]); } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index 9e5c782..33257aa 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -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); } diff --git a/crates/ld-server/tests/quorum.rs b/crates/ld-server/tests/quorum.rs index 448a78f..d8aef44 100644 --- a/crates/ld-server/tests/quorum.rs +++ b/crates/ld-server/tests/quorum.rs @@ -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::>(), diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index b65f5bc..c873c34 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -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 = 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); } diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs index d5b848b..6822e6e 100644 --- a/crates/ld-server/tests/seed_quorum_texts.rs +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -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 { /// 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; diff --git a/crates/ld-server/tests/seed_rules.rs b/crates/ld-server/tests/seed_rules.rs index 6828d63..697069d 100644 --- a/crates/ld-server/tests/seed_rules.rs +++ b/crates/ld-server/tests/seed_rules.rs @@ -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"] {