A consolidates duplicated room-id/slug splitting logic into a single `room_route_segment`/`room_id_from_route_segment` API (used consistently across UI, API, and tests) and adds a well-tested external-URL normalization module (YouTube canonicalization, query sorting) that improves identity correctness for garden items—substantial, tested infrastructure work. B is a narrower but solid correctness fix (rejecting invalid vote ratios) with good test coverage, but it's much smaller in scope and impact compared to A's broader refactor and new normalization capability.
constitution · epochs · watch · epoch 3
c_45dfa34511fe (tommy-mor) vs c_55666fe32c48 (tommy-mor)
download prompt · raw event · cmp_ba4f76d24c677e
council reasoning
A lands lasting shared design: fixed-width `/r/{short}{slug}` encoding (`room_route_segment`/`room_id_from_route_segment`), route/handler consolidation, and external identity normalization (YouTube rewrite, sorted query pairs, case-preserving dash paths) with broad call-site and test updates. B is a correct but narrow domain guard—reject 0-sided and >100 vote ratios in DSL, UI POST, and reducer, plus regression tests—valuable integrity work that does not match A’s reusable URL architecture impact.
Side A introduces a substantial, cross-cutting URL and routing refactor: it centralizes room route encoding/decoding with new `room_route_segment`/`room_id_from_route_segment` helpers, updates server routing and URL generation to a unified `/r/{room_key}` format, and adds URL normalization infrastructure (including canonicalization improvements and tests). Side B is a focused validation change that enforces vote ratio bounds across the parser, UI, and reducer with appropriate regression tests, but its impact is narrower than the architectural routing and URL normalization work in Side A.
sides
A — c_45dfa34511fe (tommy-mor)
message
[5ca518f6] url refactor
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index 67a09a3b54f778fa7e857fdd589c3ed9c92e1322..ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1757,6 +1757,7 @@ name = "slug-types"
version = "0.1.0"
dependencies = [
"serde",
+ "url",
]
[[package]]
@@ -2272,6 +2273,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
[[package]]
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 606f7d6f97a4428efb90d1e0d544934c861fc4c5..cd3e0f0afd972d9ad9e7e4b92c5fa4c22bb8f620 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -270,10 +270,10 @@ fn post_redirect_location(room: &str, thread_tag: &str) -> String {
format!("/t/{tag}")
} else {
let room = room.trim();
- let Some((a, b)) = room.split_once('/') else {
+ let Some(seg) = slug_types::room_route_segment(room) else {
return "/".to_string();
};
- format!("/r/{a}/{b}/t/{tag}")
+ format!("/r/{seg}/t/{tag}")
}
}
diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs
index cb78d2f3f95b1bc163c1fb064d1d5f657e18000f..f9c3b8bd3fbf8fcb9c035e1a1572fef0b08fa8a9 100644
--- a/server/src/api/write_actor.rs
+++ b/server/src/api/write_actor.rs
@@ -19,13 +19,15 @@ use crate::{
use super::auth::{issue_token_for_user, verify_token};
use super::helpers::{now_ms, resolve_item};
use super::validate::{normalize_room_and_thread, validate_ingest_document};
-use slug_types::RpcResult;
+use slug_types::{room_route_segment, RpcResult, ROOM_SHORT_ID_LEN};
fn gen_short_id() -> String {
use rand::Rng;
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::thread_rng();
- (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
+ (0..ROOM_SHORT_ID_LEN)
+ .map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char)
+ .collect()
}
fn parse_capability(s: &str) -> Result<crate::events::ThreadCapability, String> {
@@ -55,8 +57,8 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str
let feed_id = if room_key == "public" { "thread-feed" } else { "room-thread-feed" };
let thread_url = if room_key == "public" {
format!("/t/{thread_id}")
- } else if let Some((short, slug)) = room_key.split_once('/') {
- format!("/r/{short}/{slug}/t/{thread_id}")
+ } else if let Some(seg) = room_route_segment(room_key) {
+ format!("/r/{seg}/t/{thread_id}")
} else {
format!("/t/{thread_id}")
};
@@ -78,8 +80,8 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str
let js = builder.build();
let mut path_prefixes = vec![if room_key == "public" {
"/".to_string()
- } else if let Some((short, slug)) = room_key.split_once('/') {
- format!("/r/{short}/{slug}")
+ } else if let Some(seg) = room_route_segment(room_key) {
+ format!("/r/{seg}")
} else {
"/".to_string()
}];
diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs
index 0ee33d91160fc5542817b5e3e9ab4fee1d0e600f..48fe11e46731670874ff8b6b05baa6f09ae0b7e4 100644
--- a/server/src/html/forum/nav.rs
+++ b/server/src/html/forum/nav.rs
@@ -1,7 +1,8 @@
use crate::canonical_path::canonicalize_item;
use crate::reducer::ScopeId;
+use slug_types::room_route_segment;
-/// URL helpers for public `/t/…` and private room threads `/r/{short}/{slug}/t/…`.
+/// URL helpers for public `/t/…` and private room threads `/r/{short}{slug}/t/…`.
#[derive(Clone)]
pub struct ThreadNav {
pub room_wire: String,
@@ -22,18 +23,15 @@ impl ThreadNav {
}
}
- /// `room_id` wire form `shortid/slug`.
+ /// `room_id` wire form `shortid/slug` (HTTP uses [`slug_types::room_route_segment`]).
pub(crate) fn from_room_id(room_id: &str) -> Option<Self> {
- let (short, slug) = room_id.split_once('/')?;
- if short.is_empty() || slug.is_empty() {
- return None;
- }
+ let room_seg = room_route_segment(room_id)?;
Some(Self {
room_wire: room_id.to_string(),
scope: ScopeId::Room(room_id.to_string()),
- room_path: format!("/r/{short}/{slug}"),
- thread_path_prefix: format!("/r/{short}/{slug}/t"),
- garden_path_prefix: format!("/r/{short}/{slug}/~"),
+ room_path: format!("/r/{room_seg}"),
+ thread_path_prefix: format!("/r/{room_seg}/t"),
+ garden_path_prefix: format!("/r/{room_seg}/~"),
})
}
diff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs
index c316f8f836df9d4ef9c05ebd9e54f699540e6d72..473747b3da3d4d7a54b5e0c63165d2533df643e1 100644
--- a/server/src/html/forum/post_single.rs
+++ b/server/src/html/forum/post_single.rs
@@ -93,12 +93,14 @@ pub async fn thread_post_view(
pub async fn room_thread_post_view(
State(state): State<AppState>,
- Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,
+ Path((room_key, tag, index_str)): Path<(String, String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let reduced = state.reduced.read().await;
let user = optional_principal(&headers, &jar, &reduced);
if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs
index be5df1745ef580891a167c23c3dd6c06f804f295..1ec421f84335cbfe7f9db775b73a8ed24b197174 100644
--- a/server/src/html/forum/views.rs
+++ b/server/src/html/forum/views.rs
@@ -183,16 +183,18 @@ pub async fn thread_view(
thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await
}
-/// Room thread — `/r/:short/:slug/t/:tag`
+/// Room thread — `/r/:room_key/t/:tag` (`room_key` = `{short}{slug}`).
pub async fn room_thread_view(
State(state): State<AppState>,
- Path((room_short, room_slug, tag)): Path<(String, String, String)>,
+ Path((room_key, tag)): Path<(String, String)>,
Query(q): Query<ThreadViewQuery>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let reduced = state.reduced.read().await;
let user = optional_principal(&headers, &jar, &reduced);
if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
@@ -226,15 +228,17 @@ pub(super) fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoRespon
(StatusCode::NOT_FOUND, Html(page.into_string()))
}
-/// Private room index — `/r/:short/:slug`
+/// Private room index — `/r/:room_key`
pub async fn room_page(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "room not found").into_response();
+ };
let now = now_ms();
let reduced = state.reduced.read().await;
if !reduced.rooms.contains(&room_id) {
@@ -266,7 +270,10 @@ pub async fn room_page(
let audit_cli = format!("npx slugsocial private {room_id} audit");
drop(reduced);
- let slug_display = room_slug.as_str();
+ let slug_display = room_id
+ .split_once('/')
+ .map(|(_, slug)| slug)
+ .unwrap_or(room_id.as_str());
let page = layout(
&format!("room {slug_display} — slug.social"),
"view-thread",
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 423f23fd8c9ad7b7f454d6ea7a9a7607a4c9c5b9..e615dd356bcf634232d85610c0a26235ead125fd 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -309,12 +309,14 @@ pub async fn external_ontology_path(
pub async fn room_garden_index(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -341,12 +343,14 @@ pub async fn room_garden_index(
pub async fn room_external_garden_index(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -416,12 +420,14 @@ pub async fn room_external_garden_index(
pub async fn room_external_ontology_path(
State(state): State<AppState>,
- Path((room_short, room_slug, path)): Path<(String, String, String)>,
+ Path((room_key, path)): Path<(String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -442,12 +448,14 @@ pub async fn room_external_ontology_path(
pub async fn room_ontology_path(
State(state): State<AppState>,
- Path((room_short, room_slug, path)): Path<(String, String, String)>,
+ Path((room_key, path)): Path<(String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 43e6ebf36cf0fe72c96f0f9d850bea51ac094c43..f01732f7edb32c68fcc10c39545c8f56476adb27 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -351,8 +351,8 @@ fn render_search_results(results: &SearchResults, query: &str) -> Markup {
ul class="search-posts" {
@for r in &results.posts {
@let (post_href, post_label) = if let Some((room, tag)) = r.thread.split_once("/#") {
- if let Some((short, slug)) = room.split_once('/') {
- (format!("/r/{short}/{slug}/t/{tag}"), format!("{room}/#{tag}"))
+ if let Some(seg) = slug_types::room_route_segment(room) {
+
… preview truncated; 35,812 characters omittedB — c_55666fe32c48 (tommy-mor)
message
[aa0b175c] Enforce vote ratio constraints: both sides ≥ 1, max 100. Zero on either side produces no valid graph edge; ratios above 100 add no meaningful signal. Enforce in the DSL parser, browser POST handler, and reducer guard. Update browser pool test to use 99:1 instead of 100:0. Add unit and integration regression tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index b79efdb4d52bd445a67f38cbfd61d3507d2b3014..bc8a0130b434cc7880a4bf16eb9237080c4aa383 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -243,11 +243,23 @@ async fn dispatch_ui_action(
let pool_id = pool.as_deref().and_then(|p| {
crate::path_types::ItemId::parse(p.trim()).map(|i| i.normalized_storage())
});
- let mut rl = ratio_left.trim().parse::<i32>().unwrap_or(0).max(0);
- let mut rr = ratio_right.trim().parse::<i32>().unwrap_or(0).max(0);
- if rl == 0 && rr == 0 {
- rl = 1;
- rr = 1;
+ let rl = ratio_left.trim().parse::<i32>().unwrap_or(0).max(0);
+ let rr = ratio_right.trim().parse::<i32>().unwrap_or(0).max(0);
+ if rl == 0 || rr == 0 {
+ return form_js_error(
+ err_tgt.as_ref(),
+ "invalid ratio",
+ "Both ratio sides must be ≥ 1.",
+ )
+ .into_response();
+ }
+ if rl > 100 || rr > 100 {
+ return form_js_error(
+ err_tgt.as_ref(),
+ "invalid ratio",
+ "Ratio sides must be ≤ 100.",
+ )
+ .into_response();
}
let text = format!(
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index faa8aac6616bc6ea2b102d08ae999c01a716ef6e..338feacc579a92bed291867cc0f2f62462737462 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -545,9 +545,14 @@ fn parse_block_prefixed_statement(
let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)
.ok_or_else(|| DslError::Parse(format!("invalid comparison near: {}", &s[i..])))?;
- if ratio_left == 0 && ratio_right == 0 {
+ if ratio_left == 0 || ratio_right == 0 {
return Err(DslError::Parse(
- "vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote".to_string(),
+ "vote ratio sides must be ≥ 1; use 1:1 for a tie or omit the vote".to_string(),
+ ));
+ }
+ if ratio_left > 100 || ratio_right > 100 {
+ return Err(DslError::Parse(
+ "vote ratio sides must be ≤ 100".to_string(),
));
}
i = skip_ws(s, k);
@@ -929,11 +934,50 @@ mod tests {
let err = parse_full("{tie placeholder}\n~/a 0:0 ~/b").unwrap_err();
let DslError::Parse(msg) = err;
assert!(
- msg.contains("0:0"),
- "expected 0:0 rejection message, got: {msg}"
+ msg.contains("≥ 1"),
+ "expected zero-side rejection message, got: {msg}"
);
}
+ #[test]
+ fn parse_vote_rejects_left_zero_ratio() {
+ let err = parse_full("{prefer b}\n~/a 0:5 ~/b").unwrap_err();
+ let DslError::Parse(msg) = err;
+ assert!(
+ msg.contains("≥ 1"),
+ "expected zero-side rejection message, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn parse_vote_rejects_right_zero_ratio() {
+ let err = parse_full("{prefer a}\n~/a 5:0 ~/b").unwrap_err();
+ let DslError::Parse(msg) = err;
+ assert!(
+ msg.contains("≥ 1"),
+ "expected zero-side rejection message, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn parse_vote_rejects_over_max_ratio() {
+ let err = parse_full("{prefer a strongly}\n~/a 101:1 ~/b").unwrap_err();
+ let DslError::Parse(msg) = err;
+ assert!(
+ msg.contains("≤ 100"),
+ "expected max ratio rejection message, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn parse_vote_accepts_max_ratio() {
+ let doc = parse_full("{prefer a}\n~/a 100:1 ~/b").unwrap();
+ assert!(matches!(
+ doc.statements.last(),
+ Some(Stmt::Vote { ratio_left: 100, ratio_right: 1, .. })
+ ));
+ }
+
#[test]
fn parse_full_interleaves_prose() {
let input = "hello\n#tag\nworld";
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index e949e8e092297eedd5c20c131f16ef0121f329b3..6841d35cfc9de2389f340a22b8a45acb335e36c3 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -119,11 +119,11 @@ impl GroupState {
let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
self.voted_pairs.insert((i, j));
- let mut w_a = vote.ratio_left.max(0) as f64;
- let mut w_b = vote.ratio_right.max(0) as f64;
- if w_a == 0.0 && w_b == 0.0 {
- w_a = 1.0;
- w_b = 1.0;
+ let w_a = vote.ratio_left as f64;
+ let w_b = vote.ratio_right as f64;
+ if w_a == 0.0 || w_b == 0.0 {
+ // Zero on either side produces no valid edge; drop the vote.
+ return;
}
self.add_edge_weight(b_idx, a_idx, w_a);
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index 9d83e7a97e2c7494790db17c4b5b30c181705026..cc8c1a0d139f3722ba6ecd13dd001c65be835b67 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -175,14 +175,14 @@ fn reducer_clamps_score_bounds() {
let mut state = ReducerState::default();
state.apply_event(ingest_event(
1,
- "@00000000-0000-0000-0000-000000000000:test:local/test\n~/t/a {a}\n~/t/b {b}\n{huge}\n~/t/a 1000:1 ~/t/b\n",
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n~/t/a {a}\n~/t/b {b}\n{huge}\n~/t/a 100:1 ~/t/b\n",
));
state.apply_event(ingest_event(
2,
- "@00000000-0000-0000-0000-000000000000:test:local/test\n{huge}\n~/t/a 1:1000 ~/t/b\n",
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n{huge}\n~/t/a 1:100 ~/t/b\n",
));
- assert_eq!(state.public().ranking_group.idx_to_item.len(), 2); // Should still work, scores clamped internally
+ assert_eq!(state.public().ranking_group.idx_to_item.len(), 2); // Should still work, scores handled internally
}
// ============================================================================
@@ -513,8 +513,8 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {
.expect_err("0:0 vote must be rejected by the parser");
let slugsocial_server::dsl::DslError::Parse(msg) = err;
assert!(
- msg.contains("0:0"),
- "expected message about invalid 0:0 ratio, got: {msg}"
+ msg.contains("≥ 1"),
+ "expected message about invalid zero ratio, got: {msg}"
);
let mut state = ReducerState::default();
@@ -533,7 +533,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {
#[test]
fn reducer_negative_ratio_clamped_to_zero() {
let _state = ReducerState::default();
- // GroupState::apply_vote clamps negatives to 0, then 0:0 -> 1:1
+ // GroupState::apply_vote clamps negatives to 0; when either side is 0 the vote is dropped.
let mut group = GroupState::new();
group.apply_vote(slugsocial_server::reducer::VoteData {
ts: 1,
@@ -546,12 +546,12 @@ fn reducer_negative_ratio_clamped_to_zero() {
delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
thread_tag: "t".to_string(),
});
+ // Items are registered, but the zero-clamped vote produces no edges.
assert_eq!(group.idx_to_item.len(), 2);
- // Both edges should exist (negatives clamped to 0, then 0:0 -> 1:1)
let a_idx = group.item_to_idx[&item_id("https://slug.social/~/t/a")];
let b_idx = group.item_to_idx[&item_id("https://slug.social/~/t/b")];
- assert!(group.edges.contains_key(&(a_idx, b_idx)));
- assert!(group.edges.contains_key(&(b_idx, a_idx)));
+ assert!(!group.edges.contains_key(&(a_idx, b_idx)));
+ assert!(!group.edges.contains_key(&(b_idx, a_idx)));
}
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 23db7b5672418d5bb0ab7529e05ef1f89e59062a..09c5481f039d59efb617a82bafcc432a186e2d30 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -256,3 +256,96 @@ async fn test_sse_public_thread_morph_includes_post_body_not_thread_not_found()
);
}
+fn ui_vote_compare_post_rpc(
+ room: &str,
+ thread_tag: &str,
+ left: &str,
+ right: &str,
+ ratio_left: &str,
+ ratio_right: &str,
+ explanation: &str,
+) -> String {
+ serde_json::json!({
+ "action": "vote_compare_post",
+ "room": room,
+ "thread_tag": thread_tag,
+ "left_item": left,
+ "right_item": right,
+ "ratio_left": ratio_left,
+ "ratio_right": ratio_right,
+ "explanation": explanation,
+ "next": "/vote",
+ })
+ .to_string()
+}
+
+#[tokio::test]
+async fn test_vote_compare_post_rejects_zero_left_ratio() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::new();
+ let bearer = test_bearer();
+
+ let rpc = ui_vote_compare_post_rpc("public", "test-vote", "~/a", "~/b", "0", "5", "prefer b");
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ js.contains("invalid ratio") || js.contains("≥ 1"),
+ "expected zero-ratio rejection, got: {js}"
+ );
+}
+
+#[tokio::test]
+async fn test_vote_compare_post_rejects_zero_right_ratio() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::new();
+ let bearer = test_bearer();
+
+ let rpc = ui_vote_compare_post_rpc("public", "test-vote", "~/a", "~/b", "5", "0", "prefer a");
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ js.contains("invalid ratio") || js.contains("≥ 1"),
+ "expected zero-ratio rejection, got: {js}"
+ );
+}
+
+#[tokio::test]
+async fn test_vote_compare_post_rejects_over_max_ratio() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::new();
+ let bearer = test_bearer();
+
+ let rpc =
+ ui_vote_compare_post_rpc("public", "test-vote", "~/a", "~/b", "101", "1", "prefer a");
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ js.contains("invalid ratio") || js.contains("≤ 100"),
+ "expected over-max ratio rejection, got: {js}"
+ );
+}
+
diff --git a/test/browser_vote_pool.clj b/test/browser_vote_pool.clj
index 23d0bd80b02bd8b1b48853454bed02793296550e..738608801027d1e662660b5d6c830b4ccd07c434 100644
--- a/test/browser_vote_pool.clj
+++ b/test/browser_vote_pool.clj
@@ -38,8 +38,8 @@
;; Set the hidden ratio inputs so the alphabetically-earlier item wins.
(defn- set-ratio! [pg left-text right-text]
(let [[rl rr] (if (neg? (compare (leaf left-text) (leaf right-text)))
- [100 0] ; left is earlier → prefer left
- [0 100])] ; right is earlier → prefer right
+ [99 1] ; left is earlier → prefer left
+ [1 99])] ; right is earlier → prefer right
(page/evaluate pg (str "document.getElementById('vote-ratio-left').value='" rl "'"))
(page/evaluate pg (str "document.getElementById('vote-ratio-right').value='" rr "'"))))
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.