diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 06001212820101e0cc953d3687dea64f85e60787..d2f9769108def7ca2c5857aec8b4319426188a66 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -47,7 +47,7 @@ pub async fn post_ui_html(
ratio_left,
ratio_right,
scope,
- next,
+ vote_compare,
} => {
let parent = parent_from_scope(&scope);
if let Err(e) = state
@@ -57,14 +57,12 @@ pub async fn post_ui_html(
return ui_js_warn(&e).into_response();
}
let tree = state.tree.read().await;
- if !next.trim().is_empty() {
+ if vote_compare {
+ let left = parse_item_param(&a);
+ let right = parse_item_param(&b);
+ let morph = crate::html::vote::vote_recorded_morph(&tree, &parent, &left, &right);
drop(tree);
- return JsBuilder::new()
- .raw(&format!(
- "window.location.href={};",
- js_string_literal(next.trim())
- ))
- .into_response();
+ return morph.into_response();
}
let empty = crate::reducer::NodeState::default();
let node = tree.get(&parent).unwrap_or(&empty);
@@ -137,7 +135,7 @@ mod tests {
ratio_left: 3,
ratio_right: 1,
scope: String::new(),
- next: String::new(),
+ vote_compare: false,
}
);
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 61cdbc094819ddedb755572c59456ec0d6617619..cd578a5fea46a9a1d49e238d08a80c2caf18708d 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -65,6 +65,15 @@ impl JsBuilder {
self
}
+ pub(crate) fn morph_inner_selector(mut self, selector: &str, markup: Markup) -> Self {
+ let html = js_string_literal(&markup.into_string());
+ self.snippets.push(format!(
+ "var __el = document.querySelector({sel}); if (__el) {{ Idiomorph.morph(__el, {html}, {{ morphStyle: 'innerHTML' }}); }}",
+ sel = js_string_literal(selector),
+ ));
+ self
+ }
+
pub(crate) fn raw(mut self, js: &str) -> Self {
if !js.is_empty() {
self.snippets.push(js.to_string());
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
new file mode 100644
index 0000000000000000000000000000000000000000..89ed621ad861214e147add2062224fc4137ff8ad
--- /dev/null
+++ b/server/src/html/vote.rs
@@ -0,0 +1,306 @@
+//! Pairwise vote UI — `/vote?parent=` with optional `left` / `right`.
+
+use axum::{
+ extract::{Query, State},
+ response::{Html, IntoResponse},
+};
+use maud::{html, Markup};
+use serde::Deserialize;
+
+use crate::{
+ form_template::template_json_compact,
+ html::JsBuilder,
+ pair::{children_of, resolve_pair, suggest_next_pair_in_pool},
+ path_types::ItemId,
+ reducer::{GlobalTree, GroupState, NodeState, VoteData},
+ state::{parse_item_param, AppState},
+ ui_action::UI_RPC_FIELD,
+};
+
+use super::{breadcrumb_path, item_href, layout};
+
+#[derive(Debug, Deserialize)]
+pub struct VoteQuery {
+ pub parent: String,
+ #[serde(default)]
+ pub left: Option,
+ #[serde(default)]
+ pub right: Option,
+}
+
+pub fn vote_href(parent: &ItemId) -> String {
+ format!(
+ "/vote?parent={}",
+ urlencoding::encode(parent.as_str())
+ )
+}
+
+fn vote_compare_href(parent: &ItemId, left: &ItemId, right: &ItemId) -> String {
+ format!(
+ "/vote?parent={}&left={}&right={}",
+ urlencoding::encode(parent.as_str()),
+ urlencoding::encode(left.as_str()),
+ urlencoding::encode(right.as_str()),
+ )
+}
+
+fn display_label(id: &ItemId) -> String {
+ id.segments()
+ .last()
+ .map_or("item".into(), |v| v.to_string())
+}
+
+fn child_title(tree: &GlobalTree, id: &ItemId) -> String {
+ tree.get(id)
+ .and_then(|n| n.data.as_ref())
+ .map(|d| d.title.clone())
+ .unwrap_or_else(|| display_label(id))
+}
+
+fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {
+ let l = ratio_left.max(0) as f64;
+ let r = ratio_right.max(0) as f64;
+ let sum = l + r;
+ if sum <= 0.0 {
+ 50.0
+ } else {
+ (l / sum) * 100.0
+ }
+}
+
+fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {
+ match (v.a.as_str(), v.b.as_str()) {
+ (a, b) if a == page_left.as_str() && b == page_right.as_str() => {
+ (v.ratio_left, v.ratio_right)
+ }
+ (a, b) if a == page_right.as_str() && b == page_left.as_str() => {
+ (v.ratio_right, v.ratio_left)
+ }
+ _ => (v.ratio_left, v.ratio_right),
+ }
+}
+
+fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec {
+ group
+ .recent_votes
+ .iter()
+ .filter(|v| {
+ (v.a.as_str() == left.as_str() && v.b.as_str() == right.as_str())
+ || (v.a.as_str() == right.as_str() && v.b.as_str() == left.as_str())
+ })
+ .cloned()
+ .collect()
+}
+
+fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {
+ let mut votes = edge_votes(group, left, right);
+ votes.sort_by(|a, b| b.ts.cmp(&a.ts));
+ let legend_left = child_title(tree, left);
+ let legend_right = child_title(tree, right);
+ html! {
+ @if votes.is_empty() {
+ p class="muted vote-edge-empty" { "no votes on this pair yet" }
+ } @else {
+ h3 class="vote-edge-history-title" {
+ "votes on this pair"
+ span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) }
+ }
+ ul class="vote-edge-history" {
+ @for v in &votes {
+ @let (r_left, r_right) = ratios_for_page(v, left, right);
+ @let pct = ratio_pct(r_left, r_right);
+ li class="vote-edge-history-row" {
+ div class="vote-edge-meta" {
+ span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
+ }
+ div class="ratio-bar vote-edge-bar" aria-hidden="true" {
+ div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
+ div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn vote_back_nav(parent: &ItemId) -> Markup {
+ html! {
+ div class="vote-compare-nav" {
+ a class="vote-compare-back muted" href=(item_href(parent)) { "← back to " (display_label(parent)) }
+ }
+ }
+}
+
+fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Markup {
+ let next_href = next.map(|(l, r)| vote_compare_href(parent, l, r));
+ html! {
+ div id="vote-compare-actions" class="vote-compare-actions" {
+ button type="submit" class="btn-primary" data-testid="vote-post" { "post vote" }
+ @if let Some(href) = &next_href {
+ a class="btn-secondary vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" }
+ } @else {
+ span class="btn-secondary vote-compare-next is-disabled" { "no next pair" }
+ }
+ }
+ }
+}
+
+/// After recording a vote on the compare page: refresh edge history and next-pair link.
+pub(crate) fn vote_recorded_morph(
+ tree: &GlobalTree,
+ parent: &ItemId,
+ left: &ItemId,
+ right: &ItemId,
+) -> JsBuilder {
+ let pool = children_of(tree, parent);
+ let empty = NodeState::default();
+ let group = tree
+ .get(parent)
+ .unwrap_or(&empty)
+ .local_ranking
+ .clone();
+ let edge_history = vote_edge_history(tree, &group, left, right);
+ let next_pair = suggest_next(&group, left, right, &pool);
+ let actions = vote_compare_actions(parent, next_pair.as_ref());
+ JsBuilder::new()
+ .morph_inner_selector("#vote-edge-history-region", edge_history)
+ .morph_selector("#vote-compare-actions", actions)
+}
+
+fn vote_compare_item_card(tree: &GlobalTree, item: &ItemId, side_class: &str) -> Markup {
+ let href = item_href(item);
+ let title = child_title(tree, item);
+ html! {
+ div class=(format!("vote-compare-side {side_class}")) {
+ a class=(format!("vote-compare-item {side_class}")) href=(href) {
+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, item, &href) {
+ (row)
+ } @else {
+ strong { (title) }
+ }
+ }
+ @if let Some(node) = tree.get(item) {
+ @if crate::render::reddit::is_reddit_post(item) {
+ @if let Some(data) = &node.data {
+ @if let Some(src) = data.image_url.as_ref().or(data.thumb_url.as_ref()) {
+ figure class="vote-compare-figure" {
+ img class="vote-compare-image" src=(src) alt="" loading="lazy";
+ }
+ }
+ @if let Some(author) = &data.author {
+ p class="muted small" { "by " (author) }
+ }
+ }
+ } @else if let Some(data) = &node.data {
+ @if let Some(body) = &data.body_html {
+ div class="vote-compare-item-body" {
+ (maud::PreEscaped(body))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+fn suggest_next(group: &GroupState, left: &ItemId, right: &ItemId, pool: &[ItemId]) -> Option<(ItemId, ItemId)> {
+ suggest_next_pair_in_pool(group, pool, Some((left, right)))
+}
+
+pub async fn vote_page(
+ State(state): State,
+ Query(q): Query,
+) -> impl IntoResponse {
+ let parent = parse_item_param(&q.parent);
+ let left_param = q.left.as_deref().map(parse_item_param);
+ let right_param = q.right.as_deref().map(parse_item_param);
+
+ let tree = state.tree.read().await;
+ let empty = NodeState::default();
+ let parent_node = tree.get(&parent).unwrap_or(&empty);
+
+ let (left, right) = match resolve_pair(
+ &tree,
+ &parent,
+ left_param.as_ref(),
+ right_param.as_ref(),
+ ) {
+ Ok(p) => p,
+ Err(e) => {
+ let (msg, status) = e.status_message();
+ return (status, msg).into_response();
+ }
+ };
+
+ let pool = children_of(&tree, &parent);
+ let group = &parent_node.local_ranking;
+ let next_pair = suggest_next(group, &left, &right, &pool);
+ let edge_history = vote_edge_history(&tree, group, &left, &right);
+
+ let rpc_json = template_json_compact(&serde_json::json!({
+ "action": "record_vote",
+ "a": left.as_str(),
+ "b": right.as_str(),
+ "ratio_left": {"$form:i32": "ratio_left"},
+ "ratio_right": {"$form:i32": "ratio_right"},
+ "scope": parent.as_str(),
+ "vote_compare": true,
+ }))
+ .expect("vote rpc json");
+
+ let title = format!(
+ "vote — {} vs {}",
+ child_title(&tree, &left),
+ child_title(&tree, &right)
+ );
+
+ let body = html! {
+ section class="vote-compare-shell" {
+ h1 { "compare" }
+ (breadcrumb_path(&parent))
+ p class="muted vote-compare-scope" {
+ "ranking children of "
+ a href=(item_href(&parent)) { (child_title(&tree, &parent)) }
+ }
+ div class="vote-compare-pair" {
+ (vote_compare_item_card(&tree, &left, "vote-compare-left"))
+ span class="vote-compare-vs" { "vs" }
+ (vote_compare_item_card(&tree, &right, "vote-compare-right"))
+ }
+ (vote_back_nav(&parent))
+ div id="vote-edge-history-region" {
+ (edge_history)
+ }
+ form id="vote-compare-form" method="POST" action="/ui" {
+ input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
+ input type="hidden" name="ratio_left" id="vote-ratio-left" value="50";
+ input type="hidden" name="ratio_right" id="vote-ratio-right" value="50";
+ label class="vote-compare-slider-label" {
+ span id="vote-slider-left-label" { (child_title(&tree, &left)) }
+ input type="range" id="vote-preference-slider" min="0" max="100" value="50"
+ aria-valuemin="0" aria-valuemax="100";
+ span id="vote-slider-right-label" { (child_title(&tree, &right)) }
+ }
+ (vote_compare_actions(&parent, next_pair.as_ref()))
+ }
+ }
+ };
+
+ drop(tree);
+
+ let path = format!("/vote?parent={}", urlencoding::encode(parent.as_str()));
+ state.views.increment(path.clone());
+ let views = state.views.get_views(&path);
+
+ Html(
+ layout(
+ &title,
+ body,
+ views,
+ )
+ .into_string(),
+ )
+ .into_response()
+}
diff --git a/server/src/pair.rs b/server/src/pair.rs
new file mode 100644
index 0000000000000000000000000000000000000000..606ffa51038a57ffacf335aa48bdb1185f8483fb
--- /dev/null
+++ b/server/src/pair.rs
@@ -0,0 +1,340 @@
+//! Pick two children of a parent scope for pairwise voting.
+//!
+//! Pair selection prefers **bridge** votes — comparisons between items in
+//! different connected components of the voted-pairs graph — so the pool
+//! merges into one ranking group before refining within it.
+
+use rand::seq::SliceRandom;
+use std::collections::{HashMap, HashSet};
+
+use crate::{
+ path_types::ItemId,
+ ranking::connected_components_from_voted_pairs,
+ reducer::{GlobalTree, GroupState},
+};
+
+fn pairs_match(a: &ItemId, b: &ItemId, x: &ItemId, y: &ItemId) -> bool {
+ (a == x && b == y) || (a == y && b == x)
+}
+
+fn pair_is_voted(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {
+ let Some(&ai) = group.item_to_idx.get(a) else {
+ return false;
+ };
+ let Some(&bi) = group.item_to_idx.get(b) else {
+ return false;
+ };
+ let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) };
+ group.voted_pairs.contains(&(i, j))
+}
+
+/// Component id per pool item: voted-pairs graph components plus one id per
+/// never-voted child.
+fn component_ids(group: &GroupState, pool: &[ItemId]) -> HashMap {
+ let n = group.idx_to_item.len();
+ let (comps, isolates) =
+ connected_components_from_voted_pairs(n, group.voted_pairs.iter().copied());
+
+ let mut out: HashMap = HashMap::new();
+ for (comp_idx, comp) in comps.iter().enumerate() {
+ for &idx in comp {
+ if idx < n {
+ out.insert(group.idx_to_item[idx].clone(), comp_idx);
+ }
+ }
+ }
+ let mut next = comps.len();
+ for &idx in &isolates {
+ if idx < n {
+ out.insert(group.idx_to_item[idx].clone(), next);
+ next += 1;
+ }
+ }
+ for item in pool {
+ out.entry(item.clone()).or_insert_with(|| {
+ let id = next;
+ next += 1;
+ id
+ });
+ }
+ out
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum PairPriority {
+ /// Unvoted edge between two components — grows the ranking group.
+ BridgeUnvoted = 0,
+ /// Unvoted edge inside one component — refines order.
+ WithinUnvoted = 1,
+ /// Re-vote across components (rare once merged).
+ BridgeVoted = 2,
+ /// Re-vote within a component.
+ WithinVoted = 3,
+}
+
+fn pair_priority(
+ group: &GroupState,
+ components: &HashMap,
+ a: &ItemId,
+ b: &ItemId,
+) -> PairPriority {
+ let voted = pair_is_voted(group, a, b);
+ let bridge = components.get(a) != components.get(b);
+ match (bridge, voted) {
+ (true, false) => PairPriority::BridgeUnvoted,
+ (false, false) => PairPriority::WithinUnvoted,
+ (true, true) => PairPriority::BridgeVoted,
+ (false, true) => PairPriority::WithinVoted,
+ }
+}
+
+/// All unordered pairs from `pool`, optionally skipping `exclude`.
+fn candidate_pairs(
+ pool: &[ItemId],
+ exclude: Option<(&ItemId, &ItemId)>,
+) -> Vec<(ItemId, ItemId)> {
+ let mut out = Vec::new();
+ for i in 0..pool.len() {
+ for j in (i + 1)..pool.len() {
+ let a = &pool[i];
+ let b = &pool[j];
+ if a == b {
+ continue;
+ }
+ if exclude.is_some_and(|(x, y)| pairs_match(a, b, x, y)) {
+ continue;
+ }
+ out.push((a.clone(), b.clone()));
+ }
+ }
+ out
+}
+
+/// Pick the next pair to vote on within `pool`.
+///
+/// 1. Prefer unvoted **bridge** pairs (connect separate ranking components).
+/// 2. Then unvoted within-component pairs (refinement).
+/// 3. Then already-voted pairs (re-compare).
+pub fn suggest_next_pair_in_pool(
+ group: &GroupState,
+ pool: &[ItemId],
+ exclude: Option<(&ItemId, &ItemId)>,
+) -> Option<(ItemId, ItemId)> {
+ let candidates = candidate_pairs(pool, exclude);
+ if candidates.is_empty() {
+ return None;
+ }
+ let components = component_ids(group, pool);
+ let best = candidates
+ .iter()
+ .map(|(a, b)| (pair_priority(group, &components, a, b), (a, b)))
+ .min_by_key(|(p, _)| *p)?
+ .0;
+ let best_pairs: Vec<(ItemId, ItemId)> = candidates
+ .into_iter()
+ .filter(|(a, b)| pair_priority(group, &components, a, b) == best)
+ .collect();
+ best_pairs.choose(&mut rand::thread_rng()).cloned()
+}
+
+/// Random distinct pair from `children` (legacy pair.rs behavior).
+pub fn random_pair(children: &[ItemId]) -> Option<(ItemId, ItemId)> {
+ if children.len() < 2 {
+ return None;
+ }
+ let left = children.choose(&mut rand::thread_rng())?;
+ let mut right = children.choose(&mut rand::thread_rng())?;
+ let mut guard = 0;
+ while left == right && guard < 32 {
+ right = children.choose(&mut rand::thread_rng())?;
+ guard += 1;
+ }
+ if left == right {
+ return None;
+ }
+ Some((left.clone(), right.clone()))
+}
+
+/// Sorted children of `parent` from the global tree.
+pub fn children_of(tree: &GlobalTree, parent: &ItemId) -> Vec {
+ let Some(node) = tree.get(parent) else {
+ return Vec::new();
+ };
+ let mut children: Vec = node.children.iter().cloned().collect();
+ children.sort_by(|a, b| a.as_str().cmp(b.as_str()));
+ children
+}
+
+/// Resolve a pair to compare under `parent`.
+pub fn resolve_pair(
+ tree: &GlobalTree,
+ parent: &ItemId,
+ left: Option<&ItemId>,
+ right: Option<&ItemId>,
+) -> Result<(ItemId, ItemId), PairError> {
+ let children = children_of(tree, parent);
+ if children.len() < 2 {
+ return Err(PairError::TooFewChildren);
+ }
+ let child_set: HashSet<_> = children.iter().collect();
+
+ match (left, right) {
+ (Some(l), Some(r)) => {
+ if l == r {
+ return Err(PairError::SameItem);
+ }
+ if !child_set.contains(l) || !child_set.contains(r) {
+ return Err(PairError::NotChild);
+ }
+ Ok((l.clone(), r.clone()))
+ }
+ (None, None) => {
+ let group = tree
+ .get(parent)
+ .map(|n| &n.local_ranking)
+ .cloned()
+ .unwrap_or_default();
+ suggest_next_pair_in_pool(&group, &children, None).ok_or(PairError::NoPair)
+ }
+ _ => Err(PairError::IncompletePair),
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PairError {
+ TooFewChildren,
+ SameItem,
+ NotChild,
+ IncompletePair,
+ NoPair,
+}
+
+impl PairError {
+ pub fn status_message(&self) -> (&'static str, axum::http::StatusCode) {
+ match self {
+ Self::TooFewChildren => (
+ "parent needs at least 2 children to vote",
+ axum::http::StatusCode::BAD_REQUEST,
+ ),
+ Self::SameItem => (
+ "left and right must differ",
+ axum::http::StatusCode::BAD_REQUEST,
+ ),
+ Self::NotChild => (
+ "left and right must be children of parent",
+ axum::http::StatusCode::BAD_REQUEST,
+ ),
+ Self::IncompletePair => (
+ "provide both left and right, or neither",
+ axum::http::StatusCode::BAD_REQUEST,
+ ),
+ Self::NoPair => (
+ "no pair available",
+ axum::http::StatusCode::BAD_REQUEST,
+ ),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::reducer::{GlobalTree, VoteData};
+
+ fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {
+ let mut tree = GlobalTree::new();
+ tree.ensure_path(parent);
+ for id in ids {
+ let child = ItemId::parse(id).unwrap();
+ tree.ensure_path(&child);
+ if let Some(p) = tree.nodes.get_mut(parent) {
+ p.children.insert(child);
+ }
+ }
+ tree
+ }
+
+ fn pair_set(pair: &(ItemId, ItemId)) -> HashSet<&str> {
+ [pair.0.as_str(), pair.1.as_str()].into_iter().collect()
+ }
+
+ #[test]
+ fn suggest_prefers_unvoted_pair() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let mut tree = seed_children(
+ &parent,
+ &[
+ "reddit.com/r/rust/a",
+ "reddit.com/r/rust/b",
+ "reddit.com/r/rust/c",
+ ],
+ );
+ let vote =
+ VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+ tree.apply_vote(&parent, vote);
+ let group = tree.get(&parent).unwrap().local_ranking.clone();
+ let pool = children_of(&tree, &parent);
+ let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
+ let voted_ab = (l.as_str() == "reddit.com/r/rust/a" && r.as_str() == "reddit.com/r/rust/b")
+ || (l.as_str() == "reddit.com/r/rust/b" && r.as_str() == "reddit.com/r/rust/a");
+ assert!(!voted_ab);
+ }
+
+ #[test]
+ fn suggest_bridges_separate_components() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let mut tree = seed_children(
+ &parent,
+ &[
+ "reddit.com/r/rust/a",
+ "reddit.com/r/rust/b",
+ "reddit.com/r/rust/c",
+ "reddit.com/r/rust/d",
+ ],
+ );
+ let ab = VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+ let cd = VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap();
+ tree.apply_vote(&parent, ab);
+ tree.apply_vote(&parent, cd);
+ let group = tree.get(&parent).unwrap().local_ranking.clone();
+ let pool = children_of(&tree, &parent);
+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
+ let chosen = pair_set(&pair);
+ let from_ab = chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b");
+ let from_cd = chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d");
+ assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen);
+ }
+
+ #[test]
+ fn suggest_connects_isolate_to_existing_component() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let mut tree = seed_children(
+ &parent,
+ &[
+ "reddit.com/r/rust/a",
+ "reddit.com/r/rust/b",
+ "reddit.com/r/rust/c",
+ ],
+ );
+ let ab = VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+ tree.apply_vote(&parent, ab);
+ let group = tree.get(&parent).unwrap().local_ranking.clone();
+ let pool = children_of(&tree, &parent);
+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
+ let chosen = pair_set(&pair);
+ assert!(chosen.contains("reddit.com/r/rust/c"));
+ assert!(chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"));
+ }
+
+ #[test]
+ fn resolve_pair_picks_from_pool() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let tree = seed_children(&parent, &["reddit.com/r/rust/a", "reddit.com/r/rust/b"]);
+ let pair = resolve_pair(&tree, &parent, None, None).unwrap();
+ let pool: HashSet<_> = ["reddit.com/r/rust/a", "reddit.com/r/rust/b"]
+ .into_iter()
+ .collect();
+ assert!(pool.contains(pair.0.as_str()));
+ assert!(pool.contains(pair.1.as_str()));
+ }
+}
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
index 12dce9888f5cd4e1a0974d12d6468368d0f775b9..a0d1a028b8bd3c5d1c714dea3ec597d1ac39e1bc 100644
--- a/server/src/path_types.rs
+++ b/server/src/path_types.rs
@@ -40,6 +40,22 @@ impl ItemId {
Self::canonicalize(raw_url).map(Self)
}
+ /// Normalize strings from forms, events, and Reddit imports into the same
+ /// stored id shape (e.g. drop post title slug after comment id).
+ pub fn from_storage(s: &str) -> Option {
+ let t = s.trim();
+ if t.is_empty() {
+ return None;
+ }
+ if t.contains("://") || t.starts_with("r/") {
+ return Self::from_url(t).or_else(|| Self::parse(t));
+ }
+ if t.starts_with("reddit.com/") && t.contains("/comments/") {
+ return Self::from_url(t).or_else(|| Self::parse(t));
+ }
+ Self::parse(t).or_else(|| Self::from_url(t))
+ }
+
/// Map legacy scope keys (`""`, `"rust"`) to fractal parent nodes.
pub fn from_legacy_scope(raw: &str) -> Self {
let s = raw.trim();
@@ -324,6 +340,12 @@ mod tests {
assert_eq!(id.as_str(), "reddit.com/r/amitheasshole");
}
+ #[test]
+ fn from_storage_strips_post_title_slug() {
+ let id = ItemId::from_storage("reddit.com/r/rust/comments/aaa/announcing_rust_199").unwrap();
+ assert_eq!(id.as_str(), "reddit.com/r/rust/comments/aaa");
+ }
+
#[test]
fn from_browse_uri_strips_prefix() {
let id = ItemId::from_browse_uri("/~/https://reddit.com/r/rust").unwrap();
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index 1168ec2afc77c092514eec91b513bac301cbe225..626454e5a2f638734193b5190a2beea286af85b6 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -567,7 +567,7 @@ fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {
_ => continue,
};
let path = format!("reddit.com{}", permalink.trim_end_matches('/'));
- if let Some(id) = ItemId::parse(&path) {
+ if let Some(id) = ItemId::from_storage(&path) {
out.push((id, child.clone()));
}
}
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 336e78b77d3ab59361b89af5c9868e13da4ac962..fc23f41137d7df33997afe19c533505c250cc305 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -28,8 +28,8 @@ impl VoteData {
ratio_left: i32,
ratio_right: i32,
) -> Option {
- let a = ItemId::parse(a)?;
- let b = ItemId::parse(b)?;
+ let a = ItemId::from_storage(a)?;
+ let b = ItemId::from_storage(b)?;
if a == b {
return None;
}
@@ -85,8 +85,8 @@ impl GroupState {
}
pub fn apply_vote(&mut self, mut vote: VoteData) {
- vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());
- vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());
+ vote.a = ItemId::from_storage(vote.a.as_str()).unwrap_or(vote.a.clone());
+ vote.b = ItemId::from_storage(vote.b.as_str()).unwrap_or(vote.b.clone());
if vote.ratio_left < 0 {
vote.ratio_left = 0;
}
diff --git a/server/src/state.rs b/server/src/state.rs
index d238701208a1c708b94a778a6a2e1891a678ecbc..4c3008e73cc74d8483a2064a0a280e73d82a01ec 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -18,7 +18,7 @@ pub fn parse_item_param(raw: &str) -> ItemId {
if s.is_empty() {
return ItemId::root();
}
- ItemId::from_url(s).or_else(|| ItemId::parse(s)).unwrap_or_else(|| ItemId::opaque(s))
+ ItemId::from_storage(s).unwrap_or_else(|| ItemId::opaque(s))
}
/// Legacy: normalize raw ranking subject into a scope key for old event replay.
diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs
index 53581e1362bfcc5dfb4ae3069c41ea6f7be41437..237411a689fb10ad1b7022ea66aee1969c7507ec 100644
--- a/server/src/ui_action.rs
+++ b/server/src/ui_action.rs
@@ -31,9 +31,9 @@ pub enum HtmlUiAction {
/// Parent node [`ItemId`] string; empty = tree root.
#[serde(default)]
scope: String,
- /// After vote, navigate here (vote compare page).
+ /// Posted from `/vote` compare UI — morph edge history in place.
#[serde(default)]
- next: String,
+ vote_compare: bool,
},
/// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.
ParseQuery {
@@ -98,7 +98,7 @@ mod tests {
ratio_left: 60,
ratio_right: 40,
scope: "parent".into(),
- next: String::new(),
+ vote_compare: false,
}
);
}
@@ -129,7 +129,7 @@ mod tests {
ratio_left: 2,
ratio_right: 1,
scope: "amitheasshole".into(),
- next: String::new(),
+ vote_compare: false,
}
);
}
@@ -156,7 +156,7 @@ mod tests {
ratio_left: 2,
ratio_right: 1,
scope: String::new(),
- next: String::new(),
+ vote_compare: false,
}
);
}
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 3c2cec5cb5094013387147c621d75bd341bc06cc..b95718ce463b200a5667d3014dac2119836a0bef 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -289,11 +289,27 @@ h1 {
}
.vote-compare-nav {
+ margin: 1rem 0 0.5rem;
+}
+
+.vote-compare-actions {
display: flex;
- justify-content: space-between;
+ flex-wrap: wrap;
align-items: center;
- gap: 1rem;
- margin: 1rem 0;
+ gap: 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.vote-compare-actions .btn-secondary {
+ margin-top: 0;
+ display: inline-block;
+ text-decoration: none;
+ line-height: 1.4;
+}
+
+.vote-compare-actions .vote-compare-next.is-disabled {
+ opacity: 0.6;
+ cursor: default;
}
.vote-compare-next {
diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js
index c8d7c3ef2413399ced7898a7edb3e7a2c8e16163..ab15d27f508cc6f5fbca9d8926fd0ca26d64857b 100644
--- a/server/static/sorter_ui.js
+++ b/server/static/sorter_ui.js
@@ -104,11 +104,10 @@
if (f.getAttribute('data-navigate') === 'full') return;
e.preventDefault();
await postUiForm(f);
- if (f.id === 'vote-form' || f.id === 'vote-compare-form') {
+ if (f.id === 'vote-form') {
f.reset();
- var slider = f.querySelector('#vote-preference-slider');
- if (slider) slider.value = '50';
- initVoteSlider();
+ var firstField = f.querySelector('input[type="text"]');
+ if (firstField) firstField.focus();
}
});
}
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index df7d9ab357531c5146d0b326c149fdaa6b531a4a..56d2db313b313ffce07eff38a8ccdaa2fbb9498f 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -25,6 +25,53 @@ async fn start_test_server() -> (SocketAddr, TempDir) {
(addr, tmp)
}
+#[tokio::test]
+async fn post_ui_vote_compare_morphs_edge_history() {
+ let (addr, _tmp) = start_test_server().await;
+ let parent = "reddit.com/r/rust";
+ let a = "reddit.com/r/rust/comments/aaa/announcing_rust_199";
+ let b = "reddit.com/r/rust/comments/bbb/what_are_you_working_on";
+
+ let rpc = serde_json::json!({
+ "action": "record_vote",
+ "a": a,
+ "b": b,
+ "ratio_left": {"$form:i32": "ratio_left"},
+ "ratio_right": {"$form:i32": "ratio_right"},
+ "scope": parent,
+ "vote_compare": true,
+ })
+ .to_string();
+ let mut form = HashMap::new();
+ form.insert(UI_RPC_FIELD.to_string(), rpc);
+ form.insert("ratio_left".into(), "70".into());
+ form.insert("ratio_right".into(), "30".into());
+
+ let client = reqwest::Client::new();
+ let body = client
+ .post(format!("http://{addr}/ui"))
+ .form(&form)
+ .send()
+ .await
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+
+ assert!(
+ body.contains("vote-edge-history"),
+ "expected edge history morph, got: {body}"
+ );
+ assert!(
+ body.contains("70:30"),
+ "expected recorded ratio in morph, got: {body}"
+ );
+ assert!(
+ !body.contains("no votes on this pair yet"),
+ "should not show empty edge history after vote, got: {body}"
+ );
+}
+
#[tokio::test]
async fn post_ui_record_vote_morphs_ranking_and_persists() {
let (addr, tmp) = start_test_server().await;
diff --git a/test/reddit_import.clj b/test/reddit_import.clj
index 45a2a19f20799d77e84d8aa64735ab5e7e45f97c..b476488526252c13fd73bdda76e5201678e4a714 100644
--- a/test/reddit_import.clj
+++ b/test/reddit_import.clj
@@ -2,9 +2,8 @@
(:require [babashka.process :as process]
[clojure.java.io :as io]
[clojure.string :as str]
- [clojure.test :refer [deftest is testing]])
- (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]
- [java.net InetSocketAddress]))
+ [clojure.test :refer [deftest is testing]]
+ [test.support.mock-reddit :as mock-reddit]))
(defn- repo-root []
(.getCanonicalPath (io/file (System/getProperty "user.dir"))))
@@ -13,29 +12,6 @@
(with-open [s (java.net.ServerSocket. 0)]
(.getLocalPort s)))
-(defn- start-mock-reddit [port fixtures-dir]
- (let [about (.getBytes (slurp (io/file fixtures-dir "r_rust_about.json")) "UTF-8")
- listing (.getBytes (slurp (io/file fixtures-dir "r_rust_listing.json")) "UTF-8")
- server (HttpServer/create (InetSocketAddress. "127.0.0.1" port) 0)
- handler
- (proxy [HttpHandler] []
- (handle [^HttpExchange exchange]
- ;; Route by path: `/r//about.json` is the subreddit entity,
- ;; `/r/.json` is the children listing.
- (let [path (.getPath (.getRequestURI exchange))
- body (if (str/includes? path "/about")
- about
- listing)]
- (.sendResponseHeaders exchange 200 (alength body))
- (let [out (.getResponseBody exchange)]
- (.write out body)
- (.close out)))))]
- (.createContext server "/" handler)
- (.setExecutor server nil)
- (.start server)
- (fn stop []
- (.stop server 0))))
-
(defn- wait-health [base-url ms]
(let [deadline (+ (System/currentTimeMillis) ms)
url (str base-url "/healthz")]
@@ -100,7 +76,7 @@
(deftest reddit-fetch-via-mock-api
(testing "Fetch more queues import; event log stores full payload; page shows title"
(let [root (repo-root)
- fixtures (str root "/test/fixtures/reddit")
+ fixtures (mock-reddit/fixtures-dir root)
data-dir (.getAbsolutePath
(doto (io/file (System/getProperty "java.io.tmpdir")
(str "sorter2-reddit-" (System/currentTimeMillis)))
@@ -110,7 +86,7 @@
reddit-base (str "http://127.0.0.1:" reddit-port)
app-base (str "http://127.0.0.1:" app-port)
bin (str root "/target/release/sorter2-server")
- stop-mock (start-mock-reddit reddit-port fixtures)]
+ stop-mock (mock-reddit/start-mock-reddit reddit-port fixtures)]
(try
(is (zero? (:exit (process/shell {:dir root}
"cargo" "build" "--release" "--package" "sorter2-server")))
diff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj
new file mode 100644
index 0000000000000000000000000000000000000000..5efa92db3e1f79b8f423a2f1adcbda959123c1ad
--- /dev/null
+++ b/test/support/mock_reddit.clj
@@ -0,0 +1,35 @@
+(ns test.support.mock-reddit
+ "In-process HTTP stub for Reddit API fixtures (`test/fixtures/reddit/`)."
+ (:require [clojure.java.io :as io]
+ [clojure.string :as str])
+ (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]
+ [java.net InetSocketAddress]))
+
+(defn fixtures-dir
+ ([] (fixtures-dir (System/getProperty "user.dir")))
+ ([root] (str root "/test/fixtures/reddit")))
+
+(defn start-mock-reddit
+ "Start a mock Reddit API on `port`. Returns a zero-arg `stop` function."
+ ([port] (start-mock-reddit port (fixtures-dir)))
+ ([port dir]
+ (let [about (.getBytes (slurp (io/file dir "r_rust_about.json")) "UTF-8")
+ listing (.getBytes (slurp (io/file dir "r_rust_listing.json")) "UTF-8")
+ server (HttpServer/create (InetSocketAddress. "127.0.0.1" port) 0)
+ handler
+ (proxy [HttpHandler] []
+ (handle [^HttpExchange exchange]
+ ;; `/r//about.json` → subreddit entity; `/r/.json` → listing.
+ (let [path (.getPath (.getRequestURI exchange))
+ body (if (str/includes? path "/about")
+ about
+ listing)]
+ (.sendResponseHeaders exchange 200 (alength body))
+ (let [out (.getResponseBody exchange)]
+ (.write out body)
+ (.close out)))))]
+ (.createContext server "/" handler)
+ (.setExecutor server nil)
+ (.start server)
+ (fn stop []
+ (.stop server 0)))))
diff --git a/test/vote_compare.clj b/test/vote_compare.clj
new file mode 100644
index 0000000000000000000000000000000000000000..fbc00281e466a1ba0833193c7b96fa30aeb45a5c
--- /dev/null
+++ b/test/vote_compare.clj
@@ -0,0 +1,101 @@
+(ns test.vote-compare
+ (:require [babashka.process :as process]
+ [clojure.java.io :as io]
+ [clojure.string :as str]
+ [clojure.test :refer [deftest is testing]]
+ [com.blockether.spel.core :as core]
+ [com.blockether.spel.locator :as loc]
+ [com.blockether.spel.page :as page]
+ [test.support.mock-reddit :as mock-reddit])
+ (:import [java.net URLEncoder]))
+
+(defn- repo-root []
+ (.getCanonicalPath (io/file (System/getProperty "user.dir"))))
+
+(defn- pick-port []
+ (with-open [s (java.net.ServerSocket. 0)]
+ (.getLocalPort s)))
+
+(defn- wait-health [base-url ms]
+ (let [deadline (+ (System/currentTimeMillis) ms)
+ url (str base-url "/healthz")]
+ (loop []
+ (let [resp (try
+ (process/shell {:out :string :err :string}
+ "curl" "-sf" url)
+ (catch Exception _ nil))]
+ (if (and resp (zero? (:exit resp)) (= "ok" (str/trim (:out resp ""))))
+ true
+ (if (< (System/currentTimeMillis) deadline)
+ (do (Thread/sleep 200) (recur))
+ false))))))
+
+(defn- curl-fetch-children [base item]
+ (process/shell {:out :string :err :string}
+ "curl" "-sfN" "--max-time" "20"
+ "-X" "POST" (str base "/ui")
+ "--data-urlencode"
+ (str "__rpc__={\"action\":\"fetch_entity\",\"item\":\"" item
+ "\",\"kind\":\"children\"}")))
+
+(defn- vote-page-url [base parent]
+ (str base "/vote?parent="
+ (URLEncoder/encode parent "UTF-8")))
+
+(deftest vote-compare-shows-recorded-vote-after-post
+ (testing "post vote on /vote morphs edge history (mock Reddit children seeded)"
+ (let [root (repo-root)
+ fixtures (mock-reddit/fixtures-dir root)
+ data-dir (.getAbsolutePath
+ (doto (io/file (System/getProperty "java.io.tmpdir")
+ (str "sorter2-vote-" (System/currentTimeMillis)))
+ (.mkdirs)))
+ reddit-port (pick-port)
+ app-port (pick-port)
+ reddit-base (str "http://127.0.0.1:" reddit-port)
+ app-base (str "http://127.0.0.1:" app-port)
+ bin (str root "/target/release/sorter2-server")
+ stop-mock (mock-reddit/start-mock-reddit reddit-port fixtures)]
+ (try
+ (is (zero? (:exit (process/shell {:dir root}
+ "cargo" "build" "--release" "--package" "sorter2-server")))
+ "release build succeeds")
+ (let [proc (process/process {:dir root
+ :env (into (into {} (System/getenv))
+ {"SORTER2_SKIP_DOTENV" "1"
+ "SORTER2_DATA_DIR" data-dir
+ "SORTER2_EVENT_LOG" (str data-dir "/events.jsonl")
+ "PORT" (str app-port)
+ "REDDIT_API_BASE" reddit-base
+ "REDDIT_OAUTH_BASE" reddit-base
+ "REDDIT_CLIENT_ID" ""
+ "REDDIT_CLIENT_SECRET" ""
+ "REDDIT_APP_ID" ""
+ "REDDIT_APP_SECRET" ""})
+ :out :string
+ :err :string}
+ bin)]
+ (try
+ (is (wait-health app-base 20000) "app healthz")
+ (let [fetch (curl-fetch-children app-base "reddit.com/r/rust")]
+ (is (zero? (:exit fetch)) "fetch posts via mock Reddit")
+ (is (str/includes? (:out fetch) "Idiomorph.morph")))
+ (core/with-testing-page [pg]
+ (page/navigate pg (vote-page-url app-base "reddit.com/r/rust"))
+ (page/wait-for-selector pg "#vote-compare-form")
+ (let [before (loc/text-content (page/locator pg "#vote-edge-history-region"))]
+ (is (str/includes? before "no votes on this pair yet")
+ "empty edge history before first vote"))
+ (loc/click (page/get-by-test-id pg "vote-post"))
+ (page/wait-for-selector pg ".vote-edge-history-title")
+ (let [after (loc/text-content (page/locator pg "#vote-edge-history-region"))]
+ (is (str/includes? after "votes on this pair")
+ "shows edge history title after vote")
+ (is (str/includes? after "50:50")
+ "shows submitted ratio after vote")
+ (is (not (str/includes? after "no votes on this pair yet"))
+ "does not revert to empty edge history")))
+ (finally
+ (process/destroy proc))))
+ (finally
+ (stop-mock))))))