forked from yvv/decision
v3 : relecture de cd7e683 — coquilles, objections et retraits
- 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:
@@ -88,6 +88,9 @@ pub enum TextChange {
|
|||||||
pub struct Settle {
|
pub struct Settle {
|
||||||
pub status: TypoStatus,
|
pub status: TypoStatus,
|
||||||
pub text: TextChange,
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -98,21 +101,24 @@ pub enum TypoRefusal {
|
|||||||
|
|
||||||
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
|
/// 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> {
|
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
|
||||||
let to = |status, text| Ok(Settle { status, text });
|
let to = |status, text, while_open| Ok(Settle { status, text, while_open });
|
||||||
match (status, answer) {
|
match (status, answer) {
|
||||||
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
|
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write, false),
|
||||||
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
|
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep, false),
|
||||||
// The author keeps the hand while the version is not in force.
|
// 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::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore, true),
|
||||||
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
|
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write, true),
|
||||||
_ => Err(TypoRefusal::Settled),
|
_ => Err(TypoRefusal::Settled),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
|
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
|
||||||
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
|
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
|
||||||
(status == TypoStatus::Pending && due <= now)
|
(status == TypoStatus::Pending && due <= now).then_some(Settle {
|
||||||
.then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
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.
|
/// A text as a fix sees it: the revision it belongs to, its title and body.
|
||||||
@@ -145,11 +151,11 @@ mod tests {
|
|||||||
for in_force in [false, true] {
|
for in_force in [false, true] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
answer(TypoStatus::Pending, Answer::Validate, in_force),
|
answer(TypoStatus::Pending, Answer::Validate, in_force),
|
||||||
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false })
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
answer(TypoStatus::Pending, Answer::Refuse, in_force),
|
answer(TypoStatus::Pending, Answer::Refuse, in_force),
|
||||||
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep })
|
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep, while_open: false })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +165,11 @@ mod tests {
|
|||||||
// A proposal: an applied fix may be taken back, a refused one validated after all.
|
// A proposal: an applied fix may be taken back, a refused one validated after all.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
answer(TypoStatus::Applied, Answer::Refuse, false),
|
answer(TypoStatus::Applied, Answer::Refuse, false),
|
||||||
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore })
|
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore, while_open: true })
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
answer(TypoStatus::Refused, Answer::Validate, false),
|
answer(TypoStatus::Refused, Answer::Validate, false),
|
||||||
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: true })
|
||||||
);
|
);
|
||||||
// In force: an answer is final.
|
// In force: an answer is final.
|
||||||
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
|
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
|
||||||
@@ -182,7 +188,7 @@ mod tests {
|
|||||||
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
|
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tick(TypoStatus::Pending, due, due),
|
tick(TypoStatus::Pending, due, due),
|
||||||
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
|
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.
|
// 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());
|
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ UPDATE signals SET phase = 'lifted', lift = CASE
|
|||||||
ELSE 'admin' END
|
ELSE 'admin' END
|
||||||
WHERE lifted_at IS NOT NULL;
|
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
|
-- 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),
|
-- 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.
|
-- 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_proposal ON signals(proposal_id);
|
||||||
CREATE INDEX signals_target ON signals(target_proposal_id);
|
CREATE INDEX signals_target ON signals(target_proposal_id);
|
||||||
CREATE INDEX signals_due ON signals(phase, due_at);
|
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é »).
|
-- A proposal written to answer an objection (« modifier l'article visé »).
|
||||||
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
|
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ pub async fn raise(
|
|||||||
return Err(AppError::Conflict("objection_exists"));
|
return Err(AppError::Conflict("objection_exists"));
|
||||||
}
|
}
|
||||||
let oid = util::id();
|
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)
|
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
||||||
)
|
)
|
||||||
@@ -363,7 +363,12 @@ pub async fn raise(
|
|||||||
.bind(kind.as_str())
|
.bind(kind.as_str())
|
||||||
.bind(&held)
|
.bind(&held)
|
||||||
.execute(&state.db)
|
.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) {
|
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?;
|
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ pub async fn withdraw(
|
|||||||
User(user): User,
|
User(user): User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> 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()) {
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||||
return Err(AppError::Forbidden);
|
return Err(AppError::Forbidden);
|
||||||
}
|
}
|
||||||
@@ -461,6 +461,8 @@ pub async fn withdraw(
|
|||||||
// Nothing left to object to, nor to correct.
|
// Nothing left to object to, nor to correct.
|
||||||
objections::on_withdrawn(&state, &id, &user.id).await?;
|
objections::on_withdrawn(&state, &id, &user.id).await?;
|
||||||
typos::on_text_changed(&state, &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" })))
|
Ok(Json(json!({ "status": "withdrawn" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async fn write_text(
|
|||||||
let done = sqlx::query(
|
let done = sqlx::query(
|
||||||
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||||
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
|
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.0)
|
||||||
.bind(to.1)
|
.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 {
|
if claimed.rows_affected() != 1 {
|
||||||
return Ok(Err("settled"));
|
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 {
|
let written = match s.text {
|
||||||
TextChange::Write => {
|
TextChange::Write => {
|
||||||
write_text(
|
write_text(
|
||||||
@@ -284,7 +286,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -
|
|||||||
&t.proposal_id,
|
&t.proposal_id,
|
||||||
t.base(),
|
t.base(),
|
||||||
(&t.title, &t.body),
|
(&t.title, &t.body),
|
||||||
None,
|
only,
|
||||||
&t.writer_id,
|
&t.writer_id,
|
||||||
Some((&t.id, by)),
|
Some((&t.id, by)),
|
||||||
"typo",
|
"typo",
|
||||||
@@ -298,7 +300,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -
|
|||||||
&t.proposal_id,
|
&t.proposal_id,
|
||||||
t.fixed(),
|
t.fixed(),
|
||||||
(&t.base_title, &t.base_body),
|
(&t.base_title, &t.base_body),
|
||||||
None,
|
only,
|
||||||
by,
|
by,
|
||||||
Some((&t.id, by)),
|
Some((&t.id, by)),
|
||||||
"revert",
|
"revert",
|
||||||
@@ -475,3 +477,63 @@ pub async fn for_message(
|
|||||||
v["gestures"] = json!(gestures);
|
v["gestures"] = json!(gestures);
|
||||||
Ok(Some((v, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
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]
|
#[tokio::test]
|
||||||
async fn a_message_is_read_by_its_recipient_only() {
|
async fn a_message_is_read_by_its_recipient_only() {
|
||||||
let (mut chiara, _) = Client::new().await;
|
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.
|
/// 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]
|
#[tokio::test]
|
||||||
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
|
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()));
|
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("s2", e4.clone(), e5.clone(), None).await;
|
||||||
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
|
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
|
||||||
signal("s4", e5.clone(), e2.clone(), Some("revision")).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.
|
// The image of now starts on it.
|
||||||
let mut all = sqlx::migrate!("./migrations");
|
let mut all = sqlx::migrate!("./migrations");
|
||||||
all.set_ignore_missing(true);
|
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),
|
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
|
||||||
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
|
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
|
||||||
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".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"
|
"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)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.expect("count");
|
.expect("count");
|
||||||
assert_eq!(kept, 4);
|
assert_eq!(kept, 5);
|
||||||
// And the server reads them as objections: K1 and E5 are no longer votable.
|
// And the server reads them as objections: K1 and E5 are no longer votable.
|
||||||
let st = ld_server::docs::load_state(
|
let st = ld_server::docs::load_state(
|
||||||
&state,
|
&state,
|
||||||
@@ -659,4 +768,5 @@ async fn contradiction_signals_of_before_become_contredits() {
|
|||||||
assert!(
|
assert!(
|
||||||
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,3 +80,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
|||||||
| 2026-10-31 | automate | local, fusion de F avec B2 (`cd7e683`) ; C22 ne suppose plus un groupe d'essai de moins de 16 membres | C1–C24, C28–C33, M1 | 31 / 31 verts |
|
| 2026-10-31 | automate | local, fusion de F avec B2 (`cd7e683`) ; C22 ne suppose plus un groupe d'essai de moins de 16 membres | C1–C24, C28–C33, M1 | 31 / 31 verts |
|
||||||
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
|
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
|
||||||
| 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) |
|
| 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) |
|
||||||
|
| 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) |
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export function injectedNames(page: Page): string[] {
|
|||||||
/** What went wrong, in one of a few words the interface translates. */
|
/** What went wrong, in one of a few words the interface translates. */
|
||||||
export function failure(e: unknown): Failure {
|
export function failure(e: unknown): Failure {
|
||||||
if (e instanceof WalletError) return e.code;
|
if (e instanceof WalletError) return e.code;
|
||||||
// polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent.
|
// polkadot-api's refusal by the node's pool (stale, bad proof, too late…), before sending or once the
|
||||||
|
// finalized chain finds it invalid: it will never be included.
|
||||||
// Its message is indented JSON, so the reason is read from the error itself.
|
// Its message is indented JSON, so the reason is read from the error itself.
|
||||||
if (e instanceof Error && e.name === 'InvalidTxError') {
|
if (e instanceof Error && e.name === 'InvalidTxError') {
|
||||||
const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();
|
const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user