v3 : quatrième relecture — coquille en liste blanche, mesurée contre le texte voté
- Une coquille ne touche que des lettres latines, des espaces et la ponctuation ordinaire ; tout le reste est figé : chiffres de toute écriture, signes, relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre accents graves, séparateurs et tirets collés à un chiffre. - Adresses (domaines, liens, courriels) comparées à l'identique. - Glissement de clavier au cœur du mot seulement (ni les deux premières ni les deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de position ajoutés (dessus/dessous, avant/après…). - Chaque correction est mesurée contre le texte voté (dernière révision initiale ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un brouillon devient sa révision initiale. - Déploiement : « previous » = l'image du conteneur en service, pas la dernière construite ; migrations inconnues tolérées pour qu'une image précédente démarre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,11 @@ pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
let max = if url.contains(":memory:") { 1 } else { 8 };
|
||||
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
// A previous image (rollback) must still start on a database a newer one has
|
||||
// migrated: migrations are additive, the unknown ones are tolerated.
|
||||
let mut migrator = sqlx::migrate!("./migrations");
|
||||
migrator.set_ignore_missing(true);
|
||||
migrator.run(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision.
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
|
||||
}
|
||||
|
||||
async fn record_revision(
|
||||
state: &AppState,
|
||||
proposal_id: &str,
|
||||
@@ -302,7 +314,18 @@ pub async fn edit(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("closed"));
|
||||
}
|
||||
let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
|
||||
// Measured against the text that was voted, not the current one: successive
|
||||
// typo fixes are judged together and never add up to a change of meaning.
|
||||
let next = format!("{title}\n{body}");
|
||||
let kind = if next == format!("{}\n{}", p.title, p.body) {
|
||||
EditKind::Unchanged
|
||||
} else {
|
||||
let (voted_title, voted_body) = voted_text(&state, &p).await?;
|
||||
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
|
||||
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
|
||||
k => k,
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||
EditKind::Typographic => {
|
||||
@@ -379,6 +402,16 @@ pub async fn submit(
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// A draft is edited freely: the text submitted is the one votes will be cast on.
|
||||
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if initial.rows_affected() == 0 {
|
||||
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
||||
}
|
||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||
}
|
||||
|
||||
@@ -251,6 +251,34 @@ async fn editing_is_not_voting() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||||
// Typo fixes are measured against the voted text: slips never add up.
|
||||
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
||||
let (_, v) = b
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
||||
.await;
|
||||
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
||||
let (s, v) = b
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
||||
.await;
|
||||
assert_eq!(
|
||||
(s, v["error"].as_str()),
|
||||
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
||||
"a second slip on top is not"
|
||||
);
|
||||
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||||
assert_eq!(v["edit"], "typo", "back to the voted text");
|
||||
// An address is compared exactly.
|
||||
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
||||
let x11_body = x11["body"].as_str().expect("body");
|
||||
assert!(x11_body.contains("git.duniter.org"));
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
|
||||
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
|
||||
|
||||
// A member proposal: typo keeps votes, substance asks to reconfirm.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
|
||||
Reference in New Issue
Block a user