You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit 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 Side A — unified diff (full patch): 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, 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, 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] Side B — contributor: tommy-mor Side B — commit message: [8f6be6d0] Add copy button for garden rankings (markdown clipboard) (#168) * Add garden ranking markdown copy button via POST /ui Introduce HtmlUiAction::CopyGardenRank that rebuilds the visible child ranking and returns JsBuilder clipboard JS (fetch → eval), matching CopyThread. Place a copy control on garden ranking headings; clipboard text is a concise markdown numbered list with unranked bullets. Co-authored-by: tommy * Fix paren balance in garden ranking copy browser test Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side B — unified diff (full patch): diff --git a/agents.md b/agents.md index 7d6fea5791f7c95677e17e8975a14df83f998fd6..1dc989e4b23071f2eef69f2479c9a1ca2bd04b32 100644 --- a/agents.md +++ b/agents.md @@ -40,6 +40,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`
`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint. +- **`CopyGardenRank`:** Browser copy control on garden ranking headings. Returns **`text/javascript`** via **`JsBuilder::clipboard_write_text_and_label_btn`** (same **`fetch` → `eval`** loop as **`CopyThread`**). Payload includes **`room`**, **`parent_path`**, **`depth`**, **`copy_btn_id`**, and optional **`external_hosts`** (for **`/-/`** host-root indexes). Clipboard text is a concise markdown numbered list of display paths (plus unranked bullets). + - **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. **Guests** on a shared pair see the compose UI with **`post vote`** as a link to **`/login?next=`** (class **`vote-compare-login-cta`**); after OAuth / username selection they return to that matchup. An unauthenticated **`VoteComparePost`** (forged/stale form) still JS-redirects to the same **`/login?next=`** target. - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**. diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index b4a0c9e87e462050bd52728e49e0d6781bf1cfc8..611956d0a46062b49ce350be176e4be139312ff0 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -24,9 +24,9 @@ use crate::{ external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, room_members_section_markup, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, - thread_ui_copy_thread, - thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, - user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav, + garden_ui_copy_rank, thread_ui_copy_thread, thread_ui_expand_post_full, + thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room, + HtmlUiAction, JsBuilder, ThreadNav, }, reducer::{scope_from_room_wire, ScopeId}, state::AppState, @@ -575,6 +575,25 @@ async fn dispatch_ui_action( let viewer = session.map(|s| s.username.as_str()); thread_ui_copy_thread(state, &room, &thread_tag, ©_btn_id, viewer).await } + HtmlUiAction::CopyGardenRank { + room, + parent_path, + depth, + copy_btn_id, + external_hosts, + } => { + let viewer = session.map(|s| s.username.as_str()); + garden_ui_copy_rank( + state, + &room, + &parent_path, + depth, + ©_btn_id, + external_hosts, + viewer, + ) + .await + } HtmlUiAction::GraduateThread { room, thread_tag } => { let Some(session) = session else { return js_redirect("/login").into_response(); diff --git a/server/src/html/garden/copy.rs b/server/src/html/garden/copy.rs new file mode 100644 index 0000000000000000000000000000000000000000..271ea87c34c99e23ab1b0d8572632fdc41380b78 --- /dev/null +++ b/server/src/html/garden/copy.rs @@ -0,0 +1,199 @@ +//! Copy garden rankings to the clipboard as concise markdown (POST /ui + JsBuilder eval). + +use crate::form_template::template_json_compact; +use crate::html::forum::ThreadNav; +use crate::html::js_string_literal; +use crate::html::ui_action::HtmlUiAction; +use crate::html::{JsBuilder, ui_js_warn}; +use crate::path_types::ItemId; +use crate::reducer::scope_from_room_wire; +use crate::scope_rank::{ + build_children_rankings, build_rankings_for_item_set, external_root_host_items, + resolve_scope_recursive, ChildrenRankings, +}; +use crate::state::AppState; +use maud::{html, Markup}; + +use super::access::user_can_view_room; +use super::item::item_display_path; +use crate::reducer::{ContentState, ScopeId}; + +const COPY_BTN_ID: &str = "garden-rank-copy"; + +/// `POST /ui` + `__rpc__` from an inline button; response body is `eval`'d (same as forum copy). +fn garden_ui_fetch_onclick(rpc_compact_json: &str) -> String { + format!( + "fetch('/ui',{{method:'POST',headers:{{'Content-Type':'application/x-www-form-urlencoded'}},body:new URLSearchParams({{__rpc__:{}}}).toString(),credentials:'same-origin'}}).then(r=>r.text()).then(eval);return false", + js_string_literal(rpc_compact_json) + ) +} + +/// Concise markdown for ranked child groups (numbered lists + unranked bullets). +pub(crate) fn format_garden_rank_markdown(rankings: &ChildrenRankings) -> String { + let mut out = String::new(); + let multi = rankings.component_rankings.len() > 1; + for (ci, comp) in rankings.component_rankings.iter().enumerate() { + if ci > 0 { + out.push('\n'); + } + if multi { + out.push_str(&format!("### ordering {}\n\n", ci + 1)); + } + for (i, r) in comp.ranked.iter().enumerate() { + out.push_str(&format!( + "{}. {}\n", + i + 1, + item_display_path(r.item.as_str()) + )); + } + } + if !rankings.unranked_items.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + for name in &rankings.unranked_items { + out.push_str(&format!("- {}\n", item_display_path(name.as_str()))); + } + } + out +} + +fn rankings_for_copy( + state_content: &crate::reducer::ContentState, + parent_path: &str, + depth: usize, + external_hosts: bool, +) -> ChildrenRankings { + if external_hosts { + let hosts = external_root_host_items(state_content); + return build_rankings_for_item_set(state_content, &hosts); + } + let parent = ItemId::parse(parent_path.trim()) + .unwrap_or_else(|| ItemId::ontology_root()) + .normalized_storage(); + let depth = depth.clamp(1, 5); + if depth > 1 { + let items = resolve_scope_recursive(state_content, &[parent.as_str().to_string()], depth); + build_rankings_for_item_set(state_content, &items) + } else { + build_children_rankings(state_content, &parent) + } +} + +pub(crate) async fn garden_ui_copy_rank( + state: &AppState, + room: &str, + parent_path: &str, + depth: usize, + copy_btn_id: &str, + external_hosts: bool, + viewer: Option<&str>, +) -> axum::response::Response { + let room = room.trim(); + let scope = scope_from_room_wire(room); + if let ScopeId::Room(ref rid) = scope { + let reduced = state.reduced.read().await; + if !user_can_view_room(&reduced, rid, viewer) { + return ui_js_warn("forbidden"); + } + } + + let reduced = state.reduced.read().await; + let empty = ContentState::default(); + let content = match &scope { + ScopeId::Public => reduced.public(), + ScopeId::Room(_) => reduced.content_for_scope(&scope).unwrap_or(&empty), + }; + let rankings = rankings_for_copy(content, parent_path, depth, external_hosts); + let text = format_garden_rank_markdown(&rankings); + drop(reduced); + + if text.is_empty() { + return ui_js_warn("nothing to copy"); + } + + JsBuilder::new() + .clipboard_write_text_and_label_btn(&text, copy_btn_id, "copied") + .into_response() +} + +pub(super) fn garden_rank_copy_button_markup( + nav: &ThreadNav, + parent_path: &str, + depth: usize, + external_hosts: bool, +) -> Markup { + let rpc = template_json_compact(&HtmlUiAction::CopyGardenRank { + room: nav.room_wire.clone(), + parent_path: parent_path.to_string(), + depth, + copy_btn_id: COPY_BTN_ID.to_string(), + external_hosts, + }) + .expect("CopyGardenRank serializes"); + html! { + button type="button" id=(COPY_BTN_ID) class="post-nav-btn ont-rank-copy-btn" title="Copy ranking as markdown" + onclick=(garden_ui_fetch_onclick(&rpc)) { + "copy" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::path_types::ItemId; + use crate::ranking::RankedItem; + use crate::scope_rank::ScopedComponent; + + #[test] + fn markdown_single_component_and_unranked() { + let rankings = ChildrenRankings { + component_rankings: vec![ScopedComponent { + pairs: 1, + ranked: vec![ + RankedItem { + item: ItemId::parse("~/a").unwrap(), + score: 0.9, + }, + RankedItem { + item: ItemId::parse("~/b").unwrap(), + score: 0.1, + }, + ], + }], + unranked_items: vec![ItemId::parse("~/c").unwrap()], + }; + assert_eq!( + format_garden_rank_markdown(&rankings), + "1. ~/a\n2. ~/b\n\n- ~/c\n" + ); + } + + #[test] + fn markdown_multi_component_headers() { + let rankings = ChildrenRankings { + component_rankings: vec![ + ScopedComponent { + pairs: 1, + ranked: vec![RankedItem { + item: ItemId::parse("~/a").unwrap(), + score: 1.0, + }], + }, + ScopedComponent { + pairs: 1, + ranked: vec![RankedItem { + item: ItemId::parse("~/b").unwrap(), + score: 1.0, + }], + }, + ], + unranked_items: vec![], + }; + assert_eq!( + format_garden_rank_markdown(&rankings), + "### ordering 1\n\n1. ~/a\n\n### ordering 2\n\n1. ~/b\n" + ); + } +} diff --git a/server/src/html/garden/mod.rs b/server/src/html/garden/mod.rs index ee8696727e5473fe1fa913b8a7b9f3cb9c32d12f..69012a2c1d069e47fa611d63d3bd2a329fd6255d 100644 --- a/server/src/html/garden/mod.rs +++ b/server/src/html/garden/mod.rs @@ -2,6 +2,7 @@ mod access; mod browse; +mod copy; mod external; mod item; mod item_page; @@ -13,6 +14,7 @@ mod vote; #[cfg(test)] mod tests; +pub(crate) use copy::garden_ui_copy_rank; pub(crate) use external::external_resolver_status_markup; pub(crate) use pin::{encode_pin_cookie_value, GARDEN_PIN_COOKIE}; pub(crate) use vote::vote_compare_post_success_js; diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs index ca546693ecb2bb9ae5fb370f02b860c943f604b9..eed80e807d77e6b4eeeb0c3f38d6372e91ad2852 100644 --- a/server/src/html/garden/render.rs +++ b/server/src/html/garden/render.rs @@ -25,6 +25,7 @@ use crate::{ use super::{ access::content_for_garden_view, browse::{garden_layout_meta, scoped_bc_path_for, GardenBrowsePath}, + copy::garden_rank_copy_button_markup, external::{external_frame_allowed, external_source_href, github_resolver_controls}, item::{child_depth_from_uri, item_code_label, item_display_path, item_href}, item_page::{build_item_page_view_model, sibling_nav_markup}, @@ -196,6 +197,15 @@ pub(super) async fn render_scope_view( " " span class="muted" { (format!("(depth {})", model.child_depth)) } } + @if total_children > 0 { + " " + (garden_rank_copy_button_markup( + &nav, + &item_display_path(&model.item), + model.child_depth, + false, + )) + } @if total_children >= 2 { " " a class="ont-vote-children-btn" href=(vote_pool_href(&nav, &model.item)) { diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs index 8a0499576d9093dbac0fffa1cf2ed88187e14acb..31b61355b1ec75645d405e16ab6abf8205c66380 100644 --- a/server/src/html/garden/routes.rs +++ b/server/src/html/garden/routes.rs @@ -28,6 +28,7 @@ use crate::{ use super::{ access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room}, browse::{GardenBrowsePath, scoped_bc_path_external}, + copy::garden_rank_copy_button_markup, item::{item_display_path, item_href}, render::render_scope_view, }; @@ -52,7 +53,13 @@ pub async fn garden_index( html! { @let root_path = OntologyPath::root(); nav class="breadcrumb" { (bc_path(&root_path)) } - h2 { "paths" } + h2 { + "paths" + @if !(child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty()) { + " " + (garden_rank_copy_button_markup(&nav, "~/", 1, false)) + } + } @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no items yet" } } @else { @@ -137,7 +144,13 @@ pub async fn external_garden_index( "view-ontology view-ontology-light", html! { nav class="breadcrumb" { (bc_path_external(&ext_path)) } - h2 { "external paths" } + h2 { + "external paths" + @if !(child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty()) { + " " + (garden_rank_copy_button_markup(&nav, "", 1, true)) + } + } p class="muted" { "Items outside slug.social use the " code { "-/" } " prefix followed by the full " code { "https://…" } " URL (legacy " code { "-/host/path" } " still works)." } @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no external items indexed yet" } @@ -272,7 +285,13 @@ pub async fn room_external_garden_index( "view-ontology view-ontology-light", html! { nav class="breadcrumb" { (scoped_bc_path_external(&ext_path, &nav)) } - h2 { "external paths" } + h2 { + "external paths" + @if !(child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty()) { + " " + (garden_rank_copy_button_markup(&nav, "", 1, true)) + } + } @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no external items indexed yet" } } @else { diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index d7014fc7f934e5b0c7fb47ebf00cb4ef50d306c2..645d54f2e117ba901e02ed958ada6ff69a78ae34 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -37,8 +37,8 @@ pub(crate) use forum::{ user_can_post_room, user_can_view_room, }; pub(crate) use garden::{ - encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js, - GARDEN_PIN_COOKIE, + encode_pin_cookie_value, external_resolver_status_markup, garden_ui_copy_rank, + vote_compare_post_success_js, GARDEN_PIN_COOKIE, }; pub use garden::{ external_garden_index, external_ontology_path, garden_index, ontology_path, diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs index 5cf3168fb74e7654215cd9ff8942c6ed63fd0002..7dc3db062954370b5504fc311c0ab24bab569f80 100644 --- a/server/src/html/ui_action.rs +++ b/server/src/html/ui_action.rs @@ -118,6 +118,18 @@ pub enum HtmlUiAction { thread_tag: String, copy_btn_id: String, }, + /// Copy garden child ranking as a concise markdown list to the clipboard. + CopyGardenRank { + room: String, + /// Parent item path (storage or display). Ignored when `external_hosts` is true. + parent_path: String, + #[serde(default = "default_garden_rank_depth")] + depth: usize, + copy_btn_id: String, + /// When true, copy rankings for external host roots (`/-/` index), not parent children. + #[serde(default)] + external_hosts: bool, + }, /// Publish a private-room thread to the public forum (Manage only). GraduateThread { room: String, @@ -125,6 +137,10 @@ pub enum HtmlUiAction { }, } +fn default_garden_rank_depth() -> usize { + 1 +} + #[derive(Debug, Error)] pub enum HtmlUiParseError { #[error("missing __rpc__ field")] @@ -272,4 +288,57 @@ mod tests { } ); } + + #[test] + fn copy_garden_rank_round_trip() { + let template = serde_json::json!({ + "action": "copy_garden_rank", + "room": "public", + "parent_path": "~/topic", + "depth": 2, + "copy_btn_id": "garden-rank-copy", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::CopyGardenRank { + room: "public".into(), + parent_path: "~/topic".into(), + depth: 2, + copy_btn_id: "garden-rank-copy".into(), + external_hosts: false, + } + ); + } + + #[test] + fn copy_garden_rank_defaults_depth_and_external_hosts() { + let template = serde_json::json!({ + "action": "copy_garden_rank", + "room": "public", + "parent_path": "~/", + "copy_btn_id": "garden-rank-copy", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::CopyGardenRank { + room: "public".into(), + parent_path: "~/".into(), + depth: 1, + copy_btn_id: "garden-rank-copy".into(), + external_hosts: false, + } + ); + } } diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs index d242abff5769cc704b3c8070d7456e5788c31a45..6b1475b773106a2dd3f326475c9fb4cc727f6b4b 100644 --- a/server/tests/integration_ui.rs +++ b/server/tests/integration_ui.rs @@ -490,3 +490,71 @@ async fn test_vote_compare_post_rejects_over_max_ratio() { ); } +#[tokio::test] +async fn test_copy_garden_rank_returns_clipboard_js_with_markdown() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + let bearer = test_bearer(); + + let seed = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "copy-rank-ui", + "text": "# copy-rank-ui\n\n~/copy-a {a}\n~/copy-b {b}\n~/copy-c {c}\n{vote}\n~/copy-a 2:1 ~/copy-b\n", + "return_rank_diff": false + } + }]), + ) + .await; + assert_eq!(seed["results"][0]["ok"], true, "seed: {:?}", seed); + + let page = client + .get(format!("http://{addr}/~")) + .send() + .await + .unwrap(); + assert!(page.status().is_success()); + let html = page.text().await.unwrap(); + assert!( + html.contains("id=\"garden-rank-copy\"") && html.contains("copy_garden_rank"), + "garden index should include copy button + action payload" + ); + + let rpc = serde_json::json!({ + "action": "copy_garden_rank", + "room": "public", + "parent_path": "~/", + "depth": 1, + "copy_btn_id": "garden-rank-copy", + }) + .to_string(); + let resp = client + .post(format!("http://{addr}/ui")) + .header("Authorization", format!("Bearer {bearer}")) + .form(&[("__rpc__", rpc.as_str())]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + assert_eq!( + resp.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("text/javascript; charset=utf-8") + ); + let js = resp.text().await.unwrap(); + assert!( + js.contains("navigator.clipboard.writeText") && js.contains("garden-rank-copy"), + "expected clipboard JsBuilder snippet, got: {js}" + ); + assert!( + js.contains("1. ~/copy-a") && js.contains("2. ~/copy-b") && js.contains("- ~/copy-c"), + "expected concise markdown ranking in clipboard payload, got: {js}" + ); + assert!(js.contains("\"copied\""), "expected button label flip to copied"); +} + diff --git a/test/browser_public_garden.clj b/test/browser_public_garden.clj index 5dd49f1f70c870e25a2a9f5348f0bdb85647e897..8ceb532c664f9fc3d6f66a451e5ea5eaa1671007 100644 --- a/test/browser_public_garden.clj +++ b/test/browser_public_garden.clj @@ -73,6 +73,7 @@ (core/with-playwright [pw] (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] (core/with-context [ctx (core/new-context browser)] + (core/context-grant-permissions! ctx ["clipboard-read" "clipboard-write"]) (core/with-page [pg (core/new-page-from-context ctx)] (page/navigate pg (str base-url "/login")) (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") @@ -84,7 +85,12 @@ (is (wait-for-text pg "body" "~/br-pub-b" 10000) "ranked list shows ~/br-pub-b") (is (wait-for-text pg "body" "unranked" 10000) "unranked section present") (is (wait-for-text pg "body" "~/br-pub-c" 10000) - "unranked list shows ~/br-pub-c")))))) + "unranked list shows ~/br-pub-c") + (is (wait-for-text pg "#garden-rank-copy" "copy" 5000) + "garden ranking copy button visible") + (locator/click (page/locator pg "#garden-rank-copy")) + (is (wait-for-text pg "#garden-rank-copy" "copied" 10000) + "copy button flips to copied after POST /ui eval")))))) (finally (when-some [s @!server] (common/kill-server s))