//! 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. //! //! Lot T2 (2026-11-03, after the review of M6b): three phrases of these texts said more than //! the rules do — F1.1 « presque l'unanimité quand peu de gens votent », F1.2 « ensuite, il //! ne change plus », N1.1 « le groupe demande un quorum de votants ». A second guarded //! amendment, which follows lot T's, writes them true on a base made as production is //! after M6b; the texts of M6b are kept in `tests/fixtures/seed-quorum-m6b.json`. use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; use ld_engine::threshold::{FormulaParams, Tally, judged_voters, no_needed, passes, 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")) } /// F1.1, F1.2 and N1.1 as lot T wrote them: production's since M6b (bb4bdf5), unchanged by M7. fn m6b_texts() -> Value { read(fixtures().join("seed-quorum-m6b.json")) } /// 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) } 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}"); } } } // --------------------------------------------------------------------------- // Lot T2: the three phrases the review of M6b left inexact // --------------------------------------------------------------------------- /// (article, language, the words of M6b, the words of today): the only change of lot T2. const PHRASES: [(&str, &str, &str, &str); 9] = [ ( "F1.1", "fr", "parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.", "parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.", ), ( "F1.1", "en", "among all the voters: near-unanimity when few people vote, the majority M when everyone votes.", "among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.", ), ( "F1.1", "es", "entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.", "entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.", ), ( "F1.2", "fr", "peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus.", "peut en fixer un autre tant que l'article ne garde la trace d'aucun vote.", ), ( "F1.2", "en", "can set another one as long as nobody has voted on the article; after that, it no longer changes.", "can set another one as long as the article keeps no trace of any vote.", ), ( "F1.2", "es", "puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia.", "puede fijar otro mientras el artículo no guarde rastro de ningún voto.", ), ( "N1.1", "fr", "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse,", "À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants, plus elle baisse,", ), ( "N1.1", "en", "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets,", "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets,", ), ( "N1.1", "es", "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja,", "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja,", ), ]; #[test] fn the_three_phrases_of_the_review_of_m6b_are_written_true() { let (m6b, today) = (m6b_seed(), today_seed()); for (code, lang, old, new) in PHRASES { assert!(text(&m6b, code, lang).1.contains(old), "{code} {lang}: the words of M6b"); let (_, body) = text(&today, code, lang); assert!(!body.contains(old), "{code} {lang}: still the words of M6b\n{body}"); assert!(body.contains(new), "{code} {lang}: missing « {new} »\n{body}"); } // F1.1 and N1.1, by the engine: the share of a level is that of the quorum while fewer // vote, its highest; beyond, it falls as turnout rises, down to M when everyone votes — // never near-unanimity because few vote. Every level, quorum and electorate of the grid. for (w, median) in [(5707u32, None), (7224, Some(120.0)), (40, Some(9.0)), (2, Some(1.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); let share = |t: u32| required_ratio(w, judged_voters(t, q), ¶ms); let top = share(0); let mut last = top; for t in 0..=w { let s = share(t); assert!(s <= last, "W {w} k {} Q {q}: the share rises at {t} voters", k.get()); if t <= q { assert_eq!(s, top, "W {w} k {} Q {q}: under the quorum, the share of the quorum", k.get()); } last = s; } let m = k.majority_pct() / 100.0; assert!((share(w) - m).abs() < 1e-12, "W {w} k {} Q {q}: M when everyone votes", k.get()); } } } // N1.1: the quorum is no minimum of voters. In Monnaie libre (W 5 707 on 02/11, the // reference turnout), at every level, fewer voters than its quorum pass when they all // vote « pour »: 8 of 12, 9 of 12, 39 of 50, 43 of 50, 46 of 50. let w = 5707; let f = figures(&today); let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: f.quorums }; let fewer: Vec<(u32, u32)> = InertiaLevel::ALL .iter() .map(|k| { let q = scale.quorum(*k, None); let p = scale.params(*k, q); let yes = yes_needed(0, w, &p).expect("valid"); assert!(passes(Tally { for_votes: yes, against: 0 }, w, &p).expect("valid")); (yes, q) }) .collect(); assert_eq!(fewer, [(8, 12), (9, 12), (39, 50), (43, 50), (46, 50)]); assert!(fewer.iter().all(|(yes, q)| yes < q)); } // --------------------------------------------------------------------------- // 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 (`quorum`) or lot T2 (`phrases`). fn amendment(code: &str, lot: &str) -> &'static seed_amend::Amendment { AMENDMENTS .iter() .find(|a| a.unit == code && a.id.starts_with("2026-11-03-") && a.id.ends_with(lot)) .unwrap_or_else(|| panic!("no amendment « {lot} » for {code}")) } #[test] fn the_amendments_of_lot_t_went_from_m5_to_the_texts_of_m6b() { let (prod, m6b) = (production_seed(), m6b_seed()); for code in CODES { let a = amendment(code, "-quorum"); 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 held before M6b"); let (t, b) = text(&m6b, code, "fr"); assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text production holds since M6b"); assert_ne!(a.from, a.to, "{code}"); // F1.1 was never amended before; 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}"); } } // Lot T2 replaces its text: it is superseded in turn. assert_eq!(a.superseded_by, Some(amendment(code, "-phrases").id), "{code}"); } } #[test] fn the_amendments_of_lot_t2_go_from_the_texts_of_m6b_to_the_seed_of_today() { let (m6b, today) = (m6b_seed(), today_seed()); for code in CODES { let a = amendment(code, "-phrases"); let t = amendment(code, "-quorum"); assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}"); let (title, body) = text(&m6b, code, "fr"); assert_eq!(seed_amend::fingerprint(title, body), a.from, "{code}: the text production holds"); 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 T's, which wrote the text it replaces; nothing replaces it yet. assert_eq!( (a.follows, t.to, t.superseded_by, a.superseded_by), (Some(t.id), a.from, Some(a.id), None), "{code}" ); // The titles stay, and only the phrases of the review change (`PHRASES`). for lang in LANGS { let (before, now) = (text(&m6b, code, lang), text(&today, code, lang)); assert_eq!(before.0, now.0, "{code} {lang}: the title is kept"); let mut b = before.1.to_owned(); for (c, l, old, new) in PHRASES { if (c, l) == (code, lang) { b = b.replacen(old, new, 1); } } assert_eq!(b, now.1, "{code} {lang}: nothing else changes"); } } } /// 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 (`-quorum`) or lot T2 (`-phrases`): (group, unit, outcome, reason). async fn decisions(state: &AppState, lot: &str) -> 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 ? ORDER BY d.group_slug, s.unit_code", ) .bind(format!("2026-11-03-%{lot}")) .fetch_all(&state.db) .await .expect("decisions") } /// The rows the amendments of a lot added to the publication queue: (group, reason). async fn queued(state: &AppState, lot: &str) -> Vec<(String, String)> { let reasons: Vec<&str> = CODES.iter().map(|c| amendment(c, lot).reason).collect(); let rows: Vec<(String, String)> = sqlx::query_as("SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id ORDER BY o.id") .fetch_all(&state.db) .await .expect("outbox"); rows.into_iter().filter(|(_, r)| reasons.contains(&r.as_str())).collect() } /// A start with the seed of M6b (bb4bdf5, and M7 since): lot T decides, lot T2 does not yet. fn m6b_config(tag: &str) -> Config { config(seed_dir(&format!("{tag}-m6b"), &m6b_seed())) } /// Lot T, as M6b played it on production (03/11, 08 h 02): started with the seed of M6b. #[tokio::test] async fn production_after_m6_gets_the_quorum_texts_once() { let base = production_after_m6("apply").await; let (prod, today) = (production_seed(), m6b_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, "-quorum").await.is_empty(), "the seed of M6 decides nothing of lot T"); let (before, before_tr) = (proposals(&base).await, translations(&base).await); // Started with the seed of M6b. let state = start(&base.db, m6b_config("apply")).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, "-quorum").await, expected); assert!(decisions(&state, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2"); // 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, "-quorum").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, m6b_config("apply-again")).await; assert_eq!(decisions(&again, "-quorum").await, expected); assert_eq!(proposals(&again).await, proposals(&state).await); assert_eq!(queued(&again, "-quorum").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"); } /// Lot T's guard, as M6b played it: started with the seed of M6b. #[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, m6b_config("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); assert_eq!( decisions(&state, "-quorum").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(), m6b_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, "-quorum").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, m6b_config("refuse-again")).await; assert_eq!(decisions(&again, "-quorum").await, decisions(&state, "-quorum").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}"); } } // Lot T2's amendments find their text there: present. let got = decisions(&state, "-phrases").await; assert_eq!(got.len(), 6); assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); // The amendments of M5 and of lot T on these articles, superseded, decide nothing on a // new base. let m5: Vec = sqlx::query_scalar( "SELECT amendment_id FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles') OR amendment_id LIKE '2026-11-03-%-quorum'", ) .fetch_all(&state.db) .await .expect("m5"); assert!(m5.is_empty(), "{m5:?}"); } // --------------------------------------------------------------------------- // Lot T2 in production: on a base made as production is after M6b // --------------------------------------------------------------------------- /// Production's history, for these articles, after M6b (03/11, 08 h 02): F1.1 at its /// revision 2, F1.2 and N1.1 at their revision 3, each written by an amendment of the seed. async fn production_after_m6b(tag: &str) -> AppState { let base = production_after_m6(tag).await; start(&base.db, m6b_config(tag)).await } async fn count(state: &AppState, sql: &str) -> i64 { sqlx::query_scalar(sql).fetch_one(&state.db).await.expect("count") } /// The last revision of a proposal: (revision, kind, title, body, author). async fn last_revision(state: &AppState, id: &str) -> (i64, String, String, String, Option) { sqlx::query_as( "SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1", ) .bind(id) .fetch_one(&state.db) .await .expect("revision") } #[tokio::test] async fn production_after_m6b_gets_the_three_phrases_once() { let base = production_after_m6b("t2-apply").await; let (m6b, today) = (m6b_seed(), today_seed()); // The base is as production after M6b: the texts of lot T, at revision 2 and 3. 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(&m6b, 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(&base, &id).await, expected, "{group} {code}"); } } let lot_t = decisions(&base, "-quorum").await; assert_eq!(lot_t.len(), 6); assert!(lot_t.iter().all(|(_, _, o, _)| o == "applied"), "{lot_t:?}"); assert!(decisions(&base, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2"); let (before, before_tr) = (proposals(&base).await, translations(&base).await); let outbox = count(&base, "SELECT COUNT(*) FROM outbox").await; let revisions = count(&base, "SELECT COUNT(*) FROM revisions").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 { 4 } else { 3 }, "{group} {code}"); let mut expected = vec!["initial", "amendment", "amendment"]; if m5 { expected.push("amendment"); } assert_eq!(kinds(&state, &id).await, expected, "{group} {code}"); // The history keeps the text of lot T, and says the new one, without author. assert_eq!( last_revision(&state, &id).await, (revision, "amendment".to_owned(), title.clone(), body.clone(), None), "{group} {code}" ); let (_, kind, t, b, _) = sqlx::query_as::<_, (i64, String, String, String, Option)>( "SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? AND revision = ?", ) .bind(&id) .bind(revision - 1) .fetch_one(&state.db) .await .expect("revision of lot T"); assert_eq!( (kind.as_str(), (t.as_str(), b.as_str())), ("amendment", text(&m6b, code, "fr")), "{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, "-phrases").await, expected); // Lot T's decisions stay as they were taken; superseded, it decides nothing more. assert_eq!(decisions(&state, "-quorum").await, lot_t); // Nothing else is touched: no other proposal, no other translation, no vote, six revisions. let keep = |rows: Vec| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>(); assert_eq!(keep(proposals(&state).await), keep(before)); 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)); assert_eq!(count(&state, "SELECT COUNT(*) FROM votes").await, 0); assert_eq!(count(&state, "SELECT COUNT(*) FROM revisions").await, revisions + 6); // Monnaie libre republishes, once per article; the test group never. let rows = queued(&state, "-phrases").await; assert_eq!(rows.len(), 3, "{rows:?}"); assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}"); assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 3); // Decided once: a second start replays nothing. let again = start(&state.db, Config::for_tests()).await; assert_eq!(decisions(&again, "-phrases").await, expected); assert_eq!(proposals(&again).await, proposals(&state).await); assert_eq!(translations(&again).await, translations(&state).await); assert_eq!(count(&again, "SELECT COUNT(*) FROM revisions").await, revisions + 6); assert_eq!(count(&again, "SELECT COUNT(*) FROM outbox").await, outbox + 3); } /// A person, voting through the interface's own routes (no chain in the test group). struct Voter { router: axum::Router, cookie: String, } impl Voter { async fn call(&self, method: &str, uri: &str, body: Value, cookie: Option<&str>) -> axum::response::Response { use tower::ServiceExt; let mut req = axum::http::Request::builder() .method(method) .uri(uri) .header(ld_server::CSRF_HEADER, ld_server::CSRF_VALUE) .header(axum::http::header::CONTENT_TYPE, "application/json"); if let Some(c) = cookie { req = req.header(axum::http::header::COOKIE, c); } let req = req.body(axum::body::Body::from(body.to_string())).expect("request"); self.router.clone().oneshot(req).await.expect("response") } async fn login(state: &AppState, name: &str) -> Self { let mut v = Self { router: ld_server::app(state.clone()), cookie: String::new() }; let res = v.call("POST", "/api/auth/dev", serde_json::json!({ "name": name }), None).await; assert_eq!(res.status(), axum::http::StatusCode::OK); let set = res.headers().get(axum::http::header::SET_COOKIE).expect("cookie").to_str().expect("cookie"); v.cookie = set.split(';').next().expect("cookie").to_owned(); v } async fn vote(&self, proposal: &str, choice: Option<&str>) { let uri = format!("/api/proposals/{proposal}/vote"); let res = self.call("POST", &uri, serde_json::json!({ "choice": choice }), Some(&self.cookie)).await; assert_eq!(res.status(), axum::http::StatusCode::OK, "{uri} {choice:?}"); } } #[tokio::test] async fn an_article_voted_on_keeps_the_text_of_m6b_and_says_why() { let base = production_after_m6b("t2-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.1, at its revision 2… let (f11, ..) = proposal(&base, "monnaie-libre", "F1.1").await; sqlx::query( "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, 'dev:vera', 'against', 2, '2026-11-03T09:00:00Z', 'simulated')", ) .bind(&f11) .execute(&base.db) .await .expect("vote"); // …and a ballot still pending on F1.2, at its revision 3: cast, not proven yet. let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await; sqlx::query( "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode, created_at, expires_at) VALUES ('b2', ?, 'dev:vera', 'salt', 'digest-b2', 'remark-b2', '', 0, 'pending', 'simulated', '2026-11-03T09:00:00Z', '2026-11-04T09:00:00Z')", ) .bind(&doc) .execute(&base.db) .await .expect("ballot"); sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', ?, 3, 'against')") .bind(&f12) .execute(&base.db) .await .expect("line"); // In the test group, Luna votes « contre » N1.1 and keeps her vote (a « pour » would pass // there, quorum 1); she votes on F1.2, then withdraws: a direct vote withdrawn leaves no // trace (the guard's known limit, `seed_amend`). let luna = Voter::login(&base, "Luna").await; let (n11, ..) = proposal(&base, "essai", "N1.1").await; luna.vote(&n11, Some("against")).await; let (f12_essai, ..) = proposal(&base, "essai", "F1.2").await; luna.vote(&f12_essai, Some("against")).await; luna.vote(&f12_essai, None).await; let traces = |id: String| { let state = base.clone(); async move { let sql = "SELECT (SELECT COUNT(*) FROM votes WHERE proposal_id = ?1) + (SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?1)"; sqlx::query_scalar::<_, i64>(sql).bind(id).fetch_one(&state.db).await.expect("traces") } }; assert_eq!((traces(n11.clone()).await, traces(f12_essai.clone()).await), (1, 0)); 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, "-phrases").await, [ applied("essai", "F1.1"), applied("essai", "F1.2"), refused("essai", "N1.1", "votes"), refused("monnaie-libre", "F1.1", "votes"), refused("monnaie-libre", "F1.2", "ballots"), applied("monnaie-libre", "N1.1"), ] ); let (m6b, today) = (m6b_seed(), today_seed()); for group in GROUPS { for code in CODES { let kept = matches!((group, code), ("essai", "N1.1") | ("monnaie-libre", "F1.1" | "F1.2")); let (_, title, body, revision) = proposal(&state, group, code).await; let expected = if kept { text(&m6b, code, "fr") } else { text(&today, code, "fr") }; assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}"); let lot_t = if code == "F1.1" { 2 } else { 3 }; assert_eq!(revision, lot_t + i64::from(!kept), "{group} {code}"); } } // Republished for the one article applied in Monnaie libre only. let rows = queued(&state, "-phrases").await; assert_eq!( rows.iter().map(|(g, r)| (g.as_str(), r.contains("N1.1"))).collect::>(), [("monnaie-libre", true)] ); // The refusals stand: decided once, never tried again, even once the vote is gone. sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&f11).execute(&state.db).await.expect("delete"); let again = start(&state.db, Config::for_tests()).await; assert_eq!(decisions(&again, "-phrases").await, decisions(&state, "-phrases").await); let (_, title, body, _) = proposal(&again, "monnaie-libre", "F1.1").await; assert_eq!((title.as_str(), body.as_str()), text(&m6b, "F1.1", "fr")); } /// A base born on 02/11 with the seed of M5 (F1.2 and N1.1 at their revision 1, in M5's /// words): lot T wrote their revision 2, and lot T2 follows it there too. #[tokio::test] async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() { let prod = production_seed(); let db = open_db("sqlite::memory:").await.expect("db"); 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; let base = start(&db, m6b_config("t2-born-m5")).await; for group in GROUPS { for code in CODES { let (id, ..) = proposal(&base, group, code).await; assert_eq!(kinds(&base, &id).await, ["initial", "amendment"], "{group} {code}: lot T, alone"); } } let state = start(&db, Config::for_tests()).await; let today = today_seed(); 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()), revision), (text(&today, code, "fr"), 3), "{group} {code}"); assert_eq!(kinds(&state, &id).await, ["initial", "amendment", "amendment"], "{group} {code}"); } } let got = decisions(&state, "-phrases").await; assert_eq!(got.len(), 6); assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}"); }