diff --git a/crates/ld-engine/src/correction.rs b/crates/ld-engine/src/correction.rs new file mode 100644 index 0000000..3c5c911 --- /dev/null +++ b/crates/ld-engine/src/correction.rs @@ -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::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 { + 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 { + (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); + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 945d854..b84e883 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -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; diff --git a/crates/ld-engine/src/objection.rs b/crates/ld-engine/src/objection.rs new file mode 100644 index 0000000..bc36290 --- /dev/null +++ b/crates/ld-engine/src/objection.rs @@ -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::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::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 { + 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, +} + +#[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, 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); + } +} diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 2361538..387f6d4 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -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 { 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")));