forked from yvv/decision
Fusion du lot F : objections redit / contredit, coquilles validées par leur auteur, messagerie, délai de 3 jours
Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ; proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24 (« faire une objection », le panier par son adresse), C28–C33 et les deux passages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user