forked from yvv/decision
- Le seed : « Formule du seuil WoT », « Paramètres par défaut », « Niveaux d'inertie » réécrits d'après l'ADR 0013 (sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran) et l'ADR 0011 (aucun article en vigueur au départ, les trois Propositions seed au cran 2, le titre au cran 4) ; le _source le dit. - Les nombres : chacun, en fr, en et es, calculé par le moteur ou lu dans le seed (tests/seed_quorum_texts.rs) ; les trois formules évaluées telles qu'écrites contre threshold et inertia ; les règles de forme vérifiées. - seed_amend : 2026-11-03-f1-1-quorum, -f1-2-quorum, -n1-1-quorum. Un amendement qui « follows » un autre admet la révision 2 « initial puis amendment » écrite par lui seul, rien d'autre ; ceux de M5 sur F1.2 et N1.1 sont « superseded_by » (plus rien décidé, sans erreur). La garde compte aussi les lignes de bulletin du tampon (« ballots »). - Tests vus rouges : sept du lot T, deux unitaires de la garde ; les tests de M5 jouent sur le seed de M5 (la fixture de production). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1059 lines
47 KiB
Rust
1059 lines
47 KiB
Rust
//! 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<u8> = 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<String> {
|
||
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<String> {
|
||
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<String> = 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<String> {
|
||
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<char> = 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<String>,
|
||
at: usize,
|
||
env: &'a HashMap<String, f64>,
|
||
}
|
||
|
||
fn tokens(src: &str) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
let chars: Vec<char> = 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::<f64>() {
|
||
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<String, f64>) -> 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<String, f64> = 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<Row> {
|
||
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<String> {
|
||
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<String>)> {
|
||
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<String>) = 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<String>) = 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<String>)> = 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<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
|
||
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::<Vec<_>>()
|
||
};
|
||
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::<Vec<_>>(),
|
||
[("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<String> = 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:?}");
|
||
}
|