{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[2bc302c3] refactor\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 06001212820101e0cc953d3687dea64f85e60787..d2f9769108def7ca2c5857aec8b4319426188a66 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -47,7 +47,7 @@ pub async fn post_ui_html(\n ratio_left,\n ratio_right,\n scope,\n- next,\n+ vote_compare,\n } => {\n let parent = parent_from_scope(&scope);\n if let Err(e) = state\n@@ -57,14 +57,12 @@ pub async fn post_ui_html(\n return ui_js_warn(&e).into_response();\n }\n let tree = state.tree.read().await;\n- if !next.trim().is_empty() {\n+ if vote_compare {\n+ let left = parse_item_param(&a);\n+ let right = parse_item_param(&b);\n+ let morph = crate::html::vote::vote_recorded_morph(&tree, &parent, &left, &right);\n drop(tree);\n- return JsBuilder::new()\n- .raw(&format!(\n- \"window.location.href={};\",\n- js_string_literal(next.trim())\n- ))\n- .into_response();\n+ return morph.into_response();\n }\n let empty = crate::reducer::NodeState::default();\n let node = tree.get(&parent).unwrap_or(&empty);\n@@ -137,7 +135,7 @@ mod tests {\n ratio_left: 3,\n ratio_right: 1,\n scope: String::new(),\n- next: String::new(),\n+ vote_compare: false,\n }\n );\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 61cdbc094819ddedb755572c59456ec0d6617619..cd578a5fea46a9a1d49e238d08a80c2caf18708d 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -65,6 +65,15 @@ impl JsBuilder {\n self\n }\n \n+ pub(crate) fn morph_inner_selector(mut self, selector: &str, markup: Markup) -> Self {\n+ let html = js_string_literal(&markup.into_string());\n+ self.snippets.push(format!(\n+ \"var __el = document.querySelector({sel}); if (__el) {{ Idiomorph.morph(__el, {html}, {{ morphStyle: 'innerHTML' }}); }}\",\n+ sel = js_string_literal(selector),\n+ ));\n+ self\n+ }\n+\n pub(crate) fn raw(mut self, js: &str) -> Self {\n if !js.is_empty() {\n self.snippets.push(js.to_string());\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..89ed621ad861214e147add2062224fc4137ff8ad\n--- /dev/null\n+++ b/server/src/html/vote.rs\n@@ -0,0 +1,306 @@\n+//! Pairwise vote UI — `/vote?parent=` with optional `left` / `right`.\n+\n+use axum::{\n+ extract::{Query, State},\n+ response::{Html, IntoResponse},\n+};\n+use maud::{html, Markup};\n+use serde::Deserialize;\n+\n+use crate::{\n+ form_template::template_json_compact,\n+ html::JsBuilder,\n+ pair::{children_of, resolve_pair, suggest_next_pair_in_pool},\n+ path_types::ItemId,\n+ reducer::{GlobalTree, GroupState, NodeState, VoteData},\n+ state::{parse_item_param, AppState},\n+ ui_action::UI_RPC_FIELD,\n+};\n+\n+use super::{breadcrumb_path, item_href, layout};\n+\n+#[derive(Debug, Deserialize)]\n+pub struct VoteQuery {\n+ pub parent: String,\n+ #[serde(default)]\n+ pub left: Option,\n+ #[serde(default)]\n+ pub right: Option,\n+}\n+\n+pub fn vote_href(parent: &ItemId) -> String {\n+ format!(\n+ \"/vote?parent={}\",\n+ urlencoding::encode(parent.as_str())\n+ )\n+}\n+\n+fn vote_compare_href(parent: &ItemId, left: &ItemId, right: &ItemId) -> String {\n+ format!(\n+ \"/vote?parent={}&left={}&right={}\",\n+ urlencoding::encode(parent.as_str()),\n+ urlencoding::encode(left.as_str()),\n+ urlencoding::encode(right.as_str()),\n+ )\n+}\n+\n+fn display_label(id: &ItemId) -> String {\n+ id.segments()\n+ .last()\n+ .map_or(\"item\".into(), |v| v.to_string())\n+}\n+\n+fn child_title(tree: &GlobalTree, id: &ItemId) -> String {\n+ tree.get(id)\n+ .and_then(|n| n.data.as_ref())\n+ .map(|d| d.title.clone())\n+ .unwrap_or_else(|| display_label(id))\n+}\n+\n+fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {\n+ let l = ratio_left.max(0) as f64;\n+ let r = ratio_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ 50.0\n+ } else {\n+ (l / sum) * 100.0\n+ }\n+}\n+\n+fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {\n+ match (v.a.as_str(), v.b.as_str()) {\n+ (a, b) if a == page_left.as_str() && b == page_right.as_str() => {\n+ (v.ratio_left, v.ratio_right)\n+ }\n+ (a, b) if a == page_right.as_str() && b == page_left.as_str() => {\n+ (v.ratio_right, v.ratio_left)\n+ }\n+ _ => (v.ratio_left, v.ratio_right),\n+ }\n+}\n+\n+fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec {\n+ group\n+ .recent_votes\n+ .iter()\n+ .filter(|v| {\n+ (v.a.as_str() == left.as_str() && v.b.as_str() == right.as_str())\n+ || (v.a.as_str() == right.as_str() && v.b.as_str() == left.as_str())\n+ })\n+ .cloned()\n+ .collect()\n+}\n+\n+fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {\n+ let mut votes = edge_votes(group, left, right);\n+ votes.sort_by(|a, b| b.ts.cmp(&a.ts));\n+ let legend_left = child_title(tree, left);\n+ let legend_right = child_title(tree, right);\n+ html! {\n+ @if votes.is_empty() {\n+ p class=\"muted vote-edge-empty\" { \"no votes on this pair yet\" }\n+ } @else {\n+ h3 class=\"vote-edge-history-title\" {\n+ \"votes on this pair\"\n+ span class=\"vote-edge-history-axis muted\" { \" · \" (legend_left) \" : \" (legend_right) }\n+ }\n+ ul class=\"vote-edge-history\" {\n+ @for v in &votes {\n+ @let (r_left, r_right) = ratios_for_page(v, left, right);\n+ @let pct = ratio_pct(r_left, r_right);\n+ li class=\"vote-edge-history-row\" {\n+ div class=\"vote-edge-meta\" {\n+ span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ }\n+ div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n+ div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n+ div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+fn vote_back_nav(parent: &ItemId) -> Markup {\n+ html! {\n+ div class=\"vote-compare-nav\" {\n+ a class=\"vote-compare-back muted\" href=(item_href(parent)) { \"← back to \" (display_label(parent)) }\n+ }\n+ }\n+}\n+\n+fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Markup {\n+ let next_href = next.map(|(l, r)| vote_compare_href(parent, l, r));\n+ html! {\n+ div id=\"vote-compare-actions\" class=\"vote-compare-actions\" {\n+ button type=\"submit\" class=\"btn-primary\" data-testid=\"vote-post\" { \"post vote\" }\n+ @if let Some(href) = &next_href {\n+ a class=\"btn-secondary vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+ } @else {\n+ span class=\"btn-secondary vote-compare-next is-disabled\" { \"no next pair\" }\n+ }\n+ }\n+ }\n+}\n+\n+/// After recording a vote on the compare page: refresh edge history and next-pair link.\n+pub(crate) fn vote_recorded_morph(\n+ tree: &GlobalTree,\n+ parent: &ItemId,\n+ left: &ItemId,\n+ right: &ItemId,\n+) -> JsBuilder {\n+ let pool = children_of(tree, parent);\n+ let empty = NodeState::default();\n+ let group = tree\n+ .get(parent)\n+ .unwrap_or(&empty)\n+ .local_ranking\n+ .clone();\n+ let edge_history = vote_edge_history(tree, &group, left, right);\n+ let next_pair = suggest_next(&group, left, right, &pool);\n+ let actions = vote_compare_actions(parent, next_pair.as_ref());\n+ JsBuilder::new()\n+ .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .morph_selector(\"#vote-compare-actions\", actions)\n+}\n+\n+fn vote_compare_item_card(tree: &GlobalTree, item: &ItemId, side_class: &str) -> Markup {\n+ let href = item_href(item);\n+ let title = child_title(tree, item);\n+ html! {\n+ div class=(format!(\"vote-compare-side {side_class}\")) {\n+ a class=(format!(\"vote-compare-item {side_class}\")) href=(href) {\n+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, item, &href) {\n+ (row)\n+ } @else {\n+ strong { (title) }\n+ }\n+ }\n+ @if let Some(node) = tree.get(item) {\n+ @if crate::render::reddit::is_reddit_post(item) {\n+ @if let Some(data) = &node.data {\n+ @if let Some(src) = data.image_url.as_ref().or(data.thumb_url.as_ref()) {\n+ figure class=\"vote-compare-figure\" {\n+ img class=\"vote-compare-image\" src=(src) alt=\"\" loading=\"lazy\";\n+ }\n+ }\n+ @if let Some(author) = &data.author {\n+ p class=\"muted small\" { \"by \" (author) }\n+ }\n+ }\n+ } @else if let Some(data) = &node.data {\n+ @if let Some(body) = &data.body_html {\n+ div class=\"vote-compare-item-body\" {\n+ (maud::PreEscaped(body))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+\n+fn suggest_next(group: &GroupState, left: &ItemId, right: &ItemId, pool: &[ItemId]) -> Option<(ItemId, ItemId)> {\n+ suggest_next_pair_in_pool(group, pool, Some((left, right)))\n+}\n+\n+pub async fn vote_page(\n+ State(state): State,\n+ Query(q): Query,\n+) -> impl IntoResponse {\n+ let parent = parse_item_param(&q.parent);\n+ let left_param = q.left.as_deref().map(parse_item_param);\n+ let right_param = q.right.as_deref().map(parse_item_param);\n+\n+ let tree = state.tree.read().await;\n+ let empty = NodeState::default();\n+ let parent_node = tree.get(&parent).unwrap_or(&empty);\n+\n+ let (left, right) = match resolve_pair(\n+ &tree,\n+ &parent,\n+ left_param.as_ref(),\n+ right_param.as_ref(),\n+ ) {\n+ Ok(p) => p,\n+ Err(e) => {\n+ let (msg, status) = e.status_message();\n+ return (status, msg).into_response();\n+ }\n+ };\n+\n+ let pool = children_of(&tree, &parent);\n+ let group = &parent_node.local_ranking;\n+ let next_pair = suggest_next(group, &left, &right, &pool);\n+ let edge_history = vote_edge_history(&tree, group, &left, &right);\n+\n+ let rpc_json = template_json_compact(&serde_json::json!({\n+ \"action\": \"record_vote\",\n+ \"a\": left.as_str(),\n+ \"b\": right.as_str(),\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"},\n+ \"scope\": parent.as_str(),\n+ \"vote_compare\": true,\n+ }))\n+ .expect(\"vote rpc json\");\n+\n+ let title = format!(\n+ \"vote — {} vs {}\",\n+ child_title(&tree, &left),\n+ child_title(&tree, &right)\n+ );\n+\n+ let body = html! {\n+ section class=\"vote-compare-shell\" {\n+ h1 { \"compare\" }\n+ (breadcrumb_path(&parent))\n+ p class=\"muted vote-compare-scope\" {\n+ \"ranking children of \"\n+ a href=(item_href(&parent)) { (child_title(&tree, &parent)) }\n+ }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(&tree, &left, \"vote-compare-left\"))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n+ }\n+ (vote_back_nav(&parent))\n+ div id=\"vote-edge-history-region\" {\n+ (edge_history)\n+ }\n+ form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+ input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+ label class=\"vote-compare-slider-label\" {\n+ span id=\"vote-slider-left-label\" { (child_title(&tree, &left)) }\n+ input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\";\n+ span id=\"vote-slider-right-label\" { (child_title(&tree, &right)) }\n+ }\n+ (vote_compare_actions(&parent, next_pair.as_ref()))\n+ }\n+ }\n+ };\n+\n+ drop(tree);\n+\n+ let path = format!(\"/vote?parent={}\", urlencoding::encode(parent.as_str()));\n+ state.views.increment(path.clone());\n+ let views = state.views.get_views(&path);\n+\n+ Html(\n+ layout(\n+ &title,\n+ body,\n+ views,\n+ )\n+ .into_string(),\n+ )\n+ .into_response()\n+}\ndiff --git a/server/src/pair.rs b/server/src/pair.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..606ffa51038a57ffacf335aa48bdb1185f8483fb\n--- /dev/null\n+++ b/server/src/pair.rs\n@@ -0,0 +1,340 @@\n+//! Pick two children of a parent scope for pairwise voting.\n+//!\n+//! Pair selection prefers **bridge** votes — comparisons between items in\n+//! different connected components of the voted-pairs graph — so the pool\n+//! merges into one ranking group before refining within it.\n+\n+use rand::seq::SliceRandom;\n+use std::collections::{HashMap, HashSet};\n+\n+use crate::{\n+ path_types::ItemId,\n+ ranking::connected_components_from_voted_pairs,\n+ reducer::{GlobalTree, GroupState},\n+};\n+\n+fn pairs_match(a: &ItemId, b: &ItemId, x: &ItemId, y: &ItemId) -> bool {\n+ (a == x && b == y) || (a == y && b == x)\n+}\n+\n+fn pair_is_voted(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {\n+ let Some(&ai) = group.item_to_idx.get(a) else {\n+ return false;\n+ };\n+ let Some(&bi) = group.item_to_idx.get(b) else {\n+ return false;\n+ };\n+ let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) };\n+ group.voted_pairs.contains(&(i, j))\n+}\n+\n+/// Component id per pool item: voted-pairs graph components plus one id per\n+/// never-voted child.\n+fn component_ids(group: &GroupState, pool: &[ItemId]) -> HashMap {\n+ let n = group.idx_to_item.len();\n+ let (comps, isolates) =\n+ connected_components_from_voted_pairs(n, group.voted_pairs.iter().copied());\n+\n+ let mut out: HashMap = HashMap::new();\n+ for (comp_idx, comp) in comps.iter().enumerate() {\n+ for &idx in comp {\n+ if idx < n {\n+ out.insert(group.idx_to_item[idx].clone(), comp_idx);\n+ }\n+ }\n+ }\n+ let mut next = comps.len();\n+ for &idx in &isolates {\n+ if idx < n {\n+ out.insert(group.idx_to_item[idx].clone(), next);\n+ next += 1;\n+ }\n+ }\n+ for item in pool {\n+ out.entry(item.clone()).or_insert_with(|| {\n+ let id = next;\n+ next += 1;\n+ id\n+ });\n+ }\n+ out\n+}\n+\n+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]\n+enum PairPriority {\n+ /// Unvoted edge between two components — grows the ranking group.\n+ BridgeUnvoted = 0,\n+ /// Unvoted edge inside one component — refines order.\n+ WithinUnvoted = 1,\n+ /// Re-vote across components (rare once merged).\n+ BridgeVoted = 2,\n+ /// Re-vote within a component.\n+ WithinVoted = 3,\n+}\n+\n+fn pair_priority(\n+ group: &GroupState,\n+ components: &HashMap,\n+ a: &ItemId,\n+ b: &ItemId,\n+) -> PairPriority {\n+ let voted = pair_is_voted(group, a, b);\n+ let bridge = components.get(a) != components.get(b);\n+ match (bridge, voted) {\n+ (true, false) => PairPriority::BridgeUnvoted,\n+ (false, false) => PairPriority::WithinUnvoted,\n+ (true, true) => PairPriority::BridgeVoted,\n+ (false, true) => PairPriority::WithinVoted,\n+ }\n+}\n+\n+/// All unordered pairs from `pool`, optionally skipping `exclude`.\n+fn candidate_pairs(\n+ pool: &[ItemId],\n+ exclude: Option<(&ItemId, &ItemId)>,\n+) -> Vec<(ItemId, ItemId)> {\n+ let mut out = Vec::new();\n+ for i in 0..pool.len() {\n+ for j in (i + 1)..pool.len() {\n+ let a = &pool[i];\n+ let b = &pool[j];\n+ if a == b {\n+ continue;\n+ }\n+ if exclude.is_some_and(|(x, y)| pairs_match(a, b, x, y)) {\n+ continue;\n+ }\n+ out.push((a.clone(), b.clone()));\n+ }\n+ }\n+ out\n+}\n+\n+/// Pick the next pair to vote on within `pool`.\n+///\n+/// 1. Prefer unvoted **bridge** pairs (connect separate ranking components).\n+/// 2. Then unvoted within-component pairs (refinement).\n+/// 3. Then already-voted pairs (re-compare).\n+pub fn suggest_next_pair_in_pool(\n+ group: &GroupState,\n+ pool: &[ItemId],\n+ exclude: Option<(&ItemId, &ItemId)>,\n+) -> Option<(ItemId, ItemId)> {\n+ let candidates = candidate_pairs(pool, exclude);\n+ if candidates.is_empty() {\n+ return None;\n+ }\n+ let components = component_ids(group, pool);\n+ let best = candidates\n+ .iter()\n+ .map(|(a, b)| (pair_priority(group, &components, a, b), (a, b)))\n+ .min_by_key(|(p, _)| *p)?\n+ .0;\n+ let best_pairs: Vec<(ItemId, ItemId)> = candidates\n+ .into_iter()\n+ .filter(|(a, b)| pair_priority(group, &components, a, b) == best)\n+ .collect();\n+ best_pairs.choose(&mut rand::thread_rng()).cloned()\n+}\n+\n+/// Random distinct pair from `children` (legacy pair.rs behavior).\n+pub fn random_pair(children: &[ItemId]) -> Option<(ItemId, ItemId)> {\n+ if children.len() < 2 {\n+ return None;\n+ }\n+ let left = children.choose(&mut rand::thread_rng())?;\n+ let mut right = children.choose(&mut rand::thread_rng())?;\n+ let mut guard = 0;\n+ while left == right && guard < 32 {\n+ right = children.choose(&mut rand::thread_rng())?;\n+ guard += 1;\n+ }\n+ if left == right {\n+ return None;\n+ }\n+ Some((left.clone(), right.clone()))\n+}\n+\n+/// Sorted children of `parent` from the global tree.\n+pub fn children_of(tree: &GlobalTree, parent: &ItemId) -> Vec {\n+ let Some(node) = tree.get(parent) else {\n+ return Vec::new();\n+ };\n+ let mut children: Vec = node.children.iter().cloned().collect();\n+ children.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n+ children\n+}\n+\n+/// Resolve a pair to compare under `parent`.\n+pub fn resolve_pair(\n+ tree: &GlobalTree,\n+ parent: &ItemId,\n+ left: Option<&ItemId>,\n+ right: Option<&ItemId>,\n+) -> Result<(ItemId, ItemId), PairError> {\n+ let children = children_of(tree, parent);\n+ if children.len() < 2 {\n+ return Err(PairError::TooFewChildren);\n+ }\n+ let child_set: HashSet<_> = children.iter().collect();\n+\n+ match (left, right) {\n+ (Some(l), Some(r)) => {\n+ if l == r {\n+ return Err(PairError::SameItem);\n+ }\n+ if !child_set.contains(l) || !child_set.contains(r) {\n+ return Err(PairError::NotChild);\n+ }\n+ Ok((l.clone(), r.clone()))\n+ }\n+ (None, None) => {\n+ let group = tree\n+ .get(parent)\n+ .map(|n| &n.local_ranking)\n+ .cloned()\n+ .unwrap_or_default();\n+ suggest_next_pair_in_pool(&group, &children, None).ok_or(PairError::NoPair)\n+ }\n+ _ => Err(PairError::IncompletePair),\n+ }\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub enum PairError {\n+ TooFewChildren,\n+ SameItem,\n+ NotChild,\n+ IncompletePair,\n+ NoPair,\n+}\n+\n+impl PairError {\n+ pub fn status_message(&self) -> (&'static str, axum::http::StatusCode) {\n+ match self {\n+ Self::TooFewChildren => (\n+ \"parent needs at least 2 children to vote\",\n+ axum::http::StatusCode::BAD_REQUEST,\n+ ),\n+ Self::SameItem => (\n+ \"left and right must differ\",\n+ axum::http::StatusCode::BAD_REQUEST,\n+ ),\n+ Self::NotChild => (\n+ \"left and right must be children of parent\",\n+ axum::http::StatusCode::BAD_REQUEST,\n+ ),\n+ Self::IncompletePair => (\n+ \"provide both left and right, or neither\",\n+ axum::http::StatusCode::BAD_REQUEST,\n+ ),\n+ Self::NoPair => (\n+ \"no pair available\",\n+ axum::http::StatusCode::BAD_REQUEST,\n+ ),\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::reducer::{GlobalTree, VoteData};\n+\n+ fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {\n+ let mut tree = GlobalTree::new();\n+ tree.ensure_path(parent);\n+ for id in ids {\n+ let child = ItemId::parse(id).unwrap();\n+ tree.ensure_path(&child);\n+ if let Some(p) = tree.nodes.get_mut(parent) {\n+ p.children.insert(child);\n+ }\n+ }\n+ tree\n+ }\n+\n+ fn pair_set(pair: &(ItemId, ItemId)) -> HashSet<&str> {\n+ [pair.0.as_str(), pair.1.as_str()].into_iter().collect()\n+ }\n+\n+ #[test]\n+ fn suggest_prefers_unvoted_pair() {\n+ let parent = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let mut tree = seed_children(\n+ &parent,\n+ &[\n+ \"reddit.com/r/rust/a\",\n+ \"reddit.com/r/rust/b\",\n+ \"reddit.com/r/rust/c\",\n+ ],\n+ );\n+ let vote =\n+ VoteData::from_recorded(1, \"reddit.com/r/rust/a\", \"reddit.com/r/rust/b\", 2, 1).unwrap();\n+ tree.apply_vote(&parent, vote);\n+ let group = tree.get(&parent).unwrap().local_ranking.clone();\n+ let pool = children_of(&tree, &parent);\n+ let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n+ let voted_ab = (l.as_str() == \"reddit.com/r/rust/a\" && r.as_str() == \"reddit.com/r/rust/b\")\n+ || (l.as_str() == \"reddit.com/r/rust/b\" && r.as_str() == \"reddit.com/r/rust/a\");\n+ assert!(!voted_ab);\n+ }\n+\n+ #[test]\n+ fn suggest_bridges_separate_components() {\n+ let parent = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let mut tree = seed_children(\n+ &parent,\n+ &[\n+ \"reddit.com/r/rust/a\",\n+ \"reddit.com/r/rust/b\",\n+ \"reddit.com/r/rust/c\",\n+ \"reddit.com/r/rust/d\",\n+ ],\n+ );\n+ let ab = VoteData::from_recorded(1, \"reddit.com/r/rust/a\", \"reddit.com/r/rust/b\", 2, 1).unwrap();\n+ let cd = VoteData::from_recorded(2, \"reddit.com/r/rust/c\", \"reddit.com/r/rust/d\", 2, 1).unwrap();\n+ tree.apply_vote(&parent, ab);\n+ tree.apply_vote(&parent, cd);\n+ let group = tree.get(&parent).unwrap().local_ranking.clone();\n+ let pool = children_of(&tree, &parent);\n+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n+ let chosen = pair_set(&pair);\n+ let from_ab = chosen.contains(\"reddit.com/r/rust/a\") || chosen.contains(\"reddit.com/r/rust/b\");\n+ let from_cd = chosen.contains(\"reddit.com/r/rust/c\") || chosen.contains(\"reddit.com/r/rust/d\");\n+ assert!(from_ab && from_cd, \"expected bridge pair, got {:?}\", chosen);\n+ }\n+\n+ #[test]\n+ fn suggest_connects_isolate_to_existing_component() {\n+ let parent = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let mut tree = seed_children(\n+ &parent,\n+ &[\n+ \"reddit.com/r/rust/a\",\n+ \"reddit.com/r/rust/b\",\n+ \"reddit.com/r/rust/c\",\n+ ],\n+ );\n+ let ab = VoteData::from_recorded(1, \"reddit.com/r/rust/a\", \"reddit.com/r/rust/b\", 2, 1).unwrap();\n+ tree.apply_vote(&parent, ab);\n+ let group = tree.get(&parent).unwrap().local_ranking.clone();\n+ let pool = children_of(&tree, &parent);\n+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n+ let chosen = pair_set(&pair);\n+ assert!(chosen.contains(\"reddit.com/r/rust/c\"));\n+ assert!(chosen.contains(\"reddit.com/r/rust/a\") || chosen.contains(\"reddit.com/r/rust/b\"));\n+ }\n+\n+ #[test]\n+ fn resolve_pair_picks_from_pool() {\n+ let parent = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let tree = seed_children(&parent, &[\"reddit.com/r/rust/a\", \"reddit.com/r/rust/b\"]);\n+ let pair = resolve_pair(&tree, &parent, None, None).unwrap();\n+ let pool: HashSet<_> = [\"reddit.com/r/rust/a\", \"reddit.com/r/rust/b\"]\n+ .into_iter()\n+ .collect();\n+ assert!(pool.contains(pair.0.as_str()));\n+ assert!(pool.contains(pair.1.as_str()));\n+ }\n+}\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nindex 12dce9888f5cd4e1a0974d12d6468368d0f775b9..a0d1a028b8bd3c5d1c714dea3ec597d1ac39e1bc 100644\n--- a/server/src/path_types.rs\n+++ b/server/src/path_types.rs\n@@ -40,6 +40,22 @@ impl ItemId {\n Self::canonicalize(raw_url).map(Self)\n }\n \n+ /// Normalize strings from forms, events, and Reddit imports into the same\n+ /// stored id shape (e.g. drop post title slug after comment id).\n+ pub fn from_storage(s: &str) -> Option {\n+ let t = s.trim();\n+ if t.is_empty() {\n+ return None;\n+ }\n+ if t.contains(\"://\") || t.starts_with(\"r/\") {\n+ return Self::from_url(t).or_else(|| Self::parse(t));\n+ }\n+ if t.starts_with(\"reddit.com/\") && t.contains(\"/comments/\") {\n+ return Self::from_url(t).or_else(|| Self::parse(t));\n+ }\n+ Self::parse(t).or_else(|| Self::from_url(t))\n+ }\n+\n /// Map legacy scope keys (`\"\"`, `\"rust\"`) to fractal parent nodes.\n pub fn from_legacy_scope(raw: &str) -> Self {\n let s = raw.trim();\n@@ -324,6 +340,12 @@ mod tests {\n assert_eq!(id.as_str(), \"reddit.com/r/amitheasshole\");\n }\n \n+ #[test]\n+ fn from_storage_strips_post_title_slug() {\n+ let id = ItemId::from_storage(\"reddit.com/r/rust/comments/aaa/announcing_rust_199\").unwrap();\n+ assert_eq!(id.as_str(), \"reddit.com/r/rust/comments/aaa\");\n+ }\n+\n #[test]\n fn from_browse_uri_strips_prefix() {\n let id = ItemId::from_browse_uri(\"/~/https://reddit.com/r/rust\").unwrap();\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 1168ec2afc77c092514eec91b513bac301cbe225..626454e5a2f638734193b5190a2beea286af85b6 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -567,7 +567,7 @@ fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {\n _ => continue,\n };\n let path = format!(\"reddit.com{}\", permalink.trim_end_matches('/'));\n- if let Some(id) = ItemId::parse(&path) {\n+ if let Some(id) = ItemId::from_storage(&path) {\n out.push((id, child.clone()));\n }\n }\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 336e78b77d3ab59361b89af5c9868e13da4ac962..fc23f41137d7df33997afe19c533505c250cc305 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -28,8 +28,8 @@ impl VoteData {\n ratio_left: i32,\n ratio_right: i32,\n ) -> Option {\n- let a = ItemId::parse(a)?;\n- let b = ItemId::parse(b)?;\n+ let a = ItemId::from_storage(a)?;\n+ let b = ItemId::from_storage(b)?;\n if a == b {\n return None;\n }\n@@ -85,8 +85,8 @@ impl GroupState {\n }\n \n pub fn apply_vote(&mut self, mut vote: VoteData) {\n- vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n- vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n+ vote.a = ItemId::from_storage(vote.a.as_str()).unwrap_or(vote.a.clone());\n+ vote.b = ItemId::from_storage(vote.b.as_str()).unwrap_or(vote.b.clone());\n if vote.ratio_left < 0 {\n vote.ratio_left = 0;\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex d238701208a1c708b94a778a6a2e1891a678ecbc..4c3008e73cc74d8483a2064a0a280e73d82a01ec 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -18,7 +18,7 @@ pub fn parse_item_param(raw: &str) -> ItemId {\n if s.is_empty() {\n return ItemId::root();\n }\n- ItemId::from_url(s).or_else(|| ItemId::parse(s)).unwrap_or_else(|| ItemId::opaque(s))\n+ ItemId::from_storage(s).unwrap_or_else(|| ItemId::opaque(s))\n }\n \n /// Legacy: normalize raw ranking subject into a scope key for old event replay.\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 53581e1362bfcc5dfb4ae3069c41ea6f7be41437..237411a689fb10ad1b7022ea66aee1969c7507ec 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -31,9 +31,9 @@ pub enum HtmlUiAction {\n /// Parent node [`ItemId`] string; empty = tree root.\n #[serde(default)]\n scope: String,\n- /// After vote, navigate here (vote compare page).\n+ /// Posted from `/vote` compare UI — morph edge history in place.\n #[serde(default)]\n- next: String,\n+ vote_compare: bool,\n },\n /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n@@ -98,7 +98,7 @@ mod tests {\n ratio_left: 60,\n ratio_right: 40,\n scope: \"parent\".into(),\n- next: String::new(),\n+ vote_compare: false,\n }\n );\n }\n@@ -129,7 +129,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: \"amitheasshole\".into(),\n- next: String::new(),\n+ vote_compare: false,\n }\n );\n }\n@@ -156,7 +156,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: String::new(),\n- next: String::new(),\n+ vote_compare: false,\n }\n );\n }\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 3c2cec5cb5094013387147c621d75bd341bc06cc..b95718ce463b200a5667d3014dac2119836a0bef 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -289,11 +289,27 @@ h1 {\n }\n \n .vote-compare-nav {\n+ margin: 1rem 0 0.5rem;\n+}\n+\n+.vote-compare-actions {\n display: flex;\n- justify-content: space-between;\n+ flex-wrap: wrap;\n align-items: center;\n- gap: 1rem;\n- margin: 1rem 0;\n+ gap: 0.75rem;\n+ margin-top: 0.5rem;\n+}\n+\n+.vote-compare-actions .btn-secondary {\n+ margin-top: 0;\n+ display: inline-block;\n+ text-decoration: none;\n+ line-height: 1.4;\n+}\n+\n+.vote-compare-actions .vote-compare-next.is-disabled {\n+ opacity: 0.6;\n+ cursor: default;\n }\n \n .vote-compare-next {\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex c8d7c3ef2413399ced7898a7edb3e7a2c8e16163..ab15d27f508cc6f5fbca9d8926fd0ca26d64857b 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -104,11 +104,10 @@\n if (f.getAttribute('data-navigate') === 'full') return;\n e.preventDefault();\n await postUiForm(f);\n- if (f.id === 'vote-form' || f.id === 'vote-compare-form') {\n+ if (f.id === 'vote-form') {\n f.reset();\n- var slider = f.querySelector('#vote-preference-slider');\n- if (slider) slider.value = '50';\n- initVoteSlider();\n+ var firstField = f.querySelector('input[type=\"text\"]');\n+ if (firstField) firstField.focus();\n }\n });\n }\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex df7d9ab357531c5146d0b326c149fdaa6b531a4a..56d2db313b313ffce07eff38a8ccdaa2fbb9498f 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -25,6 +25,53 @@ async fn start_test_server() -> (SocketAddr, TempDir) {\n (addr, tmp)\n }\n \n+#[tokio::test]\n+async fn post_ui_vote_compare_morphs_edge_history() {\n+ let (addr, _tmp) = start_test_server().await;\n+ let parent = \"reddit.com/r/rust\";\n+ let a = \"reddit.com/r/rust/comments/aaa/announcing_rust_199\";\n+ let b = \"reddit.com/r/rust/comments/bbb/what_are_you_working_on\";\n+\n+ let rpc = serde_json::json!({\n+ \"action\": \"record_vote\",\n+ \"a\": a,\n+ \"b\": b,\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"},\n+ \"scope\": parent,\n+ \"vote_compare\": true,\n+ })\n+ .to_string();\n+ let mut form = HashMap::new();\n+ form.insert(UI_RPC_FIELD.to_string(), rpc);\n+ form.insert(\"ratio_left\".into(), \"70\".into());\n+ form.insert(\"ratio_right\".into(), \"30\".into());\n+\n+ let client = reqwest::Client::new();\n+ let body = client\n+ .post(format!(\"http://{addr}/ui\"))\n+ .form(&form)\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+\n+ assert!(\n+ body.contains(\"vote-edge-history\"),\n+ \"expected edge history morph, got: {body}\"\n+ );\n+ assert!(\n+ body.contains(\"70:30\"),\n+ \"expected recorded ratio in morph, got: {body}\"\n+ );\n+ assert!(\n+ !body.contains(\"no votes on this pair yet\"),\n+ \"should not show empty edge history after vote, got: {body}\"\n+ );\n+}\n+\n #[tokio::test]\n async fn post_ui_record_vote_morphs_ranking_and_persists() {\n let (addr, tmp) = start_test_server().await;\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nindex 45a2a19f20799d77e84d8aa64735ab5e7e45f97c..b476488526252c13fd73bdda76e5201678e4a714 100644\n--- a/test/reddit_import.clj\n+++ b/test/reddit_import.clj\n@@ -2,9 +2,8 @@\n (:require [babashka.process :as process]\n [clojure.java.io :as io]\n [clojure.string :as str]\n- [clojure.test :refer [deftest is testing]])\n- (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n- [java.net InetSocketAddress]))\n+ [clojure.test :refer [deftest is testing]]\n+ [test.support.mock-reddit :as mock-reddit]))\n \n (defn- repo-root []\n (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n@@ -13,29 +12,6 @@\n (with-open [s (java.net.ServerSocket. 0)]\n (.getLocalPort s)))\n \n-(defn- start-mock-reddit [port fixtures-dir]\n- (let [about (.getBytes (slurp (io/file fixtures-dir \"r_rust_about.json\")) \"UTF-8\")\n- listing (.getBytes (slurp (io/file fixtures-dir \"r_rust_listing.json\")) \"UTF-8\")\n- server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n- handler\n- (proxy [HttpHandler] []\n- (handle [^HttpExchange exchange]\n- ;; Route by path: `/r//about.json` is the subreddit entity,\n- ;; `/r/.json` is the children listing.\n- (let [path (.getPath (.getRequestURI exchange))\n- body (if (str/includes? path \"/about\")\n- about\n- listing)]\n- (.sendResponseHeaders exchange 200 (alength body))\n- (let [out (.getResponseBody exchange)]\n- (.write out body)\n- (.close out)))))]\n- (.createContext server \"/\" handler)\n- (.setExecutor server nil)\n- (.start server)\n- (fn stop []\n- (.stop server 0))))\n-\n (defn- wait-health [base-url ms]\n (let [deadline (+ (System/currentTimeMillis) ms)\n url (str base-url \"/healthz\")]\n@@ -100,7 +76,7 @@\n (deftest reddit-fetch-via-mock-api\n (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n (let [root (repo-root)\n- fixtures (str root \"/test/fixtures/reddit\")\n+ fixtures (mock-reddit/fixtures-dir root)\n data-dir (.getAbsolutePath\n (doto (io/file (System/getProperty \"java.io.tmpdir\")\n (str \"sorter2-reddit-\" (System/currentTimeMillis)))\n@@ -110,7 +86,7 @@\n reddit-base (str \"http://127.0.0.1:\" reddit-port)\n app-base (str \"http://127.0.0.1:\" app-port)\n bin (str root \"/target/release/sorter2-server\")\n- stop-mock (start-mock-reddit reddit-port fixtures)]\n+ stop-mock (mock-reddit/start-mock-reddit reddit-port fixtures)]\n (try\n (is (zero? (:exit (process/shell {:dir root}\n \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\ndiff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5efa92db3e1f79b8f423a2f1adcbda959123c1ad\n--- /dev/null\n+++ b/test/support/mock_reddit.clj\n@@ -0,0 +1,35 @@\n+(ns test.support.mock-reddit\n+ \"In-process HTTP stub for Reddit API fixtures (`test/fixtures/reddit/`).\"\n+ (:require [clojure.java.io :as io]\n+ [clojure.string :as str])\n+ (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n+ [java.net InetSocketAddress]))\n+\n+(defn fixtures-dir\n+ ([] (fixtures-dir (System/getProperty \"user.dir\")))\n+ ([root] (str root \"/test/fixtures/reddit\")))\n+\n+(defn start-mock-reddit\n+ \"Start a mock Reddit API on `port`. Returns a zero-arg `stop` function.\"\n+ ([port] (start-mock-reddit port (fixtures-dir)))\n+ ([port dir]\n+ (let [about (.getBytes (slurp (io/file dir \"r_rust_about.json\")) \"UTF-8\")\n+ listing (.getBytes (slurp (io/file dir \"r_rust_listing.json\")) \"UTF-8\")\n+ server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n+ handler\n+ (proxy [HttpHandler] []\n+ (handle [^HttpExchange exchange]\n+ ;; `/r//about.json` → subreddit entity; `/r/.json` → listing.\n+ (let [path (.getPath (.getRequestURI exchange))\n+ body (if (str/includes? path \"/about\")\n+ about\n+ listing)]\n+ (.sendResponseHeaders exchange 200 (alength body))\n+ (let [out (.getResponseBody exchange)]\n+ (.write out body)\n+ (.close out)))))]\n+ (.createContext server \"/\" handler)\n+ (.setExecutor server nil)\n+ (.start server)\n+ (fn stop []\n+ (.stop server 0)))))\ndiff --git a/test/vote_compare.clj b/test/vote_compare.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..fbc00281e466a1ba0833193c7b96fa30aeb45a5c\n--- /dev/null\n+++ b/test/vote_compare.clj\n@@ -0,0 +1,101 @@\n+(ns test.vote-compare\n+ (:require [babashka.process :as process]\n+ [clojure.java.io :as io]\n+ [clojure.string :as str]\n+ [clojure.test :refer [deftest is testing]]\n+ [com.blockether.spel.core :as core]\n+ [com.blockether.spel.locator :as loc]\n+ [com.blockether.spel.page :as page]\n+ [test.support.mock-reddit :as mock-reddit])\n+ (:import [java.net URLEncoder]))\n+\n+(defn- repo-root []\n+ (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n+\n+(defn- pick-port []\n+ (with-open [s (java.net.ServerSocket. 0)]\n+ (.getLocalPort s)))\n+\n+(defn- wait-health [base-url ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)\n+ url (str base-url \"/healthz\")]\n+ (loop []\n+ (let [resp (try\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" url)\n+ (catch Exception _ nil))]\n+ (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n+(defn- curl-fetch-children [base item]\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sfN\" \"--max-time\" \"20\"\n+ \"-X\" \"POST\" (str base \"/ui\")\n+ \"--data-urlencode\"\n+ (str \"__rpc__={\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"\" item\n+ \"\\\",\\\"kind\\\":\\\"children\\\"}\")))\n+\n+(defn- vote-page-url [base parent]\n+ (str base \"/vote?parent=\"\n+ (URLEncoder/encode parent \"UTF-8\")))\n+\n+(deftest vote-compare-shows-recorded-vote-after-post\n+ (testing \"post vote on /vote morphs edge history (mock Reddit children seeded)\"\n+ (let [root (repo-root)\n+ fixtures (mock-reddit/fixtures-dir root)\n+ data-dir (.getAbsolutePath\n+ (doto (io/file (System/getProperty \"java.io.tmpdir\")\n+ (str \"sorter2-vote-\" (System/currentTimeMillis)))\n+ (.mkdirs)))\n+ reddit-port (pick-port)\n+ app-port (pick-port)\n+ reddit-base (str \"http://127.0.0.1:\" reddit-port)\n+ app-base (str \"http://127.0.0.1:\" app-port)\n+ bin (str root \"/target/release/sorter2-server\")\n+ stop-mock (mock-reddit/start-mock-reddit reddit-port fixtures)]\n+ (try\n+ (is (zero? (:exit (process/shell {:dir root}\n+ \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n+ \"release build succeeds\")\n+ (let [proc (process/process {:dir root\n+ :env (into (into {} (System/getenv))\n+ {\"SORTER2_SKIP_DOTENV\" \"1\"\n+ \"SORTER2_DATA_DIR\" data-dir\n+ \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n+ \"PORT\" (str app-port)\n+ \"REDDIT_API_BASE\" reddit-base\n+ \"REDDIT_OAUTH_BASE\" reddit-base\n+ \"REDDIT_CLIENT_ID\" \"\"\n+ \"REDDIT_CLIENT_SECRET\" \"\"\n+ \"REDDIT_APP_ID\" \"\"\n+ \"REDDIT_APP_SECRET\" \"\"})\n+ :out :string\n+ :err :string}\n+ bin)]\n+ (try\n+ (is (wait-health app-base 20000) \"app healthz\")\n+ (let [fetch (curl-fetch-children app-base \"reddit.com/r/rust\")]\n+ (is (zero? (:exit fetch)) \"fetch posts via mock Reddit\")\n+ (is (str/includes? (:out fetch) \"Idiomorph.morph\")))\n+ (core/with-testing-page [pg]\n+ (page/navigate pg (vote-page-url app-base \"reddit.com/r/rust\"))\n+ (page/wait-for-selector pg \"#vote-compare-form\")\n+ (let [before (loc/text-content (page/locator pg \"#vote-edge-history-region\"))]\n+ (is (str/includes? before \"no votes on this pair yet\")\n+ \"empty edge history before first vote\"))\n+ (loc/click (page/get-by-test-id pg \"vote-post\"))\n+ (page/wait-for-selector pg \".vote-edge-history-title\")\n+ (let [after (loc/text-content (page/locator pg \"#vote-edge-history-region\"))]\n+ (is (str/includes? after \"votes on this pair\")\n+ \"shows edge history title after vote\")\n+ (is (str/includes? after \"50:50\")\n+ \"shows submitted ratio after vote\")\n+ (is (not (str/includes? after \"no votes on this pair yet\"))\n+ \"does not revert to empty edge history\")))\n+ (finally\n+ (process/destroy proc))))\n+ (finally\n+ (stop-mock))))))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[529cc941] Replace autocomplete parser with paste-and-go navigate.\n\nThe keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/AGENTS.md b/AGENTS.md\nindex 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki\n - **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.\n - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).\n - **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.\n-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e \"(com.microsoft.playwright.CLI/main (into-array String [\\\"install\\\" \\\"chromium\\\" \\\"--with-deps\\\"]))\"`. The browser binary is cached under `~/.cache/ms-playwright`.\n \n ### Commands (see also `TEST.sh`)\n \ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -8,7 +8,7 @@ use std::collections::HashMap;\n use crate::{\n html::{js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n- parser_render::parser_panel_morph,\n+ parser_render::navigate_panel,\n state::AppState,\n ui_action::{parse_html_ui_from_form, HtmlUiAction},\n };\n@@ -57,18 +57,23 @@ pub async fn post_ui_html(\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n }\n- HtmlUiAction::ParseQuery { query } => {\n- let action = parse_reddit_url(&query);\n- let panel = parser_panel_morph(&query, &action);\n- let mut js = JsBuilder::new().morph_selector(\"#parser-panel\", panel);\n- if let Some(comp) = action.primary_completion() {\n- js = js.raw(&format!(\n- \"var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}\",\n- js_string_literal(comp)\n- ));\n+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {\n+ Ok(subreddit) => {\n+ let dest = format!(\"/?sub={subreddit}\");\n+ JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(&dest)\n+ ))\n+ .into_response()\n }\n- js.into_response()\n- }\n+ Err(message) => {\n+ let panel = navigate_panel(&query, Some(&message));\n+ JsBuilder::new()\n+ .morph_selector(\"#parser-panel\", panel)\n+ .into_response()\n+ }\n+ },\n }\n }\n \ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -11,8 +11,7 @@ use serde::Deserialize;\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::ParserAction,\n- parser_render::parser_panel,\n+ parser_render::navigate_panel,\n ranking::{top_bottom, RankedItem},\n reducer::GroupState,\n state::{normalize_scope, AppState},\n@@ -336,10 +335,9 @@ pub async fn home(\n let empty = GroupState::new();\n let group = groups.get(&scope).unwrap_or(&empty);\n \n- let empty_action = ParserAction::suggest(String::new(), None);\n let body = html! {\n h1 { \"sorter2\" }\n- (parser_panel(\"\", &empty_action))\n+ (navigate_panel(\"\", None))\n (vote_panel(&scope))\n (ranking_panel(&scope, group))\n };\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,7 +4,6 @@ pub mod events;\n pub mod form_template;\n pub mod html;\n pub mod parser;\n-pub mod parser_action;\n pub mod parser_render;\n pub mod path_types;\n pub mod ranking;\ndiff --git a/server/src/parser.rs b/server/src/parser.rs\nindex ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644\n--- a/server/src/parser.rs\n+++ b/server/src/parser.rs\n@@ -1,1811 +1,87 @@\n-use std::collections::HashMap;\n-use std::sync::OnceLock;\n+//! Extract a subreddit name from a pasted Reddit URL or path.\n \n-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};\n-\n-// --- Core Abstractions ---\n-\n-/// Unique identifier for nodes in the graph\n-type NodeId = &'static str;\n-\n-/// Pattern matching for edges\n-#[derive(Debug, Clone)]\n-pub enum EdgePattern {\n- /// Matches exact literal string\n- Literal(&'static str),\n- \n- /// Matches any prefix of a string and suggests the full string\n- /// e.g., PrefixOf(\"reddit.com\") matches \"r\", \"re\", \"red\", \"reddit\", \"reddit.com\"\n- PrefixOf(&'static str),\n- \n- /// Captures a variable segment (e.g., subreddit name, username)\n- Variable(&'static str),\n- \n- /// Matches any string (wildcard)\n- Any,\n-}\n-\n-impl EdgePattern {\n- /// Try to match this pattern against input, return (consumed_chars, captured_value)\n- fn matches(&self, input: &str) -> Option<(usize, Option)> {\n- match self {\n- EdgePattern::Literal(lit) => {\n- if input.starts_with(lit) {\n- Some((lit.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::PrefixOf(target) => {\n- // Check if input is a prefix of target\n- if target.starts_with(input) && !input.is_empty() {\n- // It's a valid prefix\n- Some((input.len(), None))\n- } else if input.starts_with(target) {\n- // Full match\n- Some((target.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::Variable(var_name) => {\n- // Consume until next '/' or end of string\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- let captured = input[..end].to_string();\n- // Validate based on variable type\n- if is_valid_variable(var_name, &captured) {\n- Some((end, Some(captured)))\n- } else {\n- None\n- }\n- } else {\n- None\n- }\n- }\n- EdgePattern::Any => {\n- // Match everything until next '/' or end\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- Some((end, Some(input[..end].to_string())))\n- } else {\n- None\n- }\n- }\n- }\n- }\n- \n- /// Get the completion suggestion for this pattern\n- fn completion(&self, partial: &str) -> Option {\n- match self {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(partial) && partial != *target {\n- Some(target.to_string())\n- } else {\n- None\n- }\n- }\n- _ => None,\n- }\n+pub fn parse_reddit_url(query: &str) -> Result {\n+ let q = query.trim();\n+ if q.is_empty() {\n+ return Err(\"Paste a Reddit URL or r/subreddit path\".into());\n }\n-}\n \n-/// Edge in the graph\n-pub struct Edge {\n- pattern: EdgePattern,\n- target: NodeId,\n- /// Optional description for autocomplete\n- description: Option<&'static str>,\n-}\n-\n-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).\n-type Handler = Box) -> ParserAction + Send + Sync>;\n-\n-/// Node in the graph\n-pub struct Node {\n- #[allow(dead_code)]\n- id: NodeId,\n- edges: Vec,\n- handler: Option,\n-}\n-\n-/// The composable parser graph (immutable after `build`).\n-pub struct Graph {\n- nodes: HashMap,\n- root: NodeId,\n-}\n-\n-// --- Graph Builder (Fluent API) ---\n-\n-pub struct GraphBuilder {\n- nodes: HashMap,\n- current_node: Option,\n- root: NodeId,\n-}\n-\n-impl GraphBuilder {\n- pub fn new() -> Self {\n- let mut nodes = HashMap::new();\n- nodes.insert(\n- \"root\",\n- Node {\n- id: \"root\",\n- edges: Vec::new(),\n- handler: None,\n- },\n- );\n-\n- GraphBuilder {\n- nodes,\n- current_node: Some(\"root\"),\n- root: \"root\",\n- }\n+ if let Some(sub) = subreddit_after_prefix(q, \"r/\") {\n+ return Ok(sub);\n }\n \n- /// Select a node to add edges to\n- pub fn at(mut self, node_id: NodeId) -> Self {\n- self.nodes.entry(node_id).or_insert_with(|| Node {\n- id: node_id,\n- edges: Vec::new(),\n- handler: None,\n- });\n- self.current_node = Some(node_id);\n- self\n+ if let Some(sub) = subreddit_from_path_segment(q, \"/r/\") {\n+ return Ok(sub);\n }\n- \n- /// Add an edge from the current node\n- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {\n- self.edge_with_desc(pattern, target, None)\n- }\n- \n- /// Add an edge with description\n- pub fn edge_with_desc(\n- mut self,\n- pattern: EdgePattern,\n- target: NodeId,\n- desc: Option<&'static str>,\n- ) -> Self {\n- let current = self.current_node.expect(\"No current node selected\");\n-\n- self.nodes.entry(target).or_insert_with(|| Node {\n- id: target,\n- edges: Vec::new(),\n- handler: None,\n- });\n-\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.edges.push(Edge {\n- pattern,\n- target,\n- description: desc,\n- });\n- }\n \n- self\n- }\n-\n- /// Set handler for current node\n- pub fn handler(mut self, handler: F) -> Self\n- where\n- F: Fn(&str, &str, &HashMap) -> ParserAction + Send + Sync + 'static,\n- {\n- let current = self.current_node.expect(\"No current node selected\");\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.handler = Some(Box::new(handler));\n- }\n- self\n- }\n- \n- /// Build the final graph\n- pub fn build(self) -> Graph {\n- Graph {\n- nodes: self.nodes,\n- root: self.root,\n- }\n- }\n+ Err(\"Could not find a subreddit in that URL\".into())\n }\n \n-// --- Parser Implementation ---\n-\n-impl Graph {\n- pub fn parse(&self, input: &str) -> ParserAction {\n- let normalized = input.trim().to_lowercase();\n- let mut state = ParserState {\n- input: &normalized,\n- cursor: 0,\n- current_node_id: self.root,\n- context: HashMap::new(),\n- original_query: input.to_string(),\n- current_prefix: String::new(),\n- };\n- \n- self.parse_recursive(&mut state)\n- }\n- \n- fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {\n- let node = self\n- .nodes\n- .get(state.current_node_id)\n- .expect(\"Node not found in graph\");\n-\n- // If we've consumed all input, check for handler or suggestions\n- if state.cursor >= state.input.len() {\n- if let Some(handler) = &node.handler {\n- return handler(&state.original_query, &state.current_prefix, &state.context);\n- }\n-\n- // No handler, try to suggest based on available edges\n- return self.suggest_from_edges(node, state);\n- }\n-\n- let remaining = &state.input[state.cursor..];\n-\n- // Try to match each edge\n- for edge in &node.edges {\n- if let Some((consumed, captured)) = edge.pattern.matches(remaining) {\n- // Save state for potential backtracking\n- let saved_cursor = state.cursor;\n- let saved_node = state.current_node_id;\n- let saved_prefix = state.current_prefix.clone();\n- \n- // Update state\n- state.cursor += consumed;\n- state.current_node_id = edge.target;\n- state.current_prefix.push_str(&remaining[..consumed]);\n- \n- // Store captured variable if any\n- if let Some(value) = captured {\n- if let EdgePattern::Variable(var_name) = &edge.pattern {\n- state.context.insert(var_name.to_string(), value);\n- }\n- }\n- \n- // Check if this is a partial match that needs completion\n- if state.cursor == state.input.len() {\n- if let Some(completion_suffix) = edge.pattern.completion(remaining) {\n- // Use the current_prefix plus the completion suffix\n- let full_completion = format!(\"{}{}\", \n- state.current_prefix, \n- completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)\n- );\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- \n- // Continue parsing from the target node\n- let result = self.parse_recursive(state);\n- \n- // If we got a valid response, return it\n- if !matches!(result, ParserAction::ShowError(_)) {\n- return result;\n- }\n- \n- // Otherwise, restore state and try next edge\n- state.cursor = saved_cursor;\n- state.current_node_id = saved_node;\n- state.current_prefix = saved_prefix;\n- }\n- }\n- \n- // No edges matched - try to provide suggestions\n- self.suggest_from_edges(node, state)\n- }\n- \n- fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> ParserAction {\n- let remaining = &state.input[state.cursor..];\n- \n- // Find edges that could match with more input\n- for edge in &node.edges {\n- match &edge.pattern {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(remaining) && !remaining.is_empty() {\n- // Use current_prefix instead of rebuilding from input\n- let full_completion = format!(\"{}{}\", state.current_prefix, target);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- EdgePattern::Literal(lit) => {\n- if lit.starts_with(remaining) && !remaining.is_empty() {\n- let full_completion = format!(\"{}{}\", state.current_prefix, lit);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- _ => {}\n- }\n- }\n- \n- ParserAction::error(\n- \"InvalidPath\".to_string(),\n- format!(\"'{}' doesn't match any known pattern\", state.original_query)\n- )\n- }\n+fn subreddit_after_prefix(text: &str, prefix: &str) -> Option {\n+ let rest = text.strip_prefix(prefix)?;\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-struct ParserState<'a> {\n- input: &'a str,\n- cursor: usize,\n- current_node_id: NodeId,\n- context: HashMap,\n- original_query: String,\n- current_prefix: String,\n+fn subreddit_from_path_segment(text: &str, needle: &str) -> Option {\n+ let idx = text.find(needle)?;\n+ let rest = &text[idx + needle.len()..];\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-// --- Helper Functions ---\n-\n-fn is_valid_variable(var_name: &str, value: &str) -> bool {\n- match var_name {\n- \"subreddit\" => {\n- !value.is_empty() && \n- value.len() <= 21 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_')\n- }\n- \"username\" => {\n- !value.is_empty() && \n- value.len() <= 20 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')\n- }\n- \"post_id\" => {\n- !value.is_empty() && \n- value.len() <= 10 && \n- value.chars().all(|c| c.is_alphanumeric())\n- }\n- _ => true, // Allow any value for unknown variables\n+fn valid_subreddit(name: &str) -> Option {\n+ if name.is_empty() {\n+ return None;\n+ }\n+ if name\n+ .chars()\n+ .all(|c| c.is_ascii_alphanumeric() || c == '_')\n+ {\n+ Some(name.to_ascii_lowercase())\n+ } else {\n+ None\n }\n-}\n-\n-// --- Define the Reddit Graph ---\n-\n-pub fn build_reddit_graph() -> Graph {\n- GraphBuilder::new()\n- // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===\n- .at(\"root\")\n- // Direct aliases to subreddit and user selection\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"r/\"),\n- \"subreddit_selection\",\n- Some(\"Browse subreddits (e.g., r/programming)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"u/\"),\n- \"user_selection\",\n- Some(\"Browse users (e.g., u/spez)\")\n- )\n- \n- // Reddit shortcuts - one pattern handles ALL prefixes!\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"), \n- \"reddit_domain\",\n- Some(\"Go to Reddit\")\n- )\n- \n- // Protocol patterns - \"h\" can suggest \"https://\"\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"https://\"),\n- \"https_protocol\",\n- Some(\"HTTPS protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"http://\"),\n- \"http_protocol\", \n- Some(\"HTTP protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"www_prefix\",\n- Some(\"World Wide Web\")\n- )\n- \n-\n- \n- // === HTTPS PROTOCOL: Can go to any domain ===\n- .at(\"https_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTPS)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"https_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTP PROTOCOL: Similar to HTTPS ===\n- .at(\"http_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTP)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"http_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTPS + WWW ===\n- .at(\"https_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === HTTP + WWW ===\n- .at(\"http_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === WWW PREFIX (without protocol) ===\n- .at(\"www_prefix\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === REDDIT DOMAIN: Expect \"/\" ===\n- .at(\"reddit_domain\")\n- .edge(EdgePattern::Literal(\"/\"), \"reddit_root\")\n- .handler(|query, prefix, _ctx| {\n- // If someone just types \"reddit.com\" (or with protocol) without slash\n- // Suggest adding the slash using the current prefix\n- let completion = format!(\"{}/\", prefix);\n- \n- ParserAction::suggest(\n- query.to_string(),\n- Some(Suggestion {\n- text: completion.clone(),\n- completion,\n- description: Some(\"Continue to Reddit homepage\".to_string()),\n- score: 1.0,\n- })\n- )\n- })\n- \n- // === REDDIT ROOT: The main Reddit navigation ===\n- .at(\"reddit_root\")\n- .edge(EdgePattern::Literal(\"r/\"), \"subreddit_selection\")\n- .edge(EdgePattern::Literal(\"u/\"), \"user_selection\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}r/\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}u/\", prefix),\n- },\n- ],\n- 1400, // 1.4 second interval (slower)\n- true // loop through\n- ),\n- ParserAction::guide(\n- query.to_string(),\n- \"Welcome to Sorter for Reddit\".to_string(),\n- \"Where would you like to start?\".to_string(),\n- vec![\n- GuideOption { \n- key: \"r\".to_string(), \n- label: \"Sort a Subreddit\".to_string(), \n- description: \"Find the best posts in a community.\".to_string(), \n- completion: format!(\"{}r/\", prefix)\n- },\n- GuideOption { \n- key: \"u\".to_string(), \n- label: \"Sort User Content\".to_string(), \n- description: \"Explore and rank a user's posts and comments.\".to_string(), \n- completion: format!(\"{}u/\", prefix)\n- },\n- ]\n- ),\n- ])\n- })\n- \n-\n- \n- // === SUBREDDIT SELECTION: THE UNIFIED NODE ===\n- // This node is now reached from `r/` OR `reddit.com/r/`\n- .at(\"subreddit_selection\")\n- .edge(EdgePattern::Variable(\"subreddit\"), \"subreddit_page\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}programming\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}askreddit\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}aww\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}rust\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}webdev\", prefix),\n- },\n- ],\n- 1600, // 1.6 second interval (slower)\n- true // loop through\n- ),\n- // Live DB-backed suggestions for subreddits as the user types\n- ParserAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }\n- ])\n- })\n- \n- // === Specific subreddit page ===\n- .at(\"subreddit_page\")\n- .edge(EdgePattern::Literal(\"/\"), \"subreddit_slash\")\n- .handler(|_query, prefix, ctx| {\n- // Use the new unified subreddit resolution logic\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::ResolveAndDisplaySubreddit {\n- subreddit,\n- prefix: prefix.to_string(),\n- }\n- })\n- \n- .at(\"subreddit_slash\")\n- .edge(EdgePattern::Literal(\"hot\"), \"subreddit_hot\")\n- .edge(EdgePattern::Literal(\"top\"), \"subreddit_top\")\n- .edge(EdgePattern::Literal(\"new\"), \"subreddit_new\")\n- .edge(EdgePattern::Literal(\"comments\"), \"subreddit_comments\")\n- .handler(|query, prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::guide(\n- query.to_string(), \n- format!(\"What to sort in r/{}?\", subreddit), \n- \"Choose a category to begin sorting.\".to_string(), \n- vec![\n- GuideOption { \n- key: \"hot\".to_string(), \n- label: \"Hot Posts\".to_string(), \n- description: \"Import and sort posts currently on the front page.\".to_string(), \n- completion: format!(\"{}hot\", prefix) \n- },\n- GuideOption { \n- key: \"top\".to_string(), \n- label: \"Top Posts\".to_string(), \n- description: \"Import and sort the highest-rated posts.\".to_string(), \n- completion: format!(\"{}top\", prefix) \n- },\n- GuideOption { \n- key: \"new\".to_string(), \n- label: \"New Posts\".to_string(), \n- description: \"Import and sort the newest posts.\".to_string(), \n- completion: format!(\"{}new\", prefix) \n- },\n- GuideOption { \n- key: \"comments\".to_string(), \n- label: \"All Comments\".to_string(), \n- description: \"Find the best comment across all imported threads.\".to_string(), \n- completion: format!(\"{}comments/\", prefix) \n- },\n- ]\n- )\n- })\n- \n-\n- \n- .at(\"user_selection\")\n- .edge(EdgePattern::Variable(\"username\"), \"user_profile\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::guide(\n- query.to_string(),\n- \"User Profile Sorting\".to_string(),\n- \"Enter a Reddit username to sort their content.\".to_string(),\n- vec![\n- GuideOption {\n- key: \"popular\".to_string(),\n- label: \"Popular Users\".to_string(),\n- description: \"Browse well-known Reddit users.\".to_string(),\n- completion: prefix.to_string(),\n- },\n- ]\n- )\n- })\n- \n- // === Subreddit sort types ===\n- .at(\"subreddit_hot\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_top\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_new\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_comments\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- // === User profile ===\n- .at(\"user_profile\")\n- .handler(|_query, _prefix, ctx| {\n- let username = ctx.get(\"username\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.user\".to_string(),\n- pk: username,\n- }\n- })\n- \n- // === Build the graph ===\n- .build()\n-}\n-\n-// --- Public API ---\n-\n-static REDDIT_GRAPH: OnceLock = OnceLock::new();\n-\n-fn reddit_graph() -> &'static Graph {\n- REDDIT_GRAPH.get_or_init(build_reddit_graph)\n-}\n-\n-/// Parse a query string and return a UI action\n-pub fn parse_reddit_url(query: &str) -> ParserAction {\n- reddit_graph().parse(query)\n }\n \n #[cfg(test)]\n mod tests {\n use super::*;\n \n- /// Represents a keystroke action\n- #[derive(Debug, Clone, PartialEq)]\n- enum KeyAction {\n- Type(String), // Type characters\n- Tab, // Press tab (accept completion)\n-\n- }\n-\n- /// Expected state after a keystroke\n- #[derive(Debug, Clone)]\n- enum ExpectedAction {\n- Suggestion { completion: String },\n- ScrollingSuggestions { completions: Vec },\n- Guide { title_contains: String },\n-\n- RenderSubreddit { subreddit: String, sort: Option },\n- RenderSubredditComments { subreddit: String },\n- RenderUser { username: String },\n- ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution\n- Error { error_type: String },\n- Multiple { expected_actions: Vec }, // Multiple responses with specific expectations\n- MultipleAny, // Multiple responses (any sub-actions - legacy)\n- DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions\n-\n- }\n-\n- impl ExpectedAction {\n- fn matches(&self, action: &ParserAction) -> bool {\n- match (self, action) {\n- (ExpectedAction::Suggestion { completion }, ParserAction::ShowSuggestions(data)) => {\n- data.suggestion.as_ref()\n- .map(|s| s.completion == *completion)\n- .unwrap_or(false)\n- }\n- (ExpectedAction::ScrollingSuggestions { completions }, ParserAction::ShowScrollingSuggestions { suggestions, .. }) => {\n- let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();\n- *completions == actual_completions\n- }\n- (ExpectedAction::Guide { title_contains }, ParserAction::ShowStaticGuide { title, .. }) => {\n- title.contains(title_contains)\n- }\n- (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderSubredditComments { subreddit }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderUser { username }, ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.user\" && pk == username\n- }\n- (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n- ParserAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n- s == subreddit && p == prefix\n- }\n- (ExpectedAction::Error { error_type }, ParserAction::ShowError(data)) => {\n- data.error_type == *error_type\n- }\n- (ExpectedAction::Multiple { expected_actions }, ParserAction::ShowMultiple { actions }) => {\n- // Check that all expected actions are present\n- if expected_actions.len() != actions.len() {\n- return false;\n- }\n- expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {\n- expected.matches(actual)\n- })\n- }\n- (ExpectedAction::MultipleAny, ParserAction::ShowMultiple { .. }) => true,\n- (ExpectedAction::DbSuggestions { partial, prefix }, \n- ParserAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {\n- p == partial && pr == prefix\n- }\n-\n- _ => false,\n- }\n- }\n- }\n-\n- /// Test helper to simulate a sequence of keystrokes\n- fn simulate_keystrokes(actions: Vec) -> Vec<(String, ParserAction)> {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- let mut results = Vec::new();\n- \n- for action in actions {\n- match action {\n- KeyAction::Type(text) => {\n- current_text.push_str(&text);\n- let result = graph.parse(¤t_text);\n- results.push((current_text.clone(), result));\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion if there is one\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(ref data) = result {\n- if let Some(ref suggestion) = data.suggestion {\n- current_text = suggestion.completion.clone();\n- let new_result = graph.parse(¤t_text);\n- results.push((current_text.clone(), new_result));\n- }\n- }\n- }\n-\n- }\n- }\n- \n- results\n- }\n-\n- /// Test a flow using declarative (KeyAction, ExpectedAction) tuples\n- fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- \n- for (i, (key_action, expected)) in flow.iter().enumerate() {\n- // Perform the keystroke\n- match key_action {\n- KeyAction::Type(text) => {\n- current_text.push_str(text);\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(data) = result {\n- if let Some(suggestion) = &data.suggestion {\n- current_text = suggestion.completion.clone();\n- }\n- }\n- }\n- }\n- \n- // Check the result\n- let actual_action = graph.parse(¤t_text);\n- \n- println!(\" Step {}: {:?} -> '{}' -> {:?}\", \n- i + 1, key_action, current_text, actual_action);\n- \n- assert!(\n- expected.matches(&actual_action),\n- \"Flow '{}' failed at step {}\\n Expected: {:?}\\n Actual: {:?}\\n Text: '{}'\",\n- name, i + 1, expected, actual_action, current_text\n- );\n- }\n- \n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n- \n- /// Legacy helper for backward compatibility (will be removed)\n- fn assert_flow(\n- name: &str,\n- actions: Vec,\n- expected_checks: Vec bool>>,\n- ) {\n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}' -> {:?}\", i + 1, text, action);\n- \n- if i < expected_checks.len() {\n- let check = &expected_checks[i];\n- assert!(\n- check(text, action),\n- \"Flow '{}' failed at step {} with text '{}' and action {:?}\",\n- name, i + 1, text, action\n- );\n- }\n- }\n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n-\n- #[test]\n- fn test_https_reddit_tab_flow() {\n- // Test: typing \"https://reddit.com\" and pressing tab should give \"https://reddit.com/\"\n- assert_flow(\n- \"HTTPS Reddit with Tab\",\n- vec![\n- KeyAction::Type(\"https://reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // After typing \"https://reddit.com\", should get a suggestion\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com/\" and show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_quick_subreddit_flow() {\n- // Test: \"r\" -> TAB -> \"rust\" (direct alias flow)\n- assert_flow(\n- \"Quick Subreddit Access\",\n- vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"rust\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"r\" should suggest \"r/\"\n- text == \"r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"r/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"r/\" and show subreddit selection\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"r/rust\" should resolve the subreddit\n- text == \"r/rust\" && matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"rust\" && prefix == \"r/rust\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_progressive_completion_flow() {\n- // Test progressive typing: \"r\" -> \"re\" -> \"red\" -> \"redd\" -> \"reddit\" -> TAB\n- let progressive_actions = vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Type(\"e\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"i\".to_string()),\n- KeyAction::Type(\"t\".to_string()),\n- KeyAction::Tab,\n- ];\n- \n- let results = simulate_keystrokes(progressive_actions);\n- \n- println!(\"\\n=== Progressive Completion Flow ===\");\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" '{}' -> {:?}\", text, action);\n- \n- // First step: \"r\" should suggest \"r/\"\n- if i == 0 && text == \"r\" {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"r/\",\n- \"Should suggest r/ at 'r'\"\n- );\n- }\n- _ => panic!(\"Expected suggestion at 'r'\"),\n- }\n- }\n- // Other steps before tab should suggest \"reddit.com\"\n- else if i > 0 && i < results.len() - 1 {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"reddit.com\",\n- \"Should suggest reddit.com at '{}'\", text\n- );\n- }\n- _ => panic!(\"Expected suggestion at '{}'\", text),\n- }\n- }\n- }\n- \n- // After tab, should have \"reddit.com\"\n- let (final_text, _) = results.last().unwrap();\n- assert_eq!(final_text, \"reddit.com\");\n- println!(\"✓ Progressive completion flow passed!\");\n- }\n-\n #[test]\n- fn test_subreddit_sort_flow() {\n- // Test navigating to a subreddit and choosing a sort option\n- assert_flow(\n- \"Subreddit Sort Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/r/programming/hot\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/r/programming/hot\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n+ fn parses_short_path() {\n+ assert_eq!(parse_reddit_url(\"r/rust\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_user_profile_flow() {\n- // Test navigating to a user profile\n- assert_flow(\n- \"User Profile Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/u/spez\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n- }),\n- ],\n- );\n+ fn parses_path_with_trailing_slash() {\n+ assert_eq!(parse_reddit_url(\"r/rust/\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_alias_shortcut_flow() {\n- // Test using the \"r/\" shortcut - now correctly goes directly to subreddit\n- assert_flow(\n- \"Alias Shortcut\",\n- vec![\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"technology\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- text == \"r/technology\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n- }),\n- ],\n+ fn parses_full_url() {\n+ assert_eq!(\n+ parse_reddit_url(\"https://www.reddit.com/r/programming/hot\").unwrap(),\n+ \"programming\"\n );\n }\n \n #[test]\n- fn test_www_prefix_flow() {\n- // Test with www prefix\n- assert_flow(\n- \"WWW Prefix\",\n- vec![\n- KeyAction::Type(\"www.reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // Should suggest adding slash\n- text == \"www.reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"www.reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should show guide\n- text == \"www.reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n+ fn parses_url_without_scheme() {\n+ assert_eq!(\n+ parse_reddit_url(\"reddit.com/r/AskReddit\").unwrap(),\n+ \"askreddit\"\n );\n }\n \n #[test]\n- fn test_invalid_path_handling() {\n- // Test that invalid paths show errors\n- assert_flow(\n- \"Invalid Path\",\n- vec![\n- KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/invalid/path\" && \n- matches!(action, ParserAction::ShowError(_))\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_complete_user_journey() {\n- // Test a complete user journey: type partial URL, tab complete, navigate to subreddit\n- assert_flow(\n- \"Complete User Journey\",\n- vec![\n- KeyAction::Type(\"http\".to_string()),\n- KeyAction::Type(\"s://r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"programming\".to_string()),\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"top\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"http\" should suggest \"https://\"\n- text == \"http\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // \"https://r\" should suggest \"https://reddit.com\"\n- text == \"https://r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com\"\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(_))\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/\" should show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/\" should show subreddit selection\n- text == \"https://reddit.com/r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming\" should resolve subreddit\n- text == \"https://reddit.com/r/programming\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } \n- if subreddit == \"programming\" && prefix == \"https://reddit.com/r/programming\")\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/\" should show sort options\n- text == \"https://reddit.com/r/programming/\" && \n- matches!(action, ParserAction::ShowStaticGuide { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/top\" should render entity view\n- text == \"https://reddit.com/r/programming/top\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_multiple_tab_completions() {\n- // Test multiple tab completions in sequence\n- let actions = vec![\n- KeyAction::Type(\"h\".to_string()),\n- KeyAction::Tab, // Complete to \"https://\"\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab, // Complete to \"https://reddit.com\"\n- KeyAction::Type(\"/\".to_string()),\n- ];\n- \n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Multiple Tab Completions ===\");\n- for (i, (text, _action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}'\", i + 1, text);\n- }\n- \n- // Verify the final state\n- assert_eq!(results[1].0, \"https://\"); // After first tab\n- assert_eq!(results[3].0, \"https://reddit.com\"); // After second tab\n- assert_eq!(results[4].0, \"https://reddit.com/\"); // After typing /\n- \n- println!(\"✓ Multiple tab completions work correctly!\");\n- }\n-\n- // === CONVENIENCE MACROS FOR CLEANER TESTS ===\n- \n- macro_rules! flow {\n- ($(($key:expr, $expected:expr)),* $(,)?) => {\n- vec![$(($key, $expected)),*]\n- };\n- }\n- \n- macro_rules! type_text {\n- ($text:expr) => {\n- KeyAction::Type($text.to_string())\n- };\n- }\n- \n- macro_rules! suggests {\n- ($completion:expr) => {\n- ExpectedAction::Suggestion { completion: $completion.to_string() }\n- };\n- }\n- \n- macro_rules! renders_subreddit {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }\n- };\n- ($subreddit:expr, $sort:expr) => {\n- ExpectedAction::RenderSubreddit { \n- subreddit: $subreddit.to_string(), \n- sort: Some($sort.to_string()) \n- }\n- };\n- }\n- \n- macro_rules! renders_subreddit_comments {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }\n- };\n- }\n- \n- macro_rules! resolves_subreddit {\n- ($subreddit:expr, $prefix:expr) => {\n- ExpectedAction::ResolveSubreddit { \n- subreddit: $subreddit.to_string(),\n- prefix: $prefix.to_string()\n- }\n- };\n- }\n- \n- macro_rules! shows_guide {\n- ($title_contains:expr) => {\n- ExpectedAction::Guide { title_contains: $title_contains.to_string() }\n- };\n- }\n- \n- macro_rules! multiple {\n- ($($action:expr),* $(,)?) => {\n- ExpectedAction::Multiple { expected_actions: vec![$($action),*] }\n- };\n- }\n- \n- macro_rules! scrolling_suggestions {\n- ($($completion:expr),* $(,)?) => {\n- ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }\n- };\n- }\n- \n- macro_rules! db_suggestions {\n- ($partial:expr, $prefix:expr) => {\n- ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }\n- };\n- }\n-\n- // === NEW DECLARATIVE TESTS ===\n-\n- #[test]\n- fn test_declarative_https_tab_flow() {\n- test_flow(\"HTTPS Tab Completion\", vec![\n- (KeyAction::Type(\"https://reddit.com\".to_string()), \n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Tab, \n- multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"),\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_quick_subreddit_flow() {\n- test_flow(\"Quick Subreddit Flow\", vec![\n- (KeyAction::Type(\"r\".to_string()), \n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Tab, \n- ExpectedAction::MultipleAny), // r/ shows subreddit selection\n- (KeyAction::Type(\"rust\".to_string()),\n- ExpectedAction::ResolveSubreddit { subreddit: \"rust\".to_string(), prefix: \"r/rust\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_complete_journey() {\n- test_flow(\"Complete User Journey\", vec![\n- (KeyAction::Type(\"http\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://\".to_string() }),\n- (KeyAction::Type(\"s://r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Type(\"/r/programming/top\".to_string()),\n- ExpectedAction::RenderSubreddit { \n- subreddit: \"programming\".to_string(), \n- sort: Some(\"top\".to_string()) \n- }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_user_profile() {\n- test_flow(\"User Profile Navigation\", vec![\n- (KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_error_handling() {\n- test_flow(\"Error Handling\", vec![\n- (KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ExpectedAction::Error { error_type: \"InvalidPath\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_progressive_completion() {\n- test_flow(\"Progressive Completion\", vec![\n- (KeyAction::Type(\"r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Type(\"e\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"d\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"dit\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"reddit.com/\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_subreddit_guide() {\n- test_flow(\"Subreddit Guide\", vec![\n- (KeyAction::Type(\"reddit.com/r/programming/\".to_string()),\n- ExpectedAction::Guide { title_contains: \"What to sort\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_clean_macro_example() {\n- // This is what the tests can look like with macros!\n- test_flow(\"Clean Macro Example\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- (type_text!(\"rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_unified_subreddit_resolution_flow() {\n- // This test demonstrates the new unified behavior:\n- // - r/programming (exact) → tries exact match first\n- // - r/pro (partial) → tries exact match, then suggestions + TAB completion\n- \n- test_flow(\"Unified Resolution: Exact Match\", flow![\n- (type_text!(\"r/programming\"), resolves_subreddit!(\"programming\", \"r/programming\")),\n- ]);\n- \n- test_flow(\"Unified Resolution: Partial Match\", flow![\n- (type_text!(\"r/pro\"), resolves_subreddit!(\"pro\", \"r/pro\")),\n- ]);\n- \n- // Both go through the same action type, but dispatcher handles them differently:\n- // - If \"programming\" exists in DB → shows EntityView immediately\n- // - If \"pro\" doesn't exist in DB → shows Multiple with:\n- // 1. Suggestions (for TAB completion to best match) \n- // 2. Selection (for clickable options including import)\n- }\n-\n- #[test]\n- fn test_tab_completion_workflow_unified() {\n- // This demonstrates the desired TAB completion behavior:\n- // r/pro + TAB → r/programming (if \"programming\" is the best DB match)\n- \n- // Note: This test shows the PARSER behavior. The actual TAB completion \n- // happens in the frontend when it receives the Multiple response containing\n- // both Suggestions (for TAB) and Selection (for click options).\n- \n- let graph = build_reddit_graph();\n- \n- // 1. Parser generates unified action for partial input\n- match graph.parse(\"r/pro\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"pro\");\n- assert_eq!(prefix, \"r/pro\");\n- println!(\"✓ Parser correctly identifies 'r/pro' as subreddit resolution\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for 'r/pro'\"),\n- }\n- \n- // 2. When dispatcher runs (in real app), it will return Multiple response with:\n- // - Suggestions: { completion: \"r/programming\" } (for TAB)\n- // - Selection: [ \"Import r/pro\", \"r/programming\", ... ] (for clicks)\n- \n- println!(\"✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)\");\n- }\n-\n- #[test]\n- fn test_ultra_clean_user_journey() {\n- test_flow(\"Ultra Clean User Journey\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"ttps://r\"), suggests!(\"https://reddit.com\")), \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- (type_text!(\"/u/spez\"), ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- // === TESTS FROM parser.tdsl ===\n-\n- #[test]\n- fn test_tdsl_basic_reddit_progression() {\n- // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps\n- test_flow(\"TDSL Basic Reddit Progression\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (type_text!(\"e\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"i\"), suggests!(\"reddit.com\")),\n- (type_text!(\"t\"), suggests!(\"reddit.com\")),\n- (type_text!(\".\"), suggests!(\"reddit.com\")),\n- (type_text!(\"c\"), suggests!(\"reddit.com\")),\n- (type_text!(\"o\"), suggests!(\"reddit.com\")),\n- (KeyAction::Tab, suggests!(\"reddit.com/\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_reddit_com_slash_infographic() {\n- // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}\n- test_flow(\"TDSL Reddit.com/ Infographic\", flow![\n- (type_text!(\"reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_selection() {\n- // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n- test_flow(\"TDSL Subreddit Selection\", flow![\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_view() {\n- // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}\n- test_flow(\"TDSL Subreddit View\", flow![\n- (type_text!(\"reddit.com/r/programming\"), resolves_subreddit!(\"programming\", \"reddit.com/r/programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_slash_infographic() {\n- // reddit.com/r/{sub}/ -> {show infographic or something}\n- test_flow(\"TDSL Subreddit Slash Infographic\", flow![\n- (type_text!(\"reddit.com/r/programming/\"), shows_guide!(\"What to sort\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_comments() {\n- // reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n- test_flow(\"TDSL Subreddit Comments\", flow![\n- (type_text!(\"reddit.com/r/programming/comments\"), renders_subreddit_comments!(\"programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_h_to_https() {\n- // h->https:// (show supported domains)\n- test_flow(\"TDSL H to HTTPS\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_reddit() {\n- // https://r->https://reddit.com/\n- test_flow(\"TDSL Composable HTTPS Reddit\", flow![\n- (type_text!(\"https://r\"), suggests!(\"https://reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www() {\n- // https://w->https://www.\n- test_flow(\"TDSL Composable HTTPS WWW\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www_reddit() {\n- // https://www.r->https://www.reddit.com/\n- test_flow(\"TDSL Composable HTTPS WWW Reddit\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_full_composable_chain() {\n- // Complete chain showing composability: h -> https:// -> https://www.reddit.com\n- test_flow(\"TDSL Full Composable Chain Step 1\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 2\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 3\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_progressive_reddit_paths() {\n- // Test various reddit paths work as expected\n- test_flow(\"TDSL Progressive Reddit Paths\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test] \n- fn test_tdsl_subreddit_sorting_options() {\n- // Test that subreddit sorting options work as described\n- test_flow(\"TDSL Subreddit Sorting\", flow![\n- (type_text!(\"reddit.com/r/rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit Top\", flow![\n- (type_text!(\"reddit.com/r/rust/top\"), renders_subreddit!(\"rust\", \"top\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit New\", flow![\n- (type_text!(\"reddit.com/r/rust/new\"), renders_subreddit!(\"rust\", \"new\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_all_reddit_prefixes() {\n- // Test all the prefixes mentioned in parser.tdsl work\n- // \"r\" now suggests \"r/\", all others suggest \"reddit.com\"\n- let prefixes_to_reddit = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- // Test \"r\" separately since it now suggests \"r/\"\n- test_flow(\"TDSL Prefix: r\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- ]);\n- \n- for prefix in prefixes_to_reddit {\n- test_flow(&format!(\"TDSL Prefix: {}\", prefix), flow![\n- (type_text!(prefix), suggests!(\"reddit.com\")),\n- ]);\n- }\n- }\n-\n- #[test]\n- fn test_tdsl_protocol_combinations() {\n- // Test various protocol combinations from parser.tdsl\n- let test_cases = vec![\n- (\"http://r\", \"http://reddit.com\"),\n- (\"https://r\", \"https://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"http://www.r\", \"http://www.reddit.com\"),\n- ];\n- \n- for (input, expected) in test_cases {\n- test_flow(&format!(\"TDSL Protocol: {}\", input), flow![\n- (type_text!(input), suggests!(expected)),\n- ]);\n- }\n+ fn rejects_empty() {\n+ assert!(parse_reddit_url(\"\").is_err());\n+ assert!(parse_reddit_url(\" \").is_err());\n }\n \n #[test]\n- fn test_tdsl_edge_case_completions() {\n- // Test edge cases mentioned in parser.tdsl\n- test_flow(\"TDSL Reddit.com completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- ]);\n- \n- // Test that typing full reddit.com suggests the slash\n- test_flow(\"TDSL Full domain completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_real_user_trace_2025_08_07_fixed() {\n- // Based on actual WebSocket trace from 2025-08-07T01:35:46Z\n- // This test shows the CORRECT behavior after fixing the protocol preservation bug\n- test_flow(\"Real User Trace: Progressive Typing with Tab Completions (Fixed)\", flow![\n- // User started typing \"h\"\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // User continued to \"ht\" \n- (type_text!(\"t\"), suggests!(\"https://\")),\n- \n- // User finished typing \"https://\" (trace shows full protocol)\n- (type_text!(\"tps://\"), suggests!(\"https://\")),\n- \n- // User started typing \"r\" after protocol\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // User continued typing \"re\"\n- (type_text!(\"e\"), suggests!(\"https://reddit.com\")),\n- \n- // User typed out or completed \"https://reddit.com\"\n- (type_text!(\"ddit.com\"), suggests!(\"https://reddit.com/\")),\n- \n- // User accepted completion to \"https://reddit.com/\"\n- // NOW suggestions should preserve the https:// protocol\n- (KeyAction::Tab, multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // This is the key fix!\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_protocol_preservation_bug_fix() {\n- // This test specifically verifies the fix for the protocol preservation bug\n- test_flow(\"Protocol Preservation: HTTPS Reddit Homepage\", flow![\n- (type_text!(\"https://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // Should preserve https://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that subreddit selection preserves protocol\n- test_flow(\"Protocol Preservation: HTTPS Subreddit Selection\", flow![\n- (type_text!(\"https://reddit.com/r/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/programming\", \"https://reddit.com/r/askreddit\", \"https://reddit.com/r/aww\", \"https://reddit.com/r/rust\", \"https://reddit.com/r/webdev\"), // Should preserve https://\n- db_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/r/\")\n- ]),\n- ]);\n- \n- // Test with different protocols\n- test_flow(\"Protocol Preservation: HTTP\", flow![\n- (type_text!(\"http://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"http://reddit.com/r/\", \"http://reddit.com/u/\"), // Should preserve http://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- test_flow(\"Protocol Preservation: WWW\", flow![\n- (type_text!(\"www.reddit.com/\"), multiple![\n- scrolling_suggestions!(\"www.reddit.com/r/\", \"www.reddit.com/u/\"), // Should preserve www.\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that plain reddit.com still works\n- test_flow(\"Protocol Preservation: Plain Domain\", flow![\n- (type_text!(\"reddit.com/\"), multiple![\n- scrolling_suggestions!(\"reddit.com/r/\", \"reddit.com/u/\"), // No protocol prefix\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_user_navigation_pattern() {\n- // Models how users actually navigate: type → tab → click suggestion → end up at destination\n- // This captures the \"jump\" from https://reddit.com/ to reddit.com/r/ seen in the trace\n- test_flow(\"User Navigation: Protocol to Domain\", flow![\n- // User types and gets to homepage\n- (type_text!(\"https://reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- \n- // Then they navigate (perhaps clicking a suggestion) to subreddit selection\n- test_flow(\"User Navigation: Click to Subreddit\", flow![\n- // This is where they ended up - the suggestion in the guide probably said \"reddit.com/r/\"\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_progressive_typing_pattern() {\n- // Based on the trace pattern - users often type character by character\n- // This tests the exact sequence of suggestions they would see\n- test_flow(\"Progressive Typing Pattern\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"t\"), suggests!(\"https://\")), // Still suggests https://\n- (type_text!(\"t\"), suggests!(\"https://\")), // ht -> htt, still suggests https://\n- (type_text!(\"p\"), suggests!(\"https://\")), // http should still suggest https://\n- (type_text!(\"s\"), suggests!(\"https://\")), // https should suggest https://\n- (type_text!(\"://\"), suggests!(\"https://\")), // Even complete protocol still suggests itself\n- ]);\n- }\n-\n- #[test] \n- fn test_tab_completion_workflow() {\n- // Test what happens when user uses tab completions strategically\n- test_flow(\"Strategic Tab Completion Workflow\", flow![\n- // Start typing, get suggestion\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // Accept suggestion with tab - this should move us to \"https://\"\n- (KeyAction::Tab, suggests!(\"https://\")), // After tab, we're at \"https://\" which still suggests itself\n- \n- // Start typing reddit\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // Accept reddit suggestion \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- \n- // Accept final suggestion to get to homepage\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- // Keep the original tests as well\n- #[test]\n- fn test_reddit_prefix_autocomplete() {\n- let graph = build_reddit_graph();\n- \n- // \"r\" now suggests \"r/\", others suggest \"reddit.com\"\n- match graph.parse(\"r\") {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"r/\");\n- println!(\"✓ 'r' → r/\");\n- }\n- _ => panic!(\"Expected suggestion for 'r'\"),\n- }\n- \n- // Test other prefixes that should suggest \"reddit.com\"\n- let prefixes = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- for prefix in prefixes {\n- match graph.parse(prefix) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"reddit.com\");\n- println!(\"✓ '{}' → reddit.com\", prefix);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", prefix),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_protocol_composition() {\n- let graph = build_reddit_graph();\n- \n- // Test protocol + reddit compositions\n- let tests = vec![\n- (\"https://r\", \"https://reddit.com\"),\n- (\"https://re\", \"https://reddit.com\"),\n- (\"https://reddit\", \"https://reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"https://www.reddit\", \"https://www.reddit.com\"),\n- (\"http://r\", \"http://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- ];\n- \n- for (input, expected) in tests {\n- match graph.parse(input) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);\n- println!(\"✓ '{}' → {}\", input, expected);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", input),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_alias_and_full_paths() {\n- let graph = build_reddit_graph();\n- \n- // reddit.com/ should show guide, r/ should show subreddit selection\n- match graph.parse(\"reddit.com/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_guide = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowStaticGuide { .. }));\n- assert!(has_guide, \"Path 'reddit.com/' should show guide\");\n- println!(\"✓ 'reddit.com/' shows Reddit root guide\");\n- }\n- _ => panic!(\"Expected Multiple action for 'reddit.com/'\"),\n- }\n- \n- match graph.parse(\"r/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_scrolling_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowScrollingSuggestions { .. }));\n- let has_db_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::SuggestSubredditsFromDb { .. }));\n- assert!(has_scrolling_suggestions && has_db_suggestions, \n- \"Path 'r/' should show scrolling suggestions and DB suggestions\");\n- println!(\"✓ 'r/' shows subreddit selection\");\n- }\n- _ => panic!(\"Expected Multiple action for 'r/'\"),\n- }\n- }\n- \n- #[test]\n- fn test_deep_navigation() {\n- let graph = build_reddit_graph();\n- \n- // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit\n- match graph.parse(\"reddit.com/r/rust\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"rust\");\n- assert_eq!(prefix, \"reddit.com/r/rust\");\n- println!(\"✓ reddit.com/r/rust recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit\"),\n- }\n- \n- // Test with alias (r/ goes directly to subreddit, no double r/)\n- match graph.parse(\"r/programming\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"programming\");\n- assert_eq!(prefix, \"r/programming\");\n- println!(\"✓ r/programming (alias) recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit via alias\"),\n- }\n+ fn rejects_garbage() {\n+ assert!(parse_reddit_url(\"hello world\").is_err());\n }\n }\ndiff --git a/server/src/parser_action.rs b/server/src/parser_action.rs\ndeleted file mode 100644\nindex 2b98556b562d548ce231a726f9e704f595e8f4a1..0000000000000000000000000000000000000000\n--- a/server/src/parser_action.rs\n+++ /dev/null\n@@ -1,124 +0,0 @@\n-//! Parser output actions — graph handlers return these; HTML render turns them into markup.\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct Suggestion {\n- pub text: String,\n- pub completion: String,\n- pub description: Option,\n- pub score: f64,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ScrollingSuggestion {\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct GuideOption {\n- pub key: String,\n- pub label: String,\n- pub description: String,\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct SuggestionsData {\n- pub query: String,\n- pub suggestion: Option,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ErrorData {\n- pub error_type: String,\n- pub message: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub enum ParserAction {\n- ShowSuggestions(SuggestionsData),\n- ShowScrollingSuggestions {\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- },\n- ShowStaticGuide {\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- },\n- ShowMultiple {\n- actions: Vec,\n- },\n- ShowError(ErrorData),\n- SuggestSubredditsFromDb {\n- partial: String,\n- prefix: String,\n- },\n- ResolveAndDisplaySubreddit {\n- subreddit: String,\n- prefix: String,\n- },\n- RenderEntityView {\n- ns: String,\n- pk: String,\n- },\n-}\n-\n-impl ParserAction {\n- pub fn suggest(query: String, suggestion: Option) -> Self {\n- Self::ShowSuggestions(SuggestionsData { query, suggestion })\n- }\n-\n- pub fn error(error_type: String, message: String) -> Self {\n- Self::ShowError(ErrorData {\n- error_type,\n- message,\n- })\n- }\n-\n- pub fn multiple(actions: Vec) -> Self {\n- Self::ShowMultiple { actions }\n- }\n-\n- pub fn scrolling_suggestions(\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- ) -> Self {\n- Self::ShowScrollingSuggestions {\n- query,\n- suggestions,\n- interval_ms,\n- r#loop,\n- }\n- }\n-\n- pub fn guide(\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- ) -> Self {\n- Self::ShowStaticGuide {\n- query,\n- title,\n- subtitle,\n- options,\n- }\n- }\n-\n- /// Primary tab-completion string, if any.\n- pub fn primary_completion(&self) -> Option<&str> {\n- match self {\n- Self::ShowSuggestions(data) => data\n- .suggestion\n- .as_ref()\n- .map(|s| s.completion.as_str()),\n- Self::ShowMultiple { actions } => actions.iter().find_map(|a| a.primary_completion()),\n- _ => None,\n- }\n- }\n-}\ndiff --git a/server/src/parser_render.rs b/server/src/parser_render.rs\nindex 05e4f519cc3832392282b319272b61c104eaabf6..f2341afe21476b689a536137798d97277211a962 100644\n--- a/server/src/parser_render.rs\n+++ b/server/src/parser_render.rs\n@@ -2,12 +2,9 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion},\n ui_action::UI_RPC_FIELD,\n };\n \n-const SAMPLE_SUBREDDITS: &[&str] = &[\"programming\", \"askreddit\", \"rust\", \"webdev\", \"aww\"];\n-\n fn parse_query_rpc_template() -> String {\n template_json_compact(&serde_json::json!({\n \"action\": \"parse_query\",\n@@ -16,193 +13,32 @@ fn parse_query_rpc_template() -> String {\n .expect(\"parse_query rpc template\")\n }\n \n-fn completion_button(completion: &str, label: &str, primary: bool) -> Markup {\n- let class = if primary {\n- \"parser-completion parser-suggestion-primary btn-link\"\n- } else {\n- \"parser-completion btn-link\"\n- };\n- html! {\n- button\n- type=\"button\"\n- class=(class)\n- data-completion=(completion)\n- title=\"Use this path\" {\n- (label)\n- }\n- }\n-}\n-\n-fn render_suggestion(s: &Suggestion) -> Markup {\n- let desc = s\n- .description\n- .as_deref()\n- .unwrap_or(\"Tab to complete\");\n- html! {\n- p class=\"parser-suggestion\" {\n- (completion_button(&s.completion, &s.completion, true))\n- span class=\"muted small\" { \" — \" (desc) }\n- }\n- }\n-}\n-\n-fn render_scrolling(suggestions: &[ScrollingSuggestion]) -> Markup {\n- html! {\n- div class=\"parser-scrolling muted small\" {\n- p { \"Examples:\" }\n- ul class=\"parser-scroll-list\" {\n- @for s in suggestions {\n- li { (completion_button(&s.completion, &s.completion, false)) }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_guide(title: &str, subtitle: &str, options: &[GuideOption]) -> Markup {\n- html! {\n- div class=\"parser-guide\" {\n- h3 { (title) }\n- p class=\"muted\" { (subtitle) }\n- ul class=\"parser-guide-list\" {\n- @for opt in options {\n- li {\n- strong { (opt.key) \": \" }\n- (completion_button(&opt.completion, &opt.label, false))\n- span class=\"muted small\" { \" — \" (opt.description) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_db_subs(partial: &str, prefix: &str) -> Markup {\n- let needle = partial.to_lowercase();\n- let matches: Vec<_> = SAMPLE_SUBREDDITS\n- .iter()\n- .filter(|s| s.contains(&needle) || prefix.ends_with('/') && needle.is_empty())\n- .take(6)\n- .collect();\n- html! {\n- @if !matches.is_empty() {\n- div class=\"parser-db-subs muted small\" {\n- p { \"Subreddits:\" }\n- ul {\n- @for sub in matches {\n- @let completion = format!(\"{prefix}{sub}\");\n- li { (completion_button(&completion, &format!(\"r/{sub}\"), false)) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_action(action: &ParserAction) -> Markup {\n- match action {\n- ParserAction::ShowSuggestions(data) => html! {\n- div class=\"parser-result parser-suggestions\" {\n- @if let Some(s) = &data.suggestion {\n- (render_suggestion(s))\n- } @else {\n- p class=\"muted\" { \"No completion\" }\n- }\n- }\n- },\n- ParserAction::ShowScrollingSuggestions { suggestions, .. } => {\n- render_scrolling(suggestions)\n- }\n- ParserAction::ShowStaticGuide {\n- title,\n- subtitle,\n- options,\n- ..\n- } => render_guide(title, subtitle, options),\n- ParserAction::ShowMultiple { actions } => html! {\n- div class=\"parser-multiple\" {\n- @for a in actions {\n- (render_action(a))\n- }\n- }\n- },\n- ParserAction::ShowError(data) => html! {\n- p class=\"parser-error muted\" {\n- strong { (data.error_type) \": \" }\n- (data.message)\n- }\n- },\n- ParserAction::SuggestSubredditsFromDb { partial, prefix } => {\n- render_db_subs(partial, prefix)\n- }\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => html! {\n- div class=\"parser-resolve\" {\n- p {\n- \"Subreddit \"\n- strong { \"r/\" (subreddit) }\n- @if subreddit.len() <= 3 {\n- span class=\"muted small\" { \" (partial — tab or pick a match)\" }\n- }\n- }\n- p {\n- a class=\"parser-rank-link\"\n- href=(format!(\"/?sub={subreddit}\")) {\n- \"Rank r/\" (subreddit) \" →\"\n- }\n- }\n- (render_db_subs(subreddit, prefix))\n- }\n- },\n- ParserAction::RenderEntityView { ns, pk } => html! {\n- div class=\"parser-entity\" {\n- p {\n- \"Would open \"\n- code { (ns) \"/\" (pk) }\n- }\n- }\n- },\n- }\n-}\n-\n-/// Parser output panel (inner content for `#parser-panel`).\n-pub fn parser_panel(query: &str, action: &ParserAction) -> Markup {\n+/// Navigate panel: paste a Reddit URL and click Go.\n+pub fn navigate_panel(query: &str, error: Option<&str>) -> Markup {\n html! {\n section id=\"parser-panel\" class=\"demo-panel\" {\n h2 { \"Navigate\" }\n p class=\"muted small\" {\n- \"Type a Reddit path — \"\n- code { \"r/rust\" }\n- \", \"\n- code { \"reddit.com/r/programming/hot\" }\n- \", etc. Tab completes; each keystroke posts \"\n- code { \"__rpc__\" }\n- \" to \"\n- code { \"/ui\" }\n- \".\"\n+ \"Paste a Reddit URL or \"\n+ code { \"r/subreddit\" }\n+ \" path, then click Go to rank that subreddit.\"\n }\n form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n- input\n- type=\"text\"\n+ textarea\n name=\"query\"\n id=\"parser-input\"\n- value=(query)\n- placeholder=\"r/ or reddit.com/…\"\n+ rows=\"3\"\n+ placeholder=\"https://reddit.com/r/rust or r/rust\"\n autocomplete=\"off\"\n- spellcheck=\"false\";\n+ spellcheck=\"false\" {\n+ (query)\n+ }\n input type=\"hidden\" name=(UI_RPC_FIELD) value=(parse_query_rpc_template());\n+ button type=\"submit\" class=\"btn-primary\" { \"Go\" }\n }\n- div id=\"parser-output\" {\n- @if query.is_empty() {\n- p class=\"muted\" { \"Start typing…\" }\n- } @else {\n- (render_action(action))\n- }\n+ @if let Some(msg) = error {\n+ p class=\"parser-error muted\" { (msg) }\n }\n }\n }\n }\n-\n-/// Wrap panel HTML for Idiomorph (morph `#parser-panel` only).\n-pub fn parser_panel_morph(query: &str, action: &ParserAction) -> Markup {\n- parser_panel(query, action)\n-}\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 8c357b133599b798a9c06a77a826bcef05a3a407..0e030b3448b8e45acbe49d2de47ea26372445c54 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -22,7 +22,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n scope: String,\n },\n- /// Parse address-bar query via Reddit transition graph; morph `#parser-panel`.\n+ /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n query: String,\n },\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 1e5c3c13d6f7d8f180a4126ba25b44e9801cdb5d..9c475d8e076ba527aaf7a25c177d0d651bd46ca9 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -19,39 +19,6 @@\n }).then(evalJs);\n }\n \n- // Parser responses race: a slow response for an earlier keystroke can arrive\n- // after a newer one and clobber the panel. Tag each request with a monotonic\n- // sequence number and only apply a response if it is newer than the last one\n- // applied, so stale (superseded) responses are discarded.\n- var parserSeq = 0;\n- var parserApplied = 0;\n-\n- function postParserForm(form) {\n- var mySeq = ++parserSeq;\n- return fetch(form.action, {\n- method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n- credentials: 'same-origin',\n- }).then(function (resp) {\n- return resp.text();\n- }).then(function (js) {\n- if (mySeq <= parserApplied) return;\n- parserApplied = mySeq;\n- evalJs(js);\n- });\n- }\n-\n- var parserTimer = null;\n-\n- function scheduleParserInput(input) {\n- if (parserTimer) clearTimeout(parserTimer);\n- parserTimer = setTimeout(function () {\n- var form = document.getElementById('parser-form');\n- if (form) postParserForm(form);\n- }, 120);\n- }\n-\n function initSorterUi() {\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n@@ -67,38 +34,6 @@\n if (firstField) firstField.focus();\n }\n });\n-\n- document.addEventListener('input', function (e) {\n- if (e.target && e.target.id === 'parser-input') {\n- scheduleParserInput(e.target);\n- }\n- });\n-\n- document.addEventListener('keydown', function (e) {\n- if (!e.target || e.target.id !== 'parser-input') return;\n- if (e.key !== 'Tab') return;\n- var completion =\n- e.target.dataset.completion ||\n- (function () {\n- var btn = document.querySelector('#parser-output .parser-suggestion-primary');\n- return btn && btn.getAttribute('data-completion');\n- })();\n- if (!completion) return;\n- e.preventDefault();\n- e.target.value = completion;\n- scheduleParserInput(e.target);\n- });\n-\n- document.addEventListener('click', function (e) {\n- var btn = e.target.closest('.parser-completion');\n- if (!btn) return;\n- var input = document.getElementById('parser-input');\n- if (!input) return;\n- var completion = btn.getAttribute('data-completion');\n- if (!completion) return;\n- input.value = completion;\n- scheduleParserInput(input);\n- });\n }\n \n if (document.readyState === 'loading') {\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex c5fc9e7abf865ed689f58ff0763d3656d73e35f5..6ad0ac712bbc840bedee60f613794de9385fbbd1 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -129,34 +129,12 @@ code {\n background: var(--bg);\n color: var(--fg);\n font-size: 1rem;\n+ font-family: inherit;\n+ resize: vertical;\n }\n \n-.btn-link {\n- background: none;\n- border: none;\n- padding: 0;\n- color: var(--accent);\n- cursor: pointer;\n- font: inherit;\n-}\n-\n-.btn-link:hover {\n- text-decoration: underline;\n-}\n-\n-.parser-rank-link {\n- display: inline-block;\n- margin: 0.5rem 0;\n- padding: 0.4rem 0.8rem;\n- background: var(--accent);\n- color: #0f1115;\n- border-radius: 4px;\n- font-weight: 600;\n- text-decoration: none;\n-}\n-\n-.parser-rank-link:hover {\n- filter: brightness(1.1);\n+#parser-form .btn-primary {\n+ margin-top: 0.5rem;\n }\n \n .scope-name {\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 5112cb79ffe6e53df3d2432646770fc316cf4787..afeeee24e32f2d7f1d852ca96ac799fac8bce665 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -71,11 +71,11 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n }\n \n #[tokio::test]\n-async fn post_ui_parse_query_morphs_parser_panel() {\n+async fn post_ui_parse_query_redirects_to_subreddit() {\n let (addr, _tmp) = start_test_server().await;\n let rpc = serde_json::json!({\n \"action\": \"parse_query\",\n- \"query\": \"r\"\n+ \"query\": \"r/rust\"\n })\n .to_string();\n let mut form = HashMap::new();\n@@ -92,7 +92,6 @@ async fn post_ui_parse_query_morphs_parser_panel() {\n .await\n .unwrap();\n \n- assert!(body.contains(\"Idiomorph.morph\"));\n- assert!(body.contains(\"parser-panel\"));\n- assert!(body.contains(\"r/\"));\n+ assert!(body.contains(\"window.location.href\"));\n+ assert!(body.contains(\"/?sub=rust\"));\n }\ndiff --git a/test/parser_race.clj b/test/parser_race.clj\ndeleted file mode 100644\nindex 23783837f8192ff24a556b73bdd670dc63b606cb..0000000000000000000000000000000000000000\n--- a/test/parser_race.clj\n+++ /dev/null\n@@ -1,185 +0,0 @@\n-(ns test.parser-race\n- \"Browser test (spel / Playwright) for the parser search-box response race.\n-\n- The search box debounces keystrokes, POSTs each to `/ui`, and eval()s the\n- returned JS, which morphs `#parser-panel` (input value + `#parser-output`).\n- If responses are applied in arrival order with no ordering guard, a slow\n- response for an *earlier* query can land after a newer one and clobber it.\n-\n- To force the race deterministically without touching the Rust server, the\n- browser talks to a small in-process reverse proxy that injects an asymmetric\n- per-query delay: the earlier query (`r/rust`) is delayed far longer than the\n- later query (`r/aww`). The later query therefore renders first, then the\n- stale earlier response arrives. Correct behavior: the panel reflects the\n- *latest* query the user typed (`r/aww`).\"\n- (:require [babashka.process :as process]\n- [clojure.java.io :as io]\n- [clojure.string :as str]\n- [clojure.test :refer [deftest is testing]]\n- [com.blockether.spel.assertions :as assert]\n- [com.blockether.spel.core :as core]\n- [com.blockether.spel.locator :as locator]\n- [com.blockether.spel.page :as page])\n- (:import [com.sun.net.httpserver HttpServer HttpHandler]\n- [java.io ByteArrayOutputStream]\n- [java.net InetSocketAddress URI URLDecoder]\n- [java.net.http HttpClient HttpClient$Version HttpRequest\n- HttpRequest$BodyPublishers HttpResponse$BodyHandlers]\n- [java.nio.charset StandardCharsets]\n- [java.util.concurrent Executors]))\n-\n-(def ^:private slow-query \"r/rust\")\n-(def ^:private fast-query \"r/aww\")\n-(def ^:private slow-delay-ms 800)\n-\n-(defn- repo-root []\n- (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n-\n-(defn- pick-port []\n- (with-open [s (java.net.ServerSocket. 0)]\n- (.getLocalPort s)))\n-\n-(defn- wait-health [base-url ms]\n- (let [deadline (+ (System/currentTimeMillis) ms)\n- url (str base-url \"/healthz\")]\n- (loop []\n- (let [resp (try\n- (process/shell {:out :string :err :string} \"curl\" \"-sf\" url)\n- (catch Exception _ nil))]\n- (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n- true\n- (if (< (System/currentTimeMillis) deadline)\n- (do (Thread/sleep 200) (recur))\n- false))))))\n-\n-(defn- start-server\n- \"Builds the release binary and starts it on a random port. Returns a map with\n- :proc and :base.\"\n- [root]\n- (is (zero? (:exit (process/shell {:dir root}\n- \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n- \"release build succeeds\")\n- (let [bin (str root \"/target/release/sorter2-server\")]\n- (is (.exists (io/file bin)) \"binary exists\")\n- (let [data-dir (.getAbsolutePath\n- (doto (io/file (System/getProperty \"java.io.tmpdir\")\n- (str \"sorter2-race-\" (System/currentTimeMillis)))\n- (.mkdirs)))\n- port (pick-port)\n- base (str \"http://127.0.0.1:\" port)\n- proc (process/process {:dir root\n- :env {\"SORTER2_DATA_DIR\" data-dir\n- \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n- \"PORT\" (str port)}\n- :out :string\n- :err :string}\n- bin)]\n- {:proc proc :base base})))\n-\n-(defn- read-all-bytes ^bytes [in]\n- (let [bos (ByteArrayOutputStream.)]\n- (io/copy in bos)\n- (.toByteArray bos)))\n-\n-(defn- ui-query\n- \"Extracts the decoded `query` form field from a urlencoded body, or nil.\"\n- [body-str]\n- (some-> (re-find #\"(?:^|&)query=([^&]*)\" body-str)\n- second\n- (URLDecoder/decode \"UTF-8\")))\n-\n-(defn- start-proxy\n- \"Reverse proxy to `upstream` that sleeps `(delay-fn method path body-str)` ms\n- before forwarding each request. Runs handlers on a thread pool so concurrent\n- requests are delayed independently (the race needs out-of-order arrival).\n- Returns a map with :server and :base.\"\n- [upstream delay-fn]\n- (let [client (-> (HttpClient/newBuilder)\n- (.version HttpClient$Version/HTTP_1_1)\n- (.build))\n- server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" 0) 0)\n- handler (reify HttpHandler\n- (handle [_ ex]\n- (try\n- (let [method (.getRequestMethod ex)\n- uri (.getRequestURI ex)\n- path (.getRawPath uri)\n- query (.getRawQuery uri)\n- req-body (read-all-bytes (.getRequestBody ex))\n- body-str (String. req-body StandardCharsets/UTF_8)\n- delay-ms (delay-fn method path body-str)]\n- (when (pos? delay-ms)\n- (Thread/sleep (long delay-ms)))\n- (let [target (str upstream path (when query (str \"?\" query)))\n- builder (doto (HttpRequest/newBuilder)\n- (.uri (URI/create target)))\n- ct (.getFirst (.getRequestHeaders ex) \"Content-Type\")\n- _ (when ct (.header builder \"Content-Type\" ct))\n- publisher (if (zero? (alength req-body))\n- (HttpRequest$BodyPublishers/noBody)\n- (HttpRequest$BodyPublishers/ofByteArray req-body))\n- _ (.method builder method publisher)\n- resp (.send client (.build builder)\n- (HttpResponse$BodyHandlers/ofByteArray))\n- resp-body (.body resp)\n- resp-ct (-> (.headers resp)\n- (.firstValue \"content-type\")\n- (.orElse nil))]\n- (when resp-ct\n- (.set (.getResponseHeaders ex) \"Content-Type\" resp-ct))\n- (.sendResponseHeaders ex (.statusCode resp) (alength resp-body))\n- (with-open [os (.getResponseBody ex)]\n- (.write os resp-body))))\n- (catch Throwable t\n- (let [msg (.getBytes (str \"proxy error: \" (.getMessage t))\n- StandardCharsets/UTF_8)]\n- (try\n- (.sendResponseHeaders ex 500 (alength msg))\n- (with-open [os (.getResponseBody ex)]\n- (.write os msg))\n- (catch Throwable _ nil))))\n- (finally\n- (.close ex)))))]\n- (.createContext server \"/\" handler)\n- (.setExecutor server (Executors/newCachedThreadPool))\n- (.start server)\n- {:server server\n- :base (str \"http://127.0.0.1:\" (.getPort (.getAddress server)))}))\n-\n-(defn- parser-delay-fn [method path body-str]\n- (if (and (= method \"POST\") (= path \"/ui\") (= (ui-query body-str) slow-query))\n- slow-delay-ms\n- 0))\n-\n-(deftest latest-search-query-wins\n- (testing \"a slow earlier parser response must not clobber a newer one\"\n- (let [root (repo-root)\n- {:keys [proc base]} (start-server root)]\n- (try\n- (is (wait-health base 15000) \"server responds to /healthz\")\n- (let [{:keys [server] proxy-base :base} (start-proxy base parser-delay-fn)]\n- (try\n- (core/with-testing-page [pg]\n- (page/navigate pg proxy-base)\n- (let [input (page/locator pg \"#parser-input\")\n- output (page/locator pg \"#parser-output\")]\n- ;; Type the slow query first; wait past the 120ms debounce so its\n- ;; (slow) request is in flight, then type the fast query.\n- (locator/fill input slow-query)\n- (Thread/sleep 300)\n- (locator/fill input fast-query)\n- ;; Wait until the slow response has certainly arrived and been\n- ;; (mis)applied if the race exists.\n- (Thread/sleep 2000)\n- ;; The panel must reflect the latest query, not the stale one.\n- (is (nil? (assert/contains-text (assert/assert-that output) fast-query))\n- \"output shows the latest query (r/aww)\")\n- (is (nil? (assert/contains-text\n- (assert/loc-not (assert/assert-that output)) slow-query))\n- \"output does NOT show the stale earlier query (r/rust)\")\n- (is (nil? (assert/has-value (assert/assert-that input) fast-query))\n- \"input value is the latest query (r/aww)\")))\n- (finally\n- (.stop server 0))))\n- (finally\n- (process/destroy proc))))))\n","role":"user"}],"model":"~x-ai/grok-latest"}