v3 : modules d'ordre et de titre — moteur, données, serveur (L11)

L'ordre des articles de chaque section (cran 2) et le titre du document
(cran 4) deviennent des modules : des unités à part, dont les versions
sont des propositions votées comme les articles (dépliées, panier, chaîne).

- moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé,
  complété à la fin quand un article entre ou sort, places redistribuées,
  nouvel article en fin de section) ; pending : un ordre de moins de deux
  articles n'est jamais à voter ;
- données : migration 0007 (units.kind, module_amendments) ; le seed
  déclare ses modules ; module_amend.rs les ajoute une fois, gardé et
  journalisé, aux documents nés sans eux, sans toucher articles ni votes ;
- serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer,
  entrée en vigueur : les articles changent de place, le document change
  de titre, en et es passent « à revoir ») ; publication des modules ;
- reste mineur : la médiane compte les statuts open, in_force, applied,
  et eux seuls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 02:08:44 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 3831ddb1bf
21 changed files with 2249 additions and 115 deletions
+77 -2
View File
@@ -618,10 +618,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.
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
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(33), Some(32)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
}
#[tokio::test]
@@ -668,6 +668,81 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
}
#[tokio::test]
async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let order = doc["modules"]["orders"]
.as_array()
.expect("orders")
.iter()
.find(|o| o["section"] == "conclusion")
.expect("conclusion")
.clone();
let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2"));
assert_eq!(order["members"], json!([k1, k2]));
let o = c
.ok(
"POST",
&format!("/api/units/{}/order", order["id"].as_str().expect("id")),
Some(json!({ "order": [k2, k1] })),
)
.await["id"]
.as_str()
.expect("order")
.to_owned();
let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned();
let t = c
.ok(
"POST",
&format!("/api/units/{title_unit}/title"),
Some(json!({ "title": "Acte d'engagement de certification" })),
)
.await["id"]
.as_str()
.expect("title")
.to_owned();
let doc = c.doc("monnaie-libre").await;
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
let index = 20_000 + u32::try_from(n).expect("n");
indexer.pay(
2_000 + index,
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
last = b;
if n % 10 == 9 {
ballots::sweep(&state).await.expect("sweep");
}
}
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&last).await;
assert_eq!(s["status"], "confirmed");
let mut entered: Vec<(&str, &str)> = s["entered"]
.as_array()
.expect("entered")
.iter()
.map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default()))
.collect();
entered.sort_unstable();
assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}");
let d = c.doc("monnaie-libre").await;
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
assert!(p2 < p1, "K2 now reads before K1");
assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification");
assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review");
// The adoptions keep what entered, by kind.
let kinds: Vec<String> =
sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k");
assert_eq!(kinds, ["order", "title"]);
}
// ---------------------------------------------------------------------------
// Without the chain, and in managed groups
// ---------------------------------------------------------------------------