forked from yvv/decision
v3 : modules d'ordre et de titre — moteur, données, serveur (L11)
L'ordre des articles de chaque section (cran 2) et le titre du document (cran 4) deviennent des modules : des unités à part, dont les versions sont des propositions votées comme les articles (dépliées, panier, chaîne). - moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé, complété à la fin quand un article entre ou sort, places redistribuées, nouvel article en fin de section) ; pending : un ordre de moins de deux articles n'est jamais à voter ; - données : migration 0007 (units.kind, module_amendments) ; le seed déclare ses modules ; module_amend.rs les ajoute une fois, gardé et journalisé, aux documents nés sans eux, sans toucher articles ni votes ; - serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer, entrée en vigueur : les articles changent de place, le document change de titre, en et es passent « à revoir ») ; publication des modules ; - reste mineur : la médiane compte les statuts open, in_force, applied, et eux seuls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ pub mod compose;
|
||||
pub mod edit;
|
||||
pub mod inertia;
|
||||
pub mod names;
|
||||
pub mod order;
|
||||
pub mod pending;
|
||||
pub mod threshold;
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The order of the articles of a section — a module of its own.
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
|
||||
//! changer l'ordre des "articles". Lui-même à (2), avec une simple liste
|
||||
//! clipable. » The order of a section is voted like an article: its version in
|
||||
//! force is the order the section reads in now; a proposal is another order.
|
||||
//!
|
||||
//! The rules, purely:
|
||||
//!
|
||||
//! - **who is in the order** (`members`): the articles of the section that a
|
||||
//! person can see — a text in force, or an open version. A draft is nobody's
|
||||
//! but its author's; an article whose versions were all withdrawn, or that
|
||||
//! was removed with nothing left open, is out;
|
||||
//! - **an order proposed** names every article of the section, each once
|
||||
//! (`check`);
|
||||
//! - **articles come and go** while an order is under vote: the proposal is
|
||||
//! **completed at the end** (`fit`). The articles it names that are still in
|
||||
//! the section keep its order; those that entered since go at the end, in the
|
||||
//! order of the section; those that left are dropped. Its votes stay: the
|
||||
//! voters chose the relative order of the articles they saw, and a newcomer
|
||||
//! at the end is where a new article goes anyway (`place_at_end`). Neither
|
||||
//! lapsing nor asking every voter again would let an order ever pass in a
|
||||
//! section that lives;
|
||||
//! - an order that, so completed, is the order in force changes nothing and
|
||||
//! cannot enter into force (the engine compares `canonical` texts);
|
||||
//! - **entering into force** gives the section's places out again in the new
|
||||
//! order (`positions`): the section keeps the positions it holds, so it never
|
||||
//! mingles with its neighbours;
|
||||
//! - an order of fewer than two articles has nothing to decide (`is_moot`).
|
||||
//!
|
||||
//! The vote itself — threshold, margin over the version in force (ADR 0002) —
|
||||
//! is that of any version (`compose`).
|
||||
|
||||
/// An article as the order of its section sees it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Article<'a> {
|
||||
pub id: &'a str,
|
||||
pub section: Option<&'a str>,
|
||||
pub position: f64,
|
||||
/// A text in force, or at least one open version (text or removal).
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// Below this many articles, an order has nothing to decide.
|
||||
pub const MIN_ARTICLES: usize = 2;
|
||||
|
||||
pub fn is_moot(members: &[String]) -> bool {
|
||||
members.len() < MIN_ARTICLES
|
||||
}
|
||||
|
||||
/// The articles in the order of `section`, as it reads now: by position, then id.
|
||||
pub fn members(articles: &[Article<'_>], section: &str) -> Vec<String> {
|
||||
let mut inside: Vec<&Article<'_>> = articles.iter().filter(|a| a.visible && a.section == Some(section)).collect();
|
||||
inside.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(b.id)));
|
||||
inside.into_iter().map(|a| a.id.to_owned()).collect()
|
||||
}
|
||||
|
||||
/// What an order proposal gives the section now.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Fitted {
|
||||
/// The order as it would read now.
|
||||
pub order: Vec<String>,
|
||||
/// Articles that entered the section since it was proposed: at the end.
|
||||
pub added: Vec<String>,
|
||||
/// Articles it names that left the section: dropped.
|
||||
pub dropped: Vec<String>,
|
||||
}
|
||||
|
||||
/// An order proposal against the section as it is now: completed at the end.
|
||||
pub fn fit(proposed: &[String], members: &[String]) -> Fitted {
|
||||
let mut order: Vec<String> = Vec::with_capacity(members.len());
|
||||
let mut dropped = Vec::new();
|
||||
for id in proposed {
|
||||
if order.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if members.contains(id) {
|
||||
order.push(id.clone());
|
||||
} else if !dropped.contains(id) {
|
||||
dropped.push(id.clone());
|
||||
}
|
||||
}
|
||||
let added: Vec<String> = members.iter().filter(|m| !order.contains(m)).cloned().collect();
|
||||
order.extend(added.iter().cloned());
|
||||
Fitted { order, added, dropped }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OrderError {
|
||||
/// Not every article of the section, each once.
|
||||
NotTheSection,
|
||||
/// The order the section already reads in.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// An order proposed now: every article of the section, each once, in another order.
|
||||
pub fn check(proposed: &[String], members: &[String]) -> Result<(), OrderError> {
|
||||
let mut seen: Vec<&String> = Vec::with_capacity(proposed.len());
|
||||
for id in proposed {
|
||||
if seen.contains(&id) || !members.contains(id) {
|
||||
return Err(OrderError::NotTheSection);
|
||||
}
|
||||
seen.push(id);
|
||||
}
|
||||
if proposed.len() != members.len() {
|
||||
return Err(OrderError::NotTheSection);
|
||||
}
|
||||
if proposed == members {
|
||||
return Err(OrderError::Unchanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The order as a text: one article per line. It is the body of an order
|
||||
/// proposal, and what the engine compares to tell an order from another.
|
||||
pub fn canonical(order: &[String]) -> String {
|
||||
order.join("\n")
|
||||
}
|
||||
|
||||
/// The order a proposal's body holds; `None` when it holds none.
|
||||
pub fn decode(body: &str) -> Option<Vec<String>> {
|
||||
let ids: Vec<String> = body.lines().map(str::trim).map(str::to_owned).collect();
|
||||
let readable = !ids.is_empty() && ids.iter().all(|id| !id.is_empty() && !id.contains(char::is_whitespace));
|
||||
readable.then_some(ids)
|
||||
}
|
||||
|
||||
/// The places of the section, given out again in the new order: the section
|
||||
/// keeps the positions it holds (`current`, any order), the first of them goes
|
||||
/// to the first article of `order`, and so on. Articles of `order` that hold no
|
||||
/// place, and places beyond `order`, are left alone.
|
||||
pub fn positions(order: &[String], current: &[(String, f64)]) -> Vec<(String, f64)> {
|
||||
let ranked: Vec<&String> = order.iter().filter(|id| current.iter().any(|(c, _)| c == *id)).collect();
|
||||
let mut places: Vec<f64> = current.iter().filter(|(c, _)| ranked.contains(&c)).map(|(_, p)| *p).collect();
|
||||
places.sort_by(f64::total_cmp);
|
||||
ranked.into_iter().zip(places).map(|(id, p)| (id.clone(), p)).collect()
|
||||
}
|
||||
|
||||
/// Where a new article goes: at the end of its section — after the last article
|
||||
/// of the section (seen or not: a draft keeps its place), before the first one
|
||||
/// that follows it in the document. Without a section, or in a section not seen
|
||||
/// yet, after everything.
|
||||
pub fn place_at_end(articles: &[(Option<&str>, f64)], section: Option<&str>) -> f64 {
|
||||
let last_of_doc = articles.iter().map(|(_, p)| *p).fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))));
|
||||
let after_all = last_of_doc.map_or(1.0, |m| m + 1.0);
|
||||
let Some(section) = section else { return after_all };
|
||||
let Some(last) = articles
|
||||
.iter()
|
||||
.filter(|(s, _)| *s == Some(section))
|
||||
.map(|(_, p)| *p)
|
||||
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))))
|
||||
else {
|
||||
return after_all;
|
||||
};
|
||||
let next = articles
|
||||
.iter()
|
||||
.map(|(_, p)| *p)
|
||||
.filter(|p| *p > last)
|
||||
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.min(p))));
|
||||
next.map_or(last + 1.0, |next| (last + next) / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn art<'a>(id: &'a str, section: &'a str, position: f64, visible: bool) -> Article<'a> {
|
||||
Article { id, section: Some(section), position, visible }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_holds_the_articles_one_can_see_in_the_order_they_read() {
|
||||
let doc = [
|
||||
art("I1", "introduction", 1.0, true),
|
||||
art("T1", "fondamental", 3.0, true),
|
||||
art("E2", "fondamental", 5.0, true),
|
||||
art("E1", "fondamental", 4.0, true),
|
||||
// A draft, or an article with nothing left open: out.
|
||||
art("N1", "fondamental", 5.5, false),
|
||||
];
|
||||
assert_eq!(members(&doc, "fondamental"), ids(&["T1", "E1", "E2"]));
|
||||
assert_eq!(members(&doc, "introduction"), ids(&["I1"]));
|
||||
assert!(members(&doc, "annexe").is_empty());
|
||||
// The same place: the identifier settles it.
|
||||
let tie = [art("b", "s", 1.0, true), art("a", "s", 1.0, true)];
|
||||
assert_eq!(members(&tie, "s"), ids(&["a", "b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_of_fewer_than_two_articles_has_nothing_to_decide() {
|
||||
assert!(is_moot(&[]));
|
||||
assert!(is_moot(&ids(&["O1"])));
|
||||
assert!(!is_moot(&ids(&["K1", "K2"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_is_proposed_with_every_article_of_the_section_each_once() {
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
assert_eq!(check(&ids(&["E2", "E1", "T1"]), §ion), Ok(()));
|
||||
assert_eq!(check(&ids(&["T1", "E1", "E2"]), §ion), Err(OrderError::Unchanged));
|
||||
for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] {
|
||||
assert_eq!(check(&ids(wrong), §ion), Err(OrderError::NotTheSection), "{wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proposal_still_fitting_the_section_is_read_as_it_was_proposed() {
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
let f = fit(&ids(&["E2", "T1", "E1"]), §ion);
|
||||
assert_eq!(f, Fitted { order: ids(&["E2", "T1", "E1"]), added: vec![], dropped: vec![] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_article_that_enters_the_section_goes_at_the_end_of_a_proposal() {
|
||||
// Proposed when the section held T1, E1, E2; N4 then N7 entered since.
|
||||
let now = ids(&["T1", "N4", "E1", "E2", "N7"]);
|
||||
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
|
||||
assert_eq!(f.order, ids(&["E2", "E1", "T1", "N4", "N7"]), "the newcomers at the end, in the section's order");
|
||||
assert_eq!(f.added, ids(&["N4", "N7"]));
|
||||
assert!(f.dropped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_article_that_leaves_the_section_is_dropped_from_a_proposal() {
|
||||
let now = ids(&["T1", "E2"]);
|
||||
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
|
||||
assert_eq!(f.order, ids(&["E2", "T1"]));
|
||||
assert_eq!(f.dropped, ids(&["E1"]));
|
||||
assert!(f.added.is_empty());
|
||||
// Both at once; a repeated or unknown line never doubles an article.
|
||||
let f = fit(&ids(&["E2", "E2", "E1", "X1", "T1"]), &ids(&["T1", "E2", "N4"]));
|
||||
assert_eq!((f.order, f.added, f.dropped), (ids(&["E2", "T1", "N4"]), ids(&["N4"]), ids(&["E1", "X1"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proposal_that_comes_back_to_the_order_in_force_changes_nothing() {
|
||||
// [E1, T1] proposed over [T1, E1]; E1 left: completed, it is the order in force.
|
||||
let now = ids(&["T1"]);
|
||||
assert_eq!(canonical(&fit(&ids(&["E1", "T1"]), &now).order), canonical(&now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_of_an_order_proposal_is_one_article_per_line() {
|
||||
let order = ids(&["a1f0", "77c2", "09bd"]);
|
||||
assert_eq!(canonical(&order), "a1f0\n77c2\n09bd");
|
||||
assert_eq!(decode(&canonical(&order)), Some(order));
|
||||
assert_eq!(decode("a1\r\nb2"), Some(ids(&["a1", "b2"])));
|
||||
for unreadable in ["", "a1\n\nb2", "a1 b2", "\n"] {
|
||||
assert_eq!(decode(unreadable), None, "{unreadable:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_into_force_gives_the_places_of_the_section_out_again() {
|
||||
let current = vec![("T1".to_owned(), 3.0), ("E1".to_owned(), 4.0), ("E2".to_owned(), 5.0)];
|
||||
let mut moved = positions(&ids(&["E2", "T1", "E1"]), ¤t);
|
||||
moved.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(moved, vec![("E1".to_owned(), 5.0), ("E2".to_owned(), 3.0), ("T1".to_owned(), 4.0)]);
|
||||
// Uneven places stay the section's own: the neighbours never mingle with it.
|
||||
let uneven = vec![("a".to_owned(), 6.0), ("b".to_owned(), 6.5), ("c".to_owned(), 11.0)];
|
||||
assert_eq!(
|
||||
positions(&ids(&["c", "a", "b"]), &uneven),
|
||||
vec![("c".to_owned(), 6.0), ("a".to_owned(), 6.5), ("b".to_owned(), 11.0)]
|
||||
);
|
||||
// An article of the order without a place is left out; a place without an article is kept.
|
||||
assert_eq!(positions(&ids(&["z", "b"]), &uneven), vec![("b".to_owned(), 6.5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_is_voted_like_any_version_with_the_margin_of_the_one_in_force() {
|
||||
use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions};
|
||||
use crate::inertia::{InertiaLevel, Scale};
|
||||
use crate::threshold::Tally;
|
||||
// Monnaie libre before any median: W 5745, C 50; the order of a section at level 2.
|
||||
let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
let module = |tally: Tally| UnitState {
|
||||
id: "ordre.fondamental".into(),
|
||||
position: 0.0,
|
||||
inertia: InertiaLevel::new(2).expect("level"),
|
||||
in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(§ion), tally }),
|
||||
};
|
||||
let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState {
|
||||
id: id.into(),
|
||||
unit_id: "ordre.fondamental".into(),
|
||||
kind: ProposalKind::Text,
|
||||
title: String::new(),
|
||||
body: canonical(&fit(&ids(order), §ion).order),
|
||||
tally: Tally { for_votes, against: 0 },
|
||||
submitted_at: "2026-10-31T00:00:00Z".into(),
|
||||
blocked: false,
|
||||
};
|
||||
let seed = [module(Tally::default())];
|
||||
// Never under C voices for; the order in force at zero votes is a seed a minima: margin −C.
|
||||
assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty());
|
||||
assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]);
|
||||
// An order that is the one in force changes nothing, whatever its votes.
|
||||
assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty());
|
||||
// Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes.
|
||||
let voted = [module(Tally { for_votes: 70, against: 0 })];
|
||||
assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty());
|
||||
assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_article_goes_at_the_end_of_its_section() {
|
||||
let doc = [
|
||||
(Some("introduction"), 1.0),
|
||||
(Some("introduction"), 2.0),
|
||||
(Some("fondamental"), 3.0),
|
||||
(Some("fondamental"), 5.0),
|
||||
(Some("technique"), 6.0),
|
||||
];
|
||||
// Between the last of its section and the first that follows it.
|
||||
assert_eq!(place_at_end(&doc, Some("introduction")), 2.5);
|
||||
assert_eq!(place_at_end(&doc, Some("fondamental")), 5.5);
|
||||
// The last section of the document, an unknown section, or none: after everything.
|
||||
assert_eq!(place_at_end(&doc, Some("technique")), 7.0);
|
||||
assert_eq!(place_at_end(&doc, Some("annexe")), 7.0);
|
||||
assert_eq!(place_at_end(&doc, None), 7.0);
|
||||
assert_eq!(place_at_end(&[], Some("introduction")), 1.0);
|
||||
// After the ones already placed at its end.
|
||||
let again = [(Some("fondamental"), 5.0), (Some("fondamental"), 5.5), (Some("technique"), 6.0)];
|
||||
assert_eq!(place_at_end(&again, Some("fondamental")), 5.75);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! What is left to vote in a document — the line of « Décisions ».
|
||||
//!
|
||||
//! A module is an article (a unit). It stays **to vote** as long as no version
|
||||
//! A module is an article, the title of the document, or the order of a
|
||||
//! section (ADR 0009) — a unit. It stays **to vote** as long as no version
|
||||
//! of it was adopted by a vote: its version in force is a seed a minima not yet
|
||||
//! confirmed, or it has no version in force and at least one open version.
|
||||
//! Variants of an article count once. For a person who votes, a module is
|
||||
//! Variants of an article count once. The order of a section of fewer than two
|
||||
//! articles has nothing to decide: never to vote. For a person who votes, a module is
|
||||
//! **without their vote** when they hold no valid vote (one not « to
|
||||
//! reconfirm ») on any of its versions.
|
||||
|
||||
@@ -17,10 +19,16 @@ pub struct Module {
|
||||
pub open: u32,
|
||||
/// The person holds a valid vote on one of its versions, in force or open.
|
||||
pub voted: bool,
|
||||
/// Nothing to decide: the order of a section of fewer than two articles
|
||||
/// (`order::is_moot`). Never to vote, and not shown.
|
||||
pub moot: bool,
|
||||
}
|
||||
|
||||
impl Module {
|
||||
pub fn to_vote(&self) -> bool {
|
||||
if self.moot {
|
||||
return false;
|
||||
}
|
||||
match self.in_force {
|
||||
Some(adopted) => !adopted,
|
||||
None => self.open > 0,
|
||||
@@ -51,7 +59,7 @@ mod tests {
|
||||
const ADOPTED: Option<bool> = Some(true);
|
||||
|
||||
fn m(in_force: Option<bool>, open: u32, voted: bool) -> Module {
|
||||
Module { in_force, open, voted }
|
||||
Module { in_force, open, voted, moot: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,6 +96,25 @@ mod tests {
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_with_nothing_to_decide_is_never_to_vote() {
|
||||
let one_article = Module { moot: true, ..m(SEED, 1, false) };
|
||||
assert!(!one_article.to_vote());
|
||||
assert_eq!(pending(&[one_article]), Pending::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_modules_of_the_certification_act() {
|
||||
// 33 articles; the title of the document; the order of each of the 8 sections,
|
||||
// all seeds a minima — but « ordonnancement » holds one article: 41 to vote.
|
||||
let mut modules = vec![m(SEED, 0, false); 3];
|
||||
modules.extend(vec![m(None, 1, false); 30]);
|
||||
modules.push(m(SEED, 0, false));
|
||||
modules.extend(vec![m(SEED, 0, false); 7]);
|
||||
modules.push(Module { moot: true, ..m(SEED, 0, false) });
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 41, without_my_vote: 41 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_vote_in_an_empty_document() {
|
||||
assert_eq!(pending(&[]), Pending::default());
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Modules (2026-10-31, ADR 0009): the title of a document and the order of the
|
||||
-- articles of each of its sections, voted like its articles. Additive only:
|
||||
-- every unit of before is an article, nothing else changes.
|
||||
--
|
||||
-- A module is a unit of its own kind. Its versions are proposals, voted,
|
||||
-- counted, signed on the chain and brought into force like those of an
|
||||
-- article; their kind says what they hold:
|
||||
-- title — `title` is the title of the document in its source language;
|
||||
-- order — `body` is the order of the section, one article (unit id) per line.
|
||||
|
||||
ALTER TABLE units ADD COLUMN kind TEXT NOT NULL DEFAULT 'article'; -- article | title | order
|
||||
|
||||
-- The modules added to an existing document (src/module_amend.rs): decided
|
||||
-- once per document, from what it holds then (its title, the order of each
|
||||
-- section), never touching an article or a vote. Each decision is kept here:
|
||||
-- applied (and what was created), refused (and why), or present (the document
|
||||
-- was born with its modules).
|
||||
CREATE TABLE module_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
created TEXT, -- JSON array of the codes of the modules created
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -28,6 +28,8 @@ pub struct Entered {
|
||||
}
|
||||
|
||||
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
|
||||
// A text in force changed: the document's title follows its title in force (ADR 0009).
|
||||
crate::doc_title::sync(state, &doc.id).await?;
|
||||
if doc.publish_path.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -118,7 +120,8 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
"text"
|
||||
// A module does more: an order moves its articles, a title renames the document.
|
||||
crate::modules::on_entry(&mut tx, &st, unit, p).await?
|
||||
} else {
|
||||
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The title of a document, a module (ADR 0009, level 4).
|
||||
//!
|
||||
//! Its version in force is the title in the source language, the one that
|
||||
//! holds. `documents.title` keeps the title as every list and the publication
|
||||
//! read it: the source language from the version in force, the other languages
|
||||
//! from its editorial translations (`sync`).
|
||||
//!
|
||||
//! When another title enters into force, the document changes title in the
|
||||
//! source language. Its translations stay editorial (Yvv, 2026-10-21: the
|
||||
//! French is voted, the translations are not): the new version takes those of
|
||||
//! the title it replaces, pinned to that title's text, so they read « à
|
||||
//! revoir » until a member translates the new one.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs::{self, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules::{self, ModuleKind, NewVersion};
|
||||
use crate::util::{self, LangMap};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::edit;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// The longest title, as for an article.
|
||||
const MAX_TITLE: usize = 120;
|
||||
|
||||
/// The translations of the title a document starts with, from its seed or its row.
|
||||
pub async fn seed_translations(
|
||||
conn: &mut SqliteConnection,
|
||||
proposal_id: &str,
|
||||
title: &str,
|
||||
titles: &LangMap,
|
||||
src: &str,
|
||||
now: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
let hash = util::source_hash(title, "");
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let Some(t) = titles.get(*lang).filter(|t| !t.trim().is_empty()) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, '', ?, 'seed', NULL, ?)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(&hash)
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `documents.title` from the title in force and its translations; a language
|
||||
/// the version in force does not translate keeps the title it had.
|
||||
pub async fn sync_conn(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<()> {
|
||||
let row: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.title, d.title, d.source_lang FROM units u
|
||||
JOIN proposals p ON p.id = u.in_force_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE u.doc_id = ? AND u.kind = 'title'",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let Some((pid, title, raw, src)) = row else { return Ok(()) };
|
||||
let mut titles = util::parse_lang(&raw);
|
||||
titles.insert(src, title);
|
||||
let translated: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT lang, title FROM translations WHERE proposal_id = ?")
|
||||
.bind(&pid)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
for (lang, t) in translated {
|
||||
titles.insert(lang, t);
|
||||
}
|
||||
let next = util::lang_json(&titles);
|
||||
if next != raw {
|
||||
sqlx::query("UPDATE documents SET title = ? WHERE id = ?").bind(&next).bind(doc_id).execute(&mut *conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After any change to a text in force (a typo, a translation, an entry): the
|
||||
/// document's title follows its title in force.
|
||||
pub async fn sync(state: &AppState, doc_id: &str) -> sqlx::Result<()> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
sync_conn(&mut conn, doc_id).await
|
||||
}
|
||||
|
||||
/// Another title enters into force: it takes the translations of the one it
|
||||
/// replaces where it has none — « à revoir », pinned to the former title — and
|
||||
/// becomes the document's title.
|
||||
pub async fn enter(conn: &mut SqliteConnection, doc_id: &str, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
|
||||
if let Some(before) = unit.in_force_id.as_deref().filter(|b| *b != p.id) {
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
SELECT ?1, t.lang, t.title, t.body, t.source_hash, t.origin, t.updated_by, t.updated_at
|
||||
FROM translations t
|
||||
WHERE t.proposal_id = ?2
|
||||
AND NOT EXISTS (SELECT 1 FROM translations n WHERE n.proposal_id = ?1 AND n.lang = t.lang)",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.bind(before)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
sync_conn(conn, doc_id).await?;
|
||||
tracing::info!(doc = %doc_id, proposal = %p.id, "title of the document in force");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TitleBody {
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// Propose another title for the document. An open proposal of the same title
|
||||
/// is given back rather than doubled.
|
||||
pub async fn propose(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<TitleBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(&unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
if st.module_of(&unit.id) != Some(ModuleKind::Title) {
|
||||
return Err(AppError::Invalid("not_a_title"));
|
||||
}
|
||||
if edit::has_hidden_controls(&b.title) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
let title = b.title.trim();
|
||||
if title.is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
||||
return Err(AppError::Invalid("title"));
|
||||
}
|
||||
let in_force = unit.in_force_id.as_deref().and_then(|id| st.proposals.iter().find(|p| p.id == id));
|
||||
if in_force.is_some_and(|p| p.title == title) {
|
||||
return Err(AppError::Invalid("same_title"));
|
||||
}
|
||||
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||
p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Title.as_str() && p.title == title
|
||||
}) {
|
||||
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||
}
|
||||
let now = util::now();
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let id = modules::insert_version(
|
||||
&mut conn,
|
||||
NewVersion {
|
||||
unit_id: &unit.id,
|
||||
kind: ModuleKind::Title,
|
||||
title,
|
||||
body: "",
|
||||
author: Some(&user.id),
|
||||
in_force: false,
|
||||
now: &now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": id, "existing": false })))
|
||||
}
|
||||
+164
-39
@@ -7,6 +7,7 @@ use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots::{self, MyBallot, MyVote};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules::ModuleKind;
|
||||
use crate::participation;
|
||||
use crate::util::{self, LangMap};
|
||||
use axum::Json;
|
||||
@@ -146,9 +147,10 @@ pub async fn group(
|
||||
let docs = if open {
|
||||
sqlx::query_as::<_, DocListRow>(
|
||||
"SELECT d.id, d.slug, d.title, d.summary,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force,
|
||||
(SELECT COUNT(*) FROM units u
|
||||
WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force,
|
||||
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = d.id AND p.status = 'open') AS open
|
||||
WHERE u.doc_id = d.id AND u.kind = 'article' AND p.status = 'open') AS open
|
||||
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
|
||||
)
|
||||
.bind(&slug)
|
||||
@@ -250,7 +252,10 @@ pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
|
||||
pub struct DocState {
|
||||
pub doc: DocRow,
|
||||
pub group: GroupRow,
|
||||
/// Every unit, articles and modules, by position.
|
||||
pub units: Vec<UnitRow>,
|
||||
/// The units that are modules (ADR 0009), by unit id; any other is an article.
|
||||
pub module_kinds: HashMap<String, ModuleKind>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
pub blocked: HashSet<String>,
|
||||
@@ -296,6 +301,13 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let kinds: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'")
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let module_kinds: HashMap<String, ModuleKind> =
|
||||
kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect();
|
||||
// Same second (the variants of a seed): the order they were written in, not their
|
||||
// random identifiers. The engine ranks rivals on its own; this order is what people see.
|
||||
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
|
||||
@@ -370,6 +382,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
doc,
|
||||
group,
|
||||
units,
|
||||
module_kinds,
|
||||
proposals,
|
||||
tallies,
|
||||
blocked,
|
||||
@@ -396,38 +409,126 @@ impl DocState {
|
||||
self.tallies.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The module a unit is, if it is one.
|
||||
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
||||
self.module_kinds.get(unit_id).copied()
|
||||
}
|
||||
|
||||
/// The articles, by position: every unit that is no module.
|
||||
pub fn articles(&self) -> impl Iterator<Item = &UnitRow> {
|
||||
self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id))
|
||||
}
|
||||
|
||||
/// The modules of a kind; the orders in the order of the document's sections.
|
||||
pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> {
|
||||
let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect();
|
||||
let sections = self.sections();
|
||||
let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s));
|
||||
out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The sections of the document, in the order its articles read in.
|
||||
pub fn sections(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for u in self.articles() {
|
||||
if let Some(s) = &u.section
|
||||
&& !out.contains(s)
|
||||
{
|
||||
out.push(s.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The articles in the order of `section` now: those one can see — a text in
|
||||
/// force, or an open version — by position (`ld_engine::order::members`;
|
||||
/// `order::sections_now` says the same in SQL).
|
||||
pub fn members(&self, section: &str) -> Vec<String> {
|
||||
let open: HashSet<&str> = self
|
||||
.proposals
|
||||
.iter()
|
||||
.filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove"))
|
||||
.map(|p| p.unit_id.as_str())
|
||||
.collect();
|
||||
let articles: Vec<ld_engine::order::Article<'_>> = self
|
||||
.articles()
|
||||
.map(|u| ld_engine::order::Article {
|
||||
id: &u.id,
|
||||
section: u.section.as_deref(),
|
||||
position: u.position,
|
||||
visible: u.in_force_id.is_some() || open.contains(u.id.as_str()),
|
||||
})
|
||||
.collect();
|
||||
ld_engine::order::members(&articles, section)
|
||||
}
|
||||
|
||||
/// The order each order module's section reads in now, by unit id.
|
||||
fn live_orders(&self) -> HashMap<String, Vec<String>> {
|
||||
self.modules(ModuleKind::Order)
|
||||
.into_iter()
|
||||
.map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The units as the engine sees them. The version in force of an order is
|
||||
/// the order its section reads in now: the engine compares orders as texts.
|
||||
pub fn unit_states(&self) -> Vec<UnitState> {
|
||||
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||
let live = self.live_orders();
|
||||
self.units
|
||||
.iter()
|
||||
.map(|u| UnitState {
|
||||
id: u.id.clone(),
|
||||
position: u.position,
|
||||
inertia: inertia_of(u),
|
||||
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText {
|
||||
id: p.id.clone(),
|
||||
title: p.title.clone(),
|
||||
body: p.body.clone(),
|
||||
tally: self.tally(&p.id),
|
||||
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
|
||||
Some(order) => VersionText {
|
||||
id: p.id.clone(),
|
||||
title: String::new(),
|
||||
body: ld_engine::order::canonical(order),
|
||||
tally: self.tally(&p.id),
|
||||
},
|
||||
None => VersionText {
|
||||
id: p.id.clone(),
|
||||
title: p.title.clone(),
|
||||
body: p.body.clone(),
|
||||
tally: self.tally(&p.id),
|
||||
},
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Open proposals only — the in-force versions compete as incumbents.
|
||||
/// Open proposals only — the in-force versions compete as incumbents. An
|
||||
/// order proposal is read as it fits its section now (completed at the end);
|
||||
/// one that holds no order is blocked.
|
||||
pub fn proposal_states(&self) -> Vec<ProposalState> {
|
||||
let live = self.live_orders();
|
||||
self.proposals
|
||||
.iter()
|
||||
.filter(|p| p.status == "open")
|
||||
.map(|p| ProposalState {
|
||||
id: p.id.clone(),
|
||||
unit_id: p.unit_id.clone(),
|
||||
kind: p.kind(),
|
||||
title: p.title.clone(),
|
||||
body: p.body.clone(),
|
||||
tally: self.tally(&p.id),
|
||||
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
|
||||
blocked: self.blocked.contains(&p.id),
|
||||
.map(|p| {
|
||||
let mut s = ProposalState {
|
||||
id: p.id.clone(),
|
||||
unit_id: p.unit_id.clone(),
|
||||
kind: p.kind(),
|
||||
title: p.title.clone(),
|
||||
body: p.body.clone(),
|
||||
tally: self.tally(&p.id),
|
||||
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
|
||||
blocked: self.blocked.contains(&p.id),
|
||||
};
|
||||
if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) {
|
||||
s.title = String::new();
|
||||
match ld_engine::order::decode(&p.body) {
|
||||
Some(order) => {
|
||||
s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order);
|
||||
}
|
||||
None => s.blocked = true,
|
||||
}
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -527,6 +628,8 @@ fn proposal_json(
|
||||
let (mine, ballot) = mine;
|
||||
let eval = st.evaluation(unit, p);
|
||||
let (kind, target) = match p.kind() {
|
||||
// A module's version says what it holds (ADR 0009).
|
||||
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
|
||||
ProposalKind::Text => ("text", None),
|
||||
ProposalKind::Remove => ("remove", None),
|
||||
ProposalKind::Inertia(t) => ("inertia", Some(t)),
|
||||
@@ -572,28 +675,47 @@ pub async fn document(
|
||||
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
|
||||
|
||||
let changes = inertia_changes(&state, &id).await?;
|
||||
let units: Vec<_> = st
|
||||
.units
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let of_unit: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|p| p.unit_id == u.id)
|
||||
.map(|p| {
|
||||
let of = (mine.get(&p.id), pending.get(&p.id));
|
||||
proposal_json(&st, u, p, of, translations.get(&p.id), &user.id)
|
||||
})
|
||||
.collect();
|
||||
let in_force = u
|
||||
.in_force_id
|
||||
.as_ref()
|
||||
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||
json!({
|
||||
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
|
||||
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
let unit_json = |u: &UnitRow| {
|
||||
let of_unit: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|p| p.unit_id == u.id)
|
||||
.map(|p| {
|
||||
let of = (mine.get(&p.id), pending.get(&p.id));
|
||||
let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id);
|
||||
if st.module_of(&u.id) == Some(ModuleKind::Order) {
|
||||
j["order"] = crate::order::version_json(&st, u, p);
|
||||
}
|
||||
j
|
||||
})
|
||||
.collect();
|
||||
let in_force = u
|
||||
.in_force_id
|
||||
.as_ref()
|
||||
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||
json!({
|
||||
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
|
||||
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
};
|
||||
let units: Vec<_> = st.articles().map(unit_json).collect();
|
||||
// The modules (ADR 0009): the title of the document, the order of each section.
|
||||
let title = st.modules(ModuleKind::Title).first().map(|u| {
|
||||
let mut j = unit_json(u);
|
||||
j["kind"] = json!("title");
|
||||
j
|
||||
});
|
||||
let orders: Vec<_> = st
|
||||
.modules(ModuleKind::Order)
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
let members = st.members(u.section.as_deref().unwrap_or_default());
|
||||
let mut j = unit_json(u);
|
||||
j["kind"] = json!("order");
|
||||
j["moot"] = json!(ld_engine::order::is_moot(&members));
|
||||
j["members"] = json!(members);
|
||||
j
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -612,6 +734,7 @@ pub async fn document(
|
||||
"publication": publication,
|
||||
},
|
||||
"units": units,
|
||||
"modules": { "title": title, "orders": orders },
|
||||
// The five levels, with the *for* votes asked at the usual participation.
|
||||
"levels": st.levels_json(st.usual_voters()),
|
||||
})))
|
||||
@@ -687,7 +810,9 @@ pub async fn preview(
|
||||
Some("wellrated") => PreviewMode::WellRated,
|
||||
_ => PreviewMode::InForce,
|
||||
};
|
||||
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
|
||||
// The articles only: a module changes no text of the preview.
|
||||
let articles: Vec<UnitState> = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect();
|
||||
let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode)
|
||||
.map_err(|_| AppError::Invalid("electorate"))?;
|
||||
// Translations of the versions shown.
|
||||
let shown: HashSet<String> = units
|
||||
|
||||
@@ -11,10 +11,14 @@ pub mod adoption;
|
||||
pub mod auth;
|
||||
pub mod ballots;
|
||||
pub mod config;
|
||||
pub mod doc_title;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod indexer;
|
||||
pub mod inertia_amend;
|
||||
pub mod module_amend;
|
||||
pub mod modules;
|
||||
pub mod order;
|
||||
pub mod overview;
|
||||
pub mod participation;
|
||||
pub mod proposals;
|
||||
@@ -131,6 +135,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||
.route("/signals/{id}/lift", post(proposals::lift_signal))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/units/{id}/order", post(order::propose))
|
||||
.route("/units/{id}/title", post(doc_title::propose))
|
||||
.route("/feedback", post(access::send_feedback))
|
||||
.route("/access", get(access::overview))
|
||||
.route("/access/groups/{slug}/members", post(access::grant))
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Module amendments — the modules a seed now declares, added to a document
|
||||
//! that was born without them (ADR 0009).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: the title of the document at level 4, the order of the
|
||||
//! articles of each section at level 2. A new base gets them from the seed
|
||||
//! (`modules` in the seed file). An existing base — production holds two
|
||||
//! instances of the certification act, Monnaie libre and the test group — gets
|
||||
//! them through an amendment listed here, built **from what the document holds
|
||||
//! then**: its title as it reads, and the order each section reads in. Nothing
|
||||
//! else is written: no article moves, no text changes, no vote is touched.
|
||||
//!
|
||||
//! Like the seed and inertia amendments, each document is decided once, at the
|
||||
//! first start that knows the amendment, and the decision is kept in
|
||||
//! `module_amendments` (applied with the codes created, refused and why, or
|
||||
//! present) and in the logs. Applied where the document publishes, the
|
||||
//! publication queue gets a row (`document.json` carries the modules).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::modules::{self, Levels};
|
||||
use crate::seed::SeedDoc;
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// Modules to add to every document instantiated from a seed.
|
||||
pub struct ModuleAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The level of the title module: the seed's today, which must say so.
|
||||
pub title: u8,
|
||||
/// The level of the order modules: the seed's today, which must say so.
|
||||
pub order: u8,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment {
|
||||
id: "2026-10-31-modules-titre-ordre",
|
||||
doc: "acte-engagement-certification",
|
||||
title: 4,
|
||||
order: 2,
|
||||
reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section",
|
||||
}];
|
||||
|
||||
/// What the database says of the document.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
/// Its title in its source language.
|
||||
pub title: String,
|
||||
/// Modules it holds already, of either kind.
|
||||
pub modules: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with its modules: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database.
|
||||
pub fn verdict(facts: Option<&Facts>) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_document") };
|
||||
if f.modules > 0 {
|
||||
Verdict::Present
|
||||
} else if f.title.trim().is_empty() {
|
||||
Verdict::Refuse("no_title")
|
||||
} else {
|
||||
Verdict::Apply
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
pub created: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some(levels) = seed_says(seeds, a) else { continue };
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((v, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else {
|
||||
continue;
|
||||
};
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v, created });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must declare the modules at the levels the amendment gives: a seed
|
||||
/// edited later never reaches a base by this way.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option<Levels> {
|
||||
let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from);
|
||||
let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) };
|
||||
match levels {
|
||||
Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l),
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
"module amendment: the seed does not declare these modules, nothing decided"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Option<Facts>> {
|
||||
let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let Some((raw, src)) = doc else { return Ok(None) };
|
||||
let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'")
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules }))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &ModuleAmendment,
|
||||
levels: Levels,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, Vec<String>)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found = facts(&mut tx, doc_id).await?;
|
||||
let v = verdict(found.as_ref());
|
||||
let now = util::now();
|
||||
let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() };
|
||||
if v == Verdict::Apply && publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into())))
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((v, created)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc(title: &str, modules: i64) -> Facts {
|
||||
Facts { title: title.into(), modules }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_modules_receives_them() {
|
||||
assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_born_with_its_modules_is_left_as_it_is() {
|
||||
assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present);
|
||||
assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_without_a_document_or_a_title() {
|
||||
assert_eq!(verdict(None), Verdict::Refuse("no_document"));
|
||||
assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Modules — what a document decides besides its articles (ADR 0009).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
|
||||
//! changer l'ordre des "articles". Lui-même à (2) » ; « Le titre du doc à (4) »
|
||||
//! — and, asked whether the title of an article is voted apart: « Non, titre et
|
||||
//! texte ensemble ». So two modules, each a unit of its own kind:
|
||||
//!
|
||||
//! - the **title of the document** (`title`, level 4): its version in force is
|
||||
//! the title in the source language (`doc_title`);
|
||||
//! - the **order of the articles of each section** (`order`, level 2): its
|
||||
//! version in force is the order the section reads in now (`order`).
|
||||
//!
|
||||
//! A module is voted like an article — unfolded, in the basket, on the Ğ1
|
||||
//! chain — because its versions are proposals like any other: the same
|
||||
//! threshold, the same margin over the version in force, the same seed a
|
||||
//! minima at the start. What differs is only what entering into force does.
|
||||
//!
|
||||
//! A new document gets its modules from the seed (`modules` in the seed file);
|
||||
//! an existing one, once, from what it holds (`module_amend`). Both go through
|
||||
//! `create`, which reads the document as it is and touches no article and no
|
||||
//! vote.
|
||||
|
||||
use crate::docs::{DocState, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::seed::SeedModules;
|
||||
use crate::util;
|
||||
use crate::{doc_title, order};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// The code of the title module of a document.
|
||||
pub const TITLE_CODE: &str = "titre";
|
||||
|
||||
/// The code of the order module of a section.
|
||||
pub fn order_code(section: &str) -> String {
|
||||
format!("ordre.{section}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ModuleKind {
|
||||
Title,
|
||||
Order,
|
||||
}
|
||||
|
||||
impl ModuleKind {
|
||||
/// `units.kind`; an article is no module.
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"title" => Some(Self::Title),
|
||||
"order" => Some(Self::Order),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Title => "title",
|
||||
Self::Order => "order",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels a document's modules start at; `None`: no such module.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Levels {
|
||||
pub title: Option<InertiaLevel>,
|
||||
pub order: Option<InertiaLevel>,
|
||||
}
|
||||
|
||||
impl From<&SeedModules> for Levels {
|
||||
fn from(m: &SeedModules) -> Self {
|
||||
Self { title: m.title.as_ref().map(|t| t.inertia), order: m.order.as_ref().map(|o| o.inertia) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A version of a module, written directly in force (a seed a minima) or open.
|
||||
pub(crate) struct NewVersion<'a> {
|
||||
pub unit_id: &'a str,
|
||||
pub kind: ModuleKind,
|
||||
pub title: &'a str,
|
||||
pub body: &'a str,
|
||||
pub author: Option<&'a str>,
|
||||
pub in_force: bool,
|
||||
pub now: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_version(conn: &mut SqliteConnection, v: NewVersion<'_>) -> sqlx::Result<String> {
|
||||
let id = util::id();
|
||||
let (status, origin) = if v.in_force { ("in_force", "seed") } else { ("open", "member") };
|
||||
sqlx::query(
|
||||
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
|
||||
created_at, updated_at, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(v.unit_id)
|
||||
.bind(v.kind.as_str())
|
||||
.bind(v.title)
|
||||
.bind(v.body)
|
||||
.bind(status)
|
||||
.bind(origin)
|
||||
.bind(v.author)
|
||||
.bind(v.now)
|
||||
.bind(v.now)
|
||||
.bind(v.now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
VALUES (?, 1, 'initial', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(v.title)
|
||||
.bind(v.body)
|
||||
.bind(v.author)
|
||||
.bind(v.now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if v.in_force {
|
||||
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||
.bind(&id)
|
||||
.bind(v.unit_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn insert_unit(
|
||||
conn: &mut SqliteConnection,
|
||||
doc_id: &str,
|
||||
kind: ModuleKind,
|
||||
code: &str,
|
||||
section: Option<&str>,
|
||||
level: InertiaLevel,
|
||||
now: &str,
|
||||
) -> sqlx::Result<String> {
|
||||
let id = util::id();
|
||||
// Its place is not among the articles: the reader puts each module where it belongs.
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at, kind)
|
||||
VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(doc_id)
|
||||
.bind(code)
|
||||
.bind(section)
|
||||
.bind(level.as_db())
|
||||
.bind(now)
|
||||
.bind(kind.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Creates the modules of a document from what it holds now — its title, and
|
||||
/// the order each of its sections reads in — each in force as a seed a minima.
|
||||
/// No article, no vote is touched. Returns the codes created.
|
||||
pub async fn create(conn: &mut SqliteConnection, doc_id: &str, levels: Levels, now: &str) -> sqlx::Result<Vec<String>> {
|
||||
let mut created = Vec::new();
|
||||
if let Some(level) = levels.title {
|
||||
let (raw, src): (String, String) = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let titles = util::parse_lang(&raw);
|
||||
let title = titles.get(&src).cloned().unwrap_or_default();
|
||||
let unit = insert_unit(conn, doc_id, ModuleKind::Title, TITLE_CODE, None, level, now).await?;
|
||||
let pid = insert_version(
|
||||
conn,
|
||||
NewVersion {
|
||||
unit_id: &unit,
|
||||
kind: ModuleKind::Title,
|
||||
title: &title,
|
||||
body: "",
|
||||
author: None,
|
||||
in_force: true,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
doc_title::seed_translations(conn, &pid, &title, &titles, &src, now).await?;
|
||||
created.push(TITLE_CODE.to_owned());
|
||||
}
|
||||
if let Some(level) = levels.order {
|
||||
for (section, members) in order::sections_now(conn, doc_id).await? {
|
||||
let code = order_code(§ion);
|
||||
let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(§ion), level, now).await?;
|
||||
let body = ld_engine::order::canonical(&members);
|
||||
insert_version(
|
||||
conn,
|
||||
NewVersion {
|
||||
unit_id: &unit,
|
||||
kind: ModuleKind::Order,
|
||||
title: &code,
|
||||
body: &body,
|
||||
author: None,
|
||||
in_force: true,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
created.push(code);
|
||||
}
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// What entering into force does, besides the version taking its place: the
|
||||
/// order of a section moves its articles, the title of a document becomes its
|
||||
/// title. Returns the kind kept in `adoptions` (`text` for an article).
|
||||
pub async fn on_entry(
|
||||
conn: &mut SqliteConnection,
|
||||
st: &DocState,
|
||||
unit: &UnitRow,
|
||||
p: &ProposalRow,
|
||||
) -> AppResult<&'static str> {
|
||||
match st.module_of(&unit.id) {
|
||||
None => Ok("text"),
|
||||
Some(ModuleKind::Order) => {
|
||||
order::enter(conn, st, unit, p).await?;
|
||||
Ok("order")
|
||||
}
|
||||
Some(ModuleKind::Title) => {
|
||||
doc_title::enter(conn, &st.doc.id, unit, p).await?;
|
||||
Ok("title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic writing of a version stops at a module: one proposes a module
|
||||
/// through its own door (`order::propose`, `doc_title::propose`).
|
||||
pub fn require_article(st: &DocState, unit_id: &str) -> AppResult<()> {
|
||||
if st.module_of(unit_id).is_some() { Err(AppError::Invalid("module")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// An order is never corrected nor translated: another order is another proposal.
|
||||
pub fn require_text(p: &ProposalRow) -> AppResult<()> {
|
||||
if p.kind == ModuleKind::Order.as_str() { Err(AppError::Conflict("order_fixed")) } else { Ok(()) }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The order of the articles of a section, a module (ADR 0009) — the rules are
|
||||
//! the engine's (`ld_engine::order`); here, the database and the door to
|
||||
//! propose one.
|
||||
//!
|
||||
//! - The version in force is the order the section reads in now: its articles
|
||||
//! one can see, by position. The text the engine compares for it is that live
|
||||
//! order, so it always reads what the reader shows.
|
||||
//! - A proposal is another order of the same articles, each once. While it is
|
||||
//! under vote, articles may enter the section or leave it: it is completed at
|
||||
//! the end (`ld_engine::order::fit`), and its votes stay.
|
||||
//! - Entering into force gives the section's places out again in the new order;
|
||||
//! the articles keep their texts, their votes, their sections.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules::{self, ModuleKind, NewVersion};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::order::{self, Article, OrderError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// More articles than any section will hold: a longer list is refused unread.
|
||||
const MAX_ARTICLES: usize = 500;
|
||||
|
||||
/// Every section of a document, in the order of the document, with the articles
|
||||
/// of its order now (read inside a transaction: the seed writes them in it).
|
||||
pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Vec<(String, Vec<String>)>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
section: Option<String>,
|
||||
position: f64,
|
||||
visible: bool,
|
||||
}
|
||||
// Seen: a text in force, or an open version (`DocState::members` says the same).
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT u.id, u.section, u.position,
|
||||
(u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p
|
||||
WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible
|
||||
FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let articles: Vec<Article<'_>> = rows
|
||||
.iter()
|
||||
.map(|r| Article { id: &r.id, section: r.section.as_deref(), position: r.position, visible: r.visible })
|
||||
.collect();
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
for r in &rows {
|
||||
if let Some(s) = &r.section
|
||||
&& !sections.contains(s)
|
||||
{
|
||||
sections.push(s.clone());
|
||||
}
|
||||
}
|
||||
Ok(sections
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let members = order::members(&articles, &s);
|
||||
(s, members)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The order an order version gives its section now, as the screen tells it.
|
||||
pub fn version_json(st: &DocState, unit: &UnitRow, p: &ProposalRow) -> Value {
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
if p.status == "in_force" {
|
||||
return json!({ "ids": members, "added": [], "dropped": [], "same": true });
|
||||
}
|
||||
match order::decode(&p.body) {
|
||||
Some(proposed) => {
|
||||
let f = order::fit(&proposed, &members);
|
||||
let same = f.order == members;
|
||||
json!({ "ids": f.order, "added": f.added, "dropped": f.dropped, "same": same })
|
||||
}
|
||||
None => json!({ "ids": [], "added": [], "dropped": [], "same": false, "unreadable": true }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The order enters into force: its articles take the section's places in its order.
|
||||
pub async fn enter(conn: &mut SqliteConnection, st: &DocState, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
// An unreadable order is blocked: it never comes this far.
|
||||
let Some(proposed) = order::decode(&p.body) else { return Ok(()) };
|
||||
let fitted = order::fit(&proposed, &members);
|
||||
let current: Vec<(String, f64)> = members
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|u| &u.id == id).map(|u| (id.clone(), u.position)))
|
||||
.collect();
|
||||
for (id, position) in order::positions(&fitted.order, ¤t) {
|
||||
sqlx::query("UPDATE units SET position = ? WHERE id = ? AND doc_id = ? AND kind = 'article'")
|
||||
.bind(position)
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
tracing::info!(doc = %st.doc.id, unit = %unit.code, added = fitted.added.len(), dropped = fitted.dropped.len(), "order of a section in force");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OrderBody {
|
||||
/// Every article of the section (unit ids), each once, in the order proposed.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// Propose another order for a section. An open proposal that already reads in
|
||||
/// this order is given back rather than doubled.
|
||||
pub async fn propose(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<OrderBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(&unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
if st.module_of(&unit.id) != Some(ModuleKind::Order) {
|
||||
return Err(AppError::Invalid("not_an_order"));
|
||||
}
|
||||
if b.order.len() > MAX_ARTICLES {
|
||||
return Err(AppError::Invalid("order"));
|
||||
}
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
order::check(&b.order, &members).map_err(|e| match e {
|
||||
OrderError::NotTheSection => AppError::Invalid("order"),
|
||||
OrderError::Unchanged => AppError::Invalid("same_order"),
|
||||
})?;
|
||||
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||
p.unit_id == unit.id
|
||||
&& p.status == "open"
|
||||
&& p.kind == ModuleKind::Order.as_str()
|
||||
&& order::decode(&p.body).is_some_and(|o| order::fit(&o, &members).order == b.order)
|
||||
}) {
|
||||
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||
}
|
||||
let now = util::now();
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let id = modules::insert_version(
|
||||
&mut conn,
|
||||
NewVersion {
|
||||
unit_id: &unit.id,
|
||||
kind: ModuleKind::Order,
|
||||
title: &unit.code,
|
||||
body: &order::canonical(&b.order),
|
||||
author: Some(&user.id),
|
||||
in_force: false,
|
||||
now: &now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": id, "existing": false })))
|
||||
}
|
||||
@@ -133,9 +133,11 @@ pub async fn decisions(
|
||||
Ok(Json(json!({ "canVote": can_vote, "docs": out })))
|
||||
}
|
||||
|
||||
/// The articles of a document as `me` sees them: its version in force (adopted
|
||||
/// by a vote, or a seed a minima), its open versions, and whether `me` holds a
|
||||
/// valid vote on one of them. A change of inertia is not a version of the text.
|
||||
/// The modules of a document as `me` sees them — its articles, its title, the
|
||||
/// order of each section (ADR 0009): the version in force (adopted by a vote,
|
||||
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
||||
/// on one of them. A change of inertia is not a version of the text; the order
|
||||
/// of a section of fewer than two articles has nothing to decide.
|
||||
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VersionRow {
|
||||
@@ -144,6 +146,13 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
||||
adopted: bool,
|
||||
voted: bool,
|
||||
}
|
||||
let st = docs::load_state(state, doc_id).await?;
|
||||
let moot: HashSet<String> = st
|
||||
.modules(crate::modules::ModuleKind::Order)
|
||||
.into_iter()
|
||||
.filter(|u| ld_engine::order::is_moot(&st.members(u.section.as_deref().unwrap_or_default())))
|
||||
.map(|u| u.id.clone())
|
||||
.collect();
|
||||
let rows = sqlx::query_as::<_, VersionRow>(
|
||||
"SELECT u.id AS unit_id,
|
||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||
@@ -164,7 +173,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
||||
let mut current: Option<String> = None;
|
||||
for r in rows {
|
||||
if current.as_deref() != Some(r.unit_id.as_str()) {
|
||||
out.push(Module::default());
|
||||
out.push(Module { moot: moot.contains(&r.unit_id), ..Module::default() });
|
||||
current = Some(r.unit_id);
|
||||
}
|
||||
if let Some(m) = out.last_mut() {
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
//! of inertia (ADR 0007, `ld_engine::inertia`).
|
||||
//!
|
||||
//! What is measured: the proposals of the group (every document, every kind,
|
||||
//! drafts aside) that hold at least one vote on their current revision, the
|
||||
//! last one cast within the past year. For each, T is the number of those
|
||||
//! modules included) that hold at least one vote on their current revision, the
|
||||
//! last one cast within the past year — those whose status is named here, and
|
||||
//! only those: `open` (under vote), `in_force` (in force, still under vote) and
|
||||
//! `applied` (a removal or a change of inertia that passed: a decision taken).
|
||||
//! Drafts (nobody votes on them) and proposals withdrawn by their author (their
|
||||
//! votes can no longer be taken back) stay out, as would any status to come
|
||||
//! until it is named. For each, T is the number of those
|
||||
//! votes **at the time of measuring** — the same T the formula sees, since the
|
||||
//! vote is permanent. The median of these T, and how many proposals it rests
|
||||
//! on, are kept in `settings` under `participation:<group>`.
|
||||
@@ -37,16 +42,15 @@ pub struct Measure {
|
||||
pub measured_at: String,
|
||||
}
|
||||
|
||||
/// The numbers of voters of the group's proposals voted on since `since`.
|
||||
/// Drafts and withdrawn proposals stay out: neither is shown, and votes on a
|
||||
/// withdrawn proposal can no longer be taken back.
|
||||
/// The numbers of voters of the group's proposals voted on since `since`, for
|
||||
/// the statuses counted only (the list is explicit: see the head of this file).
|
||||
pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result<Vec<u32>> {
|
||||
let rows: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id
|
||||
JOIN units u ON u.id = p.unit_id
|
||||
JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = ? AND p.status NOT IN ('draft', 'withdrawn') AND v.revision = p.revision
|
||||
WHERE d.group_slug = ? AND p.status IN ('open', 'in_force', 'applied') AND v.revision = p.revision
|
||||
GROUP BY v.proposal_id
|
||||
HAVING MAX(v.cast_at) >= ?",
|
||||
)
|
||||
|
||||
@@ -15,8 +15,9 @@ use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules;
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
@@ -162,6 +163,9 @@ pub async fn assist(
|
||||
pub struct CreateBody {
|
||||
kind: Option<String>,
|
||||
unit_id: Option<String>,
|
||||
/// The section a new article goes in, at its end (ADR 0009).
|
||||
section: Option<String>,
|
||||
/// Or an article of that section (an older client).
|
||||
after_unit_id: Option<String>,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
@@ -170,27 +174,16 @@ pub struct CreateBody {
|
||||
submit: bool,
|
||||
}
|
||||
|
||||
/// Position right after `after` (or at the end), between it and the next unit.
|
||||
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
|
||||
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
|
||||
positions.sort_by(f64::total_cmp);
|
||||
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
|
||||
return positions.last().copied().unwrap_or(0.0) + 1.0;
|
||||
};
|
||||
match positions.iter().find(|p| **p > anchor) {
|
||||
Some(next) => (anchor + next) / 2.0,
|
||||
None => anchor + 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
|
||||
/// A new article goes at the end of its section (ADR 0009): the order of the
|
||||
/// section is a module of its own, voted apart.
|
||||
async fn new_unit(state: &AppState, st: &DocState, section: Option<&str>) -> AppResult<String> {
|
||||
let id = util::id();
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
|
||||
.bind(&st.doc.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
// A new article belongs to the section of the one it follows.
|
||||
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
|
||||
let places: Vec<(Option<&str>, f64)> = st.articles().map(|u| (u.section.as_deref(), u.position)).collect();
|
||||
let position = ld_engine::order::place_at_end(&places, section);
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
@@ -199,7 +192,7 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.bind(section)
|
||||
.bind(position_after(&st.units, after))
|
||||
.bind(position)
|
||||
.bind(InertiaLevel::DEFAULT.as_db())
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
@@ -260,12 +253,22 @@ pub async fn create(
|
||||
let unit_id = match b.unit_id.as_deref() {
|
||||
Some(u) => {
|
||||
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
|
||||
modules::require_article(&st, &unit.id)?;
|
||||
if kind == "remove" && unit.in_force_id.is_none() {
|
||||
return Err(AppError::Invalid("nothing_to_remove"));
|
||||
}
|
||||
unit.id.clone()
|
||||
}
|
||||
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
|
||||
None if kind == "text" => {
|
||||
// A new article belongs to the section asked for, or to that of the article named.
|
||||
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
||||
let section = match b.section.as_deref() {
|
||||
Some(s) if st.sections().iter().any(|x| x == s) => Some(s),
|
||||
Some(_) => return Err(AppError::Invalid("section")),
|
||||
None => after.and_then(|u| u.section.as_deref()),
|
||||
};
|
||||
new_unit(&state, &st, section).await?
|
||||
}
|
||||
None => return Err(AppError::Invalid("unit")),
|
||||
};
|
||||
let id = insert_proposal(
|
||||
@@ -297,6 +300,7 @@ pub async fn edit(
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||
let (title, body) = (b.title.trim(), b.body.trim());
|
||||
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
|
||||
@@ -477,7 +481,8 @@ pub struct SplitBody {
|
||||
}
|
||||
|
||||
/// One draft becomes several: the first part stays, each other part becomes a
|
||||
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
|
||||
/// draft on a new unit at the end of its section, in turn. Drafts only — nobody
|
||||
/// voted yet.
|
||||
pub async fn split(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
@@ -505,10 +510,10 @@ pub async fn split(
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let mut created = vec![id.clone()];
|
||||
let mut after = p.unit_id.clone();
|
||||
let section = st.articles().find(|u| u.id == p.unit_id).and_then(|u| u.section.clone());
|
||||
for (n, part) in parts.iter().enumerate().skip(1) {
|
||||
let fresh = docs::load_state(&state, &st.doc.id).await?;
|
||||
let unit = new_unit(&state, &fresh, Some(&after)).await?;
|
||||
let unit = new_unit(&state, &fresh, section.as_deref()).await?;
|
||||
let title = format!("{} ({})", p.title, n + 1);
|
||||
let title: String = title.chars().take(MAX_TITLE).collect();
|
||||
created.push(
|
||||
@@ -526,7 +531,6 @@ pub async fn split(
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
after = unit;
|
||||
}
|
||||
Ok(Json(json!({ "drafts": created })))
|
||||
}
|
||||
@@ -835,6 +839,7 @@ pub async fn translate(
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
if !util::is_lang(&lang) || lang == st.doc.source_lang {
|
||||
return Err(AppError::Invalid("lang"));
|
||||
}
|
||||
@@ -860,21 +865,3 @@ pub async fn translate(
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(id: &str, position: f64) -> UnitRow {
|
||||
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_units_slot_between_neighbours() {
|
||||
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
|
||||
assert_eq!(position_after(&units, Some("a")), 1.5);
|
||||
assert_eq!(position_after(&units, Some("c")), 5.0);
|
||||
assert_eq!(position_after(&units, None), 5.0);
|
||||
assert_eq!(position_after(&[], None), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,13 +289,15 @@ pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Markdown per language + JSON. Only the units in force, in order.
|
||||
/// Markdown per language + JSON. Only the articles in force, in order; the
|
||||
/// title of the document is its title in force (ADR 0009).
|
||||
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let titles = util::parse_lang(&st.doc.title);
|
||||
let src = st.doc.source_lang.clone();
|
||||
let modules = modules_json(state, st).await?;
|
||||
let stale_title = |lang: &str| modules["title"]["translations"][lang]["stale"].as_bool().unwrap_or(false);
|
||||
let in_force: Vec<_> = st
|
||||
.units
|
||||
.iter()
|
||||
.articles()
|
||||
.filter_map(|u| {
|
||||
let fid = u.in_force_id.as_ref()?;
|
||||
st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p))
|
||||
@@ -313,6 +315,9 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
|
||||
st.doc.slug,
|
||||
if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() },
|
||||
);
|
||||
if lang != src && stale_title(lang) {
|
||||
md.push_str(&format!("\n<!-- {lang} : titre à revoir, le titre source a changé -->\n"));
|
||||
}
|
||||
for (_, p) in &in_force {
|
||||
let t = tr.get(&p.id).and_then(|m| m.get(lang));
|
||||
let (ut, ub, fallback) = match t {
|
||||
@@ -356,8 +361,61 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
|
||||
let json = json!({
|
||||
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
|
||||
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
|
||||
"voteFloor": st.doc.vote_floor, "units": units,
|
||||
"voteFloor": st.doc.vote_floor, "units": units, "modules": modules,
|
||||
});
|
||||
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The modules of the document as published: the title and the order of each
|
||||
/// section, each with its level, its state and its votes (ADR 0009).
|
||||
async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_json::Value> {
|
||||
use crate::modules::ModuleKind;
|
||||
let status = |p: &docs::ProposalRow| {
|
||||
if p.entered_at.is_some() {
|
||||
"adopted"
|
||||
} else if p.confirmed_at.is_some() {
|
||||
"confirmed"
|
||||
} else {
|
||||
"seed-minimal"
|
||||
}
|
||||
};
|
||||
let version = |u: &docs::UnitRow| u.in_force_id.as_ref().and_then(|id| st.proposals.iter().find(|p| &p.id == id));
|
||||
let title = match st.modules(ModuleKind::Title).first().and_then(|u| Some((*u, version(u)?))) {
|
||||
Some((u, p)) => {
|
||||
let tr = docs::translations_for(state, &[p]).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let translations: serde_json::Map<String, serde_json::Value> = tr
|
||||
.get(&p.id)
|
||||
.map(|m| {
|
||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tally = st.tally(&p.id);
|
||||
json!({
|
||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||
})
|
||||
}
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
let orders: Vec<_> = st
|
||||
.modules(ModuleKind::Order)
|
||||
.into_iter()
|
||||
.filter_map(|u| {
|
||||
let p = version(u)?;
|
||||
let section = u.section.clone().unwrap_or_default();
|
||||
let codes: Vec<&str> = st
|
||||
.members(§ion)
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||
.collect();
|
||||
let tally = st.tally(&p.id);
|
||||
Some(json!({
|
||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||
"articles": codes,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "title": title, "orders": orders }))
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched; a newer starting level of
|
||||
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
|
||||
//! no vote changed.
|
||||
//! no vote changed; modules the seed declares (the title, the order of each
|
||||
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
|
||||
//! document holds.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::inertia_amend;
|
||||
use crate::module_amend;
|
||||
use crate::modules;
|
||||
use crate::seed_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
@@ -25,9 +29,24 @@ pub struct SeedDoc {
|
||||
pub summary: LangMap,
|
||||
pub source_lang: String,
|
||||
pub instances: Vec<SeedInstance>,
|
||||
/// The modules of the document (ADR 0009): its title, the order of each section.
|
||||
#[serde(default)]
|
||||
pub modules: Option<SeedModules>,
|
||||
pub units: Vec<SeedUnit>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedModules {
|
||||
pub title: Option<SeedModule>,
|
||||
pub order: Option<SeedModule>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedModule {
|
||||
/// The level it starts at, 1 to 5.
|
||||
pub inertia: InertiaLevel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedInstance {
|
||||
@@ -72,12 +91,13 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed and
|
||||
/// inertia amendments (each decided once per document).
|
||||
/// On start: the groups, the documents missing from them, then the seed,
|
||||
/// inertia and module amendments (each decided once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
|
||||
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -223,6 +243,10 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
}
|
||||
}
|
||||
}
|
||||
// The modules, from the document just written: its title, the order of each section.
|
||||
if let Some(m) = &seed.modules {
|
||||
modules::create(&mut tx, &doc_id, m.into(), &now).await?;
|
||||
}
|
||||
if inst.publish_path.is_some() {
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
|
||||
|
||||
@@ -618,10 +618,10 @@ async fn a_session_sees_its_own_ballots_only() {
|
||||
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
|
||||
let theirs = other.doc("monnaie-libre").await;
|
||||
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
|
||||
// « Décisions » counts the same way.
|
||||
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
|
||||
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
|
||||
assert_eq!(d["canVote"], true);
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32)));
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -668,6 +668,81 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
||||
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
|
||||
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let order = doc["modules"]["orders"]
|
||||
.as_array()
|
||||
.expect("orders")
|
||||
.iter()
|
||||
.find(|o| o["section"] == "conclusion")
|
||||
.expect("conclusion")
|
||||
.clone();
|
||||
let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2"));
|
||||
assert_eq!(order["members"], json!([k1, k2]));
|
||||
let o = c
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/units/{}/order", order["id"].as_str().expect("id")),
|
||||
Some(json!({ "order": [k2, k1] })),
|
||||
)
|
||||
.await["id"]
|
||||
.as_str()
|
||||
.expect("order")
|
||||
.to_owned();
|
||||
let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned();
|
||||
let t = c
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/units/{title_unit}/title"),
|
||||
Some(json!({ "title": "Acte d'engagement de certification" })),
|
||||
)
|
||||
.await["id"]
|
||||
.as_str()
|
||||
.expect("title")
|
||||
.to_owned();
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||
let mut last = Value::Null;
|
||||
for (n, account) in keys.iter().enumerate() {
|
||||
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
|
||||
let index = 20_000 + u32::try_from(n).expect("n");
|
||||
indexer.pay(
|
||||
2_000 + index,
|
||||
b["comment"].as_str().expect("c"),
|
||||
Signer { account, identity: Some((index, "Membre", true)) },
|
||||
);
|
||||
last = b;
|
||||
if n % 10 == 9 {
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
}
|
||||
}
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let s = c.status(&last).await;
|
||||
assert_eq!(s["status"], "confirmed");
|
||||
let mut entered: Vec<(&str, &str)> = s["entered"]
|
||||
.as_array()
|
||||
.expect("entered")
|
||||
.iter()
|
||||
.map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default()))
|
||||
.collect();
|
||||
entered.sort_unstable();
|
||||
assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}");
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
|
||||
assert!(p2 < p1, "K2 now reads before K1");
|
||||
assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification");
|
||||
assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review");
|
||||
// The adoptions keep what entered, by kind.
|
||||
let kinds: Vec<String> =
|
||||
sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k");
|
||||
assert_eq!(kinds, ["order", "title"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Without the chain, and in managed groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -310,10 +310,66 @@ async fn a_withdrawn_proposal_leaves_the_usual_participation() {
|
||||
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() {
|
||||
// Relecture of 71819f9: the list is explicit — open, in force, applied. A decision
|
||||
// taken (a change of inertia applied) keeps its voters; a status not named stays out.
|
||||
let (mut first, state) = Client::new().await;
|
||||
let mut people = Vec::new();
|
||||
for name in ["S1", "S2"] {
|
||||
let mut c = first.other();
|
||||
c.login(name).await;
|
||||
people.push(c);
|
||||
}
|
||||
first.login("S1").await;
|
||||
let doc = first.doc("essai").await;
|
||||
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6", "E8"]
|
||||
.iter()
|
||||
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
|
||||
.collect();
|
||||
for p in &open {
|
||||
for c in &mut people {
|
||||
c.vote(p, "against").await;
|
||||
}
|
||||
}
|
||||
let sample = |state: AppState| async move {
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
ld_server::participation::stored(&state, "essai").await.expect("stored").expect("measure").sample
|
||||
};
|
||||
assert_eq!(sample(state.clone()).await, 6);
|
||||
for (id, status) in [(&open[0], "applied"), (&open[1], "in_force")] {
|
||||
sqlx::query("UPDATE proposals SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("status");
|
||||
}
|
||||
assert_eq!(sample(state.clone()).await, 6, "applied and in force stay counted");
|
||||
sqlx::query("UPDATE proposals SET status = 'superseded' WHERE id = ?")
|
||||
.bind(&open[2])
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("status");
|
||||
assert_eq!(sample(state.clone()).await, 5, "a status not named is out");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The base as production holds it on 2026-10-30, then the new version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The seed as it was then: no modules yet (ADR 0009 came after).
|
||||
fn seed_of_the_time(tag: &str) -> std::path::PathBuf {
|
||||
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||||
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
|
||||
seed.as_object_mut().expect("object").remove("modules");
|
||||
let dir = std::env::temp_dir().join(format!("ld-levels-seed-{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
|
||||
}
|
||||
|
||||
/// Migrations up to 0004: the schema production runs before this version.
|
||||
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
@@ -325,6 +381,7 @@ async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
|
||||
before.run(&pool).await.expect("migrations 1 to 4");
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
|
||||
cfg.seed_dir = seed_of_the_time(tag);
|
||||
let state = AppState::new(cfg, pool.clone()).await.expect("state");
|
||||
seed::documents(&state).await.expect("documents");
|
||||
// The words of the time: every article `standard`.
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Modules (ADR 0009): the title of the document (level 4) and the order of the
|
||||
//! articles of each section (level 2), voted like articles; the guarded
|
||||
//! addition that gives them to a document born without them.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn of(state: &AppState) -> Self {
|
||||
Self { router: app(state.clone()), cookie: None }
|
||||
}
|
||||
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self::of(&state), state)
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
Self { router: self.router.clone(), cookie: None }
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||
if let Some(c) = &self.cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.expect("request");
|
||||
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||
let status = res.status();
|
||||
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||
if raw.starts_with("ld_session=") {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
||||
}
|
||||
|
||||
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
|
||||
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
fn id_of(doc: &Value, code: &str) -> String {
|
||||
unit(doc, code)["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
fn order_of<'a>(doc: &'a Value, section: &str) -> &'a Value {
|
||||
doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section).expect("order")
|
||||
}
|
||||
|
||||
/// The codes of the articles of a section one can see (a text in force or an open
|
||||
/// version), in the order the document reads them.
|
||||
fn read_order(doc: &Value, section: &str) -> Vec<String> {
|
||||
let seen = |u: &Value| {
|
||||
!u["inForce"].is_null()
|
||||
|| u["proposals"]
|
||||
.as_array()
|
||||
.is_some_and(|p| p.iter().any(|p| p["status"] == "open" && p["kind"] != "inertia"))
|
||||
};
|
||||
let mut units: Vec<&Value> =
|
||||
doc["units"].as_array().expect("units").iter().filter(|u| u["section"] == section && seen(u)).collect();
|
||||
units.sort_by(|a, b| a["position"].as_f64().unwrap_or(0.0).total_cmp(&b["position"].as_f64().unwrap_or(0.0)));
|
||||
units.iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect()
|
||||
}
|
||||
|
||||
fn codes(doc: &Value, ids: &Value) -> Vec<String> {
|
||||
ids.as_array()
|
||||
.expect("ids")
|
||||
.iter()
|
||||
.map(|id| {
|
||||
doc["units"]
|
||||
.as_array()
|
||||
.expect("units")
|
||||
.iter()
|
||||
.find(|u| u["id"] == *id)
|
||||
.map(|u| u["code"].as_str().expect("code").to_owned())
|
||||
.unwrap_or_else(|| format!("?{id}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two people of the test group (W = 2, C = 1): two « for » adopt.
|
||||
async fn two() -> (Client, Client, AppState) {
|
||||
let (mut a, state) = Client::new().await;
|
||||
a.login("Yvv").await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara").await;
|
||||
(a, b, state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_base_has_its_title_and_the_order_of_each_section() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
c.login("Chiara").await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let doc = c.doc(group).await;
|
||||
// The articles stay the articles: 33 of them, none of the modules.
|
||||
assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}");
|
||||
let title = &doc["modules"]["title"];
|
||||
assert_eq!(
|
||||
(title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()),
|
||||
(Some("title"), Some("titre"), Some(4))
|
||||
);
|
||||
let v = &title["inForce"];
|
||||
assert_eq!(
|
||||
(v["title"].as_str(), v["seedMinimal"].as_bool()),
|
||||
(Some("Acte d'engagement Certification"), Some(true))
|
||||
);
|
||||
assert_eq!(v["translations"]["en"]["title"], "Certification Commitment Act");
|
||||
assert_eq!(v["translations"]["es"]["stale"], false);
|
||||
// One order per section, in the order of the document, each at level 2.
|
||||
let orders = doc["modules"]["orders"].as_array().expect("orders");
|
||||
let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect();
|
||||
assert_eq!(
|
||||
sections,
|
||||
[
|
||||
"introduction",
|
||||
"fondamental",
|
||||
"technique",
|
||||
"conclusion",
|
||||
"annexe",
|
||||
"formule",
|
||||
"inertie",
|
||||
"ordonnancement"
|
||||
]
|
||||
);
|
||||
assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true));
|
||||
// Its version in force is the order the section reads in now.
|
||||
let fondamental = order_of(&doc, "fondamental");
|
||||
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]);
|
||||
assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]);
|
||||
assert_eq!(fondamental["moot"], false);
|
||||
// « Ordonnancement » holds one article: nothing to decide.
|
||||
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true);
|
||||
}
|
||||
// Born with them: the addition says so, once per document.
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, m.outcome FROM module_amendments m JOIN documents d ON d.id = m.doc_id ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions");
|
||||
assert_eq!(rows, [("essai".into(), "present".into()), ("monnaie-libre".into(), "present".into())]);
|
||||
// The previews show articles only.
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||||
assert_eq!(all["units"].as_array().expect("units").len(), 33);
|
||||
// The list of the group counts articles only.
|
||||
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
|
||||
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() {
|
||||
let (mut yvv, mut chiara, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let conclusion = order_of(&doc, "conclusion").clone();
|
||||
let module = conclusion["id"].as_str().expect("id").to_owned();
|
||||
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
|
||||
let uri = format!("/api/units/{module}/order");
|
||||
// Every article of the section, each once, in another order.
|
||||
for (wrong, code) in [
|
||||
(json!([k1]), "order"),
|
||||
(json!([k2, k1, k1]), "order"),
|
||||
(json!([k2, id_of(&doc, "E1")]), "order"),
|
||||
(json!([k1, k2]), "same_order"),
|
||||
] {
|
||||
let (s, v) = yvv.call("POST", &uri, Some(json!({ "order": wrong }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong}");
|
||||
}
|
||||
let first = yvv.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
|
||||
assert_eq!(first["existing"], false);
|
||||
// The same order again: the open one is given back.
|
||||
let again = chiara.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
|
||||
assert_eq!((again["id"].as_str(), again["existing"].as_bool()), (first["id"].as_str(), Some(true)));
|
||||
let p = first["id"].as_str().expect("id").to_owned();
|
||||
let doc = yvv.doc("essai").await;
|
||||
let proposal = &order_of(&doc, "conclusion")["proposals"][0];
|
||||
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("order"), Some(2)));
|
||||
assert_eq!(codes(&doc, &proposal["order"]["ids"]), ["K2", "K1"]);
|
||||
assert_eq!(proposal["order"]["same"], false);
|
||||
// Voted like an article: one voice of two is not enough, the second brings it into force.
|
||||
assert!(yvv.vote(&p, "for").await["entered"].as_array().expect("e").is_empty());
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!(
|
||||
(v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()),
|
||||
(Some("ordre.conclusion"), Some("order"))
|
||||
);
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
|
||||
let now = order_of(&doc, "conclusion");
|
||||
assert_eq!(now["inForce"]["id"], p.as_str());
|
||||
assert_eq!(codes(&doc, &now["inForce"]["order"]["ids"]), ["K2", "K1"]);
|
||||
// The order of before goes back among the proposals, with its votes, and could return.
|
||||
let back = &now["proposals"][0];
|
||||
assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed")));
|
||||
assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]);
|
||||
// Nothing else moved: the other sections read as before, the texts are the same.
|
||||
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]);
|
||||
assert_eq!(read_order(&doc, "technique")[0], "T2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
|
||||
let (mut yvv, mut chiara, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned();
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1"));
|
||||
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"]
|
||||
.as_str()
|
||||
.expect("id")
|
||||
.to_owned();
|
||||
yvv.vote(&p, "for").await;
|
||||
// A new article enters the section: at its end, and at the end of the order under vote.
|
||||
let created = chiara
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let fresh = unit(&doc, "N1")["code"]
|
||||
.as_str()
|
||||
.map(|_| ())
|
||||
.and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned());
|
||||
let fresh = fresh.expect("the new article");
|
||||
let new_code = fresh["code"].as_str().expect("code").to_owned();
|
||||
assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end");
|
||||
let module_now = order_of(&doc, "inertie");
|
||||
let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p");
|
||||
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]);
|
||||
assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]);
|
||||
// Its vote stays: completing an order never asks the voters again.
|
||||
assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for")));
|
||||
// The new article leaves again (withdrawn by its author): dropped, as if never there.
|
||||
let added = created["id"].as_str().expect("id");
|
||||
chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let open = order_of(&doc, "inertie")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == p.as_str())
|
||||
.cloned()
|
||||
.expect("p");
|
||||
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]);
|
||||
assert!(open["order"]["added"].as_array().expect("a").is_empty());
|
||||
// An order proposed with three articles, one of which then leaves: dropped from it.
|
||||
let created = chiara
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let other = created["unitId"].as_str().expect("unit").to_owned();
|
||||
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await
|
||||
["id"]
|
||||
.as_str()
|
||||
.expect("id")
|
||||
.to_owned();
|
||||
chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let o = order_of(&doc, "inertie")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == three.as_str())
|
||||
.cloned()
|
||||
.expect("o");
|
||||
assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]);
|
||||
assert_eq!(o["order"]["dropped"], json!([other]));
|
||||
// Completed, it reads like the first one: the second voice brings that order in.
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!(v["entered"][0]["kind"], "order");
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]);
|
||||
// Now the order in force: the other one changes nothing more and cannot enter.
|
||||
let o = order_of(&doc, "inertie")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == three.as_str())
|
||||
.cloned()
|
||||
.expect("o");
|
||||
assert_eq!(o["order"]["same"], true);
|
||||
yvv.vote(&three, "for").await;
|
||||
assert!(chiara.vote(&three, "for").await["entered"].as_array().expect("e").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() {
|
||||
let (mut yvv, _, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let conclusion = order_of(&doc, "conclusion");
|
||||
let module = conclusion["id"].as_str().expect("id");
|
||||
let seed_order = conclusion["inForce"]["id"].as_str().expect("id");
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "unitId": module, "title": "Texte", "body": "Un texte sur un module.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("module")));
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{seed_order}"),
|
||||
Some(json!({ "title": "ordre.conclusion", "body": "x\ny" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{seed_order}/translations/en"),
|
||||
Some(json!({ "title": "order", "body": "" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
// A title and an order each have their own door, and only that one.
|
||||
let title = doc["modules"]["title"]["id"].as_str().expect("title");
|
||||
let (s, v) = yvv.call("POST", &format!("/api/units/{title}/order"), Some(json!({ "order": [] }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
|
||||
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
|
||||
// A module's inertia is voted like an article's.
|
||||
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
|
||||
assert_eq!(i["existing"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_title_enters_into_force_and_its_translations_are_to_review() {
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned();
|
||||
let uri = format!("/api/units/{module}/title");
|
||||
for (wrong, code) in [
|
||||
(" ", "title"),
|
||||
("Deux\nlignes", "title"),
|
||||
("Acte d'engagement Certification", "same_title"),
|
||||
("Acte\u{200b}d'engagement", "hidden_characters"),
|
||||
] {
|
||||
let (s, v) = yvv.call("POST", &uri, Some(json!({ "title": wrong }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong:?}");
|
||||
}
|
||||
let new_title = "Acte d'engagement de la certification";
|
||||
let p = yvv.ok("POST", &uri, Some(json!({ "title": new_title }))).await["id"].as_str().expect("id").to_owned();
|
||||
let doc = yvv.doc("essai").await;
|
||||
let proposal = &doc["modules"]["title"]["proposals"][0];
|
||||
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("title"), Some(4)));
|
||||
yvv.vote(&p, "for").await;
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!((v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), (Some("titre"), Some("title")));
|
||||
// The document changes title in French; English and Spanish stay, « à revoir ».
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["title"]["fr"], new_title);
|
||||
assert_eq!(doc["doc"]["title"]["en"], "Certification Commitment Act");
|
||||
let tr = &doc["modules"]["title"]["inForce"]["translations"];
|
||||
assert_eq!((tr["en"]["stale"].as_bool(), tr["es"]["stale"].as_bool()), (Some(true), Some(true)));
|
||||
let g = yvv.ok("GET", "/api/groups/essai", None).await;
|
||||
assert_eq!(g["docs"][0]["title"]["fr"], new_title);
|
||||
let d = yvv.ok("GET", "/api/groups/essai/decisions", None).await;
|
||||
assert_eq!(d["docs"][0]["title"]["fr"], new_title);
|
||||
// A member translates it again: the document's English title follows, up to date.
|
||||
yvv.ok(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{p}/translations/en"),
|
||||
Some(json!({ "title": "Commitment Act of Certification", "body": "" })),
|
||||
)
|
||||
.await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
|
||||
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
|
||||
// A typo of the title in force is corrected without a vote, and the document follows.
|
||||
let fixed = "Acte d’engagement de la certification";
|
||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
|
||||
assert_eq!(r["edit"], "typo");
|
||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
|
||||
// The test group never publishes; the title of the publication is covered by `render`.
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
|
||||
let (mut yvv, _, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let new = |section: Option<&str>, after: Option<String>| json!({ "section": section, "afterUnitId": after, "title": "Nouveau", "body": "Un nouvel article.", "submit": true });
|
||||
let a = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("fondamental"), None))).await;
|
||||
// An older client names an article: the end of its section, not right after it.
|
||||
let b = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(None, Some(id_of(&doc, "I1"))))).await;
|
||||
let (s, v) = yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("nulle-part"), None))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("section")));
|
||||
let doc = yvv.doc("essai").await;
|
||||
let code = |created: &Value| {
|
||||
doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).expect("unit")["code"]
|
||||
.as_str()
|
||||
.expect("code")
|
||||
.to_owned()
|
||||
};
|
||||
let (ca, cb) = (code(&a), code(&b));
|
||||
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]);
|
||||
assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]);
|
||||
// It joins the order of its section, at its end.
|
||||
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A base born without modules, as production holds it, then the new version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
|
||||
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||||
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
|
||||
seed.as_object_mut().expect("object").remove("modules");
|
||||
let dir = std::env::temp_dir().join(format!("ld-modules-seed-{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
|
||||
}
|
||||
|
||||
/// What must not move: every unit of the base (place, text in force, section,
|
||||
/// inertia), every version (status, text, revision) and every vote.
|
||||
async fn snapshot(
|
||||
state: &AppState,
|
||||
) -> (Vec<(String, f64, Option<String>, Option<String>, String)>, Vec<(String, String, String, i64)>, i64) {
|
||||
let units = sqlx::query_as(
|
||||
"SELECT id, position, in_force_id, section, inertia FROM units WHERE kind = 'article' ORDER BY id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("units");
|
||||
let proposals = sqlx::query_as(
|
||||
"SELECT p.id, p.status, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.kind = 'article' ORDER BY p.id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("proposals");
|
||||
let votes = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
|
||||
(units, proposals, votes)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
|
||||
// Production before this version: the documents, no module.
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = seed_without_modules("applied");
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let before = AppState::new(cfg, db).await.expect("state");
|
||||
seed::documents(&before).await.expect("documents");
|
||||
let mut c = Client::of(&before);
|
||||
c.login("Yvv").await;
|
||||
let mut chiara = c.other();
|
||||
chiara.login("Chiara").await;
|
||||
let doc = c.doc("essai").await;
|
||||
assert!(doc["modules"]["title"].is_null());
|
||||
// Life goes on there: votes, an article adopted, another one proposed in « conclusion ».
|
||||
let e3 = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("e3").to_owned();
|
||||
c.vote(&e3, "for").await;
|
||||
chiara.vote(&e3, "for").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
c.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel final, proposé avant les modules.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let kept = snapshot(&before).await;
|
||||
let reads = read_order(&c.doc("essai").await, "conclusion");
|
||||
|
||||
// The new version starts: each document receives its modules, from what it holds.
|
||||
let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id
|
||||
ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions");
|
||||
let created = r#"["titre","ordre.introduction","ordre.fondamental","ordre.technique","ordre.conclusion","ordre.annexe","ordre.formule","ordre.inertie","ordre.ordonnancement"]"#;
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("essai".into(), "applied".into(), None, Some(created.into())),
|
||||
("monnaie-libre".into(), "applied".into(), None, Some(created.into())),
|
||||
]
|
||||
);
|
||||
// Nothing else moved: articles, versions, votes.
|
||||
assert_eq!(snapshot(&state).await, kept);
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Yvv").await;
|
||||
let doc = c.doc("essai").await;
|
||||
assert_eq!(doc["modules"]["title"]["inForce"]["title"], "Acte d'engagement Certification");
|
||||
assert_eq!(
|
||||
doc["modules"]["title"]["inForce"]["translations"]["es"]["title"],
|
||||
"Acta de compromiso de Certificación"
|
||||
);
|
||||
// The order of each section in force is the one it read in, the new article included.
|
||||
let conclusion = order_of(&doc, "conclusion");
|
||||
assert_eq!(codes(&doc, &conclusion["inForce"]["order"]["ids"]), reads);
|
||||
assert_eq!(reads.len(), 3);
|
||||
assert_eq!(conclusion["inForce"]["seedMinimal"], true);
|
||||
// « Décisions » counts them: 41 + the new article in the test group, E3 adopted there.
|
||||
let d = c.ok("GET", "/api/groups/essai/decisions", None).await;
|
||||
assert_eq!(d["docs"][0]["toVote"], 41);
|
||||
// Republished where the document publishes, never from the test group.
|
||||
let queued: 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 'modules%'",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(
|
||||
queued,
|
||||
[(
|
||||
"monnaie-libre".to_owned(),
|
||||
"modules ajoutés : le titre du document, l'ordre des articles de chaque section".to_owned()
|
||||
)]
|
||||
);
|
||||
// What is published: the same text, and the modules in document.json.
|
||||
let ml = c.doc("monnaie-libre").await;
|
||||
let st = ld_server::docs::load_state(&state, ml["doc"]["id"].as_str().expect("id")).await.expect("state");
|
||||
let files = ld_server::publish::render(&state, &st).await.expect("render");
|
||||
let file = |name: &str| files.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).expect("file");
|
||||
assert!(!file("fr.md").contains("ordre.") && file("fr.md").contains("# Acte d'engagement Certification"));
|
||||
let json: Value = serde_json::from_str(&file("document.json")).expect("json");
|
||||
assert_eq!(json["units"].as_array().expect("units").len(), 3, "the articles in force, no module");
|
||||
assert_eq!(
|
||||
(json["modules"]["title"]["inertia"].as_u64(), json["modules"]["title"]["status"].as_str()),
|
||||
(Some(4), Some("seed-minimal"))
|
||||
);
|
||||
assert_eq!(json["modules"]["orders"].as_array().expect("orders").len(), 8);
|
||||
assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"]));
|
||||
|
||||
// Once only: the next start adds nothing.
|
||||
let again = AppState::new(Config::for_tests(), state.db.clone()).await.expect("state");
|
||||
seed::ensure(&again).await.expect("seed");
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n");
|
||||
assert_eq!(n, 18, "9 modules in each of the two documents");
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments").fetch_one(&again.db).await.expect("n");
|
||||
assert_eq!(decided, 2);
|
||||
}
|
||||
@@ -147,20 +147,21 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
|
||||
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
|
||||
// 3 seeds a minima + 30 open articles, the two variants counting once.
|
||||
assert_eq!(docs[0]["toVote"], 33);
|
||||
assert_eq!(docs[0]["withoutMyVote"], 33);
|
||||
// 3 seeds a minima + 30 open articles, the two variants counting once; the title of the
|
||||
// document and the order of 7 sections (« ordonnancement » holds one article: nothing to decide).
|
||||
assert_eq!(docs[0]["toVote"], 41);
|
||||
assert_eq!(docs[0]["withoutMyVote"], 41);
|
||||
|
||||
let doc = the_doc(&mut c, "essai").await;
|
||||
c.vote(&version(&doc, "E3", "0"), "for").await;
|
||||
c.vote(&version(&doc, "E3", "1"), "against").await;
|
||||
c.vote(&version(&doc, "I1", "inForce"), "for").await;
|
||||
let d = decisions(&mut c, "essai").await;
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31)));
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
|
||||
// Someone else has not voted yet.
|
||||
let mut yvv = c.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -176,7 +177,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
||||
let v = chiara.vote(&e3, "for").await;
|
||||
assert_eq!(v["entered"][0]["code"], "E3");
|
||||
let d = decisions(&mut yvv, "essai").await;
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32)));
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
||||
|
||||
// A new article by Chiara: one more module to vote.
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
@@ -191,9 +192,9 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let added = v["id"].as_str().expect("id").to_owned();
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33);
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41);
|
||||
yvv.vote(&added, "against").await;
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32);
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40);
|
||||
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
|
||||
let (s, v) = chiara
|
||||
.call(
|
||||
@@ -206,7 +207,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "substantive");
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -217,10 +218,10 @@ async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
||||
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
|
||||
let d = decisions(&mut guest, "monnaie-libre").await;
|
||||
assert_eq!(d["canVote"], false);
|
||||
assert_eq!(d["docs"][0]["toVote"], 33);
|
||||
assert_eq!(d["docs"][0]["toVote"], 41);
|
||||
assert!(d["docs"][0]["withoutMyVote"].is_null());
|
||||
// In the test group, the same person votes.
|
||||
let d = decisions(&mut guest, "essai").await;
|
||||
assert_eq!(d["canVote"], true);
|
||||
assert_eq!(d["docs"][0]["withoutMyVote"], 33);
|
||||
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user