forked from yvv/decision
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user