v3 : moteur des objections « redit » / « contredit » et du circuit des coquilles

- ld-engine/objection.rs : la dernière arrivée (jamais une version en vigueur)
  n'est plus votable ; un cycle à la fois (réponse de l'auteur, puis lever ou
  réitérer) ; levée seule 3 jours après la réponse ; une révision de l'autre
  côté répond aussi ; l'administrateur lève, ne réitère jamais.
- ld-engine/correction.rs : qui applique une coquille et quand (celui qui
  répond de la version l'applique, les autres attendent 3 jours au plus),
  l'auteur garde la main hors vigueur, deux coquilles en attente : la seconde
  devient caduque.
- trustin-access : answers_typo, lifts_objection, reiterates_objection,
  reads_message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 01:13:52 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 70dbd712c8
4 changed files with 737 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is
//! does not change: `edit` (letters and spaces, judged against the voted text).
//!
//! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais
//! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule
//! une coquille validée corrige le texte en vote. »
//!
//! - The person who **answers for the version** — its author; for a text
//! without author (a starting text), an administrator of trustinWallet
//! (`trustin_access::answers_typo`) — applies a fix at once (`route`).
//! - Anyone else's fix **waits**: the one who answers is told by a message,
//! validates or refuses, and **without an answer within 3 days it applies by
//! itself** (`tick`). On a text in force, only a validated fix (or one passed
//! at the deadline) corrects it.
//! - **The author keeps the hand while the version is not in force**: a fix
//! applied (validated or passed at the deadline) may still be refused — the
//! text it was written over comes back —, and a refused one validated after
//! all. Once in force, an answer is final.
//! - A fix is written only over the text it was written over (`fits`): two
//! fixes waiting on one text are each judged against the voted text; once one
//! applies, the other no longer fits and is **stale** (« recharge, puis
//! recommence »). Slips never add up.
//! - Translations stay out of this: editorial, without validation.
use serde::{Deserialize, Serialize};
/// Days the one who answers for the version has, before a fix applies by itself.
pub const ANSWER_DAYS: i64 = 3;
/// Where a fix goes when it is written.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
/// Applied at once: the writer answers for the version.
Apply,
/// Waits for the one who answers for the version, or for the deadline.
Await,
}
pub fn route(writer_answers: bool) -> Route {
if writer_answers { Route::Apply } else { Route::Await }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TypoStatus {
/// Written, waiting for an answer or the deadline.
Pending,
/// In the text.
Applied,
/// Not in the text: refused (or taken back by the author before the text was in force).
Refused,
/// The text changed before it could apply: written over a text that is no longer there.
Stale,
}
impl TypoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Refused => "refused",
Self::Stale => "stale",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Validate,
Refuse,
}
/// What a step does to the text of the version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextChange {
/// Write the fix (only where `fits`).
Write,
/// Put back the text the fix was written over (only where the fix is still the text).
Restore,
Keep,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Settle {
pub status: TypoStatus,
pub text: TextChange,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TypoRefusal {
/// Already answered (or stale): nothing left to decide.
Settled,
}
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
let to = |status, text| Ok(Settle { status, text });
match (status, answer) {
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
// The author keeps the hand while the version is not in force.
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore),
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
_ => Err(TypoRefusal::Settled),
}
}
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
(status == TypoStatus::Pending && due <= now)
.then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
}
/// A text as a fix sees it: the revision it belongs to, its title and body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Text<'a> {
pub revision: i64,
pub title: &'a str,
pub body: &'a str,
}
/// A fix is written only over the very text it was written over: same revision
/// (no change of substance since), same title and body (no other fix since).
pub fn fits(base: Text<'_>, current: Text<'_>) -> bool {
base == current
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edit::{EditKind, classify};
#[test]
fn the_one_who_answers_applies_at_once_anyone_else_waits() {
assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text");
assert_eq!(route(false), Route::Await);
}
#[test]
fn a_pending_fix_is_validated_or_refused() {
for in_force in [false, true] {
assert_eq!(
answer(TypoStatus::Pending, Answer::Validate, in_force),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
assert_eq!(
answer(TypoStatus::Pending, Answer::Refuse, in_force),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep })
);
}
}
#[test]
fn the_author_keeps_the_hand_until_the_text_is_in_force() {
// A proposal: an applied fix may be taken back, a refused one validated after all.
assert_eq!(
answer(TypoStatus::Applied, Answer::Refuse, false),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore })
);
assert_eq!(
answer(TypoStatus::Refused, Answer::Validate, false),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// In force: an answer is final.
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled));
// Nothing to answer twice the same way, and never a stale fix.
assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled));
for a in [Answer::Validate, Answer::Refuse] {
assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled));
}
}
#[test]
fn without_an_answer_it_applies_at_the_deadline() {
let due = "2026-11-03T09:00:00Z";
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
assert_eq!(
tick(TypoStatus::Pending, due, due),
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// A deadline passed while the server was stopped is taken at the next tick.
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}");
}
}
#[test]
fn two_fixes_waiting_on_one_text_the_second_goes_stale() {
let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" };
let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" };
let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" };
// Each is judged against the voted text, as before: each is a typo there.
assert_eq!(classify(voted.body, first.body), EditKind::Typographic);
assert_eq!(classify(voted.body, second.body), EditKind::Typographic);
// Both were written over the voted text; the first applies.
assert!(fits(voted, voted));
// Then the text is the first fix: the second no longer fits — stale, never merged.
assert!(!fits(voted, first));
// A change of substance in between (a new revision) does the same, even back to the same words.
assert!(!fits(voted, Text { revision: 2, ..voted }));
}
#[test]
fn words_round_trip() {
for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(TypoStatus::parse(s.as_str()), Some(s));
}
assert_eq!(TypoStatus::parse("typo"), None);
}
}
+2
View File
@@ -10,9 +10,11 @@ pub mod address;
pub mod assist;
pub mod ballot;
pub mod compose;
pub mod correction;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod objection;
pub mod pending;
pub mod threshold;
+460
View File
@@ -0,0 +1,460 @@
//! Objections — « redit » and « contredit » (Yvv, 2026-10-30, L12).
//!
//! « Faudrait un "redit" en option avec "contredit" → faut rendre plus explicite
//! que l'objection doit être levée par l'auteur de la dernière arrivée pour être
//! une proposition votable. Pour ce qui constitue une répétition → modifier
//! l'article cible ; pour une contradiction → reformuler. → ping messagerie
//! TrustinWallet → Lever l'objection ou Réitérer l'objection ; si pas de réponse
//! sous 3j, alors "levée". »
//!
//! A member objects to a pair of versions (A, B), with a reason: B **repeats** A
//! (`Redit`) or B **contradicts** A (`Contredit`). The rules, purely:
//!
//! - **the latest arrival** of the two (submitted last; same instant: written
//! last) is no longer votable while the objection stands — unless it is in
//! force: a version in force is never made non-votable, the other one is
//! (`target`). Two versions both in force are not an objection: one of them
//! is changed by a proposal. The target is fixed when the objection is raised;
//! - the votes already cast on it stay, but it cannot enter into force
//! (`votable` and the adoption: a non-votable version is blocked);
//! - its author **answers**: a substantive revision of it, or a proposal linked
//! to the objection (on the article it repeats, for a « redit »; a reworded
//! version, for a version without author). A substantive revision of **the
//! other side** answers too: the pair changed, the objector judges again
//! (`answers`). A typo fix answers nothing;
//! - then the **objector** is asked, by a message: lift the objection (the
//! version is votable again) or reiterate it (it stands, the author is told
//! again). **Without an answer within 3 days, it is lifted** by itself;
//! - **one cycle at a time**: while the objector is asked, another answer of the
//! author changes nothing, and the deadline does not move;
//! - the objector may lift at any time; an administrator of trustinWallet may
//! lift too (never reiterate: the objection is not theirs); withdrawing either
//! version ends it.
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
/// Days the objector has to lift or reiterate once the author answered.
pub const ANSWER_DAYS: i64 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectionKind {
/// The latest repeats the other: its author changes the article it repeats.
Redit,
/// The latest contradicts the other: its author rewords it.
Contredit,
}
impl ObjectionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Redit => "redit",
Self::Contredit => "contredit",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Redit, Self::Contredit].into_iter().find(|k| k.as_str() == raw)
}
}
/// One version of the pair, as it stands when the objection is raised.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Side<'a> {
pub id: &'a str,
/// When it arrived (submitted), as a sortable stamp (RFC 3339 UTC).
pub arrived_at: &'a str,
/// The order it was written in, for versions of the same instant (a seed).
pub seq: i64,
pub in_force: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PairError {
/// A version does not object to itself.
Same,
/// Both are in force: nothing to hold back; a proposal changes one of them.
BothInForce,
}
fn arrival<'a>(s: &Side<'a>) -> (&'a str, i64) {
(s.arrived_at, s.seq)
}
/// The version an objection makes non-votable: the latest arrival of the two,
/// unless it is in force — then the other one.
pub fn target<'a>(a: &Side<'a>, b: &Side<'a>) -> Result<&'a str, PairError> {
if a.id == b.id {
return Err(PairError::Same);
}
if a.in_force && b.in_force {
return Err(PairError::BothInForce);
}
let latest = match arrival(a).cmp(&arrival(b)) {
Ordering::Greater => a,
Ordering::Less => b,
// Same instant, same rank: the larger id, so that the answer never depends on the order given.
Ordering::Equal => {
if a.id > b.id {
a
} else {
b
}
}
};
let other = if latest.id == a.id { b } else { a };
Ok(if latest.in_force { other.id } else { latest.id })
}
/// Why an objection was lifted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lift {
/// By the person who raised it.
Objector,
/// By an administrator of trustinWallet.
Admin,
/// Nobody answered within the days given: lifted by itself.
Deadline,
/// One of the two versions was withdrawn.
Withdrawn,
/// A contradiction signal of before 2026-10-31 that lapsed when the text changed.
Revision,
}
impl Lift {
pub fn as_str(self) -> &'static str {
match self {
Self::Objector => "objector",
Self::Admin => "admin",
Self::Deadline => "deadline",
Self::Withdrawn => "withdrawn",
Self::Revision => "revision",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Objector, Self::Admin, Self::Deadline, Self::Withdrawn, Self::Revision]
.into_iter()
.find(|l| l.as_str() == raw)
}
}
/// Where an objection stands. Cycles count from 1: a reiteration opens the next.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Phase {
/// Waiting for the author of the target to answer. No deadline: the version
/// stays non-votable until the author answers or the objection is lifted.
Author {
cycle: u32,
},
/// The author answered: waiting for the objector, until `due` (sortable stamp).
Objector {
cycle: u32,
due: String,
},
Lifted(Lift),
}
impl Phase {
pub fn standing(&self) -> bool {
!matches!(self, Self::Lifted(_))
}
pub fn cycle(&self) -> Option<u32> {
match self {
Self::Author { cycle } | Self::Objector { cycle, .. } => Some(*cycle),
Self::Lifted(_) => None,
}
}
}
/// Who acts on an objection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Who {
/// The person who raised it (an administrator who raised it acts as such).
Objector,
/// An administrator of trustinWallet who did not raise it.
Admin,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event<'a> {
/// The author answered (`answers`); `due` = now + `ANSWER_DAYS`.
Answered {
due: &'a str,
},
Lift(Who),
Reiterate(Who),
/// The clock, as a sortable stamp.
Tick {
now: &'a str,
},
/// One of the two versions was withdrawn.
Withdrawn,
}
/// What a step asks the server to do besides storing the phase.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// Ask the objector: lift or reiterate, by the deadline of the phase.
AskObjector,
/// Tell the author of the target (again): answer the objection.
TellAuthor,
/// The objection ended: every message about it is done.
Ended,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Step {
pub phase: Phase,
pub effects: Vec<Effect>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
/// The objection no longer stands.
Lifted,
/// Only the objector (or an administrator, to lift) may.
NotYours,
/// Reiterating answers an answer of the author: there is none yet.
NothingToReiterate,
}
/// One step of an objection. `Ok(None)`: nothing changes (an answer while the
/// objector is already asked — one cycle at a time —, a tick before the
/// deadline, an answer once lifted).
pub fn step(phase: &Phase, event: &Event<'_>) -> Result<Option<Step>, Refusal> {
let ended = |lift| Some(Step { phase: Phase::Lifted(lift), effects: vec![Effect::Ended] });
match (phase, event) {
(Phase::Lifted(_), Event::Answered { .. } | Event::Tick { .. } | Event::Withdrawn) => Ok(None),
(Phase::Lifted(_), Event::Lift(_) | Event::Reiterate(_)) => Err(Refusal::Lifted),
(Phase::Author { cycle }, Event::Answered { due }) => Ok(Some(Step {
phase: Phase::Objector { cycle: *cycle, due: (*due).to_owned() },
effects: vec![Effect::AskObjector],
})),
(Phase::Objector { .. }, Event::Answered { .. }) => Ok(None),
(_, Event::Lift(Who::Objector)) => Ok(ended(Lift::Objector)),
(_, Event::Lift(Who::Admin)) => Ok(ended(Lift::Admin)),
(_, Event::Lift(Who::Other) | Event::Reiterate(Who::Admin | Who::Other)) => Err(Refusal::NotYours),
(Phase::Author { .. }, Event::Reiterate(Who::Objector)) => Err(Refusal::NothingToReiterate),
(Phase::Objector { cycle, .. }, Event::Reiterate(Who::Objector)) => {
Ok(Some(Step { phase: Phase::Author { cycle: cycle + 1 }, effects: vec![Effect::TellAuthor] }))
}
(Phase::Objector { due, .. }, Event::Tick { now }) if due.as_str() <= *now => Ok(ended(Lift::Deadline)),
(Phase::Author { .. } | Phase::Objector { .. }, Event::Tick { .. }) => Ok(None),
(Phase::Author { .. } | Phase::Objector { .. }, Event::Withdrawn) => Ok(ended(Lift::Withdrawn)),
}
}
/// Which side of the pair a change touches.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Touched {
/// The version the objection holds back.
Target,
/// The version it is said to repeat or contradict.
Other,
}
/// A change made to the pair.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
/// A substantive revision (a new revision, votes to reconfirm).
Revision(Touched),
/// A typo fix: the meaning stays, nothing is answered.
Typo(Touched),
/// A proposal linked to the objection was submitted (a draft answers nothing).
LinkedProposal,
}
/// Does this change answer the objection (and ask the objector)?
pub fn answers(change: Change) -> bool {
match change {
Change::Revision(_) | Change::LinkedProposal => true,
Change::Typo(_) => false,
}
}
/// Who may answer with a linked proposal: the author of the target; anyone of
/// the group when the target has no author (a starting text).
pub fn may_link(target_author: Option<&str>, who: &str) -> bool {
target_author.is_none_or(|a| a == who)
}
/// Where a linked proposal may go: on the article the target repeats or
/// contradicts (« modifier l'article visé »), or on the target's own article (a
/// reworded version, for a target without author).
pub fn link_fits(unit: &str, target_unit: &str, other_unit: &str) -> bool {
unit == other_unit || unit == target_unit
}
/// Is a version votable? A version in force always is; any other is not while a
/// standing objection holds it back. `held` = the targets of the standing objections.
pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
in_force || !held.contains(&id)
}
#[cfg(test)]
mod tests {
use super::*;
fn side<'a>(id: &'a str, at: &'a str, seq: i64, in_force: bool) -> Side<'a> {
Side { id, arrived_at: at, seq, in_force }
}
const T1: &str = "2026-10-30T10:00:00Z";
const T2: &str = "2026-10-31T09:00:00Z";
const DUE: &str = "2026-11-03T09:00:00Z";
#[test]
fn the_latest_arrival_is_held_back() {
let a = side("a", T1, 5, false);
let b = side("b", T2, 3, false);
assert_eq!(target(&a, &b), Ok("b"));
assert_eq!(target(&b, &a), Ok("b"), "whatever the order given");
}
#[test]
fn same_instant_the_one_written_last() {
// The versions of a seed share one second: the order they were written in decides.
let a = side("zz", T1, 1, false);
let b = side("aa", T1, 2, false);
assert_eq!(target(&a, &b), Ok("aa"));
assert_eq!(target(&b, &a), Ok("aa"));
// Same rank too: the larger id, never the order of the arguments.
let c = side("c", T1, 7, false);
let d = side("d", T1, 7, false);
assert_eq!((target(&c, &d), target(&d, &c)), (Ok("d"), Ok("d")));
}
#[test]
fn a_version_in_force_is_never_held_back() {
// The latest arrival is in force (it entered after the other was submitted): the other is held.
let older = side("old", T1, 1, false);
let in_force = side("inf", T2, 2, true);
assert_eq!(target(&older, &in_force), Ok("old"));
// The usual case: a new proposal against the text in force.
let seed = side("seed", T1, 1, true);
let fresh = side("new", T2, 9, false);
assert_eq!(target(&seed, &fresh), Ok("new"));
}
#[test]
fn two_versions_in_force_or_one_alone_are_no_objection() {
let a = side("a", T1, 1, true);
let b = side("b", T2, 2, true);
assert_eq!(target(&a, &b), Err(PairError::BothInForce));
assert_eq!(target(&a, &a), Err(PairError::Same));
}
#[test]
fn votable_unless_held_and_never_in_force() {
assert!(votable("x", false, &[]));
assert!(!votable("x", false, &["x"]));
assert!(votable("y", false, &["x"]));
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
}
#[test]
fn the_full_cycle_answer_reiterate_answer_lift() {
let start = Phase::Author { cycle: 1 };
assert!(start.standing());
// The objector cannot reiterate before the author answered.
assert_eq!(step(&start, &Event::Reiterate(Who::Objector)), Err(Refusal::NothingToReiterate));
// No deadline while the author has not answered.
assert_eq!(step(&start, &Event::Tick { now: "2027-01-01T00:00:00Z" }), Ok(None));
let asked = step(&start, &Event::Answered { due: DUE }).expect("ok").expect("changes");
assert_eq!(asked.phase, Phase::Objector { cycle: 1, due: DUE.into() });
assert_eq!(asked.effects, vec![Effect::AskObjector]);
let again = step(&asked.phase, &Event::Reiterate(Who::Objector)).expect("ok").expect("changes");
assert_eq!(again.phase, Phase::Author { cycle: 2 });
assert_eq!(again.effects, vec![Effect::TellAuthor], "the author is told again");
let asked2 = step(&again.phase, &Event::Answered { due: DUE }).expect("ok").expect("changes");
assert_eq!(asked2.phase.cycle(), Some(2));
let lifted = step(&asked2.phase, &Event::Lift(Who::Objector)).expect("ok").expect("changes");
assert_eq!(lifted.phase, Phase::Lifted(Lift::Objector));
assert_eq!(lifted.effects, vec![Effect::Ended]);
assert!(!lifted.phase.standing());
}
#[test]
fn one_cycle_at_a_time() {
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
// A second answer while the objector is asked: nothing changes, the deadline stays.
assert_eq!(step(&asked, &Event::Answered { due: "2026-11-09T00:00:00Z" }), Ok(None));
}
#[test]
fn without_an_answer_of_the_objector_it_is_lifted_at_the_deadline() {
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
assert_eq!(step(&asked, &Event::Tick { now: "2026-11-03T08:59:59Z" }), Ok(None));
let done = step(&asked, &Event::Tick { now: DUE }).expect("ok").expect("changes");
assert_eq!(done.phase, Phase::Lifted(Lift::Deadline));
// A deadline passed while the server was stopped is taken at the next tick.
let late = step(&asked, &Event::Tick { now: "2026-12-25T00:00:00Z" }).expect("ok").expect("changes");
assert_eq!(late.phase, Phase::Lifted(Lift::Deadline));
}
#[test]
fn who_may_lift_and_reiterate() {
for phase in [Phase::Author { cycle: 1 }, Phase::Objector { cycle: 1, due: DUE.into() }] {
// The objector may change their mind at any time; an administrator may lift.
assert_eq!(
step(&phase, &Event::Lift(Who::Objector)).expect("ok").map(|s| s.phase),
Some(Phase::Lifted(Lift::Objector))
);
assert_eq!(
step(&phase, &Event::Lift(Who::Admin)).expect("ok").map(|s| s.phase),
Some(Phase::Lifted(Lift::Admin))
);
assert_eq!(step(&phase, &Event::Lift(Who::Other)), Err(Refusal::NotYours));
// Never reiterate someone else's objection, even as an administrator.
assert_eq!(step(&phase, &Event::Reiterate(Who::Admin)), Err(Refusal::NotYours));
assert_eq!(step(&phase, &Event::Reiterate(Who::Other)), Err(Refusal::NotYours));
}
}
#[test]
fn withdrawing_either_version_ends_it_and_a_lifted_one_stays_lifted() {
let w = step(&Phase::Author { cycle: 1 }, &Event::Withdrawn).expect("ok").expect("changes");
assert_eq!(w.phase, Phase::Lifted(Lift::Withdrawn));
let lifted = Phase::Lifted(Lift::Deadline);
assert_eq!(step(&lifted, &Event::Lift(Who::Objector)), Err(Refusal::Lifted));
assert_eq!(step(&lifted, &Event::Reiterate(Who::Objector)), Err(Refusal::Lifted));
assert_eq!(step(&lifted, &Event::Answered { due: DUE }), Ok(None));
assert_eq!(step(&lifted, &Event::Tick { now: DUE }), Ok(None));
assert_eq!(step(&lifted, &Event::Withdrawn), Ok(None));
}
#[test]
fn what_answers_an_objection() {
assert!(answers(Change::Revision(Touched::Target)), "the author rewords");
assert!(answers(Change::Revision(Touched::Other)), "the other side changed: the objector judges again");
assert!(answers(Change::LinkedProposal), "the article it repeats is changed");
assert!(!answers(Change::Typo(Touched::Target)), "a typo keeps the meaning");
assert!(!answers(Change::Typo(Touched::Other)));
}
#[test]
fn who_answers_with_a_linked_proposal_and_where() {
assert!(may_link(Some("chiara"), "chiara"));
assert!(!may_link(Some("chiara"), "yvv"));
assert!(may_link(None, "yvv"), "a starting text has no author: anyone of the group");
assert!(link_fits("other-unit", "target-unit", "other-unit"), "the article it repeats");
assert!(link_fits("target-unit", "target-unit", "other-unit"), "a reworded version");
assert!(!link_fits("third", "target-unit", "other-unit"));
}
#[test]
fn words_round_trip() {
for k in [ObjectionKind::Redit, ObjectionKind::Contredit] {
assert_eq!(ObjectionKind::parse(k.as_str()), Some(k));
}
for l in [Lift::Objector, Lift::Admin, Lift::Deadline, Lift::Withdrawn, Lift::Revision] {
assert_eq!(Lift::parse(l.as_str()), Some(l));
}
assert_eq!(ObjectionKind::parse("signal"), None);
}
}
+58
View File
@@ -150,6 +150,33 @@ pub fn manages_access(identity: &Identity) -> bool {
identity.admin
}
/// Who answers a typo fix someone else wrote on a version (2026-10-30, L13):
/// its author; for a text without author (a starting text), an administrator of
/// trustinWallet. Whoever answers for a version also applies their own fix at once.
pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool {
match version_author {
Some(author) => identity.id == author,
None => identity.admin,
}
}
/// Who lifts an objection (2026-10-30, L12): the person who raised it, or an
/// administrator of trustinWallet.
pub fn lifts_objection(identity: &Identity, objector: &str) -> bool {
identity.id == objector || identity.admin
}
/// Who reiterates an objection: only the person who raised it — an
/// administrator lifts, never speaks for someone else.
pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool {
identity.id == objector
}
/// A message of trustinWallet is read by its recipient only.
pub fn reads_message(identity: &Identity, recipient: &str) -> bool {
identity.id == recipient
}
/// The groups a fresh deployment starts with. Ordered as displayed.
pub fn default_groups() -> Vec<Group> {
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
@@ -251,6 +278,37 @@ mod tests {
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
}
#[test]
fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() {
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
assert!(answers_typo(&chiara, Some("chiara")));
assert!(!answers_typo(&other, Some("chiara")));
assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author");
assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers");
assert!(!answers_typo(&other, None));
}
#[test]
fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() {
let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna"));
assert!(lifts_objection(&admin, "luna"));
assert!(!reiterates_objection(&admin, "luna"));
assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna"));
}
#[test]
fn a_message_is_read_by_its_recipient_only() {
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
assert!(reads_message(&luna, "luna"));
assert!(!reads_message(&admin, "luna"), "not even an administrator");
}
#[test]
fn no_atelier_du_canal() {
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));