Side A delivers a substantial, coherent feature: it moves entity fetching from implicit auto-fetch to explicit user-initiated fetch, persists the raw API payload as an event (EntityImported) for proper replay/audit, decouples Reddit API base URLs for testability, and adds fixtures plus an integration test proving end-to-end behavior—real architectural and correctness improvements. Side B is a smaller, well-scoped bugfix enforcing ratio bounds (1-100) with matching parser/reducer/handler changes and tests, which is valuable but narrower in scope and impact than A's persistence/event-sourcing and fetch-control redesign.
constitution · epochs · watch · epoch 3
c_e57094c6229a (tommy-mor) vs c_55666fe32c48 (tommy-mor)
download prompt · raw event · cmp_8baa3e03056853
council reasoning
A redesigns Reddit/entity import into a durable path: full payloads via EntityImported events, replay into entity_raw + derived views, explicit user-driven fetch UI, configurable API bases, and an end-to-end mock integration test—core lasting architecture. B correctly hardens vote ratios (≥1, ≤100) across DSL, UI, and reducer with solid tests, but it is a narrow constraint fix versus A’s broader durable design change.
Side A introduces a durable architectural improvement: it replaces automatic Reddit fetches with an explicit user-triggered fetch flow, persists full imported API payloads as `EntityImported` events for replay, stores raw entity data alongside derived views, adds configurable API endpoints, and includes replay/integration tests. Side B is a solid correctness fix that consistently enforces valid vote ratios (1–100) across the parser, UI handler, reducer, and tests, but its scope is limited to input validation and guarding invalid graph edges.
sides
A — c_e57094c6229a (tommy-mor)
message
[40b975bf] nice
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -222,6 +222,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1238,6 +1244,7 @@ version = "0.0.1"
dependencies = [
"axum",
"axum-extra",
+ "dotenvy",
"maud",
"reqwest",
"serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -17,6 +17,7 @@ tower-http = { version = "0.5", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.12", features = ["json"] }
+dotenvy = "0.15"
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,7 @@ use axum::{
use std::collections::HashMap;
use crate::{
- html::{input_panel, js_string_literal, ranking_panel, JsBuilder},
+ html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
path_types::ItemId,
reddit::ensure_partial_tree,
@@ -87,6 +87,20 @@ pub async fn post_ui_html(
.into_response()
}
},
+ HtmlUiAction::FetchEntity { item } => {
+ let id = parse_item_param(&item);
+ if id.is_root() {
+ return ui_js_warn("nothing to fetch for the root").into_response();
+ }
+ state.queue_entity_fetch(id.clone());
+ let tree = state.tree.read().await;
+ let empty = crate::reducer::NodeState::default();
+ let node = tree.get(&id).unwrap_or(&empty);
+ let panel = entity_section(&id, node, true);
+ JsBuilder::new()
+ .morph_selector("#entity-section", panel)
+ .into_response()
+ },
}
}
diff --git a/server/src/events.rs b/server/src/events.rs
index ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
+use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -18,4 +19,10 @@ pub enum Event {
},
/// Register a node path in the fractal tree (no external fetch).
NodeEnsured { id: String },
+ /// Full upstream API payload for a node (domain-specific view derived at replay/render time).
+ EntityImported {
+ id: String,
+ ts: i64,
+ payload: Value,
+ },
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -10,6 +10,7 @@ use crate::{
form_template::template_json_compact,
path_types::ItemId,
ranking::{top_bottom, RankedItem},
+ reddit::is_fetchable,
reducer::{GroupState, NodeState},
state::AppState,
ui_action::UI_RPC_FIELD,
@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
fn entity_panel(node: &NodeState) -> Markup {
html! {
@if let Some(data) = &node.data {
- section id="entity-panel" class="demo-panel entity-card" {
+ div id="entity-panel" class="entity-card" {
h2 { (data.title) }
@if let Some(author) = &data.author {
p class="muted small" { "by " (author) }
@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {
}
}
+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+ if !is_fetchable(item) {
+ return html! {};
+ }
+ let label = if fetching {
+ "Fetching…"
+ } else if has_data {
+ "Fetch more"
+ } else {
+ "Fetch from Reddit"
+ };
+ let rpc = template_json_compact(&serde_json::json!({
+ "action": "fetch_entity",
+ "item": item.as_str(),
+ }))
+ .expect("fetch_entity rpc template");
+ html! {
+ form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+ input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+ button type="submit" class="btn-secondary" disabled=(fetching) { (label) }
+ }
+ }
+}
+
+/// Entity card + explicit fetch control (morphed as `#entity-section`).
+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
+ let has_data = node.data.is_some();
+ html! {
+ section id="entity-section" class="demo-panel" {
+ (entity_panel(node))
+ (fetch_entity_panel(item, has_data, fetching))
+ }
+ }
+}
+
fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
html! {
@if !items.is_empty() {
@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
h1 { "sorter" }
(input_panel("", None))
(breadcrumb_path(&item))
- (entity_panel(node))
+ (entity_section(&item, node, false))
(ranking_panel(&item, group))
};
layout("sorter2", body, views)
@@ -272,16 +309,5 @@ pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse
pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
- if item.as_str().starts_with("reddit.com") {
- let needs_fetch = {
- let tree = state.tree.read().await;
- tree.get(&item)
- .map(|n| n.data.is_none())
- .unwrap_or(true)
- };
- if needs_fetch {
- state.reddit.request_fetch(item.clone());
- }
- }
item_page(state, uri, item).await
}
diff --git a/server/src/main.rs b/server/src/main.rs
index c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ if std::env::var("SORTER2_SKIP_DOTENV").is_err() {
+ let _ = dotenvy::dotenv();
+ }
+
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};
use reqwest::{header, Client, StatusCode};
use serde::Deserialize;
+use serde_json::Value;
use tokio::sync::{mpsc, RwLock};
use crate::{
+ event_log::EventLog,
+ events::Event,
+ html::now_ms,
path_types::ItemId,
- reducer::{EntityData, GlobalTree},
+ reducer::GlobalTree,
};
/// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.
@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
pub struct RedditCommand {
pub id: ItemId,
+ /// User-initiated fetch bypasses the in-memory "recently fetched" cache.
+ pub force: bool,
}
#[derive(Clone)]
@@ -33,19 +39,31 @@ struct RedditCredentials {
client_secret: String,
}
+#[derive(Clone)]
+pub struct RedditApiConfig {
+ pub api_base: String,
+ pub oauth_base: String,
+ pub user_agent: String,
+ creds: Option<RedditCredentials>,
+}
+
struct OAuthToken {
access_token: String,
expires_at: Instant,
}
impl RedditBroker {
- pub fn spawn(tree: Arc<RwLock<GlobalTree>>, user_agent: &str) -> Self {
+ pub fn spawn(
+ tree: Arc<RwLock<GlobalTree>>,
+ event_log: Arc<EventLog>,
+ config: RedditApiConfig,
+ ) -> Self {
let (tx, rx) = mpsc::channel(100);
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
- header::HeaderValue::from_str(user_agent).expect("valid user agent"),
+ header::HeaderValue::from_str(&config.user_agent).expect("valid user agent"),
);
let client = Client::builder()
@@ -54,22 +72,38 @@ impl RedditBroker {
.build()
.expect("reqwest client");
- let creds = RedditCredentials::from_env();
- tokio::spawn(reddit_worker(rx, tree, client, creds));
+ tokio::spawn(reddit_worker(rx, tree, event_log, client, config));
Self { tx }
}
- /// Fire-and-forget: queue a fetch; worker updates the tree when done.
- pub fn request_fetch(&self, id: ItemId) {
- let _ = self.tx.try_send(RedditCommand { id });
+ /// Queue a fetch; drops when the channel is full (backpressure).
+ pub fn request_fetch(&self, id: ItemId, force: bool) {
+ let _ = self.tx.try_send(RedditCommand { id, force });
+ }
+}
+
+impl RedditApiConfig {
+ pub fn from_env() -> Self {
+ Self {
+ api_base: reddit_api_base(),
+ oauth_base: reddit_oauth_base(),
+ user_agent: default_user_agent(),
+ creds: RedditCredentials::from_env(),
+ }
}
}
impl RedditCredentials {
+ /// Reddit's OAuth docs call these "client id" and "client secret"; the app
+ /// registration UI often labels them "app id" / "app secret" — same values.
fn from_env() -> Option<Self> {
- let client_id = std::env::var("REDDIT_CLIENT_ID").ok()?;
- let client_secret = std::env::var("REDDIT_CLIENT_SECRET").ok()?;
+ let client_id = std::env::var("REDDIT_CLIENT_ID")
+ .or_else(|_| std::env::var("REDDIT_APP_ID"))
+ .ok()?;
+ let client_secret = std::env::var("REDDIT_CLIENT_SECRET")
+ .or_else(|_| std::env::var("REDDIT_APP_SECRET"))
+ .ok()?;
if client_id.is_empty() || client_secret.is_empty() {
return None;
}
@@ -80,29 +114,63 @@ impl RedditCredentials {
}
}
+pub fn reddit_api_base() -> String {
+ std::env::var("REDDIT_API_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
+pub fn reddit_oauth_base() -> String {
+ std::env::var("REDDIT_OAUTH_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
pub fn default_user_agent() -> String {
std::env::var("REDDIT_USER_AGENT").unwrap_or_else(|_| {
"web:sorter2.social:v0.0.1 (by /u/sorter2)".to_string()
})
}
+/// True when this node can be loaded from the Reddit JSON API.
+pub fn is_fetchable(id: &ItemId) -> bool {
+ !map_item_to_reddit_api(id, "https://example.com").is_empty()
+}
+
+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).
+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option<crate::reducer::EntityData> {
+ if id.as_str().starts_with("reddit.com") {
+ return parse_reddit_view(id, payload);
+ }
+ None
+}
+
+/// Apply a full API payload to the in-memory tree (view derived for known domains).
+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {
+ let view = entity_view_from_payload(id, &payload);
+ tree.apply_entity_raw(id, payload, view);
+}
+
async fn red
… preview truncated; 22,673 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.