B fixes a genuine functional bug (hrefs encoding the raw storage path instead of the user-facing display path) and replaces a shallow, loosely-bounded browser test with an exhaustive 45-pair test that asserts actual ranking correctness end-to-end via the RPC API. A is a clean, well-tested improvement to color gradient logic, but it only affects cosmetic rendering, whereas B corrects user-facing link generation and substantially strengthens regression coverage of the core ranking feature.
constitution · epochs · watch · epoch 3
c_66eb04076a98 (tommy-mor) vs c_4a5c84c0a37b (tommy-mor)
download prompt · raw event · cmp_83381936781fbb
council reasoning
B fixes real href correctness (encode display_path ~/…/-/… instead of full storage URLs in vote_compare_href/vote_pool_href) and turns a shallow “≥1 vote” smoke test into a full C(10,2)=45-pair flow that asserts one component ranked a→j via GetGardenRank. A is a solid UX tweak (score min–max gradient vs list ordinal) with unit tests, but it is visual polish rather than path/ranking correctness.
Side B fixes user-facing URL generation by using display_path instead of internal storage URLs in vote links, aligning hrefs with the application's displayed path format, and substantially strengthens end-to-end coverage by exercising all 45 pairwise votes and asserting the resulting ranking through the RPC API. Side A improves the visual color gradient by basing row colors on normalized score ranges within a group and adds focused unit tests, but this is primarily a presentation enhancement rather than a functional correctness change.
sides
A — c_66eb04076a98 (tommy-mor)
message
[0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String {
)
}
-fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String {
- let t = if total <= 1 {
- 0.0
- } else {
- ordinal as f64 / (total - 1) as f64
- };
+/// Map vote mass to gradient position using the group's score range, not raw mass or
+/// list position. Vote mass sums to 1 across the component, so absolute values dilute
+/// as N grows; min–max within the visible list preserves similar scores → similar colors.
+fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 {
+ let spread = max_score - min_score;
+ if spread < 1e-9 {
+ return 0.5;
+ }
+ ((max_score - score) / spread).clamp(0.0, 1.0)
+}
+
+fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String {
+ let t = score_gradient_t(score, min_score, max_score);
let base_hue = scope_base_hue(parent);
let hue = (base_hue + 118.0 * t) % 360.0;
let lightness = 0.74 - 0.34 * t;
@@ -295,14 +302,15 @@ fn rank_list(
highlighted: &HashSet<ItemId>,
tree: &GlobalTree,
) -> Markup {
- let group_len = items.len();
+ let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min);
+ let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max);
html! {
@if !items.is_empty() {
h3 class="rank-heading muted small" { (label) }
ol class="rank-list" {
@for (i, r) in items.iter().enumerate() {
@let href = item_href(&r.item);
- @let style = rank_row_style(parent, i, group_len);
+ @let style = rank_row_style(parent, r.score, min_score, max_score);
@let class = rank_row_class(&r.item, highlighted);
li class=(class)
data-rank-item=(r.item.as_str())
@@ -517,19 +525,37 @@ pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoRespons
#[cfg(test)]
mod tests {
- use super::{rank_row_style, SORTER_UI_JS};
+ use super::{rank_row_style, score_gradient_t, SORTER_UI_JS};
use crate::path_types::ItemId;
#[test]
- fn rank_row_style_gradients_per_group_not_globally() {
+ fn score_gradient_t_uses_group_range_not_absolute_mass() {
+ assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9);
+ assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9);
+ // Raw 12% mass would map near the dark end globally; within this group it's the top.
+ assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0));
+ }
+
+ #[test]
+ fn score_gradient_t_similar_scores_similar_t() {
+ let a = score_gradient_t(0.41, 0.20, 0.60);
+ let b = score_gradient_t(0.40, 0.20, 0.60);
+ assert!((a - b).abs() < 0.05);
+ assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3);
+ }
+
+ #[test]
+ fn score_gradient_t_tied_scores_neutral() {
+ assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9);
+ }
+
+ #[test]
+ fn rank_row_style_same_inputs_same_color() {
let parent = ItemId::opaque("test-scope");
- let first_in_four = rank_row_style(&parent, 0, 4);
- let last_in_four = rank_row_style(&parent, 3, 4);
- let first_in_two = rank_row_style(&parent, 0, 2);
- let last_in_two = rank_row_style(&parent, 1, 2);
- assert_eq!(first_in_four, first_in_two);
- assert_eq!(last_in_four, last_in_two);
- assert_ne!(first_in_four, last_in_four);
+ assert_eq!(
+ rank_row_style(&parent, 0.33, 0.20, 0.60),
+ rank_row_style(&parent, 0.33, 0.20, 0.60),
+ );
}
#[test]
B — c_4a5c84c0a37b (tommy-mor)
message
[0728c06a] Vote pool: use display_path in hrefs; test all 45 pairs + assert ranking. - vote_compare_href and vote_pool_href now encode ~/… and -/… as their short display forms (not the full https://slug.social/… storage URL), matching what users see in the item display and DSL. - Rewrite browser_vote_pool test to vote all C(10,2)=45 pairs in the pool, always preferring the alphabetically-earlier letter, then query GetGardenRank and assert the 10 items form one component ranked a→j. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index 2682cfcbd2f834b56459a02831aa225cffe67c58..d0ec78cd675eae284d056fb3b8eaf5cc853d6263 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -234,8 +234,10 @@ pub(super) fn vote_compare_href(
thread_override: Option<&str>,
pool: Option<&ItemId>,
) -> String {
- let left_q = urlencoding::encode(left.as_str());
- let right_q = urlencoding::encode(right.as_str());
+ let left_dp = left.display_path();
+ let right_dp = right.display_path();
+ let left_q = urlencoding::encode(&left_dp);
+ let right_q = urlencoding::encode(&right_dp);
let mut base = format!(
"{}/vote?left={}&right={}",
nav.room_path_prefix_for_vote_compare(),
@@ -246,16 +248,20 @@ pub(super) fn vote_compare_href(
base = format!("{}&thread={}", base, urlencoding::encode(t));
}
if let Some(p) = pool {
- base = format!("{}&pool={}", base, urlencoding::encode(p.as_str()));
+ let pool_dp = p.display_path();
+ base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp));
}
base
}
pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
+ let display = ItemId::parse(pool_item_str)
+ .map(|i| i.display_path())
+ .unwrap_or_else(|| pool_item_str.to_string());
format!(
"{}/vote?pool={}",
nav.room_path_prefix_for_vote_compare(),
- urlencoding::encode(pool_item_str)
+ urlencoding::encode(&display)
)
}
diff --git a/test/browser_vote_pool.clj b/test/browser_vote_pool.clj
index d51f05db47dc3cb013e56e65f3a1edfbbcbd96ab..23d0bd80b02bd8b1b48853454bed02793296550e 100644
--- a/test/browser_vote_pool.clj
+++ b/test/browser_vote_pool.clj
@@ -1,6 +1,8 @@
(ns test.browser-vote-pool
- "Pool-scoped voting: seed ~/pool/a-j, enter via /vote?pool=~/pool, follow
- the vote → next-pair → vote sequence until no next pair or 15 iterations."
+ "Pool-scoped voting: seed ~/pool/a-j (10 letters), follow the
+ vote → next-pair sequence for all C(10,2)=45 pairs voting the
+ alphabetically-earlier item each time, then assert the garden
+ ranking is a…j in order."
(:require [babashka.fs :as fs]
[cheshire.core :as json]
[clojure.string :as str]
@@ -11,26 +13,38 @@
[test.common :as common]
[test.oauth :as oauth]))
+(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"])
+(def total-pairs (/ (* (count letters) (dec (count letters))) 2)) ; C(10,2) = 45
+
(defn- wait-for-text [pg selector expected timeout-ms]
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
(loop []
- (let [text (locator/text-content (page/locator pg selector))]
+ (let [text (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil))]
(if (and (string? text) (str/includes? text expected))
true
(if (< (System/currentTimeMillis) deadline)
- (do (Thread/sleep 200) (recur))
+ (do (Thread/sleep 150) (recur))
false))))))
(defn- element-text [pg selector]
- (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil)))
+ (try (locator/text-content (page/locator pg selector)) (catch Exception _ "")))
(defn- enc [^String s]
(java.net.URLEncoder/encode s "UTF-8"))
-(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"])
+;; Extract the terminal path segment, e.g. "~/pool/c" → "c".
+(defn- leaf [path] (last (str/split path #"/")))
+
+;; Set the hidden ratio inputs so the alphabetically-earlier item wins.
+(defn- set-ratio! [pg left-text right-text]
+ (let [[rl rr] (if (neg? (compare (leaf left-text) (leaf right-text)))
+ [100 0] ; left is earlier → prefer left
+ [0 100])] ; right is earlier → prefer right
+ (page/evaluate pg (str "document.getElementById('vote-ratio-left').value='" rl "'"))
+ (page/evaluate pg (str "document.getElementById('vote-ratio-right').value='" rr "'"))))
(defn vote-pool-flow! []
- (println "\n━━━ browser vote pool (/vote?pool= seeds + follow next-pair sequence) ━━━\n")
+ (println (str "\n━━━ browser vote pool (all " total-pairs " pairs → sorted ranking) ━━━\n"))
(common/letlocals
(bind build (common/run-cargo-build-release! ["slugsocial-server"]))
@@ -54,7 +68,6 @@
(let [alice-token (oauth/fetch-bearer-token! base-url :username "alice")
thread-tag "browser-vote-pool"
- ;; seed ~/pool/a through ~/pool/j as items with bodies
item-lines (str/join "\n"
(map (fn [l] (str "~/pool/" l " {" l "}")) letters))
raw (str "# " thread-tag "\n\n~/pool {root}\n" item-lines "\n")
@@ -77,51 +90,57 @@
(page/navigate pg (str base-url "/login"))
(is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
- ;; Enter via pool URL — page picks first pair automatically.
(page/navigate pg pool-url)
(is (wait-for-text pg "body.view-vote-compare" "compare" 15000)
"pool entry: vote compare page loads")
- ;; Verify the initial pair is within the pool.
- (let [pair-text (element-text pg ".vote-compare-pair")]
- (is (and (string? pair-text) (str/includes? pair-text "~/pool/"))
- (str "initial pair is within ~/pool: " pair-text)))
-
- ;; Follow vote → next-pair sequence up to 15 iterations.
- (let [votes-cast
- (loop [i 0]
- (if (>= i 15)
- i
- (let [explanation (str "pool vote " i " reason")]
- (locator/fill (page/locator pg "#vote-explain") explanation)
- (locator/click (page/locator pg "#vote-compare-form button[type=submit]"))
- ;; Wait for edge history morph confirming the vote landed.
- (if-not (wait-for-text pg "ul.vote-edge-history" explanation 20000)
- (do (println " vote" i "history morph timed out — stopping")
- i)
- (let [has-next (wait-for-text pg "[data-testid=\"vote-next-pair\"]"
- "next pair" 8000)]
- (if-not has-next
- ;; "no next pair" — pool exhausted.
- (do (println " no next pair after vote" i " — pool exhausted")
- (inc i))
- (do
- ;; Verify the pair on this page is within the pool before advancing.
- (let [pt (element-text pg ".vote-compare-pair")]
- (is (and (string? pt) (str/includes? pt "~/pool/"))
- (str "pair at vote " i " is within ~/pool: " pt)))
- (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]"))
- ;; Wait for next pair to load.
- (wait-for-text pg "body.view-vote-compare" "compare" 10000)
- (recur (inc i)))))))))]
-
- (is (>= votes-cast 1) (str "cast at least 1 vote, got: " votes-cast))
- (println (str " pool voting sequence complete: " votes-cast " vote(s) cast")))
-
- ;; After the sequence, the current page is still a pool-scoped vote page.
- (let [url (page/url pg)]
- (is (str/includes? (or url "") "/vote")
- (str "still on /vote after sequence: " url))))))))
+ ;; Vote all 45 pairs, always preferring the alphabetically-earlier item.
+ (loop [votes-cast 0]
+ (when (< votes-cast total-pairs)
+ (let [left-text (element-text pg ".vote-compare-left code")
+ right-text (element-text pg ".vote-compare-right code")]
+ (is (str/includes? left-text "~/pool/")
+ (str "vote " votes-cast ": left is in pool: " left-text))
+ (is (str/includes? right-text "~/pool/")
+ (str "vote " votes-cast ": right is in pool: " right-text))
+ (set-ratio! pg left-text right-text)
+ (let [winner (if (neg? (compare (leaf left-text) (leaf right-text)))
+ (leaf left-text) (leaf right-text))]
+ (locator/fill (page/locator pg "#vote-explain")
+ (str "prefer " winner)))
+ (locator/click (page/locator pg "#vote-compare-form button[type=submit]"))
+ (is (wait-for-text pg "ul.vote-edge-history" "prefer " 20000)
+ (str "vote " votes-cast " appears in edge history"))
+ (when (< (inc votes-cast) total-pairs)
+ (is (wait-for-text pg "[data-testid=\"vote-next-pair\"]" "next pair" 8000)
+ (str "next pair available after vote " votes-cast))
+ (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]"))
+ (is (wait-for-text pg "body.view-vote-compare" "compare" 10000)
+ (str "vote page loaded for pair " (inc votes-cast))))
+ (recur (inc votes-cast)))))
+
+ (println (str " cast all " total-pairs " votes"))
+
+ ;; Query the ranking via RPC and assert alphabetical order.
+ (let [rank-resp (oauth/http-post-json
+ (str base-url "/api/v0/rpc")
+ [{"GetGardenRank" {"room" "public"
+ "parent_path" "~/pool"}}]
+ :headers {"Authorization" (str "Bearer " alice-token)})
+ rank-json (json/parse-string (:body rank-resp) true)
+ result (get-in rank-json [:results 0 :result :GardenRank])
+ components (:components result)
+ unranked (:unranked_items result)
+ ranked (mapv :item (mapcat :ranking components))
+ ranked-leaves (mapv #(last (str/split % #"[/~]+")) ranked)]
+ (is (= 1 (count components))
+ (str "all 10 items form one connected component (got " (count components) ")"))
+ (is (empty? unranked)
+ (str "no unranked items (got " (count unranked) ")"))
+ (is (= 10 (count ranked))
+ (str "10 items ranked (got " (count ranked) ")"))
+ (is (= letters ranked-leaves)
+ (str "ranking is alphabetical a→j (got " ranked-leaves ")"))))))))
(finally
(when-some [s @!server] (common/kill-server s))
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.