forked from yvv/decision
- Le revirement de l'auteur sur une coquille (reprendre une correction appliquée, valider une correction refusée) ne s'écrit que sur une version encore ouverte : Settle porte while_open ; et la garde de statut de write_text marche enfin (?9 restait NULL, le statut est le 8e paramètre). - 0008 lève les signalements debout dont un côté est déjà retiré. - Une seule objection debout par personne et par paire (index unique partiel) ; envoyée plusieurs fois à la fois, les autres reçoivent objection_exists. - Un retrait relance l'adoption : la version que l'objection retenait entre aussitôt. Tests vus rouges d'abord. Boucle : 230 tests Rust, svelte-check 0, 55 vitest, e2e 35 / 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
9.3 KiB
Rust
224 lines
9.3 KiB
Rust
//! 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,
|
|
/// Holds only while the version is not in force: the author's change of mind,
|
|
/// decided on a version read open, must not land on one that entered meanwhile.
|
|
pub while_open: bool,
|
|
}
|
|
|
|
#[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, while_open| Ok(Settle { status, text, while_open });
|
|
match (status, answer) {
|
|
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write, false),
|
|
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep, false),
|
|
// The author keeps the hand while the version is not in force.
|
|
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore, true),
|
|
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write, true),
|
|
_ => 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,
|
|
while_open: false,
|
|
})
|
|
}
|
|
|
|
/// 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, while_open: false })
|
|
);
|
|
assert_eq!(
|
|
answer(TypoStatus::Pending, Answer::Refuse, in_force),
|
|
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep, while_open: false })
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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, while_open: true })
|
|
);
|
|
assert_eq!(
|
|
answer(TypoStatus::Refused, Answer::Validate, false),
|
|
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: true })
|
|
);
|
|
// 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, while_open: false })
|
|
);
|
|
// 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);
|
|
}
|
|
}
|