//! Lot T (2026-11-03): the articles of the seed that still described the floor C of //! « pour » votes — F1.1 « Formule du seuil WoT », F1.2 « Paramètres par défaut », //! N1.1 « Niveaux d'inertie » — say the quorum by level (ADR 0013). //! //! Every number of these texts is computed here by the engine (or read from the seed), //! in each language; their formulas are evaluated against the engine; their form follows //! the rules of the texts (`docs/chantiers/acte-certification/textes-quorum-seed.md`). //! A guarded seed amendment replaces them in production, on a base made as production //! is after M6, only where nobody touched them. use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; use ld_engine::threshold::{FormulaParams, judged_voters, no_needed, required_ratio, yes_needed}; use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::{AppState, Config, open_db, seed}; use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; const CODES: [&str; 3] = ["F1.1", "F1.2", "N1.1"]; const LANGS: [&str; 3] = ["fr", "en", "es"]; const GROUPS: [&str; 2] = ["essai", "monnaie-libre"]; /// The illustration of N1.1: the web of trust and the usual turnout of the smith vote /// of 2024 (ADR 0007, 0013), not the size of the web today. const EXAMPLE_W: u32 = 7224; const EXAMPLE_MEDIAN: u32 = 120; // --------------------------------------------------------------------------- // The seeds // --------------------------------------------------------------------------- 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")) } fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } /// The seed production runs since M5 (3f10a0e), unchanged by M6 for these articles. fn production_seed() -> Value { read(fixtures().join("seed-production-2026-11-02/acte-engagement-certification.json")) } /// The four rule articles as production was born with them (the seed of 21/10). fn rules_before() -> Value { read(fixtures().join("seed-regles-avant.json")) } fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value { seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } /// The title and body of the first version of an article, in a language. fn text<'a>(seed: &'a Value, code: &str, lang: &str) -> (&'a str, &'a str) { let v = &unit(seed, code)["versions"][0]; (v["title"][lang].as_str().expect("title"), v["body"][lang].as_str().expect("body")) } /// A seed with the first version of `codes` taken from `from` (title and body). fn with_texts(base: &Value, from: &Value, codes: &[&str]) -> Value { let mut seed = base.clone(); for u in seed["units"].as_array_mut().expect("units") { let code = u["code"].as_str().expect("code").to_owned(); if codes.contains(&code.as_str()) { let v = from["units"].get(&code).unwrap_or_else(|| &unit(from, &code)["versions"][0]); u["versions"][0]["title"] = v["title"].clone(); u["versions"][0]["body"] = v["body"].clone(); } } seed } fn seed_dir(tag: &str, seed: &Value) -> PathBuf { let dir = std::env::temp_dir().join(format!("ld-seed-quorum-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("dir"); std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); dir } // --------------------------------------------------------------------------- // The figures: from the engine and the seed, never by hand // --------------------------------------------------------------------------- struct Figures { /// Q_k of Monnaie libre (the seed). quorums: [u32; 5], /// Twice the reference quorum of the document, in voters (the engine's p0 · W). reference: u32, /// The lowest quorum an author may set in Monnaie libre. lowest_quorum: u32, base: f64, refresh_hours: u64, default_level: u8, seed_level: u8, title_level: u8, order_level: u8, /// The level of every other article of the seed. others_level: u8, min_sample: u32, /// The bounds of the usual turnout, in percent. bounds: (f64, f64), /// « pour » asked at the example's 120 voters, level 1 to 5. example: [u32; 5], /// Under the quorum: (level, quorum, « pour » that pass, « contre » at most). under: [(u8, u32, u32, u32); 2], } fn level(k: u8) -> InertiaLevel { InertiaLevel::new(k).expect("1 to 5") } fn figures(seed: &Value) -> Figures { let ml = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == "monnaie-libre").expect("ml"); let quorums: [u32; 5] = serde_json::from_value(ml["quorums"].clone()).expect("quorums"); let vote_floor = ml["voteFloor"].as_f64().expect("voteFloor"); let p0 = Participation::of(EXAMPLE_W, vote_floor, None, 0); assert!(!p0.bounded, "the reference turnout is not brought back within the bounds"); let reference = p0.voters(EXAMPLE_W); assert!((reference - reference.round()).abs() < 1e-9, "a whole number of voters: {reference}"); let scale = Scale { electorate: EXAMPLE_W, participation: f64::from(EXAMPLE_MEDIAN) / f64::from(EXAMPLE_W), quorums }; let example = InertiaLevel::ALL.map(|k| scale.required(EXAMPLE_MEDIAN, k, scale.quorum(k, None)).expect("valid")); let under = [2u8, 3].map(|k| { let q = scale.quorum(level(k), None); let p = scale.params(level(k), q); let yes = yes_needed(0, EXAMPLE_W, &p).expect("valid"); let most = no_needed(yes, EXAMPLE_W, &p).expect("valid") - 1; assert!(yes + most <= q, "level {k}: the example stays under the quorum"); (k, q, yes, most) }); let levels: Vec = seed["units"] .as_array() .expect("units") .iter() .filter_map(|u| u["inertia"].as_u64()) .map(|k| u8::try_from(k).expect("level")) .collect(); assert!(levels.windows(2).all(|w| w[0] == w[1]), "every other article at one level: {levels:?}"); let module = |m: &str| u8::try_from(seed["modules"][m]["inertia"].as_u64().expect("module level")).expect("level"); assert_eq!(ld_server::participation::WINDOW_DAYS, 365, "« depuis un an »"); assert_eq!(ld_server::participation::EVERY_HOURS, 24, "« une fois par jour »"); Figures { quorums, reference: reference.round() as u32, lowest_quorum: quorum_bounds(EXAMPLE_W, false).0, base: FormulaParams::DEFAULT_BASE, refresh_hours: ld_server::wot::REFRESH.as_secs() / 3600, default_level: InertiaLevel::DEFAULT.get(), seed_level: seed::SEED_PROPOSAL_LEVEL, title_level: module("title"), order_level: module("order"), others_level: levels[0], min_sample: Participation::MIN_SAMPLE, bounds: (Participation::LOWEST * 100.0, Participation::HIGHEST * 100.0), example, under, } } /// A whole number as the language writes it: 7 224, 7,224, 7224. fn int(lang: &str, n: u32) -> String { let digits = n.to_string(); if n < 1000 || lang == "es" { return digits; } let sep = if lang == "fr" { " " } else { "," }; let (head, tail) = digits.split_at(digits.len() - 3); format!("{head}{sep}{tail}") } /// A decimal: 0,1 or 0.1. fn dec(lang: &str, v: f64) -> String { let s = v.to_string(); if lang == "en" { s } else { s.replace('.', ",") } } /// A percentage: 60 %, 60%. fn pct(lang: &str, v: f64) -> String { if lang == "en" { format!("{}%", dec(lang, v)) } else { format!("{} %", dec(lang, v)) } } /// The name of a level, as the interface says it (`inertia.name.k` in `web/src/lib/i18n`; /// the image builds the server without the interface's sources, so they are written here). fn level_name(lang: &str, k: u8) -> String { let names = match lang { "fr" => ["très basse", "basse", "moyenne", "haute", "très haute"], "en" => ["very low", "low", "medium", "high", "very high"], _ => ["muy baja", "baja", "media", "alta", "muy alta"], }; names[usize::from(k - 1)].to_owned() } /// The titles of the seed proposals (Raison d'être, Unicité du compte, Réciprocité). fn seed_proposal_titles(seed: &Value, lang: &str) -> Vec { seed["units"] .as_array() .expect("units") .iter() .filter(|u| u["versions"][0]["seedProposal"] == true) .map(|u| u["versions"][0]["title"][lang].as_str().expect("title").to_owned()) .collect() } /// « a, b et c », “a, b and c”, «a, b y c». fn and_list(lang: &str, items: &[String]) -> String { let and = match lang { "fr" => "et", "en" => "and", _ => "y", }; let (last, head) = items.split_last().expect("items"); format!("{} {and} {last}", head.join(", ")) } /// What is at a level at the start, as N1.1 says it. fn at_start(f: &Figures, seed: &Value, lang: &str, k: u8) -> String { let proposals = k == f.seed_level; let order = k == f.order_level; let title = k == f.title_level; let others = k == f.others_level; let titles = seed_proposal_titles(seed, lang).join(", "); match (proposals, order, title, others, lang) { (false, false, false, false, "fr") => "Aucun article au départ.".into(), (false, false, false, false, "en") => "No article at the start.".into(), (false, false, false, false, _) => "Ningún artículo al inicio.".into(), (true, true, false, false, "fr") => format!("{titles}, et l'ordre des articles de chaque section."), (true, true, false, false, "en") => format!("{titles}, and the order of the articles of each section."), (true, true, false, false, _) => format!("{titles}, y el orden de los artículos de cada sección."), (false, false, false, true, l) => { assert_eq!(k, f.default_level, "the other articles are at the default level"); match l { "fr" => "Le cran par défaut : tous les autres articles, et chaque nouvel article.".into(), "en" => "The default level: all other articles, and every new article.".into(), _ => "El nivel por defecto: todos los demás artículos, y todo artículo nuevo.".into(), } } (false, false, true, false, "fr") => "Le titre du document.".into(), (false, false, true, false, "en") => "The title of the document.".into(), (false, false, true, false, _) => "El título del documento.".into(), other => panic!("level {k}: the seed changed what starts there ({other:?}), rewrite « Niveaux d'inertie »"), } } /// The phrases of a text that carry its numbers, each built from the figures. fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { let q = f.quorums; let (lo, hi) = (InertiaLevel::LOWEST.get(), InertiaLevel::HIGHEST.get()); let d = level(f.default_level); let qd = q[usize::from(f.default_level - 1)]; let name = |k: u8| level_name(lang, k); let sp = and_list(lang, &seed_proposal_titles(seed, lang)); let (m, w) = (EXAMPLE_MEDIAN, int(lang, EXAMPLE_W)); let e = f.example; let ed = e[usize::from(f.default_level - 1)]; let [(k2, q2, y2, n2), (k3, q3, y3, n3)] = f.under; match (code, lang) { ("F1.1", _) => vec![], ("F1.2", "fr") => vec![ format!("relu sur la chaîne toutes les {} heures", f.refresh_hours), format!( "Chaque article prend celui de son cran : {}, {}, {}, {} et {} dans Monnaie libre, du cran {lo} au cran {hi}", q[0], q[1], q[2], q[3], q[4] ), format!( "Dans Monnaie libre, jamais moins de {} : une seule voix ne fait jamais passer un texte", f.lowest_quorum ), format!("**B = {}**", dec(lang, f.base)), format!( "Le cran par défaut est le {} ({}) : M = {}, un quorum de {qd} dans Monnaie libre, et le groupe demande {} de « pour » quand il vote comme d'habitude", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), pct(lang, d.share_pct()) ), format!( "{sp} sont des Propositions seed, mises en avant pour lancer le vote, au cran {} ({})", f.seed_level, name(f.seed_level) ), format!( "Le titre du document est au cran {} ({}), l'ordre des articles de chaque section au cran {}, tous les autres articles au cran {}", f.title_level, name(f.title_level), f.order_level, f.others_level ), ], ("F1.2", "en") => vec![ format!("read again from the chain every {} hours", f.refresh_hours), format!( "Each article takes the one of its level: {}, {}, {}, {} and {} in Monnaie libre, from level {lo} to level {hi}", q[0], q[1], q[2], q[3], q[4] ), format!("In Monnaie libre, never less than {}: a single vote never passes a text", f.lowest_quorum), format!("**B = {}**", dec(lang, f.base)), format!( "The default level is {} ({}): M = {}, a quorum of {qd} in Monnaie libre, and the group asks for {} of votes in favour when it votes as usual", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), pct(lang, d.share_pct()) ), format!( "{sp} are seed proposals, put forward to start the vote, at level {} ({})", f.seed_level, name(f.seed_level) ), format!( "The title of the document is at level {} ({}), the order of the articles of each section at level {}, all other articles at level {}", f.title_level, name(f.title_level), f.order_level, f.others_level ), ], ("F1.2", _) => vec![ format!("leído de nuevo en la cadena cada {} horas", f.refresh_hours), format!( "Cada artículo toma el de su nivel: {}, {}, {}, {} y {} en Monnaie libre, del nivel {lo} al nivel {hi}", q[0], q[1], q[2], q[3], q[4] ), format!("En Monnaie libre, nunca menos de {}: un solo voto nunca hace pasar un texto", f.lowest_quorum), format!("**B = {}**", dec(lang, f.base)), format!( "El nivel por defecto es el {} ({}): M = {}, un quórum de {qd} en Monnaie libre, y el grupo pide un {} de votos a favor cuando vota como de costumbre", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), pct(lang, d.share_pct()) ), format!( "{sp} son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel {} ({})", f.seed_level, name(f.seed_level) ), format!( "El título del documento está en el nivel {} ({}), el orden de los artículos de cada sección en el nivel {}, todos los demás artículos en el nivel {}", f.title_level, name(f.title_level), f.order_level, f.others_level ), ], ("N1.1", _) => { let mut out: Vec = InertiaLevel::ALL .iter() .map(|k| { let i = usize::from(k.get() - 1); let (n, s, mk, what) = ( name(k.get()), pct(lang, k.share_pct()), pct(lang, k.majority_pct()), at_start(f, seed, lang, k.get()), ); match lang { "fr" => format!( "- **Cran {} · {n}** : quorum {} ; {s} comme d'habitude, {mk} quand tout le monde vote. {what}", k.get(), q[i] ), "en" => format!( "- **Level {} · {n}**: quorum {}; {s} as usual, {mk} when everyone votes. {what}", k.get(), q[i] ), _ => format!( "- **Nivel {} · {n}**: quórum {}; {s} como de costumbre, {mk} cuando vota todo el mundo. {what}", k.get(), q[i] ), } }) .collect(); let sd = pct(lang, d.share_pct()); // « 75 % des 120 votants, soit 90 »: the share of the default level, exactly. assert_eq!(f64::from(ed), (d.share_pct() * f64::from(m) / 100.0).ceil()); let (b0, b1) = (pct(lang, f.bounds.0), pct(lang, f.bounds.1)); let (ms, r) = (f.min_sample, f.reference); out.extend(match lang { "fr" => [ "propositions du groupe votées depuis un an".to_owned(), format!("Tant que le groupe n'a pas voté sur {ms} propositions, elle vaut deux fois le quorum de référence du document : {r} votants dans Monnaie libre"), format!("Elle reste entre {b0} et {b1} de l'électorat, et se mesure une fois par jour"), format!( "avec {w} membres et une participation habituelle de {m} votants, quand {m} personnes votent, il faut {}, {}, {}, {} ou {} voix pour, du cran {lo} au cran {hi} ; au cran {}, {sd} des {m} votants, soit {ed}", e[0], e[1], e[2], e[3], e[4], f.default_level ), format!("au cran {k2} (quorum {q2}), {y2} voix pour passent avec {n2} « contre » au plus ; au cran {k3} (quorum {q3}), il faut {y3} voix pour, avec {n3} « contre » au plus"), ], "en" => [ "the group's proposals voted on over the past year".to_owned(), format!("Until the group has voted on {ms} proposals, it is twice the document's reference quorum: {r} voters in Monnaie libre"), format!("It stays between {b0} and {b1} of the electorate, and is measured once a day"), format!( "with {w} members and a usual turnout of {m} voters, when {m} people vote, it takes {}, {}, {}, {} or {} votes in favour, from level {lo} to level {hi}; at level {}, {sd} of the {m} voters, i.e. {ed}", e[0], e[1], e[2], e[3], e[4], f.default_level ), format!("at level {k2} (quorum {q2}), {y2} votes in favour pass with {n2} votes against at most; at level {k3} (quorum {q3}), it takes {y3} votes in favour, with {n3} votes against at most"), ], _ => [ "propuestas del grupo votadas en el último año".to_owned(), format!("Hasta que el grupo haya votado {ms} propuestas, vale dos veces el quórum de referencia del documento: {r} votantes en Monnaie libre"), format!("Se mantiene entre el {b0} y el {b1} del electorado, y se mide una vez al día"), format!( "con {w} miembros y una participación habitual de {m} votantes, cuando votan {m} personas, hacen falta {}, {}, {}, {} o {} votos a favor, del nivel {lo} al nivel {hi}; en el nivel {}, el {sd} de los {m} votantes, es decir {ed}", e[0], e[1], e[2], e[3], e[4], f.default_level ), format!("en el nivel {k2} (quórum {q2}), {y2} votos a favor pasan con {n2} en contra como mucho; en el nivel {k3} (quórum {q3}), hacen falta {y3} votos a favor, con {n3} en contra como mucho"), ], }); out } _ => unreachable!(), } } /// The spans of code of a text, in order. fn code_spans(body: &str) -> Vec<&str> { body.split('`').skip(1).step_by(2).collect() } /// The word a formula is named by, in each language. fn named(lang: &str, fr: &'static str, en: &'static str, es: &'static str) -> &'static str { match lang { "fr" => fr, "en" => en, _ => es, } } /// The code a text may hold: its formulas, exactly. fn formulas(code: &str, lang: &str) -> Vec { let threshold = named(lang, "Seuil", "Threshold", "Umbral"); let share = named(lang, "Part exigée", "Share asked", "Proporción exigida"); match code { "F1.1" => vec!["T' = max(T, Q)".into(), format!("{threshold} = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'")], "F1.2" => vec!["B^W".into()], _ => vec![format!("{share} = M + (1 − M) × (1 − (T'/W)^G)"), "G = ln(1 − (s − M)/(1 − M)) / ln(p̃)".into()], } } #[test] fn every_number_of_the_quorum_texts_is_the_engine_s() { let seed = today_seed(); let f = figures(&seed); for code in CODES { for lang in LANGS { let (_, body) = text(&seed, code, lang); let mut rest = body.to_owned(); for claim in claims(code, lang, &f, &seed) { assert!(rest.contains(&claim), "{code} {lang}: missing « {claim} »\n{body}"); rest = rest.replacen(&claim, "", 1); } let spans = code_spans(body); assert_eq!(spans, formulas(code, lang), "{code} {lang}: the formulas"); for span in spans { rest = rest.replacen(&format!("`{span}`"), "", 1); } let rest = rest.replace("Ğ1", ""); let stray: Vec = rest.chars().filter(char::is_ascii_digit).collect(); assert!(stray.is_empty(), "{code} {lang}: a number no engine figure stands for: {stray:?}\n{rest}"); } } } // --------------------------------------------------------------------------- // The formulas: evaluated as written, against the engine // --------------------------------------------------------------------------- /// A tiny evaluator for the formulas of the texts: + − × / ^, parentheses, `max`, `ln`. struct Formula<'a> { tokens: Vec, at: usize, env: &'a HashMap, } fn tokens(src: &str) -> Vec { let mut out = Vec::new(); let chars: Vec = src.chars().collect(); let mut i = 0; while i < chars.len() { let c = chars[i]; if c.is_whitespace() { i += 1; } else if c.is_ascii_digit() { let start = i; while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { i += 1; } out.push(chars[start..i].iter().collect()); } else if c.is_alphabetic() { let start = i; // A name: letters, a combining mark (p̃), a prime (T'). while i < chars.len() && (chars[i].is_alphabetic() || ('\u{300}'..='\u{36f}').contains(&chars[i])) { i += 1; } if i < chars.len() && chars[i] == '\'' { i += 1; } out.push(chars[start..i].iter().collect()); } else { out.push(c.to_string()); i += 1; } } out } impl Formula<'_> { fn peek(&self) -> Option<&str> { self.tokens.get(self.at).map(String::as_str) } fn take(&mut self, t: &str) -> bool { let found = self.peek() == Some(t); if found { self.at += 1; } found } fn expr(&mut self) -> f64 { let mut v = self.term(); loop { if self.take("+") { v += self.term(); } else if self.take("−") { v -= self.term(); } else { return v; } } } fn term(&mut self) -> f64 { let mut v = self.power(); loop { if self.take("×") { v *= self.power(); } else if self.take("/") { v /= self.power(); } else { return v; } } } fn power(&mut self) -> f64 { let base = self.atom(); if self.take("^") { base.powf(self.power()) } else { base } } fn atom(&mut self) -> f64 { let t = self.peek().expect("a term").to_owned(); self.at += 1; if t == "(" { let v = self.expr(); assert!(self.take(")"), "a closing parenthesis"); return v; } if let Ok(n) = t.parse::() { return n; } if self.take("(") { let a = self.expr(); let v = match t.as_str() { "max" => { assert!(self.take(","), "max(a, b)"); a.max(self.expr()) } "ln" => a.ln(), other => panic!("unknown function {other}"), }; assert!(self.take(")"), "a closing parenthesis"); return v; } *self.env.get(&t).unwrap_or_else(|| panic!("unknown letter {t}")) } } /// The value of `name = expression`, the name checked. fn eval(formula: &str, name: &str, env: &HashMap) -> f64 { let (lhs, rhs) = formula.split_once(" = ").expect("name = expression"); assert_eq!(lhs, name); let mut f = Formula { tokens: tokens(rhs), at: 0, env }; let v = f.expr(); assert_eq!(f.at, f.tokens.len(), "the whole of {rhs}"); v } fn close(a: f64, b: f64) -> bool { (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) } #[test] fn the_formulas_of_the_texts_are_those_the_engine_computes() { let seed = today_seed(); for lang in LANGS { let (_, f11) = text(&seed, "F1.1", lang); let (_, n11) = text(&seed, "N1.1", lang); let [voters, threshold] = <[&str; 2]>::try_from(code_spans(f11)).expect("two formulas in F1.1"); let [share, grad] = <[&str; 2]>::try_from(code_spans(n11)).expect("two formulas in N1.1"); let mut checked = 0; for w in [2u32, 40, 5707, 7224] { for median in [None, Some(1.0), Some(9.0), Some(120.0), Some(360.0)] { let p = Participation::of(w, 50.0, median, 9).ratio; let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] }; for k in InertiaLevel::ALL { for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) { let params = scale.params(k, q); for t in [0u32, 1, 9, 12, 13, 50, 51, 120, 500, w].into_iter().filter(|t| *t <= w) { let mut env: HashMap = HashMap::from([ ("T".into(), f64::from(t)), ("Q".into(), f64::from(q)), ("W".into(), f64::from(w)), ("B".into(), params.base), ("M".into(), params.majority_pct / 100.0), ("G".into(), params.gradient), ("s".into(), k.share_pct() / 100.0), ("p̃".into(), p), ]); let judged = eval(voters, "T'", &env); assert_eq!(judged, f64::from(judged_voters(t, q)), "{lang}: T'"); env.insert("T'".into(), judged); let ratio = required_ratio(w, judged_voters(t, q), ¶ms); let engine = params.base.powf(f64::from(w)) + ratio * judged; let written = eval(threshold, named(lang, "Seuil", "Threshold", "Umbral"), &env); assert!( close(written, engine), "{lang} W {w} k {} Q {q} T {t}: {written} ≠ {engine}", k.get() ); let asked = eval(share, named(lang, "Part exigée", "Share asked", "Proporción exigida"), &env); assert!(close(asked, ratio), "{lang}: the share"); assert!(close(eval(grad, "G", &env), gradient(k, p)), "{lang}: G"); checked += 1; } } } } } assert!(checked > 500, "{checked}"); } } // --------------------------------------------------------------------------- // The rules of form (textes-regles-seed.md, « Avant de coller ») // --------------------------------------------------------------------------- #[test] fn the_quorum_texts_follow_the_rules_of_form() { let seed = today_seed(); let titles: HashMap<&str, Vec<&str>> = LANGS .iter() .map(|l| { let all = seed["units"] .as_array() .expect("units") .iter() .flat_map(|u| u["versions"].as_array().expect("versions")) .map(|v| v["title"][*l].as_str().expect("title")) .collect(); (*l, all) }) .collect(); for code in CODES { for lang in LANGS { let (title, body) = text(&seed, code, lang); let at = format!("{code} {lang}"); // A title kept: the article still says what its title says. assert_eq!(title, text(&production_seed(), code, lang).0, "{at}: the title is kept"); // Length: under the limit of a proposal (`proposals.rs`, MAX_BODY, in characters). assert!(body.chars().count() <= 4000, "{at}: {} characters", body.chars().count()); // A list reads as a list only in a block of its own (`markdown.ts`, render). for block in body.split("\n\n") { let lines: Vec<&str> = block.lines().collect(); if lines.iter().any(|l| l.starts_with("- ")) { assert!(lines.iter().all(|l| l.starts_with("- ")), "{at}: a list without its blank line\n{block}"); } } // No asterisk but in bold pairs: a formula never multiplies with one. assert!(!body.replace("**", "").contains('*'), "{at}: a lone asterisk"); assert_eq!(body.matches("**").count() % 2, 0, "{at}: bold pairs"); for span in code_spans(body) { assert!(!span.contains('*') && !span.contains('-'), "{at}: × and −, never * or -: {span}"); } // Straight apostrophes; the quotes of each language. assert!(!body.contains('’'), "{at}: a curly apostrophe"); let refs: Vec<&str> = match lang { "fr" => { assert!(!body.contains('“') && !body.contains('”'), "{at}"); assert_eq!(body.matches("« ").count(), body.matches('«').count(), "{at}: « spaced"); assert_eq!(body.matches(" »").count(), body.matches('»').count(), "{at}: » spaced"); body.split("« ").skip(1).filter_map(|s| s.split(" »").next()).collect() } "en" => { assert!(!body.contains('«') && !body.contains('»'), "{at}"); assert!(!body.contains("favor ") && !body.contains("favor."), "{at}: British English, favour"); body.split('“').skip(1).filter_map(|s| s.split('”').next()).collect() } _ => { assert!(!body.contains('“') && !body.contains('”'), "{at}"); assert!(!body.contains("« ") && !body.contains(" »"), "{at}: «» tight"); body.split('«').skip(1).filter_map(|s| s.split('»').next()).collect() } }; // Another article is named by its title, never by its code. let words = ["non", "pour", "contre", "no"]; for r in refs.iter().filter(|r| !words.contains(r)) { assert!(titles[lang].contains(r), "{at}: « {r} » is the title of no article"); } for other in seed["units"].as_array().expect("units") { let c = other["code"].as_str().expect("code"); let named = body.split(|ch: char| !(ch.is_alphanumeric() || ch == '.')).any(|w| w == c); assert!(!named, "{at}: names the article {c} by its code"); } } } } // --------------------------------------------------------------------------- // Production: a guarded amendment, on a base made as production is after M6 // --------------------------------------------------------------------------- /// The amendment of an article, by lot T. fn amendment_t(code: &str) -> &'static seed_amend::Amendment { AMENDMENTS .iter() .find(|a| a.unit == code && a.id.starts_with("2026-11-03-")) .unwrap_or_else(|| panic!("no amendment of lot T for {code}")) } #[test] fn the_shipped_amendments_go_from_production_to_the_seed_of_today() { let (prod, today) = (production_seed(), today_seed()); for code in CODES { let a = amendment_t(code); assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}"); let (t, b) = text(&prod, code, "fr"); assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds"); let (t, b) = text(&today, code, "fr"); assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today"); assert_ne!(a.from, a.to, "{code}"); assert_eq!(a.superseded_by, None, "{code}"); // F1.1 was never amended; F1.2 and N1.1 follow the amendment of M5, which wrote the // text they replace and which the seed no longer gives: it is superseded. match a.follows { None => assert_eq!(code, "F1.1"), Some(earlier) => { let m5 = AMENDMENTS.iter().find(|e| e.id == earlier).expect("the earlier amendment"); assert_eq!((m5.unit, m5.to, m5.superseded_by), (code, a.from, Some(a.id)), "{code}"); } } } } /// Every proposal as it stands: (id, status, revision, title, body). type Row = (String, String, i64, String, String); async fn proposals(state: &AppState) -> Vec { sqlx::query_as("SELECT id, status, revision, title, body FROM proposals ORDER BY id") .fetch_all(&state.db) .await .expect("proposals") } async fn translations(state: &AppState) -> Vec<(String, String, String, String, String)> { sqlx::query_as("SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang") .fetch_all(&state.db) .await .expect("translations") } async fn start(db: &sqlx::SqlitePool, cfg: Config) -> AppState { let state = AppState::new(cfg, db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } fn config(dir: PathBuf) -> Config { let mut cfg = Config::for_tests(); cfg.seed_dir = dir; cfg } /// Production's history, for these articles: born on 21/10 (the rule articles of /// before), started by M5 (3f10a0e: its seed, its amendments), then by M6 (94ddb17: the /// seed of today with the texts of M5 — seed proposals, quorums by level). async fn production_after_m6(tag: &str) -> AppState { let prod = production_seed(); let born = with_texts(&prod, &rules_before(), &["F1.2", "N1", "N1.1", "O1"]); let db = open_db("sqlite::memory:").await.expect("db"); 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); start(&db, config(seed_dir(&format!("{tag}-m6"), &m6))).await } /// The open proposal of the seed for an article: (id, title, body, revision). async fn proposal(state: &AppState, group: &str, code: &str) -> (String, String, String, i64) { sqlx::query_as( "SELECT p.id, p.title, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = '1.0.0'", ) .bind(group) .bind(code) .fetch_one(&state.db) .await .expect("seed proposal") } async fn kinds(state: &AppState, id: &str) -> Vec { sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id") .bind(id) .fetch_all(&state.db) .await .expect("revisions") } /// Each decision of lot T: (group, unit, outcome, reason). async fn decisions(state: &AppState) -> Vec<(String, String, String, Option)> { sqlx::query_as( "SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s JOIN documents d ON d.id = s.doc_id WHERE s.amendment_id LIKE '2026-11-03-%' ORDER BY d.group_slug, s.unit_code", ) .fetch_all(&state.db) .await .expect("decisions") } async fn queued(state: &AppState) -> Vec<(String, String)> { sqlx::query_as( "SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE '%quorum par cran%' ORDER BY o.id", ) .fetch_all(&state.db) .await .expect("outbox") } #[tokio::test] async fn production_after_m6_gets_the_quorum_texts_once() { let base = production_after_m6("apply").await; let (prod, today) = (production_seed(), today_seed()); // The base is as production after M6: F1.1 as born, F1.2 and N1.1 as M5 wrote them. for group in GROUPS { for code in CODES { let (id, title, body, revision) = proposal(&base, group, code).await; assert_eq!((title.as_str(), body.as_str()), text(&prod, code, "fr"), "{group} {code}"); let m5 = code != "F1.1"; assert_eq!(revision, if m5 { 2 } else { 1 }, "{group} {code}"); let expected: &[&str] = if m5 { &["initial", "amendment"] } else { &["initial"] }; assert_eq!(kinds(&base, &id).await, expected, "{group} {code}"); } } let seed_proposals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE seed_featured = 1") .fetch_one(&base.db) .await .expect("count"); let quorums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE quorums IS NOT NULL") .fetch_one(&base.db) .await .expect("count"); assert_eq!((seed_proposals, quorums), (6, 2), "M6: seed proposals and quorums written"); assert!(decisions(&base).await.is_empty(), "the seed of M6 decides nothing of lot T"); let (before, before_tr) = (proposals(&base).await, translations(&base).await); // Started with today's seed. let state = start(&base.db, Config::for_tests()).await; let mut touched = Vec::new(); for group in GROUPS { for code in CODES { let (id, title, body, revision) = proposal(&state, group, code).await; assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}"); let m5 = code != "F1.1"; assert_eq!(revision, if m5 { 3 } else { 2 }, "{group} {code}"); let expected: &[&str] = if m5 { &["initial", "amendment", "amendment"] } else { &["initial", "amendment"] }; assert_eq!(kinds(&state, &id).await, expected, "{group} {code}"); // The history keeps the text it replaced, and says the new one, without author. let last: (i64, String, String, Option) = sqlx::query_as( "SELECT revision, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1", ) .bind(&id) .fetch_one(&state.db) .await .expect("revision"); assert_eq!(last, (revision, title.clone(), body.clone(), None), "{group} {code}"); for lang in ["en", "es"] { let (t, b, hash, origin, by): (String, String, String, String, Option) = sqlx::query_as( "SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?", ) .bind(&id) .bind(lang) .fetch_one(&state.db) .await .expect("translation"); assert_eq!((t.as_str(), b.as_str()), text(&today, code, lang), "{group} {code} {lang}"); assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date"); assert_eq!((origin.as_str(), by), ("seed", None)); } touched.push(id); } } let expected: Vec<(String, String, String, Option)> = GROUPS .iter() .flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None))) .collect(); assert_eq!(decisions(&state).await, expected); // Nothing else is touched: no other proposal, no other translation, no vote. let keep = |rows: Vec| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>(); assert_eq!(keep(proposals(&state).await), keep(before.clone())); let others = |rows: Vec<(String, String, String, String, String)>| { rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>() }; assert_eq!(others(translations(&state).await), others(before_tr)); let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes"); assert_eq!(votes, 0); // Monnaie libre republishes, once per article; the test group never. let rows = queued(&state).await; assert_eq!(rows.len(), 3, "{rows:?}"); assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}"); // Decided once: a second start changes nothing. let again = start(&state.db, Config::for_tests()).await; assert_eq!(decisions(&again).await, expected); assert_eq!(proposals(&again).await, proposals(&state).await); assert_eq!(queued(&again).await.len(), 3); } async fn identity(state: &AppState, id: &str) { sqlx::query( "INSERT INTO identities (id, kind, display_name, created_at) VALUES (?, 'dev', ?, '2026-11-03T00:00:00Z')", ) .bind(id) .bind(id) .execute(&state.db) .await .expect("identity"); } #[tokio::test] async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() { let base = production_after_m6("refuse").await; let doc: String = sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = 'monnaie-libre'") .fetch_one(&base.db) .await .expect("doc"); identity(&base, "dev:vera").await; // In Monnaie libre, a simulated vote on F1.2 (the simulation of M6 counts it)… let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await; sqlx::query( "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, 'dev:vera', 'for', 2, '2026-11-03T00:00:00Z', 'simulated')", ) .bind(&f12) .execute(&base.db) .await .expect("vote"); // …and a ballot still pending on N1.1: the vote is cast, it is not proven yet. let (n11, ..) = proposal(&base, "monnaie-libre", "N1.1").await; sqlx::query( "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode, created_at, expires_at) VALUES ('b1', ?, 'dev:vera', 'salt', 'digest-b1', 'remark-b1', '', 0, 'pending', 'simulated', '2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')", ) .bind(&doc) .execute(&base.db) .await .expect("ballot"); sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', ?, 2, 'against')") .bind(&n11) .execute(&base.db) .await .expect("line"); // In the test group, a member corrected the English of F1.1. let (f11, ..) = proposal(&base, "essai", "F1.1").await; sqlx::query( "UPDATE translations SET origin = 'member', updated_by = 'dev:vera' WHERE proposal_id = ? AND lang = 'en'", ) .bind(&f11) .execute(&base.db) .await .expect("translation"); let state = start(&base.db, Config::for_tests()).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); assert_eq!( decisions(&state).await, [ refused("essai", "F1.1", "translation_edited"), applied("essai", "F1.2"), applied("essai", "N1.1"), applied("monnaie-libre", "F1.1"), refused("monnaie-libre", "F1.2", "votes"), refused("monnaie-libre", "N1.1", "ballots"), ] ); let (prod, today) = (production_seed(), today_seed()); for (group, code, kept) in [ ("essai", "F1.1", true), ("essai", "F1.2", false), ("monnaie-libre", "F1.1", false), ("monnaie-libre", "F1.2", true), ("monnaie-libre", "N1.1", true), ] { let (_, title, body, revision) = proposal(&state, group, code).await; let expected = if kept { text(&prod, code, "fr") } else { text(&today, code, "fr") }; assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}"); let m5 = i64::from(code != "F1.1"); assert_eq!(revision, 1 + m5 + i64::from(!kept), "{group} {code}"); } // Republished for the one article applied in Monnaie libre only. let rows = queued(&state).await; assert_eq!( rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::>(), [("monnaie-libre", true)] ); // The refusal stands: decided once, it is not tried again. let again = start(&state.db, Config::for_tests()).await; assert_eq!(decisions(&again).await, decisions(&state).await); } #[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(); for group in GROUPS { for code in CODES { let (_, title, body, revision) = proposal(&state, group, code).await; assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 1), "{group} {code}"); } } let got = decisions(&state).await; assert_eq!(got.len(), 6); assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); // The amendments of M5 on F1.2 and N1.1, superseded, decide nothing on a new base. let m5: Vec = sqlx::query_scalar( "SELECT unit_code FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles')", ) .fetch_all(&state.db) .await .expect("m5"); assert!(m5.is_empty(), "{m5:?}"); }