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 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)]
|
||||
@@ -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.
|
||||
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) {
|
||||
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
|
||||
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
|
||||
(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),
|
||||
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
|
||||
(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 })
|
||||
(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.
|
||||
@@ -145,11 +151,11 @@ mod tests {
|
||||
for in_force in [false, true] {
|
||||
assert_eq!(
|
||||
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!(
|
||||
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.
|
||||
assert_eq!(
|
||||
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!(
|
||||
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.
|
||||
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, 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.
|
||||
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
|
||||
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;
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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" })))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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, 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, `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. */
|
||||
export function failure(e: unknown): Failure {
|
||||
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.
|
||||
if (e instanceof Error && e.name === 'InvalidTxError') {
|
||||
const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user