forked from yvv/decision
589 lines
22 KiB
Rust
589 lines
22 KiB
Rust
//! Seed proposal amendments — a starting text in force made a seed proposal in
|
|
//! an existing base (ADR 0011).
|
|
//!
|
|
//! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les
|
|
//! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition
|
|
//! seed" » — then: « on pouvait l'amorcer avec une inertie basse, pour que le
|
|
//! nombre de votes initiaux soit assez petit pour commencer l'expérience en mode
|
|
//! prod ». New bases get I1, E1 and E2 from the seed as open proposals put
|
|
//! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds
|
|
//! them in force, seeds a minima, in Monnaie libre and in the test group — gets
|
|
//! them through an amendment listed here, decided once per document and article.
|
|
//!
|
|
//! **The status**, only if nobody has touched the text in force:
|
|
//!
|
|
//! - it is the seed's own (origin `seed`), never voted into force, never
|
|
//! confirmed (`entered_at`, `confirmed_at` empty);
|
|
//! - nobody ever voted on it, on the server or in a ballot of the chain;
|
|
//! - no typo fix (applied, waiting or refused), no member translation, no
|
|
//! objection names it; its history is the seed's alone: its initial text, and
|
|
//! at most the seed amendment that replaced it (I1, the « Raison d'être »);
|
|
//! - its text is the seed proposal's of today (fingerprint).
|
|
//!
|
|
//! Applied, the same version stays — its text, translations, history and votes
|
|
//! (none) — and becomes open and marked; the article has no text in force any
|
|
//! more; its history gets a line (kind `seed_proposal`).
|
|
//!
|
|
//! **The level**, with the status only, and only if nobody has touched it: no
|
|
//! change of inertia proposed by a member that is open, applied or voted on, no
|
|
//! vote that changed it, and a level the seed gave the article before (I1: 3;
|
|
//! E1 and E2: 3, then 4 since 30/10). Applied, the article says so
|
|
//! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its
|
|
//! level and the status still changes.
|
|
//!
|
|
//! Each decision is kept in `seed_proposal_amendments` (status and level:
|
|
//! applied, refused and why, or present) and in the logs. Where the document
|
|
//! publishes, the publication queue gets a row — and waits while no article is
|
|
//! in force (`publish`, ADR 0011).
|
|
|
|
use crate::AppState;
|
|
use crate::seed::{self, SeedDoc};
|
|
use crate::seed_amend::{self, fingerprint};
|
|
use crate::util;
|
|
use ld_engine::inertia::InertiaLevel;
|
|
use sqlx::SqliteConnection;
|
|
|
|
/// One starting text in force to put forward, in every document instantiated from a seed.
|
|
pub struct SeedProposalAmendment {
|
|
/// Stable identifier: each document's decision is kept under it.
|
|
pub id: &'static str,
|
|
/// Slug of the seed document.
|
|
pub doc: &'static str,
|
|
/// Code of the article.
|
|
pub unit: &'static str,
|
|
/// Fingerprint of the text (source language): the seed proposal of the seed
|
|
/// today, which must hash to it, and the text in force it expects.
|
|
pub text: &'static str,
|
|
/// The levels the article may still hold for its level to go low: those the seed
|
|
/// gave it before.
|
|
pub levels: &'static [u8],
|
|
/// Reason given to the publication queue, and so to the texts repository.
|
|
pub reason: &'static str,
|
|
/// The seed amendment that gives the seed proposal another text since (lot T4): the seed
|
|
/// no longer puts this one forward, and this amendment decides nothing more, without
|
|
/// an error (the decisions already taken stay).
|
|
pub superseded_by: Option<&'static str>,
|
|
}
|
|
|
|
pub const AMENDMENTS: &[SeedProposalAmendment] = &[
|
|
SeedProposalAmendment {
|
|
id: "2026-11-02-i1-proposition-seed",
|
|
doc: "acte-engagement-certification",
|
|
unit: "I1",
|
|
text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
|
levels: &[3],
|
|
reason: "Proposition seed : I1 sort de la vigueur",
|
|
superseded_by: Some("2026-11-03-i1-amorce"),
|
|
},
|
|
SeedProposalAmendment {
|
|
id: "2026-11-02-e1-proposition-seed",
|
|
doc: "acte-engagement-certification",
|
|
unit: "E1",
|
|
text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd",
|
|
levels: &[3, 4],
|
|
reason: "Proposition seed : E1 sort de la vigueur",
|
|
superseded_by: Some("2026-11-03-e1-amorce"),
|
|
},
|
|
SeedProposalAmendment {
|
|
id: "2026-11-02-e2-proposition-seed",
|
|
doc: "acte-engagement-certification",
|
|
unit: "E2",
|
|
text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0",
|
|
levels: &[3, 4],
|
|
reason: "Proposition seed : E2 sort de la vigueur",
|
|
superseded_by: Some("2026-11-03-e2-amorce"),
|
|
},
|
|
];
|
|
|
|
/// What the database says of the version in force.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Facts {
|
|
/// As a seed amendment reads it.
|
|
pub text: seed_amend::Facts,
|
|
/// Lines of ballots of the chain naming it, whatever became of them.
|
|
pub ballots: i64,
|
|
}
|
|
|
|
/// What the article holds.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Found {
|
|
/// A version in force.
|
|
InForce(Facts),
|
|
/// No text in force, and its seed proposal: born so, or already amended.
|
|
SeedProposal,
|
|
/// Neither.
|
|
Nothing,
|
|
}
|
|
|
|
/// What the database says of the article's level.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct LevelFacts {
|
|
/// `units.inertia` as written.
|
|
pub raw: String,
|
|
/// Changes of inertia proposed on it that are open or applied, or hold a vote.
|
|
pub inertia_proposals: i64,
|
|
/// Changes of its inertia that a vote brought into force.
|
|
pub voted_changes: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Verdict {
|
|
Apply,
|
|
/// Already so: nothing to do.
|
|
Present,
|
|
Refuse(&'static str),
|
|
}
|
|
|
|
impl Verdict {
|
|
fn outcome(self) -> (&'static str, Option<&'static str>) {
|
|
match self {
|
|
Verdict::Apply => ("applied", None),
|
|
Verdict::Present => ("present", None),
|
|
Verdict::Refuse(why) => ("refused", Some(why)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The status, apart from the database: any trace of a person keeps the text in force.
|
|
pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict {
|
|
let f = match found {
|
|
Found::Nothing => return Verdict::Refuse("no_text_in_force"),
|
|
Found::SeedProposal => return Verdict::Present,
|
|
Found::InForce(f) => f,
|
|
};
|
|
let t = &f.text;
|
|
let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect();
|
|
let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2));
|
|
let refusal = if t.origin != "seed" {
|
|
"not_a_seed"
|
|
} else if t.entered {
|
|
"voted_into_force"
|
|
} else if t.confirmed {
|
|
"confirmed"
|
|
} else if t.votes > 0 {
|
|
"votes"
|
|
} else if f.ballots > 0 {
|
|
"ballots"
|
|
} else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) {
|
|
"corrected"
|
|
} else if !seed_history {
|
|
"revised"
|
|
} else if t.member_translations > 0 {
|
|
"translation_edited"
|
|
} else if t.signals > 0 {
|
|
"signalled"
|
|
} else if t.fingerprint != a.text {
|
|
"text_differs"
|
|
} else {
|
|
return Verdict::Apply;
|
|
};
|
|
Verdict::Refuse(refusal)
|
|
}
|
|
|
|
/// The level, apart from the database: it goes low with the status only, and any
|
|
/// change of inertia a person proposed or voted keeps it.
|
|
pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict {
|
|
if let Verdict::Refuse(_) = status {
|
|
return Verdict::Refuse("not_a_seed_proposal");
|
|
}
|
|
let Some(f) = facts else { return Verdict::Refuse("no_article") };
|
|
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
|
|
if level.get() == to {
|
|
Verdict::Present
|
|
} else if f.voted_changes > 0 {
|
|
Verdict::Refuse("changed_by_vote")
|
|
} else if f.inertia_proposals > 0 {
|
|
Verdict::Refuse("inertia_proposal")
|
|
} else if !a.levels.contains(&level.get()) {
|
|
Verdict::Refuse("level_differs")
|
|
} 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 status: Verdict,
|
|
pub level: Verdict,
|
|
}
|
|
|
|
/// Decides every amendment not yet decided, document by document.
|
|
pub async fn apply(
|
|
state: &AppState,
|
|
seeds: &[SeedDoc],
|
|
list: &[SeedProposalAmendment],
|
|
) -> anyhow::Result<Vec<Outcome>> {
|
|
let mut out = Vec::new();
|
|
let to = seed::SEED_PROPOSAL_LEVEL;
|
|
for a in list {
|
|
if !seed_says(seeds, a) {
|
|
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((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else {
|
|
continue;
|
|
};
|
|
let (s, sr) = status.outcome();
|
|
let (l, lr) = level.outcome();
|
|
if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) {
|
|
tracing::warn!(
|
|
amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr,
|
|
"seed proposal amendment: refused where the article was touched, it keeps what it had"
|
|
);
|
|
} else {
|
|
tracing::info!(
|
|
amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to,
|
|
"seed proposal amendment"
|
|
);
|
|
}
|
|
if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() {
|
|
state.publish_wake.notify_one();
|
|
}
|
|
out.push(Outcome { amendment: a.id, group, status, level });
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The seed must put the article forward, with the text the amendment names and
|
|
/// no level of its own: a seed edited later never reaches a base by this way.
|
|
fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool {
|
|
let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| {
|
|
let src = s.source_lang.as_str();
|
|
let u = s.units.iter().find(|u| u.code == a.unit)?;
|
|
let v = u.versions.iter().find(|v| v.seed_proposal)?;
|
|
Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?)))
|
|
});
|
|
match unit {
|
|
Some((u, print)) if print == a.text && u.inertia.is_none() => true,
|
|
_ if a.superseded_by.is_some() => {
|
|
tracing::debug!(
|
|
amendment = a.id,
|
|
unit = a.unit,
|
|
superseded_by = a.superseded_by,
|
|
"seed proposal amendment superseded: nothing decided"
|
|
);
|
|
false
|
|
}
|
|
_ => {
|
|
tracing::error!(
|
|
amendment = a.id,
|
|
doc = a.doc,
|
|
unit = a.unit,
|
|
"seed proposal amendment: the seed does not put this text forward, nothing decided"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the article holds, and the version examined: in force, or its seed proposal.
|
|
async fn found(
|
|
conn: &mut SqliteConnection,
|
|
unit_id: &str,
|
|
in_force: Option<&str>,
|
|
) -> anyhow::Result<(Found, Option<String>)> {
|
|
if let Some(id) = in_force {
|
|
let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else {
|
|
return Ok((Found::Nothing, Some(id.to_owned())));
|
|
};
|
|
let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?")
|
|
.bind(id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned())));
|
|
}
|
|
let featured: Option<String> = sqlx::query_scalar(
|
|
"SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1
|
|
ORDER BY rowid LIMIT 1",
|
|
)
|
|
.bind(unit_id)
|
|
.fetch_optional(&mut *conn)
|
|
.await?;
|
|
Ok(match featured {
|
|
Some(id) => (Found::SeedProposal, Some(id)),
|
|
None => (Found::Nothing, None),
|
|
})
|
|
}
|
|
|
|
async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<LevelFacts> {
|
|
let inertia_proposals: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia'
|
|
AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))",
|
|
)
|
|
.bind(unit_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
let voted_changes: i64 = sqlx::query_scalar(
|
|
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
|
|
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
|
|
)
|
|
.bind(unit_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
Ok(LevelFacts { raw, inertia_proposals, voted_changes })
|
|
}
|
|
|
|
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
|
async fn decide(
|
|
state: &AppState,
|
|
a: &SeedProposalAmendment,
|
|
to: u8,
|
|
doc_id: &str,
|
|
publishes: bool,
|
|
) -> anyhow::Result<Option<(Verdict, Verdict)>> {
|
|
let mut tx = state.db.begin().await?;
|
|
let decided: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_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 unit: Option<(String, String, Option<String>)> =
|
|
sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
|
.bind(doc_id)
|
|
.bind(a.unit)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let ((status_found, examined), level_found) = match &unit {
|
|
Some((id, raw, in_force)) => {
|
|
(found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?))
|
|
}
|
|
None => ((Found::Nothing, None), None),
|
|
};
|
|
let mut status = verdict(&status_found, a);
|
|
let mut level = level_verdict(status, level_found.as_ref(), a, to);
|
|
let now = util::now();
|
|
let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default();
|
|
if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) {
|
|
// Written only over the version that was judged, still untouched and in force.
|
|
let revision = match &status_found {
|
|
Found::InForce(f) => f.text.revision,
|
|
_ => 0,
|
|
};
|
|
let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?")
|
|
.bind(unit_id)
|
|
.bind(pid)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let opened = sqlx::query(
|
|
"UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ?
|
|
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
|
AND confirmed_at IS NULL AND revision = ?",
|
|
)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(pid)
|
|
.bind(revision)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if left.rows_affected() == 1 && opened.rows_affected() == 1 {
|
|
// The history says it: the same text, at the same revision, now a seed proposal.
|
|
sqlx::query(
|
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
|
SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?",
|
|
)
|
|
.bind(&now)
|
|
.bind(pid)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
} else {
|
|
// Nothing is written: what was examined changed under us.
|
|
tx.rollback().await?;
|
|
tx = state.db.begin().await?;
|
|
status = Verdict::Refuse("changed");
|
|
level = level_verdict(status, level_found.as_ref(), a, to);
|
|
}
|
|
}
|
|
if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) {
|
|
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
|
|
.bind(&to_db)
|
|
.bind(unit_id)
|
|
.bind(raw)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if done.rows_affected() == 1 {
|
|
let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db);
|
|
sqlx::query(
|
|
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
|
|
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
|
|
)
|
|
.bind(unit_id)
|
|
.bind(doc_id)
|
|
.bind(from)
|
|
.bind(&to_db)
|
|
.bind(a.id)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
} else {
|
|
level = Verdict::Refuse("changed");
|
|
}
|
|
}
|
|
if publishes && (status == Verdict::Apply || level == Verdict::Apply) {
|
|
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) = status.outcome();
|
|
let (level_outcome, level_reason) = level.outcome();
|
|
sqlx::query(
|
|
"INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason,
|
|
from_level, to_level, level_outcome, level_reason, at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(a.id)
|
|
.bind(doc_id)
|
|
.bind(a.unit)
|
|
.bind(examined)
|
|
.bind(outcome)
|
|
.bind(reason)
|
|
.bind(unit.as_ref().map(|(_, raw, _)| raw.clone()))
|
|
.bind(&to_db)
|
|
.bind(level_outcome)
|
|
.bind(level_reason)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(Some((status, level)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const A: SeedProposalAmendment = SeedProposalAmendment {
|
|
id: "test",
|
|
doc: "doc",
|
|
unit: "E1",
|
|
text: "seed",
|
|
levels: &[3, 4],
|
|
reason: "Proposition seed : E1 sort de la vigueur",
|
|
superseded_by: None,
|
|
};
|
|
|
|
fn intact() -> Facts {
|
|
Facts {
|
|
text: seed_amend::Facts {
|
|
origin: "seed".into(),
|
|
revision: 1,
|
|
revisions: vec!["initial".into()],
|
|
fingerprint: "seed".into(),
|
|
..seed_amend::Facts::default()
|
|
},
|
|
ballots: 0,
|
|
}
|
|
}
|
|
|
|
fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found {
|
|
let mut facts = intact();
|
|
f(&mut facts.text);
|
|
Found::InForce(facts)
|
|
}
|
|
|
|
fn at(raw: &str) -> LevelFacts {
|
|
LevelFacts { raw: raw.into(), ..LevelFacts::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn an_untouched_text_in_force_is_put_forward() {
|
|
assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply);
|
|
// I1: its initial text, then the seed amendment of 30/10.
|
|
let amended = with(|t| {
|
|
t.revision = 2;
|
|
t.revisions = vec!["initial".into(), "amendment".into()];
|
|
});
|
|
assert_eq!(verdict(&amended, &A), Verdict::Apply);
|
|
}
|
|
|
|
#[test]
|
|
fn a_seed_proposal_already_there_is_left_as_it_is() {
|
|
assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present);
|
|
}
|
|
|
|
#[test]
|
|
fn any_trace_of_a_person_keeps_the_text_in_force() {
|
|
let cases = [
|
|
(Found::Nothing, "no_text_in_force"),
|
|
(with(|t| t.origin = "member".into()), "not_a_seed"),
|
|
(with(|t| t.entered = true), "voted_into_force"),
|
|
(with(|t| t.confirmed = true), "confirmed"),
|
|
(with(|t| t.votes = 1), "votes"),
|
|
(Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"),
|
|
(with(|t| t.typos = 1), "corrected"),
|
|
(
|
|
with(|t| {
|
|
t.revision = 2;
|
|
t.revisions = vec!["initial".into(), "typo".into()];
|
|
}),
|
|
"corrected",
|
|
),
|
|
(
|
|
with(|t| {
|
|
t.revision = 2;
|
|
t.revisions = vec!["initial".into(), "substantive".into()];
|
|
}),
|
|
"revised",
|
|
),
|
|
(with(|t| t.revision = 2), "revised"),
|
|
(with(|t| t.revisions = vec![]), "revised"),
|
|
(with(|t| t.member_translations = 1), "translation_edited"),
|
|
(with(|t| t.signals = 1), "signalled"),
|
|
(with(|t| t.fingerprint = "other".into()), "text_differs"),
|
|
];
|
|
for (found, reason) in cases {
|
|
assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_level_goes_low_with_the_status_where_nobody_touched_it() {
|
|
assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply);
|
|
assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply);
|
|
// The word of before, if an older image wrote it.
|
|
assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply);
|
|
assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present);
|
|
assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so");
|
|
}
|
|
|
|
#[test]
|
|
fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() {
|
|
let cases = [
|
|
(Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"),
|
|
(Verdict::Apply, None, "no_article"),
|
|
(Verdict::Apply, Some(at("haute")), "level_unreadable"),
|
|
(Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"),
|
|
(Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"),
|
|
(Verdict::Apply, Some(at("5")), "level_differs"),
|
|
(Verdict::Apply, Some(at("1")), "level_differs"),
|
|
];
|
|
for (status, facts, reason) in cases {
|
|
assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() {
|
|
let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect();
|
|
assert_eq!(units, ["I1", "E1", "E2"]);
|
|
assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL)));
|
|
assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level");
|
|
}
|
|
}
|