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
+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");
}