v3 : relecture de cd7e683 — coquilles, objections et retraits
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful

- 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>
This commit is contained in:
Yvv
2026-10-31 10:00:29 +01:00
co-authored by Claude Opus 5.5
parent 033b331228
commit 4d4adcc281
8 changed files with 220 additions and 20 deletions
@@ -24,6 +24,14 @@ UPDATE signals SET phase = 'lifted', lift = CASE
ELSE 'admin' END
WHERE lifted_at IS NOT NULL;
-- A standing one whose other side left (withdrawn, or never submitted) ends: a
-- withdrawal ends the objection, which the rule of before did not do.
UPDATE signals SET phase = 'lifted', lift = 'withdrawn', lifted_by = 'withdrawn',
lifted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE lifted_at IS NULL AND EXISTS (
SELECT 1 FROM proposals p
WHERE p.id IN (signals.proposal_id, signals.target_proposal_id) AND p.status NOT IN ('open', 'in_force'));
-- The standing ones wait for the author of the version they now hold back: the
-- latest arrival of the two (submitted last; the same instant: written last),
-- unless it is in force — then the other. Both in force: none is held.
@@ -41,6 +49,11 @@ UPDATE signals SET held_id = (
CREATE INDEX signals_proposal ON signals(proposal_id);
CREATE INDEX signals_target ON signals(target_proposal_id);
CREATE INDEX signals_due ON signals(phase, due_at);
-- One person, one standing objection on a pair, whichever way it was written
-- (relecture of cd7e683: sent four times at once, four stood).
CREATE UNIQUE INDEX signals_one_standing
ON signals(author_id, min(proposal_id, target_proposal_id), max(proposal_id, target_proposal_id))
WHERE lifted_at IS NULL;
-- A proposal written to answer an objection (« modifier l'article visé »).
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
+7 -2
View File
@@ -350,7 +350,7 @@ pub async fn raise(
return Err(AppError::Conflict("objection_exists"));
}
let oid = util::id();
sqlx::query(
let inserted = sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
)
@@ -363,7 +363,12 @@ pub async fn raise(
.bind(kind.as_str())
.bind(&held)
.execute(&state.db)
.await?;
.await;
match inserted {
// The same one sent at the same instant: it stands already (`signals_one_standing`).
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Err(AppError::Conflict("objection_exists")),
other => other?,
};
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
}
+3 -1
View File
@@ -445,7 +445,7 @@ pub async fn withdraw(
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, _) = guarded(&state, &user, &id).await?;
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
@@ -461,6 +461,8 @@ pub async fn withdraw(
// Nothing left to object to, nor to correct.
objections::on_withdrawn(&state, &id, &user.id).await?;
typos::on_text_changed(&state, &id).await?;
// A version its objection held back may enter now, with its votes (relecture of cd7e683).
adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "withdrawn" })))
}
+65 -3
View File
@@ -92,7 +92,7 @@ async fn write_text(
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
AND (?9 IS NULL OR status = ?9)",
AND (?8 IS NULL OR status = ?8)",
)
.bind(to.0)
.bind(to.1)
@@ -277,6 +277,8 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -
if claimed.rows_affected() != 1 {
return Ok(Err("settled"));
}
// The author's change of mind lands only on a version still open (relecture of cd7e683).
let only = s.while_open.then_some("open");
let written = match s.text {
TextChange::Write => {
write_text(
@@ -284,7 +286,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -
&t.proposal_id,
t.base(),
(&t.title, &t.body),
None,
only,
&t.writer_id,
Some((&t.id, by)),
"typo",
@@ -298,7 +300,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -
&t.proposal_id,
t.fixed(),
(&t.base_title, &t.base_body),
None,
only,
by,
Some((&t.id, by)),
"revert",
@@ -475,3 +477,63 @@ pub async fn for_message(
v["gestures"] = json!(gestures);
Ok(Some((v, gestures)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Config, open_db, seed};
/// Relecture of cd7e683 (M1): the author takes back a fix applied, read while her
/// version was open; a vote puts it in force in between. The text in force stays.
#[tokio::test]
async fn a_change_of_mind_never_writes_over_a_text_just_in_force() {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let now = "2026-10-31T00:00:00Z";
let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as(
"SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE p.status = 'in_force' AND p.kind = 'text' LIMIT 1",
)
.fetch_one(&state.db)
.await
.expect("a version in force");
sqlx::query(
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)",
)
.bind(now)
.execute(&state.db)
.await
.expect("identity");
// Its text is the one a fix wrote over « Un texte d'avant ».
sqlx::query(
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
title, body, status, created_at, due_at)
VALUES ('t1', ?, ?, 'dev:aude', 'dev:aude', ?, ?, 'Un texte d''avant', ?, ?, 'applied', ?, ?)",
)
.bind(&id)
.bind(&doc)
.bind(revision)
.bind(&title)
.bind(&title)
.bind(&body)
.bind(now)
.bind(now)
.execute(&state.db)
.await
.expect("typo");
let t = load(&state, "t1").await.expect("load");
// What `answer` decided when it read the version, still open.
let s = correction::answer(TypoStatus::Applied, Answer::Refuse, false).expect("refuse");
assert_eq!(settle(&state, &t, s, "dev:aude", now).await.expect("settle"), Err("changed"));
let kept: String = sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?")
.bind(&id)
.fetch_one(&state.db)
.await
.expect("body");
assert_eq!(kept, body, "the text in force stays");
let status: String =
sqlx::query_scalar("SELECT status FROM typos WHERE id = 't1'").fetch_one(&state.db).await.expect("status");
assert_eq!(status, "applied", "nothing settled");
}
}
+111 -1
View File
@@ -476,6 +476,46 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
}
/// Withdrawing the other side frees the held version at once: with its votes, it
/// enters then, not at the next pass of the sweeper (relecture of cd7e683).
#[tokio::test]
async fn withdrawing_the_other_side_lets_the_held_version_enter_at_once() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut zoe = chiara.other();
zoe.login("Zoé", false).await;
let doc = chiara.doc("essai").await;
let a = yvv
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let b = chiara
.propose(
&doc,
json!({ "title": "Un seul compte", "body": "Une personne, un seul compte membre.", "submit": true }),
)
.await;
let held = zoe.object(&b, &a, "redit", "Elle répète la proposition d'Yvv.").await;
assert_eq!(held["heldId"], b.as_str(), "the latest arrival");
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
.fetch_all(&state.db)
.await
.expect("members");
for m in &members {
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
.bind(&b)
.bind(m)
.execute(&state.db)
.await
.expect("vote");
}
yvv.ok("POST", &format!("/api/proposals/{a}/withdraw"), None).await;
let status: String =
sqlx::query_scalar("SELECT status FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
assert_eq!(status, "in_force");
}
#[tokio::test]
async fn a_message_is_read_by_its_recipient_only() {
let (mut chiara, _) = Client::new().await;
@@ -511,6 +551,55 @@ async fn a_message_is_read_by_its_recipient_only() {
}
/// A deadline passed while the server was stopped is dealt with at the next start.
/// One person, one objection on a pair: sent several times at once, one stands
/// (relecture of cd7e683: four simultaneous requests made four).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_same_objection_sent_at_once_stands_once() {
let path = std::env::temp_dir().join(format!("ld-lot-f-twice-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = open_db(&format!("sqlite://{}", path.display())).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut chiara = Client { router: app(state.clone()), cookie: None };
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let sends = (0..8).map(|_| {
let mut c = Client { router: yvv.router.clone(), cookie: yvv.cookie.clone() };
let (b, e1) = (b.clone(), e1.clone());
tokio::spawn(async move {
c.call(
"POST",
&format!("/api/proposals/{b}/objections"),
Some(json!({ "otherId": e1, "kind": "redit", "reason": REPEATS })),
)
.await
})
});
let mut answers = Vec::new();
for s in sends.collect::<Vec<_>>() {
let (status, v) = s.await.expect("join");
answers.push((status, v["error"].as_str().unwrap_or_default().to_owned()));
}
assert_eq!(answers.iter().filter(|(s, _)| *s == StatusCode::OK).count(), 1, "{answers:?}");
assert!(
answers.iter().all(|(s, e)| *s == StatusCode::OK || (*s == StatusCode::CONFLICT && e == "objection_exists")),
"{answers:?}"
);
let standing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE lifted_at IS NULL")
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(standing, 1);
state.db.close().await;
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
@@ -618,6 +707,25 @@ async fn contradiction_signals_of_before_become_contredits() {
signal("s2", e4.clone(), e5.clone(), None).await;
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
// Standing against a version withdrawn since: the rule of before lifted nothing
// on a withdrawal; now a withdrawal ends the objection (relecture of cd7e683).
let spare: Vec<String> = sqlx::query_scalar(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = 'essai' AND p.status = 'open' AND p.id NOT IN (?, ?, ?) ORDER BY p.rowid LIMIT 2",
)
.bind(&k1)
.bind(&e4)
.bind(&e5)
.fetch_all(&pool)
.await
.expect("two more");
let (live, gone) = (spare[0].clone(), spare[1].clone());
sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?")
.bind(&gone)
.execute(&pool)
.await
.expect("withdraw");
signal("s5", live.clone(), gone.clone(), None).await;
// The image of now starts on it.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
@@ -634,6 +742,7 @@ async fn contradiction_signals_of_before_become_contredits() {
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
("s5".into(), "contredit".into(), "lifted".into(), None, Some("withdrawn".into())),
],
"K1 against the text in force; E5 written after E4 in the same second"
);
@@ -645,7 +754,7 @@ async fn contradiction_signals_of_before_become_contredits() {
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(kept, 4);
assert_eq!(kept, 5);
// And the server reads them as objections: K1 and E5 are no longer votable.
let st = ld_server::docs::load_state(
&state,
@@ -659,4 +768,5 @@ async fn contradiction_signals_of_before_become_contredits() {
assert!(
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
);
assert!(!st.blocked.contains(&live), "nothing holds a version back against one withdrawn");
}